From 9315f3bdd9e7590bec9045b02824255ecb25964a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 25 Aug 2021 23:15:49 +0200 Subject: [PATCH 001/843] Use EntityDescription - renault (#55061) * Cleanup sensor.py * Add EntityDescription * Add checks for state attributes * Fix pylint * Simplify checks * Add icon checks * Update data type * Use mixin for required keys, and review class initialisation * Add constraint to TypeVar("T") * Enable lambda for icon handling * Enable lambda for value handling * Enable lambda for value handling --- .../components/renault/binary_sensor.py | 76 ++- .../components/renault/renault_entities.py | 128 ++--- homeassistant/components/renault/sensor.py | 437 +++++++++--------- tests/components/renault/const.py | 209 ++++++--- .../components/renault/test_binary_sensor.py | 21 +- tests/components/renault/test_sensor.py | 45 +- 6 files changed, 529 insertions(+), 387 deletions(-) diff --git a/homeassistant/components/renault/binary_sensor.py b/homeassistant/components/renault/binary_sensor.py index dd3ccb036e0..17a735f8bc7 100644 --- a/homeassistant/components/renault/binary_sensor.py +++ b/homeassistant/components/renault/binary_sensor.py @@ -1,22 +1,44 @@ """Support for Renault binary sensors.""" from __future__ import annotations +from dataclasses import dataclass + from renault_api.kamereon.enums import ChargeState, PlugState +from renault_api.kamereon.models import KamereonVehicleBatteryStatusData from homeassistant.components.binary_sensor import ( DEVICE_CLASS_BATTERY_CHARGING, DEVICE_CLASS_PLUG, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from .const import DOMAIN -from .renault_entities import RenaultBatteryDataEntity, RenaultDataEntity +from .renault_entities import RenaultDataEntity, RenaultEntityDescription, T from .renault_hub import RenaultHub +@dataclass +class RenaultBinarySensorRequiredKeysMixin: + """Mixin for required keys.""" + + entity_class: type[RenaultBinarySensor] + on_value: StateType + + +@dataclass +class RenaultBinarySensorEntityDescription( + BinarySensorEntityDescription, + RenaultEntityDescription, + RenaultBinarySensorRequiredKeysMixin, +): + """Class describing Renault binary sensor entities.""" + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -24,35 +46,43 @@ async def async_setup_entry( ) -> None: """Set up the Renault entities from config entry.""" proxy: RenaultHub = hass.data[DOMAIN][config_entry.entry_id] - entities: list[RenaultDataEntity] = [] - for vehicle in proxy.vehicles.values(): - if "battery" in vehicle.coordinators: - entities.append(RenaultPluggedInSensor(vehicle, "Plugged In")) - entities.append(RenaultChargingSensor(vehicle, "Charging")) + entities: list[RenaultBinarySensor] = [ + description.entity_class(vehicle, description) + for vehicle in proxy.vehicles.values() + for description in BINARY_SENSOR_TYPES + if description.coordinator in vehicle.coordinators + ] async_add_entities(entities) -class RenaultPluggedInSensor(RenaultBatteryDataEntity, BinarySensorEntity): - """Plugged In binary sensor.""" +class RenaultBinarySensor(RenaultDataEntity[T], BinarySensorEntity): + """Mixin for binary sensor specific attributes.""" - _attr_device_class = DEVICE_CLASS_PLUG + entity_description: RenaultBinarySensorEntityDescription @property def is_on(self) -> bool | None: """Return true if the binary sensor is on.""" - if (not self.data) or (self.data.plugStatus is None): - return None - return self.data.get_plug_status() == PlugState.PLUGGED + return self.data == self.entity_description.on_value -class RenaultChargingSensor(RenaultBatteryDataEntity, BinarySensorEntity): - """Charging binary sensor.""" - - _attr_device_class = DEVICE_CLASS_BATTERY_CHARGING - - @property - def is_on(self) -> bool | None: - """Return true if the binary sensor is on.""" - if (not self.data) or (self.data.chargingStatus is None): - return None - return self.data.get_charging_status() == ChargeState.CHARGE_IN_PROGRESS +BINARY_SENSOR_TYPES: tuple[RenaultBinarySensorEntityDescription, ...] = ( + RenaultBinarySensorEntityDescription( + key="plugged_in", + coordinator="battery", + data_key="plugStatus", + device_class=DEVICE_CLASS_PLUG, + entity_class=RenaultBinarySensor[KamereonVehicleBatteryStatusData], + name="Plugged In", + on_value=PlugState.PLUGGED.value, + ), + RenaultBinarySensorEntityDescription( + key="charging", + coordinator="battery", + data_key="chargingStatus", + device_class=DEVICE_CLASS_BATTERY_CHARGING, + entity_class=RenaultBinarySensor[KamereonVehicleBatteryStatusData], + name="Charging", + on_value=ChargeState.CHARGE_IN_PROGRESS.value, + ), +) diff --git a/homeassistant/components/renault/renault_entities.py b/homeassistant/components/renault/renault_entities.py index 9188a1f0757..2a23d1de8f6 100644 --- a/homeassistant/components/renault/renault_entities.py +++ b/homeassistant/components/renault/renault_entities.py @@ -1,103 +1,73 @@ """Base classes for Renault entities.""" from __future__ import annotations -from typing import Any, Generic, Optional, TypeVar +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any, Optional, TypeVar, cast -from renault_api.kamereon.enums import ChargeState, PlugState -from renault_api.kamereon.models import ( - KamereonVehicleBatteryStatusData, - KamereonVehicleChargeModeData, - KamereonVehicleCockpitData, - KamereonVehicleHvacStatusData, -) +from renault_api.kamereon.models import KamereonVehicleDataAttributes -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import Entity, EntityDescription +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from homeassistant.util import slugify from .renault_vehicle import RenaultVehicleProxy + +@dataclass +class RenaultRequiredKeysMixin: + """Mixin for required keys.""" + + coordinator: str + data_key: str + + +@dataclass +class RenaultEntityDescription(EntityDescription, RenaultRequiredKeysMixin): + """Class describing Renault entities.""" + + requires_fuel: bool | None = None + + ATTR_LAST_UPDATE = "last_update" -T = TypeVar("T") +T = TypeVar("T", bound=KamereonVehicleDataAttributes) -class RenaultDataEntity(Generic[T], CoordinatorEntity[Optional[T]], Entity): +class RenaultDataEntity(CoordinatorEntity[Optional[T]], Entity): """Implementation of a Renault entity with a data coordinator.""" + entity_description: RenaultEntityDescription + def __init__( - self, vehicle: RenaultVehicleProxy, entity_type: str, coordinator_key: str + self, + vehicle: RenaultVehicleProxy, + description: RenaultEntityDescription, ) -> None: """Initialise entity.""" - super().__init__(vehicle.coordinators[coordinator_key]) + super().__init__(vehicle.coordinators[description.coordinator]) self.vehicle = vehicle - self._entity_type = entity_type + self.entity_description = description self._attr_device_info = self.vehicle.device_info - self._attr_name = entity_type - self._attr_unique_id = slugify( - f"{self.vehicle.details.vin}-{self._entity_type}" + self._attr_unique_id = f"{self.vehicle.details.vin}_{description.key}".lower() + + @property + def data(self) -> StateType: + """Return the state of this entity.""" + if self.coordinator.data is None: + return None + return cast( + StateType, getattr(self.coordinator.data, self.entity_description.data_key) ) @property - def available(self) -> bool: - """Return if entity is available.""" - # Data can succeed, but be empty - return super().available and self.coordinator.data is not None - - @property - def data(self) -> T | None: - """Return collected data.""" - return self.coordinator.data - - -class RenaultBatteryDataEntity(RenaultDataEntity[KamereonVehicleBatteryStatusData]): - """Implementation of a Renault entity with battery coordinator.""" - - def __init__(self, vehicle: RenaultVehicleProxy, entity_type: str) -> None: - """Initialise entity.""" - super().__init__(vehicle, entity_type, "battery") - - @property - def extra_state_attributes(self) -> dict[str, Any]: + def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return the state attributes of this entity.""" - last_update = self.data.timestamp if self.data else None - return {ATTR_LAST_UPDATE: last_update} - - @property - def is_charging(self) -> bool: - """Return charge state as boolean.""" - return ( - self.data is not None - and self.data.get_charging_status() == ChargeState.CHARGE_IN_PROGRESS - ) - - @property - def is_plugged_in(self) -> bool: - """Return plug state as boolean.""" - return ( - self.data is not None and self.data.get_plug_status() == PlugState.PLUGGED - ) - - -class RenaultChargeModeDataEntity(RenaultDataEntity[KamereonVehicleChargeModeData]): - """Implementation of a Renault entity with charge_mode coordinator.""" - - def __init__(self, vehicle: RenaultVehicleProxy, entity_type: str) -> None: - """Initialise entity.""" - super().__init__(vehicle, entity_type, "charge_mode") - - -class RenaultCockpitDataEntity(RenaultDataEntity[KamereonVehicleCockpitData]): - """Implementation of a Renault entity with cockpit coordinator.""" - - def __init__(self, vehicle: RenaultVehicleProxy, entity_type: str) -> None: - """Initialise entity.""" - super().__init__(vehicle, entity_type, "cockpit") - - -class RenaultHVACDataEntity(RenaultDataEntity[KamereonVehicleHvacStatusData]): - """Implementation of a Renault entity with hvac_status coordinator.""" - - def __init__(self, vehicle: RenaultVehicleProxy, entity_type: str) -> None: - """Initialise entity.""" - super().__init__(vehicle, entity_type, "hvac_status") + if self.entity_description.coordinator == "battery": + last_update = ( + getattr(self.coordinator.data, "timestamp") + if self.coordinator.data + else None + ) + return {ATTR_LAST_UPDATE: last_update} + return None diff --git a/homeassistant/components/renault/sensor.py b/homeassistant/components/renault/sensor.py index 7ef11fb2afc..56101cd378b 100644 --- a/homeassistant/components/renault/sensor.py +++ b/homeassistant/components/renault/sensor.py @@ -1,7 +1,23 @@ """Support for Renault sensors.""" from __future__ import annotations -from homeassistant.components.sensor import SensorEntity +from dataclasses import dataclass +from typing import Callable, cast + +from renault_api.kamereon.enums import ChargeState, PlugState +from renault_api.kamereon.models import ( + KamereonVehicleBatteryStatusData, + KamereonVehicleChargeModeData, + KamereonVehicleCockpitData, + KamereonVehicleHvacStatusData, +) + +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( DEVICE_CLASS_BATTERY, @@ -18,6 +34,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from .const import ( DEVICE_CLASS_CHARGE_MODE, @@ -25,17 +42,25 @@ from .const import ( DEVICE_CLASS_PLUG_STATE, DOMAIN, ) -from .renault_entities import ( - RenaultBatteryDataEntity, - RenaultChargeModeDataEntity, - RenaultCockpitDataEntity, - RenaultDataEntity, - RenaultHVACDataEntity, -) +from .renault_entities import RenaultDataEntity, RenaultEntityDescription, T from .renault_hub import RenaultHub -from .renault_vehicle import RenaultVehicleProxy -ATTR_BATTERY_AVAILABLE_ENERGY = "battery_available_energy" + +@dataclass +class RenaultSensorRequiredKeysMixin: + """Mixin for required keys.""" + + entity_class: type[RenaultSensor] + + +@dataclass +class RenaultSensorEntityDescription( + SensorEntityDescription, RenaultEntityDescription, RenaultSensorRequiredKeysMixin +): + """Class describing Renault sensor entities.""" + + icon_lambda: Callable[[RenaultDataEntity[T]], str] | None = None + value_lambda: Callable[[RenaultDataEntity[T]], StateType] | None = None async def async_setup_entry( @@ -45,224 +70,208 @@ async def async_setup_entry( ) -> None: """Set up the Renault entities from config entry.""" proxy: RenaultHub = hass.data[DOMAIN][config_entry.entry_id] - entities = get_entities(proxy) + entities: list[RenaultSensor] = [ + description.entity_class(vehicle, description) + for vehicle in proxy.vehicles.values() + for description in SENSOR_TYPES + if description.coordinator in vehicle.coordinators + and (not description.requires_fuel or vehicle.details.uses_fuel()) + ] async_add_entities(entities) -def get_entities(proxy: RenaultHub) -> list[RenaultDataEntity]: - """Create Renault entities for all vehicles.""" - entities = [] - for vehicle in proxy.vehicles.values(): - entities.extend(get_vehicle_entities(vehicle)) - return entities +class RenaultSensor(RenaultDataEntity[T], SensorEntity): + """Mixin for sensor specific attributes.""" - -def get_vehicle_entities(vehicle: RenaultVehicleProxy) -> list[RenaultDataEntity]: - """Create Renault entities for single vehicle.""" - entities: list[RenaultDataEntity] = [] - if "cockpit" in vehicle.coordinators: - entities.append(RenaultMileageSensor(vehicle, "Mileage")) - if vehicle.details.uses_fuel(): - entities.append(RenaultFuelAutonomySensor(vehicle, "Fuel Autonomy")) - entities.append(RenaultFuelQuantitySensor(vehicle, "Fuel Quantity")) - if "hvac_status" in vehicle.coordinators: - entities.append(RenaultOutsideTemperatureSensor(vehicle, "Outside Temperature")) - if "battery" in vehicle.coordinators: - entities.append(RenaultBatteryLevelSensor(vehicle, "Battery Level")) - entities.append(RenaultChargeStateSensor(vehicle, "Charge State")) - entities.append( - RenaultChargingRemainingTimeSensor(vehicle, "Charging Remaining Time") - ) - entities.append(RenaultChargingPowerSensor(vehicle, "Charging Power")) - entities.append(RenaultPlugStateSensor(vehicle, "Plug State")) - entities.append(RenaultBatteryAutonomySensor(vehicle, "Battery Autonomy")) - entities.append( - RenaultBatteryAvailableEnergySensor(vehicle, "Battery Available Energy") - ) - entities.append(RenaultBatteryTemperatureSensor(vehicle, "Battery Temperature")) - if "charge_mode" in vehicle.coordinators: - entities.append(RenaultChargeModeSensor(vehicle, "Charge Mode")) - return entities - - -class RenaultBatteryAutonomySensor(RenaultBatteryDataEntity, SensorEntity): - """Battery autonomy sensor.""" - - _attr_icon = "mdi:ev-station" - _attr_native_unit_of_measurement = LENGTH_KILOMETERS + entity_description: RenaultSensorEntityDescription @property - def native_value(self) -> int | None: - """Return the state of this entity.""" - return self.data.batteryAutonomy if self.data else None - - -class RenaultBatteryAvailableEnergySensor(RenaultBatteryDataEntity, SensorEntity): - """Battery available energy sensor.""" - - _attr_device_class = DEVICE_CLASS_ENERGY - _attr_native_unit_of_measurement = ENERGY_KILO_WATT_HOUR - - @property - def native_value(self) -> float | None: - """Return the state of this entity.""" - return self.data.batteryAvailableEnergy if self.data else None - - -class RenaultBatteryLevelSensor(RenaultBatteryDataEntity, SensorEntity): - """Battery Level sensor.""" - - _attr_device_class = DEVICE_CLASS_BATTERY - _attr_native_unit_of_measurement = PERCENTAGE - - @property - def native_value(self) -> int | None: - """Return the state of this entity.""" - return self.data.batteryLevel if self.data else None - - -class RenaultBatteryTemperatureSensor(RenaultBatteryDataEntity, SensorEntity): - """Battery Temperature sensor.""" - - _attr_device_class = DEVICE_CLASS_TEMPERATURE - _attr_native_unit_of_measurement = TEMP_CELSIUS - - @property - def native_value(self) -> int | None: - """Return the state of this entity.""" - return self.data.batteryTemperature if self.data else None - - -class RenaultChargeModeSensor(RenaultChargeModeDataEntity, SensorEntity): - """Charge Mode sensor.""" - - _attr_device_class = DEVICE_CLASS_CHARGE_MODE - - @property - def native_value(self) -> str | None: - """Return the state of this entity.""" - return self.data.chargeMode if self.data else None - - @property - def icon(self) -> str: + def icon(self) -> str | None: """Icon handling.""" - if self.data and self.data.chargeMode == "schedule_mode": - return "mdi:calendar-clock" - return "mdi:calendar-remove" - - -class RenaultChargeStateSensor(RenaultBatteryDataEntity, SensorEntity): - """Charge State sensor.""" - - _attr_device_class = DEVICE_CLASS_CHARGE_STATE + if self.entity_description.icon_lambda is None: + return super().icon + return self.entity_description.icon_lambda(self) @property - def native_value(self) -> str | None: + def native_value(self) -> StateType: """Return the state of this entity.""" - charging_status = self.data.get_charging_status() if self.data else None - return charging_status.name.lower() if charging_status is not None else None - - @property - def icon(self) -> str: - """Icon handling.""" - return "mdi:flash" if self.is_charging else "mdi:flash-off" - - -class RenaultChargingRemainingTimeSensor(RenaultBatteryDataEntity, SensorEntity): - """Charging Remaining Time sensor.""" - - _attr_icon = "mdi:timer" - _attr_native_unit_of_measurement = TIME_MINUTES - - @property - def native_value(self) -> int | None: - """Return the state of this entity.""" - return self.data.chargingRemainingTime if self.data else None - - -class RenaultChargingPowerSensor(RenaultBatteryDataEntity, SensorEntity): - """Charging Power sensor.""" - - _attr_device_class = DEVICE_CLASS_POWER - _attr_native_unit_of_measurement = POWER_KILO_WATT - - @property - def native_value(self) -> float | None: - """Return the state of this entity.""" - if not self.data or self.data.chargingInstantaneousPower is None: + if self.data is None: return None - if self.vehicle.details.reports_charging_power_in_watts(): - # Need to convert to kilowatts - return self.data.chargingInstantaneousPower / 1000 - return self.data.chargingInstantaneousPower + if self.entity_description.value_lambda is None: + return self.data + return self.entity_description.value_lambda(self) -class RenaultFuelAutonomySensor(RenaultCockpitDataEntity, SensorEntity): - """Fuel autonomy sensor.""" - - _attr_icon = "mdi:gas-station" - _attr_native_unit_of_measurement = LENGTH_KILOMETERS - - @property - def native_value(self) -> int | None: - """Return the state of this entity.""" - if not self.data or self.data.fuelAutonomy is None: - return None - return round(self.data.fuelAutonomy) +def _get_formatted_charging_status( + data: KamereonVehicleBatteryStatusData, +) -> str | None: + """Return the charging_status of this entity.""" + charging_status = data.get_charging_status() if data else None + return charging_status.name.lower() if charging_status else None -class RenaultFuelQuantitySensor(RenaultCockpitDataEntity, SensorEntity): - """Fuel quantity sensor.""" - - _attr_icon = "mdi:fuel" - _attr_native_unit_of_measurement = VOLUME_LITERS - - @property - def native_value(self) -> int | None: - """Return the state of this entity.""" - if not self.data or self.data.fuelQuantity is None: - return None - return round(self.data.fuelQuantity) +def _get_formatted_plug_status(data: KamereonVehicleBatteryStatusData) -> str | None: + """Return the plug_status of this entity.""" + plug_status = data.get_plug_status() if data else None + return plug_status.name.lower() if plug_status else None -class RenaultMileageSensor(RenaultCockpitDataEntity, SensorEntity): - """Mileage sensor.""" - - _attr_icon = "mdi:sign-direction" - _attr_native_unit_of_measurement = LENGTH_KILOMETERS - - @property - def native_value(self) -> int | None: - """Return the state of this entity.""" - if not self.data or self.data.totalMileage is None: - return None - return round(self.data.totalMileage) - - -class RenaultOutsideTemperatureSensor(RenaultHVACDataEntity, SensorEntity): - """HVAC Outside Temperature sensor.""" - - _attr_device_class = DEVICE_CLASS_TEMPERATURE - _attr_native_unit_of_measurement = TEMP_CELSIUS - - @property - def native_value(self) -> float | None: - """Return the state of this entity.""" - return self.data.externalTemperature if self.data else None - - -class RenaultPlugStateSensor(RenaultBatteryDataEntity, SensorEntity): - """Plug State sensor.""" - - _attr_device_class = DEVICE_CLASS_PLUG_STATE - - @property - def native_value(self) -> str | None: - """Return the state of this entity.""" - plug_status = self.data.get_plug_status() if self.data else None - return plug_status.name.lower() if plug_status is not None else None - - @property - def icon(self) -> str: - """Icon handling.""" - return "mdi:power-plug" if self.is_plugged_in else "mdi:power-plug-off" +SENSOR_TYPES: tuple[RenaultSensorEntityDescription, ...] = ( + RenaultSensorEntityDescription( + key="battery_level", + coordinator="battery", + data_key="batteryLevel", + device_class=DEVICE_CLASS_BATTERY, + entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], + name="Battery Level", + native_unit_of_measurement=PERCENTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + RenaultSensorEntityDescription( + key="charge_state", + coordinator="battery", + data_key="chargingStatus", + device_class=DEVICE_CLASS_CHARGE_STATE, + entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], + icon_lambda=lambda x: ( + "mdi:flash" + if x.data == ChargeState.CHARGE_IN_PROGRESS.value + else "mdi:flash-off" + ), + name="Charge State", + value_lambda=lambda x: ( + _get_formatted_charging_status( + cast(KamereonVehicleBatteryStatusData, x.coordinator.data) + ) + ), + ), + RenaultSensorEntityDescription( + key="charging_remaining_time", + coordinator="battery", + data_key="chargingRemainingTime", + entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], + icon="mdi:timer", + name="Charging Remaining Time", + native_unit_of_measurement=TIME_MINUTES, + state_class=STATE_CLASS_MEASUREMENT, + ), + RenaultSensorEntityDescription( + key="charging_power", + coordinator="battery", + data_key="chargingInstantaneousPower", + device_class=DEVICE_CLASS_POWER, + entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], + name="Charging Power", + native_unit_of_measurement=POWER_KILO_WATT, + state_class=STATE_CLASS_MEASUREMENT, + value_lambda=lambda x: ( + cast(float, x.data) / 1000 + if x.vehicle.details.reports_charging_power_in_watts() + else x.data + ), + ), + RenaultSensorEntityDescription( + key="plug_state", + coordinator="battery", + data_key="plugStatus", + device_class=DEVICE_CLASS_PLUG_STATE, + entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], + icon_lambda=lambda x: ( + "mdi:power-plug" + if x.data == PlugState.PLUGGED.value + else "mdi:power-plug-off" + ), + name="Plug State", + value_lambda=lambda x: ( + _get_formatted_plug_status( + cast(KamereonVehicleBatteryStatusData, x.coordinator.data) + ) + ), + ), + RenaultSensorEntityDescription( + key="battery_autonomy", + coordinator="battery", + data_key="batteryAutonomy", + entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], + icon="mdi:ev-station", + name="Battery Autonomy", + native_unit_of_measurement=LENGTH_KILOMETERS, + state_class=STATE_CLASS_MEASUREMENT, + ), + RenaultSensorEntityDescription( + key="battery_available_energy", + coordinator="battery", + data_key="batteryAvailableEnergy", + entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], + device_class=DEVICE_CLASS_ENERGY, + name="Battery Available Energy", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=STATE_CLASS_MEASUREMENT, + ), + RenaultSensorEntityDescription( + key="battery_temperature", + coordinator="battery", + data_key="batteryTemperature", + device_class=DEVICE_CLASS_TEMPERATURE, + entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], + name="Battery Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + state_class=STATE_CLASS_MEASUREMENT, + ), + RenaultSensorEntityDescription( + key="mileage", + coordinator="cockpit", + data_key="totalMileage", + entity_class=RenaultSensor[KamereonVehicleCockpitData], + icon="mdi:sign-direction", + name="Mileage", + native_unit_of_measurement=LENGTH_KILOMETERS, + state_class=STATE_CLASS_TOTAL_INCREASING, + value_lambda=lambda x: round(cast(float, x.data)), + ), + RenaultSensorEntityDescription( + key="fuel_autonomy", + coordinator="cockpit", + data_key="fuelAutonomy", + entity_class=RenaultSensor[KamereonVehicleCockpitData], + icon="mdi:gas-station", + name="Fuel Autonomy", + native_unit_of_measurement=LENGTH_KILOMETERS, + state_class=STATE_CLASS_MEASUREMENT, + requires_fuel=True, + value_lambda=lambda x: round(cast(float, x.data)), + ), + RenaultSensorEntityDescription( + key="fuel_quantity", + coordinator="cockpit", + data_key="fuelQuantity", + entity_class=RenaultSensor[KamereonVehicleCockpitData], + icon="mdi:fuel", + name="Fuel Quantity", + native_unit_of_measurement=VOLUME_LITERS, + state_class=STATE_CLASS_MEASUREMENT, + requires_fuel=True, + value_lambda=lambda x: round(cast(float, x.data)), + ), + RenaultSensorEntityDescription( + key="outside_temperature", + coordinator="hvac_status", + device_class=DEVICE_CLASS_TEMPERATURE, + data_key="externalTemperature", + entity_class=RenaultSensor[KamereonVehicleHvacStatusData], + name="Outside Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + state_class=STATE_CLASS_MEASUREMENT, + ), + RenaultSensorEntityDescription( + key="charge_mode", + coordinator="charge_mode", + data_key="chargeMode", + device_class=DEVICE_CLASS_CHARGE_MODE, + entity_class=RenaultSensor[KamereonVehicleChargeModeData], + icon_lambda=lambda x: ( + "mdi:calendar-clock" if x.data == "schedule_mode" else "mdi:calendar-remove" + ), + name="Charge Mode", + ), +) diff --git a/tests/components/renault/const.py b/tests/components/renault/const.py index 2c742aa07cd..c100f85c498 100644 --- a/tests/components/renault/const.py +++ b/tests/components/renault/const.py @@ -12,8 +12,17 @@ from homeassistant.components.renault.const import ( DEVICE_CLASS_PLUG_STATE, DOMAIN, ) -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.renault.renault_entities import ATTR_LAST_UPDATE +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + DOMAIN as SENSOR_DOMAIN, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, +) from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_ICON, + ATTR_UNIT_OF_MEASUREMENT, CONF_PASSWORD, CONF_USERNAME, DEVICE_CLASS_BATTERY, @@ -32,6 +41,14 @@ from homeassistant.const import ( VOLUME_LITERS, ) +CHECK_ATTRIBUTES = ( + ATTR_DEVICE_CLASS, + ATTR_ICON, + ATTR_LAST_UPDATE, + ATTR_STATE_CLASS, + ATTR_UNIT_OF_MEASUREMENT, +) + # Mock config data to be used across multiple tests MOCK_CONFIG = { CONF_USERNAME: "email@test.com", @@ -66,13 +83,15 @@ MOCK_VEHICLES = { "entity_id": "binary_sensor.plugged_in", "unique_id": "vf1aaaaa555777999_plugged_in", "result": STATE_ON, - "class": DEVICE_CLASS_PLUG, + ATTR_DEVICE_CLASS: DEVICE_CLASS_PLUG, + ATTR_LAST_UPDATE: "2020-01-12T21:40:16Z", }, { "entity_id": "binary_sensor.charging", "unique_id": "vf1aaaaa555777999_charging", "result": STATE_ON, - "class": DEVICE_CLASS_BATTERY_CHARGING, + ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY_CHARGING, + ATTR_LAST_UPDATE: "2020-01-12T21:40:16Z", }, ], SENSOR_DOMAIN: [ @@ -80,72 +99,94 @@ MOCK_VEHICLES = { "entity_id": "sensor.battery_autonomy", "unique_id": "vf1aaaaa555777999_battery_autonomy", "result": "141", - "unit": LENGTH_KILOMETERS, + ATTR_ICON: "mdi:ev-station", + ATTR_LAST_UPDATE: "2020-01-12T21:40:16Z", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: LENGTH_KILOMETERS, }, { "entity_id": "sensor.battery_available_energy", "unique_id": "vf1aaaaa555777999_battery_available_energy", "result": "31", - "unit": ENERGY_KILO_WATT_HOUR, - "class": DEVICE_CLASS_ENERGY, + ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, + ATTR_LAST_UPDATE: "2020-01-12T21:40:16Z", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, }, { "entity_id": "sensor.battery_level", "unique_id": "vf1aaaaa555777999_battery_level", "result": "60", - "unit": PERCENTAGE, - "class": DEVICE_CLASS_BATTERY, + ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY, + ATTR_LAST_UPDATE: "2020-01-12T21:40:16Z", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, }, { "entity_id": "sensor.battery_temperature", "unique_id": "vf1aaaaa555777999_battery_temperature", "result": "20", - "unit": TEMP_CELSIUS, - "class": DEVICE_CLASS_TEMPERATURE, + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_LAST_UPDATE: "2020-01-12T21:40:16Z", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, }, { "entity_id": "sensor.charge_mode", "unique_id": "vf1aaaaa555777999_charge_mode", "result": "always", - "class": DEVICE_CLASS_CHARGE_MODE, + ATTR_DEVICE_CLASS: DEVICE_CLASS_CHARGE_MODE, + ATTR_ICON: "mdi:calendar-remove", }, { "entity_id": "sensor.charge_state", "unique_id": "vf1aaaaa555777999_charge_state", "result": "charge_in_progress", - "class": DEVICE_CLASS_CHARGE_STATE, + ATTR_DEVICE_CLASS: DEVICE_CLASS_CHARGE_STATE, + ATTR_ICON: "mdi:flash", + ATTR_LAST_UPDATE: "2020-01-12T21:40:16Z", }, { "entity_id": "sensor.charging_power", "unique_id": "vf1aaaaa555777999_charging_power", "result": "0.027", - "unit": POWER_KILO_WATT, - "class": DEVICE_CLASS_POWER, + ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, + ATTR_LAST_UPDATE: "2020-01-12T21:40:16Z", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: POWER_KILO_WATT, }, { "entity_id": "sensor.charging_remaining_time", "unique_id": "vf1aaaaa555777999_charging_remaining_time", "result": "145", - "unit": TIME_MINUTES, + ATTR_ICON: "mdi:timer", + ATTR_LAST_UPDATE: "2020-01-12T21:40:16Z", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: TIME_MINUTES, }, { "entity_id": "sensor.mileage", "unique_id": "vf1aaaaa555777999_mileage", "result": "49114", - "unit": LENGTH_KILOMETERS, + ATTR_ICON: "mdi:sign-direction", + ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, + ATTR_UNIT_OF_MEASUREMENT: LENGTH_KILOMETERS, }, { "entity_id": "sensor.outside_temperature", "unique_id": "vf1aaaaa555777999_outside_temperature", "result": "8.0", - "unit": TEMP_CELSIUS, - "class": DEVICE_CLASS_TEMPERATURE, + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, }, { "entity_id": "sensor.plug_state", "unique_id": "vf1aaaaa555777999_plug_state", "result": "plugged", - "class": DEVICE_CLASS_PLUG_STATE, + ATTR_DEVICE_CLASS: DEVICE_CLASS_PLUG_STATE, + ATTR_ICON: "mdi:power-plug", + ATTR_LAST_UPDATE: "2020-01-12T21:40:16Z", }, ], }, @@ -173,13 +214,15 @@ MOCK_VEHICLES = { "entity_id": "binary_sensor.plugged_in", "unique_id": "vf1aaaaa555777999_plugged_in", "result": STATE_OFF, - "class": DEVICE_CLASS_PLUG, + ATTR_DEVICE_CLASS: DEVICE_CLASS_PLUG, + ATTR_LAST_UPDATE: "2020-11-17T09:06:48+01:00", }, { "entity_id": "binary_sensor.charging", "unique_id": "vf1aaaaa555777999_charging", "result": STATE_OFF, - "class": DEVICE_CLASS_BATTERY_CHARGING, + ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY_CHARGING, + ATTR_LAST_UPDATE: "2020-11-17T09:06:48+01:00", }, ], SENSOR_DOMAIN: [ @@ -187,65 +230,86 @@ MOCK_VEHICLES = { "entity_id": "sensor.battery_autonomy", "unique_id": "vf1aaaaa555777999_battery_autonomy", "result": "128", - "unit": LENGTH_KILOMETERS, + ATTR_ICON: "mdi:ev-station", + ATTR_LAST_UPDATE: "2020-11-17T09:06:48+01:00", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: LENGTH_KILOMETERS, }, { "entity_id": "sensor.battery_available_energy", "unique_id": "vf1aaaaa555777999_battery_available_energy", "result": "0", - "unit": ENERGY_KILO_WATT_HOUR, - "class": DEVICE_CLASS_ENERGY, + ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, + ATTR_LAST_UPDATE: "2020-11-17T09:06:48+01:00", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, }, { "entity_id": "sensor.battery_level", "unique_id": "vf1aaaaa555777999_battery_level", "result": "50", - "unit": PERCENTAGE, - "class": DEVICE_CLASS_BATTERY, + ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY, + ATTR_LAST_UPDATE: "2020-11-17T09:06:48+01:00", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, }, { "entity_id": "sensor.battery_temperature", "unique_id": "vf1aaaaa555777999_battery_temperature", "result": STATE_UNKNOWN, - "unit": TEMP_CELSIUS, - "class": DEVICE_CLASS_TEMPERATURE, + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_LAST_UPDATE: "2020-11-17T09:06:48+01:00", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, }, { "entity_id": "sensor.charge_mode", "unique_id": "vf1aaaaa555777999_charge_mode", "result": "schedule_mode", - "class": DEVICE_CLASS_CHARGE_MODE, + ATTR_DEVICE_CLASS: DEVICE_CLASS_CHARGE_MODE, + ATTR_ICON: "mdi:calendar-clock", }, { "entity_id": "sensor.charge_state", "unique_id": "vf1aaaaa555777999_charge_state", "result": "charge_error", - "class": DEVICE_CLASS_CHARGE_STATE, + ATTR_DEVICE_CLASS: DEVICE_CLASS_CHARGE_STATE, + ATTR_ICON: "mdi:flash-off", + ATTR_LAST_UPDATE: "2020-11-17T09:06:48+01:00", }, { "entity_id": "sensor.charging_power", "unique_id": "vf1aaaaa555777999_charging_power", "result": STATE_UNKNOWN, - "unit": POWER_KILO_WATT, - "class": DEVICE_CLASS_POWER, + ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, + ATTR_LAST_UPDATE: "2020-11-17T09:06:48+01:00", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: POWER_KILO_WATT, }, { "entity_id": "sensor.charging_remaining_time", "unique_id": "vf1aaaaa555777999_charging_remaining_time", "result": STATE_UNKNOWN, - "unit": TIME_MINUTES, + ATTR_ICON: "mdi:timer", + ATTR_LAST_UPDATE: "2020-11-17T09:06:48+01:00", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: TIME_MINUTES, }, { "entity_id": "sensor.mileage", "unique_id": "vf1aaaaa555777999_mileage", "result": "49114", - "unit": LENGTH_KILOMETERS, + ATTR_ICON: "mdi:sign-direction", + ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, + ATTR_UNIT_OF_MEASUREMENT: LENGTH_KILOMETERS, }, { "entity_id": "sensor.plug_state", "unique_id": "vf1aaaaa555777999_plug_state", "result": "unplugged", - "class": DEVICE_CLASS_PLUG_STATE, + ATTR_DEVICE_CLASS: DEVICE_CLASS_PLUG_STATE, + ATTR_ICON: "mdi:power-plug-off", + ATTR_LAST_UPDATE: "2020-11-17T09:06:48+01:00", }, ], }, @@ -273,13 +337,15 @@ MOCK_VEHICLES = { "entity_id": "binary_sensor.plugged_in", "unique_id": "vf1aaaaa555777123_plugged_in", "result": STATE_ON, - "class": DEVICE_CLASS_PLUG, + ATTR_DEVICE_CLASS: DEVICE_CLASS_PLUG, + ATTR_LAST_UPDATE: "2020-01-12T21:40:16Z", }, { "entity_id": "binary_sensor.charging", "unique_id": "vf1aaaaa555777123_charging", "result": STATE_ON, - "class": DEVICE_CLASS_BATTERY_CHARGING, + ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY_CHARGING, + ATTR_LAST_UPDATE: "2020-01-12T21:40:16Z", }, ], SENSOR_DOMAIN: [ @@ -287,77 +353,102 @@ MOCK_VEHICLES = { "entity_id": "sensor.battery_autonomy", "unique_id": "vf1aaaaa555777123_battery_autonomy", "result": "141", - "unit": LENGTH_KILOMETERS, + ATTR_ICON: "mdi:ev-station", + ATTR_LAST_UPDATE: "2020-01-12T21:40:16Z", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: LENGTH_KILOMETERS, }, { "entity_id": "sensor.battery_available_energy", "unique_id": "vf1aaaaa555777123_battery_available_energy", "result": "31", - "unit": ENERGY_KILO_WATT_HOUR, - "class": DEVICE_CLASS_ENERGY, + ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, + ATTR_LAST_UPDATE: "2020-01-12T21:40:16Z", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, }, { "entity_id": "sensor.battery_level", "unique_id": "vf1aaaaa555777123_battery_level", "result": "60", - "unit": PERCENTAGE, - "class": DEVICE_CLASS_BATTERY, + ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY, + ATTR_LAST_UPDATE: "2020-01-12T21:40:16Z", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, }, { "entity_id": "sensor.battery_temperature", "unique_id": "vf1aaaaa555777123_battery_temperature", "result": "20", - "unit": TEMP_CELSIUS, - "class": DEVICE_CLASS_TEMPERATURE, + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_LAST_UPDATE: "2020-01-12T21:40:16Z", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, }, { "entity_id": "sensor.charge_mode", "unique_id": "vf1aaaaa555777123_charge_mode", "result": "always", - "class": DEVICE_CLASS_CHARGE_MODE, + ATTR_DEVICE_CLASS: DEVICE_CLASS_CHARGE_MODE, + ATTR_ICON: "mdi:calendar-remove", }, { "entity_id": "sensor.charge_state", "unique_id": "vf1aaaaa555777123_charge_state", "result": "charge_in_progress", - "class": DEVICE_CLASS_CHARGE_STATE, + ATTR_DEVICE_CLASS: DEVICE_CLASS_CHARGE_STATE, + ATTR_ICON: "mdi:flash", + ATTR_LAST_UPDATE: "2020-01-12T21:40:16Z", }, { "entity_id": "sensor.charging_power", "unique_id": "vf1aaaaa555777123_charging_power", "result": "27.0", - "unit": POWER_KILO_WATT, - "class": DEVICE_CLASS_POWER, + ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, + ATTR_LAST_UPDATE: "2020-01-12T21:40:16Z", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: POWER_KILO_WATT, }, { "entity_id": "sensor.charging_remaining_time", "unique_id": "vf1aaaaa555777123_charging_remaining_time", "result": "145", - "unit": TIME_MINUTES, + ATTR_ICON: "mdi:timer", + ATTR_LAST_UPDATE: "2020-01-12T21:40:16Z", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: TIME_MINUTES, }, { "entity_id": "sensor.fuel_autonomy", "unique_id": "vf1aaaaa555777123_fuel_autonomy", "result": "35", - "unit": LENGTH_KILOMETERS, + ATTR_ICON: "mdi:gas-station", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: LENGTH_KILOMETERS, }, { "entity_id": "sensor.fuel_quantity", "unique_id": "vf1aaaaa555777123_fuel_quantity", "result": "3", - "unit": VOLUME_LITERS, + ATTR_ICON: "mdi:fuel", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: VOLUME_LITERS, }, { "entity_id": "sensor.mileage", "unique_id": "vf1aaaaa555777123_mileage", "result": "5567", - "unit": LENGTH_KILOMETERS, + ATTR_ICON: "mdi:sign-direction", + ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, + ATTR_UNIT_OF_MEASUREMENT: LENGTH_KILOMETERS, }, { "entity_id": "sensor.plug_state", "unique_id": "vf1aaaaa555777123_plug_state", "result": "plugged", - "class": DEVICE_CLASS_PLUG_STATE, + ATTR_DEVICE_CLASS: DEVICE_CLASS_PLUG_STATE, + ATTR_ICON: "mdi:power-plug", + ATTR_LAST_UPDATE: "2020-01-12T21:40:16Z", }, ], }, @@ -382,19 +473,25 @@ MOCK_VEHICLES = { "entity_id": "sensor.fuel_autonomy", "unique_id": "vf1aaaaa555777123_fuel_autonomy", "result": "35", - "unit": LENGTH_KILOMETERS, + ATTR_ICON: "mdi:gas-station", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: LENGTH_KILOMETERS, }, { "entity_id": "sensor.fuel_quantity", "unique_id": "vf1aaaaa555777123_fuel_quantity", "result": "3", - "unit": VOLUME_LITERS, + ATTR_ICON: "mdi:fuel", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: VOLUME_LITERS, }, { "entity_id": "sensor.mileage", "unique_id": "vf1aaaaa555777123_mileage", "result": "5567", - "unit": LENGTH_KILOMETERS, + ATTR_ICON: "mdi:sign-direction", + ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, + ATTR_UNIT_OF_MEASUREMENT: LENGTH_KILOMETERS, }, ], }, diff --git a/tests/components/renault/test_binary_sensor.py b/tests/components/renault/test_binary_sensor.py index 71bb90f16a6..c357d9d7a5a 100644 --- a/tests/components/renault/test_binary_sensor.py +++ b/tests/components/renault/test_binary_sensor.py @@ -5,6 +5,7 @@ import pytest from renault_api.kamereon import exceptions from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.renault.renault_entities import ATTR_LAST_UPDATE from homeassistant.const import STATE_OFF, STATE_UNAVAILABLE from homeassistant.setup import async_setup_component @@ -14,7 +15,7 @@ from . import ( setup_renault_integration_vehicle_with_no_data, setup_renault_integration_vehicle_with_side_effect, ) -from .const import MOCK_VEHICLES +from .const import CHECK_ATTRIBUTES, MOCK_VEHICLES from tests.common import mock_device_registry, mock_registry @@ -40,10 +41,10 @@ async def test_binary_sensors(hass, vehicle_type): registry_entry = entity_registry.entities.get(entity_id) assert registry_entry is not None assert registry_entry.unique_id == expected_entity["unique_id"] - assert registry_entry.unit_of_measurement == expected_entity.get("unit") - assert registry_entry.device_class == expected_entity.get("class") state = hass.states.get(entity_id) assert state.state == expected_entity["result"] + for attr in CHECK_ATTRIBUTES: + assert state.attributes.get(attr) == expected_entity.get(attr) @pytest.mark.parametrize("vehicle_type", MOCK_VEHICLES.keys()) @@ -67,10 +68,13 @@ async def test_binary_sensor_empty(hass, vehicle_type): registry_entry = entity_registry.entities.get(entity_id) assert registry_entry is not None assert registry_entry.unique_id == expected_entity["unique_id"] - assert registry_entry.unit_of_measurement == expected_entity.get("unit") - assert registry_entry.device_class == expected_entity.get("class") state = hass.states.get(entity_id) assert state.state == STATE_OFF + for attr in CHECK_ATTRIBUTES: + if attr == ATTR_LAST_UPDATE: + assert state.attributes.get(attr) is None + else: + assert state.attributes.get(attr) == expected_entity.get(attr) @pytest.mark.parametrize("vehicle_type", MOCK_VEHICLES.keys()) @@ -101,10 +105,13 @@ async def test_binary_sensor_errors(hass, vehicle_type): registry_entry = entity_registry.entities.get(entity_id) assert registry_entry is not None assert registry_entry.unique_id == expected_entity["unique_id"] - assert registry_entry.unit_of_measurement == expected_entity.get("unit") - assert registry_entry.device_class == expected_entity.get("class") state = hass.states.get(entity_id) assert state.state == STATE_UNAVAILABLE + for attr in CHECK_ATTRIBUTES: + if attr == ATTR_LAST_UPDATE: + assert state.attributes.get(attr) is None + else: + assert state.attributes.get(attr) == expected_entity.get(attr) async def test_binary_sensor_access_denied(hass): diff --git a/tests/components/renault/test_sensor.py b/tests/components/renault/test_sensor.py index 41fceccb56c..4bc4d96d75f 100644 --- a/tests/components/renault/test_sensor.py +++ b/tests/components/renault/test_sensor.py @@ -4,8 +4,20 @@ from unittest.mock import patch import pytest from renault_api.kamereon import exceptions +from homeassistant.components.renault.const import ( + DEVICE_CLASS_CHARGE_MODE, + DEVICE_CLASS_CHARGE_STATE, + DEVICE_CLASS_PLUG_STATE, +) +from homeassistant.components.renault.renault_entities import ATTR_LAST_UPDATE from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_ICON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) +from homeassistant.core import State from homeassistant.setup import async_setup_component from . import ( @@ -14,11 +26,28 @@ from . import ( setup_renault_integration_vehicle_with_no_data, setup_renault_integration_vehicle_with_side_effect, ) -from .const import MOCK_VEHICLES +from .const import CHECK_ATTRIBUTES, MOCK_VEHICLES from tests.common import mock_device_registry, mock_registry +def check_inactive_attribute(state: State, attr: str, expected_entity: dict): + """Check attribute for icon for inactive sensors.""" + if attr == ATTR_LAST_UPDATE: + assert state.attributes.get(attr) is None + elif attr == ATTR_ICON: + if expected_entity.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_CHARGE_MODE: + assert state.attributes.get(ATTR_ICON) == "mdi:calendar-remove" + elif expected_entity.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_CHARGE_STATE: + assert state.attributes.get(ATTR_ICON) == "mdi:flash-off" + elif expected_entity.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_PLUG_STATE: + assert state.attributes.get(ATTR_ICON) == "mdi:power-plug-off" + else: + assert state.attributes.get(ATTR_ICON) == expected_entity.get(ATTR_ICON) + else: + assert state.attributes.get(attr) == expected_entity.get(attr) + + @pytest.mark.parametrize("vehicle_type", MOCK_VEHICLES.keys()) async def test_sensors(hass, vehicle_type): """Test for Renault sensors.""" @@ -40,10 +69,10 @@ async def test_sensors(hass, vehicle_type): registry_entry = entity_registry.entities.get(entity_id) assert registry_entry is not None assert registry_entry.unique_id == expected_entity["unique_id"] - assert registry_entry.unit_of_measurement == expected_entity.get("unit") - assert registry_entry.device_class == expected_entity.get("class") state = hass.states.get(entity_id) assert state.state == expected_entity["result"] + for attr in CHECK_ATTRIBUTES: + assert state.attributes.get(attr) == expected_entity.get(attr) @pytest.mark.parametrize("vehicle_type", MOCK_VEHICLES.keys()) @@ -67,10 +96,10 @@ async def test_sensor_empty(hass, vehicle_type): registry_entry = entity_registry.entities.get(entity_id) assert registry_entry is not None assert registry_entry.unique_id == expected_entity["unique_id"] - assert registry_entry.unit_of_measurement == expected_entity.get("unit") - assert registry_entry.device_class == expected_entity.get("class") state = hass.states.get(entity_id) assert state.state == STATE_UNKNOWN + for attr in CHECK_ATTRIBUTES: + check_inactive_attribute(state, attr, expected_entity) @pytest.mark.parametrize("vehicle_type", MOCK_VEHICLES.keys()) @@ -101,10 +130,10 @@ async def test_sensor_errors(hass, vehicle_type): registry_entry = entity_registry.entities.get(entity_id) assert registry_entry is not None assert registry_entry.unique_id == expected_entity["unique_id"] - assert registry_entry.unit_of_measurement == expected_entity.get("unit") - assert registry_entry.device_class == expected_entity.get("class") state = hass.states.get(entity_id) assert state.state == STATE_UNAVAILABLE + for attr in CHECK_ATTRIBUTES: + check_inactive_attribute(state, attr, expected_entity) async def test_sensor_access_denied(hass): From 06a30c882ff44dafe1bde58b7a41961043b9abfd Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 25 Aug 2021 23:19:14 +0200 Subject: [PATCH 002/843] Bump version to 2021.10.0dev0 (#55227) --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 7f5dba5b17d..4f495e93cea 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Final MAJOR_VERSION: Final = 2021 -MINOR_VERSION: Final = 9 +MINOR_VERSION: Final = 10 PATCH_VERSION: Final = "0.dev0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" From b81c2806bbb2dec3eac1297d19a9febb784f5db4 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 26 Aug 2021 00:37:18 +0200 Subject: [PATCH 003/843] Remove temperature conversion - tado (#55231) --- homeassistant/components/tado/sensor.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/tado/sensor.py b/homeassistant/components/tado/sensor.py index 537e094bfd2..044241f2be0 100644 --- a/homeassistant/components/tado/sensor.py +++ b/homeassistant/components/tado/sensor.py @@ -168,10 +168,7 @@ class TadoHomeSensor(TadoHomeEntity, SensorEntity): return if self.home_variable == "outdoor temperature": - self._state = self.hass.config.units.temperature( - self._tado_weather_data["outsideTemperature"]["celsius"], - TEMP_CELSIUS, - ) + self._state = self._tado_weather_data["outsideTemperature"]["celsius"] self._state_attributes = { "time": self._tado_weather_data["outsideTemperature"]["timestamp"], } @@ -245,7 +242,7 @@ class TadoZoneSensor(TadoZoneEntity, SensorEntity): def native_unit_of_measurement(self): """Return the unit of measurement.""" if self.zone_variable == "temperature": - return self.hass.config.units.temperature_unit + return TEMP_CELSIUS if self.zone_variable == "humidity": return PERCENTAGE if self.zone_variable == "heating": @@ -277,9 +274,7 @@ class TadoZoneSensor(TadoZoneEntity, SensorEntity): return if self.zone_variable == "temperature": - self._state = self.hass.config.units.temperature( - self._tado_zone_data.current_temp, TEMP_CELSIUS - ) + self._state = self._tado_zone_data.current_temp self._state_attributes = { "time": self._tado_zone_data.current_temp_timestamp, "setting": 0, # setting is used in climate device From 3d7bfa835725f1015eb6ec3de5e104305feb0870 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Thu, 26 Aug 2021 00:13:27 +0000 Subject: [PATCH 004/843] [ci skip] Translation update --- .../fjaraskupan/translations/it.json | 13 +++++++++ .../components/homekit/translations/ca.json | 5 ++-- .../components/homekit/translations/de.json | 5 ++-- .../components/homekit/translations/et.json | 5 ++-- .../components/homekit/translations/it.json | 5 ++-- .../components/nanoleaf/translations/ca.json | 1 + .../components/nanoleaf/translations/de.json | 1 + .../components/nanoleaf/translations/et.json | 28 +++++++++++++++++++ .../components/nanoleaf/translations/it.json | 28 +++++++++++++++++++ .../components/nanoleaf/translations/nl.json | 27 ++++++++++++++++++ .../nanoleaf/translations/zh-Hant.json | 27 ++++++++++++++++++ .../components/openuv/translations/et.json | 11 ++++++++ .../components/openuv/translations/it.json | 11 ++++++++ .../openuv/translations/zh-Hant.json | 11 ++++++++ .../p1_monitor/translations/it.json | 17 +++++++++++ .../rainforest_eagle/translations/it.json | 21 ++++++++++++++ .../components/roomba/translations/it.json | 2 +- .../components/sensor/translations/it.json | 2 ++ .../components/yeelight/translations/it.json | 2 +- .../components/zha/translations/it.json | 4 +++ .../components/zwave_js/translations/it.json | 12 ++++++-- 21 files changed, 226 insertions(+), 12 deletions(-) create mode 100644 homeassistant/components/fjaraskupan/translations/it.json create mode 100644 homeassistant/components/nanoleaf/translations/et.json create mode 100644 homeassistant/components/nanoleaf/translations/it.json create mode 100644 homeassistant/components/nanoleaf/translations/nl.json create mode 100644 homeassistant/components/nanoleaf/translations/zh-Hant.json create mode 100644 homeassistant/components/p1_monitor/translations/it.json create mode 100644 homeassistant/components/rainforest_eagle/translations/it.json diff --git a/homeassistant/components/fjaraskupan/translations/it.json b/homeassistant/components/fjaraskupan/translations/it.json new file mode 100644 index 00000000000..49b68f1f9a8 --- /dev/null +++ b/homeassistant/components/fjaraskupan/translations/it.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nessun dispositivo trovato sulla rete", + "single_instance_allowed": "Gi\u00e0 configurato. \u00c8 possibile una sola configurazione." + }, + "step": { + "confirm": { + "description": "Vuoi impostare Fj\u00e4r\u00e5skupan?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit/translations/ca.json b/homeassistant/components/homekit/translations/ca.json index d6bb88f1dba..a71287d3c0b 100644 --- a/homeassistant/components/homekit/translations/ca.json +++ b/homeassistant/components/homekit/translations/ca.json @@ -21,9 +21,10 @@ "step": { "advanced": { "data": { - "auto_start": "Inici autom\u00e0tic (desactiva-ho si crides el servei homekit.start manualment)" + "auto_start": "Inici autom\u00e0tic (desactiva-ho si crides el servei homekit.start manualment)", + "devices": "Dispositius (disparadors)" }, - "description": "Aquests par\u00e0metres nom\u00e9s s'han d'ajustar si HomeKit no \u00e9s funcional.", + "description": "Els interruptors programables es creen per cada dispositiu seleccionat. HomeKit pot ser programat per a que executi una automatitzaci\u00f3 o escena quan un dispositiu es dispari.", "title": "Configuraci\u00f3 avan\u00e7ada" }, "cameras": { diff --git a/homeassistant/components/homekit/translations/de.json b/homeassistant/components/homekit/translations/de.json index 759a33fca91..06027c4c09e 100644 --- a/homeassistant/components/homekit/translations/de.json +++ b/homeassistant/components/homekit/translations/de.json @@ -21,9 +21,10 @@ "step": { "advanced": { "data": { - "auto_start": "Autostart (deaktivieren, wenn du den homekit.start-Dienst manuell aufrufst)" + "auto_start": "Autostart (deaktivieren, wenn du den homekit.start-Dienst manuell aufrufst)", + "devices": "Ger\u00e4te (Trigger)" }, - "description": "Diese Einstellungen m\u00fcssen nur angepasst werden, wenn HomeKit nicht funktioniert.", + "description": "F\u00fcr jedes ausgew\u00e4hlte Ger\u00e4t werden programmierbare Schalter erstellt. Wenn ein Ger\u00e4teausl\u00f6ser ausgel\u00f6st wird, kann HomeKit so konfiguriert werden, dass eine Automatisierung oder Szene ausgef\u00fchrt wird.", "title": "Erweiterte Konfiguration" }, "cameras": { diff --git a/homeassistant/components/homekit/translations/et.json b/homeassistant/components/homekit/translations/et.json index 1213200474a..4e454178048 100644 --- a/homeassistant/components/homekit/translations/et.json +++ b/homeassistant/components/homekit/translations/et.json @@ -21,9 +21,10 @@ "step": { "advanced": { "data": { - "auto_start": "Autostart (keela kui kasutad homekit.start teenust k\u00e4sitsi)" + "auto_start": "Autostart (keela kui kasutad homekit.start teenust k\u00e4sitsi)", + "devices": "Seadmed (p\u00e4\u00e4stikud)" }, - "description": "Neid s\u00e4tteid tuleb muuta ainult siis kui HomeKit ei t\u00f6\u00f6ta.", + "description": "Iga valitud seadme jaoks luuakse programmeeritavad l\u00fclitid. Seadme p\u00e4\u00e4stiku k\u00e4ivitamisel saab HomeKiti seadistada automaatiseeringu v\u00f5i stseeni k\u00e4ivitamiseks.", "title": "T\u00e4psem seadistamine" }, "cameras": { diff --git a/homeassistant/components/homekit/translations/it.json b/homeassistant/components/homekit/translations/it.json index c9afa13fb85..74bc0032580 100644 --- a/homeassistant/components/homekit/translations/it.json +++ b/homeassistant/components/homekit/translations/it.json @@ -21,9 +21,10 @@ "step": { "advanced": { "data": { - "auto_start": "Avvio automatico (disabilitare se stai chiamando manualmente il servizio homekit.start)" + "auto_start": "Avvio automatico (disabilitare se stai chiamando manualmente il servizio homekit.start)", + "devices": "Dispositivi (Attivatori)" }, - "description": "Queste impostazioni devono essere regolate solo se HomeKit non funziona.", + "description": "Gli interruttori programmabili vengono creati per ogni dispositivo selezionato. Quando si attiva un trigger del dispositivo, HomeKit pu\u00f2 essere configurato per eseguire un'automazione o una scena.", "title": "Configurazione Avanzata" }, "cameras": { diff --git a/homeassistant/components/nanoleaf/translations/ca.json b/homeassistant/components/nanoleaf/translations/ca.json index 80403026c91..6c966627f94 100644 --- a/homeassistant/components/nanoleaf/translations/ca.json +++ b/homeassistant/components/nanoleaf/translations/ca.json @@ -4,6 +4,7 @@ "already_configured": "El dispositiu ja est\u00e0 configurat", "cannot_connect": "Ha fallat la connexi\u00f3", "invalid_token": "Token d'acc\u00e9s no v\u00e0lid", + "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament", "unknown": "Error inesperat" }, "error": { diff --git a/homeassistant/components/nanoleaf/translations/de.json b/homeassistant/components/nanoleaf/translations/de.json index b79c2995cb4..fa1b9d98057 100644 --- a/homeassistant/components/nanoleaf/translations/de.json +++ b/homeassistant/components/nanoleaf/translations/de.json @@ -4,6 +4,7 @@ "already_configured": "Ger\u00e4t ist bereits konfiguriert", "cannot_connect": "Verbindung fehlgeschlagen", "invalid_token": "Ung\u00fcltiger Zugriffs-Token", + "reauth_successful": "Die erneute Authentifizierung war erfolgreich", "unknown": "Unerwarteter Fehler" }, "error": { diff --git a/homeassistant/components/nanoleaf/translations/et.json b/homeassistant/components/nanoleaf/translations/et.json new file mode 100644 index 00000000000..15690ab3072 --- /dev/null +++ b/homeassistant/components/nanoleaf/translations/et.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud", + "cannot_connect": "\u00dchendamine nurjus", + "invalid_token": "Vigane juurdep\u00e4\u00e4sut\u00f5end", + "reauth_successful": "Taastuvastamine \u00f5nnestus", + "unknown": "Ootamatu t\u00f5rge" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "not_allowing_new_tokens": "Nanoleaf ei luba uusi juurdep\u00e4\u00e4sut\u00f5endeid, j\u00e4rgi \u00fclaltoodud juhiseid.", + "unknown": "Ootamatu t\u00f5rge" + }, + "flow_title": "{name}", + "step": { + "link": { + "description": "Vajuta ja hoia Nanoleafi toitenuppu 5 sekundit all kuni nuppude LED -id hakkavad vilkuma ja seej\u00e4rel kl\u00f5psa 30 sekundi jooksul **ESITA**.", + "title": "Nanoleafi sidumine" + }, + "user": { + "data": { + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nanoleaf/translations/it.json b/homeassistant/components/nanoleaf/translations/it.json new file mode 100644 index 00000000000..1e7517d8ada --- /dev/null +++ b/homeassistant/components/nanoleaf/translations/it.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", + "cannot_connect": "Impossibile connettersi", + "invalid_token": "Token di accesso non valido", + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente", + "unknown": "Errore imprevisto" + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "not_allowing_new_tokens": "Nanoleaf non permette nuovi token, segui le istruzioni qui sopra.", + "unknown": "Errore imprevisto" + }, + "flow_title": "{name}", + "step": { + "link": { + "description": "Tieni premuto il pulsante di accensione sul tuo Nanoleaf per 5 secondi fino a quando i LED del pulsante iniziano a lampeggiare, quindi fai clic su **INVIA** entro 30 secondi.", + "title": "Collega Nanoleaf" + }, + "user": { + "data": { + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nanoleaf/translations/nl.json b/homeassistant/components/nanoleaf/translations/nl.json new file mode 100644 index 00000000000..8dd31fc0f44 --- /dev/null +++ b/homeassistant/components/nanoleaf/translations/nl.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd", + "cannot_connect": "Kan geen verbinding maken", + "invalid_token": "Ongeldig toegangstoken", + "unknown": "Onverwachte fout" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "not_allowing_new_tokens": "Nanoleaf staat geen nieuwe tokens toe, volg de bovenstaande instructies.", + "unknown": "Onverwachte fout" + }, + "flow_title": "{name}", + "step": { + "link": { + "description": "Houd de aan/uit-knop van je Nanoleaf 5 seconden ingedrukt totdat de LED's van de knoppen beginnen te knipperen en klik vervolgens binnen 30 seconden op **OPSLAAN**.", + "title": "Link Nanoleaf" + }, + "user": { + "data": { + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nanoleaf/translations/zh-Hant.json b/homeassistant/components/nanoleaf/translations/zh-Hant.json new file mode 100644 index 00000000000..22de2698b39 --- /dev/null +++ b/homeassistant/components/nanoleaf/translations/zh-Hant.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "invalid_token": "\u5b58\u53d6\u6b0a\u6756\u7121\u6548", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "not_allowing_new_tokens": "Nanoleaf \u4e0d\u5141\u8a31\u65b0\u6b0a\u6756\uff0c\u8acb\u8ddf\u96a8\u4e0a\u65b9\u8aaa\u660e\u9032\u884c\u64cd\u4f5c\u3002", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "flow_title": "{name}", + "step": { + "link": { + "description": "\u6309\u4f4f Nanoleaf \u96fb\u6e90\u9375\u4e94\u79d2\u3001\u76f4\u5230 LED \u958b\u59cb\u9583\u720d\u5f8c\uff0c\u7136\u5f8c\u65bc 30 \u79d2\u5167\u9ede\u9078 **\u50b3\u9001**\u3002", + "title": "\u9023\u7d50 Nanoleaf" + }, + "user": { + "data": { + "host": "\u4e3b\u6a5f\u7aef" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/openuv/translations/et.json b/homeassistant/components/openuv/translations/et.json index b238b0a0964..89bfcd38318 100644 --- a/homeassistant/components/openuv/translations/et.json +++ b/homeassistant/components/openuv/translations/et.json @@ -17,5 +17,16 @@ "title": "Sisesta oma teave" } } + }, + "options": { + "step": { + "init": { + "data": { + "from_window": "Ohutu UV indeksi algv\u00e4\u00e4rtus", + "to_window": "Ohutu UV indeksi l\u00f5ppv\u00e4\u00e4rtus" + }, + "title": "OpenUV seadistamine" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/openuv/translations/it.json b/homeassistant/components/openuv/translations/it.json index ab7cfd39af9..241e118a800 100644 --- a/homeassistant/components/openuv/translations/it.json +++ b/homeassistant/components/openuv/translations/it.json @@ -17,5 +17,16 @@ "title": "Inserisci i tuoi dati" } } + }, + "options": { + "step": { + "init": { + "data": { + "from_window": "Indice UV iniziale per la finestra di protezione", + "to_window": "Indice UV finale per la finestra di protezione" + }, + "title": "Configura OpenUV" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/openuv/translations/zh-Hant.json b/homeassistant/components/openuv/translations/zh-Hant.json index f2b34ae8bf2..c8aeb8f4a55 100644 --- a/homeassistant/components/openuv/translations/zh-Hant.json +++ b/homeassistant/components/openuv/translations/zh-Hant.json @@ -17,5 +17,16 @@ "title": "\u586b\u5beb\u8cc7\u8a0a" } } + }, + "options": { + "step": { + "init": { + "data": { + "from_window": "\u958b\u59cb\u4fdd\u8b77\u7a97\u53e3\u4e4b\u7d2b\u5916\u7dda\u6307\u6578", + "to_window": "\u7d50\u675f\u4fdd\u8b77\u7a97\u53e3\u4e4b\u7d2b\u5916\u7dda\u6307\u6578" + }, + "title": "\u8a2d\u5b9a OpenUV" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/p1_monitor/translations/it.json b/homeassistant/components/p1_monitor/translations/it.json new file mode 100644 index 00000000000..25cac38aff6 --- /dev/null +++ b/homeassistant/components/p1_monitor/translations/it.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "Impossibile connettersi", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Nome" + }, + "description": "Configura P1 Monitor per l'integrazione con Home Assistant." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rainforest_eagle/translations/it.json b/homeassistant/components/rainforest_eagle/translations/it.json new file mode 100644 index 00000000000..f01839b59da --- /dev/null +++ b/homeassistant/components/rainforest_eagle/translations/it.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato" + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "invalid_auth": "Autenticazione non valida", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "cloud_id": "ID Cloud", + "host": "Host", + "install_code": "Codice di installazione" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/roomba/translations/it.json b/homeassistant/components/roomba/translations/it.json index d5909d5bcc5..4be1be53540 100644 --- a/homeassistant/components/roomba/translations/it.json +++ b/homeassistant/components/roomba/translations/it.json @@ -34,7 +34,7 @@ "blid": "BLID", "host": "Host" }, - "description": "Nessun Roomba o Braava sono stati rilevati all'interno della tua rete. Il BLID \u00e8 la porzione del nome host del dispositivo dopo `iRobot-` o `Roomba-`. Segui i passaggi descritti nella documentazione all'indirizzo: {auth_help_url}", + "description": "Nessun Roomba o Braava \u00e8 stato rilevato sulla rete.", "title": "Connettiti manualmente al dispositivo" }, "user": { diff --git a/homeassistant/components/sensor/translations/it.json b/homeassistant/components/sensor/translations/it.json index 7b9b483c024..cc5d3534715 100644 --- a/homeassistant/components/sensor/translations/it.json +++ b/homeassistant/components/sensor/translations/it.json @@ -23,6 +23,7 @@ "is_sulphur_dioxide": "Attuale livello di concentrazione di anidride solforosa di {entity_name}", "is_temperature": "Temperatura attuale di {entity_name}", "is_value": "Valore attuale di {entity_name}", + "is_volatile_organic_compounds": "Attuale livello di concentrazione di composti organici volatili di {entity_name}", "is_voltage": "Tensione attuale di {entity_name}" }, "trigger_type": { @@ -48,6 +49,7 @@ "sulphur_dioxide": "Variazioni della concentrazione di anidride solforosa di {entity_name}", "temperature": "variazioni di temperatura di {entity_name}", "value": "{entity_name} valori cambiati", + "volatile_organic_compounds": "Variazioni della concentrazione di composti organici volatili di {entity_name}", "voltage": "variazioni di tensione di {entity_name}" } }, diff --git a/homeassistant/components/yeelight/translations/it.json b/homeassistant/components/yeelight/translations/it.json index 1a139dcd8b4..4036b6d6338 100644 --- a/homeassistant/components/yeelight/translations/it.json +++ b/homeassistant/components/yeelight/translations/it.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "Impossibile connettersi" }, - "flow_title": "{model} {host}", + "flow_title": "{model} {id} ({host})", "step": { "discovery_confirm": { "description": "Vuoi configurare {model} ({host})?" diff --git a/homeassistant/components/zha/translations/it.json b/homeassistant/components/zha/translations/it.json index 4cdcdd654cc..2b58e486a2c 100644 --- a/homeassistant/components/zha/translations/it.json +++ b/homeassistant/components/zha/translations/it.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "not_zha_device": "Questo dispositivo non \u00e8 un dispositivo zha", "single_instance_allowed": "Gi\u00e0 configurato. \u00c8 possibile una sola configurazione." }, "error": { @@ -8,6 +9,9 @@ }, "flow_title": "{name}", "step": { + "confirm": { + "description": "Vuoi configurare {name}?" + }, "pick_radio": { "data": { "radio_type": "Tipo di radio" diff --git a/homeassistant/components/zwave_js/translations/it.json b/homeassistant/components/zwave_js/translations/it.json index 7c79cb304ef..165849e9387 100644 --- a/homeassistant/components/zwave_js/translations/it.json +++ b/homeassistant/components/zwave_js/translations/it.json @@ -8,7 +8,9 @@ "addon_start_failed": "Impossibile avviare il componente aggiuntivo Z-Wave JS.", "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", "already_in_progress": "Il flusso di configurazione \u00e8 gi\u00e0 in corso", - "cannot_connect": "Impossibile connettersi" + "cannot_connect": "Impossibile connettersi", + "discovery_requires_supervisor": "Il rilevamento richiede il Supervisor.", + "not_zwave_device": "Il dispositivo rilevato non \u00e8 un dispositivo Z-Wave." }, "error": { "addon_start_failed": "Impossibile avviare il componente aggiuntivo Z-Wave JS. Controlla la configurazione.", @@ -16,6 +18,7 @@ "invalid_ws_url": "URL websocket non valido", "unknown": "Errore imprevisto" }, + "flow_title": "{name}", "progress": { "install_addon": "Attendi il termine dell'installazione del componente aggiuntivo Z-Wave JS. Questa operazione pu\u00f2 richiedere diversi minuti.", "start_addon": "Attendi il completamento dell'avvio del componente aggiuntivo Z-Wave JS. L'operazione potrebbe richiedere alcuni secondi." @@ -48,6 +51,9 @@ }, "start_addon": { "title": "Il componente aggiuntivo Z-Wave JS si sta avviando." + }, + "usb_confirm": { + "description": "Vuoi configurare {name} con il componente aggiuntivo JS Z-Wave?" } } }, @@ -63,7 +69,9 @@ "event.value_notification.basic": "Evento CC di base su {subtype}", "event.value_notification.central_scene": "Azione della scena centrale su {subtype}", "event.value_notification.scene_activation": "Attivazione scena su {subtype}", - "state.node_status": "Lo stato del nodo \u00e8 cambiato" + "state.node_status": "Lo stato del nodo \u00e8 cambiato", + "zwave_js.value_updated.config_parameter": "Variazione sul parametro di configurazione {subtype}", + "zwave_js.value_updated.value": "Variazione su un valore Z-Wave JS" } }, "options": { From b45c985d58ac524496eff43c7ce01ed5ae41d68e Mon Sep 17 00:00:00 2001 From: Sean Vig Date: Thu, 26 Aug 2021 02:34:58 -0400 Subject: [PATCH 005/843] Remove un-needed asserts on hass in Amecrest (#55244) --- .../components/amcrest/binary_sensor.py | 2 -- homeassistant/components/amcrest/camera.py | 16 ---------------- homeassistant/components/amcrest/sensor.py | 1 - 3 files changed, 19 deletions(-) diff --git a/homeassistant/components/amcrest/binary_sensor.py b/homeassistant/components/amcrest/binary_sensor.py index 93e5b17d548..ebe19273b82 100644 --- a/homeassistant/components/amcrest/binary_sensor.py +++ b/homeassistant/components/amcrest/binary_sensor.py @@ -232,8 +232,6 @@ class AmcrestBinarySensor(BinarySensorEntity): async def async_added_to_hass(self) -> None: """Subscribe to signals.""" - assert self.hass is not None - self._unsub_dispatcher.append( async_dispatcher_connect( self.hass, diff --git a/homeassistant/components/amcrest/camera.py b/homeassistant/components/amcrest/camera.py index 772824864df..98dd15adcfb 100644 --- a/homeassistant/components/amcrest/camera.py +++ b/homeassistant/components/amcrest/camera.py @@ -180,7 +180,6 @@ class AmcrestCam(Camera): raise CannotSnapshot async def _async_get_image(self) -> None: - assert self.hass is not None try: # Send the request to snap a picture and return raw jpg data # Snapshot command needs a much longer read timeout than other commands. @@ -201,7 +200,6 @@ class AmcrestCam(Camera): self, width: int | None = None, height: int | None = None ) -> bytes | None: """Return a still image response from the camera.""" - assert self.hass is not None _LOGGER.debug("Take snapshot from %s", self._name) try: # Amcrest cameras only support one snapshot command at a time. @@ -226,7 +224,6 @@ class AmcrestCam(Camera): self, request: web.Request ) -> web.StreamResponse | None: """Return an MJPEG stream.""" - assert self.hass is not None # The snapshot implementation is handled by the parent class if self._stream_source == "snapshot": return await super().handle_async_mjpeg_stream(request) @@ -344,7 +341,6 @@ class AmcrestCam(Camera): async def async_added_to_hass(self) -> None: """Subscribe to signals and add camera to list.""" - assert self.hass is not None self._unsub_dispatcher.extend( async_dispatcher_connect( self.hass, @@ -364,7 +360,6 @@ class AmcrestCam(Camera): async def async_will_remove_from_hass(self) -> None: """Remove camera from list and disconnect from signals.""" - assert self.hass is not None self.hass.data[DATA_AMCREST][CAMERAS].remove(self.entity_id) for unsub_dispatcher in self._unsub_dispatcher: unsub_dispatcher() @@ -428,57 +423,46 @@ class AmcrestCam(Camera): async def async_enable_recording(self) -> None: """Call the job and enable recording.""" - assert self.hass is not None await self.hass.async_add_executor_job(self._enable_recording, True) async def async_disable_recording(self) -> None: """Call the job and disable recording.""" - assert self.hass is not None await self.hass.async_add_executor_job(self._enable_recording, False) async def async_enable_audio(self) -> None: """Call the job and enable audio.""" - assert self.hass is not None await self.hass.async_add_executor_job(self._enable_audio, True) async def async_disable_audio(self) -> None: """Call the job and disable audio.""" - assert self.hass is not None await self.hass.async_add_executor_job(self._enable_audio, False) async def async_enable_motion_recording(self) -> None: """Call the job and enable motion recording.""" - assert self.hass is not None await self.hass.async_add_executor_job(self._enable_motion_recording, True) async def async_disable_motion_recording(self) -> None: """Call the job and disable motion recording.""" - assert self.hass is not None await self.hass.async_add_executor_job(self._enable_motion_recording, False) async def async_goto_preset(self, preset: int) -> None: """Call the job and move camera to preset position.""" - assert self.hass is not None await self.hass.async_add_executor_job(self._goto_preset, preset) async def async_set_color_bw(self, color_bw: str) -> None: """Call the job and set camera color mode.""" - assert self.hass is not None await self.hass.async_add_executor_job(self._set_color_bw, color_bw) async def async_start_tour(self) -> None: """Call the job and start camera tour.""" - assert self.hass is not None await self.hass.async_add_executor_job(self._start_tour, True) async def async_stop_tour(self) -> None: """Call the job and stop camera tour.""" - assert self.hass is not None await self.hass.async_add_executor_job(self._start_tour, False) async def async_ptz_control(self, movement: str, travel_time: float) -> None: """Move or zoom camera in specified direction.""" - assert self.hass is not None code = _ACTION[_MOV.index(movement)] kwargs = {"code": code, "arg1": 0, "arg2": 0, "arg3": 0} diff --git a/homeassistant/components/amcrest/sensor.py b/homeassistant/components/amcrest/sensor.py index b916757f44a..e6040cfa728 100644 --- a/homeassistant/components/amcrest/sensor.py +++ b/homeassistant/components/amcrest/sensor.py @@ -129,7 +129,6 @@ class AmcrestSensor(SensorEntity): async def async_added_to_hass(self) -> None: """Subscribe to update signal.""" - assert self.hass is not None self._unsub_dispatcher = async_dispatcher_connect( self.hass, service_signal(SERVICE_UPDATE, self._signal_name), From e6d710c203e1c52bd788bd355ddf12ff3cd3d227 Mon Sep 17 00:00:00 2001 From: Milan Meulemans Date: Thu, 26 Aug 2021 08:37:27 +0200 Subject: [PATCH 006/843] Remove option and range checks in Rituals integration (#55222) * Fix number * Fix select --- homeassistant/components/rituals_perfume_genie/number.py | 8 +++----- homeassistant/components/rituals_perfume_genie/select.py | 7 +------ 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/rituals_perfume_genie/number.py b/homeassistant/components/rituals_perfume_genie/number.py index 26ae393b071..1bcadf9aa88 100644 --- a/homeassistant/components/rituals_perfume_genie/number.py +++ b/homeassistant/components/rituals_perfume_genie/number.py @@ -54,10 +54,8 @@ class DiffuserPerfumeAmount(DiffuserEntity, NumberEntity): async def async_set_value(self, value: float) -> None: """Set the perfume amount.""" - if value.is_integer() and MIN_PERFUME_AMOUNT <= value <= MAX_PERFUME_AMOUNT: - await self._diffuser.set_perfume_amount(int(value)) - else: + if not value.is_integer(): raise ValueError( - f"Can't set the perfume amount to {value}. " - f"Perfume amount must be an integer between {self.min_value} and {self.max_value}, inclusive" + f"Can't set the perfume amount to {value}. Perfume amount must be an integer." ) + await self._diffuser.set_perfume_amount(int(value)) diff --git a/homeassistant/components/rituals_perfume_genie/select.py b/homeassistant/components/rituals_perfume_genie/select.py index ac6f4aa872a..eac95ee5ed4 100644 --- a/homeassistant/components/rituals_perfume_genie/select.py +++ b/homeassistant/components/rituals_perfume_genie/select.py @@ -51,9 +51,4 @@ class DiffuserRoomSize(DiffuserEntity, SelectEntity): async def async_select_option(self, option: str) -> None: """Change the diffuser room size.""" - if option in self.options: - await self._diffuser.set_room_size_square_meter(int(option)) - else: - raise ValueError( - f"Can't set the room size to {option}. Allowed room sizes are: {self.options}" - ) + await self._diffuser.set_room_size_square_meter(int(option)) From 6d4a47a53de32aee990e96aa305c2b8c02259463 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Aug 2021 10:06:53 +0200 Subject: [PATCH 007/843] Fix double precision float for postgresql (#55249) --- homeassistant/components/recorder/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py index 017c65cd75f..8b5aef88738 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -70,7 +70,7 @@ DOUBLE_TYPE = ( Float() .with_variant(mysql.DOUBLE(asdecimal=False), "mysql") .with_variant(oracle.DOUBLE_PRECISION(), "oracle") - .with_variant(postgresql.DOUBLE_PRECISION, "postgresql") + .with_variant(postgresql.DOUBLE_PRECISION(), "postgresql") ) From 56246056cec1dcb1af35b9687c14bfe8814cece9 Mon Sep 17 00:00:00 2001 From: Eric Severance Date: Thu, 26 Aug 2021 01:27:06 -0700 Subject: [PATCH 008/843] Be tolerant of Wemo insight_param keys that might not exist (#55232) --- homeassistant/components/wemo/sensor.py | 6 ++++-- homeassistant/components/wemo/switch.py | 16 +++++++++------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/wemo/sensor.py b/homeassistant/components/wemo/sensor.py index d1a15ecec3a..654ac92df56 100644 --- a/homeassistant/components/wemo/sensor.py +++ b/homeassistant/components/wemo/sensor.py @@ -78,7 +78,9 @@ class InsightCurrentPower(InsightSensor): def native_value(self) -> StateType: """Return the current power consumption.""" return ( - convert(self.wemo.insight_params[self.entity_description.key], float, 0.0) + convert( + self.wemo.insight_params.get(self.entity_description.key), float, 0.0 + ) / 1000.0 ) @@ -98,6 +100,6 @@ class InsightTodayEnergy(InsightSensor): def native_value(self) -> StateType: """Return the current energy use today.""" miliwatts = convert( - self.wemo.insight_params[self.entity_description.key], float, 0.0 + self.wemo.insight_params.get(self.entity_description.key), float, 0.0 ) return round(miliwatts / (1000.0 * 1000.0 * 60), 2) diff --git a/homeassistant/components/wemo/switch.py b/homeassistant/components/wemo/switch.py index 46e143902f9..a9ee2579c47 100644 --- a/homeassistant/components/wemo/switch.py +++ b/homeassistant/components/wemo/switch.py @@ -76,16 +76,17 @@ class WemoSwitch(WemoEntity, SwitchEntity): if isinstance(self.wemo, Insight): attr["on_latest_time"] = WemoSwitch.as_uptime( - self.wemo.insight_params["onfor"] + self.wemo.insight_params.get("onfor", 0) ) attr["on_today_time"] = WemoSwitch.as_uptime( - self.wemo.insight_params["ontoday"] + self.wemo.insight_params.get("ontoday", 0) ) attr["on_total_time"] = WemoSwitch.as_uptime( - self.wemo.insight_params["ontotal"] + self.wemo.insight_params.get("ontotal", 0) ) attr["power_threshold_w"] = ( - convert(self.wemo.insight_params["powerthreshold"], float, 0.0) / 1000.0 + convert(self.wemo.insight_params.get("powerthreshold"), float, 0.0) + / 1000.0 ) if isinstance(self.wemo, CoffeeMaker): @@ -106,14 +107,15 @@ class WemoSwitch(WemoEntity, SwitchEntity): """Return the current power usage in W.""" if isinstance(self.wemo, Insight): return ( - convert(self.wemo.insight_params["currentpower"], float, 0.0) / 1000.0 + convert(self.wemo.insight_params.get("currentpower"), float, 0.0) + / 1000.0 ) @property def today_energy_kwh(self): """Return the today total energy usage in kWh.""" if isinstance(self.wemo, Insight): - miliwatts = convert(self.wemo.insight_params["todaymw"], float, 0.0) + miliwatts = convert(self.wemo.insight_params.get("todaymw"), float, 0.0) return round(miliwatts / (1000.0 * 1000.0 * 60), 2) @property @@ -122,7 +124,7 @@ class WemoSwitch(WemoEntity, SwitchEntity): if isinstance(self.wemo, CoffeeMaker): return self.wemo.mode_string if isinstance(self.wemo, Insight): - standby_state = int(self.wemo.insight_params["state"]) + standby_state = int(self.wemo.insight_params.get("state", 0)) if standby_state == WEMO_ON: return STATE_ON if standby_state == WEMO_OFF: From 03d3bbfba13fb7b7d8cf66c84c14d0b41ce1bcfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Thu, 26 Aug 2021 10:54:42 +0200 Subject: [PATCH 009/843] Only postfix image name for container (#55248) --- homeassistant/components/version/sensor.py | 2 +- tests/components/version/test_sensor.py | 30 ++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/version/sensor.py b/homeassistant/components/version/sensor.py index 1cd42cce9b3..925e9111c1a 100644 --- a/homeassistant/components/version/sensor.py +++ b/homeassistant/components/version/sensor.py @@ -87,7 +87,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= source = HaVersionSource.CONTAINER if ( - source in (HaVersionSource.SUPERVISOR, HaVersionSource.CONTAINER) + source == HaVersionSource.CONTAINER and image is not None and image != DEFAULT_IMAGE ): diff --git a/tests/components/version/test_sensor.py b/tests/components/version/test_sensor.py index c8883e72389..cd56223a1e6 100644 --- a/tests/components/version/test_sensor.py +++ b/tests/components/version/test_sensor.py @@ -98,3 +98,33 @@ async def test_update(hass): state = hass.states.get("sensor.current_version") assert state assert state.state == "1234" + + +async def test_image_name_container(hass): + """Test the Version sensor with image name for container.""" + config = { + "sensor": {"platform": "version", "source": "docker", "image": "qemux86-64"} + } + + with patch("homeassistant.components.version.sensor.HaVersion") as haversion: + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + + constructor = haversion.call_args[1] + assert constructor["source"] == "container" + assert constructor["image"] == "qemux86-64-homeassistant" + + +async def test_image_name_supervisor(hass): + """Test the Version sensor with image name for supervisor.""" + config = { + "sensor": {"platform": "version", "source": "hassio", "image": "qemux86-64"} + } + + with patch("homeassistant.components.version.sensor.HaVersion") as haversion: + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + + constructor = haversion.call_args[1] + assert constructor["source"] == "supervisor" + assert constructor["image"] == "qemux86-64" From 96303a1d80373818ee0cb0bf153ddba2ff30d1ae Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 26 Aug 2021 11:14:42 +0200 Subject: [PATCH 010/843] Fix MQTT add-on discovery to be ignorable (#55250) --- homeassistant/components/mqtt/config_flow.py | 3 +-- homeassistant/components/mqtt/strings.json | 1 + .../components/mqtt/translations/en.json | 1 + tests/components/mqtt/test_config_flow.py | 21 +++++++++++++++++-- 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index c6af0cc08b5..172657ded98 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -95,8 +95,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_hassio(self, discovery_info): """Receive a Hass.io discovery.""" - if self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") + await self._async_handle_discovery_without_unique_id() self._hassio_discovery = discovery_info diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 9de9075f19d..155f9fcb4f2 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -20,6 +20,7 @@ } }, "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" }, "error": { diff --git a/homeassistant/components/mqtt/translations/en.json b/homeassistant/components/mqtt/translations/en.json index 775b4d21c9b..23012946a71 100644 --- a/homeassistant/components/mqtt/translations/en.json +++ b/homeassistant/components/mqtt/translations/en.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "Service is already configured", "single_instance_allowed": "Already configured. Only a single configuration possible." }, "error": { diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 55bacb0ef91..e00e959e606 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -7,6 +7,7 @@ import voluptuous as vol from homeassistant import config_entries, data_entry_flow from homeassistant.components import mqtt +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -100,7 +101,7 @@ async def test_user_single_instance(hass): assert result["reason"] == "single_instance_allowed" -async def test_hassio_single_instance(hass): +async def test_hassio_already_configured(hass): """Test we only allow a single config flow.""" MockConfigEntry(domain="mqtt").add_to_hass(hass) @@ -108,7 +109,23 @@ async def test_hassio_single_instance(hass): "mqtt", context={"source": config_entries.SOURCE_HASSIO} ) assert result["type"] == "abort" - assert result["reason"] == "single_instance_allowed" + assert result["reason"] == "already_configured" + + +async def test_hassio_ignored(hass: HomeAssistant) -> None: + """Test we supervisor discovered instance can be ignored.""" + MockConfigEntry( + domain=mqtt.DOMAIN, source=config_entries.SOURCE_IGNORE + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + mqtt.DOMAIN, + data={"addon": "Mosquitto", "host": "mock-mosquitto", "port": "1883"}, + context={"source": config_entries.SOURCE_HASSIO}, + ) + assert result + assert result.get("type") == data_entry_flow.RESULT_TYPE_ABORT + assert result.get("reason") == "already_configured" async def test_hassio_confirm(hass, mock_try_connection, mock_finish_setup): From 0a07ff4d23b04c083238658e61be5272421d266b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Aug 2021 14:27:14 +0200 Subject: [PATCH 011/843] Warn if a sensor with state_class_total has a decreasing value twice (#55251) --- homeassistant/components/sensor/recorder.py | 13 ++++++++++++- tests/components/sensor/test_recorder.py | 20 +++++++++++++------- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 2115cca2892..bcb21136007 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -108,6 +108,7 @@ UNIT_CONVERSIONS: dict[str, dict[str, Callable]] = { } # Keep track of entities for which a warning about decreasing value has been logged +SEEN_DIP = "sensor_seen_total_increasing_dip" WARN_DIP = "sensor_warn_total_increasing_dip" # Keep track of entities for which a warning about unsupported unit has been logged WARN_UNSUPPORTED_UNIT = "sensor_warn_unsupported_unit" @@ -233,7 +234,17 @@ def _normalize_states( def warn_dip(hass: HomeAssistant, entity_id: str) -> None: - """Log a warning once if a sensor with state_class_total has a decreasing value.""" + """Log a warning once if a sensor with state_class_total has a decreasing value. + + The log will be suppressed until two dips have been seen to prevent warning due to + rounding issues with databases storing the state as a single precision float, which + was fixed in recorder DB version 20. + """ + if SEEN_DIP not in hass.data: + hass.data[SEEN_DIP] = set() + if entity_id not in hass.data[SEEN_DIP]: + hass.data[SEEN_DIP].add(entity_id) + return if WARN_DIP not in hass.data: hass.data[WARN_DIP] = set() if entity_id not in hass.data[WARN_DIP]: diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 0234a8c0613..c7f356e49ee 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -371,7 +371,7 @@ def test_compile_hourly_sum_statistics_total_increasing_small_dip( "state_class": "total_increasing", "unit_of_measurement": unit, } - seq = [10, 15, 20, 19, 30, 40, 50, 60, 70] + seq = [10, 15, 20, 19, 30, 40, 39, 60, 70] four, eight, states = record_meter_states( hass, zero, "sensor.test1", attributes, seq @@ -385,8 +385,20 @@ def test_compile_hourly_sum_statistics_total_increasing_small_dip( wait_recording_done(hass) recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=1)) wait_recording_done(hass) + assert ( + "Entity sensor.test1 has state class total_increasing, but its state is not " + "strictly increasing. Please create a bug report at https://github.com/" + "home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A" + "+recorder%22" + ) not in caplog.text recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=2)) wait_recording_done(hass) + assert ( + "Entity sensor.test1 has state class total_increasing, but its state is not " + "strictly increasing. Please create a bug report at https://github.com/" + "home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A" + "+recorder%22" + ) in caplog.text statistic_ids = list_statistic_ids(hass) assert statistic_ids == [ {"statistic_id": "sensor.test1", "unit_of_measurement": native_unit} @@ -427,12 +439,6 @@ def test_compile_hourly_sum_statistics_total_increasing_small_dip( ] } assert "Error while processing event StatisticsTask" not in caplog.text - assert ( - "Entity sensor.test1 has state class total_increasing, but its state is not " - "strictly increasing. Please create a bug report at https://github.com/" - "home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A" - "+recorder%22" - ) in caplog.text def test_compile_hourly_energy_statistics_unsupported(hass_recorder, caplog): From 2d5176eee9b2a6eb370a5797500639d9cfa7c044 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Thu, 26 Aug 2021 15:23:00 +0200 Subject: [PATCH 012/843] Change entity_timers to be a local variable. (#55258) Ensure outstanding pymodbus calls are handled before closing. --- homeassistant/components/modbus/modbus.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index c2cae9f4ec3..92609f5e891 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -189,13 +189,12 @@ class ModbusHub: name: str - entity_timers: list[CALLBACK_TYPE] = [] - def __init__(self, hass, client_config): """Initialize the Modbus hub.""" # generic configuration self._client = None + self.entity_timers: list[CALLBACK_TYPE] = [] self._async_cancel_listener = None self._in_error = False self._lock = asyncio.Lock() @@ -294,11 +293,12 @@ class ModbusHub: call() self.entity_timers = [] if self._client: - try: - self._client.close() - except ModbusException as exception_error: - self._log_error(str(exception_error)) - self._client = None + async with self._lock: + try: + self._client.close() + except ModbusException as exception_error: + self._log_error(str(exception_error)) + self._client = None def _pymodbus_connect(self): """Connect client.""" From a89057ece5e268ca7dd4d2c08296145b46cbf697 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Aug 2021 08:59:02 -0500 Subject: [PATCH 013/843] Limit USB discovery to specific manufacturer/description/serial_number matches (#55236) * Limit USB discovery to specific manufacturer/description/serial_number matches * test for None case --- homeassistant/components/usb/__init__.py | 20 ++ homeassistant/components/zha/manifest.json | 7 +- homeassistant/generated/usb.py | 14 +- script/hassfest/manifest.py | 3 + tests/components/usb/test_init.py | 293 ++++++++++++++++++++- 5 files changed, 324 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index 3aaccc15a64..d02c01ad03d 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations import dataclasses +import fnmatch import logging import os import sys @@ -72,6 +73,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True +def _fnmatch_lower(name: str | None, pattern: str) -> bool: + """Match a lowercase version of the name.""" + if name is None: + return False + return fnmatch.fnmatch(name.lower(), pattern) + + class USBDiscovery: """Manage USB Discovery.""" @@ -152,6 +160,18 @@ class USBDiscovery: continue if "pid" in matcher and device.pid != matcher["pid"]: continue + if "serial_number" in matcher and not _fnmatch_lower( + device.serial_number, matcher["serial_number"] + ): + continue + if "manufacturer" in matcher and not _fnmatch_lower( + device.manufacturer, matcher["manufacturer"] + ): + continue + if "description" in matcher and not _fnmatch_lower( + device.description, matcher["description"] + ): + continue flow: USBFlow = { "domain": matcher["domain"], "context": {"source": config_entries.SOURCE_USB}, diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 93d9816d339..2c1d625b7fe 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -16,10 +16,9 @@ "zigpy-znp==0.5.3" ], "usb": [ - {"vid":"10C4","pid":"EA60","known_devices":["slae.sh cc2652rb stick"]}, - {"vid":"1CF1","pid":"0030","known_devices":["Conbee II"]}, - {"vid":"1A86","pid":"7523","known_devices":["Electrolama zig-a-zig-ah"]}, - {"vid":"10C4","pid":"8A2A","known_devices":["Nortek HUSBZB-1"]} + {"vid":"10C4","pid":"EA60","description":"*2652*","known_devices":["slae.sh cc2652rb stick"]}, + {"vid":"1CF1","pid":"0030","description":"*conbee*","known_devices":["Conbee II"]}, + {"vid":"10C4","pid":"8A2A","description":"*zigbee*","known_devices":["Nortek HUSBZB-1"]} ], "codeowners": ["@dmulcahey", "@adminiuga"], "zeroconf": [ diff --git a/homeassistant/generated/usb.py b/homeassistant/generated/usb.py index cb672c736b2..477a762ae62 100644 --- a/homeassistant/generated/usb.py +++ b/homeassistant/generated/usb.py @@ -9,22 +9,20 @@ USB = [ { "domain": "zha", "vid": "10C4", - "pid": "EA60" + "pid": "EA60", + "description": "*2652*" }, { "domain": "zha", "vid": "1CF1", - "pid": "0030" - }, - { - "domain": "zha", - "vid": "1A86", - "pid": "7523" + "pid": "0030", + "description": "*conbee*" }, { "domain": "zha", "vid": "10C4", - "pid": "8A2A" + "pid": "8A2A", + "description": "*zigbee*" }, { "domain": "zwave_js", diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index 8c9776ed7c9..abade24dbf9 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -210,6 +210,9 @@ MANIFEST_SCHEMA = vol.Schema( { vol.Optional("vid"): vol.All(str, verify_uppercase), vol.Optional("pid"): vol.All(str, verify_uppercase), + vol.Optional("serial_number"): vol.All(str, verify_lowercase), + vol.Optional("manufacturer"): vol.All(str, verify_lowercase), + vol.Optional("description"): vol.All(str, verify_lowercase), vol.Optional("known_devices"): [str], } ) diff --git a/tests/components/usb/test_init.py b/tests/components/usb/test_init.py index 9c480f11fc6..e22e514f230 100644 --- a/tests/components/usb/test_init.py +++ b/tests/components/usb/test_init.py @@ -9,7 +9,7 @@ from homeassistant.components import usb from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.setup import async_setup_component -from . import slae_sh_device +from . import conbee_device, slae_sh_device @pytest.fixture(name="operating_system") @@ -171,6 +171,297 @@ async def test_discovered_by_websocket_scan(hass, hass_ws_client): assert mock_config_flow.mock_calls[0][1][0] == "test1" +async def test_discovered_by_websocket_scan_limited_by_description_matcher( + hass, hass_ws_client +): + """Test a device is discovered from websocket scan is limited by the description matcher.""" + new_usb = [ + {"domain": "test1", "vid": "3039", "pid": "3039", "description": "*2652*"} + ] + + mock_comports = [ + MagicMock( + device=slae_sh_device.device, + vid=12345, + pid=12345, + serial_number=slae_sh_device.serial_number, + manufacturer=slae_sh_device.manufacturer, + description=slae_sh_device.description, + ) + ] + + with patch("pyudev.Context", side_effect=ImportError), patch( + "homeassistant.components.usb.async_get_usb", return_value=new_usb + ), patch( + "homeassistant.components.usb.comports", return_value=mock_comports + ), patch.object( + hass.config_entries.flow, "async_init" + ) as mock_config_flow: + assert await async_setup_component(hass, "usb", {"usb": {}}) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + ws_client = await hass_ws_client(hass) + await ws_client.send_json({"id": 1, "type": "usb/scan"}) + response = await ws_client.receive_json() + assert response["success"] + await hass.async_block_till_done() + + assert len(mock_config_flow.mock_calls) == 1 + assert mock_config_flow.mock_calls[0][1][0] == "test1" + + +async def test_discovered_by_websocket_scan_rejected_by_description_matcher( + hass, hass_ws_client +): + """Test a device is discovered from websocket scan rejected by the description matcher.""" + new_usb = [ + {"domain": "test1", "vid": "3039", "pid": "3039", "description": "*not_it*"} + ] + + mock_comports = [ + MagicMock( + device=slae_sh_device.device, + vid=12345, + pid=12345, + serial_number=slae_sh_device.serial_number, + manufacturer=slae_sh_device.manufacturer, + description=slae_sh_device.description, + ) + ] + + with patch("pyudev.Context", side_effect=ImportError), patch( + "homeassistant.components.usb.async_get_usb", return_value=new_usb + ), patch( + "homeassistant.components.usb.comports", return_value=mock_comports + ), patch.object( + hass.config_entries.flow, "async_init" + ) as mock_config_flow: + assert await async_setup_component(hass, "usb", {"usb": {}}) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + ws_client = await hass_ws_client(hass) + await ws_client.send_json({"id": 1, "type": "usb/scan"}) + response = await ws_client.receive_json() + assert response["success"] + await hass.async_block_till_done() + + assert len(mock_config_flow.mock_calls) == 0 + + +async def test_discovered_by_websocket_scan_limited_by_serial_number_matcher( + hass, hass_ws_client +): + """Test a device is discovered from websocket scan is limited by the serial_number matcher.""" + new_usb = [ + { + "domain": "test1", + "vid": "3039", + "pid": "3039", + "serial_number": "00_12_4b_00*", + } + ] + + mock_comports = [ + MagicMock( + device=slae_sh_device.device, + vid=12345, + pid=12345, + serial_number=slae_sh_device.serial_number, + manufacturer=slae_sh_device.manufacturer, + description=slae_sh_device.description, + ) + ] + + with patch("pyudev.Context", side_effect=ImportError), patch( + "homeassistant.components.usb.async_get_usb", return_value=new_usb + ), patch( + "homeassistant.components.usb.comports", return_value=mock_comports + ), patch.object( + hass.config_entries.flow, "async_init" + ) as mock_config_flow: + assert await async_setup_component(hass, "usb", {"usb": {}}) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + ws_client = await hass_ws_client(hass) + await ws_client.send_json({"id": 1, "type": "usb/scan"}) + response = await ws_client.receive_json() + assert response["success"] + await hass.async_block_till_done() + + assert len(mock_config_flow.mock_calls) == 1 + assert mock_config_flow.mock_calls[0][1][0] == "test1" + + +async def test_discovered_by_websocket_scan_rejected_by_serial_number_matcher( + hass, hass_ws_client +): + """Test a device is discovered from websocket scan is rejected by the serial_number matcher.""" + new_usb = [ + {"domain": "test1", "vid": "3039", "pid": "3039", "serial_number": "123*"} + ] + + mock_comports = [ + MagicMock( + device=slae_sh_device.device, + vid=12345, + pid=12345, + serial_number=slae_sh_device.serial_number, + manufacturer=slae_sh_device.manufacturer, + description=slae_sh_device.description, + ) + ] + + with patch("pyudev.Context", side_effect=ImportError), patch( + "homeassistant.components.usb.async_get_usb", return_value=new_usb + ), patch( + "homeassistant.components.usb.comports", return_value=mock_comports + ), patch.object( + hass.config_entries.flow, "async_init" + ) as mock_config_flow: + assert await async_setup_component(hass, "usb", {"usb": {}}) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + ws_client = await hass_ws_client(hass) + await ws_client.send_json({"id": 1, "type": "usb/scan"}) + response = await ws_client.receive_json() + assert response["success"] + await hass.async_block_till_done() + + assert len(mock_config_flow.mock_calls) == 0 + + +async def test_discovered_by_websocket_scan_limited_by_manufacturer_matcher( + hass, hass_ws_client +): + """Test a device is discovered from websocket scan is limited by the manufacturer matcher.""" + new_usb = [ + { + "domain": "test1", + "vid": "3039", + "pid": "3039", + "manufacturer": "dresden elektronik ingenieurtechnik*", + } + ] + + mock_comports = [ + MagicMock( + device=conbee_device.device, + vid=12345, + pid=12345, + serial_number=conbee_device.serial_number, + manufacturer=conbee_device.manufacturer, + description=conbee_device.description, + ) + ] + + with patch("pyudev.Context", side_effect=ImportError), patch( + "homeassistant.components.usb.async_get_usb", return_value=new_usb + ), patch( + "homeassistant.components.usb.comports", return_value=mock_comports + ), patch.object( + hass.config_entries.flow, "async_init" + ) as mock_config_flow: + assert await async_setup_component(hass, "usb", {"usb": {}}) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + ws_client = await hass_ws_client(hass) + await ws_client.send_json({"id": 1, "type": "usb/scan"}) + response = await ws_client.receive_json() + assert response["success"] + await hass.async_block_till_done() + + assert len(mock_config_flow.mock_calls) == 1 + assert mock_config_flow.mock_calls[0][1][0] == "test1" + + +async def test_discovered_by_websocket_scan_rejected_by_manufacturer_matcher( + hass, hass_ws_client +): + """Test a device is discovered from websocket scan is rejected by the manufacturer matcher.""" + new_usb = [ + { + "domain": "test1", + "vid": "3039", + "pid": "3039", + "manufacturer": "other vendor*", + } + ] + + mock_comports = [ + MagicMock( + device=conbee_device.device, + vid=12345, + pid=12345, + serial_number=conbee_device.serial_number, + manufacturer=conbee_device.manufacturer, + description=conbee_device.description, + ) + ] + + with patch("pyudev.Context", side_effect=ImportError), patch( + "homeassistant.components.usb.async_get_usb", return_value=new_usb + ), patch( + "homeassistant.components.usb.comports", return_value=mock_comports + ), patch.object( + hass.config_entries.flow, "async_init" + ) as mock_config_flow: + assert await async_setup_component(hass, "usb", {"usb": {}}) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + ws_client = await hass_ws_client(hass) + await ws_client.send_json({"id": 1, "type": "usb/scan"}) + response = await ws_client.receive_json() + assert response["success"] + await hass.async_block_till_done() + + assert len(mock_config_flow.mock_calls) == 0 + + +async def test_discovered_by_websocket_rejected_with_empty_serial_number_only( + hass, hass_ws_client +): + """Test a device is discovered from websocket is rejected with empty serial number.""" + new_usb = [ + {"domain": "test1", "vid": "3039", "pid": "3039", "serial_number": "123*"} + ] + + mock_comports = [ + MagicMock( + device=conbee_device.device, + vid=12345, + pid=12345, + serial_number=None, + manufacturer=None, + description=None, + ) + ] + + with patch("pyudev.Context", side_effect=ImportError), patch( + "homeassistant.components.usb.async_get_usb", return_value=new_usb + ), patch( + "homeassistant.components.usb.comports", return_value=mock_comports + ), patch.object( + hass.config_entries.flow, "async_init" + ) as mock_config_flow: + assert await async_setup_component(hass, "usb", {"usb": {}}) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + ws_client = await hass_ws_client(hass) + await ws_client.send_json({"id": 1, "type": "usb/scan"}) + response = await ws_client.receive_json() + assert response["success"] + await hass.async_block_till_done() + + assert len(mock_config_flow.mock_calls) == 0 + + async def test_discovered_by_websocket_scan_match_vid_only(hass, hass_ws_client): """Test a device is discovered from websocket scan only matching vid.""" new_usb = [{"domain": "test1", "vid": "3039"}] From d4fa625a7f0d42d5a38fc8c4888104d8c91733d0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Aug 2021 08:59:41 -0500 Subject: [PATCH 014/843] Defer zha auto configure probe until after clicking configure (#55239) --- homeassistant/components/zha/config_flow.py | 18 ++++++------- homeassistant/components/zha/strings.json | 3 ++- tests/components/zha/test_config_flow.py | 28 +++++++-------------- 3 files changed, 18 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 61898328d2e..772362b3850 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -36,7 +36,6 @@ class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Initialize flow instance.""" self._device_path = None self._radio_type = None - self._auto_detected_data = None self._title = None async def async_step_user(self, user_input=None): @@ -124,15 +123,6 @@ class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if flow["handler"] == "deconz": return self.async_abort(reason="not_zha_device") - # The Nortek sticks are a special case since they - # have a Z-Wave and a Zigbee radio. We need to reject - # the Z-Wave radio. - if vid == "10C4" and pid == "8A2A" and "ZigBee" not in description: - return self.async_abort(reason="not_zha_device") - - self._auto_detected_data = await detect_radios(dev_path) - if self._auto_detected_data is None: - return self.async_abort(reason="not_zha_device") self._device_path = dev_path self._title = usb.human_readable_device_name( dev_path, @@ -149,9 +139,15 @@ class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_confirm(self, user_input=None): """Confirm a discovery.""" if user_input is not None: + auto_detected_data = await detect_radios(self._device_path) + if auto_detected_data is None: + # This probably will not happen how they have + # have very specific usb matching, but there could + # be a problem with the device + return self.async_abort(reason="usb_probe_failed") return self.async_create_entry( title=self._title, - data=self._auto_detected_data, + data=auto_detected_data, ) return self.async_show_form( diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 4b5b429522f..5953df52e92 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -30,7 +30,8 @@ }, "abort": { "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", - "not_zha_device": "This device is not a zha device" + "not_zha_device": "This device is not a zha device", + "usb_probe_failed": "Failed to probe the usb device" } }, "config_panel": { diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index 9f7e3baeaf1..281a0683eb8 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -164,27 +164,17 @@ async def test_discovery_via_usb_no_radio(detect_mock, hass): "zha", context={"source": SOURCE_USB}, data=discovery_info ) await hass.async_block_till_done() - assert result["type"] == RESULT_TYPE_ABORT - assert result["reason"] == "not_zha_device" + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "confirm" + with patch("homeassistant.components.zha.async_setup_entry"): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + await hass.async_block_till_done() -@patch("zigpy_znp.zigbee.application.ControllerApplication.probe", return_value=True) -async def test_discovery_via_usb_rejects_nortek_zwave(detect_mock, hass): - """Test usb flow -- reject the nortek zwave radio.""" - discovery_info = { - "device": "/dev/null", - "vid": "10C4", - "pid": "8A2A", - "serial_number": "612020FD", - "description": "HubZ Smart Home Controller - HubZ Z-Wave Com Port", - "manufacturer": "Silicon Labs", - } - result = await hass.config_entries.flow.async_init( - "zha", context={"source": SOURCE_USB}, data=discovery_info - ) - await hass.async_block_till_done() - assert result["type"] == RESULT_TYPE_ABORT - assert result["reason"] == "not_zha_device" + assert result2["type"] == RESULT_TYPE_ABORT + assert result2["reason"] == "usb_probe_failed" @patch("zigpy_znp.zigbee.application.ControllerApplication.probe", return_value=True) From d59ea5329e3a93f9a65f61415b47d5218b7250aa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Aug 2021 09:00:32 -0500 Subject: [PATCH 015/843] Abort zha usb discovery if deconz is setup (#55245) * Abort zha usb discovery if deconz is setup * Update tests/components/zha/test_config_flow.py * add deconz domain const * Update homeassistant/components/zha/config_flow.py Co-authored-by: Robert Svensson Co-authored-by: Robert Svensson --- homeassistant/components/zha/config_flow.py | 6 ++- tests/components/zha/test_config_flow.py | 48 ++++++++++++++++++++- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 772362b3850..4bf255e95a0 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -25,6 +25,7 @@ SUPPORTED_PORT_SETTINGS = ( CONF_BAUDRATE, CONF_FLOWCONTROL, ) +DECONZ_DOMAIN = "deconz" class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @@ -120,7 +121,10 @@ class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): # we ignore the usb discovery as they probably # want to use it there instead for flow in self.hass.config_entries.flow.async_progress(): - if flow["handler"] == "deconz": + if flow["handler"] == DECONZ_DOMAIN: + return self.async_abort(reason="not_zha_device") + for entry in self.hass.config_entries.async_entries(DECONZ_DOMAIN): + if entry.source != config_entries.SOURCE_IGNORE: return self.async_abort(reason="not_zha_device") self._device_path = dev_path diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index 281a0683eb8..732b7cf440d 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -7,7 +7,7 @@ import serial.tools.list_ports import zigpy.config from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH -from homeassistant import setup +from homeassistant import config_entries, setup from homeassistant.components.ssdp import ( ATTR_SSDP_LOCATION, ATTR_UPNP_MANUFACTURER_URL, @@ -271,6 +271,52 @@ async def test_discovery_via_usb_deconz_already_discovered(detect_mock, hass): assert result["reason"] == "not_zha_device" +@patch("zigpy_znp.zigbee.application.ControllerApplication.probe", return_value=True) +async def test_discovery_via_usb_deconz_already_setup(detect_mock, hass): + """Test usb flow -- deconz setup.""" + MockConfigEntry(domain="deconz", data={}).add_to_hass(hass) + await hass.async_block_till_done() + discovery_info = { + "device": "/dev/ttyZIGBEE", + "pid": "AAAA", + "vid": "AAAA", + "serial_number": "1234", + "description": "zigbee radio", + "manufacturer": "test", + } + result = await hass.config_entries.flow.async_init( + "zha", context={"source": SOURCE_USB}, data=discovery_info + ) + await hass.async_block_till_done() + + assert result["type"] == "abort" + assert result["reason"] == "not_zha_device" + + +@patch("zigpy_znp.zigbee.application.ControllerApplication.probe", return_value=True) +async def test_discovery_via_usb_deconz_ignored(detect_mock, hass): + """Test usb flow -- deconz ignored.""" + MockConfigEntry( + domain="deconz", source=config_entries.SOURCE_IGNORE, data={} + ).add_to_hass(hass) + await hass.async_block_till_done() + discovery_info = { + "device": "/dev/ttyZIGBEE", + "pid": "AAAA", + "vid": "AAAA", + "serial_number": "1234", + "description": "zigbee radio", + "manufacturer": "test", + } + result = await hass.config_entries.flow.async_init( + "zha", context={"source": SOURCE_USB}, data=discovery_info + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "confirm" + + @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) @patch("zigpy_znp.zigbee.application.ControllerApplication.probe", return_value=True) async def test_discovery_already_setup(detect_mock, hass): From d3ac72d013614c9b15f4158a0aba3e1a501d29e4 Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Thu, 26 Aug 2021 11:38:35 -0400 Subject: [PATCH 016/843] Bump up ZHA dependencies (#55242) * Bump up ZHA dependencies * Bump up zha-device-handlers --- homeassistant/components/zha/manifest.json | 12 ++++++------ requirements_all.txt | 12 ++++++------ requirements_test_all.txt | 12 ++++++------ 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 2c1d625b7fe..4b2b27e829c 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -4,16 +4,16 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zha", "requirements": [ - "bellows==0.26.0", + "bellows==0.27.0", "pyserial==3.5", "pyserial-asyncio==0.5", - "zha-quirks==0.0.59", + "zha-quirks==0.0.60", "zigpy-cc==0.5.2", - "zigpy-deconz==0.12.1", - "zigpy==0.36.1", - "zigpy-xbee==0.13.0", + "zigpy-deconz==0.13.0", + "zigpy==0.37.1", + "zigpy-xbee==0.14.0", "zigpy-zigate==0.7.3", - "zigpy-znp==0.5.3" + "zigpy-znp==0.5.4" ], "usb": [ {"vid":"10C4","pid":"EA60","description":"*2652*","known_devices":["slae.sh cc2652rb stick"]}, diff --git a/requirements_all.txt b/requirements_all.txt index b9611f50d76..83e3c0418cb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -372,7 +372,7 @@ beautifulsoup4==4.9.3 # beewi_smartclim==0.0.10 # homeassistant.components.zha -bellows==0.26.0 +bellows==0.27.0 # homeassistant.components.bmw_connected_drive bimmer_connected==0.7.19 @@ -2459,7 +2459,7 @@ zengge==0.2 zeroconf==0.36.0 # homeassistant.components.zha -zha-quirks==0.0.59 +zha-quirks==0.0.60 # homeassistant.components.zhong_hong zhong_hong_hvac==1.0.9 @@ -2471,19 +2471,19 @@ ziggo-mediabox-xl==1.1.0 zigpy-cc==0.5.2 # homeassistant.components.zha -zigpy-deconz==0.12.1 +zigpy-deconz==0.13.0 # homeassistant.components.zha -zigpy-xbee==0.13.0 +zigpy-xbee==0.14.0 # homeassistant.components.zha zigpy-zigate==0.7.3 # homeassistant.components.zha -zigpy-znp==0.5.3 +zigpy-znp==0.5.4 # homeassistant.components.zha -zigpy==0.36.1 +zigpy==0.37.1 # homeassistant.components.zoneminder zm-py==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 23ea1648560..375948cacc4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -227,7 +227,7 @@ azure-eventhub==5.5.0 base36==0.1.1 # homeassistant.components.zha -bellows==0.26.0 +bellows==0.27.0 # homeassistant.components.bmw_connected_drive bimmer_connected==0.7.19 @@ -1379,25 +1379,25 @@ zeep[async]==4.0.0 zeroconf==0.36.0 # homeassistant.components.zha -zha-quirks==0.0.59 +zha-quirks==0.0.60 # homeassistant.components.zha zigpy-cc==0.5.2 # homeassistant.components.zha -zigpy-deconz==0.12.1 +zigpy-deconz==0.13.0 # homeassistant.components.zha -zigpy-xbee==0.13.0 +zigpy-xbee==0.14.0 # homeassistant.components.zha zigpy-zigate==0.7.3 # homeassistant.components.zha -zigpy-znp==0.5.3 +zigpy-znp==0.5.4 # homeassistant.components.zha -zigpy==0.36.1 +zigpy==0.37.1 # homeassistant.components.zwave_js zwave-js-server-python==0.29.0 From f942cb03a4456942576774705fcde91161d6ff13 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 26 Aug 2021 18:29:25 +0200 Subject: [PATCH 017/843] Fix AttributeError for non-MIOT Xiaomi Miio purifiers (#55271) --- homeassistant/components/xiaomi_miio/fan.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index 19d85ced2dc..42828943d93 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -333,7 +333,7 @@ class XiaomiGenericDevice(XiaomiCoordinatedMiioEntity, FanEntity): } ) self._mode = self._state_attrs.get(ATTR_MODE) - self._fan_level = self.coordinator.data.fan_level + self._fan_level = getattr(self.coordinator.data, ATTR_FAN_LEVEL, None) self.async_write_ha_state() # @@ -440,7 +440,7 @@ class XiaomiAirPurifier(XiaomiGenericDevice): {attribute: None for attribute in self._available_attributes} ) self._mode = self._state_attrs.get(ATTR_MODE) - self._fan_level = self.coordinator.data.fan_level + self._fan_level = getattr(self.coordinator.data, ATTR_FAN_LEVEL, None) @property def preset_mode(self): From c3316df31d60878e7778881a3e4f21d891eafa58 Mon Sep 17 00:00:00 2001 From: Florian Gareis Date: Thu, 26 Aug 2021 18:33:41 +0200 Subject: [PATCH 018/843] Don't create DSL sensor for devices that don't support DSL (#55269) --- homeassistant/components/fritz/sensor.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py index e3d366e83fd..7b6a6528eab 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -5,7 +5,12 @@ import datetime import logging from typing import Callable, TypedDict -from fritzconnection.core.exceptions import FritzConnectionException +from fritzconnection.core.exceptions import ( + FritzActionError, + FritzActionFailedError, + FritzConnectionException, + FritzServiceError, +) from fritzconnection.lib.fritzstatus import FritzStatus from homeassistant.components.sensor import ( @@ -260,12 +265,16 @@ async def async_setup_entry( return entities = [] - dslinterface = await hass.async_add_executor_job( - fritzbox_tools.connection.call_action, - "WANDSLInterfaceConfig:1", - "GetInfo", - ) - dsl: bool = dslinterface["NewEnable"] + dsl: bool = False + try: + dslinterface = await hass.async_add_executor_job( + fritzbox_tools.connection.call_action, + "WANDSLInterfaceConfig:1", + "GetInfo", + ) + dsl = dslinterface["NewEnable"] + except (FritzActionError, FritzActionFailedError, FritzServiceError): + pass for sensor_type, sensor_data in SENSOR_DATA.items(): if not dsl and sensor_data.get("connection_type") == DSL_CONNECTION: From b3e84c6ee8dc5d6880751666e3448bbea7c2f04b Mon Sep 17 00:00:00 2001 From: jjlawren Date: Thu, 26 Aug 2021 11:35:35 -0500 Subject: [PATCH 019/843] Set up polling task with subscriptions in Sonos (#54355) --- homeassistant/components/sonos/speaker.py | 32 +++++++++-------------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index 6f37739a17a..30d107bdd8d 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -323,6 +323,18 @@ class SonosSpeaker: async def async_subscribe(self) -> bool: """Initiate event subscriptions.""" _LOGGER.debug("Creating subscriptions for %s", self.zone_name) + + # Create a polling task in case subscriptions fail or callback events do not arrive + if not self._poll_timer: + self._poll_timer = self.hass.helpers.event.async_track_time_interval( + partial( + async_dispatcher_send, + self.hass, + f"{SONOS_POLL_UPDATE}-{self.soco.uid}", + ), + SCAN_INTERVAL, + ) + try: await self.hass.async_add_executor_job(self.set_basic_info) @@ -337,10 +349,10 @@ class SonosSpeaker: for service in SUBSCRIPTION_SERVICES ] await asyncio.gather(*subscriptions) - return True except SoCoException as ex: _LOGGER.warning("Could not connect %s: %s", self.zone_name, ex) return False + return True async def _subscribe( self, target: SubscriptionBase, sub_callback: Callable @@ -497,15 +509,6 @@ class SonosSpeaker: self.soco.ip_address, ) - self._poll_timer = self.hass.helpers.event.async_track_time_interval( - partial( - async_dispatcher_send, - self.hass, - f"{SONOS_POLL_UPDATE}-{self.soco.uid}", - ), - SCAN_INTERVAL, - ) - if self._is_ready and not self.subscriptions_failed: done = await self.async_subscribe() if not done: @@ -567,15 +570,6 @@ class SonosSpeaker: self._seen_timer = self.hass.helpers.event.async_call_later( SEEN_EXPIRE_TIME.total_seconds(), self.async_unseen ) - if not self._poll_timer: - self._poll_timer = self.hass.helpers.event.async_track_time_interval( - partial( - async_dispatcher_send, - self.hass, - f"{SONOS_POLL_UPDATE}-{self.soco.uid}", - ), - SCAN_INTERVAL, - ) self.async_write_entity_states() # From fbcf21412dc61b66025ea0dc1c8f6a6b1936dace Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Aug 2021 11:36:25 -0500 Subject: [PATCH 020/843] Only warn once per entity when the async_camera_image signature needs to be updated (#55238) --- homeassistant/components/camera/__init__.py | 17 +++++++++++----- tests/components/camera/test_init.py | 22 +++++++++++++++++++++ 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 14cd64df920..9724e8e1e70 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -165,10 +165,7 @@ async def _async_get_image( width=width, height=height ) else: - _LOGGER.warning( - "The camera entity %s does not support requesting width and height, please open an issue with the integration author", - camera.entity_id, - ) + camera.async_warn_old_async_camera_image_signature() image_bytes = await camera.async_camera_image() if image_bytes: @@ -381,6 +378,7 @@ class Camera(Entity): self.stream_options: dict[str, str] = {} self.content_type: str = DEFAULT_CONTENT_TYPE self.access_tokens: collections.deque = collections.deque([], 2) + self._warned_old_signature = False self.async_update_token() @property @@ -455,11 +453,20 @@ class Camera(Entity): return await self.hass.async_add_executor_job( partial(self.camera_image, width=width, height=height) ) + self.async_warn_old_async_camera_image_signature() + return await self.hass.async_add_executor_job(self.camera_image) + + # Remove in 2022.1 after all custom components have had a chance to change their signature + @callback + def async_warn_old_async_camera_image_signature(self) -> None: + """Warn once when calling async_camera_image with the function old signature.""" + if self._warned_old_signature: + return _LOGGER.warning( "The camera entity %s does not support requesting width and height, please open an issue with the integration author", self.entity_id, ) - return await self.hass.async_add_executor_job(self.camera_image) + self._warned_old_signature = True async def handle_async_still_stream( self, request: web.Request, interval: float diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index bb3f76e0d1b..df4b64e4310 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -77,6 +77,28 @@ async def test_get_image_from_camera(hass, image_mock_url): assert image.content == b"Test" +async def test_legacy_async_get_image_signature_warns_only_once( + hass, image_mock_url, caplog +): + """Test that we only warn once when we encounter a legacy async_get_image function signature.""" + + async def _legacy_async_camera_image(self): + return b"Image" + + with patch( + "homeassistant.components.demo.camera.DemoCamera.async_camera_image", + new=_legacy_async_camera_image, + ): + image = await camera.async_get_image(hass, "camera.demo_camera") + assert image.content == b"Image" + assert "does not support requesting width and height" in caplog.text + caplog.clear() + + image = await camera.async_get_image(hass, "camera.demo_camera") + assert image.content == b"Image" + assert "does not support requesting width and height" not in caplog.text + + async def test_get_image_from_camera_with_width_height(hass, image_mock_url): """Grab an image from camera entity with width and height.""" From eb9d242adea7dd94af90652c376770d3a06e53d7 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 26 Aug 2021 18:40:42 +0200 Subject: [PATCH 021/843] Move AirlySensorEntityDescription to sensor platform (#55277) --- homeassistant/components/airly/const.py | 70 ------------------ homeassistant/components/airly/model.py | 14 ---- homeassistant/components/airly/sensor.py | 92 ++++++++++++++++++++++-- 3 files changed, 87 insertions(+), 89 deletions(-) delete mode 100644 homeassistant/components/airly/model.py diff --git a/homeassistant/components/airly/const.py b/homeassistant/components/airly/const.py index 79004abbe41..c583a56c22b 100644 --- a/homeassistant/components/airly/const.py +++ b/homeassistant/components/airly/const.py @@ -3,23 +3,6 @@ from __future__ import annotations from typing import Final -from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT -from homeassistant.const import ( - CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - DEVICE_CLASS_AQI, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_PM1, - DEVICE_CLASS_PM10, - DEVICE_CLASS_PM25, - DEVICE_CLASS_PRESSURE, - DEVICE_CLASS_TEMPERATURE, - PERCENTAGE, - PRESSURE_HPA, - TEMP_CELSIUS, -) - -from .model import AirlySensorEntityDescription - ATTR_API_ADVICE: Final = "ADVICE" ATTR_API_CAQI: Final = "CAQI" ATTR_API_CAQI_DESCRIPTION: Final = "DESCRIPTION" @@ -49,56 +32,3 @@ MANUFACTURER: Final = "Airly sp. z o.o." MAX_UPDATE_INTERVAL: Final = 90 MIN_UPDATE_INTERVAL: Final = 5 NO_AIRLY_SENSORS: Final = "There are no Airly sensors in this area yet." - -SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( - AirlySensorEntityDescription( - key=ATTR_API_CAQI, - device_class=DEVICE_CLASS_AQI, - name=ATTR_API_CAQI, - native_unit_of_measurement="CAQI", - ), - AirlySensorEntityDescription( - key=ATTR_API_PM1, - device_class=DEVICE_CLASS_PM1, - name=ATTR_API_PM1, - native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - state_class=STATE_CLASS_MEASUREMENT, - ), - AirlySensorEntityDescription( - key=ATTR_API_PM25, - device_class=DEVICE_CLASS_PM25, - name="PM2.5", - native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - state_class=STATE_CLASS_MEASUREMENT, - ), - AirlySensorEntityDescription( - key=ATTR_API_PM10, - device_class=DEVICE_CLASS_PM10, - name=ATTR_API_PM10, - native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - state_class=STATE_CLASS_MEASUREMENT, - ), - AirlySensorEntityDescription( - key=ATTR_API_HUMIDITY, - device_class=DEVICE_CLASS_HUMIDITY, - name=ATTR_API_HUMIDITY.capitalize(), - native_unit_of_measurement=PERCENTAGE, - state_class=STATE_CLASS_MEASUREMENT, - value=lambda value: round(value, 1), - ), - AirlySensorEntityDescription( - key=ATTR_API_PRESSURE, - device_class=DEVICE_CLASS_PRESSURE, - name=ATTR_API_PRESSURE.capitalize(), - native_unit_of_measurement=PRESSURE_HPA, - state_class=STATE_CLASS_MEASUREMENT, - ), - AirlySensorEntityDescription( - key=ATTR_API_TEMPERATURE, - device_class=DEVICE_CLASS_TEMPERATURE, - name=ATTR_API_TEMPERATURE.capitalize(), - native_unit_of_measurement=TEMP_CELSIUS, - state_class=STATE_CLASS_MEASUREMENT, - value=lambda value: round(value, 1), - ), -) diff --git a/homeassistant/components/airly/model.py b/homeassistant/components/airly/model.py deleted file mode 100644 index 38b433de34c..00000000000 --- a/homeassistant/components/airly/model.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Type definitions for Airly integration.""" -from __future__ import annotations - -from dataclasses import dataclass -from typing import Callable - -from homeassistant.components.sensor import SensorEntityDescription - - -@dataclass -class AirlySensorEntityDescription(SensorEntityDescription): - """Class describing Airly sensor entities.""" - - value: Callable = round diff --git a/homeassistant/components/airly/sensor.py b/homeassistant/components/airly/sensor.py index b5d45afd2d8..7fbfe2077a7 100644 --- a/homeassistant/components/airly/sensor.py +++ b/homeassistant/components/airly/sensor.py @@ -1,11 +1,30 @@ """Support for the Airly sensor service.""" from __future__ import annotations -from typing import Any, cast +from dataclasses import dataclass +from typing import Any, Callable, cast -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME +from homeassistant.const import ( + ATTR_ATTRIBUTION, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONF_NAME, + DEVICE_CLASS_AQI, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_PM1, + DEVICE_CLASS_PM10, + DEVICE_CLASS_PM25, + DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_TEMPERATURE, + PERCENTAGE, + PRESSURE_HPA, + TEMP_CELSIUS, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -18,8 +37,12 @@ from .const import ( ATTR_API_CAQI, ATTR_API_CAQI_DESCRIPTION, ATTR_API_CAQI_LEVEL, + ATTR_API_HUMIDITY, + ATTR_API_PM1, ATTR_API_PM10, ATTR_API_PM25, + ATTR_API_PRESSURE, + ATTR_API_TEMPERATURE, ATTR_DESCRIPTION, ATTR_LEVEL, ATTR_LIMIT, @@ -28,15 +51,74 @@ from .const import ( DEFAULT_NAME, DOMAIN, MANUFACTURER, - SENSOR_TYPES, SUFFIX_LIMIT, SUFFIX_PERCENT, ) -from .model import AirlySensorEntityDescription PARALLEL_UPDATES = 1 +@dataclass +class AirlySensorEntityDescription(SensorEntityDescription): + """Class describing Airly sensor entities.""" + + value: Callable = round + + +SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( + AirlySensorEntityDescription( + key=ATTR_API_CAQI, + device_class=DEVICE_CLASS_AQI, + name=ATTR_API_CAQI, + native_unit_of_measurement="CAQI", + ), + AirlySensorEntityDescription( + key=ATTR_API_PM1, + device_class=DEVICE_CLASS_PM1, + name=ATTR_API_PM1, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=STATE_CLASS_MEASUREMENT, + ), + AirlySensorEntityDescription( + key=ATTR_API_PM25, + device_class=DEVICE_CLASS_PM25, + name="PM2.5", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=STATE_CLASS_MEASUREMENT, + ), + AirlySensorEntityDescription( + key=ATTR_API_PM10, + device_class=DEVICE_CLASS_PM10, + name=ATTR_API_PM10, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=STATE_CLASS_MEASUREMENT, + ), + AirlySensorEntityDescription( + key=ATTR_API_HUMIDITY, + device_class=DEVICE_CLASS_HUMIDITY, + name=ATTR_API_HUMIDITY.capitalize(), + native_unit_of_measurement=PERCENTAGE, + state_class=STATE_CLASS_MEASUREMENT, + value=lambda value: round(value, 1), + ), + AirlySensorEntityDescription( + key=ATTR_API_PRESSURE, + device_class=DEVICE_CLASS_PRESSURE, + name=ATTR_API_PRESSURE.capitalize(), + native_unit_of_measurement=PRESSURE_HPA, + state_class=STATE_CLASS_MEASUREMENT, + ), + AirlySensorEntityDescription( + key=ATTR_API_TEMPERATURE, + device_class=DEVICE_CLASS_TEMPERATURE, + name=ATTR_API_TEMPERATURE.capitalize(), + native_unit_of_measurement=TEMP_CELSIUS, + state_class=STATE_CLASS_MEASUREMENT, + value=lambda value: round(value, 1), + ), +) + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: From f6bb5c77a0a9ce78992cb830d366005bc4e93d90 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 26 Aug 2021 10:37:53 -0700 Subject: [PATCH 022/843] Bump ring to 0.7.1 (#55282) --- homeassistant/components/ring/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ring/manifest.json b/homeassistant/components/ring/manifest.json index ecb64c99fd7..527fb143aff 100644 --- a/homeassistant/components/ring/manifest.json +++ b/homeassistant/components/ring/manifest.json @@ -2,7 +2,7 @@ "domain": "ring", "name": "Ring", "documentation": "https://www.home-assistant.io/integrations/ring", - "requirements": ["ring_doorbell==0.6.2"], + "requirements": ["ring_doorbell==0.7.1"], "dependencies": ["ffmpeg"], "codeowners": ["@balloob"], "config_flow": true, diff --git a/requirements_all.txt b/requirements_all.txt index 83e3c0418cb..2d80759a62f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2043,7 +2043,7 @@ rfk101py==0.0.1 rflink==0.0.58 # homeassistant.components.ring -ring_doorbell==0.6.2 +ring_doorbell==0.7.1 # homeassistant.components.fleetgo ritassist==0.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 375948cacc4..4a847f539c5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1148,7 +1148,7 @@ restrictedpython==5.1 rflink==0.0.58 # homeassistant.components.ring -ring_doorbell==0.6.2 +ring_doorbell==0.7.1 # homeassistant.components.roku rokuecp==0.8.1 From 089dfad78a7acdfce6fbd2209e20bfcd9c318a07 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Aug 2021 13:02:59 -0500 Subject: [PATCH 023/843] Ensure yeelight model is set in the config entry (#55281) * Ensure yeelight model is set in the config entry - If the model was not set in the config entry the light could be sent commands it could not handle * update tests * fix test --- homeassistant/components/yeelight/__init__.py | 22 +++++++---- .../components/yeelight/config_flow.py | 13 ++++++- tests/components/yeelight/test_config_flow.py | 37 +++++++++++++++---- tests/components/yeelight/test_init.py | 3 ++ 4 files changed, 58 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py index 2bdde2113a4..cee6cb7b9e1 100644 --- a/homeassistant/components/yeelight/__init__.py +++ b/homeassistant/components/yeelight/__init__.py @@ -196,7 +196,6 @@ async def _async_initialize( entry_data = hass.data[DOMAIN][DATA_CONFIG_ENTRIES][entry.entry_id] = { DATA_PLATFORMS_LOADED: False } - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) @callback def _async_load_platforms(): @@ -212,6 +211,15 @@ async def _async_initialize( await device.async_setup() entry_data[DATA_DEVICE] = device + if ( + device.capabilities + and entry.options.get(CONF_MODEL) != device.capabilities["model"] + ): + hass.config_entries.async_update_entry( + entry, options={**entry.options, CONF_MODEL: device.capabilities["model"]} + ) + + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) entry.async_on_unload( async_dispatcher_connect( hass, DEVICE_INITIALIZED.format(host), _async_load_platforms @@ -540,7 +548,7 @@ class YeelightDevice: self._config = config self._host = host self._bulb_device = bulb - self._capabilities = {} + self.capabilities = {} self._device_type = None self._available = False self._initialized = False @@ -574,12 +582,12 @@ class YeelightDevice: @property def model(self): """Return configured/autodetected device model.""" - return self._bulb_device.model or self._capabilities.get("model") + return self._bulb_device.model or self.capabilities.get("model") @property def fw_version(self): """Return the firmware version.""" - return self._capabilities.get("fw_ver") + return self.capabilities.get("fw_ver") @property def is_nightlight_supported(self) -> bool: @@ -674,13 +682,13 @@ class YeelightDevice: async def async_setup(self): """Fetch capabilities and setup name if available.""" scanner = YeelightScanner.async_get(self._hass) - self._capabilities = await scanner.async_get_capabilities(self._host) or {} + self.capabilities = await scanner.async_get_capabilities(self._host) or {} if name := self._config.get(CONF_NAME): # Override default name when name is set in config self._name = name - elif self._capabilities: + elif self.capabilities: # Generate name from model and id when capabilities is available - self._name = _async_unique_name(self._capabilities) + self._name = _async_unique_name(self.capabilities) else: self._name = self._host # Default name is host diff --git a/homeassistant/components/yeelight/config_flow.py b/homeassistant/components/yeelight/config_flow.py index 268a0e9cea2..73bbcdcfe5f 100644 --- a/homeassistant/components/yeelight/config_flow.py +++ b/homeassistant/components/yeelight/config_flow.py @@ -96,7 +96,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: return self.async_create_entry( title=async_format_model_id(self._discovered_model, self.unique_id), - data={CONF_ID: self.unique_id, CONF_HOST: self._discovered_ip}, + data={ + CONF_ID: self.unique_id, + CONF_HOST: self._discovered_ip, + CONF_MODEL: self._discovered_model, + }, ) self._set_confirm_only() @@ -129,6 +133,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): data={ CONF_HOST: user_input[CONF_HOST], CONF_ID: self.unique_id, + CONF_MODEL: model, }, ) @@ -151,7 +156,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): host = urlparse(capabilities["location"]).hostname return self.async_create_entry( title=_async_unique_name(capabilities), - data={CONF_ID: unique_id, CONF_HOST: host}, + data={ + CONF_ID: unique_id, + CONF_HOST: host, + CONF_MODEL: capabilities["model"], + }, ) configured_devices = { diff --git a/tests/components/yeelight/test_config_flow.py b/tests/components/yeelight/test_config_flow.py index bde8a18ae55..6bc3ba68275 100644 --- a/tests/components/yeelight/test_config_flow.py +++ b/tests/components/yeelight/test_config_flow.py @@ -19,7 +19,7 @@ from homeassistant.components.yeelight import ( DOMAIN, NIGHTLIGHT_SWITCH_TYPE_LIGHT, ) -from homeassistant.components.yeelight.config_flow import CannotConnect +from homeassistant.components.yeelight.config_flow import MODEL_UNKNOWN, CannotConnect from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_ID, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import RESULT_TYPE_ABORT, RESULT_TYPE_FORM @@ -28,6 +28,7 @@ from . import ( CAPABILITIES, ID, IP_ADDRESS, + MODEL, MODULE, MODULE_CONFIG_FLOW, NAME, @@ -87,7 +88,7 @@ async def test_discovery(hass: HomeAssistant): ) assert result3["type"] == "create_entry" assert result3["title"] == UNIQUE_FRIENDLY_NAME - assert result3["data"] == {CONF_ID: ID, CONF_HOST: IP_ADDRESS} + assert result3["data"] == {CONF_ID: ID, CONF_HOST: IP_ADDRESS, CONF_MODEL: MODEL} await hass.async_block_till_done() mock_setup.assert_called_once() mock_setup_entry.assert_called_once() @@ -160,7 +161,11 @@ async def test_discovery_with_existing_device_present(hass: HomeAssistant): ) assert result3["type"] == "create_entry" assert result3["title"] == UNIQUE_FRIENDLY_NAME - assert result3["data"] == {CONF_ID: ID, CONF_HOST: IP_ADDRESS} + assert result3["data"] == { + CONF_ID: ID, + CONF_HOST: IP_ADDRESS, + CONF_MODEL: MODEL, + } await hass.async_block_till_done() await hass.async_block_till_done() @@ -300,7 +305,11 @@ async def test_manual(hass: HomeAssistant): await hass.async_block_till_done() assert result4["type"] == "create_entry" assert result4["title"] == "Color 0x15243f" - assert result4["data"] == {CONF_HOST: IP_ADDRESS, CONF_ID: "0x000000000015243f"} + assert result4["data"] == { + CONF_HOST: IP_ADDRESS, + CONF_ID: "0x000000000015243f", + CONF_MODEL: MODEL, + } # Duplicate result = await hass.config_entries.flow.async_init( @@ -333,7 +342,7 @@ async def test_options(hass: HomeAssistant): config = { CONF_NAME: NAME, - CONF_MODEL: "", + CONF_MODEL: MODEL, CONF_TRANSITION: DEFAULT_TRANSITION, CONF_MODE_MUSIC: DEFAULT_MODE_MUSIC, CONF_SAVE_ON_CHANGE: DEFAULT_SAVE_ON_CHANGE, @@ -383,7 +392,11 @@ async def test_manual_no_capabilities(hass: HomeAssistant): result["flow_id"], {CONF_HOST: IP_ADDRESS} ) assert result["type"] == "create_entry" - assert result["data"] == {CONF_HOST: IP_ADDRESS, CONF_ID: None} + assert result["data"] == { + CONF_HOST: IP_ADDRESS, + CONF_ID: None, + CONF_MODEL: MODEL_UNKNOWN, + } async def test_discovered_by_homekit_and_dhcp(hass): @@ -480,7 +493,11 @@ async def test_discovered_by_dhcp_or_homekit(hass, source, data): await hass.async_block_till_done() assert result2["type"] == "create_entry" - assert result2["data"] == {CONF_HOST: IP_ADDRESS, CONF_ID: "0x000000000015243f"} + assert result2["data"] == { + CONF_HOST: IP_ADDRESS, + CONF_ID: "0x000000000015243f", + CONF_MODEL: MODEL, + } assert mock_async_setup.called assert mock_async_setup_entry.called @@ -540,7 +557,11 @@ async def test_discovered_ssdp(hass): await hass.async_block_till_done() assert result2["type"] == "create_entry" - assert result2["data"] == {CONF_HOST: IP_ADDRESS, CONF_ID: "0x000000000015243f"} + assert result2["data"] == { + CONF_HOST: IP_ADDRESS, + CONF_ID: "0x000000000015243f", + CONF_MODEL: MODEL, + } assert mock_async_setup.called assert mock_async_setup_entry.called diff --git a/tests/components/yeelight/test_init.py b/tests/components/yeelight/test_init.py index 84c87b7f1dc..4414909d8e0 100644 --- a/tests/components/yeelight/test_init.py +++ b/tests/components/yeelight/test_init.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, patch from yeelight import BulbException, BulbType from homeassistant.components.yeelight import ( + CONF_MODEL, CONF_NIGHTLIGHT_SWITCH, CONF_NIGHTLIGHT_SWITCH_TYPE, DATA_CONFIG_ENTRIES, @@ -35,6 +36,7 @@ from . import ( FAIL_TO_BIND_IP, ID, IP_ADDRESS, + MODEL, MODULE, SHORT_ID, _mocked_bulb, @@ -360,6 +362,7 @@ async def test_async_listen_error_late_discovery(hass, caplog): assert "Failed to connect to bulb at" not in caplog.text assert config_entry.state is ConfigEntryState.LOADED + assert config_entry.options[CONF_MODEL] == MODEL async def test_async_listen_error_has_host_with_id(hass: HomeAssistant): From ae1d2926cfb250c115c5d7514e82d6df85e428c7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Aug 2021 13:25:26 -0500 Subject: [PATCH 024/843] Fix some yeelights showing wrong state after on/off (#55279) --- homeassistant/components/yeelight/__init__.py | 6 +- homeassistant/components/yeelight/light.py | 7 +++ tests/components/yeelight/test_light.py | 56 +++++++++++++++++++ 3 files changed, 66 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py index cee6cb7b9e1..3d1a6cd03e1 100644 --- a/homeassistant/components/yeelight/__init__.py +++ b/homeassistant/components/yeelight/__init__.py @@ -692,10 +692,10 @@ class YeelightDevice: else: self._name = self._host # Default name is host - async def async_update(self): + async def async_update(self, force=False): """Update device properties and send data updated signal.""" - if self._initialized and self._available: - # No need to poll, already connected + if not force and self._initialized and self._available: + # No need to poll unless force, already connected return await self._async_update_properties() async_dispatcher_send(self._hass, DATA_UPDATED.format(self._host)) diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py index 4766d897909..bc4d027ce93 100644 --- a/homeassistant/components/yeelight/light.py +++ b/homeassistant/components/yeelight/light.py @@ -762,6 +762,10 @@ class YeelightGenericLight(YeelightEntity, LightEntity): _LOGGER.error("Unable to set the defaults: %s", ex) return + # Some devices (mainly nightlights) will not send back the on state so we need to force a refresh + if not self.is_on: + await self.device.async_update(True) + async def async_turn_off(self, **kwargs) -> None: """Turn off.""" if not self.is_on: @@ -772,6 +776,9 @@ class YeelightGenericLight(YeelightEntity, LightEntity): duration = int(kwargs.get(ATTR_TRANSITION) * 1000) # kwarg in s await self.device.async_turn_off(duration=duration, light_type=self.light_type) + # Some devices will not send back the off state so we need to force a refresh + if self.is_on: + await self.device.async_update(True) async def async_set_mode(self, mode: str): """Set a power mode.""" diff --git a/tests/components/yeelight/test_light.py b/tests/components/yeelight/test_light.py index 4b8717f4ba4..17924facfad 100644 --- a/tests/components/yeelight/test_light.py +++ b/tests/components/yeelight/test_light.py @@ -1122,3 +1122,59 @@ async def test_effects(hass: HomeAssistant): for name, target in effects.items(): await _async_test_effect(name, target) await _async_test_effect("not_existed", called=False) + + +async def test_state_fails_to_update_triggers_update(hass: HomeAssistant): + """Ensure we call async_get_properties if the turn on/off fails to update the state.""" + mocked_bulb = _mocked_bulb() + properties = {**PROPERTIES} + properties.pop("active_mode") + properties["color_mode"] = "3" # HSV + mocked_bulb.last_properties = properties + mocked_bulb.bulb_type = BulbType.Color + config_entry = MockConfigEntry( + domain=DOMAIN, data={**CONFIG_ENTRY_DATA, CONF_NIGHTLIGHT_SWITCH: False} + ) + config_entry.add_to_hass(hass) + with patch(f"{MODULE}.AsyncBulb", return_value=mocked_bulb): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + # We use asyncio.create_task now to avoid + # blocking starting so we need to block again + await hass.async_block_till_done() + + mocked_bulb.last_properties["power"] = "off" + await hass.services.async_call( + "light", + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: ENTITY_LIGHT, + }, + blocking=True, + ) + assert len(mocked_bulb.async_turn_on.mock_calls) == 1 + assert len(mocked_bulb.async_get_properties.mock_calls) == 2 + + mocked_bulb.last_properties["power"] = "on" + await hass.services.async_call( + "light", + SERVICE_TURN_OFF, + { + ATTR_ENTITY_ID: ENTITY_LIGHT, + }, + blocking=True, + ) + assert len(mocked_bulb.async_turn_off.mock_calls) == 1 + assert len(mocked_bulb.async_get_properties.mock_calls) == 3 + + # But if the state is correct no calls + await hass.services.async_call( + "light", + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: ENTITY_LIGHT, + }, + blocking=True, + ) + assert len(mocked_bulb.async_turn_on.mock_calls) == 1 + assert len(mocked_bulb.async_get_properties.mock_calls) == 3 From c3972b22fdc2f08d7d3ef21a82b358775dfe3ac2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Aug 2021 15:18:36 -0500 Subject: [PATCH 025/843] Fix yeelight brightness when nightlight switch is disabled (#55278) --- homeassistant/components/yeelight/light.py | 14 +++++++-- tests/components/yeelight/test_light.py | 36 ++++++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py index bc4d027ce93..be876690b06 100644 --- a/homeassistant/components/yeelight/light.py +++ b/homeassistant/components/yeelight/light.py @@ -859,7 +859,12 @@ class YeelightColorLightWithoutNightlightSwitch( @property def _brightness_property(self): - return "current_brightness" + # If the nightlight is not active, we do not + # want to "current_brightness" since it will check + # "bg_power" and main light could still be on + if self.device.is_nightlight_enabled: + return "current_brightness" + return super()._brightness_property class YeelightColorLightWithNightlightSwitch( @@ -883,7 +888,12 @@ class YeelightWhiteTempWithoutNightlightSwitch( @property def _brightness_property(self): - return "current_brightness" + # If the nightlight is not active, we do not + # want to "current_brightness" since it will check + # "bg_power" and main light could still be on + if self.device.is_nightlight_enabled: + return "current_brightness" + return super()._brightness_property class YeelightWithNightLight( diff --git a/tests/components/yeelight/test_light.py b/tests/components/yeelight/test_light.py index 17924facfad..030f6a54cea 100644 --- a/tests/components/yeelight/test_light.py +++ b/tests/components/yeelight/test_light.py @@ -96,6 +96,7 @@ from homeassistant.util.color import ( ) from . import ( + CAPABILITIES, ENTITY_LIGHT, ENTITY_NIGHTLIGHT, IP_ADDRESS, @@ -1178,3 +1179,38 @@ async def test_state_fails_to_update_triggers_update(hass: HomeAssistant): ) assert len(mocked_bulb.async_turn_on.mock_calls) == 1 assert len(mocked_bulb.async_get_properties.mock_calls) == 3 + + +async def test_ambilight_with_nightlight_disabled(hass: HomeAssistant): + """Test that main light on ambilights with the nightlight disabled shows the correct brightness.""" + mocked_bulb = _mocked_bulb() + properties = {**PROPERTIES} + capabilities = {**CAPABILITIES} + capabilities["model"] = "ceiling10" + properties["color_mode"] = "3" # HSV + properties["bg_power"] = "off" + properties["current_brightness"] = 0 + properties["bg_lmode"] = "2" # CT + mocked_bulb.last_properties = properties + mocked_bulb.bulb_type = BulbType.WhiteTempMood + main_light_entity_id = "light.yeelight_ceiling10_0x15243f" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={**CONFIG_ENTRY_DATA, CONF_NIGHTLIGHT_SWITCH: False}, + options={**CONFIG_ENTRY_DATA, CONF_NIGHTLIGHT_SWITCH: False}, + ) + config_entry.add_to_hass(hass) + with _patch_discovery(capabilities=capabilities), patch( + f"{MODULE}.AsyncBulb", return_value=mocked_bulb + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + # We use asyncio.create_task now to avoid + # blocking starting so we need to block again + await hass.async_block_till_done() + + state = hass.states.get(main_light_entity_id) + assert state.state == "on" + # bg_power off should not set the brightness to 0 + assert state.attributes[ATTR_BRIGHTNESS] == 128 From 14aa19b814fd310d2832fba25d4ef4ebfc207a2c Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 26 Aug 2021 13:43:26 -0700 Subject: [PATCH 026/843] Fix unique_id conflict in smarttthings (#55235) --- homeassistant/components/smartthings/sensor.py | 2 +- tests/components/smartthings/test_sensor.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 7c682486f04..f5ab5562229 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -561,7 +561,7 @@ class SmartThingsPowerConsumptionSensor(SmartThingsEntity, SensorEntity): @property def unique_id(self) -> str: """Return a unique ID.""" - return f"{self._device.device_id}.{self.report_name}" + return f"{self._device.device_id}.{self.report_name}_meter" @property def native_value(self): diff --git a/tests/components/smartthings/test_sensor.py b/tests/components/smartthings/test_sensor.py index 70103c3a837..f36f05616d6 100644 --- a/tests/components/smartthings/test_sensor.py +++ b/tests/components/smartthings/test_sensor.py @@ -168,7 +168,7 @@ async def test_power_consumption_sensor(hass, device_factory): assert state.state == "1412.002" entry = entity_registry.async_get("sensor.refrigerator_energy") assert entry - assert entry.unique_id == f"{device.device_id}.energy" + assert entry.unique_id == f"{device.device_id}.energy_meter" entry = device_registry.async_get_device({(DOMAIN, device.device_id)}) assert entry assert entry.name == device.label @@ -180,7 +180,7 @@ async def test_power_consumption_sensor(hass, device_factory): assert state.state == "109" entry = entity_registry.async_get("sensor.refrigerator_power") assert entry - assert entry.unique_id == f"{device.device_id}.power" + assert entry.unique_id == f"{device.device_id}.power_meter" entry = device_registry.async_get_device({(DOMAIN, device.device_id)}) assert entry assert entry.name == device.label @@ -202,7 +202,7 @@ async def test_power_consumption_sensor(hass, device_factory): assert state.state == "unknown" entry = entity_registry.async_get("sensor.vacuum_energy") assert entry - assert entry.unique_id == f"{device.device_id}.energy" + assert entry.unique_id == f"{device.device_id}.energy_meter" entry = device_registry.async_get_device({(DOMAIN, device.device_id)}) assert entry assert entry.name == device.label From dfc255666922ca7f75e1a2a632f21878ade4d20d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Aug 2021 15:47:10 -0500 Subject: [PATCH 027/843] Gracefully handle pyudev failing to filter on WSL (#55286) * Gracefully handle pyudev failing to filter on WSL * add debug message * add mocks so we reach the new check --- homeassistant/components/usb/__init__.py | 8 +++- tests/components/usb/test_init.py | 56 ++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index d02c01ad03d..679f2e1caa2 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -127,7 +127,13 @@ class USBDiscovery: return monitor = Monitor.from_netlink(context) - monitor.filter_by(subsystem="tty") + try: + monitor.filter_by(subsystem="tty") + except ValueError as ex: # this fails on WSL + _LOGGER.debug( + "Unable to setup pyudev filtering; This is expected on WSL: %s", ex + ) + return observer = MonitorObserver( monitor, callback=self._device_discovered, name="usb-observer" ) diff --git a/tests/components/usb/test_init.py b/tests/components/usb/test_init.py index e22e514f230..6ba21222052 100644 --- a/tests/components/usb/test_init.py +++ b/tests/components/usb/test_init.py @@ -38,6 +38,20 @@ def mock_docker(): yield +@pytest.fixture(name="venv") +def mock_venv(): + """Mock running Home Assistant in a venv container.""" + with patch( + "homeassistant.components.usb.system_info.async_get_system_info", + return_value={ + "hassio": False, + "docker": False, + "virtualenv": True, + }, + ): + yield + + @pytest.mark.skipif( not sys.platform.startswith("linux"), reason="Only works on linux", @@ -606,6 +620,48 @@ async def test_non_matching_discovered_by_scanner_after_started( assert len(mock_config_flow.mock_calls) == 0 +@pytest.mark.skipif( + not sys.platform.startswith("linux"), + reason="Only works on linux", +) +async def test_observer_on_wsl_fallback_without_throwing_exception( + hass, hass_ws_client, venv +): + """Test that observer on WSL failure results in fallback to scanning without raising an exception.""" + new_usb = [{"domain": "test1", "vid": "3039"}] + + mock_comports = [ + MagicMock( + device=slae_sh_device.device, + vid=12345, + pid=12345, + serial_number=slae_sh_device.serial_number, + manufacturer=slae_sh_device.manufacturer, + description=slae_sh_device.description, + ) + ] + + with patch("pyudev.Context"), patch( + "pyudev.Monitor.filter_by", side_effect=ValueError + ), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch( + "homeassistant.components.usb.comports", return_value=mock_comports + ), patch.object( + hass.config_entries.flow, "async_init" + ) as mock_config_flow: + assert await async_setup_component(hass, "usb", {"usb": {}}) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + ws_client = await hass_ws_client(hass) + await ws_client.send_json({"id": 1, "type": "usb/scan"}) + response = await ws_client.receive_json() + assert response["success"] + await hass.async_block_till_done() + + assert len(mock_config_flow.mock_calls) == 1 + assert mock_config_flow.mock_calls[0][1][0] == "test1" + + @pytest.mark.skipif( not sys.platform.startswith("linux"), reason="Only works on linux", From ef107732026525472540410df5a38b715727aa2a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Aug 2021 17:02:49 -0500 Subject: [PATCH 028/843] Fix creation of new nmap tracker entities (#55297) --- homeassistant/components/nmap_tracker/__init__.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/homeassistant/components/nmap_tracker/__init__.py b/homeassistant/components/nmap_tracker/__init__.py index 78465fbe91d..dfd8987484c 100644 --- a/homeassistant/components/nmap_tracker/__init__.py +++ b/homeassistant/components/nmap_tracker/__init__.py @@ -361,13 +361,6 @@ class NmapDeviceScanner: continue formatted_mac = format_mac(mac) - new = formatted_mac not in devices.tracked - if ( - new - and formatted_mac not in devices.tracked - and formatted_mac not in self._known_mac_addresses - ): - continue if ( devices.config_entry_owner.setdefault(formatted_mac, entry_id) @@ -382,6 +375,7 @@ class NmapDeviceScanner: formatted_mac, hostname, name, ipv4, vendor, reason, now, 0 ) + new = formatted_mac not in devices.tracked devices.tracked[formatted_mac] = device devices.ipv4_last_mac[ipv4] = formatted_mac self._last_results.append(device) From cbd65efe5211f6501f4a6fc9b796eac7a0b8609f Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Thu, 26 Aug 2021 16:59:27 -0600 Subject: [PATCH 029/843] Bump aiorecollect to 1.0.8 (#55300) --- homeassistant/components/recollect_waste/manifest.json | 2 +- homeassistant/components/recollect_waste/sensor.py | 5 ++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/recollect_waste/manifest.json b/homeassistant/components/recollect_waste/manifest.json index 258d74915f7..85cb7100a65 100644 --- a/homeassistant/components/recollect_waste/manifest.json +++ b/homeassistant/components/recollect_waste/manifest.json @@ -3,7 +3,7 @@ "name": "ReCollect Waste", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/recollect_waste", - "requirements": ["aiorecollect==1.0.7"], + "requirements": ["aiorecollect==1.0.8"], "codeowners": ["@bachya"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/recollect_waste/sensor.py b/homeassistant/components/recollect_waste/sensor.py index 304eaafb85f..434d24be22a 100644 --- a/homeassistant/components/recollect_waste/sensor.py +++ b/homeassistant/components/recollect_waste/sensor.py @@ -20,7 +20,6 @@ from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, ) -from homeassistant.util.dt import as_utc from .const import CONF_PLACE_ID, CONF_SERVICE_ID, DATA_COORDINATOR, DOMAIN, LOGGER @@ -124,7 +123,7 @@ class ReCollectWasteSensor(CoordinatorEntity, SensorEntity): ATTR_NEXT_PICKUP_TYPES: async_get_pickup_type_names( self._entry, next_pickup_event.pickup_types ), - ATTR_NEXT_PICKUP_DATE: as_utc(next_pickup_event.date).isoformat(), + ATTR_NEXT_PICKUP_DATE: next_pickup_event.date.isoformat(), } ) - self._attr_native_value = as_utc(pickup_event.date).isoformat() + self._attr_native_value = pickup_event.date.isoformat() diff --git a/requirements_all.txt b/requirements_all.txt index 2d80759a62f..1559edd0311 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -237,7 +237,7 @@ aiopvpc==2.2.0 aiopylgtv==0.4.0 # homeassistant.components.recollect_waste -aiorecollect==1.0.7 +aiorecollect==1.0.8 # homeassistant.components.shelly aioshelly==0.6.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4a847f539c5..cf214597fe8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -158,7 +158,7 @@ aiopvpc==2.2.0 aiopylgtv==0.4.0 # homeassistant.components.recollect_waste -aiorecollect==1.0.7 +aiorecollect==1.0.8 # homeassistant.components.shelly aioshelly==0.6.4 From 5393a16c44754b1649799f8f7df28bfcc5262b9d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Aug 2021 19:04:12 -0500 Subject: [PATCH 030/843] Set yeelight capabilities from external discovery (#55280) --- homeassistant/components/yeelight/__init__.py | 2 ++ homeassistant/components/yeelight/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py index 3d1a6cd03e1..8684e331fad 100644 --- a/homeassistant/components/yeelight/__init__.py +++ b/homeassistant/components/yeelight/__init__.py @@ -683,6 +683,8 @@ class YeelightDevice: """Fetch capabilities and setup name if available.""" scanner = YeelightScanner.async_get(self._hass) self.capabilities = await scanner.async_get_capabilities(self._host) or {} + if self.capabilities: + self._bulb_device.set_capabilities(self.capabilities) if name := self._config.get(CONF_NAME): # Override default name when name is set in config self._name = name diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index 5910341cfb4..0a4b5d4499f 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -2,7 +2,7 @@ "domain": "yeelight", "name": "Yeelight", "documentation": "https://www.home-assistant.io/integrations/yeelight", - "requirements": ["yeelight==0.7.3", "async-upnp-client==0.20.0"], + "requirements": ["yeelight==0.7.4", "async-upnp-client==0.20.0"], "codeowners": ["@rytilahti", "@zewelor", "@shenxn", "@starkillerOG"], "config_flow": true, "dependencies": ["network"], diff --git a/requirements_all.txt b/requirements_all.txt index 1559edd0311..b7d2197f27d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2438,7 +2438,7 @@ yalesmartalarmclient==0.3.4 yalexs==1.1.13 # homeassistant.components.yeelight -yeelight==0.7.3 +yeelight==0.7.4 # homeassistant.components.yeelightsunflower yeelightsunflower==0.0.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cf214597fe8..73f3b19c8b6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1367,7 +1367,7 @@ yalesmartalarmclient==0.3.4 yalexs==1.1.13 # homeassistant.components.yeelight -yeelight==0.7.3 +yeelight==0.7.4 # homeassistant.components.youless youless-api==0.12 From 65d14909ee3678b942ed87308a55cad576119ea9 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Fri, 27 Aug 2021 00:14:42 +0000 Subject: [PATCH 031/843] [ci skip] Translation update --- .../components/abode/translations/fr.json | 2 +- .../accuweather/translations/fr.json | 2 +- .../components/adguard/translations/fr.json | 4 +-- .../components/agent_dvr/translations/fr.json | 4 +-- .../components/airly/translations/fr.json | 4 +-- .../components/airnow/translations/fr.json | 4 +-- .../components/airtouch4/translations/fr.json | 17 ++++++++++++ .../components/airvisual/translations/fr.json | 4 +-- .../components/almond/translations/fr.json | 4 +-- .../components/ambee/translations/fr.json | 8 +++--- .../ambiclimate/translations/fr.json | 2 +- .../ambient_station/translations/fr.json | 4 +-- .../components/apple_tv/translations/fr.json | 8 +++--- .../components/arcam_fmj/translations/fr.json | 4 +-- .../components/atag/translations/fr.json | 4 +-- .../components/august/translations/fr.json | 4 +-- .../components/aurora/translations/fr.json | 2 +- .../components/awair/translations/fr.json | 4 +-- .../components/axis/translations/fr.json | 4 +-- .../azure_devops/translations/fr.json | 2 +- .../binary_sensor/translations/fr.json | 12 ++++----- .../components/blebox/translations/fr.json | 6 ++--- .../components/blink/translations/fr.json | 4 +-- .../bmw_connected_drive/translations/fr.json | 2 +- .../components/bond/translations/fr.json | 4 +-- .../components/bosch_shc/translations/fr.json | 6 ++--- .../components/braviatv/translations/fr.json | 8 +++--- .../components/broadlink/translations/fr.json | 2 +- .../components/brother/translations/fr.json | 4 +-- .../components/bsblan/translations/fr.json | 2 +- .../components/cast/translations/fr.json | 4 +-- .../cert_expiry/translations/fr.json | 2 +- .../cloudflare/translations/fr.json | 4 +-- .../components/co2signal/translations/fr.json | 2 +- .../components/coinbase/translations/fr.json | 4 +-- .../coronavirus/translations/fr.json | 2 +- .../components/daikin/translations/fr.json | 4 +-- .../components/deconz/translations/fr.json | 4 +-- .../components/denonavr/translations/fr.json | 4 +-- .../devolo_home_control/translations/fr.json | 8 +++--- .../components/directv/translations/fr.json | 2 +- .../components/doorbird/translations/fr.json | 6 ++--- .../components/dunehd/translations/fr.json | 2 +- .../components/ecobee/translations/fr.json | 2 +- .../components/econet/translations/fr.json | 10 +++---- .../components/elgato/translations/fr.json | 4 +-- .../components/elkm1/translations/fr.json | 4 +-- .../components/emonitor/translations/fr.json | 2 +- .../enphase_envoy/translations/fr.json | 4 +-- .../components/epson/translations/fr.json | 2 +- .../components/esphome/translations/fr.json | 6 ++--- .../components/ezviz/translations/fr.json | 6 ++--- .../fireservicerota/translations/fr.json | 8 +++--- .../components/firmata/translations/fr.json | 2 +- .../fjaraskupan/translations/cs.json | 7 +++++ .../fjaraskupan/translations/fr.json | 13 +++++++++ .../flick_electric/translations/fr.json | 6 ++--- .../components/flume/translations/fr.json | 8 +++--- .../flunearyou/translations/fr.json | 2 +- .../forked_daapd/translations/fr.json | 4 +-- .../components/foscam/translations/fr.json | 4 +-- .../components/freebox/translations/fr.json | 8 +++--- .../freedompro/translations/fr.json | 2 +- .../components/fritz/translations/fr.json | 10 +++---- .../components/fritzbox/translations/fr.json | 6 ++--- .../fritzbox_callmonitor/translations/fr.json | 8 +++--- .../components/gdacs/translations/fr.json | 2 +- .../geonetnz_quakes/translations/fr.json | 2 +- .../components/gios/translations/fr.json | 6 ++--- .../components/glances/translations/fr.json | 8 +++--- .../components/goalzero/translations/fr.json | 2 +- .../google_travel_time/translations/fr.json | 2 +- .../growatt_server/translations/fr.json | 2 +- .../components/guardian/translations/fr.json | 4 +-- .../components/hangouts/translations/fr.json | 6 ++--- .../components/harmony/translations/fr.json | 4 +-- .../hisense_aehw4a1/translations/fr.json | 4 +-- .../home_connect/translations/fr.json | 6 ++--- .../home_plus_control/translations/fr.json | 12 ++++----- .../components/homekit/translations/pl.json | 3 ++- .../homekit/translations/zh-Hant.json | 5 ++-- .../homekit_controller/translations/fr.json | 2 +- .../homematicip_cloud/translations/fr.json | 8 +++--- .../components/honeywell/translations/fr.json | 2 +- .../huawei_lte/translations/fr.json | 4 +-- .../components/hue/translations/fr.json | 8 +++--- .../huisbaasje/translations/fr.json | 8 +++--- .../humidifier/translations/fr.json | 4 +-- .../translations/fr.json | 2 +- .../hvv_departures/translations/fr.json | 4 +-- .../components/hyperion/translations/fr.json | 8 +++--- .../components/ialarm/translations/fr.json | 2 +- .../components/icloud/translations/fr.json | 2 +- .../components/ios/translations/fr.json | 4 +-- .../components/ipp/translations/fr.json | 6 ++--- .../components/iqvia/translations/fr.json | 2 +- .../components/isy994/translations/fr.json | 4 +-- .../components/izone/translations/fr.json | 4 +-- .../components/juicenet/translations/fr.json | 8 +++--- .../components/kodi/translations/fr.json | 4 +-- .../components/konnected/translations/fr.json | 6 ++--- .../kostal_plenticore/translations/fr.json | 4 +-- .../components/kulersky/translations/fr.json | 4 +-- .../components/lifx/translations/fr.json | 4 +-- .../components/local_ip/translations/fr.json | 2 +- .../components/locative/translations/fr.json | 2 +- .../lutron_caseta/translations/fr.json | 8 +++--- .../components/lyric/translations/fr.json | 4 +-- .../components/mazda/translations/fr.json | 4 +-- .../components/melcloud/translations/fr.json | 4 +-- .../meteo_france/translations/fr.json | 4 +-- .../components/metoffice/translations/fr.json | 6 ++--- .../components/mikrotik/translations/fr.json | 6 ++--- .../minecraft_server/translations/fr.json | 4 +-- .../modern_forms/translations/fr.json | 2 +- .../components/monoprice/translations/fr.json | 2 +- .../motion_blinds/translations/fr.json | 8 +++--- .../components/motioneye/translations/fr.json | 4 +-- .../components/mqtt/translations/ca.json | 1 + .../components/mqtt/translations/cs.json | 1 + .../components/mqtt/translations/de.json | 1 + .../components/mqtt/translations/et.json | 1 + .../components/mqtt/translations/fr.json | 9 ++++--- .../components/mutesync/translations/fr.json | 2 +- .../components/myq/translations/fr.json | 8 +++--- .../components/nam/translations/fr.json | 2 +- .../components/nanoleaf/translations/cs.json | 23 ++++++++++++++++ .../components/nanoleaf/translations/fr.json | 27 +++++++++++++++++++ .../components/nanoleaf/translations/pl.json | 26 ++++++++++++++++++ .../nanoleaf/translations/zh-Hant.json | 1 + .../components/neato/translations/fr.json | 6 ++--- .../components/nest/translations/fr.json | 4 +-- .../components/netatmo/translations/fr.json | 12 ++++----- .../components/nexia/translations/fr.json | 6 ++--- .../nmap_tracker/translations/fr.json | 2 +- .../components/notion/translations/fr.json | 2 +- .../components/nuheat/translations/fr.json | 6 ++--- .../components/nuki/translations/fr.json | 10 +++---- .../components/nut/translations/fr.json | 4 +-- .../components/nws/translations/fr.json | 4 +-- .../components/nzbget/translations/fr.json | 4 +-- .../components/onvif/translations/fr.json | 6 ++--- .../opentherm_gw/translations/fr.json | 2 +- .../components/openuv/translations/fr.json | 11 ++++++-- .../components/openuv/translations/pl.json | 7 +++++ .../openweathermap/translations/fr.json | 4 +-- .../components/ozw/translations/fr.json | 4 +-- .../p1_monitor/translations/cs.json | 16 +++++++++++ .../p1_monitor/translations/fr.json | 16 +++++++++++ .../panasonic_viera/translations/fr.json | 6 ++--- .../components/pi_hole/translations/fr.json | 8 +++--- .../components/picnic/translations/fr.json | 2 +- .../components/plaato/translations/fr.json | 4 +-- .../components/plex/translations/fr.json | 8 +++--- .../components/plugwise/translations/fr.json | 6 ++--- .../plum_lightpad/translations/fr.json | 2 +- .../components/poolsense/translations/fr.json | 4 +-- .../components/powerwall/translations/fr.json | 4 +-- .../components/prosegur/translations/fr.json | 4 +-- .../components/ps4/translations/fr.json | 4 +-- .../pvpc_hourly_pricing/translations/fr.json | 2 +- .../components/rachio/translations/fr.json | 4 +-- .../rainforest_eagle/translations/cs.json | 12 +++++++++ .../rainforest_eagle/translations/fr.json | 21 +++++++++++++++ .../rainmachine/translations/fr.json | 2 +- .../recollect_waste/translations/fr.json | 2 +- .../components/renault/translations/fr.json | 2 +- .../components/ring/translations/fr.json | 2 +- .../components/risco/translations/fr.json | 12 ++++----- .../components/roku/translations/fr.json | 2 +- .../components/roomba/translations/fr.json | 8 +++--- .../components/samsungtv/translations/fr.json | 10 +++---- .../components/sense/translations/fr.json | 4 +-- .../components/sharkiq/translations/fr.json | 2 +- .../shopping_list/translations/fr.json | 2 +- .../components/sia/translations/fr.json | 2 +- .../simplisafe/translations/fr.json | 6 ++--- .../components/sma/translations/fr.json | 4 +-- .../components/smappee/translations/fr.json | 2 +- .../components/smarthab/translations/fr.json | 2 +- .../components/smarttub/translations/fr.json | 4 +-- .../components/sms/translations/fr.json | 4 +-- .../components/solaredge/translations/fr.json | 4 +-- .../components/solarlog/translations/fr.json | 2 +- .../components/somfy/translations/fr.json | 8 +++--- .../somfy_mylink/translations/fr.json | 8 +++--- .../components/sonarr/translations/fr.json | 10 +++---- .../components/songpal/translations/fr.json | 4 +-- .../components/sonos/translations/fr.json | 4 +-- .../speedtestdotnet/translations/fr.json | 2 +- .../components/spotify/translations/fr.json | 4 +-- .../squeezebox/translations/fr.json | 2 +- .../srp_energy/translations/fr.json | 8 +++--- .../switcher_kis/translations/fr.json | 2 +- .../components/syncthing/translations/fr.json | 2 +- .../components/syncthru/translations/fr.json | 2 +- .../synology_dsm/translations/fr.json | 16 +++++------ .../system_bridge/translations/fr.json | 8 +++--- .../components/tado/translations/fr.json | 4 +-- .../tellduslive/translations/fr.json | 6 ++--- .../components/tibber/translations/fr.json | 2 +- .../components/tile/translations/fr.json | 2 +- .../components/toon/translations/fr.json | 4 +-- .../totalconnect/translations/fr.json | 2 +- .../components/tplink/translations/fr.json | 4 +-- .../components/tractive/translations/fr.json | 6 +++-- .../components/tradfri/translations/fr.json | 8 +++--- .../transmission/translations/fr.json | 4 +-- .../components/tuya/translations/fr.json | 2 +- .../components/twinkly/translations/fr.json | 4 +-- .../components/unifi/translations/fr.json | 4 +-- .../components/upb/translations/fr.json | 4 +-- .../components/upnp/translations/fr.json | 4 +-- .../uptimerobot/translations/fr.json | 14 +++++----- .../components/vacuum/translations/fr.json | 2 +- .../components/vilfo/translations/fr.json | 12 ++++----- .../components/vizio/translations/fr.json | 4 +-- .../components/wallbox/translations/fr.json | 2 +- .../waze_travel_time/translations/fr.json | 2 +- .../components/wemo/translations/fr.json | 4 +-- .../components/wiffi/translations/fr.json | 2 +- .../components/withings/translations/fr.json | 8 +++--- .../components/wled/translations/fr.json | 4 +-- .../xiaomi_aqara/translations/fr.json | 6 ++--- .../xiaomi_miio/translations/fr.json | 10 +++---- .../yale_smart_alarm/translations/fr.json | 2 +- .../yamaha_musiccast/translations/fr.json | 2 +- .../components/yeelight/translations/cs.json | 1 + .../components/zerproc/translations/fr.json | 4 +-- .../components/zha/translations/ca.json | 3 ++- .../components/zha/translations/de.json | 3 ++- .../components/zha/translations/en.json | 3 ++- .../components/zha/translations/et.json | 3 ++- .../components/zha/translations/fr.json | 5 ++-- .../zoneminder/translations/fr.json | 2 +- .../components/zwave/translations/fr.json | 2 +- .../components/zwave_js/translations/cs.json | 1 + .../components/zwave_js/translations/fr.json | 10 ++++--- 238 files changed, 706 insertions(+), 491 deletions(-) create mode 100644 homeassistant/components/airtouch4/translations/fr.json create mode 100644 homeassistant/components/fjaraskupan/translations/cs.json create mode 100644 homeassistant/components/fjaraskupan/translations/fr.json create mode 100644 homeassistant/components/nanoleaf/translations/cs.json create mode 100644 homeassistant/components/nanoleaf/translations/fr.json create mode 100644 homeassistant/components/nanoleaf/translations/pl.json create mode 100644 homeassistant/components/p1_monitor/translations/cs.json create mode 100644 homeassistant/components/p1_monitor/translations/fr.json create mode 100644 homeassistant/components/rainforest_eagle/translations/cs.json create mode 100644 homeassistant/components/rainforest_eagle/translations/fr.json diff --git a/homeassistant/components/abode/translations/fr.json b/homeassistant/components/abode/translations/fr.json index 2ab158cca57..fb5a079d405 100644 --- a/homeassistant/components/abode/translations/fr.json +++ b/homeassistant/components/abode/translations/fr.json @@ -2,7 +2,7 @@ "config": { "abort": { "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi", - "single_instance_allowed": "D\u00e9ja configur\u00e9. Une seule configuration possible." + "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." }, "error": { "cannot_connect": "\u00c9chec de connexion", diff --git a/homeassistant/components/accuweather/translations/fr.json b/homeassistant/components/accuweather/translations/fr.json index a083ed09bdf..7c04e51da23 100644 --- a/homeassistant/components/accuweather/translations/fr.json +++ b/homeassistant/components/accuweather/translations/fr.json @@ -14,7 +14,7 @@ "api_key": "Cl\u00e9 d'API", "latitude": "Latitude", "longitude": "Longitude", - "name": "Nom de l'int\u00e9gration" + "name": "Nom" }, "description": "Si vous avez besoin d'aide pour la configuration, consultez le site suivant : https://www.home-assistant.io/integrations/accuweather/\n\nCertains capteurs ne sont pas activ\u00e9s par d\u00e9faut. Vous pouvez les activer dans le registre des entit\u00e9s apr\u00e8s la configuration de l'int\u00e9gration.\nLes pr\u00e9visions m\u00e9t\u00e9orologiques ne sont pas activ\u00e9es par d\u00e9faut. Vous pouvez l'activer dans les options d'int\u00e9gration.", "title": "AccuWeather" diff --git a/homeassistant/components/adguard/translations/fr.json b/homeassistant/components/adguard/translations/fr.json index 7add7c9829f..da6dd866983 100644 --- a/homeassistant/components/adguard/translations/fr.json +++ b/homeassistant/components/adguard/translations/fr.json @@ -17,9 +17,9 @@ "host": "H\u00f4te", "password": "Mot de passe", "port": "Port", - "ssl": "AdGuard Home utilise un certificat SSL", + "ssl": "Utilise un certificat SSL", "username": "Nom d'utilisateur", - "verify_ssl": "AdGuard Home utilise un certificat appropri\u00e9" + "verify_ssl": "V\u00e9rifier le certificat SSL" }, "description": "Configurez votre instance AdGuard Home pour permettre la surveillance et le contr\u00f4le." } diff --git a/homeassistant/components/agent_dvr/translations/fr.json b/homeassistant/components/agent_dvr/translations/fr.json index e78c1da7d8b..1b641dd38ab 100644 --- a/homeassistant/components/agent_dvr/translations/fr.json +++ b/homeassistant/components/agent_dvr/translations/fr.json @@ -4,13 +4,13 @@ "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "already_in_progress": "La configuration de l'appareil est d\u00e9j\u00e0 en cours.", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", "cannot_connect": "\u00c9chec de connexion" }, "step": { "user": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "port": "Port" }, "title": "Configurer l'agent DVR" diff --git a/homeassistant/components/airly/translations/fr.json b/homeassistant/components/airly/translations/fr.json index a23f455e0b8..945de28e07a 100644 --- a/homeassistant/components/airly/translations/fr.json +++ b/homeassistant/components/airly/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "L'int\u00e9gration des coordonn\u00e9es d'Airly est d\u00e9j\u00e0 configur\u00e9." + "already_configured": "L'emplacement est d\u00e9j\u00e0 configur\u00e9" }, "error": { "invalid_api_key": "Cl\u00e9 API invalide", @@ -13,7 +13,7 @@ "api_key": "Cl\u00e9 d'API", "latitude": "Latitude", "longitude": "Longitude", - "name": "Nom de l'int\u00e9gration" + "name": "Nom" }, "description": "Configurez l'int\u00e9gration de la qualit\u00e9 de l'air Airly. Pour g\u00e9n\u00e9rer une cl\u00e9 API, rendez-vous sur https://developer.airly.eu/register.", "title": "Airly" diff --git a/homeassistant/components/airnow/translations/fr.json b/homeassistant/components/airnow/translations/fr.json index ff85d9318e9..686dedb9bb6 100644 --- a/homeassistant/components/airnow/translations/fr.json +++ b/homeassistant/components/airnow/translations/fr.json @@ -4,7 +4,7 @@ "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "\u00c9chec \u00e0 la connexion", + "cannot_connect": "\u00c9chec de connexion", "invalid_auth": "Authentification invalide", "invalid_location": "Aucun r\u00e9sultat trouv\u00e9 pour cet emplacement", "unknown": "Erreur inattendue" @@ -12,7 +12,7 @@ "step": { "user": { "data": { - "api_key": "Cl\u00e9 API", + "api_key": "Cl\u00e9 d'API", "latitude": "Latitude", "longitude": "Longitude", "radius": "Rayon d'action de la station (en miles, facultatif)" diff --git a/homeassistant/components/airtouch4/translations/fr.json b/homeassistant/components/airtouch4/translations/fr.json new file mode 100644 index 00000000000..33580a8eae3 --- /dev/null +++ b/homeassistant/components/airtouch4/translations/fr.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" + }, + "error": { + "cannot_connect": "\u00c9chec de connexion" + }, + "step": { + "user": { + "data": { + "host": "H\u00f4te" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/fr.json b/homeassistant/components/airvisual/translations/fr.json index 510bf8597d1..4817a720225 100644 --- a/homeassistant/components/airvisual/translations/fr.json +++ b/homeassistant/components/airvisual/translations/fr.json @@ -13,7 +13,7 @@ "step": { "geography_by_coords": { "data": { - "api_key": "Clef d'API", + "api_key": "Cl\u00e9 d'API", "latitude": "Latitude", "longitude": "Longitude" }, @@ -22,7 +22,7 @@ }, "geography_by_name": { "data": { - "api_key": "Clef d'API", + "api_key": "Cl\u00e9 d'API", "city": "Ville", "country": "Pays", "state": "Etat" diff --git a/homeassistant/components/almond/translations/fr.json b/homeassistant/components/almond/translations/fr.json index 0e6a8e0be3f..a464e8b56e9 100644 --- a/homeassistant/components/almond/translations/fr.json +++ b/homeassistant/components/almond/translations/fr.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "cannot_connect": "Impossible de se connecter au serveur Almond", - "missing_configuration": "Veuillez consulter la documentation pour savoir comment configurer Almond.", + "cannot_connect": "\u00c9chec de connexion", + "missing_configuration": "Le composant n'est pas configur\u00e9. Veuillez suivre la documentation.", "no_url_available": "Aucune URL disponible. Pour plus d'informations sur cette erreur, [consultez la section d'aide] ( {docs_url} )", "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." }, diff --git a/homeassistant/components/ambee/translations/fr.json b/homeassistant/components/ambee/translations/fr.json index bbb09edf763..dc968329b49 100644 --- a/homeassistant/components/ambee/translations/fr.json +++ b/homeassistant/components/ambee/translations/fr.json @@ -1,22 +1,22 @@ { "config": { "abort": { - "reauth_successful": "R\u00e9-authentification r\u00e9ussie" + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { "cannot_connect": "\u00c9chec de connexion", - "invalid_api_key": "Cl\u00e9 API non valide" + "invalid_api_key": "Cl\u00e9 API invalide" }, "step": { "reauth_confirm": { "data": { - "api_key": "cl\u00e9 API", + "api_key": "Cl\u00e9 d'API", "description": "R\u00e9-authentifiez-vous avec votre compte Ambee." } }, "user": { "data": { - "api_key": "cl\u00e9 API", + "api_key": "Cl\u00e9 d'API", "latitude": "Latitude", "longitude": "Longitude", "name": "Nom" diff --git a/homeassistant/components/ambiclimate/translations/fr.json b/homeassistant/components/ambiclimate/translations/fr.json index 37ef9549686..b6464b58244 100644 --- a/homeassistant/components/ambiclimate/translations/fr.json +++ b/homeassistant/components/ambiclimate/translations/fr.json @@ -6,7 +6,7 @@ "missing_configuration": "Le composant n'est pas configur\u00e9. Veuillez suivre la documentation." }, "create_entry": { - "default": "Authentifi\u00e9 avec succ\u00e8s avec Ambiclimate" + "default": "Authentification r\u00e9ussie" }, "error": { "follow_link": "Veuillez suivre le lien et vous authentifier avant d'appuyer sur Soumettre.", diff --git a/homeassistant/components/ambient_station/translations/fr.json b/homeassistant/components/ambient_station/translations/fr.json index d88e9f9c9f6..1877a0af4ff 100644 --- a/homeassistant/components/ambient_station/translations/fr.json +++ b/homeassistant/components/ambient_station/translations/fr.json @@ -1,10 +1,10 @@ { "config": { "abort": { - "already_configured": "Cette cl\u00e9 d'application est d\u00e9j\u00e0 utilis\u00e9e." + "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "invalid_key": "Cl\u00e9 d'API et / ou cl\u00e9 d'application non valide", + "invalid_key": "Cl\u00e9 API invalide", "no_devices": "Aucun appareil trouv\u00e9 dans le compte" }, "step": { diff --git a/homeassistant/components/apple_tv/translations/fr.json b/homeassistant/components/apple_tv/translations/fr.json index e1a719b31c9..056a98ea74f 100644 --- a/homeassistant/components/apple_tv/translations/fr.json +++ b/homeassistant/components/apple_tv/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured_device": "Le p\u00e9riph\u00e9rique est d\u00e9j\u00e0 configur\u00e9", + "already_configured_device": "L'appareil est d\u00e9j\u00e0 configur\u00e9", "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", "backoff": "L'appareil n'accepte pas les demandes d'appariement pour le moment (vous avez peut-\u00eatre saisi un code PIN non valide trop de fois), r\u00e9essayez plus tard.", "device_did_not_pair": "Aucune tentative pour terminer l'appairage n'a \u00e9t\u00e9 effectu\u00e9e \u00e0 partir de l'appareil.", @@ -11,10 +11,10 @@ }, "error": { "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", - "invalid_auth": "Autentification invalide", - "no_devices_found": "Aucun appareil d\u00e9tect\u00e9 sur le r\u00e9seau", + "invalid_auth": "Authentification invalide", + "no_devices_found": "Aucun appareil trouv\u00e9 sur le r\u00e9seau", "no_usable_service": "Un dispositif a \u00e9t\u00e9 trouv\u00e9, mais aucun moyen d\u2019\u00e9tablir un lien avec lui. Si vous continuez \u00e0 voir ce message, essayez de sp\u00e9cifier son adresse IP ou de red\u00e9marrer votre Apple TV.", - "unknown": "Erreur innatendue" + "unknown": "Erreur inattendue" }, "flow_title": "Apple TV: {name}", "step": { diff --git a/homeassistant/components/arcam_fmj/translations/fr.json b/homeassistant/components/arcam_fmj/translations/fr.json index 511d9e98a50..938e9ab7b5d 100644 --- a/homeassistant/components/arcam_fmj/translations/fr.json +++ b/homeassistant/components/arcam_fmj/translations/fr.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "already_configured": "L'appareil \u00e9tait d\u00e9j\u00e0 configur\u00e9.", - "already_in_progress": "Le flux de configuration de l'appareil est d\u00e9j\u00e0 en cours.", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", "cannot_connect": "\u00c9chec de connexion" }, "error": { diff --git a/homeassistant/components/atag/translations/fr.json b/homeassistant/components/atag/translations/fr.json index a0f4b9f3808..c42b64a16c8 100644 --- a/homeassistant/components/atag/translations/fr.json +++ b/homeassistant/components/atag/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Un seul appareil Atag peut \u00eatre ajout\u00e9 \u00e0 Home Assistant" + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { "cannot_connect": "\u00c9chec de connexion", @@ -10,7 +10,7 @@ "step": { "user": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "port": "Port" }, "title": "Se connecter \u00e0 l'appareil" diff --git a/homeassistant/components/august/translations/fr.json b/homeassistant/components/august/translations/fr.json index aebb72a76ed..8b61f7b3267 100644 --- a/homeassistant/components/august/translations/fr.json +++ b/homeassistant/components/august/translations/fr.json @@ -5,8 +5,8 @@ "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { - "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", - "invalid_auth": "Authentification non valide", + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide", "unknown": "Erreur inattendue" }, "step": { diff --git a/homeassistant/components/aurora/translations/fr.json b/homeassistant/components/aurora/translations/fr.json index 473ecefdbd9..aa334f074c6 100644 --- a/homeassistant/components/aurora/translations/fr.json +++ b/homeassistant/components/aurora/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "error": { - "cannot_connect": "\u00c9chec \u00e0 la connexion" + "cannot_connect": "\u00c9chec de connexion" }, "step": { "user": { diff --git a/homeassistant/components/awair/translations/fr.json b/homeassistant/components/awair/translations/fr.json index dd90f940977..65d550b52a6 100644 --- a/homeassistant/components/awair/translations/fr.json +++ b/homeassistant/components/awair/translations/fr.json @@ -3,11 +3,11 @@ "abort": { "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9", "no_devices_found": "Aucun appareil trouv\u00e9 sur le r\u00e9seau", - "reauth_successful": "Jeton d'acc\u00e8s mis \u00e0 jour avec succ\u00e8s" + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { "invalid_access_token": "Jeton d'acc\u00e8s non valide", - "unknown": "Erreur d'API Awair inconnue." + "unknown": "Erreur inattendue" }, "step": { "reauth": { diff --git a/homeassistant/components/axis/translations/fr.json b/homeassistant/components/axis/translations/fr.json index ed4113d02e2..ea3f93feb50 100644 --- a/homeassistant/components/axis/translations/fr.json +++ b/homeassistant/components/axis/translations/fr.json @@ -7,7 +7,7 @@ }, "error": { "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", - "already_in_progress": "Le flux de configuration de l'appareil est d\u00e9j\u00e0 en cours.", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", "cannot_connect": "\u00c9chec de connexion", "invalid_auth": "Authentification invalide" }, @@ -15,7 +15,7 @@ "step": { "user": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "password": "Mot de passe", "port": "Port", "username": "Nom d'utilisateur" diff --git a/homeassistant/components/azure_devops/translations/fr.json b/homeassistant/components/azure_devops/translations/fr.json index 5e62d54ec1d..27513074046 100644 --- a/homeassistant/components/azure_devops/translations/fr.json +++ b/homeassistant/components/azure_devops/translations/fr.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9", - "reauth_successful": "Jeton d'acc\u00e8s mis \u00e0 jour avec succ\u00e8s" + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { "cannot_connect": "\u00c9chec de connexion", diff --git a/homeassistant/components/binary_sensor/translations/fr.json b/homeassistant/components/binary_sensor/translations/fr.json index aa0686c0375..74f54a30814 100644 --- a/homeassistant/components/binary_sensor/translations/fr.json +++ b/homeassistant/components/binary_sensor/translations/fr.json @@ -115,12 +115,12 @@ "on": "Connect\u00e9" }, "door": { - "off": "Ferm\u00e9e", - "on": "Ouverte" + "off": "Ferm\u00e9", + "on": "Ouvert" }, "garage_door": { - "off": "Ferm\u00e9e", - "on": "Ouverte" + "off": "Ferm\u00e9", + "on": "Ouvert" }, "gas": { "off": "Non d\u00e9tect\u00e9", @@ -191,8 +191,8 @@ "on": "D\u00e9tect\u00e9e" }, "window": { - "off": "Ferm\u00e9e", - "on": "Ouverte" + "off": "Ferm\u00e9", + "on": "Ouvert" } }, "title": "Capteur binaire" diff --git a/homeassistant/components/blebox/translations/fr.json b/homeassistant/components/blebox/translations/fr.json index d30d026d177..83983be5be1 100644 --- a/homeassistant/components/blebox/translations/fr.json +++ b/homeassistant/components/blebox/translations/fr.json @@ -2,11 +2,11 @@ "config": { "abort": { "address_already_configured": "Un p\u00e9riph\u00e9rique BleBox est d\u00e9j\u00e0 configur\u00e9 \u00e0 {address}.", - "already_configured": "Ce p\u00e9riph\u00e9rique BleBox est d\u00e9j\u00e0 configur\u00e9." + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Impossible de connecter le p\u00e9riph\u00e9rique BleBox. (V\u00e9rifiez les journaux pour les erreurs.)", - "unknown": "Erreur inconnue lors de la connexion au p\u00e9riph\u00e9rique BleBox. (V\u00e9rifiez les journaux pour les erreurs.)", + "cannot_connect": "\u00c9chec de connexion", + "unknown": "Erreur inattendue", "unsupported_version": "L'appareil BleBox a un micrologiciel obsol\u00e8te. Veuillez d'abord le mettre \u00e0 jour." }, "flow_title": "P\u00e9riph\u00e9rique Blebox: {name} ({host)}", diff --git a/homeassistant/components/blink/translations/fr.json b/homeassistant/components/blink/translations/fr.json index 23bb7fb91dd..bef14d641f7 100644 --- a/homeassistant/components/blink/translations/fr.json +++ b/homeassistant/components/blink/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "P\u00e9riph\u00e9rique d\u00e9j\u00e0 configur\u00e9" + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { "cannot_connect": "\u00c9chec de connexion", @@ -20,7 +20,7 @@ "user": { "data": { "password": "Mot de passe", - "username": "Identifiant" + "username": "Nom d'utilisateur" }, "title": "Connectez-vous avec un compte Blink" } diff --git a/homeassistant/components/bmw_connected_drive/translations/fr.json b/homeassistant/components/bmw_connected_drive/translations/fr.json index 900b352ecb6..aadce398cdc 100644 --- a/homeassistant/components/bmw_connected_drive/translations/fr.json +++ b/homeassistant/components/bmw_connected_drive/translations/fr.json @@ -4,7 +4,7 @@ "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "\u00c9chec \u00e0 la connexion", + "cannot_connect": "\u00c9chec de connexion", "invalid_auth": "Authentification invalide" }, "step": { diff --git a/homeassistant/components/bond/translations/fr.json b/homeassistant/components/bond/translations/fr.json index d9eb14b1a62..7d2450dc9b5 100644 --- a/homeassistant/components/bond/translations/fr.json +++ b/homeassistant/components/bond/translations/fr.json @@ -4,7 +4,7 @@ "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Echec de connexion", + "cannot_connect": "\u00c9chec de connexion", "invalid_auth": "Authentification invalide", "old_firmware": "Ancien micrologiciel non pris en charge sur l'appareil Bond - veuillez mettre \u00e0 niveau avant de continuer", "unknown": "Erreur inattendue" @@ -19,7 +19,7 @@ }, "user": { "data": { - "access_token": "Token d'acc\u00e8s", + "access_token": "Jeton d'acc\u00e8s", "host": "H\u00f4te" } } diff --git a/homeassistant/components/bosch_shc/translations/fr.json b/homeassistant/components/bosch_shc/translations/fr.json index 38a48b269b4..43eeb04490d 100644 --- a/homeassistant/components/bosch_shc/translations/fr.json +++ b/homeassistant/components/bosch_shc/translations/fr.json @@ -2,11 +2,11 @@ "config": { "abort": { "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", - "reauth_successful": "R\u00e9-authentification r\u00e9ussie" + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { "cannot_connect": "\u00c9chec de connexion", - "invalid_auth": "Authentification incorrecte", + "invalid_auth": "Authentification invalide", "pairing_failed": "L'appairage a \u00e9chou\u00e9\u00a0; veuillez v\u00e9rifier que le Bosch Smart Home Controller est en mode d'appairage (voyant clignotant) et que votre mot de passe est correct.", "session_error": "Erreur de session\u00a0: l'API renvoie un r\u00e9sultat non-OK.", "unknown": "Erreur inattendue" @@ -23,7 +23,7 @@ }, "reauth_confirm": { "description": "L'int\u00e9gration bosch_shc doit r\u00e9-authentifier votre compte", - "title": "R\u00e9authentification de l'int\u00e9gration" + "title": "R\u00e9-authentifier l'int\u00e9gration" }, "user": { "data": { diff --git a/homeassistant/components/braviatv/translations/fr.json b/homeassistant/components/braviatv/translations/fr.json index 68988b1fbfe..d609f1a2fa1 100644 --- a/homeassistant/components/braviatv/translations/fr.json +++ b/homeassistant/components/braviatv/translations/fr.json @@ -1,12 +1,12 @@ { "config": { "abort": { - "already_configured": "Ce t\u00e9l\u00e9viseur est d\u00e9j\u00e0 configur\u00e9.", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", "no_ip_control": "Le contr\u00f4le IP est d\u00e9sactiv\u00e9 sur votre t\u00e9l\u00e9viseur ou le t\u00e9l\u00e9viseur n'est pas pris en charge." }, "error": { - "cannot_connect": "\u00c9chec de connexion, h\u00f4te ou code PIN non valide.", - "invalid_host": "Nom d'h\u00f4te ou adresse IP invalide.", + "cannot_connect": "\u00c9chec de connexion", + "invalid_host": "Nom d'h\u00f4te ou adresse IP non valide", "unsupported_model": "Votre mod\u00e8le de t\u00e9l\u00e9viseur n'est pas pris en charge." }, "step": { @@ -19,7 +19,7 @@ }, "user": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP" + "host": "H\u00f4te" }, "description": "Configurez l'int\u00e9gration du t\u00e9l\u00e9viseur Sony Bravia. Si vous rencontrez des probl\u00e8mes de configuration, rendez-vous sur: https://www.home-assistant.io/integrations/braviatv \n\n Assurez-vous que votre t\u00e9l\u00e9viseur est allum\u00e9.", "title": "Sony Bravia TV" diff --git a/homeassistant/components/broadlink/translations/fr.json b/homeassistant/components/broadlink/translations/fr.json index 1d80059fb7a..e39b722d8c9 100644 --- a/homeassistant/components/broadlink/translations/fr.json +++ b/homeassistant/components/broadlink/translations/fr.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", - "already_in_progress": "Il y a d\u00e9j\u00e0 un processus de configuration en cours pour cet appareil", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", "cannot_connect": "\u00c9chec de connexion", "invalid_host": "Nom d'h\u00f4te ou adresse IP non valide", "not_supported": "Dispositif non pris en charge", diff --git a/homeassistant/components/brother/translations/fr.json b/homeassistant/components/brother/translations/fr.json index 5eb00bb4447..d5a53b94622 100644 --- a/homeassistant/components/brother/translations/fr.json +++ b/homeassistant/components/brother/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Cette imprimante est d\u00e9j\u00e0 configur\u00e9e.", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", "unsupported_model": "Ce mod\u00e8le d'imprimante n'est pas pris en charge." }, "error": { @@ -13,7 +13,7 @@ "step": { "user": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "type": "Type d'imprimante" }, "description": "Configurez l'int\u00e9gration de l'imprimante Brother. Si vous avez des probl\u00e8mes avec la configuration, allez \u00e0 : https://www.home-assistant.io/integrations/brother" diff --git a/homeassistant/components/bsblan/translations/fr.json b/homeassistant/components/bsblan/translations/fr.json index 0c54aecdd88..2d1388bed18 100644 --- a/homeassistant/components/bsblan/translations/fr.json +++ b/homeassistant/components/bsblan/translations/fr.json @@ -10,7 +10,7 @@ "step": { "user": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "passkey": "Cha\u00eene de cl\u00e9 d'acc\u00e8s", "password": "Mot de passe", "port": "Port", diff --git a/homeassistant/components/cast/translations/fr.json b/homeassistant/components/cast/translations/fr.json index b5274cae453..c07122f820f 100644 --- a/homeassistant/components/cast/translations/fr.json +++ b/homeassistant/components/cast/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "single_instance_allowed": "Une seule configuration de Google Cast est n\u00e9cessaire." + "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." }, "error": { "invalid_known_hosts": "Les h\u00f4tes connus doivent \u00eatre une liste d'h\u00f4tes s\u00e9par\u00e9s par des virgules." @@ -15,7 +15,7 @@ "title": "Google Cast" }, "confirm": { - "description": "Voulez-vous configurer Google Cast?" + "description": "Voulez-vous commencer la configuration ?" } } }, diff --git a/homeassistant/components/cert_expiry/translations/fr.json b/homeassistant/components/cert_expiry/translations/fr.json index 070b5e26cba..ae2a83be849 100644 --- a/homeassistant/components/cert_expiry/translations/fr.json +++ b/homeassistant/components/cert_expiry/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Cette combinaison h\u00f4te et port est d\u00e9j\u00e0 configur\u00e9e", + "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9", "import_failed": "\u00c9chec de l'importation \u00e0 partir de la configuration" }, "error": { diff --git a/homeassistant/components/cloudflare/translations/fr.json b/homeassistant/components/cloudflare/translations/fr.json index 677dc8552fb..7add319cf29 100644 --- a/homeassistant/components/cloudflare/translations/fr.json +++ b/homeassistant/components/cloudflare/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "reauth_successful": "R\u00e9-authentification r\u00e9ussie", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi", "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible.", "unknown": "Erreur inattendue" }, @@ -14,7 +14,7 @@ "step": { "reauth_confirm": { "data": { - "api_token": "Jeton API", + "api_token": "Jeton d'API", "description": "R\u00e9-authentifiez-vous avec votre compte Cloudflare." } }, diff --git a/homeassistant/components/co2signal/translations/fr.json b/homeassistant/components/co2signal/translations/fr.json index 4b36bd3bd74..1ed60fd3227 100644 --- a/homeassistant/components/co2signal/translations/fr.json +++ b/homeassistant/components/co2signal/translations/fr.json @@ -24,7 +24,7 @@ }, "user": { "data": { - "api_key": "Token d'acc\u00e8s", + "api_key": "Jeton d'acc\u00e8s", "location": "Obtenir des donn\u00e9es pour" }, "description": "Visitez https://co2signal.com/ pour demander un jeton." diff --git a/homeassistant/components/coinbase/translations/fr.json b/homeassistant/components/coinbase/translations/fr.json index e0ec1ae200d..101411edabe 100644 --- a/homeassistant/components/coinbase/translations/fr.json +++ b/homeassistant/components/coinbase/translations/fr.json @@ -5,13 +5,13 @@ }, "error": { "cannot_connect": "\u00c9chec de connexion", - "invalid_auth": "Authentification incorrecte", + "invalid_auth": "Authentification invalide", "unknown": "Erreur inattendue" }, "step": { "user": { "data": { - "api_key": "cl\u00e9 API", + "api_key": "Cl\u00e9 d'API", "api_token": "API secr\u00e8te", "currencies": "Devises du solde du compte", "exchange_rates": "Taux d'\u00e9change" diff --git a/homeassistant/components/coronavirus/translations/fr.json b/homeassistant/components/coronavirus/translations/fr.json index 9a9a960cf31..26b2937a8ae 100644 --- a/homeassistant/components/coronavirus/translations/fr.json +++ b/homeassistant/components/coronavirus/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Ce pays est d\u00e9j\u00e0 configur\u00e9.", + "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9", "cannot_connect": "\u00c9chec de connexion" }, "step": { diff --git a/homeassistant/components/daikin/translations/fr.json b/homeassistant/components/daikin/translations/fr.json index ab193dc20af..8d033bdb853 100644 --- a/homeassistant/components/daikin/translations/fr.json +++ b/homeassistant/components/daikin/translations/fr.json @@ -13,8 +13,8 @@ "user": { "data": { "api_key": "Cl\u00e9 d'API", - "host": "Nom d'h\u00f4te ou adresse IP", - "password": "Mot de passe de l'appareil (utilis\u00e9 uniquement par les appareils SKYFi)" + "host": "H\u00f4te", + "password": "Mot de passe" }, "description": "Saisissez l'adresse IP de votre Daikin AC. \n\n Notez que Cl\u00e9 d'API et Mot de passe sont utilis\u00e9s respectivement par les p\u00e9riph\u00e9riques BRP072Cxx et SKYFi.", "title": "Configurer Daikin AC" diff --git a/homeassistant/components/deconz/translations/fr.json b/homeassistant/components/deconz/translations/fr.json index 05d53405e54..48322a55659 100644 --- a/homeassistant/components/deconz/translations/fr.json +++ b/homeassistant/components/deconz/translations/fr.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Ce pont est d\u00e9j\u00e0 configur\u00e9", - "already_in_progress": "Le flux de configuration pour le pont est d\u00e9j\u00e0 en cours.", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", "no_bridges": "Aucun pont deCONZ n'a \u00e9t\u00e9 d\u00e9couvert", "no_hardware_available": "Aucun mat\u00e9riel radio connect\u00e9 \u00e0 deCONZ", "not_deconz_bridge": "Pas un pont deCONZ", @@ -23,7 +23,7 @@ }, "manual_input": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "port": "Port" } }, diff --git a/homeassistant/components/denonavr/translations/fr.json b/homeassistant/components/denonavr/translations/fr.json index 797f10fe06f..0b7fb29a6d8 100644 --- a/homeassistant/components/denonavr/translations/fr.json +++ b/homeassistant/components/denonavr/translations/fr.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "already_configured": "Appareil d\u00e9j\u00e0 configur\u00e9", - "already_in_progress": "Le flux de configuration pour ce Denon AVR est d\u00e9j\u00e0 en cours", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", "cannot_connect": "\u00c9chec de la connexion, veuillez r\u00e9essayer, d\u00e9brancher l'alimentation secteur et les c\u00e2bles ethernet et les reconnecter peut aider", "not_denonavr_manufacturer": "Ce n'est pas un r\u00e9cepteur r\u00e9seau Denon AVR, le fabricant d\u00e9couvert ne correspondait pas", "not_denonavr_missing": "Ce n'est pas un r\u00e9cepteur r\u00e9seau Denon AVR, les informations d\u00e9couvertes ne sont pas compl\u00e8tes" diff --git a/homeassistant/components/devolo_home_control/translations/fr.json b/homeassistant/components/devolo_home_control/translations/fr.json index bc9a5715238..020b469092d 100644 --- a/homeassistant/components/devolo_home_control/translations/fr.json +++ b/homeassistant/components/devolo_home_control/translations/fr.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "already_configured": "Cette unit\u00e9 centrale Home Control est d\u00e9j\u00e0 utilis\u00e9e.", - "reauth_successful": "R\u00e9-authentification r\u00e9ussie" + "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { "invalid_auth": "Authentification invalide", @@ -13,14 +13,14 @@ "data": { "mydevolo_url": "URL mydevolo", "password": "Mot de passe", - "username": "Adresse e-mail / devolo ID" + "username": "Email / devolo ID" } }, "zeroconf_confirm": { "data": { "mydevolo_url": "mydevolo URL", "password": "Mot de passe", - "username": "[%key:common::config_flow::d ata::email%] / devolo ID" + "username": "Email / devolo ID" } } } diff --git a/homeassistant/components/directv/translations/fr.json b/homeassistant/components/directv/translations/fr.json index 4876c455ff0..6d5bc24cb5a 100644 --- a/homeassistant/components/directv/translations/fr.json +++ b/homeassistant/components/directv/translations/fr.json @@ -18,7 +18,7 @@ }, "user": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP" + "host": "H\u00f4te" } } } diff --git a/homeassistant/components/doorbird/translations/fr.json b/homeassistant/components/doorbird/translations/fr.json index fd8bf04d29e..92961908be4 100644 --- a/homeassistant/components/doorbird/translations/fr.json +++ b/homeassistant/components/doorbird/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Ce DoorBird est d\u00e9j\u00e0 configur\u00e9", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", "link_local_address": "Les adresses locales ne sont pas prises en charge", "not_doorbird_device": "Cet appareil n'est pas un DoorBird" }, @@ -14,10 +14,10 @@ "step": { "user": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "name": "Nom de l'appareil", "password": "Mot de passe", - "username": "Identifiant" + "username": "Nom d'utilisateur" }, "title": "Connectez-vous au DoorBird" } diff --git a/homeassistant/components/dunehd/translations/fr.json b/homeassistant/components/dunehd/translations/fr.json index 7547ceadb72..0e8cb6d6ff8 100644 --- a/homeassistant/components/dunehd/translations/fr.json +++ b/homeassistant/components/dunehd/translations/fr.json @@ -6,7 +6,7 @@ "error": { "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", "cannot_connect": "\u00c9chec de connexion", - "invalid_host": "Nom d'h\u00f4te ou adresse IP invalide." + "invalid_host": "Nom d'h\u00f4te ou adresse IP non valide" }, "step": { "user": { diff --git a/homeassistant/components/ecobee/translations/fr.json b/homeassistant/components/ecobee/translations/fr.json index acbc909d881..8f8d0c42b59 100644 --- a/homeassistant/components/ecobee/translations/fr.json +++ b/homeassistant/components/ecobee/translations/fr.json @@ -14,7 +14,7 @@ }, "user": { "data": { - "api_key": "Cl\u00e9 API" + "api_key": "Cl\u00e9 d'API" }, "description": "Veuillez entrer la cl\u00e9 API obtenue aupr\u00e8s d'ecobee.com.", "title": "Cl\u00e9 API ecobee" diff --git a/homeassistant/components/econet/translations/fr.json b/homeassistant/components/econet/translations/fr.json index 64fd39c852a..e6081bef90a 100644 --- a/homeassistant/components/econet/translations/fr.json +++ b/homeassistant/components/econet/translations/fr.json @@ -1,13 +1,13 @@ { "config": { "abort": { - "already_configured": "L'appareil est d\u00e9ja configur\u00e9 ", - "cannot_connect": "\u00c9chec de la connexion ", - "invalid_auth": "Authentification invalide " + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide" }, "error": { - "cannot_connect": "\u00c9chec de la connexion", - "invalid_auth": "Authentification invalide " + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide" }, "step": { "user": { diff --git a/homeassistant/components/elgato/translations/fr.json b/homeassistant/components/elgato/translations/fr.json index ccc325c84e2..6cd1cd247a7 100644 --- a/homeassistant/components/elgato/translations/fr.json +++ b/homeassistant/components/elgato/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Cet appareil Elgato Key Light est d\u00e9j\u00e0 configur\u00e9.", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", "cannot_connect": "\u00c9chec de connexion" }, "error": { @@ -11,7 +11,7 @@ "step": { "user": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "port": "Port" }, "description": "Configurez votre Elgato Key Light pour l'int\u00e9grer \u00e0 Home Assistant." diff --git a/homeassistant/components/elkm1/translations/fr.json b/homeassistant/components/elkm1/translations/fr.json index 618299def29..665ac4b4d92 100644 --- a/homeassistant/components/elkm1/translations/fr.json +++ b/homeassistant/components/elkm1/translations/fr.json @@ -5,8 +5,8 @@ "already_configured": "Un ElkM1 avec ce pr\u00e9fixe est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", - "invalid_auth": "Authentification non valide", + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide", "unknown": "Erreur inattendue" }, "step": { diff --git a/homeassistant/components/emonitor/translations/fr.json b/homeassistant/components/emonitor/translations/fr.json index fcfee3bc710..9557160e335 100644 --- a/homeassistant/components/emonitor/translations/fr.json +++ b/homeassistant/components/emonitor/translations/fr.json @@ -15,7 +15,7 @@ }, "user": { "data": { - "host": "Hote" + "host": "H\u00f4te" } } } diff --git a/homeassistant/components/enphase_envoy/translations/fr.json b/homeassistant/components/enphase_envoy/translations/fr.json index 9587739e88a..a369562d70c 100644 --- a/homeassistant/components/enphase_envoy/translations/fr.json +++ b/homeassistant/components/enphase_envoy/translations/fr.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", - "reauth_successful": "R\u00e9-authentification r\u00e9ussie" + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { "cannot_connect": "\u00c9chec de connexion", @@ -13,7 +13,7 @@ "step": { "user": { "data": { - "host": "Hote", + "host": "H\u00f4te", "password": "Mot de passe", "username": "Nom d'utilisateur" } diff --git a/homeassistant/components/epson/translations/fr.json b/homeassistant/components/epson/translations/fr.json index 51a18284e73..c07a305a677 100644 --- a/homeassistant/components/epson/translations/fr.json +++ b/homeassistant/components/epson/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "error": { - "cannot_connect": "Echec de la connection", + "cannot_connect": "\u00c9chec de connexion", "powered_off": "Le projecteur est-il allum\u00e9? Vous devez allumer le projecteur pour la configuration initiale." }, "step": { diff --git a/homeassistant/components/esphome/translations/fr.json b/homeassistant/components/esphome/translations/fr.json index 0b977815f6b..9f6f092afff 100644 --- a/homeassistant/components/esphome/translations/fr.json +++ b/homeassistant/components/esphome/translations/fr.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "already_configured": "ESP est d\u00e9j\u00e0 configur\u00e9", - "already_in_progress": "La configuration ESP est d\u00e9j\u00e0 en cours" + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours" }, "error": { "connection_error": "Impossible de se connecter \u00e0 ESP. Assurez-vous que votre fichier YAML contient une ligne 'api:'.", @@ -23,7 +23,7 @@ }, "user": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "port": "Port" }, "description": "Veuillez saisir les param\u00e8tres de connexion de votre n\u0153ud [ESPHome] (https://esphomelib.com/)." diff --git a/homeassistant/components/ezviz/translations/fr.json b/homeassistant/components/ezviz/translations/fr.json index 216cf73c7b7..ddce689a2ba 100644 --- a/homeassistant/components/ezviz/translations/fr.json +++ b/homeassistant/components/ezviz/translations/fr.json @@ -15,7 +15,7 @@ "confirm": { "data": { "password": "Mot de passe", - "username": "Identifiant" + "username": "Nom d'utilisateur" }, "description": "Entrez les informations d'identification RTSP pour la cam\u00e9ra Ezviz {serial} avec IP {ip_address}", "title": "Cam\u00e9ra Ezviz d\u00e9couverte" @@ -24,7 +24,7 @@ "data": { "password": "Mot de passe", "url": "URL", - "username": "Identifiant" + "username": "Nom d'utilisateur" }, "title": "Connectez-vous \u00e0 Ezviz Cloud" }, @@ -32,7 +32,7 @@ "data": { "password": "Mot de passe", "url": "URL", - "username": "Identifiant" + "username": "Nom d'utilisateur" }, "description": "Sp\u00e9cifiez manuellement l'URL de votre r\u00e9gion", "title": "Connectez-vous \u00e0 l'URL Ezviz personnalis\u00e9e" diff --git a/homeassistant/components/fireservicerota/translations/fr.json b/homeassistant/components/fireservicerota/translations/fr.json index fdbf28e32e1..477e8df621c 100644 --- a/homeassistant/components/fireservicerota/translations/fr.json +++ b/homeassistant/components/fireservicerota/translations/fr.json @@ -1,14 +1,14 @@ { "config": { "abort": { - "already_configured": "Le compte \u00e0 d\u00e9j\u00e0 \u00e9t\u00e9 configur\u00e9", + "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9", "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "create_entry": { - "default": "Autentification r\u00e9ussie" + "default": "Authentification r\u00e9ussie" }, "error": { - "invalid_auth": "Autentification invalide" + "invalid_auth": "Authentification invalide" }, "step": { "reauth": { @@ -21,7 +21,7 @@ "data": { "password": "Mot de passe", "url": "Site web", - "username": "Utilisateur" + "username": "Nom d'utilisateur" } } } diff --git a/homeassistant/components/firmata/translations/fr.json b/homeassistant/components/firmata/translations/fr.json index b3509c9126c..ea09d58c354 100644 --- a/homeassistant/components/firmata/translations/fr.json +++ b/homeassistant/components/firmata/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "cannot_connect": "Impossible de se connecter \u00e0 la carte Firmata pendant la configuration" + "cannot_connect": "\u00c9chec de connexion" }, "step": { "one": "Vide ", diff --git a/homeassistant/components/fjaraskupan/translations/cs.json b/homeassistant/components/fjaraskupan/translations/cs.json new file mode 100644 index 00000000000..3f0012e00d2 --- /dev/null +++ b/homeassistant/components/fjaraskupan/translations/cs.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "no_devices_found": "V s\u00edti nebyla nalezena \u017e\u00e1dn\u00e1 za\u0159\u00edzen\u00ed" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fjaraskupan/translations/fr.json b/homeassistant/components/fjaraskupan/translations/fr.json new file mode 100644 index 00000000000..4239f4eff7f --- /dev/null +++ b/homeassistant/components/fjaraskupan/translations/fr.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "Aucun appareil trouv\u00e9 sur le r\u00e9seau", + "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." + }, + "step": { + "confirm": { + "description": "Voulez-vous configurer Fj\u00e4r\u00e5skupan?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flick_electric/translations/fr.json b/homeassistant/components/flick_electric/translations/fr.json index 291a8e59fe4..fc7605c0975 100644 --- a/homeassistant/components/flick_electric/translations/fr.json +++ b/homeassistant/components/flick_electric/translations/fr.json @@ -1,11 +1,11 @@ { "config": { "abort": { - "already_configured": "Ce compte est d\u00e9j\u00e0 configur\u00e9." + "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", - "invalid_auth": "Authentification non valide", + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide", "unknown": "Erreur inattendue" }, "step": { diff --git a/homeassistant/components/flume/translations/fr.json b/homeassistant/components/flume/translations/fr.json index 5fe7fcf2ca4..43157234eff 100644 --- a/homeassistant/components/flume/translations/fr.json +++ b/homeassistant/components/flume/translations/fr.json @@ -1,12 +1,12 @@ { "config": { "abort": { - "already_configured": "Ce compte est d\u00e9j\u00e0 configur\u00e9.", - "reauth_successful": "R\u00e9-authentification r\u00e9ussie" + "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { - "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", - "invalid_auth": "Authentification non valide", + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide", "unknown": "Erreur inattendue" }, "step": { diff --git a/homeassistant/components/flunearyou/translations/fr.json b/homeassistant/components/flunearyou/translations/fr.json index bd1cc30ca5b..a9d8064d865 100644 --- a/homeassistant/components/flunearyou/translations/fr.json +++ b/homeassistant/components/flunearyou/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Coordonn\u00e9es d\u00e9j\u00e0 enregistr\u00e9es" + "already_configured": "L'emplacement est d\u00e9j\u00e0 configur\u00e9" }, "error": { "unknown": "Erreur inattendue" diff --git a/homeassistant/components/forked_daapd/translations/fr.json b/homeassistant/components/forked_daapd/translations/fr.json index 2e20c75d33f..2b3633f01a0 100644 --- a/homeassistant/components/forked_daapd/translations/fr.json +++ b/homeassistant/components/forked_daapd/translations/fr.json @@ -1,12 +1,12 @@ { "config": { "abort": { - "already_configured": "Le p\u00e9riph\u00e9rique est d\u00e9j\u00e0 configur\u00e9.", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", "not_forked_daapd": "Le p\u00e9riph\u00e9rique n'est pas un serveur forked-daapd." }, "error": { "forbidden": "Impossible de se connecter. Veuillez v\u00e9rifier vos autorisations r\u00e9seau forked-daapd.", - "unknown_error": "Erreur inconnue", + "unknown_error": "Erreur inattendue", "websocket_not_enabled": "le socket web du serveur forked-daapd n'est pas activ\u00e9.", "wrong_host_or_port": "Impossible de se connecter. Veuillez v\u00e9rifier l'h\u00f4te et le port.", "wrong_password": "Mot de passe incorrect.", diff --git a/homeassistant/components/foscam/translations/fr.json b/homeassistant/components/foscam/translations/fr.json index 1424c22ad61..7c0bb8398da 100644 --- a/homeassistant/components/foscam/translations/fr.json +++ b/homeassistant/components/foscam/translations/fr.json @@ -1,10 +1,10 @@ { "config": { "abort": { - "already_configured": "Le p\u00e9riph\u00e9rique est d\u00e9j\u00e0 configur\u00e9" + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Echec de connection", + "cannot_connect": "\u00c9chec de connexion", "invalid_auth": "Authentification invalide", "invalid_response": "R\u00e9ponse invalide de l\u2019appareil", "unknown": "Erreur inattendue" diff --git a/homeassistant/components/freebox/translations/fr.json b/homeassistant/components/freebox/translations/fr.json index f06cfed6cd7..7b459ebc0ab 100644 --- a/homeassistant/components/freebox/translations/fr.json +++ b/homeassistant/components/freebox/translations/fr.json @@ -1,12 +1,12 @@ { "config": { "abort": { - "already_configured": "H\u00f4te d\u00e9j\u00e0 configur\u00e9" + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", + "cannot_connect": "\u00c9chec de connexion", "register_failed": "\u00c9chec de l'inscription, veuillez r\u00e9essayer", - "unknown": "Erreur inconnue: veuillez r\u00e9essayer plus tard" + "unknown": "Erreur inattendue" }, "step": { "link": { @@ -15,7 +15,7 @@ }, "user": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "port": "Port" }, "title": "Freebox" diff --git a/homeassistant/components/freedompro/translations/fr.json b/homeassistant/components/freedompro/translations/fr.json index 6667226a206..090c95fa3c2 100644 --- a/homeassistant/components/freedompro/translations/fr.json +++ b/homeassistant/components/freedompro/translations/fr.json @@ -10,7 +10,7 @@ "step": { "user": { "data": { - "api_key": "cl\u00e9 API" + "api_key": "Cl\u00e9 d'API" }, "description": "Veuillez saisir la cl\u00e9 API obtenue sur https://home.freedompro.eu", "title": "Cl\u00e9 API Freedompro" diff --git a/homeassistant/components/fritz/translations/fr.json b/homeassistant/components/fritz/translations/fr.json index 6518b5ed20c..f46c47cab5e 100644 --- a/homeassistant/components/fritz/translations/fr.json +++ b/homeassistant/components/fritz/translations/fr.json @@ -2,14 +2,14 @@ "config": { "abort": { "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", - "already_in_progress": "Le flux de configuration est d\u00e9j\u00e0 en cours", - "reauth_successful": "R\u00e9-authentification r\u00e9ussie" + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { - "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9 ", - "already_in_progress": "Le flux de configuration est d\u00e9j\u00e0 en cours", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", "cannot_connect": "\u00c9chec de connexion", - "connection_error": "Erreur de connexion", + "connection_error": "\u00c9chec de connexion", "invalid_auth": "Authentification invalide" }, "flow_title": "FRITZ!Box Tools : {name}", diff --git a/homeassistant/components/fritzbox/translations/fr.json b/homeassistant/components/fritzbox/translations/fr.json index e6302964988..1f9d5d9893b 100644 --- a/homeassistant/components/fritzbox/translations/fr.json +++ b/homeassistant/components/fritzbox/translations/fr.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "already_configured": "Cette AVM FRITZ!Box est d\u00e9j\u00e0 configur\u00e9e.", - "already_in_progress": "Une configuration d'AVM FRITZ!Box est d\u00e9j\u00e0 en cours.", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", "no_devices_found": "Aucun appareil trouv\u00e9 sur le r\u00e9seau", "not_supported": "Connect\u00e9 \u00e0 AVM FRITZ! Box mais impossible de contr\u00f4ler les appareils Smart Home.", "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" @@ -28,7 +28,7 @@ }, "user": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "password": "Mot de passe", "username": "Nom d'utilisateur" }, diff --git a/homeassistant/components/fritzbox_callmonitor/translations/fr.json b/homeassistant/components/fritzbox_callmonitor/translations/fr.json index cde9023273c..99baf51d0bd 100644 --- a/homeassistant/components/fritzbox_callmonitor/translations/fr.json +++ b/homeassistant/components/fritzbox_callmonitor/translations/fr.json @@ -1,9 +1,9 @@ { "config": { "abort": { - "already_configured": "L'appareil est d\u00e9ja configur\u00e9 ", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", "insufficient_permissions": "L'utilisateur ne dispose pas des autorisations n\u00e9cessaires pour acc\u00e9der aux param\u00e8tres d'AVM FRITZ! Box et \u00e0 ses r\u00e9pertoires.", - "no_devices_found": "Aucun appreil trouv\u00e9 sur le r\u00e9seau " + "no_devices_found": "Aucun appareil trouv\u00e9 sur le r\u00e9seau" }, "error": { "invalid_auth": "Authentification invalide" @@ -17,10 +17,10 @@ }, "user": { "data": { - "host": "Hote", + "host": "H\u00f4te", "password": "Mot de passe", "port": "Port", - "username": "Nom d'utilisateur " + "username": "Nom d'utilisateur" } } } diff --git a/homeassistant/components/gdacs/translations/fr.json b/homeassistant/components/gdacs/translations/fr.json index df44a1d9fa5..b1e77bf4d43 100644 --- a/homeassistant/components/gdacs/translations/fr.json +++ b/homeassistant/components/gdacs/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "L'emplacement est d\u00e9j\u00e0 configur\u00e9." + "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9" }, "step": { "user": { diff --git a/homeassistant/components/geonetnz_quakes/translations/fr.json b/homeassistant/components/geonetnz_quakes/translations/fr.json index e448f9993bf..aeb3763ce46 100644 --- a/homeassistant/components/geonetnz_quakes/translations/fr.json +++ b/homeassistant/components/geonetnz_quakes/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "L'emplacement est d\u00e9j\u00e0 configur\u00e9." + "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9" }, "step": { "user": { diff --git a/homeassistant/components/gios/translations/fr.json b/homeassistant/components/gios/translations/fr.json index 2b02b5cfea0..af107914c06 100644 --- a/homeassistant/components/gios/translations/fr.json +++ b/homeassistant/components/gios/translations/fr.json @@ -1,17 +1,17 @@ { "config": { "abort": { - "already_configured": "L'int\u00e9gration GIO\u015a pour cette station de mesure est d\u00e9j\u00e0 configur\u00e9e." + "already_configured": "L'emplacement est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Impossible de se connecter au serveur GIOS", + "cannot_connect": "\u00c9chec de connexion", "invalid_sensors_data": "Donn\u00e9es des capteurs non valides pour cette station de mesure.", "wrong_station_id": "L'identifiant de la station de mesure n'est pas correct." }, "step": { "user": { "data": { - "name": "Nom de l'int\u00e9gration", + "name": "Nom", "station_id": "Identifiant de la station de mesure" }, "description": "Mettre en place l'int\u00e9gration de la qualit\u00e9 de l'air GIO\u015a (Inspection g\u00e9n\u00e9rale polonaise de la protection de l'environnement). Si vous avez besoin d'aide pour la configuration, regardez ici: https://www.home-assistant.io/integrations/gios", diff --git a/homeassistant/components/glances/translations/fr.json b/homeassistant/components/glances/translations/fr.json index cc9be2d6ce8..6fafa8a3a51 100644 --- a/homeassistant/components/glances/translations/fr.json +++ b/homeassistant/components/glances/translations/fr.json @@ -1,10 +1,10 @@ { "config": { "abort": { - "already_configured": "L'h\u00f4te est d\u00e9j\u00e0 configur\u00e9." + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Impossible de se connecter \u00e0 l'h\u00f4te", + "cannot_connect": "\u00c9chec de connexion", "wrong_version": "Version non prise en charge (2 ou 3 uniquement)" }, "step": { @@ -14,9 +14,9 @@ "name": "Nom", "password": "Mot de passe", "port": "Port", - "ssl": "Utiliser SSL / TLS pour se connecter au syst\u00e8me Glances", + "ssl": "Utilise un certificat SSL", "username": "Nom d'utilisateur", - "verify_ssl": "V\u00e9rifier la certification du syst\u00e8me", + "verify_ssl": "V\u00e9rifier le certificat SSL", "version": "Glances API Version (2 ou 3)" }, "title": "Installation de Glances" diff --git a/homeassistant/components/goalzero/translations/fr.json b/homeassistant/components/goalzero/translations/fr.json index bb6a777b6be..469f37143a2 100644 --- a/homeassistant/components/goalzero/translations/fr.json +++ b/homeassistant/components/goalzero/translations/fr.json @@ -8,7 +8,7 @@ "error": { "cannot_connect": "\u00c9chec de connexion", "invalid_host": "Nom d'h\u00f4te ou adresse IP non valide", - "unknown": "Erreur inconnue" + "unknown": "Erreur inattendue" }, "step": { "confirm_discovery": { diff --git a/homeassistant/components/google_travel_time/translations/fr.json b/homeassistant/components/google_travel_time/translations/fr.json index d9c19d0d793..790fe9117bd 100644 --- a/homeassistant/components/google_travel_time/translations/fr.json +++ b/homeassistant/components/google_travel_time/translations/fr.json @@ -9,7 +9,7 @@ "step": { "user": { "data": { - "api_key": "common::config_flow::data::api_key", + "api_key": "Cl\u00e9 d'API", "destination": "Destination", "name": "Nom", "origin": "Origine" diff --git a/homeassistant/components/growatt_server/translations/fr.json b/homeassistant/components/growatt_server/translations/fr.json index 1ad47166f8d..939111c4151 100644 --- a/homeassistant/components/growatt_server/translations/fr.json +++ b/homeassistant/components/growatt_server/translations/fr.json @@ -4,7 +4,7 @@ "no_plants": "Aucune plante n'a \u00e9t\u00e9 trouv\u00e9e sur ce compte" }, "error": { - "invalid_auth": "Authentification incorrecte" + "invalid_auth": "Authentification invalide" }, "step": { "plant": { diff --git a/homeassistant/components/guardian/translations/fr.json b/homeassistant/components/guardian/translations/fr.json index 62ffae35776..e1e1bcb4fcf 100644 --- a/homeassistant/components/guardian/translations/fr.json +++ b/homeassistant/components/guardian/translations/fr.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "already_configured": "Ce p\u00e9riph\u00e9rique Guardian a d\u00e9j\u00e0 \u00e9t\u00e9 configur\u00e9.", - "already_in_progress": "La configuration de l'appareil Guardian est d\u00e9j\u00e0 en cours.", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", "cannot_connect": "\u00c9chec de connexion" }, "step": { diff --git a/homeassistant/components/hangouts/translations/fr.json b/homeassistant/components/hangouts/translations/fr.json index 68e652db309..ab2d2fc5168 100644 --- a/homeassistant/components/hangouts/translations/fr.json +++ b/homeassistant/components/hangouts/translations/fr.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "already_configured": "Google Hangouts est d\u00e9j\u00e0 configur\u00e9", - "unknown": "Une erreur inconnue s'est produite" + "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9", + "unknown": "Erreur inattendue" }, "error": { "invalid_2fa": "Authentification \u00e0 2 facteurs invalide, veuillez r\u00e9essayer.", @@ -20,7 +20,7 @@ "user": { "data": { "authorization_code": "Code d'autorisation (requis pour l'authentification manuelle)", - "email": "Adresse e-mail", + "email": "Email", "password": "Mot de passe" }, "description": "Vide", diff --git a/homeassistant/components/harmony/translations/fr.json b/homeassistant/components/harmony/translations/fr.json index 4343ec3139d..25b9e24eb5f 100644 --- a/homeassistant/components/harmony/translations/fr.json +++ b/homeassistant/components/harmony/translations/fr.json @@ -4,7 +4,7 @@ "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", + "cannot_connect": "\u00c9chec de connexion", "unknown": "Erreur inattendue" }, "flow_title": "Logitech Harmony Hub {name}", @@ -15,7 +15,7 @@ }, "user": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "name": "Nom du Hub" }, "title": "Configuration de Logitech Harmony Hub" diff --git a/homeassistant/components/hisense_aehw4a1/translations/fr.json b/homeassistant/components/hisense_aehw4a1/translations/fr.json index 7fa1598fa76..72d3eec98dd 100644 --- a/homeassistant/components/hisense_aehw4a1/translations/fr.json +++ b/homeassistant/components/hisense_aehw4a1/translations/fr.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "no_devices_found": "Aucun p\u00e9riph\u00e9rique AEH-W4A1 trouv\u00e9 sur le r\u00e9seau.", - "single_instance_allowed": "Une seule configuration de AEH-W4A1 est possible." + "no_devices_found": "Aucun appareil trouv\u00e9 sur le r\u00e9seau", + "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." }, "step": { "confirm": { diff --git a/homeassistant/components/home_connect/translations/fr.json b/homeassistant/components/home_connect/translations/fr.json index 42a0c34fe81..5eba6fa03c1 100644 --- a/homeassistant/components/home_connect/translations/fr.json +++ b/homeassistant/components/home_connect/translations/fr.json @@ -1,15 +1,15 @@ { "config": { "abort": { - "missing_configuration": "Le composant Home Connect n'est pas configur\u00e9. Veuillez suivre la documentation.", + "missing_configuration": "Le composant n'est pas configur\u00e9. Veuillez suivre la documentation.", "no_url_available": "Aucune URL disponible. Pour plus d'informations sur cette erreur, [consultez la section d'aide] ( {docs_url} )" }, "create_entry": { - "default": "Authentification r\u00e9ussie avec Home Connect." + "default": "Authentification r\u00e9ussie" }, "step": { "pick_implementation": { - "title": "Choisissez la m\u00e9thode d'authentification" + "title": "S\u00e9lectionner une m\u00e9thode d'authentification" } } } diff --git a/homeassistant/components/home_plus_control/translations/fr.json b/homeassistant/components/home_plus_control/translations/fr.json index c39d4a2867e..489e0499324 100644 --- a/homeassistant/components/home_plus_control/translations/fr.json +++ b/homeassistant/components/home_plus_control/translations/fr.json @@ -1,19 +1,19 @@ { "config": { "abort": { - "already_configured": "Compte d\u00e9j\u00e0 configur\u00e9", + "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9", "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", - "authorize_url_timeout": "[%key::common::config_flow::abort::oauth2_authorize_url_timeout%]", - "missing_configuration": "Le composant n'est pas configur\u00e9. Merci de suivre la documentation.", - "no_url_available": "Aucune URL disponible. Pour plus d'information sur cette erreur, [v\u00e9rifier la section d'aide]({docs_url})", - "single_instance_allowed": "[%key::common::config_flow::abort::single_instance_allowed%]" + "authorize_url_timeout": "D\u00e9lai de g\u00e9n\u00e9ration de l'URL d'authentification d\u00e9pass\u00e9.", + "missing_configuration": "Le composant n'est pas configur\u00e9. Veuillez suivre la documentation.", + "no_url_available": "Aucune URL disponible. Pour plus d'informations sur cette erreur, [consultez la section d'aide] ( {docs_url} )", + "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." }, "create_entry": { "default": "Authentification r\u00e9ussie" }, "step": { "pick_implementation": { - "title": "Choisir une m\u00e9thode d'authentification" + "title": "S\u00e9lectionner une m\u00e9thode d'authentification" } } }, diff --git a/homeassistant/components/homekit/translations/pl.json b/homeassistant/components/homekit/translations/pl.json index 15ccb10e118..cf415e4d735 100644 --- a/homeassistant/components/homekit/translations/pl.json +++ b/homeassistant/components/homekit/translations/pl.json @@ -21,7 +21,8 @@ "step": { "advanced": { "data": { - "auto_start": "Automatyczne uruchomienie (wy\u0142\u0105cz, je\u015bli r\u0119cznie uruchamiasz us\u0142ug\u0119 homekit.start)" + "auto_start": "Automatyczne uruchomienie (wy\u0142\u0105cz, je\u015bli r\u0119cznie uruchamiasz us\u0142ug\u0119 homekit.start)", + "devices": "Urz\u0105dzenia (Wyzwalacze)" }, "description": "Te ustawienia nale\u017cy dostosowa\u0107 tylko wtedy, gdy HomeKit nie dzia\u0142a.", "title": "Konfiguracja zaawansowana" diff --git a/homeassistant/components/homekit/translations/zh-Hant.json b/homeassistant/components/homekit/translations/zh-Hant.json index b6389567969..a4a5ac06b96 100644 --- a/homeassistant/components/homekit/translations/zh-Hant.json +++ b/homeassistant/components/homekit/translations/zh-Hant.json @@ -21,9 +21,10 @@ "step": { "advanced": { "data": { - "auto_start": "\u81ea\u52d5\u555f\u52d5\uff08\u5047\u5982\u624b\u52d5\u4f7f\u7528 homekit.start \u670d\u52d9\u6642\u3001\u8acb\u95dc\u9589\uff09" + "auto_start": "\u81ea\u52d5\u555f\u52d5\uff08\u5047\u5982\u624b\u52d5\u4f7f\u7528 homekit.start \u670d\u52d9\u6642\u3001\u8acb\u95dc\u9589\uff09", + "devices": "\u88dd\u7f6e\uff08\u89f8\u767c\u5668\uff09" }, - "description": "\u50c5\u65bc Homekit \u7121\u6cd5\u6b63\u5e38\u4f7f\u7528\u6642\uff0c\u8abf\u6574\u6b64\u4e9b\u8a2d\u5b9a\u3002", + "description": "\u70ba\u6240\u9078\u64c7\u7684\u88dd\u7f6e\u65b0\u589e\u53ef\u7a0b\u5f0f\u958b\u95dc\u3002\u7576\u88dd\u7f6e\u89f8\u767c\u5668\u89f8\u767c\u6642\u3001Homekit \u53ef\u8a2d\u5b9a\u70ba\u57f7\u884c\u81ea\u52d5\u5316\u6216\u5834\u666f\u3002", "title": "\u9032\u968e\u8a2d\u5b9a" }, "cameras": { diff --git a/homeassistant/components/homekit_controller/translations/fr.json b/homeassistant/components/homekit_controller/translations/fr.json index faf970f88eb..72d8d58517f 100644 --- a/homeassistant/components/homekit_controller/translations/fr.json +++ b/homeassistant/components/homekit_controller/translations/fr.json @@ -3,7 +3,7 @@ "abort": { "accessory_not_found_error": "Impossible d'ajouter le couplage car l'appareil est introuvable.", "already_configured": "L'accessoire est d\u00e9j\u00e0 configur\u00e9 avec ce contr\u00f4leur.", - "already_in_progress": "Le flux de configuration de l'appareil est d\u00e9j\u00e0 en cours.", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", "already_paired": "Cet accessoire est d\u00e9j\u00e0 associ\u00e9 \u00e0 un autre appareil. R\u00e9initialisez l\u2019accessoire et r\u00e9essayez.", "ignored_model": "La prise en charge de HomeKit pour ce mod\u00e8le est bloqu\u00e9e car une int\u00e9gration native plus compl\u00e8te est disponible.", "invalid_config_entry": "Cet appareil est pr\u00eat \u00e0 \u00eatre coupl\u00e9, mais il existe d\u00e9j\u00e0 une entr\u00e9e de configuration en conflit dans Home Assistant \u00e0 supprimer.", diff --git a/homeassistant/components/homematicip_cloud/translations/fr.json b/homeassistant/components/homematicip_cloud/translations/fr.json index 212206bb298..106ff6225d5 100644 --- a/homeassistant/components/homematicip_cloud/translations/fr.json +++ b/homeassistant/components/homematicip_cloud/translations/fr.json @@ -1,9 +1,9 @@ { "config": { "abort": { - "already_configured": "Le point d'acc\u00e8s est d\u00e9j\u00e0 configur\u00e9", - "connection_aborted": "Impossible de se connecter au serveur HMIP", - "unknown": "Une erreur inconnue s'est produite." + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "connection_aborted": "\u00c9chec de connexion", + "unknown": "Erreur inattendue" }, "error": { "invalid_sgtin_or_pin": "Code SGTIN ou PIN invalide, veuillez r\u00e9essayer.", @@ -16,7 +16,7 @@ "data": { "hapid": "ID du point d'acc\u00e8s (SGTIN)", "name": "Nom (facultatif, utilis\u00e9 comme pr\u00e9fixe de nom pour tous les p\u00e9riph\u00e9riques)", - "pin": "Code PIN (facultatif)" + "pin": "Code PIN" }, "title": "Choisissez le point d'acc\u00e8s HomematicIP" }, diff --git a/homeassistant/components/honeywell/translations/fr.json b/homeassistant/components/honeywell/translations/fr.json index b9b625eb589..fbe3def3113 100644 --- a/homeassistant/components/honeywell/translations/fr.json +++ b/homeassistant/components/honeywell/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "error": { - "invalid_auth": "Authentification incorrecte" + "invalid_auth": "Authentification invalide" }, "step": { "user": { diff --git a/homeassistant/components/huawei_lte/translations/fr.json b/homeassistant/components/huawei_lte/translations/fr.json index da8fbcbd115..d04f7e83d3f 100644 --- a/homeassistant/components/huawei_lte/translations/fr.json +++ b/homeassistant/components/huawei_lte/translations/fr.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "already_configured": "Cet appareil est d\u00e9j\u00e0 configur\u00e9", - "already_in_progress": "Ce p\u00e9riph\u00e9rique est d\u00e9j\u00e0 en cours de configuration", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", "not_huawei_lte": "Pas un appareil Huawei LTE" }, "error": { diff --git a/homeassistant/components/hue/translations/fr.json b/homeassistant/components/hue/translations/fr.json index e9dd546840f..ee82b3ec4e6 100644 --- a/homeassistant/components/hue/translations/fr.json +++ b/homeassistant/components/hue/translations/fr.json @@ -2,13 +2,13 @@ "config": { "abort": { "all_configured": "Tous les ponts Philips Hue sont d\u00e9j\u00e0 configur\u00e9s", - "already_configured": "Ce pont est d\u00e9j\u00e0 configur\u00e9", - "already_in_progress": "Le flux de configuration pour le pont est d\u00e9j\u00e0 en cours.", - "cannot_connect": "Connexion au pont impossible", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", + "cannot_connect": "\u00c9chec de connexion", "discover_timeout": "D\u00e9tection de ponts Philips Hue impossible", "no_bridges": "Aucun pont Philips Hue n'a \u00e9t\u00e9 d\u00e9couvert", "not_hue_bridge": "Pas de pont Hue", - "unknown": "Une erreur inconnue s'est produite" + "unknown": "Erreur inattendue" }, "error": { "linking": "Erreur inattendue", diff --git a/homeassistant/components/huisbaasje/translations/fr.json b/homeassistant/components/huisbaasje/translations/fr.json index 9012293fa48..aa84ec33d8c 100644 --- a/homeassistant/components/huisbaasje/translations/fr.json +++ b/homeassistant/components/huisbaasje/translations/fr.json @@ -1,12 +1,12 @@ { "config": { "abort": { - "already_configured": "L'appareil est d\u00e9ja configur\u00e9 " + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "[%key::common::config_flow::error::cannot_connect%]", - "invalid_auth": "Authentification invalide ", - "unknown": "Erreur inatendue" + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide", + "unknown": "Erreur inattendue" }, "step": { "user": { diff --git a/homeassistant/components/humidifier/translations/fr.json b/homeassistant/components/humidifier/translations/fr.json index 236c3b93343..746d9930426 100644 --- a/homeassistant/components/humidifier/translations/fr.json +++ b/homeassistant/components/humidifier/translations/fr.json @@ -20,8 +20,8 @@ }, "state": { "_": { - "off": "Eteint", - "on": "Allum\u00e9" + "off": "Inactif", + "on": "Actif" } }, "title": "Humidificateur" diff --git a/homeassistant/components/hunterdouglas_powerview/translations/fr.json b/homeassistant/components/hunterdouglas_powerview/translations/fr.json index 68ea30b293f..9eb8edda7db 100644 --- a/homeassistant/components/hunterdouglas_powerview/translations/fr.json +++ b/homeassistant/components/hunterdouglas_powerview/translations/fr.json @@ -4,7 +4,7 @@ "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", + "cannot_connect": "\u00c9chec de connexion", "unknown": "Erreur inattendue" }, "flow_title": "{name} ({host})", diff --git a/homeassistant/components/hvv_departures/translations/fr.json b/homeassistant/components/hvv_departures/translations/fr.json index 1ade2ebd742..0c7fd03f148 100644 --- a/homeassistant/components/hvv_departures/translations/fr.json +++ b/homeassistant/components/hvv_departures/translations/fr.json @@ -4,8 +4,8 @@ "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", - "invalid_auth": "Authentification non valide", + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide", "no_results": "Aucun r\u00e9sultat. Essayez avec une autre station / adresse" }, "step": { diff --git a/homeassistant/components/hyperion/translations/fr.json b/homeassistant/components/hyperion/translations/fr.json index 57870c3b3ef..7bb5c02543d 100644 --- a/homeassistant/components/hyperion/translations/fr.json +++ b/homeassistant/components/hyperion/translations/fr.json @@ -1,18 +1,18 @@ { "config": { "abort": { - "already_configured": "Le service est d\u00e9ja configur\u00e9 ", + "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9", "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", "auth_new_token_not_granted_error": "Le jeton nouvellement cr\u00e9\u00e9 n'a pas \u00e9t\u00e9 approuv\u00e9 sur l'interface utilisateur Hyperion", "auth_new_token_not_work_error": "\u00c9chec de l'authentification \u00e0 l'aide du jeton nouvellement cr\u00e9\u00e9", "auth_required_error": "Impossible de d\u00e9terminer si une autorisation est requise", - "cannot_connect": "Echec de connection", + "cannot_connect": "\u00c9chec de connexion", "no_id": "L'instance Hyperion Ambilight n'a pas signal\u00e9 son identifiant", "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { - "cannot_connect": "Echec de la connexion ", - "invalid_access_token": "jeton d'acc\u00e8s Invalide" + "cannot_connect": "\u00c9chec de connexion", + "invalid_access_token": "Jeton d'acc\u00e8s non valide" }, "step": { "auth": { diff --git a/homeassistant/components/ialarm/translations/fr.json b/homeassistant/components/ialarm/translations/fr.json index ae61afa9d78..03844ccf99b 100644 --- a/homeassistant/components/ialarm/translations/fr.json +++ b/homeassistant/components/ialarm/translations/fr.json @@ -4,7 +4,7 @@ "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Echec de la connexion", + "cannot_connect": "\u00c9chec de connexion", "unknown": "Erreur inattendue" }, "step": { diff --git a/homeassistant/components/icloud/translations/fr.json b/homeassistant/components/icloud/translations/fr.json index 77fc925851e..f9c0ceb3db9 100644 --- a/homeassistant/components/icloud/translations/fr.json +++ b/homeassistant/components/icloud/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Compte d\u00e9j\u00e0 configur\u00e9", + "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9", "no_device": "Aucun de vos appareils n'a activ\u00e9 \"Find my iPhone\"", "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, diff --git a/homeassistant/components/ios/translations/fr.json b/homeassistant/components/ios/translations/fr.json index a6318718f94..85000f60a49 100644 --- a/homeassistant/components/ios/translations/fr.json +++ b/homeassistant/components/ios/translations/fr.json @@ -1,11 +1,11 @@ { "config": { "abort": { - "single_instance_allowed": "Seule une configuration de Home Assistant iOS est n\u00e9cessaire." + "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." }, "step": { "confirm": { - "description": "Voulez-vous configurer le composant Home Assistant iOS?" + "description": "Voulez-vous commencer la configuration ?" } } } diff --git a/homeassistant/components/ipp/translations/fr.json b/homeassistant/components/ipp/translations/fr.json index 17d7375dbf4..21805c55330 100644 --- a/homeassistant/components/ipp/translations/fr.json +++ b/homeassistant/components/ipp/translations/fr.json @@ -18,10 +18,10 @@ "user": { "data": { "base_path": "Chemin d'acc\u00e8s relatif \u00e0 l'imprimante", - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "port": "Port", - "ssl": "L'imprimante prend en charge la communication via SSL/TLS", - "verify_ssl": "L'imprimante utilise un certificat SSL appropri\u00e9" + "ssl": "Utilise un certificat SSL", + "verify_ssl": "V\u00e9rifier le certificat SSL" }, "description": "Configurez votre imprimante via IPP (Internet Printing Protocol) pour l'int\u00e9grer \u00e0 Home Assistant", "title": "Reliez votre imprimante" diff --git a/homeassistant/components/iqvia/translations/fr.json b/homeassistant/components/iqvia/translations/fr.json index 22f45ac2f0e..a967ff490e8 100644 --- a/homeassistant/components/iqvia/translations/fr.json +++ b/homeassistant/components/iqvia/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Ce code postal a d\u00e9j\u00e0 \u00e9t\u00e9 configur\u00e9." + "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9" }, "error": { "invalid_zip_code": "Code postal invalide" diff --git a/homeassistant/components/isy994/translations/fr.json b/homeassistant/components/isy994/translations/fr.json index 0bd04cd14b1..8a4e4ffa707 100644 --- a/homeassistant/components/isy994/translations/fr.json +++ b/homeassistant/components/isy994/translations/fr.json @@ -4,8 +4,8 @@ "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Echec de connexion", - "invalid_auth": "Autentification non valide", + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide", "invalid_host": "L'entr\u00e9e d'h\u00f4te n'\u00e9tait pas au format URL complet, par exemple http://192.168.10.100:80", "unknown": "Erreur inattendue" }, diff --git a/homeassistant/components/izone/translations/fr.json b/homeassistant/components/izone/translations/fr.json index 0c6faf83e6e..eb86962e11b 100644 --- a/homeassistant/components/izone/translations/fr.json +++ b/homeassistant/components/izone/translations/fr.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "no_devices_found": "Aucun p\u00e9riph\u00e9rique iZone trouv\u00e9 sur le r\u00e9seau.", - "single_instance_allowed": "Une seule configuration d'iZone est n\u00e9cessaire." + "no_devices_found": "Aucun appareil trouv\u00e9 sur le r\u00e9seau", + "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." }, "step": { "confirm": { diff --git a/homeassistant/components/juicenet/translations/fr.json b/homeassistant/components/juicenet/translations/fr.json index 2448ec6263c..f4e5bfa53d5 100644 --- a/homeassistant/components/juicenet/translations/fr.json +++ b/homeassistant/components/juicenet/translations/fr.json @@ -1,17 +1,17 @@ { "config": { "abort": { - "already_configured": "Ce compte JuiceNet est d\u00e9j\u00e0 configur\u00e9" + "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", - "invalid_auth": "Authentification non valide", + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide", "unknown": "Erreur inattendue" }, "step": { "user": { "data": { - "api_token": "Jeton d'API JuiceNet" + "api_token": "Jeton d'API" }, "description": "Vous aurez besoin du jeton API de https://home.juice.net/Manage.", "title": "Se connecter \u00e0 JuiceNet" diff --git a/homeassistant/components/kodi/translations/fr.json b/homeassistant/components/kodi/translations/fr.json index a7c4b3f34a1..8e740466bc4 100644 --- a/homeassistant/components/kodi/translations/fr.json +++ b/homeassistant/components/kodi/translations/fr.json @@ -3,7 +3,7 @@ "abort": { "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", "cannot_connect": "\u00c9chec de connexion", - "invalid_auth": "Authentification erron\u00e9e", + "invalid_auth": "Authentification invalide", "no_uuid": "L'instance Kodi n'a pas d'identifiant unique. Cela est probablement d\u00fb \u00e0 une ancienne version de Kodi (17.x ou inf\u00e9rieure). Vous pouvez configurer l'int\u00e9gration manuellement ou passer \u00e0 une version plus r\u00e9cente de Kodi.", "unknown": "Erreur inattendue" }, @@ -29,7 +29,7 @@ "data": { "host": "H\u00f4te", "port": "Port", - "ssl": "Connexion via SSL" + "ssl": "Utilise un certificat SSL" }, "description": "Informations de connexion Kodi. Veuillez vous assurer d'activer \"Autoriser le contr\u00f4le de Kodi via HTTP\" dans Syst\u00e8me / Param\u00e8tres / R\u00e9seau / Services." }, diff --git a/homeassistant/components/konnected/translations/fr.json b/homeassistant/components/konnected/translations/fr.json index 3c020967c8d..7d50c474909 100644 --- a/homeassistant/components/konnected/translations/fr.json +++ b/homeassistant/components/konnected/translations/fr.json @@ -2,12 +2,12 @@ "config": { "abort": { "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", - "already_in_progress": "Le flux de configuration de l'appareil est d\u00e9j\u00e0 en cours.", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", "not_konn_panel": "Non reconnu comme appareil Konnected.io", - "unknown": "Une erreur inconnue s'est produite" + "unknown": "Erreur inattendue" }, "error": { - "cannot_connect": "Impossible de se connecter \u00e0 Konnected Panel sur {host} : {port}" + "cannot_connect": "\u00c9chec de connexion" }, "step": { "confirm": { diff --git a/homeassistant/components/kostal_plenticore/translations/fr.json b/homeassistant/components/kostal_plenticore/translations/fr.json index 08a75486d7f..a06ade90e9a 100644 --- a/homeassistant/components/kostal_plenticore/translations/fr.json +++ b/homeassistant/components/kostal_plenticore/translations/fr.json @@ -5,13 +5,13 @@ }, "error": { "cannot_connect": "\u00c9chec de connexion", - "invalid_auth": "Erreur inattendue", + "invalid_auth": "Authentification invalide", "unknown": "Erreur inattendue" }, "step": { "user": { "data": { - "host": "Hote", + "host": "H\u00f4te", "password": "Mot de passe" } } diff --git a/homeassistant/components/kulersky/translations/fr.json b/homeassistant/components/kulersky/translations/fr.json index 42f356ac365..e9ae4e0b644 100644 --- a/homeassistant/components/kulersky/translations/fr.json +++ b/homeassistant/components/kulersky/translations/fr.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "no_devices_found": "Aucun appareil n'a \u00e9t\u00e9 d\u00e9tect\u00e9 sur le r\u00e9seau", - "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Seulement une seule configuration est possible " + "no_devices_found": "Aucun appareil trouv\u00e9 sur le r\u00e9seau", + "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." }, "step": { "confirm": { diff --git a/homeassistant/components/lifx/translations/fr.json b/homeassistant/components/lifx/translations/fr.json index 837ca29a314..f8cc0a9dddd 100644 --- a/homeassistant/components/lifx/translations/fr.json +++ b/homeassistant/components/lifx/translations/fr.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "no_devices_found": "Aucun p\u00e9riph\u00e9rique LIFX trouv\u00e9 sur le r\u00e9seau.", - "single_instance_allowed": "Une seule configuration de LIFX est possible." + "no_devices_found": "Aucun appareil trouv\u00e9 sur le r\u00e9seau", + "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." }, "step": { "confirm": { diff --git a/homeassistant/components/local_ip/translations/fr.json b/homeassistant/components/local_ip/translations/fr.json index 554ecef2380..d7d0eef17c1 100644 --- a/homeassistant/components/local_ip/translations/fr.json +++ b/homeassistant/components/local_ip/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "single_instance_allowed": "Une seule configuration d'IP locale est autoris\u00e9e" + "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." }, "step": { "user": { diff --git a/homeassistant/components/locative/translations/fr.json b/homeassistant/components/locative/translations/fr.json index d17ede01d2e..9c9414caf9b 100644 --- a/homeassistant/components/locative/translations/fr.json +++ b/homeassistant/components/locative/translations/fr.json @@ -9,7 +9,7 @@ }, "step": { "user": { - "description": "\u00cates-vous s\u00fbr de vouloir configurer le Webhook Locative ?", + "description": "Voulez-vous commencer la configuration ?", "title": "Configurer le Locative Webhook" } } diff --git a/homeassistant/components/lutron_caseta/translations/fr.json b/homeassistant/components/lutron_caseta/translations/fr.json index ff561548b44..0dcc8755173 100644 --- a/homeassistant/components/lutron_caseta/translations/fr.json +++ b/homeassistant/components/lutron_caseta/translations/fr.json @@ -1,12 +1,12 @@ { "config": { "abort": { - "already_configured": "Pont Cas\u00e9ta d\u00e9j\u00e0 configur\u00e9.", - "cannot_connect": "Installation annul\u00e9e du pont Cas\u00e9ta en raison d'un \u00e9chec de connexion.", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "cannot_connect": "\u00c9chec de connexion", "not_lutron_device": "L'appareil d\u00e9couvert n'est pas un appareil Lutron" }, "error": { - "cannot_connect": "\u00c9chec de la connexion \u00e0 la passerelle Cas\u00e9ta; v\u00e9rifiez la configuration de votre h\u00f4te et de votre certificat." + "cannot_connect": "\u00c9chec de connexion" }, "flow_title": "Lutron Cas\u00e9ta {name} ( {host} )", "step": { @@ -20,7 +20,7 @@ }, "user": { "data": { - "host": "Hote" + "host": "H\u00f4te" }, "description": "Saisissez l'adresse IP de l'appareil.", "title": "Se connecter automatiquement au pont" diff --git a/homeassistant/components/lyric/translations/fr.json b/homeassistant/components/lyric/translations/fr.json index 9eb02fc3811..25992620652 100644 --- a/homeassistant/components/lyric/translations/fr.json +++ b/homeassistant/components/lyric/translations/fr.json @@ -3,7 +3,7 @@ "abort": { "authorize_url_timeout": "D\u00e9lai de g\u00e9n\u00e9ration de l'URL d'authentification d\u00e9pass\u00e9.", "missing_configuration": "Le composant n'est pas configur\u00e9. Veuillez suivre la documentation.", - "reauth_successful": "R\u00e9-authentification r\u00e9ussie" + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "create_entry": { "default": "Authentification r\u00e9ussie" @@ -14,7 +14,7 @@ }, "reauth_confirm": { "description": "L'int\u00e9gration Lyric doit authentifier \u00e0 nouveau votre compte.", - "title": "R\u00e9authentification de l'int\u00e9gration" + "title": "R\u00e9-authentifier l'int\u00e9gration" } } } diff --git a/homeassistant/components/mazda/translations/fr.json b/homeassistant/components/mazda/translations/fr.json index ff0ee33d368..1f6f442a3ae 100644 --- a/homeassistant/components/mazda/translations/fr.json +++ b/homeassistant/components/mazda/translations/fr.json @@ -1,12 +1,12 @@ { "config": { "abort": { - "already_configured": "Le compte est d\u00e9ja configur\u00e9", + "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9", "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { "account_locked": "Compte bloqu\u00e9. Veuillez r\u00e9essayer plus tard.", - "cannot_connect": "Echec de la connexion", + "cannot_connect": "\u00c9chec de connexion", "invalid_auth": "Authentification invalide", "unknown": "Erreur inattendue" }, diff --git a/homeassistant/components/melcloud/translations/fr.json b/homeassistant/components/melcloud/translations/fr.json index 1ee577e0bfa..f7f40c68bc5 100644 --- a/homeassistant/components/melcloud/translations/fr.json +++ b/homeassistant/components/melcloud/translations/fr.json @@ -4,8 +4,8 @@ "already_configured": "L'int\u00e9gration MELCloud est d\u00e9j\u00e0 configur\u00e9e pour cet e-mail. Le jeton d'acc\u00e8s a \u00e9t\u00e9 actualis\u00e9." }, "error": { - "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", - "invalid_auth": "Authentification non valide", + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide", "unknown": "Erreur inattendue" }, "step": { diff --git a/homeassistant/components/meteo_france/translations/fr.json b/homeassistant/components/meteo_france/translations/fr.json index 8e321092d63..1121dd02b17 100644 --- a/homeassistant/components/meteo_france/translations/fr.json +++ b/homeassistant/components/meteo_france/translations/fr.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "already_configured": "Ville d\u00e9j\u00e0 configur\u00e9e", - "unknown": "Erreur inconnue: veuillez r\u00e9essayer plus tard" + "already_configured": "L'emplacement est d\u00e9j\u00e0 configur\u00e9", + "unknown": "Erreur inattendue" }, "error": { "empty": "Aucun r\u00e9sultat dans la recherche par ville: veuillez v\u00e9rifier le champ de la ville" diff --git a/homeassistant/components/metoffice/translations/fr.json b/homeassistant/components/metoffice/translations/fr.json index a046a71fe95..d5219342c00 100644 --- a/homeassistant/components/metoffice/translations/fr.json +++ b/homeassistant/components/metoffice/translations/fr.json @@ -1,16 +1,16 @@ { "config": { "abort": { - "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" + "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Echec de connexion", + "cannot_connect": "\u00c9chec de connexion", "unknown": "Erreur inattendue" }, "step": { "user": { "data": { - "api_key": "Cl\u00e9 API Met Office DataPoint", + "api_key": "Cl\u00e9 d'API", "latitude": "Latitude", "longitude": "Longitude" }, diff --git a/homeassistant/components/mikrotik/translations/fr.json b/homeassistant/components/mikrotik/translations/fr.json index 1d328a843cc..5632ab5b8dd 100644 --- a/homeassistant/components/mikrotik/translations/fr.json +++ b/homeassistant/components/mikrotik/translations/fr.json @@ -1,17 +1,17 @@ { "config": { "abort": { - "already_configured": "Mikrotik est d\u00e9j\u00e0 configur\u00e9" + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "\u00c9chec de la connexion", + "cannot_connect": "\u00c9chec de connexion", "invalid_auth": "Authentification invalide", "name_exists": "Le nom existe" }, "step": { "user": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "name": "Nom", "password": "Mot de passe", "port": "Port", diff --git a/homeassistant/components/minecraft_server/translations/fr.json b/homeassistant/components/minecraft_server/translations/fr.json index dde686376cc..48bf06592dd 100644 --- a/homeassistant/components/minecraft_server/translations/fr.json +++ b/homeassistant/components/minecraft_server/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "L'h\u00f4te est d\u00e9j\u00e0 configur\u00e9." + "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9" }, "error": { "cannot_connect": "\u00c9chec de connexion au serveur. Veuillez v\u00e9rifier l'h\u00f4te et le port et r\u00e9essayer. Assurez-vous \u00e9galement que vous ex\u00e9cutez au moins Minecraft version 1.7 sur votre serveur.", @@ -11,7 +11,7 @@ "step": { "user": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "name": "Nom" }, "description": "Configurez votre instance Minecraft Server pour permettre la surveillance.", diff --git a/homeassistant/components/modern_forms/translations/fr.json b/homeassistant/components/modern_forms/translations/fr.json index d68f4a7f680..cde37b5251b 100644 --- a/homeassistant/components/modern_forms/translations/fr.json +++ b/homeassistant/components/modern_forms/translations/fr.json @@ -10,7 +10,7 @@ "flow_title": "{name}", "step": { "confirm": { - "description": "Voulez-vous commencer la configuration\u00a0?" + "description": "Voulez-vous commencer la configuration ?" }, "user": { "data": { diff --git a/homeassistant/components/monoprice/translations/fr.json b/homeassistant/components/monoprice/translations/fr.json index 9a0ffa2354d..5489fb4e0e6 100644 --- a/homeassistant/components/monoprice/translations/fr.json +++ b/homeassistant/components/monoprice/translations/fr.json @@ -4,7 +4,7 @@ "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", + "cannot_connect": "\u00c9chec de connexion", "unknown": "Erreur inattendue" }, "step": { diff --git a/homeassistant/components/motion_blinds/translations/fr.json b/homeassistant/components/motion_blinds/translations/fr.json index b6715970e40..dc883066c47 100644 --- a/homeassistant/components/motion_blinds/translations/fr.json +++ b/homeassistant/components/motion_blinds/translations/fr.json @@ -1,9 +1,9 @@ { "config": { "abort": { - "already_configured": "L'appareil est d\u00e9ja configur\u00e9 ", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", - "connection_error": "\u00c9chec de la connexion " + "connection_error": "\u00c9chec de connexion" }, "error": { "discovery_error": "Impossible de d\u00e9couvrir une Motion Gateway" @@ -12,7 +12,7 @@ "step": { "connect": { "data": { - "api_key": "Cl\u00e9 API" + "api_key": "Cl\u00e9 d'API" }, "description": "Vous aurez besoin de la cl\u00e9 API de 16 caract\u00e8res, voir https://www.home-assistant.io/integrations/motion_blinds/#retrieving-the-key pour les instructions", "title": "Stores de mouvement" @@ -26,7 +26,7 @@ }, "user": { "data": { - "api_key": "Clef d'API", + "api_key": "Cl\u00e9 d'API", "host": "Adresse IP" }, "description": "Connectez-vous \u00e0 votre Motion Gateway, si l'adresse IP n'est pas d\u00e9finie, la d\u00e9tection automatique est utilis\u00e9e", diff --git a/homeassistant/components/motioneye/translations/fr.json b/homeassistant/components/motioneye/translations/fr.json index b8d79b683a6..c7a27090396 100644 --- a/homeassistant/components/motioneye/translations/fr.json +++ b/homeassistant/components/motioneye/translations/fr.json @@ -2,11 +2,11 @@ "config": { "abort": { "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9", - "reauth_successful": "R\u00e9-authentification r\u00e9ussie" + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { "cannot_connect": "\u00c9chec de connexion", - "invalid_auth": "Authentification incorrecte", + "invalid_auth": "Authentification invalide", "invalid_url": "URL invalide", "unknown": "Erreur inattendue" }, diff --git a/homeassistant/components/mqtt/translations/ca.json b/homeassistant/components/mqtt/translations/ca.json index 108da3b1263..569adc2e423 100644 --- a/homeassistant/components/mqtt/translations/ca.json +++ b/homeassistant/components/mqtt/translations/ca.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "El servei ja est\u00e0 configurat", "single_instance_allowed": "Ja configurat. Nom\u00e9s \u00e9s possible una sola configuraci\u00f3." }, "error": { diff --git a/homeassistant/components/mqtt/translations/cs.json b/homeassistant/components/mqtt/translations/cs.json index 9876e2509b3..f82a3f1c973 100644 --- a/homeassistant/components/mqtt/translations/cs.json +++ b/homeassistant/components/mqtt/translations/cs.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "Slu\u017eba je ji\u017e nastavena", "single_instance_allowed": "Ji\u017e nastaveno. Je mo\u017en\u00e1 pouze jedin\u00e1 konfigurace." }, "error": { diff --git a/homeassistant/components/mqtt/translations/de.json b/homeassistant/components/mqtt/translations/de.json index 2961a69ed1b..3a71d7bc547 100644 --- a/homeassistant/components/mqtt/translations/de.json +++ b/homeassistant/components/mqtt/translations/de.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "Der Dienst ist bereits konfiguriert", "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich." }, "error": { diff --git a/homeassistant/components/mqtt/translations/et.json b/homeassistant/components/mqtt/translations/et.json index 3b7a0c87f57..0fee989f25d 100644 --- a/homeassistant/components/mqtt/translations/et.json +++ b/homeassistant/components/mqtt/translations/et.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "Teenus on juba seadistatud", "single_instance_allowed": "Lubatud on ainult \u00fcks MQTT konfiguratsioon." }, "error": { diff --git a/homeassistant/components/mqtt/translations/fr.json b/homeassistant/components/mqtt/translations/fr.json index af13e69ab4a..13bfce8dd5e 100644 --- a/homeassistant/components/mqtt/translations/fr.json +++ b/homeassistant/components/mqtt/translations/fr.json @@ -1,10 +1,11 @@ { "config": { "abort": { - "single_instance_allowed": "Une seule configuration de MQTT est autoris\u00e9e." + "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9", + "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." }, "error": { - "cannot_connect": "Impossible de se connecter au broker." + "cannot_connect": "\u00c9chec de connexion" }, "step": { "broker": { @@ -52,7 +53,7 @@ "error": { "bad_birth": "Topic de naissance invalide", "bad_will": "Topic de testament invalide", - "cannot_connect": "Impossible de se connecter au broker." + "cannot_connect": "\u00c9chec de connexion" }, "step": { "broker": { @@ -60,7 +61,7 @@ "broker": "Broker", "password": "Mot de passe", "port": "Port", - "username": "Username" + "username": "Nom d'utilisateur" }, "description": "Veuillez entrer les informations de connexion de votre broker MQTT.", "title": "Options de courtier" diff --git a/homeassistant/components/mutesync/translations/fr.json b/homeassistant/components/mutesync/translations/fr.json index 7a292eeeeae..6e7730953c6 100644 --- a/homeassistant/components/mutesync/translations/fr.json +++ b/homeassistant/components/mutesync/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "error": { - "cannot_connect": "Erreur de connexion", + "cannot_connect": "\u00c9chec de connexion", "invalid_auth": "Activer l'authentification dans Pr\u00e9f\u00e9rences > Authentification de m\u00fctesync", "unknown": "Erreur inattendue" }, diff --git a/homeassistant/components/myq/translations/fr.json b/homeassistant/components/myq/translations/fr.json index c07e3710645..6aa94c54577 100644 --- a/homeassistant/components/myq/translations/fr.json +++ b/homeassistant/components/myq/translations/fr.json @@ -1,18 +1,18 @@ { "config": { "abort": { - "already_configured": "MyQ est d\u00e9j\u00e0 configur\u00e9", - "reauth_successful": "R\u00e9-authentification r\u00e9ussie" + "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { - "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", + "cannot_connect": "\u00c9chec de connexion", "invalid_auth": "Authentification invalide", "unknown": "Erreur inattendue" }, "step": { "reauth_confirm": { "data": { - "password": "mot de passe" + "password": "Mot de passe" }, "description": "Le mot de passe de {username} n'est plus valide.", "title": "R\u00e9authentifiez votre compte MyQ" diff --git a/homeassistant/components/nam/translations/fr.json b/homeassistant/components/nam/translations/fr.json index 1800e6da508..fbb2f4ae367 100644 --- a/homeassistant/components/nam/translations/fr.json +++ b/homeassistant/components/nam/translations/fr.json @@ -15,7 +15,7 @@ }, "user": { "data": { - "host": "Hotes" + "host": "H\u00f4te" }, "description": "Configurez l'int\u00e9gration Nettigo Air Monitor." } diff --git a/homeassistant/components/nanoleaf/translations/cs.json b/homeassistant/components/nanoleaf/translations/cs.json new file mode 100644 index 00000000000..d66a08e73e7 --- /dev/null +++ b/homeassistant/components/nanoleaf/translations/cs.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno", + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", + "invalid_token": "Neplatn\u00fd p\u0159\u00edstupov\u00fd token", + "reauth_successful": "Op\u011btovn\u00e9 ov\u011b\u0159en\u00ed bylo \u00fasp\u011b\u0161n\u00e9", + "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" + }, + "error": { + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", + "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "host": "Hostitel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nanoleaf/translations/fr.json b/homeassistant/components/nanoleaf/translations/fr.json new file mode 100644 index 00000000000..2486a74c0dd --- /dev/null +++ b/homeassistant/components/nanoleaf/translations/fr.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "cannot_connect": "\u00c9chec de connexion", + "invalid_token": "Jeton d'acc\u00e8s non valide", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi", + "unknown": "Erreur inattendue" + }, + "error": { + "cannot_connect": "\u00c9chec de connexion", + "not_allowing_new_tokens": "Nanoleaf n'autorise pas les nouveaux jetons, suivez les instructions ci-dessus.", + "unknown": "Erreur inattendue" + }, + "flow_title": "{name}", + "step": { + "link": { + "description": "Appuyez sur le bouton d'alimentation de votre Nanoleaf et maintenez-le enfonc\u00e9 pendant 5 secondes jusqu'\u00e0 ce que les voyants du bouton commencent \u00e0 clignoter, puis cliquez sur **ENVOYER** dans les 30 secondes." + }, + "user": { + "data": { + "host": "H\u00f4te" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nanoleaf/translations/pl.json b/homeassistant/components/nanoleaf/translations/pl.json new file mode 100644 index 00000000000..fdf7e9a75b4 --- /dev/null +++ b/homeassistant/components/nanoleaf/translations/pl.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "invalid_token": "Niepoprawny token dost\u0119pu", + "reauth_successful": "Ponowne uwierzytelnienie powiod\u0142o si\u0119", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "flow_title": "{name}", + "step": { + "link": { + "title": "Po\u0142\u0105cz Nanoleaf" + }, + "user": { + "data": { + "host": "Nazwa hosta lub adres IP" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nanoleaf/translations/zh-Hant.json b/homeassistant/components/nanoleaf/translations/zh-Hant.json index 22de2698b39..cc5d1c08a8b 100644 --- a/homeassistant/components/nanoleaf/translations/zh-Hant.json +++ b/homeassistant/components/nanoleaf/translations/zh-Hant.json @@ -4,6 +4,7 @@ "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", "cannot_connect": "\u9023\u7dda\u5931\u6557", "invalid_token": "\u5b58\u53d6\u6b0a\u6756\u7121\u6548", + "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f", "unknown": "\u672a\u9810\u671f\u932f\u8aa4" }, "error": { diff --git a/homeassistant/components/neato/translations/fr.json b/homeassistant/components/neato/translations/fr.json index bf11c9edfe3..4348ce3d6f5 100644 --- a/homeassistant/components/neato/translations/fr.json +++ b/homeassistant/components/neato/translations/fr.json @@ -1,14 +1,14 @@ { "config": { "abort": { - "already_configured": "D\u00e9j\u00e0 configur\u00e9", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", "authorize_url_timeout": "D\u00e9lai de g\u00e9n\u00e9ration de l'URL d'authentification d\u00e9pass\u00e9.", - "missing_configuration": "Le composant n'est pas configur\u00e9. Veuillez suivre la documentation ", + "missing_configuration": "Le composant n'est pas configur\u00e9. Veuillez suivre la documentation.", "no_url_available": "Aucune URL disponible. Pour plus d'informations sur cette erreur, [consultez la section d'aide] ( {docs_url} )", "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "create_entry": { - "default": "Voir [Documentation Neato]({docs_url})." + "default": "Authentification r\u00e9ussie" }, "step": { "pick_implementation": { diff --git a/homeassistant/components/nest/translations/fr.json b/homeassistant/components/nest/translations/fr.json index 2d74b179d19..06f6897f364 100644 --- a/homeassistant/components/nest/translations/fr.json +++ b/homeassistant/components/nest/translations/fr.json @@ -15,14 +15,14 @@ "internal_error": "Erreur interne lors de la validation du code", "invalid_pin": "Code PIN invalide", "timeout": "D\u00e9lai de la validation du code expir\u00e9", - "unknown": "Erreur inconnue lors de la validation du code" + "unknown": "Erreur inattendue" }, "step": { "init": { "data": { "flow_impl": "Fournisseur" }, - "description": "S\u00e9lectionnez via quel fournisseur d'authentification vous souhaitez vous authentifier avec Nest.", + "description": "S\u00e9lectionner une m\u00e9thode d'authentification", "title": "Fournisseur d'authentification" }, "link": { diff --git a/homeassistant/components/netatmo/translations/fr.json b/homeassistant/components/netatmo/translations/fr.json index 6c294d467ab..3a66f224bc2 100644 --- a/homeassistant/components/netatmo/translations/fr.json +++ b/homeassistant/components/netatmo/translations/fr.json @@ -2,7 +2,7 @@ "config": { "abort": { "authorize_url_timeout": "D\u00e9lai de g\u00e9n\u00e9ration de l'URL d'authentification d\u00e9pass\u00e9.", - "missing_configuration": "Ce composant n'est pas configur\u00e9. Veuillez suivre la documentation.", + "missing_configuration": "Le composant n'est pas configur\u00e9. Veuillez suivre la documentation.", "no_url_available": "Aucune URL disponible. Pour plus d'informations sur cette erreur, [consultez la section d'aide] ( {docs_url} )", "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." }, @@ -11,7 +11,7 @@ }, "step": { "pick_implementation": { - "title": "Choisir une m\u00e9thode d'authentification" + "title": "S\u00e9lectionner une m\u00e9thode d'authentification" } } }, @@ -42,10 +42,10 @@ "public_weather": { "data": { "area_name": "Nom de la zone", - "lat_ne": "Latitude nord-est", - "lat_sw": "Latitude sud-ouest", - "lon_ne": "Longitude nord-est", - "lon_sw": "Longitude sud-ouest", + "lat_ne": "Latitude Nord-Est", + "lat_sw": "Latitude Sud-Ouest", + "lon_ne": "Longitude Nord-Est", + "lon_sw": "Longitude Sud-Ouest", "mode": "Calcul", "show_on_map": "Montrer sur la carte" }, diff --git a/homeassistant/components/nexia/translations/fr.json b/homeassistant/components/nexia/translations/fr.json index 5cec9b66836..b76672cd017 100644 --- a/homeassistant/components/nexia/translations/fr.json +++ b/homeassistant/components/nexia/translations/fr.json @@ -1,11 +1,11 @@ { "config": { "abort": { - "already_configured": "Cette maison Nexia est d\u00e9j\u00e0 configur\u00e9e" + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", - "invalid_auth": "Authentification non valide", + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide", "unknown": "Erreur inattendue" }, "step": { diff --git a/homeassistant/components/nmap_tracker/translations/fr.json b/homeassistant/components/nmap_tracker/translations/fr.json index 69d7d58f2e6..0f0ce5fe728 100644 --- a/homeassistant/components/nmap_tracker/translations/fr.json +++ b/homeassistant/components/nmap_tracker/translations/fr.json @@ -32,7 +32,7 @@ "scan_options": "Options d'analyse brutes configurables pour Nmap", "track_new_devices": "Suivre les nouveaux appareils" }, - "description": "Configurer les h\u00f4tes \u00e0 analyser par Nmap. L'adresse r\u00e9seau et les exclusions peuvent \u00eatre des adresses IP (192.168.1.1),R\u00e9seaux IP (192.168.0.0/24) ou plages IP (192.168.1.0-32)." + "description": "Configurer les h\u00f4tes \u00e0 analyser par Nmap. L'adresse r\u00e9seau et les exclusions peuvent \u00eatre des adresses IP (192.168.1.1), des r\u00e9seaux IP (192.168.0.0/24) ou des plages IP (192.168.1.0-32)." } } }, diff --git a/homeassistant/components/notion/translations/fr.json b/homeassistant/components/notion/translations/fr.json index c8f82077a0a..5979af3cf04 100644 --- a/homeassistant/components/notion/translations/fr.json +++ b/homeassistant/components/notion/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Ce nom d'utilisateur est d\u00e9j\u00e0 utilis\u00e9." + "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9" }, "error": { "invalid_auth": "Authentification invalide", diff --git a/homeassistant/components/nuheat/translations/fr.json b/homeassistant/components/nuheat/translations/fr.json index f0e912805ed..6c50dae47d5 100644 --- a/homeassistant/components/nuheat/translations/fr.json +++ b/homeassistant/components/nuheat/translations/fr.json @@ -1,11 +1,11 @@ { "config": { "abort": { - "already_configured": "Le thermostat est d\u00e9j\u00e0 configur\u00e9" + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", - "invalid_auth": "Authentification non valide", + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide", "invalid_thermostat": "Le num\u00e9ro de s\u00e9rie du thermostat n'est pas valide.", "unknown": "Erreur inattendue" }, diff --git a/homeassistant/components/nuki/translations/fr.json b/homeassistant/components/nuki/translations/fr.json index 248acf70133..360e374888c 100644 --- a/homeassistant/components/nuki/translations/fr.json +++ b/homeassistant/components/nuki/translations/fr.json @@ -4,8 +4,8 @@ "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { - "cannot_connect": "\u00c9chec de la connexion ", - "invalid_auth": "Authentification invalide ", + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide", "unknown": "Erreur inattendue" }, "step": { @@ -14,13 +14,13 @@ "token": "Jeton d'acc\u00e8s" }, "description": "L'int\u00e9gration Nuki doit s'authentifier de nouveau avec votre pont.", - "title": "R\u00e9authentifier l'int\u00e9gration" + "title": "R\u00e9-authentifier l'int\u00e9gration" }, "user": { "data": { - "host": "Hote", + "host": "H\u00f4te", "port": "Port", - "token": "jeton d'acc\u00e8s" + "token": "Jeton d'acc\u00e8s" } } } diff --git a/homeassistant/components/nut/translations/fr.json b/homeassistant/components/nut/translations/fr.json index 35739689425..3e1b345a8f1 100644 --- a/homeassistant/components/nut/translations/fr.json +++ b/homeassistant/components/nut/translations/fr.json @@ -4,7 +4,7 @@ "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", + "cannot_connect": "\u00c9chec de connexion", "unknown": "Erreur inattendue" }, "step": { @@ -23,7 +23,7 @@ }, "user": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "password": "Mot de passe", "port": "Port", "username": "Nom d'utilisateur" diff --git a/homeassistant/components/nws/translations/fr.json b/homeassistant/components/nws/translations/fr.json index 568179cf9fa..f88444c440f 100644 --- a/homeassistant/components/nws/translations/fr.json +++ b/homeassistant/components/nws/translations/fr.json @@ -1,10 +1,10 @@ { "config": { "abort": { - "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" + "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", + "cannot_connect": "\u00c9chec de connexion", "unknown": "Erreur inattendue" }, "step": { diff --git a/homeassistant/components/nzbget/translations/fr.json b/homeassistant/components/nzbget/translations/fr.json index 4ccc080fbcc..15420989501 100644 --- a/homeassistant/components/nzbget/translations/fr.json +++ b/homeassistant/components/nzbget/translations/fr.json @@ -15,9 +15,9 @@ "name": "Nom", "password": "Mot de passe", "port": "Port", - "ssl": "NZBGet utilise un certificat SSL", + "ssl": "Utilise un certificat SSL", "username": "Nom d'utilisateur", - "verify_ssl": "NZBGet utilise un certificat appropri\u00e9" + "verify_ssl": "V\u00e9rifier le certificat SSL" }, "title": "Se connecter \u00e0 NZBGet" } diff --git a/homeassistant/components/onvif/translations/fr.json b/homeassistant/components/onvif/translations/fr.json index 76eb733db3d..4ea0ae566cc 100644 --- a/homeassistant/components/onvif/translations/fr.json +++ b/homeassistant/components/onvif/translations/fr.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "already_configured": "Le p\u00e9riph\u00e9rique ONVIF est d\u00e9j\u00e0 configur\u00e9.", - "already_in_progress": "Le flux de configuration pour le p\u00e9riph\u00e9rique ONVIF est d\u00e9j\u00e0 en cours.", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", "no_h264": "Aucun flux H264 n'\u00e9tait disponible. V\u00e9rifiez la configuration du profil sur votre appareil.", "no_mac": "Impossible de configurer l'ID unique pour le p\u00e9riph\u00e9rique ONVIF.", "onvif_error": "Erreur lors de la configuration du p\u00e9riph\u00e9rique ONVIF. Consultez les journaux pour plus d'informations." @@ -43,7 +43,7 @@ }, "manual_input": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "name": "Nom", "port": "Port" }, diff --git a/homeassistant/components/opentherm_gw/translations/fr.json b/homeassistant/components/opentherm_gw/translations/fr.json index 6b9bf032244..32b642fa639 100644 --- a/homeassistant/components/opentherm_gw/translations/fr.json +++ b/homeassistant/components/opentherm_gw/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "error": { - "already_configured": "Passerelle d\u00e9j\u00e0 configur\u00e9e", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", "cannot_connect": "\u00c9chec de connexion", "id_exists": "L'identifiant de la passerelle existe d\u00e9j\u00e0" }, diff --git a/homeassistant/components/openuv/translations/fr.json b/homeassistant/components/openuv/translations/fr.json index 60000cd0058..cd6002c5b65 100644 --- a/homeassistant/components/openuv/translations/fr.json +++ b/homeassistant/components/openuv/translations/fr.json @@ -1,10 +1,10 @@ { "config": { "abort": { - "already_configured": "Coordonn\u00e9es d\u00e9j\u00e0 enregistr\u00e9es" + "already_configured": "L'emplacement est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "invalid_api_key": "Cl\u00e9 d'API invalide" + "invalid_api_key": "Cl\u00e9 API invalide" }, "step": { "user": { @@ -17,5 +17,12 @@ "title": "Veuillez saisir vos informations" } } + }, + "options": { + "step": { + "init": { + "title": "Configurer OpenUV" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/openuv/translations/pl.json b/homeassistant/components/openuv/translations/pl.json index bd633e92f05..ad7f99fcdc3 100644 --- a/homeassistant/components/openuv/translations/pl.json +++ b/homeassistant/components/openuv/translations/pl.json @@ -17,5 +17,12 @@ "title": "Wprowad\u017a dane" } } + }, + "options": { + "step": { + "init": { + "title": "Skonfiguruj OpenUV" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/openweathermap/translations/fr.json b/homeassistant/components/openweathermap/translations/fr.json index e5b55db1ed3..f8879a04d32 100644 --- a/homeassistant/components/openweathermap/translations/fr.json +++ b/homeassistant/components/openweathermap/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "L'int\u00e9gration OpenWeatherMap pour ces coordonn\u00e9es est d\u00e9j\u00e0 configur\u00e9e." + "already_configured": "L'emplacement est d\u00e9j\u00e0 configur\u00e9" }, "error": { "cannot_connect": "\u00c9chec de connexion", @@ -10,7 +10,7 @@ "step": { "user": { "data": { - "api_key": "Cl\u00e9 d'API OpenWeatherMap", + "api_key": "Cl\u00e9 d'API", "language": "Langue", "latitude": "Latitude", "longitude": "Longitude", diff --git a/homeassistant/components/ozw/translations/fr.json b/homeassistant/components/ozw/translations/fr.json index 5e408b7b807..0a0232dd91d 100644 --- a/homeassistant/components/ozw/translations/fr.json +++ b/homeassistant/components/ozw/translations/fr.json @@ -4,8 +4,8 @@ "addon_info_failed": "Impossible d'obtenir les informations sur le module compl\u00e9mentaire OpenZWave.", "addon_install_failed": "\u00c9chec de l'installation du module compl\u00e9mentaire OpenZWave.", "addon_set_config_failed": "\u00c9chec de la configuration OpenZWave.", - "already_configured": "Cet appareil est d\u00e9j\u00e0 configur\u00e9", - "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours\u00e0", "mqtt_required": "L'int\u00e9gration MQTT n'est pas configur\u00e9e", "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." }, diff --git a/homeassistant/components/p1_monitor/translations/cs.json b/homeassistant/components/p1_monitor/translations/cs.json new file mode 100644 index 00000000000..7981cfc800e --- /dev/null +++ b/homeassistant/components/p1_monitor/translations/cs.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", + "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" + }, + "step": { + "user": { + "data": { + "host": "Hostitel", + "name": "Jm\u00e9no" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/p1_monitor/translations/fr.json b/homeassistant/components/p1_monitor/translations/fr.json new file mode 100644 index 00000000000..156b5150330 --- /dev/null +++ b/homeassistant/components/p1_monitor/translations/fr.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "cannot_connect": "\u00c9chec de connexion", + "unknown": "Erreur inattendue" + }, + "step": { + "user": { + "data": { + "host": "H\u00f4te", + "name": "Nom" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/panasonic_viera/translations/fr.json b/homeassistant/components/panasonic_viera/translations/fr.json index 18add07074b..9684029e1d0 100644 --- a/homeassistant/components/panasonic_viera/translations/fr.json +++ b/homeassistant/components/panasonic_viera/translations/fr.json @@ -1,9 +1,9 @@ { "config": { "abort": { - "already_configured": "Ce t\u00e9l\u00e9viseur Panasonic Viera est d\u00e9j\u00e0 configur\u00e9.", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", "cannot_connect": "\u00c9chec de connexion", - "unknown": "Une erreur inconnue est survenue. Veuillez consulter les journaux pour obtenir plus de d\u00e9tails." + "unknown": "Erreur inattendue" }, "error": { "cannot_connect": "\u00c9chec de connexion", @@ -12,7 +12,7 @@ "step": { "pairing": { "data": { - "pin": "PIN" + "pin": "Code PIN" }, "description": "Entrer le code PIN affich\u00e9 sur votre t\u00e9l\u00e9viseur", "title": "Appairage" diff --git a/homeassistant/components/pi_hole/translations/fr.json b/homeassistant/components/pi_hole/translations/fr.json index 152fb0f3def..4eb97a04756 100644 --- a/homeassistant/components/pi_hole/translations/fr.json +++ b/homeassistant/components/pi_hole/translations/fr.json @@ -1,15 +1,15 @@ { "config": { "abort": { - "already_configured": "Service d\u00e9j\u00e0 configur\u00e9" + "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Connexion impossible" + "cannot_connect": "\u00c9chec de connexion" }, "step": { "api_key": { "data": { - "api_key": "Clef d'API" + "api_key": "Cl\u00e9 d'API" } }, "user": { @@ -19,7 +19,7 @@ "location": "Emplacement", "name": "Nom", "port": "Port", - "ssl": "Utiliser SSL", + "ssl": "Utilise un certificat SSL", "statistics_only": "Statistiques uniquement", "verify_ssl": "V\u00e9rifier le certificat SSL" } diff --git a/homeassistant/components/picnic/translations/fr.json b/homeassistant/components/picnic/translations/fr.json index 75e35a951de..03b5566566f 100644 --- a/homeassistant/components/picnic/translations/fr.json +++ b/homeassistant/components/picnic/translations/fr.json @@ -5,7 +5,7 @@ }, "error": { "cannot_connect": "\u00c9chec de connexion", - "invalid_auth": "Authentification incorrecte", + "invalid_auth": "Authentification invalide", "unknown": "Erreur inattendue" }, "step": { diff --git a/homeassistant/components/plaato/translations/fr.json b/homeassistant/components/plaato/translations/fr.json index ab3c01144dd..3bac269eaf9 100644 --- a/homeassistant/components/plaato/translations/fr.json +++ b/homeassistant/components/plaato/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Le compte est d\u00e9ja configur\u00e9", + "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9", "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible.", "webhook_not_internet_accessible": "Votre installation de Home Assistant doit \u00eatre accessible depuis internet pour recevoir des messages webhook." }, @@ -27,7 +27,7 @@ "device_name": "Nommez votre appareil", "device_type": "Type d'appareil Plaato" }, - "description": "\u00cates-vous s\u00fbr de vouloir installer le Plaato Airlock ?", + "description": "Voulez-vous commencer la configuration ?", "title": "Configurer le Webhook Plaato" }, "webhook": { diff --git a/homeassistant/components/plex/translations/fr.json b/homeassistant/components/plex/translations/fr.json index 63a2413316e..73704d31c7a 100644 --- a/homeassistant/components/plex/translations/fr.json +++ b/homeassistant/components/plex/translations/fr.json @@ -3,10 +3,10 @@ "abort": { "all_configured": "Tous les serveurs li\u00e9s sont d\u00e9j\u00e0 configur\u00e9s", "already_configured": "Ce serveur Plex est d\u00e9j\u00e0 configur\u00e9", - "already_in_progress": "Plex en cours de configuration", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi", "token_request_timeout": "D\u00e9lai d'obtention du jeton", - "unknown": "\u00c9chec pour une raison inconnue" + "unknown": "Erreur inattendue" }, "error": { "faulty_credentials": "L'autorisation \u00e0 \u00e9chou\u00e9e", @@ -19,9 +19,9 @@ "step": { "manual_setup": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "port": "Port", - "ssl": "Utiliser SSL", + "ssl": "Utilise un certificat SSL", "token": "Jeton (facultatif)", "verify_ssl": "V\u00e9rifier le certificat SSL" }, diff --git a/homeassistant/components/plugwise/translations/fr.json b/homeassistant/components/plugwise/translations/fr.json index f89c8509136..ce262e72f24 100644 --- a/homeassistant/components/plugwise/translations/fr.json +++ b/homeassistant/components/plugwise/translations/fr.json @@ -1,11 +1,11 @@ { "config": { "abort": { - "already_configured": "Ce Smile est d\u00e9j\u00e0 configur\u00e9" + "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", - "invalid_auth": "Authentification invalide, v\u00e9rifiez les 8 caract\u00e8res de votre ID Smile", + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide", "unknown": "Erreur inattendue" }, "flow_title": "Smile: {name}", diff --git a/homeassistant/components/plum_lightpad/translations/fr.json b/homeassistant/components/plum_lightpad/translations/fr.json index cee1b083f7f..20c633e8d0f 100644 --- a/homeassistant/components/plum_lightpad/translations/fr.json +++ b/homeassistant/components/plum_lightpad/translations/fr.json @@ -10,7 +10,7 @@ "user": { "data": { "password": "Mot de passe", - "username": "Adresse e-mail" + "username": "Email" } } } diff --git a/homeassistant/components/poolsense/translations/fr.json b/homeassistant/components/poolsense/translations/fr.json index 0a9fac75005..bfe2ecf1bd2 100644 --- a/homeassistant/components/poolsense/translations/fr.json +++ b/homeassistant/components/poolsense/translations/fr.json @@ -4,12 +4,12 @@ "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "invalid_auth": "L'authentification ne'st pas valide" + "invalid_auth": "Authentification invalide" }, "step": { "user": { "data": { - "email": "Adresse e-mail", + "email": "Email", "password": "Mot de passe" }, "description": "Voulez-vous commencer la configuration ?", diff --git a/homeassistant/components/powerwall/translations/fr.json b/homeassistant/components/powerwall/translations/fr.json index 3bfd70cd44c..61e69d3dedc 100644 --- a/homeassistant/components/powerwall/translations/fr.json +++ b/homeassistant/components/powerwall/translations/fr.json @@ -1,11 +1,11 @@ { "config": { "abort": { - "already_configured": "Le Powerwall est d\u00e9j\u00e0 configur\u00e9", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { - "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", + "cannot_connect": "\u00c9chec de connexion", "invalid_auth": "Authentification invalide", "unknown": "Erreur inattendue", "wrong_version": "Votre Powerwall utilise une version logicielle qui n'est pas prise en charge. Veuillez envisager de mettre \u00e0 niveau ou de signaler ce probl\u00e8me afin qu'il puisse \u00eatre r\u00e9solu." diff --git a/homeassistant/components/prosegur/translations/fr.json b/homeassistant/components/prosegur/translations/fr.json index 7c0d361da6a..42061673128 100644 --- a/homeassistant/components/prosegur/translations/fr.json +++ b/homeassistant/components/prosegur/translations/fr.json @@ -2,11 +2,11 @@ "config": { "abort": { "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", - "reauth_successful": "R\u00e9-authentification r\u00e9ussie" + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { "cannot_connect": "\u00c9chec de connexion", - "invalid_auth": "Authentification incorrecte", + "invalid_auth": "Authentification invalide", "unknown": "Erreur inattendue" }, "step": { diff --git a/homeassistant/components/ps4/translations/fr.json b/homeassistant/components/ps4/translations/fr.json index 82fe682e8f6..c4765c31c4a 100644 --- a/homeassistant/components/ps4/translations/fr.json +++ b/homeassistant/components/ps4/translations/fr.json @@ -3,7 +3,7 @@ "abort": { "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", "credential_error": "Erreur lors de l'extraction des informations d'identification.", - "no_devices_found": "Aucun appareil PlayStation 4 trouv\u00e9 sur le r\u00e9seau.", + "no_devices_found": "Aucun appareil trouv\u00e9 sur le r\u00e9seau", "port_987_bind_error": "Impossible de se connecter au port 997. Reportez-vous \u00e0 la [documentation] (https://www.home-assistant.io/components/ps4/) pour plus d'informations.", "port_997_bind_error": "Impossible de se connecter au port 997. Reportez-vous \u00e0 la [documentation] (https://www.home-assistant.io/components/ps4/) pour plus d'informations." }, @@ -20,7 +20,7 @@ }, "link": { "data": { - "code": "PIN", + "code": "Code PIN", "ip_address": "Adresse IP", "name": "Nom", "region": "R\u00e9gion" diff --git a/homeassistant/components/pvpc_hourly_pricing/translations/fr.json b/homeassistant/components/pvpc_hourly_pricing/translations/fr.json index f8511a80579..e22a70092c3 100644 --- a/homeassistant/components/pvpc_hourly_pricing/translations/fr.json +++ b/homeassistant/components/pvpc_hourly_pricing/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "L'int\u00e9gration est d\u00e9j\u00e0 configur\u00e9e avec un capteur existant avec ce tarif" + "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9" }, "step": { "user": { diff --git a/homeassistant/components/rachio/translations/fr.json b/homeassistant/components/rachio/translations/fr.json index a52c0bd6d4a..343256cea9a 100644 --- a/homeassistant/components/rachio/translations/fr.json +++ b/homeassistant/components/rachio/translations/fr.json @@ -4,8 +4,8 @@ "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", - "invalid_auth": "Authentification non valide", + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide", "unknown": "Erreur inattendue" }, "step": { diff --git a/homeassistant/components/rainforest_eagle/translations/cs.json b/homeassistant/components/rainforest_eagle/translations/cs.json new file mode 100644 index 00000000000..19a31a3f9cb --- /dev/null +++ b/homeassistant/components/rainforest_eagle/translations/cs.json @@ -0,0 +1,12 @@ +{ + "config": { + "abort": { + "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno" + }, + "error": { + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", + "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed", + "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rainforest_eagle/translations/fr.json b/homeassistant/components/rainforest_eagle/translations/fr.json new file mode 100644 index 00000000000..9631ff6cc93 --- /dev/null +++ b/homeassistant/components/rainforest_eagle/translations/fr.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" + }, + "error": { + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide", + "unknown": "Erreur inattendue" + }, + "step": { + "user": { + "data": { + "cloud_id": "Nom d'utilisateur cloud", + "host": "H\u00f4te", + "install_code": "Code d'installation" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rainmachine/translations/fr.json b/homeassistant/components/rainmachine/translations/fr.json index df0f9efa588..de4e5cdc1ed 100644 --- a/homeassistant/components/rainmachine/translations/fr.json +++ b/homeassistant/components/rainmachine/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Ce contr\u00f4leur RainMachine est d\u00e9j\u00e0 configur\u00e9." + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { "invalid_auth": "Authentification invalide" diff --git a/homeassistant/components/recollect_waste/translations/fr.json b/homeassistant/components/recollect_waste/translations/fr.json index dc62c8f520a..2aef5934e9c 100644 --- a/homeassistant/components/recollect_waste/translations/fr.json +++ b/homeassistant/components/recollect_waste/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "L'appareil est d\u00e9ja configur\u00e9 " + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { "invalid_place_or_service_id": "ID de lieu ou de service non valide" diff --git a/homeassistant/components/renault/translations/fr.json b/homeassistant/components/renault/translations/fr.json index 874a9b8df67..c6dd881ecb4 100644 --- a/homeassistant/components/renault/translations/fr.json +++ b/homeassistant/components/renault/translations/fr.json @@ -5,7 +5,7 @@ "kamereon_no_account": "Impossible de trouver le compte Kamereon." }, "error": { - "invalid_credentials": "Authentification incorrecte" + "invalid_credentials": "Authentification invalide" }, "step": { "kamereon": { diff --git a/homeassistant/components/ring/translations/fr.json b/homeassistant/components/ring/translations/fr.json index 01bbd6587c3..c86cd78564c 100644 --- a/homeassistant/components/ring/translations/fr.json +++ b/homeassistant/components/ring/translations/fr.json @@ -4,7 +4,7 @@ "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "invalid_auth": "Authentification non valide", + "invalid_auth": "Authentification invalide", "unknown": "Erreur inattendue" }, "step": { diff --git a/homeassistant/components/risco/translations/fr.json b/homeassistant/components/risco/translations/fr.json index 69224a3e8b1..0b33b841e1d 100644 --- a/homeassistant/components/risco/translations/fr.json +++ b/homeassistant/components/risco/translations/fr.json @@ -1,18 +1,18 @@ { "config": { "abort": { - "already_configured": "P\u00e9riph\u00e9rique d\u00e9j\u00e0 configur\u00e9" + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Echec de connexion", - "invalid_auth": "Authentification erron\u00e9e", + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide", "unknown": "Erreur inattendue" }, "step": { "user": { "data": { "password": "Mot de passe", - "pin": "Code Pin", + "pin": "Code PIN", "username": "Nom d'utilisateur" } } @@ -32,8 +32,8 @@ }, "init": { "data": { - "code_arm_required": "Exiger un code PIN pour armer", - "code_disarm_required": "Exiger un code PIN pour d\u00e9sarmer", + "code_arm_required": "Exiger un Code PIN pour armer", + "code_disarm_required": "Exiger un Code PIN pour d\u00e9sarmer", "scan_interval": "\u00c0 quelle fr\u00e9quence interroger Risco (en secondes)" }, "title": "Configurer les options" diff --git a/homeassistant/components/roku/translations/fr.json b/homeassistant/components/roku/translations/fr.json index b3dc08a7dc8..4888ed60a1a 100644 --- a/homeassistant/components/roku/translations/fr.json +++ b/homeassistant/components/roku/translations/fr.json @@ -28,7 +28,7 @@ }, "user": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP" + "host": "H\u00f4te" }, "description": "Entrez vos informations Roku." } diff --git a/homeassistant/components/roomba/translations/fr.json b/homeassistant/components/roomba/translations/fr.json index 767d7a9708a..b4c06b26c9f 100644 --- a/homeassistant/components/roomba/translations/fr.json +++ b/homeassistant/components/roomba/translations/fr.json @@ -1,13 +1,13 @@ { "config": { "abort": { - "already_configured": "L'appareil est d\u00e9ja configur\u00e9 ", - "cannot_connect": "Echec de connection", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "cannot_connect": "\u00c9chec de connexion", "not_irobot_device": "L'appareil d\u00e9couvert n'est pas un appareil iRobot", "short_blid": "La BLID a \u00e9t\u00e9 tronqu\u00e9" }, "error": { - "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer" + "cannot_connect": "\u00c9chec de connexion" }, "flow_title": "iRobot {name} ( {host} )", "step": { @@ -42,7 +42,7 @@ "blid": "BLID", "continuous": "En continu", "delay": "D\u00e9lai", - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "password": "Mot de passe" }, "description": "La r\u00e9cup\u00e9ration du BLID et du mot de passe est actuellement un processus manuel. Veuillez suivre les \u00e9tapes d\u00e9crites dans la documentation \u00e0 l'adresse: https://www.home-assistant.io/integrations/roomba/#retrieving-your-credentials", diff --git a/homeassistant/components/samsungtv/translations/fr.json b/homeassistant/components/samsungtv/translations/fr.json index 5a20992d8e5..9ee94b5edc2 100644 --- a/homeassistant/components/samsungtv/translations/fr.json +++ b/homeassistant/components/samsungtv/translations/fr.json @@ -1,17 +1,17 @@ { "config": { "abort": { - "already_configured": "Ce t\u00e9l\u00e9viseur Samsung est d\u00e9j\u00e0 configur\u00e9.", - "already_in_progress": "La configuration du t\u00e9l\u00e9viseur Samsung est d\u00e9j\u00e0 en cours.", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", "auth_missing": "Home Assistant n'est pas autoris\u00e9 \u00e0 se connecter \u00e0 ce t\u00e9l\u00e9viseur Samsung. Veuillez v\u00e9rifier les param\u00e8tres de votre t\u00e9l\u00e9viseur pour autoriser Home Assistant.", "cannot_connect": "\u00c9chec de connexion", "id_missing": "Cet appareil Samsung n'a pas de num\u00e9ro de s\u00e9rie.", "not_supported": "Ce t\u00e9l\u00e9viseur Samsung n'est actuellement pas pris en charge.", - "reauth_successful": "R\u00e9-authentification r\u00e9ussie", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi", "unknown": "Erreur inattendue" }, "error": { - "auth_missing": "Home Assistant n'est pas autoris\u00e9 \u00e0 se connecter \u00e0 ce t\u00e9l\u00e9viseur Samsung. V\u00e9rifiez les param\u00e8tres du Gestionnaire de p\u00e9riph\u00e9riques externes de votre t\u00e9l\u00e9viseur pour autoriser Home Assistant." + "auth_missing": "Home Assistant n'est pas autoris\u00e9 \u00e0 se connecter \u00e0 ce t\u00e9l\u00e9viseur Samsung. Veuillez v\u00e9rifier les param\u00e8tres de votre t\u00e9l\u00e9viseur pour autoriser Home Assistant." }, "flow_title": "Samsung TV: {model}", "step": { @@ -24,7 +24,7 @@ }, "user": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "name": "Nom" }, "description": "Entrez les informations relatives \u00e0 votre t\u00e9l\u00e9viseur Samsung. Si vous n'avez jamais connect\u00e9 Home Assistant avant, vous devriez voir une fen\u00eatre contextuelle sur votre t\u00e9l\u00e9viseur demandant une authentification." diff --git a/homeassistant/components/sense/translations/fr.json b/homeassistant/components/sense/translations/fr.json index bf5509a44a8..83ec49fd388 100644 --- a/homeassistant/components/sense/translations/fr.json +++ b/homeassistant/components/sense/translations/fr.json @@ -4,14 +4,14 @@ "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", + "cannot_connect": "\u00c9chec de connexion", "invalid_auth": "Authentification invalide", "unknown": "Erreur inattendue" }, "step": { "user": { "data": { - "email": "Adresse e-mail", + "email": "Email", "password": "Mot de passe" }, "title": "Connectez-vous \u00e0 votre moniteur d'\u00e9nergie Sense" diff --git a/homeassistant/components/sharkiq/translations/fr.json b/homeassistant/components/sharkiq/translations/fr.json index 6fa3ba7707c..552715f0d1c 100644 --- a/homeassistant/components/sharkiq/translations/fr.json +++ b/homeassistant/components/sharkiq/translations/fr.json @@ -3,7 +3,7 @@ "abort": { "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9", "cannot_connect": "\u00c9chec de connexion", - "reauth_successful": "Jeton d'acc\u00e8s mis \u00e0 jour avec succ\u00e8s", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi", "unknown": "Erreur inattendue" }, "error": { diff --git a/homeassistant/components/shopping_list/translations/fr.json b/homeassistant/components/shopping_list/translations/fr.json index b5265a70784..73822cc49f0 100644 --- a/homeassistant/components/shopping_list/translations/fr.json +++ b/homeassistant/components/shopping_list/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "La liste d'achats est d\u00e9j\u00e0 configur\u00e9e." + "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9" }, "step": { "user": { diff --git a/homeassistant/components/sia/translations/fr.json b/homeassistant/components/sia/translations/fr.json index 2b3188dd082..843c707ce19 100644 --- a/homeassistant/components/sia/translations/fr.json +++ b/homeassistant/components/sia/translations/fr.json @@ -12,7 +12,7 @@ "step": { "additional_account": { "data": { - "account": "Identifiant du compte", + "account": "Identifiant de compte", "additional_account": "Comptes suppl\u00e9mentaires", "encryption_key": "Cl\u00e9 de cryptage", "ping_interval": "Intervalle de ping (min)", diff --git a/homeassistant/components/simplisafe/translations/fr.json b/homeassistant/components/simplisafe/translations/fr.json index 1627f41c212..b1ea8441369 100644 --- a/homeassistant/components/simplisafe/translations/fr.json +++ b/homeassistant/components/simplisafe/translations/fr.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Ce compte SimpliSafe est d\u00e9j\u00e0 utilis\u00e9.", - "reauth_successful": "SimpliSafe a \u00e9t\u00e9 r\u00e9 authentifi\u00e9 avec succ\u00e8s." + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { "identifier_exists": "Compte d\u00e9j\u00e0 enregistr\u00e9", @@ -20,13 +20,13 @@ "password": "Mot de passe" }, "description": "Votre jeton d'acc\u00e8s a expir\u00e9 ou a \u00e9t\u00e9 r\u00e9voqu\u00e9. Entrez votre mot de passe pour r\u00e9 associer votre compte.", - "title": "Relier le compte SimpliSafe" + "title": "R\u00e9-authentifier l'int\u00e9gration" }, "user": { "data": { "code": "Code (utilis\u00e9 dans l'interface Home Assistant)", "password": "Mot de passe", - "username": "Adresse e-mail" + "username": "Email" }, "title": "Veuillez saisir vos informations" } diff --git a/homeassistant/components/sma/translations/fr.json b/homeassistant/components/sma/translations/fr.json index e70401c87f5..46b3be072f7 100644 --- a/homeassistant/components/sma/translations/fr.json +++ b/homeassistant/components/sma/translations/fr.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", - "already_in_progress": "Le flux de configuration est d\u00e9j\u00e0 en cours" + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours" }, "error": { "cannot_connect": "\u00c9chec de connexion", @@ -14,7 +14,7 @@ "user": { "data": { "group": "Groupe", - "host": "H\u00f4te ", + "host": "H\u00f4te", "password": "Mot de passe", "ssl": "Utilise un certificat SSL", "verify_ssl": "V\u00e9rifier le certificat SSL" diff --git a/homeassistant/components/smappee/translations/fr.json b/homeassistant/components/smappee/translations/fr.json index f1d8cdb9615..4bcdd0b5c16 100644 --- a/homeassistant/components/smappee/translations/fr.json +++ b/homeassistant/components/smappee/translations/fr.json @@ -24,7 +24,7 @@ "description": "Entrez l'h\u00f4te pour lancer l'int\u00e9gration locale Smappee" }, "pick_implementation": { - "title": "Choisissez la m\u00e9thode d'authentification" + "title": "S\u00e9lectionner une m\u00e9thode d'authentification" }, "zeroconf_confirm": { "description": "Voulez-vous ajouter l'appareil Smappee avec le num\u00e9ro de s\u00e9rie \u00ab {serialnumber} \u00bb \u00e0 Home Assistant?", diff --git a/homeassistant/components/smarthab/translations/fr.json b/homeassistant/components/smarthab/translations/fr.json index 7bc0bdc9531..efbbfe25818 100644 --- a/homeassistant/components/smarthab/translations/fr.json +++ b/homeassistant/components/smarthab/translations/fr.json @@ -8,7 +8,7 @@ "step": { "user": { "data": { - "email": "Adresse email", + "email": "Email", "password": "Mot de passe" }, "description": "Pour des raisons techniques, utilisez un compte sp\u00e9cifique \u00e0 Home Assistant. Vous pouvez cr\u00e9er un compte secondaire depuis l'application SmartHab.", diff --git a/homeassistant/components/smarttub/translations/fr.json b/homeassistant/components/smarttub/translations/fr.json index c660ca15e87..bb481048fff 100644 --- a/homeassistant/components/smarttub/translations/fr.json +++ b/homeassistant/components/smarttub/translations/fr.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9", - "reauth_successful": "La r\u00e9-authentification a \u00e9t\u00e9 un succ\u00e8s" + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { "invalid_auth": "Authentification invalide" @@ -10,7 +10,7 @@ "step": { "reauth_confirm": { "description": "L'int\u00e9gration SmartTub doit r\u00e9-authentifier votre compte", - "title": "R\u00e9authentification de l'int\u00e9gration" + "title": "R\u00e9-authentifier l'int\u00e9gration" }, "user": { "data": { diff --git a/homeassistant/components/sms/translations/fr.json b/homeassistant/components/sms/translations/fr.json index b4c479cfd50..ebfa3c1da08 100644 --- a/homeassistant/components/sms/translations/fr.json +++ b/homeassistant/components/sms/translations/fr.json @@ -5,8 +5,8 @@ "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." }, "error": { - "cannot_connect": "Echec de connexion", - "unknown": "Erreur inatendue" + "cannot_connect": "\u00c9chec de connexion", + "unknown": "Erreur inattendue" }, "step": { "user": { diff --git a/homeassistant/components/solaredge/translations/fr.json b/homeassistant/components/solaredge/translations/fr.json index 638e19a2a03..36b283a9145 100644 --- a/homeassistant/components/solaredge/translations/fr.json +++ b/homeassistant/components/solaredge/translations/fr.json @@ -1,10 +1,10 @@ { "config": { "abort": { - "already_configured": "L'appareil est d\u00e9ja configur\u00e9" + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "already_configured": "L'appareil est d\u00e9ja configur\u00e9", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", "could_not_connect": "Impossible de se connecter \u00e0 l'API solaredge", "invalid_api_key": "Cl\u00e9 API invalide", "site_not_active": "The site n'est pas actif" diff --git a/homeassistant/components/solarlog/translations/fr.json b/homeassistant/components/solarlog/translations/fr.json index b327f58adf5..b65ca3b05e7 100644 --- a/homeassistant/components/solarlog/translations/fr.json +++ b/homeassistant/components/solarlog/translations/fr.json @@ -5,7 +5,7 @@ }, "error": { "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", - "cannot_connect": "\u00c9chec de la connexion" + "cannot_connect": "\u00c9chec de connexion" }, "step": { "user": { diff --git a/homeassistant/components/somfy/translations/fr.json b/homeassistant/components/somfy/translations/fr.json index c8783effc28..08b978e3f12 100644 --- a/homeassistant/components/somfy/translations/fr.json +++ b/homeassistant/components/somfy/translations/fr.json @@ -1,17 +1,17 @@ { "config": { "abort": { - "authorize_url_timeout": "D\u00e9lai de g\u00e9n\u00e9ration d'url autoriser.", - "missing_configuration": "Le composant Somfy n'est pas configur\u00e9. Veuillez suivre la documentation.", + "authorize_url_timeout": "D\u00e9lai de g\u00e9n\u00e9ration de l'URL d'authentification d\u00e9pass\u00e9.", + "missing_configuration": "Le composant n'est pas configur\u00e9. Veuillez suivre la documentation.", "no_url_available": "Aucune URL disponible. Pour plus d'informations sur cette erreur, [consultez la section d'aide] ( {docs_url} )", "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." }, "create_entry": { - "default": "Authentifi\u00e9 avec succ\u00e8s avec Somfy." + "default": "Authentification r\u00e9ussie" }, "step": { "pick_implementation": { - "title": "Choisir la m\u00e9thode d'authentification" + "title": "S\u00e9lectionner une m\u00e9thode d'authentification" } } } diff --git a/homeassistant/components/somfy_mylink/translations/fr.json b/homeassistant/components/somfy_mylink/translations/fr.json index bee2ea3ba13..f31a3b9d86f 100644 --- a/homeassistant/components/somfy_mylink/translations/fr.json +++ b/homeassistant/components/somfy_mylink/translations/fr.json @@ -1,11 +1,11 @@ { "config": { "abort": { - "already_configured": "L'appareil est d\u00e9ja configur\u00e9 " + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "\u00c9chec de la connexion ", - "invalid_auth": "Authentification invalide ", + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide", "unknown": "Erreur inattendue" }, "flow_title": "Somfy MyLink {mac} ( {ip} )", @@ -22,7 +22,7 @@ }, "options": { "abort": { - "cannot_connect": "Echec de connection" + "cannot_connect": "\u00c9chec de connexion" }, "step": { "entity_config": { diff --git a/homeassistant/components/sonarr/translations/fr.json b/homeassistant/components/sonarr/translations/fr.json index 6fa91de98a4..154d3435da3 100644 --- a/homeassistant/components/sonarr/translations/fr.json +++ b/homeassistant/components/sonarr/translations/fr.json @@ -3,7 +3,7 @@ "abort": { "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9", "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi", - "unknown": "Erreur innatendue" + "unknown": "Erreur inattendue" }, "error": { "cannot_connect": "\u00c9chec de connexion", @@ -13,16 +13,16 @@ "step": { "reauth_confirm": { "description": "L'int\u00e9gration Sonarr doit \u00eatre r\u00e9-authentifi\u00e9e manuellement avec l'API Sonarr h\u00e9berg\u00e9e sur: {host}", - "title": "R\u00e9-authentifier avec Sonarr" + "title": "R\u00e9-authentifier l'int\u00e9gration" }, "user": { "data": { - "api_key": "Cl\u00e9 API", + "api_key": "Cl\u00e9 d'API", "base_path": "Chemin vers l'API", "host": "H\u00f4te", "port": "Port", - "ssl": "Sonarr utilise un certificat SSL", - "verify_ssl": "Sonarr utilise un certificat appropri\u00e9" + "ssl": "Utilise un certificat SSL", + "verify_ssl": "V\u00e9rifier le certificat SSL" } } } diff --git a/homeassistant/components/songpal/translations/fr.json b/homeassistant/components/songpal/translations/fr.json index 5975bb955fa..84f115ec1a5 100644 --- a/homeassistant/components/songpal/translations/fr.json +++ b/homeassistant/components/songpal/translations/fr.json @@ -1,11 +1,11 @@ { "config": { "abort": { - "already_configured": "Appareil d\u00e9j\u00e0 configur\u00e9", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", "not_songpal_device": "Pas un appareil Songpal" }, "error": { - "cannot_connect": "Echec de connexion" + "cannot_connect": "\u00c9chec de connexion" }, "flow_title": "Sony Songpal {name} ({host})", "step": { diff --git a/homeassistant/components/sonos/translations/fr.json b/homeassistant/components/sonos/translations/fr.json index 50a6086e2e8..828a8e45791 100644 --- a/homeassistant/components/sonos/translations/fr.json +++ b/homeassistant/components/sonos/translations/fr.json @@ -1,9 +1,9 @@ { "config": { "abort": { - "no_devices_found": "Aucun p\u00e9riph\u00e9rique Sonos trouv\u00e9 sur le r\u00e9seau.", + "no_devices_found": "Aucun appareil trouv\u00e9 sur le r\u00e9seau", "not_sonos_device": "L'appareil d\u00e9couvert n'est pas un appareil Sonos", - "single_instance_allowed": "Une seule configuration de Sonos est n\u00e9cessaire." + "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." }, "step": { "confirm": { diff --git a/homeassistant/components/speedtestdotnet/translations/fr.json b/homeassistant/components/speedtestdotnet/translations/fr.json index 9407a674550..41a1ae7dc2d 100644 --- a/homeassistant/components/speedtestdotnet/translations/fr.json +++ b/homeassistant/components/speedtestdotnet/translations/fr.json @@ -6,7 +6,7 @@ }, "step": { "user": { - "description": "Voulez-vous vraiment configurer SpeedTest ?" + "description": "Voulez-vous commencer la configuration ?" } } }, diff --git a/homeassistant/components/spotify/translations/fr.json b/homeassistant/components/spotify/translations/fr.json index d6b5838feb5..4422ddef176 100644 --- a/homeassistant/components/spotify/translations/fr.json +++ b/homeassistant/components/spotify/translations/fr.json @@ -11,11 +11,11 @@ }, "step": { "pick_implementation": { - "title": "Choisissez la m\u00e9thode d'authentification" + "title": "S\u00e9lectionner une m\u00e9thode d'authentification" }, "reauth_confirm": { "description": "L'int\u00e9gration de Spotify doit se r\u00e9-authentifier avec Spotify pour le compte: {account}", - "title": "R\u00e9-authentifier avec Spotify" + "title": "R\u00e9-authentifier l'int\u00e9gration" } } }, diff --git a/homeassistant/components/squeezebox/translations/fr.json b/homeassistant/components/squeezebox/translations/fr.json index f79d25bc20a..c416a18b49d 100644 --- a/homeassistant/components/squeezebox/translations/fr.json +++ b/homeassistant/components/squeezebox/translations/fr.json @@ -17,7 +17,7 @@ "host": "H\u00f4te", "password": "Mot de passe", "port": "Port", - "username": "Username" + "username": "Nom d'utilisateur" }, "title": "Modifier les informations de connexion" }, diff --git a/homeassistant/components/srp_energy/translations/fr.json b/homeassistant/components/srp_energy/translations/fr.json index b9b33cfa930..4ce2a6dbbea 100644 --- a/homeassistant/components/srp_energy/translations/fr.json +++ b/homeassistant/components/srp_energy/translations/fr.json @@ -1,12 +1,12 @@ { "config": { "abort": { - "single_instance_allowed": "D\u00e9ja configur\u00e9. Seulement une seule configuration est possible " + "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." }, "error": { - "cannot_connect": "\u00c9chec de la connexion ", + "cannot_connect": "\u00c9chec de connexion", "invalid_account": "L'ID de compte doit \u00eatre un num\u00e9ro \u00e0 9 chiffres", - "invalid_auth": "Authentification invalide ", + "invalid_auth": "Authentification invalide", "unknown": "Erreur inattendue" }, "step": { @@ -15,7 +15,7 @@ "id": "Identifiant de compte", "is_tou": "Est le plan de temps d'utilisation", "password": "Mot de passe", - "username": "Nom d'utilisateur " + "username": "Nom d'utilisateur" } } } diff --git a/homeassistant/components/switcher_kis/translations/fr.json b/homeassistant/components/switcher_kis/translations/fr.json index e6e7a3c271f..e9ae4e0b644 100644 --- a/homeassistant/components/switcher_kis/translations/fr.json +++ b/homeassistant/components/switcher_kis/translations/fr.json @@ -6,7 +6,7 @@ }, "step": { "confirm": { - "description": "Voulez-vous commencer l'installation ?" + "description": "Voulez-vous commencer la configuration ?" } } } diff --git a/homeassistant/components/syncthing/translations/fr.json b/homeassistant/components/syncthing/translations/fr.json index 12486fb5cf2..99c31269565 100644 --- a/homeassistant/components/syncthing/translations/fr.json +++ b/homeassistant/components/syncthing/translations/fr.json @@ -5,7 +5,7 @@ }, "error": { "cannot_connect": "\u00c9chec de connexion", - "invalid_auth": "Authentification incorrecte" + "invalid_auth": "Authentification invalide" }, "step": { "user": { diff --git a/homeassistant/components/syncthru/translations/fr.json b/homeassistant/components/syncthru/translations/fr.json index 6d21912e168..2e1d73688e0 100644 --- a/homeassistant/components/syncthru/translations/fr.json +++ b/homeassistant/components/syncthru/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Appareil d\u00e9j\u00e0 configur\u00e9" + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { "invalid_url": "URL invalide", diff --git a/homeassistant/components/synology_dsm/translations/fr.json b/homeassistant/components/synology_dsm/translations/fr.json index b254fc8e561..41ce8fdb788 100644 --- a/homeassistant/components/synology_dsm/translations/fr.json +++ b/homeassistant/components/synology_dsm/translations/fr.json @@ -1,15 +1,15 @@ { "config": { "abort": { - "already_configured": "H\u00f4te d\u00e9j\u00e0 configur\u00e9", - "reauth_successful": "R\u00e9-authentification r\u00e9ussie" + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { "cannot_connect": "\u00c9chec de connexion", "invalid_auth": "Authentification invalide", "missing_data": "Donn\u00e9es manquantes: veuillez r\u00e9essayer plus tard ou utilisez une autre configuration", "otp_failed": "\u00c9chec de l'authentification en deux \u00e9tapes, r\u00e9essayez avec un nouveau code d'acc\u00e8s", - "unknown": "Erreur inconnue: veuillez consulter les journaux pour obtenir plus de d\u00e9tails" + "unknown": "Erreur inattendue" }, "flow_title": "Synology DSM {name} ({host})", "step": { @@ -23,9 +23,9 @@ "data": { "password": "Mot de passe", "port": "Port", - "ssl": "Utilisez SSL/TLS pour vous connecter \u00e0 votre NAS", + "ssl": "Utilise un certificat SSL", "username": "Nom d'utilisateur", - "verify_ssl": "V\u00e9rifiez le certificat SSL" + "verify_ssl": "V\u00e9rifier le certificat SSL" }, "description": "Voulez-vous configurer {name} ({host})?", "title": "Synology DSM" @@ -40,12 +40,12 @@ }, "user": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "password": "Mot de passe", "port": "Port", - "ssl": "Utilisez SSL/TLS pour vous connecter \u00e0 votre NAS", + "ssl": "Utilise un certificat SSL", "username": "Nom d'utilisateur", - "verify_ssl": "V\u00e9rifiez le certificat SSL" + "verify_ssl": "V\u00e9rifier le certificat SSL" }, "title": "Synology DSM" } diff --git a/homeassistant/components/system_bridge/translations/fr.json b/homeassistant/components/system_bridge/translations/fr.json index a21fab81777..99b38c44ad8 100644 --- a/homeassistant/components/system_bridge/translations/fr.json +++ b/homeassistant/components/system_bridge/translations/fr.json @@ -2,25 +2,25 @@ "config": { "abort": { "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", - "reauth_successful": "R\u00e9-authentification r\u00e9ussie", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi", "unknown": "Erreur inattendue" }, "error": { "cannot_connect": "\u00c9chec de connexion", - "invalid_auth": "Authentification incorrecte", + "invalid_auth": "Authentification invalide", "unknown": "Erreur inattendue" }, "flow_title": "Pont syst\u00e8me: {name}", "step": { "authenticate": { "data": { - "api_key": "Clef d'API" + "api_key": "Cl\u00e9 d'API" }, "description": "Veuillez saisir la cl\u00e9 API que vous avez d\u00e9finie dans votre configuration pour {name} ." }, "user": { "data": { - "api_key": "Clef d'API", + "api_key": "Cl\u00e9 d'API", "host": "H\u00f4te", "port": "Port" }, diff --git a/homeassistant/components/tado/translations/fr.json b/homeassistant/components/tado/translations/fr.json index 0ebbe4054a1..d862a27f392 100644 --- a/homeassistant/components/tado/translations/fr.json +++ b/homeassistant/components/tado/translations/fr.json @@ -4,8 +4,8 @@ "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", - "invalid_auth": "Authentification non valide", + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide", "no_homes": "Il n\u2019y a pas de maisons li\u00e9es \u00e0 ce compte tado.", "unknown": "Erreur inattendue" }, diff --git a/homeassistant/components/tellduslive/translations/fr.json b/homeassistant/components/tellduslive/translations/fr.json index 02e05d2c869..9dd1a8cd3f8 100644 --- a/homeassistant/components/tellduslive/translations/fr.json +++ b/homeassistant/components/tellduslive/translations/fr.json @@ -1,9 +1,9 @@ { "config": { "abort": { - "already_configured": "TelldusLive est d\u00e9j\u00e0 configur\u00e9", + "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9", "authorize_url_timeout": "D\u00e9lai de g\u00e9n\u00e9ration de l'URL d'authentification d\u00e9pass\u00e9.", - "unknown": "Une erreur inconnue s'est produite", + "unknown": "Erreur inattendue", "unknown_authorize_url_generation": "Erreur inconnue lors de la g\u00e9n\u00e9ration d'une URL d'autorisation." }, "error": { @@ -16,7 +16,7 @@ }, "user": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP" + "host": "H\u00f4te" }, "description": "Vide", "title": "Choisissez le point de terminaison." diff --git a/homeassistant/components/tibber/translations/fr.json b/homeassistant/components/tibber/translations/fr.json index 82dd065e53c..256516c44a6 100644 --- a/homeassistant/components/tibber/translations/fr.json +++ b/homeassistant/components/tibber/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Un compte Tibber est d\u00e9j\u00e0 configur\u00e9." + "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9" }, "error": { "cannot_connect": "\u00c9chec de connexion", diff --git a/homeassistant/components/tile/translations/fr.json b/homeassistant/components/tile/translations/fr.json index 2af0fbab669..ade27c9053f 100644 --- a/homeassistant/components/tile/translations/fr.json +++ b/homeassistant/components/tile/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Ce compte Tile est d\u00e9j\u00e0 enregistr\u00e9." + "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9" }, "error": { "invalid_auth": "Authentification invalide" diff --git a/homeassistant/components/toon/translations/fr.json b/homeassistant/components/toon/translations/fr.json index 3aa36d8a554..2b70c85dc8f 100644 --- a/homeassistant/components/toon/translations/fr.json +++ b/homeassistant/components/toon/translations/fr.json @@ -2,8 +2,8 @@ "config": { "abort": { "already_configured": "L'accord s\u00e9lectionn\u00e9 est d\u00e9j\u00e0 configur\u00e9.", - "authorize_url_timeout": "Timout de g\u00e9n\u00e9ration de l'URL d'autorisation.", - "missing_configuration": "The composant n'est pas configur\u00e9. Veuillez vous r\u00e9f\u00e9rer \u00e0 la documentation.", + "authorize_url_timeout": "D\u00e9lai de g\u00e9n\u00e9ration de l'URL d'authentification d\u00e9pass\u00e9.", + "missing_configuration": "Le composant n'est pas configur\u00e9. Veuillez suivre la documentation.", "no_agreements": "Ce compte n'a pas d'affichages Toon.", "no_url_available": "Aucune URL disponible. Pour plus d'informations sur cette erreur, [consultez la section d'aide] ( {docs_url} )", "unknown_authorize_url_generation": "Erreur inconnue lors de la g\u00e9n\u00e9ration d'une URL d'autorisation." diff --git a/homeassistant/components/totalconnect/translations/fr.json b/homeassistant/components/totalconnect/translations/fr.json index 668b20726fc..6c51c724f77 100644 --- a/homeassistant/components/totalconnect/translations/fr.json +++ b/homeassistant/components/totalconnect/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Compte d\u00e9j\u00e0 configur\u00e9", + "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9", "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { diff --git a/homeassistant/components/tplink/translations/fr.json b/homeassistant/components/tplink/translations/fr.json index 43ea1d1b111..f36b3865e55 100644 --- a/homeassistant/components/tplink/translations/fr.json +++ b/homeassistant/components/tplink/translations/fr.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "no_devices_found": "Aucun p\u00e9riph\u00e9rique TP-Link trouv\u00e9 sur le r\u00e9seau.", - "single_instance_allowed": "Une seule configuration est n\u00e9cessaire." + "no_devices_found": "Aucun appareil trouv\u00e9 sur le r\u00e9seau", + "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." }, "step": { "confirm": { diff --git a/homeassistant/components/tractive/translations/fr.json b/homeassistant/components/tractive/translations/fr.json index 1d3c15c13d5..7e53cba0d74 100644 --- a/homeassistant/components/tractive/translations/fr.json +++ b/homeassistant/components/tractive/translations/fr.json @@ -1,7 +1,9 @@ { "config": { "abort": { - "already_configured": "Dispositif d\u00e9j\u00e0 configur\u00e9" + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "reauth_failed_existing": "Impossible de mettre \u00e0 jour l'entr\u00e9e de configuration, veuillez supprimer l'int\u00e9gration et la configurer \u00e0 nouveau.", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { "invalid_auth": "Authentification invalide", @@ -10,7 +12,7 @@ "step": { "user": { "data": { - "email": "Adresse mail", + "email": "Email", "password": "Mot de passe" } } diff --git a/homeassistant/components/tradfri/translations/fr.json b/homeassistant/components/tradfri/translations/fr.json index 92d327be951..2d32029c954 100644 --- a/homeassistant/components/tradfri/translations/fr.json +++ b/homeassistant/components/tradfri/translations/fr.json @@ -1,18 +1,18 @@ { "config": { "abort": { - "already_configured": "Le pont est d\u00e9j\u00e0 configur\u00e9.", - "already_in_progress": "La configuration du pont est d\u00e9j\u00e0 en cours." + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours" }, "error": { - "cannot_connect": "Impossible de se connecter \u00e0 la passerelle.", + "cannot_connect": "\u00c9chec de connexion", "invalid_key": "\u00c9chec de l'enregistrement avec la cl\u00e9 fournie. Si cela se reproduit, essayez de red\u00e9marrer la passerelle.", "timeout": "D\u00e9lai d'attente de la validation du code expir\u00e9" }, "step": { "auth": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "security_code": "Code de s\u00e9curit\u00e9" }, "description": "Vous pouvez trouver le code de s\u00e9curit\u00e9 au dos de votre passerelle.", diff --git a/homeassistant/components/transmission/translations/fr.json b/homeassistant/components/transmission/translations/fr.json index 45ad7968bcb..64efb47c8c3 100644 --- a/homeassistant/components/transmission/translations/fr.json +++ b/homeassistant/components/transmission/translations/fr.json @@ -1,10 +1,10 @@ { "config": { "abort": { - "already_configured": "L'h\u00f4te est d\u00e9j\u00e0 configur\u00e9." + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Impossible de se connecter \u00e0 l'h\u00f4te", + "cannot_connect": "\u00c9chec de connexion", "invalid_auth": "Authentification invalide", "name_exists": "Ce nom est d\u00e9j\u00e0 utilis\u00e9" }, diff --git a/homeassistant/components/tuya/translations/fr.json b/homeassistant/components/tuya/translations/fr.json index 1681343f3b7..b741d3f9377 100644 --- a/homeassistant/components/tuya/translations/fr.json +++ b/homeassistant/components/tuya/translations/fr.json @@ -24,7 +24,7 @@ }, "options": { "abort": { - "cannot_connect": "Impossible de se connecter" + "cannot_connect": "\u00c9chec de connexion" }, "error": { "dev_multi_type": "Plusieurs p\u00e9riph\u00e9riques s\u00e9lectionn\u00e9s \u00e0 configurer doivent \u00eatre du m\u00eame type", diff --git a/homeassistant/components/twinkly/translations/fr.json b/homeassistant/components/twinkly/translations/fr.json index c26edea54ee..02ba8cb1b3e 100644 --- a/homeassistant/components/twinkly/translations/fr.json +++ b/homeassistant/components/twinkly/translations/fr.json @@ -1,10 +1,10 @@ { "config": { "abort": { - "device_exists": "D\u00e9j\u00e0 configur\u00e9" + "device_exists": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Connexion impossible" + "cannot_connect": "\u00c9chec de connexion" }, "step": { "user": { diff --git a/homeassistant/components/unifi/translations/fr.json b/homeassistant/components/unifi/translations/fr.json index d750fb0cdd9..5486b633fd4 100644 --- a/homeassistant/components/unifi/translations/fr.json +++ b/homeassistant/components/unifi/translations/fr.json @@ -14,12 +14,12 @@ "step": { "user": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "password": "Mot de passe", "port": "Port", "site": "ID du site", "username": "Nom d'utilisateur", - "verify_ssl": "Contr\u00f4leur utilisant un certificat appropri\u00e9" + "verify_ssl": "V\u00e9rifier le certificat SSL" }, "title": "Configurer le contr\u00f4leur UniFi" } diff --git a/homeassistant/components/upb/translations/fr.json b/homeassistant/components/upb/translations/fr.json index f4d3279c284..6f96f42f3dd 100644 --- a/homeassistant/components/upb/translations/fr.json +++ b/homeassistant/components/upb/translations/fr.json @@ -4,9 +4,9 @@ "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Impossible de se connecter \u00e0 UPB PIM, veuillez r\u00e9essayer.", + "cannot_connect": "\u00c9chec de connexion", "invalid_upb_file": "Fichier d'exportation UPB UPStart manquant ou invalide, v\u00e9rifiez le nom et le chemin du fichier.", - "unknown": "Erreur inattendue." + "unknown": "Erreur inattendue" }, "step": { "user": { diff --git a/homeassistant/components/upnp/translations/fr.json b/homeassistant/components/upnp/translations/fr.json index ffbef69abe7..574cb9f4f93 100644 --- a/homeassistant/components/upnp/translations/fr.json +++ b/homeassistant/components/upnp/translations/fr.json @@ -1,9 +1,9 @@ { "config": { "abort": { - "already_configured": "UPnP / IGD est d\u00e9j\u00e0 configur\u00e9", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", "incomplete_discovery": "D\u00e9couverte incompl\u00e8te", - "no_devices_found": "Aucun p\u00e9riph\u00e9rique UPnP / IGD trouv\u00e9 sur le r\u00e9seau." + "no_devices_found": "Aucun appareil trouv\u00e9 sur le r\u00e9seau" }, "error": { "one": "Vide", diff --git a/homeassistant/components/uptimerobot/translations/fr.json b/homeassistant/components/uptimerobot/translations/fr.json index 2b4322bb410..6d20816632f 100644 --- a/homeassistant/components/uptimerobot/translations/fr.json +++ b/homeassistant/components/uptimerobot/translations/fr.json @@ -1,26 +1,28 @@ { "config": { "abort": { - "already_configured": "Compte d\u00e9j\u00e0 configur\u00e9", + "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9", "reauth_failed_existing": "Impossible de mettre \u00e0 jour l'entr\u00e9e de configuration, veuillez supprimer l'int\u00e9gration et la configurer \u00e0 nouveau.", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi", "unknown": "Erreur inattendue" }, "error": { - "cannot_connect": "Echec de la connexion", - "invalid_api_key": "Cl\u00e9 API non valide", + "cannot_connect": "\u00c9chec de connexion", + "invalid_api_key": "Cl\u00e9 API invalide", "reauth_failed_matching_account": "La cl\u00e9 API que vous avez fournie ne correspond pas \u00e0 l\u2019ID de compte pour la configuration existante.", "unknown": "Erreur inattendue" }, "step": { "reauth_confirm": { "data": { - "api_key": "Cl\u00e9 API" + "api_key": "Cl\u00e9 d'API" }, - "description": "Vous devez fournir une nouvelle cl\u00e9 API en lecture seule \u00e0 partir d'Uptime Robot" + "description": "Vous devez fournir une nouvelle cl\u00e9 API en lecture seule \u00e0 partir d'Uptime Robot", + "title": "R\u00e9-authentifier l'int\u00e9gration" }, "user": { "data": { - "api_key": "Cl\u00e9 API" + "api_key": "Cl\u00e9 d'API" }, "description": "Vous devez fournir une cl\u00e9 API en lecture seule \u00e0 partir d'Uptime Robot" } diff --git a/homeassistant/components/vacuum/translations/fr.json b/homeassistant/components/vacuum/translations/fr.json index 1c069a98132..7bd851a3a8f 100644 --- a/homeassistant/components/vacuum/translations/fr.json +++ b/homeassistant/components/vacuum/translations/fr.json @@ -18,7 +18,7 @@ "cleaning": "Nettoyage", "docked": "Sur la base", "error": "Erreur", - "idle": "Inactif", + "idle": "En veille", "off": "Inactif", "on": "Actif", "paused": "En pause", diff --git a/homeassistant/components/vilfo/translations/fr.json b/homeassistant/components/vilfo/translations/fr.json index b6790d98d39..bfd455fe379 100644 --- a/homeassistant/components/vilfo/translations/fr.json +++ b/homeassistant/components/vilfo/translations/fr.json @@ -1,18 +1,18 @@ { "config": { "abort": { - "already_configured": "Ce routeur Vilfo est d\u00e9j\u00e0 configur\u00e9." + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "\u00c9chec de connexion. Veuillez v\u00e9rifier les informations que vous avez fournies et r\u00e9essayer.", - "invalid_auth": "Authentification non valide. Veuillez v\u00e9rifier le jeton d'acc\u00e8s et r\u00e9essayer.", - "unknown": "Une erreur inattendue s'est produite lors de la configuration de l'int\u00e9gration." + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide", + "unknown": "Erreur inattendue" }, "step": { "user": { "data": { - "access_token": "Jeton d'Acc\u00e8s", - "host": "Nom d'h\u00f4te ou adresse IP" + "access_token": "Jeton d'acc\u00e8s", + "host": "H\u00f4te" }, "description": "Configurez l'int\u00e9gration du routeur Vilfo. Vous avez besoin du nom d'h\u00f4te / IP de votre routeur Vilfo et d'un jeton d'acc\u00e8s API. Pour plus d'informations sur cette int\u00e9gration et comment obtenir ces d\u00e9tails, visitez: https://www.home-assistant.io/integrations/vilfo", "title": "Connectez-vous au routeur Vilfo" diff --git a/homeassistant/components/vizio/translations/fr.json b/homeassistant/components/vizio/translations/fr.json index 5fc9158c803..b7f92f11b8d 100644 --- a/homeassistant/components/vizio/translations/fr.json +++ b/homeassistant/components/vizio/translations/fr.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured_device": "L'appareil est d\u00e9j\u00e0 configur\u00e9", - "cannot_connect": "\u00c9chec de la connexion ", + "cannot_connect": "\u00c9chec de connexion", "updated_entry": "Cette entr\u00e9e a d\u00e9j\u00e0 \u00e9t\u00e9 configur\u00e9e mais le nom et/ou les options d\u00e9finis dans la configuration ne correspondent pas \u00e0 la configuration pr\u00e9c\u00e9demment import\u00e9e, de sorte que l'entr\u00e9e de configuration a \u00e9t\u00e9 mise \u00e0 jour en cons\u00e9quence." }, "error": { @@ -13,7 +13,7 @@ "step": { "pair_tv": { "data": { - "pin": "PIN" + "pin": "Code PIN" }, "description": "Votre t\u00e9l\u00e9viseur devrait afficher un code. Saisissez ce code dans le formulaire, puis passez \u00e0 l'\u00e9tape suivante pour terminer le couplage.", "title": "Processus de couplage complet" diff --git a/homeassistant/components/wallbox/translations/fr.json b/homeassistant/components/wallbox/translations/fr.json index 04428ef567f..05e57f9adc4 100644 --- a/homeassistant/components/wallbox/translations/fr.json +++ b/homeassistant/components/wallbox/translations/fr.json @@ -5,7 +5,7 @@ }, "error": { "cannot_connect": "\u00c9chec de connexion", - "invalid_auth": "Authentification incorrecte", + "invalid_auth": "Authentification invalide", "unknown": "Erreur inattendue" }, "step": { diff --git a/homeassistant/components/waze_travel_time/translations/fr.json b/homeassistant/components/waze_travel_time/translations/fr.json index e0039ef4b14..8336bf1f6ac 100644 --- a/homeassistant/components/waze_travel_time/translations/fr.json +++ b/homeassistant/components/waze_travel_time/translations/fr.json @@ -4,7 +4,7 @@ "already_configured": "L'emplacement est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Echec de la connection" + "cannot_connect": "\u00c9chec de connexion" }, "step": { "user": { diff --git a/homeassistant/components/wemo/translations/fr.json b/homeassistant/components/wemo/translations/fr.json index e0372147f58..6b0f48e38e5 100644 --- a/homeassistant/components/wemo/translations/fr.json +++ b/homeassistant/components/wemo/translations/fr.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "no_devices_found": "Aucun p\u00e9riph\u00e9rique Wemo trouv\u00e9 sur le r\u00e9seau.", - "single_instance_allowed": "Une seule configuration de Wemo est possible." + "no_devices_found": "Aucun appareil trouv\u00e9 sur le r\u00e9seau", + "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." }, "step": { "confirm": { diff --git a/homeassistant/components/wiffi/translations/fr.json b/homeassistant/components/wiffi/translations/fr.json index 599ea7a4b65..3d24f7791c0 100644 --- a/homeassistant/components/wiffi/translations/fr.json +++ b/homeassistant/components/wiffi/translations/fr.json @@ -7,7 +7,7 @@ "step": { "user": { "data": { - "port": "Port de serveur" + "port": "Port" }, "title": "Configurer le serveur TCP pour les appareils WIFFI" } diff --git a/homeassistant/components/withings/translations/fr.json b/homeassistant/components/withings/translations/fr.json index b5f524698f5..9f675026022 100644 --- a/homeassistant/components/withings/translations/fr.json +++ b/homeassistant/components/withings/translations/fr.json @@ -2,8 +2,8 @@ "config": { "abort": { "already_configured": "Configuration mise \u00e0 jour pour le profil.", - "authorize_url_timeout": "D\u00e9lai d'expiration g\u00e9n\u00e9rant une URL d'autorisation.", - "missing_configuration": "L'int\u00e9gration Withings n'est pas configur\u00e9e. Veuillez suivre la documentation.", + "authorize_url_timeout": "D\u00e9lai de g\u00e9n\u00e9ration de l'URL d'authentification d\u00e9pass\u00e9.", + "missing_configuration": "Le composant n'est pas configur\u00e9. Veuillez suivre la documentation.", "no_url_available": "Aucune URL disponible. Pour plus d'informations sur cette erreur, [consultez la section d'aide] ( {docs_url} )" }, "create_entry": { @@ -15,7 +15,7 @@ "flow_title": "Withings: {profile}", "step": { "pick_implementation": { - "title": "Choisissez une m\u00e9thode d'authentification" + "title": "S\u00e9lectionner une m\u00e9thode d'authentification" }, "profile": { "data": { @@ -26,7 +26,7 @@ }, "reauth": { "description": "Le profile \" {profile} \" doit \u00eatre r\u00e9-authentifi\u00e9 afin de continuer \u00e0 recevoir les donn\u00e9es Withings.", - "title": "R\u00e9-authentifier le profil" + "title": "R\u00e9-authentifier l'int\u00e9gration" } } } diff --git a/homeassistant/components/wled/translations/fr.json b/homeassistant/components/wled/translations/fr.json index dec038a8a92..03255541ad9 100644 --- a/homeassistant/components/wled/translations/fr.json +++ b/homeassistant/components/wled/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Cet appareil WLED est d\u00e9j\u00e0 configur\u00e9.", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", "cannot_connect": "\u00c9chec de connexion" }, "error": { @@ -11,7 +11,7 @@ "step": { "user": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP" + "host": "H\u00f4te" }, "description": "Configurez votre WLED pour l'int\u00e9grer \u00e0 Home Assistant." }, diff --git a/homeassistant/components/xiaomi_aqara/translations/fr.json b/homeassistant/components/xiaomi_aqara/translations/fr.json index f4c16e045b7..f6ca4af9d3e 100644 --- a/homeassistant/components/xiaomi_aqara/translations/fr.json +++ b/homeassistant/components/xiaomi_aqara/translations/fr.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9.", - "already_in_progress": "Le flux de configuration pour cette passerelle est d\u00e9j\u00e0 en cours", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", "not_xiaomi_aqara": "Ce n'est pas une passerelle Xiaomi Aqara, l'appareil d\u00e9couvert ne correspond pas aux passerelles connues" }, "error": { @@ -16,7 +16,7 @@ "step": { "select": { "data": { - "select_ip": "IP de la passerelle" + "select_ip": "Adresse IP" }, "description": "Ex\u00e9cutez \u00e0 nouveau la configuration si vous souhaitez connecter des passerelles suppl\u00e9mentaires", "title": "S\u00e9lectionnez la passerelle Xiaomi Aqara que vous souhaitez connecter" diff --git a/homeassistant/components/xiaomi_miio/translations/fr.json b/homeassistant/components/xiaomi_miio/translations/fr.json index 2b68325a246..06a0d29c608 100644 --- a/homeassistant/components/xiaomi_miio/translations/fr.json +++ b/homeassistant/components/xiaomi_miio/translations/fr.json @@ -2,10 +2,10 @@ "config": { "abort": { "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", - "already_in_progress": "Le flux de configuration pour cet appareil Xiaomi Miio est d\u00e9j\u00e0 en cours.", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", "incomplete_info": "Informations incompl\u00e8tes pour configurer l'appareil, aucun h\u00f4te ou jeton fourni.", "not_xiaomi_miio": "L'appareil n'est pas (encore) pris en charge par Xiaomi Miio.", - "reauth_successful": "R\u00e9-authentification r\u00e9ussie" + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { "cannot_connect": "\u00c9chec de connexion", @@ -56,14 +56,14 @@ "manual": { "data": { "host": "Adresse IP", - "token": "Jeton API" + "token": "Jeton d'API" }, - "description": "Vous aurez besoin du jeton API de 32 caract\u00e8res, voir https://www.home-assistant.io/integrations/xiaomi_miio#retrieving-the-access-token pour les instructions. Veuillez noter que ce jeton API est diff\u00e9rent de la cl\u00e9 utilis\u00e9e par l'int\u00e9gration Xiaomi Aqara.", + "description": "Vous aurez besoin du Jeton d'API de 32 caract\u00e8res, voir https://www.home-assistant.io/integrations/xiaomi_miio#retrieving-the-access-token pour les instructions. Veuillez noter que ce Jeton d'API est diff\u00e9rent de la cl\u00e9 utilis\u00e9e par l'int\u00e9gration Xiaomi Aqara.", "title": "Se connecter \u00e0 un appareil Xiaomi Miio ou \u00e0 une passerelle Xiaomi" }, "reauth_confirm": { "description": "L'int\u00e9gration de Xiaomi Miio doit r\u00e9-authentifier votre compte afin de mettre \u00e0 jour les jetons ou d'ajouter les informations d'identification cloud manquantes.", - "title": "R\u00e9authentification de l'int\u00e9gration" + "title": "R\u00e9-authentifier l'int\u00e9gration" }, "select": { "data": { diff --git a/homeassistant/components/yale_smart_alarm/translations/fr.json b/homeassistant/components/yale_smart_alarm/translations/fr.json index 60d6f5cc548..c2cf20086e2 100644 --- a/homeassistant/components/yale_smart_alarm/translations/fr.json +++ b/homeassistant/components/yale_smart_alarm/translations/fr.json @@ -4,7 +4,7 @@ "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "invalid_auth": "Authentification incorrecte" + "invalid_auth": "Authentification invalide" }, "step": { "reauth_confirm": { diff --git a/homeassistant/components/yamaha_musiccast/translations/fr.json b/homeassistant/components/yamaha_musiccast/translations/fr.json index 0a8671dc2aa..14cbec9e877 100644 --- a/homeassistant/components/yamaha_musiccast/translations/fr.json +++ b/homeassistant/components/yamaha_musiccast/translations/fr.json @@ -10,7 +10,7 @@ "flow_title": "MusicCast: {name}", "step": { "confirm": { - "description": "Voulez-vous commencer la configuration\u00a0?" + "description": "Voulez-vous commencer la configuration ?" }, "user": { "data": { diff --git a/homeassistant/components/yeelight/translations/cs.json b/homeassistant/components/yeelight/translations/cs.json index 8bab9bd19b1..adc42efddb7 100644 --- a/homeassistant/components/yeelight/translations/cs.json +++ b/homeassistant/components/yeelight/translations/cs.json @@ -7,6 +7,7 @@ "error": { "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit" }, + "flow_title": "{model} {id} ({host})", "step": { "pick_device": { "data": { diff --git a/homeassistant/components/zerproc/translations/fr.json b/homeassistant/components/zerproc/translations/fr.json index 80ae6cb0ec8..e9ae4e0b644 100644 --- a/homeassistant/components/zerproc/translations/fr.json +++ b/homeassistant/components/zerproc/translations/fr.json @@ -1,12 +1,12 @@ { "config": { "abort": { - "no_devices_found": "Pas d'appareil trouv\u00e9 sur le r\u00e9seau", + "no_devices_found": "Aucun appareil trouv\u00e9 sur le r\u00e9seau", "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." }, "step": { "confirm": { - "description": "Voulez-vous demmarer la configuration ?" + "description": "Voulez-vous commencer la configuration ?" } } } diff --git a/homeassistant/components/zha/translations/ca.json b/homeassistant/components/zha/translations/ca.json index 0e4abfe3a57..2fe8d1a151c 100644 --- a/homeassistant/components/zha/translations/ca.json +++ b/homeassistant/components/zha/translations/ca.json @@ -2,7 +2,8 @@ "config": { "abort": { "not_zha_device": "Aquest no \u00e9s un dispositiu zha", - "single_instance_allowed": "Ja configurat. Nom\u00e9s \u00e9s possible una sola configuraci\u00f3." + "single_instance_allowed": "Ja configurat. Nom\u00e9s \u00e9s possible una sola configuraci\u00f3.", + "usb_probe_failed": "No s'ha pogut provar el dispositiu USB" }, "error": { "cannot_connect": "Ha fallat la connexi\u00f3" diff --git a/homeassistant/components/zha/translations/de.json b/homeassistant/components/zha/translations/de.json index 2c7c6fed132..88638c6c696 100644 --- a/homeassistant/components/zha/translations/de.json +++ b/homeassistant/components/zha/translations/de.json @@ -2,7 +2,8 @@ "config": { "abort": { "not_zha_device": "Dieses Ger\u00e4t ist kein ZHA-Ger\u00e4t", - "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich." + "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich.", + "usb_probe_failed": "Fehler beim Testen des USB-Ger\u00e4ts" }, "error": { "cannot_connect": "Verbindung fehlgeschlagen" diff --git a/homeassistant/components/zha/translations/en.json b/homeassistant/components/zha/translations/en.json index 93d3c5f697a..00c78101a53 100644 --- a/homeassistant/components/zha/translations/en.json +++ b/homeassistant/components/zha/translations/en.json @@ -2,7 +2,8 @@ "config": { "abort": { "not_zha_device": "This device is not a zha device", - "single_instance_allowed": "Already configured. Only a single configuration possible." + "single_instance_allowed": "Already configured. Only a single configuration possible.", + "usb_probe_failed": "Failed to probe the usb device" }, "error": { "cannot_connect": "Failed to connect" diff --git a/homeassistant/components/zha/translations/et.json b/homeassistant/components/zha/translations/et.json index 4924b3c954f..16ab4a84b6d 100644 --- a/homeassistant/components/zha/translations/et.json +++ b/homeassistant/components/zha/translations/et.json @@ -2,7 +2,8 @@ "config": { "abort": { "not_zha_device": "See seade ei ole zha seade", - "single_instance_allowed": "Juba seadistatud. V\u00f5imalik on ainult \u00fcks seadistamine." + "single_instance_allowed": "Juba seadistatud. V\u00f5imalik on ainult \u00fcks seadistamine.", + "usb_probe_failed": "USB seadme k\u00fcsitlemine eba\u00f5nnestus" }, "error": { "cannot_connect": "\u00dchendamine nurjus" diff --git a/homeassistant/components/zha/translations/fr.json b/homeassistant/components/zha/translations/fr.json index 90d0908d6c3..1acb42a169b 100644 --- a/homeassistant/components/zha/translations/fr.json +++ b/homeassistant/components/zha/translations/fr.json @@ -1,10 +1,11 @@ { "config": { "abort": { - "single_instance_allowed": "Une seule configuration de ZHA est autoris\u00e9e." + "not_zha_device": "Cet appareil n'est pas un appareil zha", + "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." }, "error": { - "cannot_connect": "Impossible de se connecter au p\u00e9riph\u00e9rique ZHA." + "cannot_connect": "\u00c9chec de connexion" }, "flow_title": "ZHA: {name}", "step": { diff --git a/homeassistant/components/zoneminder/translations/fr.json b/homeassistant/components/zoneminder/translations/fr.json index 3f3729ce02f..7c730786384 100644 --- a/homeassistant/components/zoneminder/translations/fr.json +++ b/homeassistant/components/zoneminder/translations/fr.json @@ -23,7 +23,7 @@ "password": "Mot de passe", "path": "Chemin ZM", "path_zms": "Chemin ZMS", - "ssl": "Utiliser SSL pour les connexions \u00e0 ZoneMinder", + "ssl": "Utilise un certificat SSL", "username": "Nom d'utilisateur", "verify_ssl": "V\u00e9rifier le certificat SSL" }, diff --git a/homeassistant/components/zwave/translations/fr.json b/homeassistant/components/zwave/translations/fr.json index 03f6f9823ad..280d86e1537 100644 --- a/homeassistant/components/zwave/translations/fr.json +++ b/homeassistant/components/zwave/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Z-Wave est d\u00e9j\u00e0 configur\u00e9", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." }, "error": { diff --git a/homeassistant/components/zwave_js/translations/cs.json b/homeassistant/components/zwave_js/translations/cs.json index 05efdb8e5ff..013488d113f 100644 --- a/homeassistant/components/zwave_js/translations/cs.json +++ b/homeassistant/components/zwave_js/translations/cs.json @@ -9,6 +9,7 @@ "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" }, + "flow_title": "{name}", "step": { "configure_addon": { "data": { diff --git a/homeassistant/components/zwave_js/translations/fr.json b/homeassistant/components/zwave_js/translations/fr.json index 1e51e97044a..d898e3e33ac 100644 --- a/homeassistant/components/zwave_js/translations/fr.json +++ b/homeassistant/components/zwave_js/translations/fr.json @@ -6,16 +6,17 @@ "addon_install_failed": "\u00c9chec de l'installation du module compl\u00e9mentaire Z-Wave JS.", "addon_set_config_failed": "\u00c9chec de la d\u00e9finition de la configuration Z-Wave JS.", "addon_start_failed": "\u00c9chec du d\u00e9marrage du module compl\u00e9mentaire Z-Wave JS.", - "already_configured": "Le p\u00e9riph\u00e9rique est d\u00e9j\u00e0 configur\u00e9", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", - "cannot_connect": "\u00c9chec de la connexion " + "cannot_connect": "\u00c9chec de connexion" }, "error": { "addon_start_failed": "\u00c9chec du d\u00e9marrage du module compl\u00e9mentaire Z-Wave JS. V\u00e9rifiez la configuration.", - "cannot_connect": "Erreur de connection", + "cannot_connect": "\u00c9chec de connexion", "invalid_ws_url": "URL websocket invalide", "unknown": "Erreur inattendue" }, + "flow_title": "{name}", "progress": { "install_addon": "Veuillez patienter pendant l'installation du module compl\u00e9mentaire Z-Wave JS. Cela peut prendre plusieurs minutes.", "start_addon": "Veuillez patienter pendant le d\u00e9marrage du module compl\u00e9mentaire Z-Wave JS. Cela peut prendre quelques secondes." @@ -48,6 +49,9 @@ }, "start_addon": { "title": "Le module compl\u00e9mentaire Z-Wave JS est d\u00e9marr\u00e9." + }, + "usb_confirm": { + "description": "Voulez-vous configurer {name} avec le plugin Z-Wave JS ?" } } }, From cd0ae66d58bf74b5e7b2235573b886bf9755d594 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Fri, 27 Aug 2021 05:54:50 +0200 Subject: [PATCH 032/843] Add CONF_STATE_CLASS to `sensor/__init__.py` (#54106) * add CONF_STATE_CLASS to const.py * move to `sensor/__init__.py` * move to sensor/const.py * Revert "move to sensor/const.py" This reverts commit 604d0d066bfcb93f1a11e3d7732d430ab6de8d59. * move it to `sensor/const.py` but import it from `sensor/__init__.py` * add Modbus --- homeassistant/components/knx/schema.py | 3 +-- homeassistant/components/knx/sensor.py | 8 ++++++-- homeassistant/components/modbus/__init__.py | 2 +- homeassistant/components/modbus/const.py | 1 - homeassistant/components/modbus/sensor.py | 3 +-- homeassistant/components/mqtt/sensor.py | 2 +- homeassistant/components/sensor/__init__.py | 2 ++ homeassistant/components/sensor/const.py | 4 ++++ homeassistant/components/template/const.py | 1 - homeassistant/components/template/sensor.py | 2 +- tests/components/modbus/test_sensor.py | 2 +- 11 files changed, 18 insertions(+), 12 deletions(-) create mode 100644 homeassistant/components/sensor/const.py diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index 65ff6b3b8fa..89dab40958c 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -18,7 +18,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.components.climate.const import HVAC_MODE_HEAT, HVAC_MODES from homeassistant.components.cover import DEVICE_CLASSES as COVER_DEVICE_CLASSES -from homeassistant.components.sensor import STATE_CLASSES_SCHEMA +from homeassistant.components.sensor import CONF_STATE_CLASS, STATE_CLASSES_SCHEMA from homeassistant.const import ( CONF_DEVICE_CLASS, CONF_ENTITY_ID, @@ -730,7 +730,6 @@ class SensorSchema(KNXPlatformSchema): CONF_ALWAYS_CALLBACK = "always_callback" CONF_STATE_ADDRESS = CONF_STATE_ADDRESS - CONF_STATE_CLASS = "state_class" CONF_SYNC_STATE = CONF_SYNC_STATE DEFAULT_NAME = "KNX Sensor" diff --git a/homeassistant/components/knx/sensor.py b/homeassistant/components/knx/sensor.py index 933ba7bf30d..c1f68e4c376 100644 --- a/homeassistant/components/knx/sensor.py +++ b/homeassistant/components/knx/sensor.py @@ -6,7 +6,11 @@ from typing import Any from xknx import XKNX from xknx.devices import Sensor as XknxSensor -from homeassistant.components.sensor import DEVICE_CLASSES, SensorEntity +from homeassistant.components.sensor import ( + CONF_STATE_CLASS, + DEVICE_CLASSES, + SensorEntity, +) from homeassistant.const import CONF_NAME, CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -63,7 +67,7 @@ class KNXSensor(KnxEntity, SensorEntity): self._attr_force_update = self._device.always_callback self._attr_unique_id = str(self._device.sensor_value.group_address_state) self._attr_native_unit_of_measurement = self._device.unit_of_measurement() - self._attr_state_class = config.get(SensorSchema.CONF_STATE_CLASS) + self._attr_state_class = config.get(CONF_STATE_CLASS) @property def native_value(self) -> StateType: diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 26d196f8af9..df1fc2f6995 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -12,6 +12,7 @@ from homeassistant.components.cover import ( DEVICE_CLASSES_SCHEMA as COVER_DEVICE_CLASSES_SCHEMA, ) from homeassistant.components.sensor import ( + CONF_STATE_CLASS, DEVICE_CLASSES_SCHEMA as SENSOR_DEVICE_CLASSES_SCHEMA, STATE_CLASSES_SCHEMA as SENSOR_STATE_CLASSES_SCHEMA, ) @@ -76,7 +77,6 @@ from .const import ( CONF_RETRY_ON_EMPTY, CONF_REVERSE_ORDER, CONF_SCALE, - CONF_STATE_CLASS, CONF_STATE_CLOSED, CONF_STATE_CLOSING, CONF_STATE_OFF, diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py index b259b93285f..3bcd85053d2 100644 --- a/homeassistant/components/modbus/const.py +++ b/homeassistant/components/modbus/const.py @@ -41,7 +41,6 @@ CONF_RETRY_ON_EMPTY = "retry_on_empty" CONF_REVERSE_ORDER = "reverse_order" CONF_PRECISION = "precision" CONF_SCALE = "scale" -CONF_STATE_CLASS = "state_class" CONF_STATE_CLOSED = "state_closed" CONF_STATE_CLOSING = "state_closing" CONF_STATE_OFF = "state_off" diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py index c2f69065196..83ffafc7441 100644 --- a/homeassistant/components/modbus/sensor.py +++ b/homeassistant/components/modbus/sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging from typing import Any -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import CONF_STATE_CLASS, SensorEntity from homeassistant.const import CONF_NAME, CONF_SENSORS, CONF_UNIT_OF_MEASUREMENT from homeassistant.core import HomeAssistant from homeassistant.helpers.restore_state import RestoreEntity @@ -12,7 +12,6 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import get_hub from .base_platform import BaseStructPlatform -from .const import CONF_STATE_CLASS from .modbus import ModbusHub PARALLEL_UPDATES = 1 diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 16c19c8fc51..c5441840878 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -9,6 +9,7 @@ import voluptuous as vol from homeassistant.components import sensor from homeassistant.components.sensor import ( + CONF_STATE_CLASS, DEVICE_CLASSES_SCHEMA, STATE_CLASSES_SCHEMA, SensorEntity, @@ -42,7 +43,6 @@ _LOGGER = logging.getLogger(__name__) CONF_EXPIRE_AFTER = "expire_after" CONF_LAST_RESET_TOPIC = "last_reset_topic" CONF_LAST_RESET_VALUE_TEMPLATE = "last_reset_value_template" -CONF_STATE_CLASS = "state_class" MQTT_SENSOR_ATTRIBUTES_BLOCKED = frozenset( { diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index fafaabbd217..0518495a0f0 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -51,6 +51,8 @@ from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType, StateType +from .const import CONF_STATE_CLASS # noqa: F401 + _LOGGER: Final = logging.getLogger(__name__) ATTR_LAST_RESET: Final = "last_reset" # Deprecated, to be removed in 2021.11 diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py new file mode 100644 index 00000000000..54d683242ea --- /dev/null +++ b/homeassistant/components/sensor/const.py @@ -0,0 +1,4 @@ +"""Constants for sensor.""" +from typing import Final + +CONF_STATE_CLASS: Final = "state_class" diff --git a/homeassistant/components/template/const.py b/homeassistant/components/template/const.py index 0309321afbc..54d213be0b1 100644 --- a/homeassistant/components/template/const.py +++ b/homeassistant/components/template/const.py @@ -27,4 +27,3 @@ CONF_AVAILABILITY = "availability" CONF_ATTRIBUTES = "attributes" CONF_PICTURE = "picture" CONF_OBJECT_ID = "object_id" -CONF_STATE_CLASS = "state_class" diff --git a/homeassistant/components/template/sensor.py b/homeassistant/components/template/sensor.py index d51b18e294b..4214323c8ee 100644 --- a/homeassistant/components/template/sensor.py +++ b/homeassistant/components/template/sensor.py @@ -4,6 +4,7 @@ from __future__ import annotations import voluptuous as vol from homeassistant.components.sensor import ( + CONF_STATE_CLASS, DEVICE_CLASSES_SCHEMA, DOMAIN as SENSOR_DOMAIN, ENTITY_ID_FORMAT, @@ -38,7 +39,6 @@ from .const import ( CONF_AVAILABILITY_TEMPLATE, CONF_OBJECT_ID, CONF_PICTURE, - CONF_STATE_CLASS, CONF_TRIGGER, ) from .template_entity import TemplateEntity diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index 0da9d86f262..5227be835db 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -9,7 +9,6 @@ from homeassistant.components.modbus.const import ( CONF_LAZY_ERROR, CONF_PRECISION, CONF_SCALE, - CONF_STATE_CLASS, CONF_SWAP, CONF_SWAP_BYTE, CONF_SWAP_NONE, @@ -22,6 +21,7 @@ from homeassistant.components.modbus.const import ( DATA_TYPE_UINT, ) from homeassistant.components.sensor import ( + CONF_STATE_CLASS, DOMAIN as SENSOR_DOMAIN, STATE_CLASS_MEASUREMENT, ) From 176fd39e0bd2be92ef351e5b605f53ee29f56c2c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Aug 2021 23:37:28 -0500 Subject: [PATCH 033/843] Fix lifx model to be a string (#55309) Fixes #55307 --- homeassistant/components/lifx/light.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/lifx/light.py b/homeassistant/components/lifx/light.py index 30c0ffbe850..a4412d042a8 100644 --- a/homeassistant/components/lifx/light.py +++ b/homeassistant/components/lifx/light.py @@ -470,7 +470,7 @@ class LIFXLight(LightEntity): model = product_map.get(self.bulb.product) or self.bulb.product if model is not None: - info["model"] = model + info["model"] = str(model) return info From 9ba504cd7841640625960da4a2ec06ce8f4d3988 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 27 Aug 2021 07:22:23 +0200 Subject: [PATCH 034/843] Address late review for Renault integration (#55230) * Add type hints * Fix isort * tweaks to state attributes * Move lambdas to regular functions * Split CHECK_ATTRIBUTES into DYNAMIC_ATTRIBUTES and FIXED_ATTRIBUTES * Clarify tests * Fix typo --- .../components/renault/renault_entities.py | 11 +-- homeassistant/components/renault/sensor.py | 81 +++++++++++-------- tests/components/renault/__init__.py | 10 ++- tests/components/renault/const.py | 14 +++- .../components/renault/test_binary_sensor.py | 34 ++++---- tests/components/renault/test_init.py | 7 +- tests/components/renault/test_sensor.py | 60 +++++--------- 7 files changed, 112 insertions(+), 105 deletions(-) diff --git a/homeassistant/components/renault/renault_entities.py b/homeassistant/components/renault/renault_entities.py index 2a23d1de8f6..4e364c9d5d1 100644 --- a/homeassistant/components/renault/renault_entities.py +++ b/homeassistant/components/renault/renault_entities.py @@ -63,11 +63,8 @@ class RenaultDataEntity(CoordinatorEntity[Optional[T]], Entity): @property def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return the state attributes of this entity.""" - if self.entity_description.coordinator == "battery": - last_update = ( - getattr(self.coordinator.data, "timestamp") - if self.coordinator.data - else None - ) - return {ATTR_LAST_UPDATE: last_update} + if self.entity_description.coordinator == "battery" and self.coordinator.data: + timestamp = getattr(self.coordinator.data, "timestamp") + if timestamp: + return {ATTR_LAST_UPDATE: timestamp} return None diff --git a/homeassistant/components/renault/sensor.py b/homeassistant/components/renault/sensor.py index 56101cd378b..982e16f3b13 100644 --- a/homeassistant/components/renault/sensor.py +++ b/homeassistant/components/renault/sensor.py @@ -102,20 +102,53 @@ class RenaultSensor(RenaultDataEntity[T], SensorEntity): return self.entity_description.value_lambda(self) -def _get_formatted_charging_status( - data: KamereonVehicleBatteryStatusData, -) -> str | None: +def _get_charge_mode_icon(entity: RenaultDataEntity[T]) -> str: + """Return the icon of this entity.""" + if entity.data == "schedule_mode": + return "mdi:calendar-clock" + return "mdi:calendar-remove" + + +def _get_charging_power(entity: RenaultDataEntity[T]) -> StateType: + """Return the charging_power of this entity.""" + if entity.vehicle.details.reports_charging_power_in_watts(): + return cast(float, entity.data) / 1000 + return entity.data + + +def _get_charge_state_formatted(entity: RenaultDataEntity[T]) -> str | None: """Return the charging_status of this entity.""" + data = cast(KamereonVehicleBatteryStatusData, entity.coordinator.data) charging_status = data.get_charging_status() if data else None return charging_status.name.lower() if charging_status else None -def _get_formatted_plug_status(data: KamereonVehicleBatteryStatusData) -> str | None: +def _get_charge_state_icon(entity: RenaultDataEntity[T]) -> str: + """Return the icon of this entity.""" + if entity.data == ChargeState.CHARGE_IN_PROGRESS.value: + return "mdi:flash" + return "mdi:flash-off" + + +def _get_plug_state_formatted(entity: RenaultDataEntity[T]) -> str | None: """Return the plug_status of this entity.""" + data = cast(KamereonVehicleBatteryStatusData, entity.coordinator.data) plug_status = data.get_plug_status() if data else None return plug_status.name.lower() if plug_status else None +def _get_plug_state_icon(entity: RenaultDataEntity[T]) -> str: + """Return the icon of this entity.""" + if entity.data == PlugState.PLUGGED.value: + return "mdi:power-plug" + return "mdi:power-plug-off" + + +def _get_rounded_value(entity: RenaultDataEntity[T]) -> float: + """Return the icon of this entity.""" + return round(cast(float, entity.data)) + + SENSOR_TYPES: tuple[RenaultSensorEntityDescription, ...] = ( RenaultSensorEntityDescription( key="battery_level", @@ -133,17 +166,9 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription, ...] = ( data_key="chargingStatus", device_class=DEVICE_CLASS_CHARGE_STATE, entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], - icon_lambda=lambda x: ( - "mdi:flash" - if x.data == ChargeState.CHARGE_IN_PROGRESS.value - else "mdi:flash-off" - ), + icon_lambda=_get_charge_state_icon, name="Charge State", - value_lambda=lambda x: ( - _get_formatted_charging_status( - cast(KamereonVehicleBatteryStatusData, x.coordinator.data) - ) - ), + value_lambda=_get_charge_state_formatted, ), RenaultSensorEntityDescription( key="charging_remaining_time", @@ -164,11 +189,7 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription, ...] = ( name="Charging Power", native_unit_of_measurement=POWER_KILO_WATT, state_class=STATE_CLASS_MEASUREMENT, - value_lambda=lambda x: ( - cast(float, x.data) / 1000 - if x.vehicle.details.reports_charging_power_in_watts() - else x.data - ), + value_lambda=_get_charging_power, ), RenaultSensorEntityDescription( key="plug_state", @@ -176,17 +197,9 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription, ...] = ( data_key="plugStatus", device_class=DEVICE_CLASS_PLUG_STATE, entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], - icon_lambda=lambda x: ( - "mdi:power-plug" - if x.data == PlugState.PLUGGED.value - else "mdi:power-plug-off" - ), + icon_lambda=_get_plug_state_icon, name="Plug State", - value_lambda=lambda x: ( - _get_formatted_plug_status( - cast(KamereonVehicleBatteryStatusData, x.coordinator.data) - ) - ), + value_lambda=_get_plug_state_formatted, ), RenaultSensorEntityDescription( key="battery_autonomy", @@ -227,7 +240,7 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription, ...] = ( name="Mileage", native_unit_of_measurement=LENGTH_KILOMETERS, state_class=STATE_CLASS_TOTAL_INCREASING, - value_lambda=lambda x: round(cast(float, x.data)), + value_lambda=_get_rounded_value, ), RenaultSensorEntityDescription( key="fuel_autonomy", @@ -239,7 +252,7 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription, ...] = ( native_unit_of_measurement=LENGTH_KILOMETERS, state_class=STATE_CLASS_MEASUREMENT, requires_fuel=True, - value_lambda=lambda x: round(cast(float, x.data)), + value_lambda=_get_rounded_value, ), RenaultSensorEntityDescription( key="fuel_quantity", @@ -251,7 +264,7 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription, ...] = ( native_unit_of_measurement=VOLUME_LITERS, state_class=STATE_CLASS_MEASUREMENT, requires_fuel=True, - value_lambda=lambda x: round(cast(float, x.data)), + value_lambda=_get_rounded_value, ), RenaultSensorEntityDescription( key="outside_temperature", @@ -269,9 +282,7 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription, ...] = ( data_key="chargeMode", device_class=DEVICE_CLASS_CHARGE_MODE, entity_class=RenaultSensor[KamereonVehicleChargeModeData], - icon_lambda=lambda x: ( - "mdi:calendar-clock" if x.data == "schedule_mode" else "mdi:calendar-remove" - ), + icon_lambda=_get_charge_mode_icon, name="Charge Mode", ), ) diff --git a/tests/components/renault/__init__.py b/tests/components/renault/__init__.py index 9191851c777..f77c4bcd40a 100644 --- a/tests/components/renault/__init__.py +++ b/tests/components/renault/__init__.py @@ -1,6 +1,7 @@ """Tests for the Renault integration.""" from __future__ import annotations +from types import MappingProxyType from typing import Any from unittest.mock import patch @@ -10,6 +11,7 @@ from renault_api.renault_account import RenaultAccount from homeassistant.components.renault.const import DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( + ATTR_ICON, ATTR_IDENTIFIERS, ATTR_MANUFACTURER, ATTR_MODEL, @@ -20,7 +22,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client from homeassistant.helpers.device_registry import DeviceRegistry -from .const import MOCK_CONFIG, MOCK_VEHICLES +from .const import ICON_FOR_EMPTY_VALUES, MOCK_CONFIG, MOCK_VEHICLES from tests.common import MockConfigEntry, load_fixture @@ -64,6 +66,12 @@ def get_fixtures(vehicle_type: str) -> dict[str, Any]: } +def get_no_data_icon(expected_entity: MappingProxyType): + """Check attribute for icon for inactive sensors.""" + entity_id = expected_entity["entity_id"] + return ICON_FOR_EMPTY_VALUES.get(entity_id, expected_entity.get(ATTR_ICON)) + + async def setup_renault_integration_simple(hass: HomeAssistant): """Create the Renault integration.""" config_entry = get_mock_config_entry() diff --git a/tests/components/renault/const.py b/tests/components/renault/const.py index c100f85c498..e955f24018a 100644 --- a/tests/components/renault/const.py +++ b/tests/components/renault/const.py @@ -41,13 +41,21 @@ from homeassistant.const import ( VOLUME_LITERS, ) -CHECK_ATTRIBUTES = ( +FIXED_ATTRIBUTES = ( ATTR_DEVICE_CLASS, - ATTR_ICON, - ATTR_LAST_UPDATE, ATTR_STATE_CLASS, ATTR_UNIT_OF_MEASUREMENT, ) +DYNAMIC_ATTRIBUTES = ( + ATTR_ICON, + ATTR_LAST_UPDATE, +) + +ICON_FOR_EMPTY_VALUES = { + "sensor.charge_mode": "mdi:calendar-remove", + "sensor.charge_state": "mdi:flash-off", + "sensor.plug_state": "mdi:power-plug-off", +} # Mock config data to be used across multiple tests MOCK_CONFIG = { diff --git a/tests/components/renault/test_binary_sensor.py b/tests/components/renault/test_binary_sensor.py index c357d9d7a5a..a89c1da7808 100644 --- a/tests/components/renault/test_binary_sensor.py +++ b/tests/components/renault/test_binary_sensor.py @@ -6,22 +6,24 @@ from renault_api.kamereon import exceptions from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.renault.renault_entities import ATTR_LAST_UPDATE -from homeassistant.const import STATE_OFF, STATE_UNAVAILABLE +from homeassistant.const import ATTR_ICON, STATE_OFF, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from . import ( check_device_registry, + get_no_data_icon, setup_renault_integration_vehicle, setup_renault_integration_vehicle_with_no_data, setup_renault_integration_vehicle_with_side_effect, ) -from .const import CHECK_ATTRIBUTES, MOCK_VEHICLES +from .const import DYNAMIC_ATTRIBUTES, FIXED_ATTRIBUTES, MOCK_VEHICLES from tests.common import mock_device_registry, mock_registry @pytest.mark.parametrize("vehicle_type", MOCK_VEHICLES.keys()) -async def test_binary_sensors(hass, vehicle_type): +async def test_binary_sensors(hass: HomeAssistant, vehicle_type: str): """Test for Renault binary sensors.""" await async_setup_component(hass, "persistent_notification", {}) entity_registry = mock_registry(hass) @@ -43,12 +45,12 @@ async def test_binary_sensors(hass, vehicle_type): assert registry_entry.unique_id == expected_entity["unique_id"] state = hass.states.get(entity_id) assert state.state == expected_entity["result"] - for attr in CHECK_ATTRIBUTES: + for attr in FIXED_ATTRIBUTES + DYNAMIC_ATTRIBUTES: assert state.attributes.get(attr) == expected_entity.get(attr) @pytest.mark.parametrize("vehicle_type", MOCK_VEHICLES.keys()) -async def test_binary_sensor_empty(hass, vehicle_type): +async def test_binary_sensor_empty(hass: HomeAssistant, vehicle_type: str): """Test for Renault binary sensors with empty data from Renault.""" await async_setup_component(hass, "persistent_notification", {}) entity_registry = mock_registry(hass) @@ -70,15 +72,15 @@ async def test_binary_sensor_empty(hass, vehicle_type): assert registry_entry.unique_id == expected_entity["unique_id"] state = hass.states.get(entity_id) assert state.state == STATE_OFF - for attr in CHECK_ATTRIBUTES: - if attr == ATTR_LAST_UPDATE: - assert state.attributes.get(attr) is None - else: - assert state.attributes.get(attr) == expected_entity.get(attr) + for attr in FIXED_ATTRIBUTES: + assert state.attributes.get(attr) == expected_entity.get(attr) + # Check dynamic attributes: + assert state.attributes.get(ATTR_ICON) == get_no_data_icon(expected_entity) + assert ATTR_LAST_UPDATE not in state.attributes @pytest.mark.parametrize("vehicle_type", MOCK_VEHICLES.keys()) -async def test_binary_sensor_errors(hass, vehicle_type): +async def test_binary_sensor_errors(hass: HomeAssistant, vehicle_type: str): """Test for Renault binary sensors with temporary failure.""" await async_setup_component(hass, "persistent_notification", {}) entity_registry = mock_registry(hass) @@ -107,11 +109,11 @@ async def test_binary_sensor_errors(hass, vehicle_type): assert registry_entry.unique_id == expected_entity["unique_id"] state = hass.states.get(entity_id) assert state.state == STATE_UNAVAILABLE - for attr in CHECK_ATTRIBUTES: - if attr == ATTR_LAST_UPDATE: - assert state.attributes.get(attr) is None - else: - assert state.attributes.get(attr) == expected_entity.get(attr) + for attr in FIXED_ATTRIBUTES: + assert state.attributes.get(attr) == expected_entity.get(attr) + # Check dynamic attributes: + assert state.attributes.get(ATTR_ICON) == get_no_data_icon(expected_entity) + assert ATTR_LAST_UPDATE not in state.attributes async def test_binary_sensor_access_denied(hass): diff --git a/tests/components/renault/test_init.py b/tests/components/renault/test_init.py index 37a67151972..3446bb1f9fa 100644 --- a/tests/components/renault/test_init.py +++ b/tests/components/renault/test_init.py @@ -6,11 +6,12 @@ from renault_api.gigya.exceptions import InvalidCredentialsException from homeassistant.components.renault.const import DOMAIN from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant from . import get_mock_config_entry, setup_renault_integration_simple -async def test_setup_unload_entry(hass): +async def test_setup_unload_entry(hass: HomeAssistant): """Test entry setup and unload.""" with patch("homeassistant.components.renault.PLATFORMS", []): config_entry = await setup_renault_integration_simple(hass) @@ -26,7 +27,7 @@ async def test_setup_unload_entry(hass): assert config_entry.entry_id not in hass.data[DOMAIN] -async def test_setup_entry_bad_password(hass): +async def test_setup_entry_bad_password(hass: HomeAssistant): """Test entry setup and unload.""" # Create a mock entry so we don't have to go through config flow config_entry = get_mock_config_entry() @@ -44,7 +45,7 @@ async def test_setup_entry_bad_password(hass): assert not hass.data.get(DOMAIN) -async def test_setup_entry_exception(hass): +async def test_setup_entry_exception(hass: HomeAssistant): """Test ConfigEntryNotReady when API raises an exception during entry setup.""" config_entry = get_mock_config_entry() config_entry.add_to_hass(hass) diff --git a/tests/components/renault/test_sensor.py b/tests/components/renault/test_sensor.py index 4bc4d96d75f..370721bb0dd 100644 --- a/tests/components/renault/test_sensor.py +++ b/tests/components/renault/test_sensor.py @@ -4,52 +4,26 @@ from unittest.mock import patch import pytest from renault_api.kamereon import exceptions -from homeassistant.components.renault.const import ( - DEVICE_CLASS_CHARGE_MODE, - DEVICE_CLASS_CHARGE_STATE, - DEVICE_CLASS_PLUG_STATE, -) from homeassistant.components.renault.renault_entities import ATTR_LAST_UPDATE from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ICON, - STATE_UNAVAILABLE, - STATE_UNKNOWN, -) -from homeassistant.core import State +from homeassistant.const import ATTR_ICON, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from . import ( check_device_registry, + get_no_data_icon, setup_renault_integration_vehicle, setup_renault_integration_vehicle_with_no_data, setup_renault_integration_vehicle_with_side_effect, ) -from .const import CHECK_ATTRIBUTES, MOCK_VEHICLES +from .const import DYNAMIC_ATTRIBUTES, FIXED_ATTRIBUTES, MOCK_VEHICLES from tests.common import mock_device_registry, mock_registry -def check_inactive_attribute(state: State, attr: str, expected_entity: dict): - """Check attribute for icon for inactive sensors.""" - if attr == ATTR_LAST_UPDATE: - assert state.attributes.get(attr) is None - elif attr == ATTR_ICON: - if expected_entity.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_CHARGE_MODE: - assert state.attributes.get(ATTR_ICON) == "mdi:calendar-remove" - elif expected_entity.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_CHARGE_STATE: - assert state.attributes.get(ATTR_ICON) == "mdi:flash-off" - elif expected_entity.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_PLUG_STATE: - assert state.attributes.get(ATTR_ICON) == "mdi:power-plug-off" - else: - assert state.attributes.get(ATTR_ICON) == expected_entity.get(ATTR_ICON) - else: - assert state.attributes.get(attr) == expected_entity.get(attr) - - @pytest.mark.parametrize("vehicle_type", MOCK_VEHICLES.keys()) -async def test_sensors(hass, vehicle_type): +async def test_sensors(hass: HomeAssistant, vehicle_type: str): """Test for Renault sensors.""" await async_setup_component(hass, "persistent_notification", {}) entity_registry = mock_registry(hass) @@ -71,12 +45,12 @@ async def test_sensors(hass, vehicle_type): assert registry_entry.unique_id == expected_entity["unique_id"] state = hass.states.get(entity_id) assert state.state == expected_entity["result"] - for attr in CHECK_ATTRIBUTES: + for attr in FIXED_ATTRIBUTES + DYNAMIC_ATTRIBUTES: assert state.attributes.get(attr) == expected_entity.get(attr) @pytest.mark.parametrize("vehicle_type", MOCK_VEHICLES.keys()) -async def test_sensor_empty(hass, vehicle_type): +async def test_sensor_empty(hass: HomeAssistant, vehicle_type: str): """Test for Renault sensors with empty data from Renault.""" await async_setup_component(hass, "persistent_notification", {}) entity_registry = mock_registry(hass) @@ -98,12 +72,15 @@ async def test_sensor_empty(hass, vehicle_type): assert registry_entry.unique_id == expected_entity["unique_id"] state = hass.states.get(entity_id) assert state.state == STATE_UNKNOWN - for attr in CHECK_ATTRIBUTES: - check_inactive_attribute(state, attr, expected_entity) + for attr in FIXED_ATTRIBUTES: + assert state.attributes.get(attr) == expected_entity.get(attr) + # Check dynamic attributes: + assert state.attributes.get(ATTR_ICON) == get_no_data_icon(expected_entity) + assert ATTR_LAST_UPDATE not in state.attributes @pytest.mark.parametrize("vehicle_type", MOCK_VEHICLES.keys()) -async def test_sensor_errors(hass, vehicle_type): +async def test_sensor_errors(hass: HomeAssistant, vehicle_type: str): """Test for Renault sensors with temporary failure.""" await async_setup_component(hass, "persistent_notification", {}) entity_registry = mock_registry(hass) @@ -132,11 +109,14 @@ async def test_sensor_errors(hass, vehicle_type): assert registry_entry.unique_id == expected_entity["unique_id"] state = hass.states.get(entity_id) assert state.state == STATE_UNAVAILABLE - for attr in CHECK_ATTRIBUTES: - check_inactive_attribute(state, attr, expected_entity) + for attr in FIXED_ATTRIBUTES: + assert state.attributes.get(attr) == expected_entity.get(attr) + # Check dynamic attributes: + assert state.attributes.get(ATTR_ICON) == get_no_data_icon(expected_entity) + assert ATTR_LAST_UPDATE not in state.attributes -async def test_sensor_access_denied(hass): +async def test_sensor_access_denied(hass: HomeAssistant): """Test for Renault sensors with access denied failure.""" await async_setup_component(hass, "persistent_notification", {}) entity_registry = mock_registry(hass) @@ -160,7 +140,7 @@ async def test_sensor_access_denied(hass): assert len(entity_registry.entities) == 0 -async def test_sensor_not_supported(hass): +async def test_sensor_not_supported(hass: HomeAssistant): """Test for Renault sensors with access denied failure.""" await async_setup_component(hass, "persistent_notification", {}) entity_registry = mock_registry(hass) From 5693f9ff9bef64c1eefd0738656c0bde4ab64e85 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Fri, 27 Aug 2021 00:48:20 -0600 Subject: [PATCH 035/843] Bump simplisafe-python to 11.0.5 (#55306) --- homeassistant/components/simplisafe/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/simplisafe/manifest.json b/homeassistant/components/simplisafe/manifest.json index 6bf029ead6e..2d524d4c381 100644 --- a/homeassistant/components/simplisafe/manifest.json +++ b/homeassistant/components/simplisafe/manifest.json @@ -3,7 +3,7 @@ "name": "SimpliSafe", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/simplisafe", - "requirements": ["simplisafe-python==11.0.4"], + "requirements": ["simplisafe-python==11.0.5"], "codeowners": ["@bachya"], "iot_class": "cloud_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index b7d2197f27d..0aca370f0c3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2131,7 +2131,7 @@ simplehound==0.3 simplepush==1.1.4 # homeassistant.components.simplisafe -simplisafe-python==11.0.4 +simplisafe-python==11.0.5 # homeassistant.components.sisyphus sisyphus-control==3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 73f3b19c8b6..b807161b19d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1191,7 +1191,7 @@ sharkiqpy==0.1.8 simplehound==0.3 # homeassistant.components.simplisafe -simplisafe-python==11.0.4 +simplisafe-python==11.0.5 # homeassistant.components.slack slackclient==2.5.0 From efb1fb997869a3ace10bc77f756ada817ec40fb5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 Aug 2021 01:49:50 -0500 Subject: [PATCH 036/843] Always send powerview move command in case shade is out of sync (#55308) --- homeassistant/components/hunterdouglas_powerview/cover.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/hunterdouglas_powerview/cover.py b/homeassistant/components/hunterdouglas_powerview/cover.py index 901a048fc7f..22636b7e3c4 100644 --- a/homeassistant/components/hunterdouglas_powerview/cover.py +++ b/homeassistant/components/hunterdouglas_powerview/cover.py @@ -177,8 +177,6 @@ class PowerViewShade(ShadeEntity, CoverEntity): """Move the shade to a position.""" current_hass_position = hd_position_to_hass(self._current_cover_position) steps_to_move = abs(current_hass_position - target_hass_position) - if not steps_to_move: - return self._async_schedule_update_for_transition(steps_to_move) self._async_update_from_command( await self._shade.move( From 259eeb31694f7c665fe1fd49f7627d2864524b05 Mon Sep 17 00:00:00 2001 From: rikroe <42204099+rikroe@users.noreply.github.com> Date: Fri, 27 Aug 2021 12:03:25 +0200 Subject: [PATCH 037/843] Bump bimmer_connected to 0.7.20 (#55299) --- homeassistant/components/bmw_connected_drive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index a7c4c5c837b..8a1e7e2c826 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -2,7 +2,7 @@ "domain": "bmw_connected_drive", "name": "BMW Connected Drive", "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", - "requirements": ["bimmer_connected==0.7.19"], + "requirements": ["bimmer_connected==0.7.20"], "codeowners": ["@gerard33", "@rikroe"], "config_flow": true, "iot_class": "cloud_polling" diff --git a/requirements_all.txt b/requirements_all.txt index 0aca370f0c3..32eb110b1d8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -375,7 +375,7 @@ beautifulsoup4==4.9.3 bellows==0.27.0 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.7.19 +bimmer_connected==0.7.20 # homeassistant.components.bizkaibus bizkaibus==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b807161b19d..bb6f5c4c2ce 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -230,7 +230,7 @@ base36==0.1.1 bellows==0.27.0 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.7.19 +bimmer_connected==0.7.20 # homeassistant.components.blebox blebox_uniapi==1.3.3 From adab367f0e8c48a68b4dffd0783351b0072fbd0a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 27 Aug 2021 12:12:09 +0200 Subject: [PATCH 038/843] Renault code quality improvements (#55313) * Use constants * Drop entity_class for binary_sensor * Move data property from RenaultDataEntity to RenaultSensor * Update function description --- .../components/renault/binary_sensor.py | 21 +++++++++------- homeassistant/components/renault/const.py | 7 ++++-- .../components/renault/renault_entities.py | 20 ++++++--------- homeassistant/components/renault/sensor.py | 25 ++++++++++++------- 4 files changed, 40 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/renault/binary_sensor.py b/homeassistant/components/renault/binary_sensor.py index 17a735f8bc7..2799289fc1d 100644 --- a/homeassistant/components/renault/binary_sensor.py +++ b/homeassistant/components/renault/binary_sensor.py @@ -18,7 +18,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from .const import DOMAIN -from .renault_entities import RenaultDataEntity, RenaultEntityDescription, T +from .renault_entities import RenaultDataEntity, RenaultEntityDescription from .renault_hub import RenaultHub @@ -26,7 +26,7 @@ from .renault_hub import RenaultHub class RenaultBinarySensorRequiredKeysMixin: """Mixin for required keys.""" - entity_class: type[RenaultBinarySensor] + on_key: str on_value: StateType @@ -47,7 +47,7 @@ async def async_setup_entry( """Set up the Renault entities from config entry.""" proxy: RenaultHub = hass.data[DOMAIN][config_entry.entry_id] entities: list[RenaultBinarySensor] = [ - description.entity_class(vehicle, description) + RenaultBinarySensor(vehicle, description) for vehicle in proxy.vehicles.values() for description in BINARY_SENSOR_TYPES if description.coordinator in vehicle.coordinators @@ -55,7 +55,9 @@ async def async_setup_entry( async_add_entities(entities) -class RenaultBinarySensor(RenaultDataEntity[T], BinarySensorEntity): +class RenaultBinarySensor( + RenaultDataEntity[KamereonVehicleBatteryStatusData], BinarySensorEntity +): """Mixin for binary sensor specific attributes.""" entity_description: RenaultBinarySensorEntityDescription @@ -63,26 +65,27 @@ class RenaultBinarySensor(RenaultDataEntity[T], BinarySensorEntity): @property def is_on(self) -> bool | None: """Return true if the binary sensor is on.""" - return self.data == self.entity_description.on_value + return ( + self._get_data_attr(self.entity_description.on_key) + == self.entity_description.on_value + ) BINARY_SENSOR_TYPES: tuple[RenaultBinarySensorEntityDescription, ...] = ( RenaultBinarySensorEntityDescription( key="plugged_in", coordinator="battery", - data_key="plugStatus", device_class=DEVICE_CLASS_PLUG, - entity_class=RenaultBinarySensor[KamereonVehicleBatteryStatusData], name="Plugged In", + on_key="plugStatus", on_value=PlugState.PLUGGED.value, ), RenaultBinarySensorEntityDescription( key="charging", coordinator="battery", - data_key="chargingStatus", device_class=DEVICE_CLASS_BATTERY_CHARGING, - entity_class=RenaultBinarySensor[KamereonVehicleBatteryStatusData], name="Charging", + on_key="chargingStatus", on_value=ChargeState.CHARGE_IN_PROGRESS.value, ), ) diff --git a/homeassistant/components/renault/const.py b/homeassistant/components/renault/const.py index 0987d1829ed..824779a4d3e 100644 --- a/homeassistant/components/renault/const.py +++ b/homeassistant/components/renault/const.py @@ -1,4 +1,7 @@ """Constants for the Renault component.""" +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN + DOMAIN = "renault" CONF_LOCALE = "locale" @@ -7,8 +10,8 @@ CONF_KAMEREON_ACCOUNT_ID = "kamereon_account_id" DEFAULT_SCAN_INTERVAL = 300 # 5 minutes PLATFORMS = [ - "binary_sensor", - "sensor", + BINARY_SENSOR_DOMAIN, + SENSOR_DOMAIN, ] DEVICE_CLASS_PLUG_STATE = "renault__plug_state" diff --git a/homeassistant/components/renault/renault_entities.py b/homeassistant/components/renault/renault_entities.py index 4e364c9d5d1..003103d52d3 100644 --- a/homeassistant/components/renault/renault_entities.py +++ b/homeassistant/components/renault/renault_entities.py @@ -19,15 +19,12 @@ class RenaultRequiredKeysMixin: """Mixin for required keys.""" coordinator: str - data_key: str @dataclass class RenaultEntityDescription(EntityDescription, RenaultRequiredKeysMixin): """Class describing Renault entities.""" - requires_fuel: bool | None = None - ATTR_LAST_UPDATE = "last_update" @@ -51,20 +48,17 @@ class RenaultDataEntity(CoordinatorEntity[Optional[T]], Entity): self._attr_device_info = self.vehicle.device_info self._attr_unique_id = f"{self.vehicle.details.vin}_{description.key}".lower() - @property - def data(self) -> StateType: - """Return the state of this entity.""" + def _get_data_attr(self, key: str) -> StateType: + """Return the attribute value from the coordinator data.""" if self.coordinator.data is None: return None - return cast( - StateType, getattr(self.coordinator.data, self.entity_description.data_key) - ) + return cast(StateType, getattr(self.coordinator.data, key)) @property def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return the state attributes of this entity.""" - if self.entity_description.coordinator == "battery" and self.coordinator.data: - timestamp = getattr(self.coordinator.data, "timestamp") - if timestamp: - return {ATTR_LAST_UPDATE: timestamp} + if self.entity_description.coordinator == "battery": + last_update = self._get_data_attr("timestamp") + if last_update: + return {ATTR_LAST_UPDATE: last_update} return None diff --git a/homeassistant/components/renault/sensor.py b/homeassistant/components/renault/sensor.py index 982e16f3b13..537d708f391 100644 --- a/homeassistant/components/renault/sensor.py +++ b/homeassistant/components/renault/sensor.py @@ -50,6 +50,7 @@ from .renault_hub import RenaultHub class RenaultSensorRequiredKeysMixin: """Mixin for required keys.""" + data_key: str entity_class: type[RenaultSensor] @@ -59,8 +60,9 @@ class RenaultSensorEntityDescription( ): """Class describing Renault sensor entities.""" - icon_lambda: Callable[[RenaultDataEntity[T]], str] | None = None - value_lambda: Callable[[RenaultDataEntity[T]], StateType] | None = None + icon_lambda: Callable[[RenaultSensor[T]], str] | None = None + requires_fuel: bool | None = None + value_lambda: Callable[[RenaultSensor[T]], StateType] | None = None async def async_setup_entry( @@ -85,6 +87,11 @@ class RenaultSensor(RenaultDataEntity[T], SensorEntity): entity_description: RenaultSensorEntityDescription + @property + def data(self) -> StateType: + """Return the state of this entity.""" + return self._get_data_attr(self.entity_description.data_key) + @property def icon(self) -> str | None: """Icon handling.""" @@ -102,49 +109,49 @@ class RenaultSensor(RenaultDataEntity[T], SensorEntity): return self.entity_description.value_lambda(self) -def _get_charge_mode_icon(entity: RenaultDataEntity[T]) -> str: +def _get_charge_mode_icon(entity: RenaultSensor[T]) -> str: """Return the icon of this entity.""" if entity.data == "schedule_mode": return "mdi:calendar-clock" return "mdi:calendar-remove" -def _get_charging_power(entity: RenaultDataEntity[T]) -> StateType: +def _get_charging_power(entity: RenaultSensor[T]) -> StateType: """Return the charging_power of this entity.""" if entity.vehicle.details.reports_charging_power_in_watts(): return cast(float, entity.data) / 1000 return entity.data -def _get_charge_state_formatted(entity: RenaultDataEntity[T]) -> str | None: +def _get_charge_state_formatted(entity: RenaultSensor[T]) -> str | None: """Return the charging_status of this entity.""" data = cast(KamereonVehicleBatteryStatusData, entity.coordinator.data) charging_status = data.get_charging_status() if data else None return charging_status.name.lower() if charging_status else None -def _get_charge_state_icon(entity: RenaultDataEntity[T]) -> str: +def _get_charge_state_icon(entity: RenaultSensor[T]) -> str: """Return the icon of this entity.""" if entity.data == ChargeState.CHARGE_IN_PROGRESS.value: return "mdi:flash" return "mdi:flash-off" -def _get_plug_state_formatted(entity: RenaultDataEntity[T]) -> str | None: +def _get_plug_state_formatted(entity: RenaultSensor[T]) -> str | None: """Return the plug_status of this entity.""" data = cast(KamereonVehicleBatteryStatusData, entity.coordinator.data) plug_status = data.get_plug_status() if data else None return plug_status.name.lower() if plug_status else None -def _get_plug_state_icon(entity: RenaultDataEntity[T]) -> str: +def _get_plug_state_icon(entity: RenaultSensor[T]) -> str: """Return the icon of this entity.""" if entity.data == PlugState.PLUGGED.value: return "mdi:power-plug" return "mdi:power-plug-off" -def _get_rounded_value(entity: RenaultDataEntity[T]) -> float: +def _get_rounded_value(entity: RenaultSensor[T]) -> float: """Return the icon of this entity.""" return round(cast(float, entity.data)) From cc857abfd21b415c9d16756c5e6e670c038987b9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 Aug 2021 09:01:26 -0500 Subject: [PATCH 039/843] Adjust zha comment to be readable (#55330) --- homeassistant/components/zha/config_flow.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 4bf255e95a0..481b79c5aa7 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -145,9 +145,9 @@ class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: auto_detected_data = await detect_radios(self._device_path) if auto_detected_data is None: - # This probably will not happen how they have - # have very specific usb matching, but there could - # be a problem with the device + # This path probably will not happen now that we have + # more precise USB matching unless there is a problem + # with the device return self.async_abort(reason="usb_probe_failed") return self.async_create_entry( title=self._title, From 7e70252de58d6dfa857eb35c1d21a599b0fc655f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 27 Aug 2021 16:18:49 +0200 Subject: [PATCH 040/843] Handle statistics for sensor with changing state class (#55316) --- homeassistant/components/recorder/models.py | 1 + .../components/recorder/statistics.py | 89 +++++++++++++----- homeassistant/components/sensor/recorder.py | 2 +- tests/components/sensor/test_recorder.py | 90 +++++++++++++++++++ 4 files changed, 158 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py index 8b5aef88738..28eff4d9d95 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -267,6 +267,7 @@ class Statistics(Base): # type: ignore class StatisticMetaData(TypedDict, total=False): """Statistic meta data class.""" + statistic_id: str unit_of_measurement: str | None has_mean: bool has_sum: bool diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 06f7851b1a6..ddc542d23b7 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -53,6 +53,13 @@ QUERY_STATISTIC_META = [ StatisticsMeta.id, StatisticsMeta.statistic_id, StatisticsMeta.unit_of_measurement, + StatisticsMeta.has_mean, + StatisticsMeta.has_sum, +] + +QUERY_STATISTIC_META_ID = [ + StatisticsMeta.id, + StatisticsMeta.statistic_id, ] STATISTICS_BAKERY = "recorder_statistics_bakery" @@ -124,33 +131,61 @@ def _get_metadata_ids( ) -> list[str]: """Resolve metadata_id for a list of statistic_ids.""" baked_query = hass.data[STATISTICS_META_BAKERY]( - lambda session: session.query(*QUERY_STATISTIC_META) + lambda session: session.query(*QUERY_STATISTIC_META_ID) ) baked_query += lambda q: q.filter( StatisticsMeta.statistic_id.in_(bindparam("statistic_ids")) ) result = execute(baked_query(session).params(statistic_ids=statistic_ids)) - return [id for id, _, _ in result] if result else [] + return [id for id, _ in result] if result else [] -def _get_or_add_metadata_id( +def _update_or_add_metadata( hass: HomeAssistant, session: scoped_session, statistic_id: str, - metadata: StatisticMetaData, + new_metadata: StatisticMetaData, ) -> str: """Get metadata_id for a statistic_id, add if it doesn't exist.""" - metadata_id = _get_metadata_ids(hass, session, [statistic_id]) - if not metadata_id: - unit = metadata["unit_of_measurement"] - has_mean = metadata["has_mean"] - has_sum = metadata["has_sum"] + old_metadata_dict = _get_metadata(hass, session, [statistic_id], None) + if not old_metadata_dict: + unit = new_metadata["unit_of_measurement"] + has_mean = new_metadata["has_mean"] + has_sum = new_metadata["has_sum"] session.add( StatisticsMeta.from_meta(DOMAIN, statistic_id, unit, has_mean, has_sum) ) - metadata_id = _get_metadata_ids(hass, session, [statistic_id]) - return metadata_id[0] + metadata_ids = _get_metadata_ids(hass, session, [statistic_id]) + _LOGGER.debug( + "Added new statistics metadata for %s, new_metadata: %s", + statistic_id, + new_metadata, + ) + return metadata_ids[0] + + metadata_id, old_metadata = next(iter(old_metadata_dict.items())) + if ( + old_metadata["has_mean"] != new_metadata["has_mean"] + or old_metadata["has_sum"] != new_metadata["has_sum"] + or old_metadata["unit_of_measurement"] != new_metadata["unit_of_measurement"] + ): + session.query(StatisticsMeta).filter_by(statistic_id=statistic_id).update( + { + StatisticsMeta.has_mean: new_metadata["has_mean"], + StatisticsMeta.has_sum: new_metadata["has_sum"], + StatisticsMeta.unit_of_measurement: new_metadata["unit_of_measurement"], + }, + synchronize_session=False, + ) + _LOGGER.debug( + "Updated statistics metadata for %s, old_metadata: %s, new_metadata: %s", + statistic_id, + old_metadata, + new_metadata, + ) + + return metadata_id @retryable_database_job("statistics") @@ -177,7 +212,7 @@ def compile_statistics(instance: Recorder, start: datetime) -> bool: with session_scope(session=instance.get_session()) as session: # type: ignore for stats in platform_stats: for entity_id, stat in stats.items(): - metadata_id = _get_or_add_metadata_id( + metadata_id = _update_or_add_metadata( instance.hass, session, entity_id, stat["meta"] ) session.add(Statistics.from_stats(metadata_id, start, stat["stat"])) @@ -191,14 +226,19 @@ def _get_metadata( session: scoped_session, statistic_ids: list[str] | None, statistic_type: str | None, -) -> dict[str, dict[str, str]]: +) -> dict[str, StatisticMetaData]: """Fetch meta data.""" - def _meta(metas: list, wanted_metadata_id: str) -> dict[str, str] | None: - meta = None - for metadata_id, statistic_id, unit in metas: + def _meta(metas: list, wanted_metadata_id: str) -> StatisticMetaData | None: + meta: StatisticMetaData | None = None + for metadata_id, statistic_id, unit, has_mean, has_sum in metas: if metadata_id == wanted_metadata_id: - meta = {"unit_of_measurement": unit, "statistic_id": statistic_id} + meta = { + "statistic_id": statistic_id, + "unit_of_measurement": unit, + "has_mean": has_mean, + "has_sum": has_sum, + } return meta baked_query = hass.data[STATISTICS_META_BAKERY]( @@ -219,7 +259,7 @@ def _get_metadata( return {} metadata_ids = [metadata[0] for metadata in result] - metadata = {} + metadata: dict[str, StatisticMetaData] = {} for _id in metadata_ids: meta = _meta(result, _id) if meta: @@ -230,7 +270,7 @@ def _get_metadata( def get_metadata( hass: HomeAssistant, statistic_id: str, -) -> dict[str, str] | None: +) -> StatisticMetaData | None: """Return metadata for a statistic_id.""" statistic_ids = [statistic_id] with session_scope(hass=hass) as session: @@ -255,7 +295,7 @@ def _configured_unit(unit: str, units: UnitSystem) -> str: def list_statistic_ids( hass: HomeAssistant, statistic_type: str | None = None -) -> list[dict[str, str] | None]: +) -> list[StatisticMetaData | None]: """Return statistic_ids and meta data.""" units = hass.config.units statistic_ids = {} @@ -263,7 +303,9 @@ def list_statistic_ids( metadata = _get_metadata(hass, session, None, statistic_type) for meta in metadata.values(): - unit = _configured_unit(meta["unit_of_measurement"], units) + unit = meta["unit_of_measurement"] + if unit is not None: + unit = _configured_unit(unit, units) meta["unit_of_measurement"] = unit statistic_ids = { @@ -277,7 +319,8 @@ def list_statistic_ids( platform_statistic_ids = platform.list_statistic_ids(hass, statistic_type) for statistic_id, unit in platform_statistic_ids.items(): - unit = _configured_unit(unit, units) + if unit is not None: + unit = _configured_unit(unit, units) platform_statistic_ids[statistic_id] = unit statistic_ids = {**statistic_ids, **platform_statistic_ids} @@ -367,7 +410,7 @@ def _sorted_statistics_to_dict( hass: HomeAssistant, stats: list, statistic_ids: list[str] | None, - metadata: dict[str, dict[str, str]], + metadata: dict[str, StatisticMetaData], ) -> dict[str, list[dict]]: """Convert SQL results into JSON friendly data structure.""" result: dict = defaultdict(list) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index bcb21136007..2b59592dd17 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -352,7 +352,7 @@ def compile_statistics( # We have compiled history for this sensor before, use that as a starting point last_reset = old_last_reset = last_stats[entity_id][0]["last_reset"] new_state = old_state = last_stats[entity_id][0]["state"] - _sum = last_stats[entity_id][0]["sum"] + _sum = last_stats[entity_id][0]["sum"] or 0 for fstate, state in fstates: diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index c7f356e49ee..2e300b9c748 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -10,6 +10,7 @@ from homeassistant.components.recorder import history from homeassistant.components.recorder.const import DATA_INSTANCE from homeassistant.components.recorder.models import process_timestamp_to_utc_isoformat from homeassistant.components.recorder.statistics import ( + get_metadata, list_statistic_ids, statistics_during_period, ) @@ -1037,6 +1038,95 @@ def test_compile_hourly_statistics_changing_units_2( assert "Error while processing event StatisticsTask" not in caplog.text +@pytest.mark.parametrize( + "device_class,unit,native_unit,mean,min,max", + [ + (None, None, None, 16.440677, 10, 30), + ], +) +def test_compile_hourly_statistics_changing_statistics( + hass_recorder, caplog, device_class, unit, native_unit, mean, min, max +): + """Test compiling hourly statistics where units change during an hour.""" + zero = dt_util.utcnow() + hass = hass_recorder() + recorder = hass.data[DATA_INSTANCE] + setup_component(hass, "sensor", {}) + attributes_1 = { + "device_class": device_class, + "state_class": "measurement", + "unit_of_measurement": unit, + } + attributes_2 = { + "device_class": device_class, + "state_class": "total_increasing", + "unit_of_measurement": unit, + } + four, states = record_states(hass, zero, "sensor.test1", attributes_1) + recorder.do_adhoc_statistics(period="hourly", start=zero) + wait_recording_done(hass) + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + {"statistic_id": "sensor.test1", "unit_of_measurement": None} + ] + metadata = get_metadata(hass, "sensor.test1") + assert metadata == { + "has_mean": True, + "has_sum": False, + "statistic_id": "sensor.test1", + "unit_of_measurement": None, + } + + # Add more states, with changed state class + four, _states = record_states( + hass, zero + timedelta(hours=1), "sensor.test1", attributes_2 + ) + states["sensor.test1"] += _states["sensor.test1"] + hist = history.get_significant_states(hass, zero, four) + assert dict(states) == dict(hist) + + recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=1)) + wait_recording_done(hass) + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + {"statistic_id": "sensor.test1", "unit_of_measurement": None} + ] + metadata = get_metadata(hass, "sensor.test1") + assert metadata == { + "has_mean": False, + "has_sum": True, + "statistic_id": "sensor.test1", + "unit_of_measurement": None, + } + stats = statistics_during_period(hass, zero) + assert stats == { + "sensor.test1": [ + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero), + "mean": approx(mean), + "min": approx(min), + "max": approx(max), + "last_reset": None, + "state": None, + "sum": None, + }, + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), + "mean": None, + "min": None, + "max": None, + "last_reset": None, + "state": approx(30.0), + "sum": approx(30.0), + }, + ] + } + + assert "Error while processing event StatisticsTask" not in caplog.text + + def record_states(hass, zero, entity_id, attributes): """Record some test states. From e2dac31471893d032bbcd42be4c4daeefcccba60 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 27 Aug 2021 17:09:34 +0200 Subject: [PATCH 041/843] Use EntityDescription - fritzbox (#55104) * Use sensor entity description * check if not none instead if callable * List comprehension in switch and climate * change state to native_value in description * merge FritzBoxSensorEntity into FritzBoxEntity * rename SENSOR_DESCRIPTIONS to SENSOR_TYPES * use mixins for descriptions * use comprehension in async_setup_entry() * improve extra_state_attributes --- homeassistant/components/fritzbox/__init__.py | 50 +++--- .../components/fritzbox/binary_sensor.py | 63 ++++--- homeassistant/components/fritzbox/climate.py | 32 +--- homeassistant/components/fritzbox/const.py | 70 ++++++++ homeassistant/components/fritzbox/model.py | 67 ++++++-- homeassistant/components/fritzbox/sensor.py | 160 ++---------------- homeassistant/components/fritzbox/switch.py | 51 +----- 7 files changed, 206 insertions(+), 287 deletions(-) diff --git a/homeassistant/components/fritzbox/__init__.py b/homeassistant/components/fritzbox/__init__.py index ce5e74cfeec..d9226f36c87 100644 --- a/homeassistant/components/fritzbox/__init__.py +++ b/homeassistant/components/fritzbox/__init__.py @@ -6,12 +6,8 @@ from datetime import timedelta from pyfritzhome import Fritzhome, FritzhomeDevice, LoginError import requests -from homeassistant.components.sensor import ATTR_STATE_CLASS from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ENTITY_ID, - ATTR_NAME, CONF_HOST, CONF_PASSWORD, CONF_USERNAME, @@ -20,15 +16,23 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity import DeviceInfo, EntityDescription from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, ) -from .const import CONF_CONNECTIONS, CONF_COORDINATOR, DOMAIN, LOGGER, PLATFORMS -from .model import EntityInfo +from .const import ( + ATTR_STATE_DEVICE_LOCKED, + ATTR_STATE_LOCKED, + CONF_CONNECTIONS, + CONF_COORDINATOR, + DOMAIN, + LOGGER, + PLATFORMS, +) +from .model import FritzExtraAttributes async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -128,18 +132,21 @@ class FritzBoxEntity(CoordinatorEntity): def __init__( self, - entity_info: EntityInfo, coordinator: DataUpdateCoordinator[dict[str, FritzhomeDevice]], ain: str, + entity_description: EntityDescription | None = None, ) -> None: """Initialize the FritzBox entity.""" super().__init__(coordinator) self.ain = ain - self._name = entity_info[ATTR_NAME] - self._unique_id = entity_info[ATTR_ENTITY_ID] - self._device_class = entity_info[ATTR_DEVICE_CLASS] - self._attr_state_class = entity_info[ATTR_STATE_CLASS] + if entity_description is not None: + self.entity_description = entity_description + self._attr_name = f"{self.device.name} {entity_description.name}" + self._attr_unique_id = f"{ain}_{entity_description.key}" + else: + self._attr_name = self.device.name + self._attr_unique_id = ain @property def available(self) -> bool: @@ -163,16 +170,9 @@ class FritzBoxEntity(CoordinatorEntity): } @property - def unique_id(self) -> str: - """Return the unique ID of the device.""" - return self._unique_id - - @property - def name(self) -> str: - """Return the name of the device.""" - return self._name - - @property - def device_class(self) -> str | None: - """Return the device class.""" - return self._device_class + def extra_state_attributes(self) -> FritzExtraAttributes: + """Return the state attributes of the device.""" + return { + ATTR_STATE_DEVICE_LOCKED: self.device.device_lock, + ATTR_STATE_LOCKED: self.device.lock, + } diff --git a/homeassistant/components/fritzbox/binary_sensor.py b/homeassistant/components/fritzbox/binary_sensor.py index 5514408cb3c..b7e78ceaf47 100644 --- a/homeassistant/components/fritzbox/binary_sensor.py +++ b/homeassistant/components/fritzbox/binary_sensor.py @@ -1,57 +1,52 @@ """Support for Fritzbox binary sensors.""" from __future__ import annotations -from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_WINDOW, - BinarySensorEntity, -) -from homeassistant.components.sensor import ATTR_STATE_CLASS +from pyfritzhome.fritzhomedevice import FritzhomeDevice + +from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.components.fritzbox.model import FritzBinarySensorEntityDescription from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ENTITY_ID, - ATTR_NAME, - ATTR_UNIT_OF_MEASUREMENT, -) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import FritzBoxEntity -from .const import CONF_COORDINATOR, DOMAIN as FRITZBOX_DOMAIN +from .const import BINARY_SENSOR_TYPES, CONF_COORDINATOR, DOMAIN as FRITZBOX_DOMAIN async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the FRITZ!SmartHome binary sensor from ConfigEntry.""" - entities: list[FritzboxBinarySensor] = [] coordinator = hass.data[FRITZBOX_DOMAIN][entry.entry_id][CONF_COORDINATOR] - for ain, device in coordinator.data.items(): - if not device.has_alarm: - continue - - entities.append( - FritzboxBinarySensor( - { - ATTR_NAME: f"{device.name}", - ATTR_ENTITY_ID: f"{device.ain}", - ATTR_UNIT_OF_MEASUREMENT: None, - ATTR_DEVICE_CLASS: DEVICE_CLASS_WINDOW, - ATTR_STATE_CLASS: None, - }, - coordinator, - ain, - ) - ) - - async_add_entities(entities) + async_add_entities( + [ + FritzboxBinarySensor(coordinator, ain, description) + for ain, device in coordinator.data.items() + for description in BINARY_SENSOR_TYPES + if description.suitable(device) + ] + ) class FritzboxBinarySensor(FritzBoxEntity, BinarySensorEntity): """Representation of a binary FRITZ!SmartHome device.""" + entity_description: FritzBinarySensorEntityDescription + + def __init__( + self, + coordinator: DataUpdateCoordinator[dict[str, FritzhomeDevice]], + ain: str, + entity_description: FritzBinarySensorEntityDescription, + ) -> None: + """Initialize the FritzBox entity.""" + super().__init__(coordinator, ain, entity_description) + self._attr_name = self.device.name + self._attr_unique_id = ain + @property - def is_on(self) -> bool: + def is_on(self) -> bool | None: """Return true if sensor is on.""" - return self.device.alert_state # type: ignore [no-any-return] + return self.entity_description.is_on(self.device) diff --git a/homeassistant/components/fritzbox/climate.py b/homeassistant/components/fritzbox/climate.py index 4baa1b3b81a..f8e394e1ef0 100644 --- a/homeassistant/components/fritzbox/climate.py +++ b/homeassistant/components/fritzbox/climate.py @@ -13,15 +13,10 @@ from homeassistant.components.climate.const import ( SUPPORT_PRESET_MODE, SUPPORT_TARGET_TEMPERATURE, ) -from homeassistant.components.sensor import ATTR_STATE_CLASS from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_BATTERY_LEVEL, - ATTR_DEVICE_CLASS, - ATTR_ENTITY_ID, - ATTR_NAME, ATTR_TEMPERATURE, - ATTR_UNIT_OF_MEASUREMENT, PRECISION_HALVES, TEMP_CELSIUS, ) @@ -61,28 +56,15 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the FRITZ!SmartHome thermostat from ConfigEntry.""" - entities: list[FritzboxThermostat] = [] coordinator = hass.data[FRITZBOX_DOMAIN][entry.entry_id][CONF_COORDINATOR] - for ain, device in coordinator.data.items(): - if not device.has_thermostat: - continue - - entities.append( - FritzboxThermostat( - { - ATTR_NAME: f"{device.name}", - ATTR_ENTITY_ID: f"{device.ain}", - ATTR_UNIT_OF_MEASUREMENT: None, - ATTR_DEVICE_CLASS: None, - ATTR_STATE_CLASS: None, - }, - coordinator, - ain, - ) - ) - - async_add_entities(entities) + async_add_entities( + [ + FritzboxThermostat(coordinator, ain) + for ain, device in coordinator.data.items() + if device.has_thermostat + ] + ) class FritzboxThermostat(FritzBoxEntity, ClimateEntity): diff --git a/homeassistant/components/fritzbox/const.py b/homeassistant/components/fritzbox/const.py index 6af75449a29..79123abbda7 100644 --- a/homeassistant/components/fritzbox/const.py +++ b/homeassistant/components/fritzbox/const.py @@ -4,6 +4,26 @@ from __future__ import annotations import logging from typing import Final +from homeassistant.components.binary_sensor import DEVICE_CLASS_WINDOW +from homeassistant.components.fritzbox.model import ( + FritzBinarySensorEntityDescription, + FritzSensorEntityDescription, +) +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, +) +from homeassistant.const import ( + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_TEMPERATURE, + ENERGY_KILO_WATT_HOUR, + PERCENTAGE, + POWER_WATT, + TEMP_CELSIUS, +) + ATTR_STATE_BATTERY_LOW: Final = "battery_low" ATTR_STATE_DEVICE_LOCKED: Final = "device_locked" ATTR_STATE_HOLIDAY_MODE: Final = "holiday_mode" @@ -24,3 +44,53 @@ DOMAIN: Final = "fritzbox" LOGGER: Final[logging.Logger] = logging.getLogger(__package__) PLATFORMS: Final[list[str]] = ["binary_sensor", "climate", "switch", "sensor"] + +BINARY_SENSOR_TYPES: Final[tuple[FritzBinarySensorEntityDescription, ...]] = ( + FritzBinarySensorEntityDescription( + key="alarm", + name="Alarm", + device_class=DEVICE_CLASS_WINDOW, + suitable=lambda device: device.has_alarm, # type: ignore[no-any-return] + is_on=lambda device: device.alert_state, # type: ignore[no-any-return] + ), +) + +SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( + FritzSensorEntityDescription( + key="temperature", + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + suitable=lambda device: ( + device.has_temperature_sensor and not device.has_thermostat + ), + native_value=lambda device: device.temperature, # type: ignore[no-any-return] + ), + FritzSensorEntityDescription( + key="battery", + name="Battery", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_BATTERY, + suitable=lambda device: device.battery_level is not None, + native_value=lambda device: device.battery_level, # type: ignore[no-any-return] + ), + FritzSensorEntityDescription( + key="power_consumption", + name="Power Consumption", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + suitable=lambda device: device.has_powermeter, # type: ignore[no-any-return] + native_value=lambda device: device.power / 1000 if device.power else 0.0, + ), + FritzSensorEntityDescription( + key="total_energy", + name="Total Energy", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + suitable=lambda device: device.has_powermeter, # type: ignore[no-any-return] + native_value=lambda device: device.energy / 1000 if device.energy else 0.0, + ), +) diff --git a/homeassistant/components/fritzbox/model.py b/homeassistant/components/fritzbox/model.py index 0e401a75be3..fb694a97012 100644 --- a/homeassistant/components/fritzbox/model.py +++ b/homeassistant/components/fritzbox/model.py @@ -1,7 +1,13 @@ """Models for the AVM FRITZ!SmartHome integration.""" from __future__ import annotations -from typing import TypedDict +from dataclasses import dataclass +from typing import Callable, TypedDict + +from pyfritzhome import FritzhomeDevice + +from homeassistant.components.binary_sensor import BinarySensorEntityDescription +from homeassistant.components.sensor import SensorEntityDescription class EntityInfo(TypedDict): @@ -14,25 +20,23 @@ class EntityInfo(TypedDict): state_class: str | None -class ClimateExtraAttributes(TypedDict, total=False): - """TypedDict for climates extra attributes.""" - - battery_low: bool - device_locked: bool - locked: bool - battery_level: int - holiday_mode: bool - summer_mode: bool - window_open: bool - - -class SensorExtraAttributes(TypedDict): +class FritzExtraAttributes(TypedDict): """TypedDict for sensors extra attributes.""" device_locked: bool locked: bool +class ClimateExtraAttributes(FritzExtraAttributes, total=False): + """TypedDict for climates extra attributes.""" + + battery_low: bool + battery_level: int + holiday_mode: bool + summer_mode: bool + window_open: bool + + class SwitchExtraAttributes(TypedDict, total=False): """TypedDict for sensors extra attributes.""" @@ -42,3 +46,38 @@ class SwitchExtraAttributes(TypedDict, total=False): total_consumption_unit: str temperature: str temperature_unit: str + + +@dataclass +class FritzEntityDescriptionMixinBase: + """Bases description mixin for Fritz!Smarthome entities.""" + + suitable: Callable[[FritzhomeDevice], bool] + + +@dataclass +class FritzEntityDescriptionMixinSensor(FritzEntityDescriptionMixinBase): + """Sensor description mixin for Fritz!Smarthome entities.""" + + native_value: Callable[[FritzhomeDevice], float | int | None] + + +@dataclass +class FritzEntityDescriptionMixinBinarySensor(FritzEntityDescriptionMixinBase): + """BinarySensor description mixin for Fritz!Smarthome entities.""" + + is_on: Callable[[FritzhomeDevice], bool | None] + + +@dataclass +class FritzSensorEntityDescription( + SensorEntityDescription, FritzEntityDescriptionMixinSensor +): + """Description for Fritz!Smarthome sensor entities.""" + + +@dataclass +class FritzBinarySensorEntityDescription( + BinarySensorEntityDescription, FritzEntityDescriptionMixinBinarySensor +): + """Description for Fritz!Smarthome binary sensor entities.""" diff --git a/homeassistant/components/fritzbox/sensor.py b/homeassistant/components/fritzbox/sensor.py index 09a652d64ad..2150c2359b3 100644 --- a/homeassistant/components/fritzbox/sensor.py +++ b/homeassistant/components/fritzbox/sensor.py @@ -1,170 +1,38 @@ """Support for AVM FRITZ!SmartHome temperature sensor only devices.""" from __future__ import annotations -from pyfritzhome import FritzhomeDevice - -from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, - STATE_CLASS_MEASUREMENT, - STATE_CLASS_TOTAL_INCREASING, - SensorEntity, -) +from homeassistant.components.fritzbox.model import FritzSensorEntityDescription +from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ENTITY_ID, - ATTR_NAME, - ATTR_UNIT_OF_MEASUREMENT, - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_ENERGY, - DEVICE_CLASS_POWER, - DEVICE_CLASS_TEMPERATURE, - ENERGY_KILO_WATT_HOUR, - PERCENTAGE, - POWER_WATT, - TEMP_CELSIUS, -) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import FritzBoxEntity -from .const import ( - ATTR_STATE_DEVICE_LOCKED, - ATTR_STATE_LOCKED, - CONF_COORDINATOR, - DOMAIN as FRITZBOX_DOMAIN, -) -from .model import EntityInfo, SensorExtraAttributes +from .const import CONF_COORDINATOR, DOMAIN as FRITZBOX_DOMAIN, SENSOR_TYPES async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the FRITZ!SmartHome sensor from ConfigEntry.""" - entities: list[FritzBoxEntity] = [] coordinator = hass.data[FRITZBOX_DOMAIN][entry.entry_id][CONF_COORDINATOR] - for ain, device in coordinator.data.items(): - if device.has_temperature_sensor and not device.has_thermostat: - entities.append( - FritzBoxTempSensor( - { - ATTR_NAME: f"{device.name} Temperature", - ATTR_ENTITY_ID: f"{device.ain}_temperature", - ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - coordinator, - ain, - ) - ) - - if device.battery_level is not None: - entities.append( - FritzBoxBatterySensor( - { - ATTR_NAME: f"{device.name} Battery", - ATTR_ENTITY_ID: f"{device.ain}_battery", - ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, - ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY, - ATTR_STATE_CLASS: None, - }, - coordinator, - ain, - ) - ) - - if device.has_powermeter: - entities.append( - FritzBoxPowerSensor( - { - ATTR_NAME: f"{device.name} Power Consumption", - ATTR_ENTITY_ID: f"{device.ain}_power_consumption", - ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, - ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - coordinator, - ain, - ) - ) - entities.append( - FritzBoxEnergySensor( - { - ATTR_NAME: f"{device.name} Total Energy", - ATTR_ENTITY_ID: f"{device.ain}_total_energy", - ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, - ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, - }, - coordinator, - ain, - ) - ) - - async_add_entities(entities) + async_add_entities( + [ + FritzBoxSensor(coordinator, ain, description) + for ain, device in coordinator.data.items() + for description in SENSOR_TYPES + if description.suitable(device) + ] + ) class FritzBoxSensor(FritzBoxEntity, SensorEntity): """The entity class for FRITZ!SmartHome sensors.""" - def __init__( - self, - entity_info: EntityInfo, - coordinator: DataUpdateCoordinator[dict[str, FritzhomeDevice]], - ain: str, - ) -> None: - """Initialize the FritzBox entity.""" - FritzBoxEntity.__init__(self, entity_info, coordinator, ain) - self._attr_native_unit_of_measurement = entity_info[ATTR_UNIT_OF_MEASUREMENT] - - -class FritzBoxBatterySensor(FritzBoxSensor): - """The entity class for FRITZ!SmartHome battery sensors.""" + entity_description: FritzSensorEntityDescription @property - def native_value(self) -> int | None: + def native_value(self) -> float | int | None: """Return the state of the sensor.""" - return self.device.battery_level # type: ignore [no-any-return] - - -class FritzBoxPowerSensor(FritzBoxSensor): - """The entity class for FRITZ!SmartHome power consumption sensors.""" - - @property - def native_value(self) -> float | None: - """Return the state of the sensor.""" - if power := self.device.power: - return power / 1000 # type: ignore [no-any-return] - return 0.0 - - -class FritzBoxEnergySensor(FritzBoxSensor): - """The entity class for FRITZ!SmartHome total energy sensors.""" - - @property - def native_value(self) -> float | None: - """Return the state of the sensor.""" - if energy := self.device.energy: - return energy / 1000 # type: ignore [no-any-return] - return 0.0 - - -class FritzBoxTempSensor(FritzBoxSensor): - """The entity class for FRITZ!SmartHome temperature sensors.""" - - @property - def native_value(self) -> float | None: - """Return the state of the sensor.""" - return self.device.temperature # type: ignore [no-any-return] - - @property - def extra_state_attributes(self) -> SensorExtraAttributes: - """Return the state attributes of the device.""" - attrs: SensorExtraAttributes = { - ATTR_STATE_DEVICE_LOCKED: self.device.device_lock, - ATTR_STATE_LOCKED: self.device.lock, - } - return attrs + return self.entity_description.native_value(self.device) diff --git a/homeassistant/components/fritzbox/switch.py b/homeassistant/components/fritzbox/switch.py index 133db92feda..79f256bded0 100644 --- a/homeassistant/components/fritzbox/switch.py +++ b/homeassistant/components/fritzbox/switch.py @@ -3,54 +3,28 @@ from __future__ import annotations from typing import Any -from homeassistant.components.sensor import ATTR_STATE_CLASS from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ENTITY_ID, - ATTR_NAME, - ATTR_UNIT_OF_MEASUREMENT, -) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import FritzBoxEntity -from .const import ( - ATTR_STATE_DEVICE_LOCKED, - ATTR_STATE_LOCKED, - CONF_COORDINATOR, - DOMAIN as FRITZBOX_DOMAIN, -) -from .model import SwitchExtraAttributes +from .const import CONF_COORDINATOR, DOMAIN as FRITZBOX_DOMAIN async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the FRITZ!SmartHome switch from ConfigEntry.""" - entities: list[FritzboxSwitch] = [] coordinator = hass.data[FRITZBOX_DOMAIN][entry.entry_id][CONF_COORDINATOR] - for ain, device in coordinator.data.items(): - if not device.has_switch: - continue - - entities.append( - FritzboxSwitch( - { - ATTR_NAME: f"{device.name}", - ATTR_ENTITY_ID: f"{device.ain}", - ATTR_UNIT_OF_MEASUREMENT: None, - ATTR_DEVICE_CLASS: None, - ATTR_STATE_CLASS: None, - }, - coordinator, - ain, - ) - ) - - async_add_entities(entities) + async_add_entities( + [ + FritzboxSwitch(coordinator, ain) + for ain, device in coordinator.data.items() + if device.has_switch + ] + ) class FritzboxSwitch(FritzBoxEntity, SwitchEntity): @@ -70,12 +44,3 @@ class FritzboxSwitch(FritzBoxEntity, SwitchEntity): """Turn the switch off.""" await self.hass.async_add_executor_job(self.device.set_switch_state_off) await self.coordinator.async_refresh() - - @property - def extra_state_attributes(self) -> SwitchExtraAttributes: - """Return the state attributes of the device.""" - attrs: SwitchExtraAttributes = { - ATTR_STATE_DEVICE_LOCKED: self.device.device_lock, - ATTR_STATE_LOCKED: self.device.lock, - } - return attrs From 819fd811afc39e0c2fb53cfedfd118dcd7e54160 Mon Sep 17 00:00:00 2001 From: Chris Talkington Date: Fri, 27 Aug 2021 11:04:07 -0500 Subject: [PATCH 042/843] Fix reauth for sonarr (#55329) * fix reauth for sonarr * Update config_flow.py * Update config_flow.py * Update config_flow.py * Update test_config_flow.py * Update config_flow.py * Update test_config_flow.py * Update config_flow.py --- .../components/sonarr/config_flow.py | 30 +++++++------------ tests/components/sonarr/test_config_flow.py | 10 ++++--- 2 files changed, 17 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/sonarr/config_flow.py b/homeassistant/components/sonarr/config_flow.py index db82e729483..cc35a8db4af 100644 --- a/homeassistant/components/sonarr/config_flow.py +++ b/homeassistant/components/sonarr/config_flow.py @@ -64,9 +64,7 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): def __init__(self): """Initialize the flow.""" - self._reauth = False - self._entry_id = None - self._entry_data = {} + self.entry = None @staticmethod @callback @@ -76,10 +74,7 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_reauth(self, data: dict[str, Any] | None = None) -> FlowResult: """Handle configuration by re-auth.""" - self._reauth = True - self._entry_data = dict(data) - entry = await self.async_set_unique_id(self.unique_id) - self._entry_id = entry.entry_id + self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) return await self.async_step_reauth_confirm() @@ -90,7 +85,7 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is None: return self.async_show_form( step_id="reauth_confirm", - description_placeholders={"host": self._entry_data[CONF_HOST]}, + description_placeholders={"host": self.entry.data[CONF_HOST]}, data_schema=vol.Schema({}), errors={}, ) @@ -104,8 +99,8 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): errors = {} if user_input is not None: - if self._reauth: - user_input = {**self._entry_data, **user_input} + if self.entry: + user_input = {**self.entry.data, **user_input} if CONF_VERIFY_SSL not in user_input: user_input[CONF_VERIFY_SSL] = DEFAULT_VERIFY_SSL @@ -120,10 +115,8 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") else: - if self._reauth: - return await self._async_reauth_update_entry( - self._entry_id, user_input - ) + if self.entry: + return await self._async_reauth_update_entry(user_input) return self.async_create_entry( title=user_input[CONF_HOST], data=user_input @@ -136,17 +129,16 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def _async_reauth_update_entry(self, entry_id: str, data: dict) -> FlowResult: + async def _async_reauth_update_entry(self, data: dict) -> FlowResult: """Update existing config entry.""" - entry = self.hass.config_entries.async_get_entry(entry_id) - self.hass.config_entries.async_update_entry(entry, data=data) - await self.hass.config_entries.async_reload(entry.entry_id) + self.hass.config_entries.async_update_entry(self.entry, data=data) + await self.hass.config_entries.async_reload(self.entry.entry_id) return self.async_abort(reason="reauth_successful") def _get_user_data_schema(self) -> dict[str, Any]: """Get the data schema to display user form.""" - if self._reauth: + if self.entry: return {vol.Required(CONF_API_KEY): str} data_schema = { diff --git a/tests/components/sonarr/test_config_flow.py b/tests/components/sonarr/test_config_flow.py index c1896061f79..87b38e52742 100644 --- a/tests/components/sonarr/test_config_flow.py +++ b/tests/components/sonarr/test_config_flow.py @@ -100,14 +100,16 @@ async def test_full_reauth_flow_implementation( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Test the manual reauth flow from start to finish.""" - entry = await setup_integration( - hass, aioclient_mock, skip_entry_setup=True, unique_id="any" - ) + entry = await setup_integration(hass, aioclient_mock, skip_entry_setup=True) assert entry result = await hass.config_entries.flow.async_init( DOMAIN, - context={CONF_SOURCE: SOURCE_REAUTH, "unique_id": entry.unique_id}, + context={ + CONF_SOURCE: SOURCE_REAUTH, + "entry_id": entry.entry_id, + "unique_id": entry.unique_id, + }, data=entry.data, ) From 3f2fad1a2714c9feba045ae46f4780127686eff3 Mon Sep 17 00:00:00 2001 From: prwood80 <22550665+prwood80@users.noreply.github.com> Date: Fri, 27 Aug 2021 11:22:49 -0500 Subject: [PATCH 043/843] Improve performance of ring camera still images (#53803) Co-authored-by: Pat Wood Co-authored-by: Paulus Schoutsen Co-authored-by: Paulus Schoutsen --- homeassistant/components/ring/camera.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/ring/camera.py b/homeassistant/components/ring/camera.py index 509877ae5ff..6a4ef692c1e 100644 --- a/homeassistant/components/ring/camera.py +++ b/homeassistant/components/ring/camera.py @@ -52,6 +52,7 @@ class RingCam(RingEntityMixin, Camera): self._last_event = None self._last_video_id = None self._video_url = None + self._image = None self._expires_at = dt_util.utcnow() - FORCE_REFRESH_INTERVAL async def async_added_to_hass(self): @@ -80,6 +81,7 @@ class RingCam(RingEntityMixin, Camera): self._last_event = None self._last_video_id = None self._video_url = None + self._image = None self._expires_at = dt_util.utcnow() self.async_write_ha_state() @@ -106,12 +108,18 @@ class RingCam(RingEntityMixin, Camera): self, width: int | None = None, height: int | None = None ) -> bytes | None: """Return a still image response from the camera.""" - if self._video_url is None: - return + if self._image is None and self._video_url: + image = await ffmpeg.async_get_image( + self.hass, + self._video_url, + width=width, + height=height, + ) - return await ffmpeg.async_get_image( - self.hass, self._video_url, width=width, height=height - ) + if image: + self._image = image + + return self._image async def handle_async_mjpeg_stream(self, request): """Generate an HTTP MJPEG stream from the camera.""" @@ -144,6 +152,9 @@ class RingCam(RingEntityMixin, Camera): if self._last_video_id == self._last_event["id"] and utcnow <= self._expires_at: return + if self._last_video_id != self._last_event["id"]: + self._image = None + try: video_url = await self.hass.async_add_executor_job( self._device.recording_url, self._last_event["id"] From 7bd7d644a08ef3df800072996ed539140b5a2d29 Mon Sep 17 00:00:00 2001 From: realPy Date: Fri, 27 Aug 2021 18:25:27 +0200 Subject: [PATCH 044/843] Correct flash light livarno when use hue (#55294) Co-authored-by: Paulus Schoutsen --- homeassistant/components/hue/light.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hue/light.py b/homeassistant/components/hue/light.py index 345156de7d7..ea89d91113b 100644 --- a/homeassistant/components/hue/light.py +++ b/homeassistant/components/hue/light.py @@ -282,12 +282,14 @@ class HueLight(CoordinatorEntity, LightEntity): self.is_osram = False self.is_philips = False self.is_innr = False + self.is_livarno = False self.gamut_typ = GAMUT_TYPE_UNAVAILABLE self.gamut = None else: self.is_osram = light.manufacturername == "OSRAM" self.is_philips = light.manufacturername == "Philips" self.is_innr = light.manufacturername == "innr" + self.is_livarno = light.manufacturername.startswith("_TZ3000_") self.gamut_typ = self.light.colorgamuttype self.gamut = self.light.colorgamut _LOGGER.debug("Color gamut of %s: %s", self.name, str(self.gamut)) @@ -383,6 +385,8 @@ class HueLight(CoordinatorEntity, LightEntity): """Return the warmest color_temp that this light supports.""" if self.is_group: return super().max_mireds + if self.is_livarno: + return 500 max_mireds = self.light.controlcapabilities.get("ct", {}).get("max") @@ -493,7 +497,7 @@ class HueLight(CoordinatorEntity, LightEntity): elif flash == FLASH_SHORT: command["alert"] = "select" del command["on"] - elif not self.is_innr: + elif not self.is_innr and not self.is_livarno: command["alert"] = "none" if ATTR_EFFECT in kwargs: @@ -532,7 +536,7 @@ class HueLight(CoordinatorEntity, LightEntity): elif flash == FLASH_SHORT: command["alert"] = "select" del command["on"] - elif not self.is_innr: + elif not self.is_innr and not self.is_livarno: command["alert"] = "none" if self.is_group: From 98c8782c2b2c907f17819edc1803a1e07a415f37 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Fri, 27 Aug 2021 12:25:40 -0400 Subject: [PATCH 045/843] Fix sonos alarm schema (#55318) --- homeassistant/components/sonos/media_player.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 4fdc5c6f320..5cb6e225510 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -223,6 +223,7 @@ async def async_setup_entry( { vol.Required(ATTR_ALARM_ID): cv.positive_int, vol.Optional(ATTR_TIME): cv.time, + vol.Optional(ATTR_VOLUME): cv.small_float, vol.Optional(ATTR_ENABLED): cv.boolean, vol.Optional(ATTR_INCLUDE_LINKED_ZONES): cv.boolean, }, From 7ac72ebf3803475fa1090077036d2ec5af3818e1 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Fri, 27 Aug 2021 18:26:57 +0200 Subject: [PATCH 046/843] Add modbus name to log_error (#55336) --- homeassistant/components/modbus/modbus.py | 2 +- tests/components/modbus/test_init.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 92609f5e891..c2e39542077 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -242,7 +242,7 @@ class ModbusHub: self._msg_wait = 0 def _log_error(self, text: str, error_state=True): - log_text = f"Pymodbus: {text}" + log_text = f"Pymodbus: {self.name}: {text}" if self._in_error: _LOGGER.debug(log_text) else: diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index b24115ee964..1bb538a886a 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -593,6 +593,7 @@ async def test_pymodbus_constructor_fail(hass, caplog): config = { DOMAIN: [ { + CONF_NAME: TEST_MODBUS_NAME, CONF_TYPE: TCP, CONF_HOST: TEST_MODBUS_HOST, CONF_PORT: TEST_PORT_TCP, @@ -606,7 +607,8 @@ async def test_pymodbus_constructor_fail(hass, caplog): mock_pb.side_effect = ModbusException("test no class") assert await async_setup_component(hass, DOMAIN, config) is False await hass.async_block_till_done() - assert caplog.messages[0].startswith("Pymodbus: Modbus Error: test") + message = f"Pymodbus: {TEST_MODBUS_NAME}: Modbus Error: test" + assert caplog.messages[0].startswith(message) assert caplog.records[0].levelname == "ERROR" assert mock_pb.called From 2cc87cb7abb2b721b978ba863f397fa895c41433 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 Aug 2021 11:53:29 -0500 Subject: [PATCH 047/843] Retrigger config flow when the ssdp location changes for a UDN (#55343) Fixes #55229 --- homeassistant/components/ssdp/__init__.py | 12 ++- tests/components/ssdp/test_init.py | 124 ++++++++++++++++++++++ 2 files changed, 135 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index 1fd2bba77cc..6e9441534ab 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -286,6 +286,11 @@ class Scanner: if header_st is not None: self.seen.add((header_st, header_location)) + def _async_unsee(self, header_st: str | None, header_location: str | None) -> None: + """If we see a device in a new location, unsee the original location.""" + if header_st is not None: + self.seen.remove((header_st, header_location)) + async def _async_process_entry(self, headers: Mapping[str, str]) -> None: """Process SSDP entries.""" _LOGGER.debug("_async_process_entry: %s", headers) @@ -293,7 +298,12 @@ class Scanner: h_location = headers.get("location") if h_st and (udn := _udn_from_usn(headers.get("usn"))): - self.cache[(udn, h_st)] = headers + cache_key = (udn, h_st) + if old_headers := self.cache.get(cache_key): + old_h_location = old_headers.get("location") + if h_location != old_h_location: + self._async_unsee(old_headers.get("st"), old_h_location) + self.cache[cache_key] = headers callbacks = self._async_get_matching_callbacks(headers) if self._async_seen(h_st, h_location) and not callbacks: diff --git a/tests/components/ssdp/test_init.py b/tests/components/ssdp/test_init.py index 2c5dc74db44..43b7fd98cd0 100644 --- a/tests/components/ssdp/test_init.py +++ b/tests/components/ssdp/test_init.py @@ -926,3 +926,127 @@ async def test_ipv4_does_additional_search_for_sonos(hass, caplog): ), ), } + + +async def test_location_change_evicts_prior_location_from_cache(hass, aioclient_mock): + """Test that a location change for a UDN will evict the prior location from the cache.""" + mock_get_ssdp = { + "hue": [{"manufacturer": "Signify", "modelName": "Philips hue bridge 2015"}] + } + + hue_response = """ + + +1 +0 + +http://{ip_address}:80/ + +urn:schemas-upnp-org:device:Basic:1 +Philips hue ({ip_address}) +Signify +http://www.philips-hue.com +Philips hue Personal Wireless Lighting +Philips hue bridge 2015 +BSB002 +http://www.philips-hue.com +001788a36abf +uuid:2f402f80-da50-11e1-9b23-001788a36abf + + + """ + + aioclient_mock.get( + "http://192.168.212.23/description.xml", + text=hue_response.format(ip_address="192.168.212.23"), + ) + aioclient_mock.get( + "http://169.254.8.155/description.xml", + text=hue_response.format(ip_address="169.254.8.155"), + ) + ssdp_response_without_location = { + "ST": "uuid:2f402f80-da50-11e1-9b23-001788a36abf", + "_udn": "uuid:2f402f80-da50-11e1-9b23-001788a36abf", + "USN": "uuid:2f402f80-da50-11e1-9b23-001788a36abf", + "SERVER": "Hue/1.0 UPnP/1.0 IpBridge/1.44.0", + "hue-bridgeid": "001788FFFEA36ABF", + "EXT": "", + } + + mock_good_ip_ssdp_response = CaseInsensitiveDict( + **ssdp_response_without_location, + **{"LOCATION": "http://192.168.212.23/description.xml"}, + ) + mock_link_local_ip_ssdp_response = CaseInsensitiveDict( + **ssdp_response_without_location, + **{"LOCATION": "http://169.254.8.155/description.xml"}, + ) + mock_ssdp_response = mock_good_ip_ssdp_response + + def _generate_fake_ssdp_listener(*args, **kwargs): + listener = SSDPListener(*args, **kwargs) + + async def _async_callback(*_): + pass + + @callback + def _callback(*_): + import pprint + + pprint.pprint(mock_ssdp_response) + hass.async_create_task(listener.async_callback(mock_ssdp_response)) + + listener.async_start = _async_callback + listener.async_search = _callback + return listener + + with patch( + "homeassistant.components.ssdp.async_get_ssdp", + return_value=mock_get_ssdp, + ), patch( + "homeassistant.components.ssdp.SSDPListener", + new=_generate_fake_ssdp_listener, + ), patch.object( + hass.config_entries.flow, "async_init" + ) as mock_init: + assert await async_setup_component(hass, ssdp.DOMAIN, {ssdp.DOMAIN: {}}) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) + await hass.async_block_till_done() + assert len(mock_init.mock_calls) == 1 + assert mock_init.mock_calls[0][1][0] == "hue" + assert mock_init.mock_calls[0][2]["context"] == { + "source": config_entries.SOURCE_SSDP + } + assert ( + mock_init.mock_calls[0][2]["data"][ssdp.ATTR_SSDP_LOCATION] + == mock_good_ip_ssdp_response["location"] + ) + + mock_init.reset_mock() + mock_ssdp_response = mock_link_local_ip_ssdp_response + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=400)) + await hass.async_block_till_done() + assert mock_init.mock_calls[0][1][0] == "hue" + assert mock_init.mock_calls[0][2]["context"] == { + "source": config_entries.SOURCE_SSDP + } + assert ( + mock_init.mock_calls[0][2]["data"][ssdp.ATTR_SSDP_LOCATION] + == mock_link_local_ip_ssdp_response["location"] + ) + + mock_init.reset_mock() + mock_ssdp_response = mock_good_ip_ssdp_response + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=600)) + await hass.async_block_till_done() + assert mock_init.mock_calls[0][1][0] == "hue" + assert mock_init.mock_calls[0][2]["context"] == { + "source": config_entries.SOURCE_SSDP + } + assert ( + mock_init.mock_calls[0][2]["data"][ssdp.ATTR_SSDP_LOCATION] + == mock_good_ip_ssdp_response["location"] + ) From ed19fdd462a37baf86da5e79fc44d12f0cd528cb Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Fri, 27 Aug 2021 18:53:42 +0200 Subject: [PATCH 048/843] Upgrade aiolifx to 0.6.10 (#55344) --- homeassistant/components/lifx/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json index 9e1a4fc2689..847c75b4fa5 100644 --- a/homeassistant/components/lifx/manifest.json +++ b/homeassistant/components/lifx/manifest.json @@ -3,7 +3,7 @@ "name": "LIFX", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/lifx", - "requirements": ["aiolifx==0.6.9", "aiolifx_effects==0.2.2"], + "requirements": ["aiolifx==0.6.10", "aiolifx_effects==0.2.2"], "homekit": { "models": ["LIFX"] }, diff --git a/requirements_all.txt b/requirements_all.txt index 32eb110b1d8..f17978a3c80 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -201,7 +201,7 @@ aiokafka==0.6.0 aiokef==0.2.16 # homeassistant.components.lifx -aiolifx==0.6.9 +aiolifx==0.6.10 # homeassistant.components.lifx aiolifx_effects==0.2.2 From 10fa63775d71755414a906e44fd1a365a40cbec0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 Aug 2021 12:43:53 -0500 Subject: [PATCH 049/843] Ensure yeelights resync state if they are busy on first connect (#55333) --- homeassistant/components/yeelight/__init__.py | 27 +++++++++++--- homeassistant/components/yeelight/light.py | 21 +++++------ tests/components/yeelight/__init__.py | 36 ++++++++++++++----- tests/components/yeelight/test_init.py | 27 ++++++++++++++ 4 files changed, 87 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py index 8684e331fad..a0deb0fdf21 100644 --- a/homeassistant/components/yeelight/__init__.py +++ b/homeassistant/components/yeelight/__init__.py @@ -163,6 +163,8 @@ UPDATE_REQUEST_PROPERTIES = [ "active_mode", ] +BULB_EXCEPTIONS = (BulbException, asyncio.TimeoutError) + PLATFORMS = ["binary_sensor", "light"] @@ -272,7 +274,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if entry.data.get(CONF_HOST): try: device = await _async_get_device(hass, entry.data[CONF_HOST], entry) - except BulbException as ex: + except BULB_EXCEPTIONS as ex: # If CONF_ID is not valid we cannot fallback to discovery # so we must retry by raising ConfigEntryNotReady if not entry.data.get(CONF_ID): @@ -287,7 +289,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: host = urlparse(capabilities["location"]).hostname try: await _async_initialize(hass, entry, host) - except BulbException: + except BULB_EXCEPTIONS: _LOGGER.exception("Failed to connect to bulb at %s", host) # discovery @@ -552,6 +554,7 @@ class YeelightDevice: self._device_type = None self._available = False self._initialized = False + self._did_first_update = False self._name = None @property @@ -647,14 +650,14 @@ class YeelightDevice: await self.bulb.async_turn_on( duration=duration, light_type=light_type, power_mode=power_mode ) - except BulbException as ex: + except BULB_EXCEPTIONS as ex: _LOGGER.error("Unable to turn the bulb on: %s", ex) async def async_turn_off(self, duration=DEFAULT_TRANSITION, light_type=None): """Turn off device.""" try: await self.bulb.async_turn_off(duration=duration, light_type=light_type) - except BulbException as ex: + except BULB_EXCEPTIONS as ex: _LOGGER.error( "Unable to turn the bulb off: %s, %s: %s", self._host, self.name, ex ) @@ -670,7 +673,7 @@ class YeelightDevice: if not self._initialized: self._initialized = True async_dispatcher_send(self._hass, DEVICE_INITIALIZED.format(self._host)) - except BulbException as ex: + except BULB_EXCEPTIONS as ex: if self._available: # just inform once _LOGGER.error( "Unable to update device %s, %s: %s", self._host, self.name, ex @@ -696,6 +699,7 @@ class YeelightDevice: async def async_update(self, force=False): """Update device properties and send data updated signal.""" + self._did_first_update = True if not force and self._initialized and self._available: # No need to poll unless force, already connected return @@ -705,7 +709,20 @@ class YeelightDevice: @callback def async_update_callback(self, data): """Update push from device.""" + was_available = self._available self._available = data.get(KEY_CONNECTED, True) + if self._did_first_update and not was_available and self._available: + # On reconnect the properties may be out of sync + # + # We need to make sure the DEVICE_INITIALIZED dispatcher is setup + # before we can update on reconnect by checking self._did_first_update + # + # If the device drops the connection right away, we do not want to + # do a property resync via async_update since its about + # to be called when async_setup_entry reaches the end of the + # function + # + asyncio.create_task(self.async_update(True)) async_dispatcher_send(self._hass, DATA_UPDATED.format(self._host)) diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py index be876690b06..e0c21f21fc7 100644 --- a/homeassistant/components/yeelight/light.py +++ b/homeassistant/components/yeelight/light.py @@ -6,7 +6,7 @@ import math import voluptuous as vol import yeelight -from yeelight import Bulb, BulbException, Flow, RGBTransition, SleepTransition, flows +from yeelight import Bulb, Flow, RGBTransition, SleepTransition, flows from yeelight.enums import BulbType, LightType, PowerMode, SceneClass from homeassistant.components.light import ( @@ -49,6 +49,7 @@ from . import ( ATTR_COUNT, ATTR_MODE_MUSIC, ATTR_TRANSITIONS, + BULB_EXCEPTIONS, CONF_FLOW_PARAMS, CONF_MODE_MUSIC, CONF_NIGHTLIGHT_SWITCH, @@ -241,7 +242,7 @@ def _async_cmd(func): try: _LOGGER.debug("Calling %s with %s %s", func, args, kwargs) return await func(self, *args, **kwargs) - except BulbException as ex: + except BULB_EXCEPTIONS as ex: _LOGGER.error("Error when calling %s: %s", func, ex) return _async_wrap @@ -678,7 +679,7 @@ class YeelightGenericLight(YeelightEntity, LightEntity): flow = Flow(count=count, transitions=transitions) try: await self._bulb.async_start_flow(flow, light_type=self.light_type) - except BulbException as ex: + except BULB_EXCEPTIONS as ex: _LOGGER.error("Unable to set flash: %s", ex) @_async_cmd @@ -709,7 +710,7 @@ class YeelightGenericLight(YeelightEntity, LightEntity): try: await self._bulb.async_start_flow(flow, light_type=self.light_type) self._effect = effect - except BulbException as ex: + except BULB_EXCEPTIONS as ex: _LOGGER.error("Unable to set effect: %s", ex) async def async_turn_on(self, **kwargs) -> None: @@ -737,7 +738,7 @@ class YeelightGenericLight(YeelightEntity, LightEntity): await self.hass.async_add_executor_job( self.set_music_mode, self.config[CONF_MODE_MUSIC] ) - except BulbException as ex: + except BULB_EXCEPTIONS as ex: _LOGGER.error( "Unable to turn on music mode, consider disabling it: %s", ex ) @@ -750,7 +751,7 @@ class YeelightGenericLight(YeelightEntity, LightEntity): await self.async_set_brightness(brightness, duration) await self.async_set_flash(flash) await self.async_set_effect(effect) - except BulbException as ex: + except BULB_EXCEPTIONS as ex: _LOGGER.error("Unable to set bulb properties: %s", ex) return @@ -758,7 +759,7 @@ class YeelightGenericLight(YeelightEntity, LightEntity): if self.config[CONF_SAVE_ON_CHANGE] and (brightness or colortemp or rgb): try: await self.async_set_default() - except BulbException as ex: + except BULB_EXCEPTIONS as ex: _LOGGER.error("Unable to set the defaults: %s", ex) return @@ -784,7 +785,7 @@ class YeelightGenericLight(YeelightEntity, LightEntity): """Set a power mode.""" try: await self._bulb.async_set_power_mode(PowerMode[mode.upper()]) - except BulbException as ex: + except BULB_EXCEPTIONS as ex: _LOGGER.error("Unable to set the power mode: %s", ex) async def async_start_flow(self, transitions, count=0, action=ACTION_RECOVER): @@ -795,7 +796,7 @@ class YeelightGenericLight(YeelightEntity, LightEntity): ) await self._bulb.async_start_flow(flow, light_type=self.light_type) - except BulbException as ex: + except BULB_EXCEPTIONS as ex: _LOGGER.error("Unable to set effect: %s", ex) async def async_set_scene(self, scene_class, *args): @@ -806,7 +807,7 @@ class YeelightGenericLight(YeelightEntity, LightEntity): """ try: await self._bulb.async_set_scene(scene_class, *args) - except BulbException as ex: + except BULB_EXCEPTIONS as ex: _LOGGER.error("Unable to set scene: %s", ex) diff --git a/tests/components/yeelight/__init__.py b/tests/components/yeelight/__init__.py index 06c0243e918..4a862fa13dd 100644 --- a/tests/components/yeelight/__init__.py +++ b/tests/components/yeelight/__init__.py @@ -90,11 +90,33 @@ YAML_CONFIGURATION = { CONFIG_ENTRY_DATA = {CONF_ID: ID} +class MockAsyncBulb: + """A mock for yeelight.aio.AsyncBulb.""" + + def __init__(self, model, bulb_type, cannot_connect): + """Init the mock.""" + self.model = model + self.bulb_type = bulb_type + self._async_callback = None + self._cannot_connect = cannot_connect + + async def async_listen(self, callback): + """Mock the listener.""" + if self._cannot_connect: + raise BulbException + self._async_callback = callback + + async def async_stop_listening(self): + """Drop the listener.""" + self._async_callback = None + + def set_capabilities(self, capabilities): + """Mock setting capabilities.""" + self.capabilities = capabilities + + def _mocked_bulb(cannot_connect=False): - bulb = MagicMock() - type(bulb).async_listen = AsyncMock( - side_effect=BulbException if cannot_connect else None - ) + bulb = MockAsyncBulb(MODEL, BulbType.Color, cannot_connect) type(bulb).async_get_properties = AsyncMock( side_effect=BulbException if cannot_connect else None ) @@ -102,14 +124,10 @@ def _mocked_bulb(cannot_connect=False): side_effect=BulbException if cannot_connect else None ) type(bulb).get_model_specs = MagicMock(return_value=_MODEL_SPECS[MODEL]) - bulb.capabilities = CAPABILITIES.copy() - bulb.model = MODEL - bulb.bulb_type = BulbType.Color bulb.last_properties = PROPERTIES.copy() bulb.music_mode = False bulb.async_get_properties = AsyncMock() - bulb.async_stop_listening = AsyncMock() bulb.async_update = AsyncMock() bulb.async_turn_on = AsyncMock() bulb.async_turn_off = AsyncMock() @@ -122,7 +140,7 @@ def _mocked_bulb(cannot_connect=False): bulb.async_set_power_mode = AsyncMock() bulb.async_set_scene = AsyncMock() bulb.async_set_default = AsyncMock() - + bulb.start_music = MagicMock() return bulb diff --git a/tests/components/yeelight/test_init.py b/tests/components/yeelight/test_init.py index 4414909d8e0..4b3ac8e0e83 100644 --- a/tests/components/yeelight/test_init.py +++ b/tests/components/yeelight/test_init.py @@ -3,6 +3,7 @@ from datetime import timedelta from unittest.mock import AsyncMock, patch from yeelight import BulbException, BulbType +from yeelight.aio import KEY_CONNECTED from homeassistant.components.yeelight import ( CONF_MODEL, @@ -414,3 +415,29 @@ async def test_async_setup_with_missing_id(hass: HomeAssistant): assert config_entry.state is ConfigEntryState.LOADED assert config_entry.data[CONF_ID] == ID + + +async def test_connection_dropped_resyncs_properties(hass: HomeAssistant): + """Test handling a connection drop results in a property resync.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=ID, + data={CONF_HOST: "127.0.0.1"}, + options={CONF_NAME: "Test name"}, + ) + config_entry.add_to_hass(hass) + mocked_bulb = _mocked_bulb() + + with _patch_discovery(), _patch_discovery_timeout(), _patch_discovery_interval(), patch( + f"{MODULE}.AsyncBulb", return_value=mocked_bulb + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert len(mocked_bulb.async_get_properties.mock_calls) == 1 + mocked_bulb._async_callback({KEY_CONNECTED: False}) + await hass.async_block_till_done() + assert len(mocked_bulb.async_get_properties.mock_calls) == 1 + mocked_bulb._async_callback({KEY_CONNECTED: True}) + await hass.async_block_till_done() + await hass.async_block_till_done() + assert len(mocked_bulb.async_get_properties.mock_calls) == 2 From eb458fb1d5c7c4965baefc3425f764dcc7edc02a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 27 Aug 2021 14:59:28 -0700 Subject: [PATCH 050/843] Fix wolflink super call (#55359) --- homeassistant/components/wolflink/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/wolflink/sensor.py b/homeassistant/components/wolflink/sensor.py index 92f18e04de4..975ddbdd068 100644 --- a/homeassistant/components/wolflink/sensor.py +++ b/homeassistant/components/wolflink/sensor.py @@ -148,7 +148,7 @@ class WolfLinkState(WolfLinkSensor): @property def native_value(self): """Return the state converting with supported values.""" - state = super().state + state = super().native_value resolved_state = [ item for item in self.wolf_object.items if item.value == int(state) ] From 46d0523f9866955a0e299104fde1a47fe59e3296 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 27 Aug 2021 14:59:55 -0700 Subject: [PATCH 051/843] Convert solarlog to coordinator (#55345) --- homeassistant/components/solarlog/__init__.py | 86 +++++++++++ homeassistant/components/solarlog/const.py | 6 +- homeassistant/components/solarlog/sensor.py | 133 +++--------------- 3 files changed, 108 insertions(+), 117 deletions(-) diff --git a/homeassistant/components/solarlog/__init__.py b/homeassistant/components/solarlog/__init__.py index b3cfebe9abc..e32f1d85564 100644 --- a/homeassistant/components/solarlog/__init__.py +++ b/homeassistant/components/solarlog/__init__.py @@ -1,12 +1,28 @@ """Solar-Log integration.""" +from datetime import timedelta +import logging +from urllib.parse import ParseResult, urlparse + +from requests.exceptions import HTTPError, Timeout +from sunwatcher.solarlog.solarlog import SolarLog + from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant +from homeassistant.helpers import update_coordinator + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) PLATFORMS = ["sensor"] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry for solarlog.""" + coordinator = SolarlogData(hass, entry) + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True @@ -14,3 +30,73 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass, entry): """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +class SolarlogData(update_coordinator.DataUpdateCoordinator): + """Get and update the latest data.""" + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize the data object.""" + super().__init__( + hass, _LOGGER, name="SolarLog", update_interval=timedelta(seconds=60) + ) + + host_entry = entry.data[CONF_HOST] + + url = urlparse(host_entry, "http") + netloc = url.netloc or url.path + path = url.path if url.netloc else "" + url = ParseResult("http", netloc, path, *url[3:]) + self.unique_id = entry.entry_id + self.name = entry.title + self.host = url.geturl() + + async def _async_update_data(self): + """Update the data from the SolarLog device.""" + try: + api = await self.hass.async_add_executor_job(SolarLog, self.host) + except (OSError, Timeout, HTTPError) as err: + raise update_coordinator.UpdateFailed(err) + + if api.time.year == 1999: + raise update_coordinator.UpdateFailed( + "Invalid data returned (can happen after Solarlog restart)." + ) + + self.logger.debug( + "Connection to Solarlog successful. Retrieving latest Solarlog update of %s", + api.time, + ) + + data = {} + + try: + data["TIME"] = api.time + data["powerAC"] = api.power_ac + data["powerDC"] = api.power_dc + data["voltageAC"] = api.voltage_ac + data["voltageDC"] = api.voltage_dc + data["yieldDAY"] = api.yield_day / 1000 + data["yieldYESTERDAY"] = api.yield_yesterday / 1000 + data["yieldMONTH"] = api.yield_month / 1000 + data["yieldYEAR"] = api.yield_year / 1000 + data["yieldTOTAL"] = api.yield_total / 1000 + data["consumptionAC"] = api.consumption_ac + data["consumptionDAY"] = api.consumption_day / 1000 + data["consumptionYESTERDAY"] = api.consumption_yesterday / 1000 + data["consumptionMONTH"] = api.consumption_month / 1000 + data["consumptionYEAR"] = api.consumption_year / 1000 + data["consumptionTOTAL"] = api.consumption_total / 1000 + data["totalPOWER"] = api.total_power + data["alternatorLOSS"] = api.alternator_loss + data["CAPACITY"] = round(api.capacity * 100, 0) + data["EFFICIENCY"] = round(api.efficiency * 100, 0) + data["powerAVAILABLE"] = api.power_available + data["USAGE"] = round(api.usage * 100, 0) + except AttributeError as err: + raise update_coordinator.UpdateFailed( + f"Missing details data in Solarlog response: {err}" + ) from err + + _LOGGER.debug("Updated Solarlog overview data: %s", data) + return data diff --git a/homeassistant/components/solarlog/const.py b/homeassistant/components/solarlog/const.py index e4e10b3a7e6..eecf73b6a09 100644 --- a/homeassistant/components/solarlog/const.py +++ b/homeassistant/components/solarlog/const.py @@ -2,7 +2,6 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import timedelta from homeassistant.components.sensor import ( STATE_CLASS_MEASUREMENT, @@ -23,13 +22,10 @@ from homeassistant.const import ( DOMAIN = "solarlog" -"""Default config for solarlog.""" +# Default config for solarlog. DEFAULT_HOST = "http://solar-log" DEFAULT_NAME = "solarlog" -"""Fixed constants.""" -SCAN_INTERVAL = timedelta(seconds=60) - @dataclass class SolarlogRequiredKeysMixin: diff --git a/homeassistant/components/solarlog/sensor.py b/homeassistant/components/solarlog/sensor.py index e87977f64e5..ee7425cf2d7 100644 --- a/homeassistant/components/solarlog/sensor.py +++ b/homeassistant/components/solarlog/sensor.py @@ -1,133 +1,42 @@ """Platform for solarlog sensors.""" -import logging -from urllib.parse import ParseResult, urlparse - -from requests.exceptions import HTTPError, Timeout -from sunwatcher.solarlog.solarlog import SolarLog - from homeassistant.components.sensor import SensorEntity -from homeassistant.const import CONF_HOST -from homeassistant.util import Throttle +from homeassistant.helpers import update_coordinator +from homeassistant.helpers.entity import StateType -from .const import DOMAIN, SCAN_INTERVAL, SENSOR_TYPES, SolarLogSensorEntityDescription - -_LOGGER = logging.getLogger(__name__) - - -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the solarlog platform.""" - _LOGGER.warning( - "Configuration of the solarlog platform in configuration.yaml is deprecated " - "in Home Assistant 0.119. Please remove entry from your configuration" - ) +from . import SolarlogData +from .const import DOMAIN, SENSOR_TYPES, SolarLogSensorEntityDescription async def async_setup_entry(hass, entry, async_add_entities): """Add solarlog entry.""" - host_entry = entry.data[CONF_HOST] - device_name = entry.title - - url = urlparse(host_entry, "http") - netloc = url.netloc or url.path - path = url.path if url.netloc else "" - url = ParseResult("http", netloc, path, *url[3:]) - host = url.geturl() - - try: - api = await hass.async_add_executor_job(SolarLog, host) - _LOGGER.debug("Connected to Solar-Log device, setting up entries") - except (OSError, HTTPError, Timeout): - _LOGGER.error( - "Could not connect to Solar-Log device at %s, check host ip address", host - ) - return - - # Create solarlog data service which will retrieve and update the data. - data = await hass.async_add_executor_job(SolarlogData, hass, api, host) - - # Create a new sensor for each sensor type. - entities = [ - SolarlogSensor(entry.entry_id, device_name, data, description) - for description in SENSOR_TYPES - ] - async_add_entities(entities, True) - return True + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + SolarlogSensor(coordinator, description) for description in SENSOR_TYPES + ) -class SolarlogData: - """Get and update the latest data.""" - - def __init__(self, hass, api, host): - """Initialize the data object.""" - self.api = api - self.hass = hass - self.host = host - self.update = Throttle(SCAN_INTERVAL)(self._update) - self.data = {} - - def _update(self): - """Update the data from the SolarLog device.""" - try: - self.api = SolarLog(self.host) - response = self.api.time - _LOGGER.debug( - "Connection to Solarlog successful. Retrieving latest Solarlog update of %s", - response, - ) - except (OSError, Timeout, HTTPError): - _LOGGER.error("Connection error, Could not retrieve data, skipping update") - return - - try: - self.data["TIME"] = self.api.time - self.data["powerAC"] = self.api.power_ac - self.data["powerDC"] = self.api.power_dc - self.data["voltageAC"] = self.api.voltage_ac - self.data["voltageDC"] = self.api.voltage_dc - self.data["yieldDAY"] = self.api.yield_day / 1000 - self.data["yieldYESTERDAY"] = self.api.yield_yesterday / 1000 - self.data["yieldMONTH"] = self.api.yield_month / 1000 - self.data["yieldYEAR"] = self.api.yield_year / 1000 - self.data["yieldTOTAL"] = self.api.yield_total / 1000 - self.data["consumptionAC"] = self.api.consumption_ac - self.data["consumptionDAY"] = self.api.consumption_day / 1000 - self.data["consumptionYESTERDAY"] = self.api.consumption_yesterday / 1000 - self.data["consumptionMONTH"] = self.api.consumption_month / 1000 - self.data["consumptionYEAR"] = self.api.consumption_year / 1000 - self.data["consumptionTOTAL"] = self.api.consumption_total / 1000 - self.data["totalPOWER"] = self.api.total_power - self.data["alternatorLOSS"] = self.api.alternator_loss - self.data["CAPACITY"] = round(self.api.capacity * 100, 0) - self.data["EFFICIENCY"] = round(self.api.efficiency * 100, 0) - self.data["powerAVAILABLE"] = self.api.power_available - self.data["USAGE"] = round(self.api.usage * 100, 0) - _LOGGER.debug("Updated Solarlog overview data: %s", self.data) - except AttributeError: - _LOGGER.error("Missing details data in Solarlog response") - - -class SolarlogSensor(SensorEntity): +class SolarlogSensor(update_coordinator.CoordinatorEntity, SensorEntity): """Representation of a Sensor.""" + entity_description: SolarLogSensorEntityDescription + def __init__( self, - entry_id: str, - device_name: str, - data: SolarlogData, + coordinator: SolarlogData, description: SolarLogSensorEntityDescription, ) -> None: """Initialize the sensor.""" + super().__init__(coordinator) self.entity_description = description - self.data = data - self._attr_name = f"{device_name} {description.name}" - self._attr_unique_id = f"{entry_id}_{description.key}" + self._attr_name = f"{coordinator.name} {description.name}" + self._attr_unique_id = f"{coordinator.unique_id}_{description.key}" self._attr_device_info = { - "identifiers": {(DOMAIN, entry_id)}, - "name": device_name, + "identifiers": {(DOMAIN, coordinator.unique_id)}, + "name": coordinator.name, "manufacturer": "Solar-Log", } - def update(self): - """Get the latest data from the sensor and update the state.""" - self.data.update() - self._attr_native_value = self.data.data[self.entity_description.json_key] + @property + def native_value(self) -> StateType: + """Return the native sensor value.""" + return self.coordinator.data[self.entity_description.json_key] From 1f37c215f641f5ddac39f405c576300cbc073512 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Fri, 27 Aug 2021 16:00:17 -0600 Subject: [PATCH 052/843] Ensure ReCollect Waste starts up even if no future pickup is found (#55349) --- .../components/recollect_waste/config_flow.py | 2 +- tests/components/recollect_waste/test_config_flow.py | 10 +++------- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/recollect_waste/config_flow.py b/homeassistant/components/recollect_waste/config_flow.py index 92f94a314ee..5d6b66d8abd 100644 --- a/homeassistant/components/recollect_waste/config_flow.py +++ b/homeassistant/components/recollect_waste/config_flow.py @@ -59,7 +59,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) try: - await client.async_get_next_pickup_event() + await client.async_get_pickup_events() except RecollectError as err: LOGGER.error("Error during setup of integration: %s", err) return self.async_show_form( diff --git a/tests/components/recollect_waste/test_config_flow.py b/tests/components/recollect_waste/test_config_flow.py index cabcb1a8f9e..22f32983055 100644 --- a/tests/components/recollect_waste/test_config_flow.py +++ b/tests/components/recollect_waste/test_config_flow.py @@ -36,7 +36,7 @@ async def test_invalid_place_or_service_id(hass): conf = {CONF_PLACE_ID: "12345", CONF_SERVICE_ID: "12345"} with patch( - "aiorecollect.client.Client.async_get_next_pickup_event", + "aiorecollect.client.Client.async_get_pickup_events", side_effect=RecollectError, ): result = await hass.config_entries.flow.async_init( @@ -87,9 +87,7 @@ async def test_step_import(hass): with patch( "homeassistant.components.recollect_waste.async_setup_entry", return_value=True - ), patch( - "aiorecollect.client.Client.async_get_next_pickup_event", return_value=True - ): + ), patch("aiorecollect.client.Client.async_get_pickup_events", return_value=True): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_IMPORT}, data=conf ) @@ -105,9 +103,7 @@ async def test_step_user(hass): with patch( "homeassistant.components.recollect_waste.async_setup_entry", return_value=True - ), patch( - "aiorecollect.client.Client.async_get_next_pickup_event", return_value=True - ): + ), patch("aiorecollect.client.Client.async_get_pickup_events", return_value=True): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=conf ) From 714564eaa67d889197bcc3ba1b787c6e7f55f453 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Fri, 27 Aug 2021 18:01:20 -0400 Subject: [PATCH 053/843] Listen to node events in the zwave_js node status sensor (#55341) --- homeassistant/components/zwave_js/sensor.py | 6 +-- tests/components/zwave_js/conftest.py | 10 +++++ tests/components/zwave_js/test_sensor.py | 41 ++++++++++++++++++++- 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 5c8ed8633f1..40159b383a6 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -462,6 +462,7 @@ class ZWaveNodeStatusSensor(SensorEntity): """Poll a value.""" raise ValueError("There is no value to poll for this entity") + @callback def _status_changed(self, _: dict) -> None: """Call when status event is received.""" self._attr_native_value = self.node.status.name.lower() @@ -480,8 +481,3 @@ class ZWaveNodeStatusSensor(SensorEntity): ) ) self.async_write_ha_state() - - @property - def available(self) -> bool: - """Return entity availability.""" - return self.client.connected and bool(self.node.ready) diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 900a7937539..6634fdf759d 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -769,6 +769,16 @@ def lock_id_lock_as_id150(client, lock_id_lock_as_id150_state): return node +@pytest.fixture(name="lock_id_lock_as_id150_not_ready") +def node_not_ready(client, lock_id_lock_as_id150_state): + """Mock an id lock id-150 lock node that's not ready.""" + state = copy.deepcopy(lock_id_lock_as_id150_state) + state["ready"] = False + node = Node(client, state) + client.driver.controller.nodes[node.node_id] = node + return node + + @pytest.fixture(name="climate_radio_thermostat_ct101_multiple_temp_units") def climate_radio_thermostat_ct101_multiple_temp_units_fixture( client, climate_radio_thermostat_ct101_multiple_temp_units_state diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index 6d64f6f92dd..b595b6462b3 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -23,6 +23,7 @@ from homeassistant.const import ( ELECTRIC_POTENTIAL_VOLT, ENERGY_KILO_WATT_HOUR, POWER_WATT, + STATE_UNAVAILABLE, TEMP_CELSIUS, ) from homeassistant.helpers import entity_registry as er @@ -136,7 +137,7 @@ async def test_config_parameter_sensor(hass, lock_id_lock_as_id150, integration) assert entity_entry.disabled -async def test_node_status_sensor(hass, lock_id_lock_as_id150, integration): +async def test_node_status_sensor(hass, client, lock_id_lock_as_id150, integration): """Test node status sensor is created and gets updated on node state changes.""" NODE_STATUS_ENTITY = "sensor.z_wave_module_for_id_lock_150_and_101_node_status" node = lock_id_lock_as_id150 @@ -179,6 +180,44 @@ async def test_node_status_sensor(hass, lock_id_lock_as_id150, integration): node.receive_event(event) assert hass.states.get(NODE_STATUS_ENTITY).state == "alive" + # Disconnect the client and make sure the entity is still available + await client.disconnect() + assert hass.states.get(NODE_STATUS_ENTITY).state != STATE_UNAVAILABLE + + +async def test_node_status_sensor_not_ready( + hass, + client, + lock_id_lock_as_id150_not_ready, + lock_id_lock_as_id150_state, + integration, +): + """Test node status sensor is created and available if node is not ready.""" + NODE_STATUS_ENTITY = "sensor.z_wave_module_for_id_lock_150_and_101_node_status" + node = lock_id_lock_as_id150_not_ready + assert not node.ready + ent_reg = er.async_get(hass) + entity_entry = ent_reg.async_get(NODE_STATUS_ENTITY) + assert entity_entry.disabled + assert entity_entry.disabled_by == er.DISABLED_INTEGRATION + updated_entry = ent_reg.async_update_entity( + entity_entry.entity_id, **{"disabled_by": None} + ) + + await hass.config_entries.async_reload(integration.entry_id) + await hass.async_block_till_done() + + assert not updated_entry.disabled + assert hass.states.get(NODE_STATUS_ENTITY) + assert hass.states.get(NODE_STATUS_ENTITY).state == "alive" + + # Mark node as ready + event = Event("ready", {"nodeState": lock_id_lock_as_id150_state}) + node.receive_event(event) + assert node.ready + assert hass.states.get(NODE_STATUS_ENTITY) + assert hass.states.get(NODE_STATUS_ENTITY).state == "alive" + async def test_reset_meter( hass, From b0c52220bc6d34991a4b8af91bb305b267845a0c Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sat, 28 Aug 2021 00:11:00 +0000 Subject: [PATCH 054/843] [ci skip] Translation update --- .../components/homekit/translations/no.json | 5 ++-- .../components/homekit/translations/ru.json | 5 ++-- .../components/mqtt/translations/no.json | 1 + .../components/mqtt/translations/ru.json | 1 + .../components/mqtt/translations/zh-Hant.json | 1 + .../components/nanoleaf/translations/no.json | 28 +++++++++++++++++++ .../components/nanoleaf/translations/ru.json | 28 +++++++++++++++++++ .../components/openuv/translations/no.json | 11 ++++++++ .../components/openuv/translations/ru.json | 11 ++++++++ .../translations/select.zh-Hant.json | 2 +- .../components/zha/translations/no.json | 3 +- .../components/zha/translations/ru.json | 3 +- .../components/zha/translations/zh-Hant.json | 3 +- 13 files changed, 94 insertions(+), 8 deletions(-) create mode 100644 homeassistant/components/nanoleaf/translations/no.json create mode 100644 homeassistant/components/nanoleaf/translations/ru.json diff --git a/homeassistant/components/homekit/translations/no.json b/homeassistant/components/homekit/translations/no.json index 2a4f1497e2f..08df5bd72fa 100644 --- a/homeassistant/components/homekit/translations/no.json +++ b/homeassistant/components/homekit/translations/no.json @@ -21,9 +21,10 @@ "step": { "advanced": { "data": { - "auto_start": "Autostart (deaktiver hvis du ringer til homekit.start-tjenesten manuelt)" + "auto_start": "Autostart (deaktiver hvis du ringer til homekit.start-tjenesten manuelt)", + "devices": "Enheter (utl\u00f8sere)" }, - "description": "Disse innstillingene m\u00e5 bare justeres hvis HomeKit ikke fungerer.", + "description": "Programmerbare brytere opprettes for hver valgt enhet. N\u00e5r en enhetstrigger utl\u00f8ses, kan HomeKit konfigureres til \u00e5 kj\u00f8re en automatisering eller scene.", "title": "Avansert konfigurasjon" }, "cameras": { diff --git a/homeassistant/components/homekit/translations/ru.json b/homeassistant/components/homekit/translations/ru.json index 6b85983073a..670c5e8002f 100644 --- a/homeassistant/components/homekit/translations/ru.json +++ b/homeassistant/components/homekit/translations/ru.json @@ -21,9 +21,10 @@ "step": { "advanced": { "data": { - "auto_start": "\u0410\u0432\u0442\u043e\u0437\u0430\u043f\u0443\u0441\u043a (\u043e\u0442\u043a\u043b\u044e\u0447\u0438\u0442\u0435, \u0435\u0441\u043b\u0438 \u0412\u044b \u0432\u0440\u0443\u0447\u043d\u0443\u044e \u0432\u044b\u0437\u044b\u0432\u0430\u0435\u0442\u0435 \u0441\u043b\u0443\u0436\u0431\u0443 homekit.start)" + "auto_start": "\u0410\u0432\u0442\u043e\u0437\u0430\u043f\u0443\u0441\u043a (\u043e\u0442\u043a\u043b\u044e\u0447\u0438\u0442\u0435, \u0435\u0441\u043b\u0438 \u0412\u044b \u0432\u0440\u0443\u0447\u043d\u0443\u044e \u0432\u044b\u0437\u044b\u0432\u0430\u0435\u0442\u0435 \u0441\u043b\u0443\u0436\u0431\u0443 homekit.start)", + "devices": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 (\u0442\u0440\u0438\u0433\u0433\u0435\u0440\u044b)" }, - "description": "\u042d\u0442\u0438 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u044b, \u0442\u043e\u043b\u044c\u043a\u043e \u0435\u0441\u043b\u0438 HomeKit \u043d\u0435 \u0440\u0430\u0431\u043e\u0442\u0430\u0435\u0442.", + "description": "\u041f\u0440\u043e\u0433\u0440\u0430\u043c\u043c\u043d\u044b\u0435 \u043f\u0435\u0440\u0435\u043a\u043b\u044e\u0447\u0430\u0442\u0435\u043b\u0438 \u0441\u043e\u0437\u0434\u0430\u044e\u0442\u0441\u044f \u0434\u043b\u044f \u043a\u0430\u0436\u0434\u043e\u0433\u043e \u0432\u044b\u0431\u0440\u0430\u043d\u043d\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430. HomeKit \u043c\u043e\u0436\u043d\u043e \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c \u0434\u043b\u044f \u0437\u0430\u043f\u0443\u0441\u043a\u0430 \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0437\u0430\u0446\u0438\u0438 \u0438\u043b\u0438 \u0441\u0446\u0435\u043d\u044b, \u043a\u043e\u0433\u0434\u0430 \u0441\u0440\u0430\u0431\u0430\u0442\u044b\u0432\u0430\u0435\u0442 \u0442\u0440\u0438\u0433\u0433\u0435\u0440 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430.", "title": "\u0420\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u043d\u0430\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430" }, "cameras": { diff --git a/homeassistant/components/mqtt/translations/no.json b/homeassistant/components/mqtt/translations/no.json index fee6505862a..11f3610e033 100644 --- a/homeassistant/components/mqtt/translations/no.json +++ b/homeassistant/components/mqtt/translations/no.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "Tjenesten er allerede konfigurert", "single_instance_allowed": "Allerede konfigurert. Bare \u00e9n enkelt konfigurasjon er mulig." }, "error": { diff --git a/homeassistant/components/mqtt/translations/ru.json b/homeassistant/components/mqtt/translations/ru.json index 321a1e5e56c..c9b15c9489c 100644 --- a/homeassistant/components/mqtt/translations/ru.json +++ b/homeassistant/components/mqtt/translations/ru.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "\u042d\u0442\u0430 \u0441\u043b\u0443\u0436\u0431\u0430 \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430 \u0432 Home Assistant.", "single_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430. \u0412\u043e\u0437\u043c\u043e\u0436\u043d\u043e \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0442\u043e\u043b\u044c\u043a\u043e \u043e\u0434\u043d\u0443 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e." }, "error": { diff --git a/homeassistant/components/mqtt/translations/zh-Hant.json b/homeassistant/components/mqtt/translations/zh-Hant.json index a8dc6d4ce9e..9b08ba9aee8 100644 --- a/homeassistant/components/mqtt/translations/zh-Hant.json +++ b/homeassistant/components/mqtt/translations/zh-Hant.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "\u670d\u52d9\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", "single_instance_allowed": "\u50c5\u80fd\u8a2d\u5b9a\u4e00\u7d44\u88dd\u7f6e\u3002" }, "error": { diff --git a/homeassistant/components/nanoleaf/translations/no.json b/homeassistant/components/nanoleaf/translations/no.json new file mode 100644 index 00000000000..5c06bc4b811 --- /dev/null +++ b/homeassistant/components/nanoleaf/translations/no.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfiugrert", + "cannot_connect": "Tilkobling mislyktes", + "invalid_token": "Ugyldig tilgangstoken", + "reauth_successful": "Re-autentisering var vellykket", + "unknown": "Uventet feil" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "not_allowing_new_tokens": "Nanoleaf tillater ikke nye tokens, f\u00f8lg instruksjonene ovenfor.", + "unknown": "Uventet feil" + }, + "flow_title": "{name}", + "step": { + "link": { + "description": "Trykk og hold inne str\u00f8mknappen p\u00e5 Nanoleaf i 5 sekunder til knappene begynner \u00e5 blinke, og klikk deretter ** SEND ** innen 30 sekunder.", + "title": "Lenke Nanoleaf" + }, + "user": { + "data": { + "host": "Vert" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nanoleaf/translations/ru.json b/homeassistant/components/nanoleaf/translations/ru.json new file mode 100644 index 00000000000..884ace3dedc --- /dev/null +++ b/homeassistant/components/nanoleaf/translations/ru.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant.", + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "invalid_token": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u0442\u043e\u043a\u0435\u043d \u0434\u043e\u0441\u0442\u0443\u043f\u0430.", + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430 \u0443\u0441\u043f\u0435\u0448\u043d\u043e.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "not_allowing_new_tokens": "Nanoleaf \u043d\u0435 \u043f\u0440\u0438\u043d\u0438\u043c\u0430\u0435\u0442 \u043d\u043e\u0432\u044b\u0435 \u0442\u043e\u043a\u0435\u043d\u044b, \u0441\u043b\u0435\u0434\u0443\u0439\u0442\u0435 \u043f\u0440\u0438\u0432\u0435\u0434\u0451\u043d\u043d\u044b\u043c \u0432\u044b\u0448\u0435 \u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "flow_title": "{name}", + "step": { + "link": { + "description": "\u041d\u0430\u0436\u043c\u0438\u0442\u0435 \u0438 \u0443\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0439\u0442\u0435 \u043a\u043d\u043e\u043f\u043a\u0443 \u043f\u0438\u0442\u0430\u043d\u0438\u044f Nanoleaf \u0432 \u0442\u0435\u0447\u0435\u043d\u0438\u0435 5 \u0441\u0435\u043a\u0443\u043d\u0434, \u043f\u043e\u043a\u0430 \u0438\u043d\u0434\u0438\u043a\u0430\u0442\u043e\u0440\u044b \u043a\u043d\u043e\u043f\u043e\u043a \u043d\u0435 \u043d\u0430\u0447\u043d\u0443\u0442 \u043c\u0438\u0433\u0430\u0442\u044c, \u0437\u0430\u0442\u0435\u043c \u043d\u0430\u0436\u043c\u0438\u0442\u0435 **\u041f\u041e\u0414\u0422\u0412\u0415\u0420\u0414\u0418\u0422\u042c** \u0432 \u0442\u0435\u0447\u0435\u043d\u0438\u0435 30 \u0441\u0435\u043a\u0443\u043d\u0434.", + "title": "Nanoleaf" + }, + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/openuv/translations/no.json b/homeassistant/components/openuv/translations/no.json index 0c4356c6f79..f76787b4e4d 100644 --- a/homeassistant/components/openuv/translations/no.json +++ b/homeassistant/components/openuv/translations/no.json @@ -17,5 +17,16 @@ "title": "Fyll ut informasjonen din" } } + }, + "options": { + "step": { + "init": { + "data": { + "from_window": "Starter UV -indeks for beskyttelsesvinduet", + "to_window": "Avsluttende UV -indeks for beskyttelsesvinduet" + }, + "title": "Konfigurer OpenUV" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/openuv/translations/ru.json b/homeassistant/components/openuv/translations/ru.json index 405b9625d32..e4a2fe9f49c 100644 --- a/homeassistant/components/openuv/translations/ru.json +++ b/homeassistant/components/openuv/translations/ru.json @@ -17,5 +17,16 @@ "title": "OpenUV" } } + }, + "options": { + "step": { + "init": { + "data": { + "from_window": "\u0417\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0423\u0424-\u0438\u043d\u0434\u0435\u043a\u0441\u0430, \u043d\u0430\u0447\u0438\u043d\u0430\u044f \u0441 \u043a\u043e\u0442\u043e\u0440\u043e\u0433\u043e \u0442\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f \u0437\u0430\u0449\u0438\u0442\u0430 \u043e\u043a\u043e\u043d", + "to_window": "\u0417\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0423\u0424-\u0438\u043d\u0434\u0435\u043a\u0441\u0430, \u0437\u0430\u043a\u0430\u043d\u0447\u0438\u0432\u0430\u044f \u043a\u043e\u0442\u043e\u0440\u044b\u043c \u043d\u0435 \u0442\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f \u0437\u0430\u0449\u0438\u0442\u0430 \u043e\u043a\u043e\u043d" + }, + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 OpenUV" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/xiaomi_miio/translations/select.zh-Hant.json b/homeassistant/components/xiaomi_miio/translations/select.zh-Hant.json index ed977dc9cd5..3c3152db0da 100644 --- a/homeassistant/components/xiaomi_miio/translations/select.zh-Hant.json +++ b/homeassistant/components/xiaomi_miio/translations/select.zh-Hant.json @@ -2,7 +2,7 @@ "state": { "xiaomi_miio__led_brightness": { "bright": "\u4eae\u5149", - "dim": "\u8abf\u5149", + "dim": "\u5fae\u5149", "off": "\u95dc\u9589" } } diff --git a/homeassistant/components/zha/translations/no.json b/homeassistant/components/zha/translations/no.json index 64986b7f6da..4e719e63ae1 100644 --- a/homeassistant/components/zha/translations/no.json +++ b/homeassistant/components/zha/translations/no.json @@ -2,7 +2,8 @@ "config": { "abort": { "not_zha_device": "Denne enheten er ikke en zha -enhet", - "single_instance_allowed": "Allerede konfigurert. Bare \u00e9n enkelt konfigurasjon er mulig." + "single_instance_allowed": "Allerede konfigurert. Bare \u00e9n enkelt konfigurasjon er mulig.", + "usb_probe_failed": "Kunne ikke unders\u00f8ke usb -enheten" }, "error": { "cannot_connect": "Tilkobling mislyktes" diff --git a/homeassistant/components/zha/translations/ru.json b/homeassistant/components/zha/translations/ru.json index 17d95dbd7e8..f9d67cc2d35 100644 --- a/homeassistant/components/zha/translations/ru.json +++ b/homeassistant/components/zha/translations/ru.json @@ -2,7 +2,8 @@ "config": { "abort": { "not_zha_device": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 ZHA.", - "single_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430. \u0412\u043e\u0437\u043c\u043e\u0436\u043d\u043e \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0442\u043e\u043b\u044c\u043a\u043e \u043e\u0434\u043d\u0443 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e." + "single_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430. \u0412\u043e\u0437\u043c\u043e\u0436\u043d\u043e \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0442\u043e\u043b\u044c\u043a\u043e \u043e\u0434\u043d\u0443 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e.", + "usb_probe_failed": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u0440\u043e\u0432\u0435\u0440\u0438\u0442\u044c USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e." }, "error": { "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f." diff --git a/homeassistant/components/zha/translations/zh-Hant.json b/homeassistant/components/zha/translations/zh-Hant.json index e08adf98527..28b8f70a8dd 100644 --- a/homeassistant/components/zha/translations/zh-Hant.json +++ b/homeassistant/components/zha/translations/zh-Hant.json @@ -2,7 +2,8 @@ "config": { "abort": { "not_zha_device": "\u6240\u767c\u73fe\u7684\u88dd\u7f6e\u4e26\u975e ZHA \u88dd\u7f6e", - "single_instance_allowed": "\u50c5\u80fd\u8a2d\u5b9a\u4e00\u7d44\u88dd\u7f6e\u3002" + "single_instance_allowed": "\u50c5\u80fd\u8a2d\u5b9a\u4e00\u7d44\u88dd\u7f6e\u3002", + "usb_probe_failed": "\u5075\u6e2c USB \u88dd\u7f6e\u5931\u6557" }, "error": { "cannot_connect": "\u9023\u7dda\u5931\u6557" From 61a7ce173c84bc8c3a9eede3ca36409dea9dc512 Mon Sep 17 00:00:00 2001 From: Jason Hunter Date: Fri, 27 Aug 2021 20:34:32 -0400 Subject: [PATCH 055/843] close connection on connection retry, bump onvif lib (#55363) --- homeassistant/components/onvif/device.py | 1 + homeassistant/components/onvif/manifest.json | 6 +----- requirements_all.txt | 5 +---- requirements_test_all.txt | 5 +---- 4 files changed, 4 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/onvif/device.py b/homeassistant/components/onvif/device.py index 87b68508fa1..9ebf87a4132 100644 --- a/homeassistant/components/onvif/device.py +++ b/homeassistant/components/onvif/device.py @@ -130,6 +130,7 @@ class ONVIFDevice: err, ) self.available = False + await self.device.close() except Fault as err: LOGGER.error( "Couldn't connect to camera '%s', please verify " diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json index 641497f5204..a7faa60cdcd 100644 --- a/homeassistant/components/onvif/manifest.json +++ b/homeassistant/components/onvif/manifest.json @@ -2,11 +2,7 @@ "domain": "onvif", "name": "ONVIF", "documentation": "https://www.home-assistant.io/integrations/onvif", - "requirements": [ - "onvif-zeep-async==1.0.0", - "WSDiscovery==2.0.0", - "zeep[async]==4.0.0" - ], + "requirements": ["onvif-zeep-async==1.2.0", "WSDiscovery==2.0.0"], "dependencies": ["ffmpeg"], "codeowners": ["@hunterjm"], "config_flow": true, diff --git a/requirements_all.txt b/requirements_all.txt index f17978a3c80..6d46cf6b51e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1100,7 +1100,7 @@ ondilo==0.2.0 onkyo-eiscp==1.2.7 # homeassistant.components.onvif -onvif-zeep-async==1.0.0 +onvif-zeep-async==1.2.0 # homeassistant.components.opengarage open-garage==0.1.5 @@ -2449,9 +2449,6 @@ youless-api==0.12 # homeassistant.components.media_extractor youtube_dl==2021.04.26 -# homeassistant.components.onvif -zeep[async]==4.0.0 - # homeassistant.components.zengge zengge==0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bb6f5c4c2ce..da8f93c0d61 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -632,7 +632,7 @@ omnilogic==0.4.5 ondilo==0.2.0 # homeassistant.components.onvif -onvif-zeep-async==1.0.0 +onvif-zeep-async==1.2.0 # homeassistant.components.openerz openerz-api==0.1.0 @@ -1372,9 +1372,6 @@ yeelight==0.7.4 # homeassistant.components.youless youless-api==0.12 -# homeassistant.components.onvif -zeep[async]==4.0.0 - # homeassistant.components.zeroconf zeroconf==0.36.0 From 470aa7e87165432bd546d3afb4d443de929ea1db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Sat, 28 Aug 2021 03:39:12 +0200 Subject: [PATCH 056/843] Add data update coordinator to the Tautulli integration (#54706) * Add data update coordinator to the Tautulli integration * update .coveragerc * Add guard for UpdateFailed * Apply suggestions from code review Co-authored-by: Chris Talkington * ignore issues Co-authored-by: Chris Talkington --- .coveragerc | 2 + homeassistant/components/tautulli/const.py | 5 + .../components/tautulli/coordinator.py | 52 ++++++ homeassistant/components/tautulli/sensor.py | 156 +++++++----------- 4 files changed, 120 insertions(+), 95 deletions(-) create mode 100644 homeassistant/components/tautulli/const.py create mode 100644 homeassistant/components/tautulli/coordinator.py diff --git a/.coveragerc b/.coveragerc index 70a74e0a356..bc6486283c4 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1032,6 +1032,8 @@ omit = homeassistant/components/tank_utility/sensor.py homeassistant/components/tankerkoenig/* homeassistant/components/tapsaff/binary_sensor.py + homeassistant/components/tautulli/const.py + homeassistant/components/tautulli/coordinator.py homeassistant/components/tautulli/sensor.py homeassistant/components/ted5000/sensor.py homeassistant/components/telegram/notify.py diff --git a/homeassistant/components/tautulli/const.py b/homeassistant/components/tautulli/const.py new file mode 100644 index 00000000000..a7427e401ba --- /dev/null +++ b/homeassistant/components/tautulli/const.py @@ -0,0 +1,5 @@ +"""Constants for the Tautulli integration.""" +from logging import Logger, getLogger + +DOMAIN = "tautulli" +LOGGER: Logger = getLogger(__package__) diff --git a/homeassistant/components/tautulli/coordinator.py b/homeassistant/components/tautulli/coordinator.py new file mode 100644 index 00000000000..6ca2ed0d7d6 --- /dev/null +++ b/homeassistant/components/tautulli/coordinator.py @@ -0,0 +1,52 @@ +"""Data update coordinator for the Tautulli integration.""" +from __future__ import annotations + +import asyncio +from datetime import timedelta + +from pytautulli import ( + PyTautulli, + PyTautulliApiActivity, + PyTautulliApiHomeStats, + PyTautulliApiUser, + PyTautulliException, +) + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER + + +class TautulliDataUpdateCoordinator(DataUpdateCoordinator): + """Data update coordinator for the Tautulli integration.""" + + def __init__( + self, + hass: HomeAssistant, + api_client: PyTautulli, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass=hass, + logger=LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=10), + ) + self.api_client = api_client + self.activity: PyTautulliApiActivity | None = None + self.home_stats: list[PyTautulliApiHomeStats] | None = None + self.users: list[PyTautulliApiUser] | None = None + + async def _async_update_data(self) -> None: + """Get the latest data from Tautulli.""" + try: + [self.activity, self.home_stats, self.users] = await asyncio.gather( + *[ + self.api_client.async_get_activity(), + self.api_client.async_get_home_stats(), + self.api_client.async_get_users(), + ] + ) + except PyTautulliException as exception: + raise UpdateFailed(exception) from exception diff --git a/homeassistant/components/tautulli/sensor.py b/homeassistant/components/tautulli/sensor.py index 16b58b206aa..054f59e9b5d 100644 --- a/homeassistant/components/tautulli/sensor.py +++ b/homeassistant/components/tautulli/sensor.py @@ -1,6 +1,4 @@ """A platform which allows you to get information from Tautulli.""" -from datetime import timedelta - from pytautulli import PyTautulli import voluptuous as vol @@ -15,10 +13,11 @@ from homeassistant.const import ( CONF_SSL, CONF_VERIFY_SSL, ) -from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv -from homeassistant.util import Throttle +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import TautulliDataUpdateCoordinator CONF_MONITORED_USERS = "monitored_users" @@ -28,8 +27,6 @@ DEFAULT_PATH = "" DEFAULT_SSL = False DEFAULT_VERIFY_SSL = True -TIME_BETWEEN_UPDATES = timedelta(seconds=10) - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_API_KEY): cv.string, @@ -59,90 +56,34 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= verify_ssl = config.get(CONF_VERIFY_SSL) session = async_get_clientsession(hass, verify_ssl) - tautulli = TautulliData( - PyTautulli( - api_token=api_key, - hostname=host, - session=session, - verify_ssl=verify_ssl, - port=port, - ssl=use_ssl, - base_api_path=path, - ) + api_client = PyTautulli( + api_token=api_key, + hostname=host, + session=session, + verify_ssl=verify_ssl, + port=port, + ssl=use_ssl, + base_api_path=path, ) - await tautulli.async_update() - if not tautulli.activity or not tautulli.home_stats or not tautulli.users: - raise PlatformNotReady + coordinator = TautulliDataUpdateCoordinator(hass=hass, api_client=api_client) - sensor = [TautulliSensor(tautulli, name, monitored_conditions, user)] + entities = [TautulliSensor(coordinator, name, monitored_conditions, user)] - async_add_entities(sensor, True) + async_add_entities(entities, True) -class TautulliSensor(SensorEntity): +class TautulliSensor(CoordinatorEntity, SensorEntity): """Representation of a Tautulli sensor.""" - def __init__(self, tautulli, name, monitored_conditions, users): + coordinator: TautulliDataUpdateCoordinator + + def __init__(self, coordinator, name, monitored_conditions, users): """Initialize the Tautulli sensor.""" - self.tautulli = tautulli + super().__init__(coordinator) self.monitored_conditions = monitored_conditions self.usernames = users - self.sessions = {} - self.home = {} - self._attributes = {} self._name = name - self._state = None - - async def async_update(self): - """Get the latest data from the Tautulli API.""" - await self.tautulli.async_update() - if ( - not self.tautulli.activity - or not self.tautulli.home_stats - or not self.tautulli.users - ): - return - - self._attributes = { - "stream_count": self.tautulli.activity.stream_count, - "stream_count_direct_play": self.tautulli.activity.stream_count_direct_play, - "stream_count_direct_stream": self.tautulli.activity.stream_count_direct_stream, - "stream_count_transcode": self.tautulli.activity.stream_count_transcode, - "total_bandwidth": self.tautulli.activity.total_bandwidth, - "lan_bandwidth": self.tautulli.activity.lan_bandwidth, - "wan_bandwidth": self.tautulli.activity.wan_bandwidth, - } - - for stat in self.tautulli.home_stats: - if stat.stat_id == "top_movies": - self._attributes["Top Movie"] = ( - stat.rows[0].title if stat.rows else None - ) - elif stat.stat_id == "top_tv": - self._attributes["Top TV Show"] = ( - stat.rows[0].title if stat.rows else None - ) - elif stat.stat_id == "top_users": - self._attributes["Top User"] = stat.rows[0].user if stat.rows else None - - for user in self.tautulli.users: - if ( - self.usernames - and user.username not in self.usernames - or user.username == "Local" - ): - continue - self._attributes.setdefault(user.username, {})["Activity"] = None - - for session in self.tautulli.activity.sessions: - if not self._attributes.get(session.username): - continue - - self._attributes[session.username]["Activity"] = session.state - if self.monitored_conditions: - for key in self.monitored_conditions: - self._attributes[session.username][key] = getattr(session, key) @property def name(self): @@ -152,9 +93,9 @@ class TautulliSensor(SensorEntity): @property def native_value(self): """Return the state of the sensor.""" - if not self.tautulli.activity: + if not self.coordinator.activity: return 0 - return self.tautulli.activity.stream_count + return self.coordinator.activity.stream_count @property def icon(self): @@ -169,22 +110,47 @@ class TautulliSensor(SensorEntity): @property def extra_state_attributes(self): """Return attributes for the sensor.""" - return self._attributes + if ( + not self.coordinator.activity + or not self.coordinator.home_stats + or not self.coordinator.users + ): + return None + _attributes = { + "stream_count": self.coordinator.activity.stream_count, + "stream_count_direct_play": self.coordinator.activity.stream_count_direct_play, + "stream_count_direct_stream": self.coordinator.activity.stream_count_direct_stream, + "stream_count_transcode": self.coordinator.activity.stream_count_transcode, + "total_bandwidth": self.coordinator.activity.total_bandwidth, + "lan_bandwidth": self.coordinator.activity.lan_bandwidth, + "wan_bandwidth": self.coordinator.activity.wan_bandwidth, + } -class TautulliData: - """Get the latest data and update the states.""" + for stat in self.coordinator.home_stats: + if stat.stat_id == "top_movies": + _attributes["Top Movie"] = stat.rows[0].title if stat.rows else None + elif stat.stat_id == "top_tv": + _attributes["Top TV Show"] = stat.rows[0].title if stat.rows else None + elif stat.stat_id == "top_users": + _attributes["Top User"] = stat.rows[0].user if stat.rows else None - def __init__(self, api): - """Initialize the data object.""" - self.api = api - self.activity = None - self.home_stats = None - self.users = None + for user in self.coordinator.users: + if ( + self.usernames + and user.username not in self.usernames + or user.username == "Local" + ): + continue + _attributes.setdefault(user.username, {})["Activity"] = None - @Throttle(TIME_BETWEEN_UPDATES) - async def async_update(self): - """Get the latest data from Tautulli.""" - self.activity = await self.api.async_get_activity() - self.home_stats = await self.api.async_get_home_stats() - self.users = await self.api.async_get_users() + for session in self.coordinator.activity.sessions: + if not _attributes.get(session.username): + continue + + _attributes[session.username]["Activity"] = session.state + if self.monitored_conditions: + for key in self.monitored_conditions: + _attributes[session.username][key] = getattr(session, key) + + return _attributes From ea8702b0dfcc9afc6867e3be0e2b14436ac8ae6a Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sat, 28 Aug 2021 04:41:15 +0200 Subject: [PATCH 057/843] Address late review for Xiaomi Miio number platform (#55275) --- homeassistant/components/xiaomi_miio/number.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/number.py b/homeassistant/components/xiaomi_miio/number.py index af5f29306a0..a31478df1f3 100644 --- a/homeassistant/components/xiaomi_miio/number.py +++ b/homeassistant/components/xiaomi_miio/number.py @@ -248,7 +248,7 @@ class XiaomiNumberEntity(XiaomiCoordinatedMiioEntity, NumberEntity): ) self.async_write_ha_state() - async def async_set_motor_speed(self, motor_speed: int = 400): + async def async_set_motor_speed(self, motor_speed: int = 400) -> bool: """Set the target motor speed.""" return await self._try_command( "Setting the target motor speed of the miio device failed.", @@ -256,7 +256,7 @@ class XiaomiNumberEntity(XiaomiCoordinatedMiioEntity, NumberEntity): motor_speed, ) - async def async_set_favorite_level(self, level: int = 1): + async def async_set_favorite_level(self, level: int = 1) -> bool: """Set the favorite level.""" return await self._try_command( "Setting the favorite level of the miio device failed.", @@ -264,7 +264,7 @@ class XiaomiNumberEntity(XiaomiCoordinatedMiioEntity, NumberEntity): level, ) - async def async_set_fan_level(self, level: int = 1): + async def async_set_fan_level(self, level: int = 1) -> bool: """Set the fan level.""" return await self._try_command( "Setting the favorite level of the miio device failed.", @@ -272,7 +272,7 @@ class XiaomiNumberEntity(XiaomiCoordinatedMiioEntity, NumberEntity): level, ) - async def async_set_volume(self, volume: int = 50): + async def async_set_volume(self, volume: int = 50) -> bool: """Set the volume.""" return await self._try_command( "Setting the volume of the miio device failed.", @@ -280,13 +280,13 @@ class XiaomiNumberEntity(XiaomiCoordinatedMiioEntity, NumberEntity): volume, ) - async def async_set_oscillation_angle(self, angle: int): + async def async_set_oscillation_angle(self, angle: int) -> bool: """Set the volume.""" return await self._try_command( "Setting angle of the miio device failed.", self._device.set_angle, angle ) - async def async_set_delay_off_countdown(self, delay_off_countdown: int): + async def async_set_delay_off_countdown(self, delay_off_countdown: int) -> bool: """Set the delay off countdown.""" return await self._try_command( "Setting delay off miio device failed.", From e2f257cb6302941a046e22d03da3127d8461a010 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Fri, 27 Aug 2021 21:58:21 -0600 Subject: [PATCH 058/843] Bump pylitterbot to 2021.8.1 (#55360) --- homeassistant/components/litterrobot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/litterrobot/manifest.json b/homeassistant/components/litterrobot/manifest.json index facf79a7bd7..543a15736fe 100644 --- a/homeassistant/components/litterrobot/manifest.json +++ b/homeassistant/components/litterrobot/manifest.json @@ -3,7 +3,7 @@ "name": "Litter-Robot", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/litterrobot", - "requirements": ["pylitterbot==2021.8.0"], + "requirements": ["pylitterbot==2021.8.1"], "codeowners": ["@natekspencer"], "iot_class": "cloud_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index 6d46cf6b51e..fe9979c28d2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1578,7 +1578,7 @@ pylibrespot-java==0.1.0 pylitejet==0.3.0 # homeassistant.components.litterrobot -pylitterbot==2021.8.0 +pylitterbot==2021.8.1 # homeassistant.components.loopenergy pyloopenergy==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index da8f93c0d61..013fae0eb26 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -906,7 +906,7 @@ pylibrespot-java==0.1.0 pylitejet==0.3.0 # homeassistant.components.litterrobot -pylitterbot==2021.8.0 +pylitterbot==2021.8.1 # homeassistant.components.lutron_caseta pylutron-caseta==0.11.0 From 16351ef3c2e5bef0292f315929b613c8c00b3e3d Mon Sep 17 00:00:00 2001 From: jan iversen Date: Sat, 28 Aug 2021 08:11:58 +0200 Subject: [PATCH 059/843] Add shutdown test. (#55357) --- tests/components/modbus/test_init.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index 1bb538a886a..5bc94b2df41 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -79,6 +79,7 @@ from homeassistant.const import ( CONF_STRUCTURE, CONF_TIMEOUT, CONF_TYPE, + EVENT_HOMEASSISTANT_STOP, STATE_ON, STATE_UNAVAILABLE, ) @@ -686,3 +687,29 @@ async def test_delay(hass, mock_pymodbus): async_fire_time_changed(hass, now) await hass.async_block_till_done() assert hass.states.get(entity_id).state == STATE_ON + + +@pytest.mark.parametrize( + "do_config", + [ + { + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 117, + CONF_SCAN_INTERVAL: 0, + } + ], + }, + ], +) +async def test_shutdown(hass, caplog, mock_pymodbus, mock_modbus_with_pymodbus): + """Run test for shutdown.""" + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + await hass.async_block_till_done() + assert mock_pymodbus.close.called + assert caplog.text == "" From d1965eef8bc406e4bb183a3d0223eb56245ffc53 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Sat, 28 Aug 2021 12:05:48 +0200 Subject: [PATCH 060/843] Activate mypy for sonar (#55327) * Please mypy. Co-authored-by: Martin Hjelmare --- .../components/sonarr/config_flow.py | 10 +++--- homeassistant/components/sonarr/sensor.py | 35 +++++++++++-------- mypy.ini | 3 -- script/hassfest/mypy_config.py | 1 - 4 files changed, 25 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/sonarr/config_flow.py b/homeassistant/components/sonarr/config_flow.py index cc35a8db4af..f226d1883a5 100644 --- a/homeassistant/components/sonarr/config_flow.py +++ b/homeassistant/components/sonarr/config_flow.py @@ -35,7 +35,7 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -async def validate_input(hass: HomeAssistant, data: dict) -> dict[str, Any]: +async def validate_input(hass: HomeAssistant, data: dict) -> None: """Validate the user input allows us to connect. Data has the keys from DATA_SCHEMA with values provided by the user. @@ -54,8 +54,6 @@ async def validate_input(hass: HomeAssistant, data: dict) -> dict[str, Any]: await sonarr.update() - return True - class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Sonarr.""" @@ -72,14 +70,14 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): """Get the options flow for this handler.""" return SonarrOptionsFlowHandler(config_entry) - async def async_step_reauth(self, data: dict[str, Any] | None = None) -> FlowResult: + async def async_step_reauth(self, data: dict[str, Any]) -> FlowResult: """Handle configuration by re-auth.""" self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( - self, user_input: dict[str, Any] | None = None + self, user_input: dict[str, str] | None = None ) -> FlowResult: """Confirm reauth dialog.""" if user_input is None: @@ -164,7 +162,7 @@ class SonarrOptionsFlowHandler(OptionsFlow): """Initialize options flow.""" self.config_entry = config_entry - async def async_step_init(self, user_input: dict[str, Any] | None = None): + async def async_step_init(self, user_input: dict[str, int] | None = None): """Manage Sonarr options.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) diff --git a/homeassistant/components/sonarr/sensor.py b/homeassistant/components/sonarr/sensor.py index 3f5ef275fef..374791304c7 100644 --- a/homeassistant/components/sonarr/sensor.py +++ b/homeassistant/components/sonarr/sensor.py @@ -3,9 +3,16 @@ from __future__ import annotations from datetime import timedelta import logging -from typing import Any from sonarr import Sonarr, SonarrConnectionError, SonarrError +from sonarr.models import ( + CommandItem, + Disk, + Episode, + QueueItem, + SeriesItem, + WantedResults, +) from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry @@ -106,7 +113,7 @@ class SonarrCommandsSensor(SonarrSensor): def __init__(self, sonarr: Sonarr, entry_id: str) -> None: """Initialize Sonarr Commands sensor.""" - self._commands = [] + self._commands: list[CommandItem] = [] super().__init__( sonarr=sonarr, @@ -124,7 +131,7 @@ class SonarrCommandsSensor(SonarrSensor): self._commands = await self.sonarr.commands() @property - def extra_state_attributes(self) -> dict[str, Any] | None: + def extra_state_attributes(self) -> dict[str, str] | None: """Return the state attributes of the entity.""" attrs = {} @@ -144,7 +151,7 @@ class SonarrDiskspaceSensor(SonarrSensor): def __init__(self, sonarr: Sonarr, entry_id: str) -> None: """Initialize Sonarr Disk Space sensor.""" - self._disks = [] + self._disks: list[Disk] = [] self._total_free = 0 super().__init__( @@ -165,7 +172,7 @@ class SonarrDiskspaceSensor(SonarrSensor): self._total_free = sum(disk.free for disk in self._disks) @property - def extra_state_attributes(self) -> dict[str, Any] | None: + def extra_state_attributes(self) -> dict[str, str] | None: """Return the state attributes of the entity.""" attrs = {} @@ -192,7 +199,7 @@ class SonarrQueueSensor(SonarrSensor): def __init__(self, sonarr: Sonarr, entry_id: str) -> None: """Initialize Sonarr Queue sensor.""" - self._queue = [] + self._queue: list[QueueItem] = [] super().__init__( sonarr=sonarr, @@ -210,7 +217,7 @@ class SonarrQueueSensor(SonarrSensor): self._queue = await self.sonarr.queue() @property - def extra_state_attributes(self) -> dict[str, Any] | None: + def extra_state_attributes(self) -> dict[str, str] | None: """Return the state attributes of the entity.""" attrs = {} @@ -233,7 +240,7 @@ class SonarrSeriesSensor(SonarrSensor): def __init__(self, sonarr: Sonarr, entry_id: str) -> None: """Initialize Sonarr Series sensor.""" - self._items = [] + self._items: list[SeriesItem] = [] super().__init__( sonarr=sonarr, @@ -251,7 +258,7 @@ class SonarrSeriesSensor(SonarrSensor): self._items = await self.sonarr.series() @property - def extra_state_attributes(self) -> dict[str, Any] | None: + def extra_state_attributes(self) -> dict[str, str] | None: """Return the state attributes of the entity.""" attrs = {} @@ -272,7 +279,7 @@ class SonarrUpcomingSensor(SonarrSensor): def __init__(self, sonarr: Sonarr, entry_id: str, days: int = 1) -> None: """Initialize Sonarr Upcoming sensor.""" self._days = days - self._upcoming = [] + self._upcoming: list[Episode] = [] super().__init__( sonarr=sonarr, @@ -294,7 +301,7 @@ class SonarrUpcomingSensor(SonarrSensor): ) @property - def extra_state_attributes(self) -> dict[str, Any] | None: + def extra_state_attributes(self) -> dict[str, str] | None: """Return the state attributes of the entity.""" attrs = {} @@ -315,7 +322,7 @@ class SonarrWantedSensor(SonarrSensor): def __init__(self, sonarr: Sonarr, entry_id: str, max_items: int = 10) -> None: """Initialize Sonarr Wanted sensor.""" self._max_items = max_items - self._results = None + self._results: WantedResults | None = None self._total: int | None = None super().__init__( @@ -335,9 +342,9 @@ class SonarrWantedSensor(SonarrSensor): self._total = self._results.total @property - def extra_state_attributes(self) -> dict[str, Any] | None: + def extra_state_attributes(self) -> dict[str, str] | None: """Return the state attributes of the entity.""" - attrs = {} + attrs: dict[str, str] = {} if self._results is not None: for episode in self._results.episodes: diff --git a/mypy.ini b/mypy.ini index 02a2800a801..92247205c10 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1597,9 +1597,6 @@ ignore_errors = true [mypy-homeassistant.components.somfy_mylink.*] ignore_errors = true -[mypy-homeassistant.components.sonarr.*] -ignore_errors = true - [mypy-homeassistant.components.sonos.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 0026be479a4..91bba97fa31 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -121,7 +121,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.solaredge.*", "homeassistant.components.somfy.*", "homeassistant.components.somfy_mylink.*", - "homeassistant.components.sonarr.*", "homeassistant.components.sonos.*", "homeassistant.components.spotify.*", "homeassistant.components.stt.*", From 2fcd77098ddf4855b891c0fa7382bf7f9a3bee8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Sat, 28 Aug 2021 15:00:14 +0200 Subject: [PATCH 061/843] Pin regex to 2021.8.28 (#55368) --- homeassistant/package_constraints.txt | 5 +++++ script/gen_requirements_all.py | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 37649dcf42f..510d27ccccb 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -72,3 +72,8 @@ uuid==1000000000.0.0 # Temporary constraint on pandas, to unblock 2021.7 releases # until we have fixed the wheels builds for newer versions. pandas==1.3.0 + +# regex causes segfault with version 2021.8.27 +# https://bitbucket.org/mrabarnett/mrab-regex/issues/421/2021827-results-in-fatal-python-error +# This is fixed in 2021.8.28 +regex==2021.8.28 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index c2c98191a85..f535958412d 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -93,6 +93,11 @@ uuid==1000000000.0.0 # Temporary constraint on pandas, to unblock 2021.7 releases # until we have fixed the wheels builds for newer versions. pandas==1.3.0 + +# regex causes segfault with version 2021.8.27 +# https://bitbucket.org/mrabarnett/mrab-regex/issues/421/2021827-results-in-fatal-python-error +# This is fixed in 2021.8.28 +regex==2021.8.28 """ IGNORE_PRE_COMMIT_HOOK_ID = ( From 19873e654761eaa5e4e8a561c10dd6b18deefb46 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sat, 28 Aug 2021 17:49:34 +0200 Subject: [PATCH 062/843] Address late review for Tractive integration (#55371) --- homeassistant/components/tractive/sensor.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/tractive/sensor.py b/homeassistant/components/tractive/sensor.py index fdc38d8b83a..116c8218556 100644 --- a/homeassistant/components/tractive/sensor.py +++ b/homeassistant/components/tractive/sensor.py @@ -29,7 +29,6 @@ from .entity import TractiveEntity class TractiveSensorEntityDescription(SensorEntityDescription): """Class describing Tractive sensor entities.""" - attributes: tuple = () entity_class: type[TractiveSensor] | None = None @@ -97,10 +96,6 @@ class TractiveActivitySensor(TractiveSensor): def handle_activity_status_update(self, event): """Handle activity status update.""" self._attr_native_value = event[self.entity_description.key] - self._attr_extra_state_attributes = { - attr: event[attr] if attr in event else None - for attr in self.entity_description.attributes - } self._attr_available = True self.async_write_ha_state() @@ -137,7 +132,13 @@ SENSOR_TYPES = ( name="Minutes Active", icon="mdi:clock-time-eight-outline", native_unit_of_measurement=TIME_MINUTES, - attributes=(ATTR_DAILY_GOAL,), + entity_class=TractiveActivitySensor, + ), + TractiveSensorEntityDescription( + key=ATTR_DAILY_GOAL, + name="Daily Goal", + icon="mdi:flag-checkered", + native_unit_of_measurement=TIME_MINUTES, entity_class=TractiveActivitySensor, ), ) From 6a93f5b7ad1fc751be8ac3a57aea10f9858a7b6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Sat, 28 Aug 2021 17:57:57 +0200 Subject: [PATCH 063/843] Tractive name (#55342) --- homeassistant/components/tractive/sensor.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/homeassistant/components/tractive/sensor.py b/homeassistant/components/tractive/sensor.py index 116c8218556..ba2f330f894 100644 --- a/homeassistant/components/tractive/sensor.py +++ b/homeassistant/components/tractive/sensor.py @@ -39,6 +39,7 @@ class TractiveSensor(TractiveEntity, SensorEntity): """Initialize sensor entity.""" super().__init__(user_id, trackable, tracker_details) + self._attr_name = f"{trackable['details']['name']} {description.name}" self._attr_unique_id = unique_id self.entity_description = description @@ -52,11 +53,6 @@ class TractiveSensor(TractiveEntity, SensorEntity): class TractiveHardwareSensor(TractiveSensor): """Tractive hardware sensor.""" - def __init__(self, user_id, trackable, tracker_details, unique_id, description): - """Initialize sensor entity.""" - super().__init__(user_id, trackable, tracker_details, unique_id, description) - self._attr_name = f"{self._tracker_id} {description.name}" - @callback def handle_hardware_status_update(self, event): """Handle hardware status update.""" @@ -87,11 +83,6 @@ class TractiveHardwareSensor(TractiveSensor): class TractiveActivitySensor(TractiveSensor): """Tractive active sensor.""" - def __init__(self, user_id, trackable, tracker_details, unique_id, description): - """Initialize sensor entity.""" - super().__init__(user_id, trackable, tracker_details, unique_id, description) - self._attr_name = f"{trackable['details']['name']} {description.name}" - @callback def handle_activity_status_update(self, event): """Handle activity status update.""" From 778fa2e3fe4e17306ddafb623b44e493a11ef850 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Sat, 28 Aug 2021 12:57:02 -0600 Subject: [PATCH 064/843] Bump simplisafe-python to 11.0.6 (#55385) --- homeassistant/components/simplisafe/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/simplisafe/manifest.json b/homeassistant/components/simplisafe/manifest.json index 2d524d4c381..c6bc3ae61fa 100644 --- a/homeassistant/components/simplisafe/manifest.json +++ b/homeassistant/components/simplisafe/manifest.json @@ -3,7 +3,7 @@ "name": "SimpliSafe", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/simplisafe", - "requirements": ["simplisafe-python==11.0.5"], + "requirements": ["simplisafe-python==11.0.6"], "codeowners": ["@bachya"], "iot_class": "cloud_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index fe9979c28d2..9a2b1c033a9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2131,7 +2131,7 @@ simplehound==0.3 simplepush==1.1.4 # homeassistant.components.simplisafe -simplisafe-python==11.0.5 +simplisafe-python==11.0.6 # homeassistant.components.sisyphus sisyphus-control==3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 013fae0eb26..28a2a7b8e3c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1191,7 +1191,7 @@ sharkiqpy==0.1.8 simplehound==0.3 # homeassistant.components.simplisafe -simplisafe-python==11.0.5 +simplisafe-python==11.0.6 # homeassistant.components.slack slackclient==2.5.0 From 979797136a4209b4b8546da17439dc94dfa2cbd3 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Sat, 28 Aug 2021 21:10:19 +0200 Subject: [PATCH 065/843] Add select entity to Logitech Harmony (#53943) Co-authored-by: J. Nick Koston --- CODEOWNERS | 2 +- homeassistant/components/harmony/const.py | 2 +- .../{connection_state.py => entity.py} | 21 +++- .../components/harmony/manifest.json | 8 +- homeassistant/components/harmony/remote.py | 49 ++------ homeassistant/components/harmony/select.py | 75 ++++++++++++ homeassistant/components/harmony/switch.py | 37 ++---- tests/components/harmony/const.py | 1 + tests/components/harmony/test_init.py | 11 ++ tests/components/harmony/test_remote.py | 34 +++--- tests/components/harmony/test_select.py | 113 ++++++++++++++++++ tests/components/harmony/test_switch.py | 19 +++ 12 files changed, 278 insertions(+), 94 deletions(-) rename homeassistant/components/harmony/{connection_state.py => entity.py} (70%) create mode 100644 homeassistant/components/harmony/select.py create mode 100644 tests/components/harmony/test_select.py diff --git a/CODEOWNERS b/CODEOWNERS index 121d1875202..645fdce52a2 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -202,7 +202,7 @@ homeassistant/components/group/* @home-assistant/core homeassistant/components/growatt_server/* @indykoning @muppet3000 @JasperPlant homeassistant/components/guardian/* @bachya homeassistant/components/habitica/* @ASMfreaK @leikoilja -homeassistant/components/harmony/* @ehendrix23 @bramkragten @bdraco @mkeesey +homeassistant/components/harmony/* @ehendrix23 @bramkragten @bdraco @mkeesey @Aohzan homeassistant/components/hassio/* @home-assistant/supervisor homeassistant/components/heatmiser/* @andylockran homeassistant/components/heos/* @andrewsayre diff --git a/homeassistant/components/harmony/const.py b/homeassistant/components/harmony/const.py index 0d8d893a98e..c8e15ed0b0f 100644 --- a/homeassistant/components/harmony/const.py +++ b/homeassistant/components/harmony/const.py @@ -2,7 +2,7 @@ DOMAIN = "harmony" SERVICE_SYNC = "sync" SERVICE_CHANGE_CHANNEL = "change_channel" -PLATFORMS = ["remote", "switch"] +PLATFORMS = ["remote", "switch", "select"] UNIQUE_ID = "unique_id" ACTIVITY_POWER_OFF = "PowerOff" HARMONY_OPTIONS_UPDATE = "harmony_options_update" diff --git a/homeassistant/components/harmony/connection_state.py b/homeassistant/components/harmony/entity.py similarity index 70% rename from homeassistant/components/harmony/connection_state.py rename to homeassistant/components/harmony/entity.py index 84ad353480c..24c72a771e7 100644 --- a/homeassistant/components/harmony/connection_state.py +++ b/homeassistant/components/harmony/entity.py @@ -1,20 +1,31 @@ -"""Mixin class for handling connection state changes.""" +"""Base class Harmony entities.""" import logging +from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_call_later +from .data import HarmonyData + _LOGGER = logging.getLogger(__name__) TIME_MARK_DISCONNECTED = 10 -class ConnectionStateMixin: - """Base implementation for connection state handling.""" +class HarmonyEntity(Entity): + """Base entity for Harmony with connection state handling.""" - def __init__(self): - """Initialize this mixin instance.""" + def __init__(self, data: HarmonyData) -> None: + """Initialize the Harmony base entity.""" super().__init__() self._unsub_mark_disconnected = None + self._name = data.name + self._data = data + self._attr_should_poll = False + + @property + def available(self) -> bool: + """Return True if we're connected to the Hub, otherwise False.""" + return self._data.available async def async_got_connected(self, _=None): """Notification that we're connected to the HUB.""" diff --git a/homeassistant/components/harmony/manifest.json b/homeassistant/components/harmony/manifest.json index e28d525539b..f35f4e99303 100644 --- a/homeassistant/components/harmony/manifest.json +++ b/homeassistant/components/harmony/manifest.json @@ -3,7 +3,13 @@ "name": "Logitech Harmony Hub", "documentation": "https://www.home-assistant.io/integrations/harmony", "requirements": ["aioharmony==0.2.7"], - "codeowners": ["@ehendrix23", "@bramkragten", "@bdraco", "@mkeesey"], + "codeowners": [ + "@ehendrix23", + "@bramkragten", + "@bdraco", + "@mkeesey", + "@Aohzan" + ], "ssdp": [ { "manufacturer": "Logitech", diff --git a/homeassistant/components/harmony/remote.py b/homeassistant/components/harmony/remote.py index 593fbf3cb22..806b638aee8 100644 --- a/homeassistant/components/harmony/remote.py +++ b/homeassistant/components/harmony/remote.py @@ -21,7 +21,6 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.restore_state import RestoreEntity -from .connection_state import ConnectionStateMixin from .const import ( ACTIVITY_POWER_OFF, ATTR_ACTIVITY_STARTING, @@ -34,6 +33,7 @@ from .const import ( SERVICE_CHANGE_CHANNEL, SERVICE_SYNC, ) +from .entity import HarmonyEntity from .subscriber import HarmonyCallback _LOGGER = logging.getLogger(__name__) @@ -76,28 +76,24 @@ async def async_setup_entry( ) -class HarmonyRemote(ConnectionStateMixin, remote.RemoteEntity, RestoreEntity): +class HarmonyRemote(HarmonyEntity, remote.RemoteEntity, RestoreEntity): """Remote representation used to control a Harmony device.""" def __init__(self, data, activity, delay_secs, out_path): """Initialize HarmonyRemote class.""" - super().__init__() - self._data = data - self._name = data.name + super().__init__(data=data) self._state = None self._current_activity = ACTIVITY_POWER_OFF self.default_activity = activity self._activity_starting = None self._is_initial_update = True self.delay_secs = delay_secs - self._unique_id = data.unique_id self._last_activity = None self._config_path = out_path - - @property - def supported_features(self): - """Supported features for the remote.""" - return SUPPORT_ACTIVITY + self._attr_unique_id = data.unique_id + self._attr_device_info = self._data.device_info(DOMAIN) + self._attr_name = data.name + self._attr_supported_features = SUPPORT_ACTIVITY async def _async_update_options(self, data): """Change options when the options flow does.""" @@ -128,7 +124,7 @@ class HarmonyRemote(ConnectionStateMixin, remote.RemoteEntity, RestoreEntity): """Complete the initialization.""" await super().async_added_to_hass() - _LOGGER.debug("%s: Harmony Hub added", self._name) + _LOGGER.debug("%s: Harmony Hub added", self.name) self.async_on_remove(self._clear_disconnection_delay) self._setup_callbacks() @@ -158,26 +154,6 @@ class HarmonyRemote(ConnectionStateMixin, remote.RemoteEntity, RestoreEntity): self._last_activity = last_state.attributes[ATTR_LAST_ACTIVITY] - @property - def device_info(self): - """Return device info.""" - return self._data.device_info(DOMAIN) - - @property - def unique_id(self): - """Return the unique id.""" - return self._unique_id - - @property - def name(self): - """Return the Harmony device's name.""" - return self._name - - @property - def should_poll(self): - """Return the fact that we should not be polled.""" - return False - @property def current_activity(self): """Return the current activity.""" @@ -202,16 +178,11 @@ class HarmonyRemote(ConnectionStateMixin, remote.RemoteEntity, RestoreEntity): """Return False if PowerOff is the current activity, otherwise True.""" return self._current_activity not in [None, "PowerOff"] - @property - def available(self): - """Return True if connected to Hub, otherwise False.""" - return self._data.available - @callback def async_new_activity(self, activity_info: tuple) -> None: """Call for updating the current activity.""" activity_id, activity_name = activity_info - _LOGGER.debug("%s: activity reported as: %s", self._name, activity_name) + _LOGGER.debug("%s: activity reported as: %s", self.name, activity_name) self._current_activity = activity_name if self._is_initial_update: self._is_initial_update = False @@ -227,7 +198,7 @@ class HarmonyRemote(ConnectionStateMixin, remote.RemoteEntity, RestoreEntity): async def async_new_config(self, _=None): """Call for updating the current activity.""" - _LOGGER.debug("%s: configuration has been updated", self._name) + _LOGGER.debug("%s: configuration has been updated", self.name) self.async_new_activity(self._data.current_activity) await self.hass.async_add_executor_job(self.write_config_file) diff --git a/homeassistant/components/harmony/select.py b/homeassistant/components/harmony/select.py new file mode 100644 index 00000000000..18f273e4bfb --- /dev/null +++ b/homeassistant/components/harmony/select.py @@ -0,0 +1,75 @@ +"""Support for Harmony Hub select activities.""" +from __future__ import annotations + +import logging + +from homeassistant.components.select import SelectEntity +from homeassistant.const import CONF_NAME +from homeassistant.core import callback + +from .const import ACTIVITY_POWER_OFF, DOMAIN, HARMONY_DATA +from .data import HarmonyData +from .entity import HarmonyEntity +from .subscriber import HarmonyCallback + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up harmony activities select.""" + data = hass.data[DOMAIN][entry.entry_id][HARMONY_DATA] + _LOGGER.debug("creating select for %s hub activities", entry.data[CONF_NAME]) + async_add_entities( + [HarmonyActivitySelect(f"{entry.data[CONF_NAME]} Activities", data)] + ) + + +class HarmonyActivitySelect(HarmonyEntity, SelectEntity): + """Select representation of a Harmony activities.""" + + def __init__(self, name: str, data: HarmonyData) -> None: + """Initialize HarmonyActivitySelect class.""" + super().__init__(data=data) + self._data = data + self._attr_unique_id = self._data.unique_id + self._attr_device_info = self._data.device_info(DOMAIN) + self._attr_name = name + + @property + def icon(self): + """Return a representative icon.""" + if not self.available or self.current_option == ACTIVITY_POWER_OFF: + return "mdi:remote-tv-off" + return "mdi:remote-tv" + + @property + def options(self) -> list[str]: + """Return a set of selectable options.""" + return [ACTIVITY_POWER_OFF] + sorted(self._data.activity_names) + + @property + def current_option(self): + """Return the current activity.""" + _, activity_name = self._data.current_activity + return activity_name + + async def async_select_option(self, option: str) -> None: + """Change the current activity.""" + await self._data.async_start_activity(option) + + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + + callbacks = { + "connected": self.async_got_connected, + "disconnected": self.async_got_disconnected, + "activity_starting": self._async_activity_update, + "activity_started": self._async_activity_update, + "config_updated": None, + } + + self.async_on_remove(self._data.async_subscribe(HarmonyCallback(**callbacks))) + + @callback + def _async_activity_update(self, activity_info: tuple): + self.async_write_ha_state() diff --git a/homeassistant/components/harmony/switch.py b/homeassistant/components/harmony/switch.py index a45b43fce0f..02885289a06 100644 --- a/homeassistant/components/harmony/switch.py +++ b/homeassistant/components/harmony/switch.py @@ -5,9 +5,9 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.const import CONF_NAME from homeassistant.core import callback -from .connection_state import ConnectionStateMixin from .const import DOMAIN, HARMONY_DATA from .data import HarmonyData +from .entity import HarmonyEntity from .subscriber import HarmonyCallback _LOGGER = logging.getLogger(__name__) @@ -27,31 +27,18 @@ async def async_setup_entry(hass, entry, async_add_entities): async_add_entities(switches, True) -class HarmonyActivitySwitch(ConnectionStateMixin, SwitchEntity): +class HarmonyActivitySwitch(HarmonyEntity, SwitchEntity): """Switch representation of a Harmony activity.""" def __init__(self, name: str, activity: dict, data: HarmonyData) -> None: """Initialize HarmonyActivitySwitch class.""" - super().__init__() - self._name = name + super().__init__(data=data) self._activity_name = activity["label"] self._activity_id = activity["id"] - self._data = data - - @property - def name(self): - """Return the Harmony activity's name.""" - return self._name - - @property - def unique_id(self): - """Return the unique id.""" - return f"activity_{self._activity_id}" - - @property - def device_info(self): - """Return device info.""" - return self._data.device_info(DOMAIN) + self._attr_entity_registry_enabled_default = False + self._attr_unique_id = f"activity_{self._activity_id}" + self._attr_name = name + self._attr_device_info = self._data.device_info(DOMAIN) @property def is_on(self): @@ -59,16 +46,6 @@ class HarmonyActivitySwitch(ConnectionStateMixin, SwitchEntity): _, activity_name = self._data.current_activity return activity_name == self._activity_name - @property - def should_poll(self): - """Return that we shouldn't be polled.""" - return False - - @property - def available(self): - """Return True if we're connected to the Hub, otherwise False.""" - return self._data.available - async def async_turn_on(self, **kwargs): """Start this activity.""" await self._data.async_start_activity(self._activity_name) diff --git a/tests/components/harmony/const.py b/tests/components/harmony/const.py index 488fe30dec3..9677883d25f 100644 --- a/tests/components/harmony/const.py +++ b/tests/components/harmony/const.py @@ -5,6 +5,7 @@ ENTITY_REMOTE = "remote.guest_room" ENTITY_WATCH_TV = "switch.guest_room_watch_tv" ENTITY_PLAY_MUSIC = "switch.guest_room_play_music" ENTITY_NILE_TV = "switch.guest_room_nile_tv" +ENTITY_SELECT = "select.guest_room_activities" WATCH_TV_ACTIVITY_ID = 123 PLAY_MUSIC_ACTIVITY_ID = 456 diff --git a/tests/components/harmony/test_init.py b/tests/components/harmony/test_init.py index 29a1ff26b82..d64e5b61701 100644 --- a/tests/components/harmony/test_init.py +++ b/tests/components/harmony/test_init.py @@ -7,6 +7,7 @@ from homeassistant.setup import async_setup_component from .const import ( ENTITY_NILE_TV, ENTITY_PLAY_MUSIC, + ENTITY_SELECT, ENTITY_WATCH_TV, HUB_NAME, NILE_TV_ACTIVITY_ID, @@ -55,6 +56,13 @@ async def test_unique_id_migration(mock_hc, hass, mock_write_config): platform="harmony", config_entry_id=entry.entry_id, ), + # select entity + ENTITY_SELECT: er.RegistryEntry( + entity_id=ENTITY_SELECT, + unique_id=f"{HUB_NAME}_activities", + platform="harmony", + config_entry_id=entry.entry_id, + ), }, ) assert await async_setup_component(hass, DOMAIN, {}) @@ -70,3 +78,6 @@ async def test_unique_id_migration(mock_hc, hass, mock_write_config): switch_music = ent_reg.async_get(ENTITY_PLAY_MUSIC) assert switch_music.unique_id == f"activity_{PLAY_MUSIC_ACTIVITY_ID}" + + select_activities = ent_reg.async_get(ENTITY_SELECT) + assert select_activities.unique_id == f"{HUB_NAME}_activities" diff --git a/tests/components/harmony/test_remote.py b/tests/components/harmony/test_remote.py index df75485e30d..0a176518131 100644 --- a/tests/components/harmony/test_remote.py +++ b/tests/components/harmony/test_remote.py @@ -33,7 +33,7 @@ from homeassistant.const import ( from homeassistant.util import utcnow from .conftest import ACTIVITIES_TO_IDS, TV_DEVICE_ID, TV_DEVICE_NAME -from .const import ENTITY_PLAY_MUSIC, ENTITY_REMOTE, ENTITY_WATCH_TV, HUB_NAME +from .const import ENTITY_REMOTE, HUB_NAME from tests.common import MockConfigEntry, async_fire_time_changed @@ -91,10 +91,10 @@ async def test_remote_toggles(mock_hc, hass, mock_write_config): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - # mocks start with current activity == Watch TV - assert hass.states.is_state(ENTITY_REMOTE, STATE_ON) - assert hass.states.is_state(ENTITY_WATCH_TV, STATE_ON) - assert hass.states.is_state(ENTITY_PLAY_MUSIC, STATE_OFF) + # mocks start remote with Watch TV default activity + state = hass.states.get(ENTITY_REMOTE) + assert state.state == STATE_ON + assert state.attributes.get("current_activity") == "Watch TV" # turn off remote await hass.services.async_call( @@ -105,9 +105,9 @@ async def test_remote_toggles(mock_hc, hass, mock_write_config): ) await hass.async_block_till_done() - assert hass.states.is_state(ENTITY_REMOTE, STATE_OFF) - assert hass.states.is_state(ENTITY_WATCH_TV, STATE_OFF) - assert hass.states.is_state(ENTITY_PLAY_MUSIC, STATE_OFF) + state = hass.states.get(ENTITY_REMOTE) + assert state.state == STATE_OFF + assert state.attributes.get("current_activity") == "PowerOff" # turn on remote, restoring the last activity await hass.services.async_call( @@ -118,9 +118,9 @@ async def test_remote_toggles(mock_hc, hass, mock_write_config): ) await hass.async_block_till_done() - assert hass.states.is_state(ENTITY_REMOTE, STATE_ON) - assert hass.states.is_state(ENTITY_WATCH_TV, STATE_ON) - assert hass.states.is_state(ENTITY_PLAY_MUSIC, STATE_OFF) + state = hass.states.get(ENTITY_REMOTE) + assert state.state == STATE_ON + assert state.attributes.get("current_activity") == "Watch TV" # send new activity command, with activity name await hass.services.async_call( @@ -131,9 +131,9 @@ async def test_remote_toggles(mock_hc, hass, mock_write_config): ) await hass.async_block_till_done() - assert hass.states.is_state(ENTITY_REMOTE, STATE_ON) - assert hass.states.is_state(ENTITY_WATCH_TV, STATE_OFF) - assert hass.states.is_state(ENTITY_PLAY_MUSIC, STATE_ON) + state = hass.states.get(ENTITY_REMOTE) + assert state.state == STATE_ON + assert state.attributes.get("current_activity") == "Play Music" # send new activity command, with activity id await hass.services.async_call( @@ -144,9 +144,9 @@ async def test_remote_toggles(mock_hc, hass, mock_write_config): ) await hass.async_block_till_done() - assert hass.states.is_state(ENTITY_REMOTE, STATE_ON) - assert hass.states.is_state(ENTITY_WATCH_TV, STATE_ON) - assert hass.states.is_state(ENTITY_PLAY_MUSIC, STATE_OFF) + state = hass.states.get(ENTITY_REMOTE) + assert state.state == STATE_ON + assert state.attributes.get("current_activity") == "Watch TV" async def test_async_send_command(mock_hc, harmony_client, hass, mock_write_config): diff --git a/tests/components/harmony/test_select.py b/tests/components/harmony/test_select.py new file mode 100644 index 00000000000..4607f035893 --- /dev/null +++ b/tests/components/harmony/test_select.py @@ -0,0 +1,113 @@ +"""Test the Logitech Harmony Hub activity select.""" + +from datetime import timedelta + +from homeassistant.components.harmony.const import DOMAIN +from homeassistant.components.select import ( + ATTR_OPTION, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_HOST, + CONF_NAME, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, +) +from homeassistant.util import utcnow + +from .const import ENTITY_REMOTE, ENTITY_SELECT, HUB_NAME + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_connection_state_changes( + harmony_client, mock_hc, hass, mock_write_config +): + """Ensure connection changes are reflected in the switch states.""" + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "192.0.2.0", CONF_NAME: HUB_NAME} + ) + + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # mocks start with current activity == Watch TV + assert hass.states.is_state(ENTITY_SELECT, "Watch TV") + + harmony_client.mock_disconnection() + await hass.async_block_till_done() + + # Entities do not immediately show as unavailable + assert hass.states.is_state(ENTITY_SELECT, "Watch TV") + + future_time = utcnow() + timedelta(seconds=10) + async_fire_time_changed(hass, future_time) + await hass.async_block_till_done() + assert hass.states.is_state(ENTITY_SELECT, STATE_UNAVAILABLE) + + harmony_client.mock_reconnection() + await hass.async_block_till_done() + + assert hass.states.is_state(ENTITY_SELECT, "Watch TV") + + +async def test_options(mock_hc, hass, mock_write_config): + """Ensure calls to the switch modify the harmony state.""" + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "192.0.2.0", CONF_NAME: HUB_NAME} + ) + + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # assert we have all options + state = hass.states.get(ENTITY_SELECT) + assert state.attributes.get("options") == [ + "PowerOff", + "Nile-TV", + "Play Music", + "Watch TV", + ] + + +async def test_select_option(mock_hc, hass, mock_write_config): + """Ensure calls to the switch modify the harmony state.""" + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "192.0.2.0", CONF_NAME: HUB_NAME} + ) + + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # mocks start with current activity == Watch TV + assert hass.states.is_state(ENTITY_REMOTE, STATE_ON) + assert hass.states.is_state(ENTITY_SELECT, "Watch TV") + + # launch Play Music activity + await _select_option_and_wait(hass, ENTITY_SELECT, "Play Music") + assert hass.states.is_state(ENTITY_REMOTE, STATE_ON) + assert hass.states.is_state(ENTITY_SELECT, "Play Music") + + # turn off harmony by selecting PowerOff activity + await _select_option_and_wait(hass, ENTITY_SELECT, "PowerOff") + assert hass.states.is_state(ENTITY_REMOTE, STATE_OFF) + assert hass.states.is_state(ENTITY_SELECT, "PowerOff") + + +async def _select_option_and_wait(hass, entity, option): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: entity, + ATTR_OPTION: option, + }, + blocking=True, + ) + await hass.async_block_till_done() diff --git a/tests/components/harmony/test_switch.py b/tests/components/harmony/test_switch.py index 1940c54e112..d7af3680dd9 100644 --- a/tests/components/harmony/test_switch.py +++ b/tests/components/harmony/test_switch.py @@ -16,6 +16,7 @@ from homeassistant.const import ( STATE_ON, STATE_UNAVAILABLE, ) +from homeassistant.helpers import entity_registry from homeassistant.util import utcnow from .const import ENTITY_PLAY_MUSIC, ENTITY_REMOTE, ENTITY_WATCH_TV, HUB_NAME @@ -35,6 +36,17 @@ async def test_connection_state_changes( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() + # check if switch entities are disabled by default + assert not hass.states.get(ENTITY_WATCH_TV) + assert not hass.states.get(ENTITY_PLAY_MUSIC) + + # enable switch entities + ent_reg = entity_registry.async_get(hass) + ent_reg.async_update_entity(ENTITY_WATCH_TV, disabled_by=None) + ent_reg.async_update_entity(ENTITY_PLAY_MUSIC, disabled_by=None) + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + # mocks start with current activity == Watch TV assert hass.states.is_state(ENTITY_WATCH_TV, STATE_ON) assert hass.states.is_state(ENTITY_PLAY_MUSIC, STATE_OFF) @@ -78,6 +90,13 @@ async def test_switch_toggles(mock_hc, hass, mock_write_config): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() + # enable switch entities + ent_reg = entity_registry.async_get(hass) + ent_reg.async_update_entity(ENTITY_WATCH_TV, disabled_by=None) + ent_reg.async_update_entity(ENTITY_PLAY_MUSIC, disabled_by=None) + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + # mocks start with current activity == Watch TV assert hass.states.is_state(ENTITY_REMOTE, STATE_ON) assert hass.states.is_state(ENTITY_WATCH_TV, STATE_ON) From 13cc6718445b7fb35c3e8f95cc7e4f73c2de40bc Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 28 Aug 2021 21:11:51 +0200 Subject: [PATCH 066/843] Re-configuration possibilities for Synology DSM (#53285) * add automated host/ip reconfig via SSDP * add reconfig of existing entry * adjust tests * adjust tests again * use self._async_current_entries() * _async_get_existing_entry() * apply suggestions --- .../components/synology_dsm/config_flow.py | 46 ++++--- .../components/synology_dsm/strings.json | 3 +- .../synology_dsm/translations/de.json | 3 +- .../synology_dsm/translations/en.json | 3 +- .../synology_dsm/test_config_flow.py | 112 ++++++++++++------ 5 files changed, 109 insertions(+), 58 deletions(-) diff --git a/homeassistant/components/synology_dsm/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py index 97f9e4343fa..003af39ecdf 100644 --- a/homeassistant/components/synology_dsm/config_flow.py +++ b/homeassistant/components/synology_dsm/config_flow.py @@ -228,14 +228,14 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): if user_input.get(CONF_VOLUMES): config_data[CONF_VOLUMES] = user_input[CONF_VOLUMES] - if existing_entry and self.reauth_conf: + if existing_entry: self.hass.config_entries.async_update_entry( existing_entry, data=config_data ) await self.hass.config_entries.async_reload(existing_entry.entry_id) - return self.async_abort(reason="reauth_successful") - if existing_entry: - return self.async_abort(reason="already_configured") + if self.reauth_conf: + return self.async_abort(reason="reauth_successful") + return self.async_abort(reason="reconfigure_successful") return self.async_create_entry(title=host, data=config_data) @@ -246,14 +246,26 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): discovery_info[ssdp.ATTR_UPNP_FRIENDLY_NAME].split("(", 1)[0].strip() ) - mac = discovery_info[ssdp.ATTR_UPNP_SERIAL].upper() + discovered_mac = discovery_info[ssdp.ATTR_UPNP_SERIAL].upper() # Synology NAS can broadcast on multiple IP addresses, since they can be connected to multiple ethernets. # The serial of the NAS is actually its MAC address. - if self._mac_already_configured(mac): - return self.async_abort(reason="already_configured") - await self.async_set_unique_id(mac) - self._abort_if_unique_id_configured() + existing_entry = self._async_get_existing_entry(discovered_mac) + + if existing_entry and existing_entry.data[CONF_HOST] != parsed_url.hostname: + _LOGGER.debug( + "Update host from '%s' to '%s' for NAS '%s' via SSDP discovery", + existing_entry.data[CONF_HOST], + parsed_url.hostname, + existing_entry.unique_id, + ) + self.hass.config_entries.async_update_entry( + existing_entry, + data={**existing_entry.data, CONF_HOST: parsed_url.hostname}, + ) + return self.async_abort(reason="reconfigure_successful") + if existing_entry: + return self.async_abort(reason="already_configured") self.discovered_conf = { CONF_NAME: friendly_name, @@ -295,14 +307,14 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): return await self.async_step_user(user_input) - def _mac_already_configured(self, mac: str) -> bool: - """See if we already have configured a NAS with this MAC address.""" - existing_macs = [ - mac.replace("-", "") - for entry in self._async_current_entries() - for mac in entry.data.get(CONF_MAC, []) - ] - return mac in existing_macs + def _async_get_existing_entry(self, discovered_mac: str) -> ConfigEntry | None: + """See if we already have a configured NAS with this MAC address.""" + for entry in self._async_current_entries(): + if discovered_mac in [ + mac.replace("-", "") for mac in entry.data.get(CONF_MAC, []) + ]: + return entry + return None class SynologyDSMOptionsFlowHandler(OptionsFlow): diff --git a/homeassistant/components/synology_dsm/strings.json b/homeassistant/components/synology_dsm/strings.json index 6baaaaef9f6..9a7500e08bc 100644 --- a/homeassistant/components/synology_dsm/strings.json +++ b/homeassistant/components/synology_dsm/strings.json @@ -48,7 +48,8 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "Re-configuration was successful" } }, "options": { diff --git a/homeassistant/components/synology_dsm/translations/de.json b/homeassistant/components/synology_dsm/translations/de.json index 86c154e8567..aca87d46d70 100644 --- a/homeassistant/components/synology_dsm/translations/de.json +++ b/homeassistant/components/synology_dsm/translations/de.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "Ger\u00e4t ist bereits konfiguriert", - "reauth_successful": "Die erneute Authentifizierung war erfolgreich" + "reauth_successful": "Die erneute Authentifizierung war erfolgreich", + "reconfigure_successful": "Die Anpassung der Konfiguration war erfolgreich" }, "error": { "cannot_connect": "Verbindung fehlgeschlagen", diff --git a/homeassistant/components/synology_dsm/translations/en.json b/homeassistant/components/synology_dsm/translations/en.json index 0231f8ddb3c..15384a1690e 100644 --- a/homeassistant/components/synology_dsm/translations/en.json +++ b/homeassistant/components/synology_dsm/translations/en.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "Device is already configured", - "reauth_successful": "Re-authentication was successful" + "reauth_successful": "Re-authentication was successful", + "reconfigure_successful": "Re-configuration was successful" }, "error": { "cannot_connect": "Failed to connect", diff --git a/tests/components/synology_dsm/test_config_flow.py b/tests/components/synology_dsm/test_config_flow.py index cf043c2ce5f..34434eb4a43 100644 --- a/tests/components/synology_dsm/test_config_flow.py +++ b/tests/components/synology_dsm/test_config_flow.py @@ -305,22 +305,29 @@ async def test_reauth(hass: HomeAssistant, service: MagicMock): assert result["reason"] == "reauth_successful" -async def test_abort_if_already_setup(hass: HomeAssistant, service: MagicMock): - """Test we abort if the account is already setup.""" +async def test_reconfig_user(hass: HomeAssistant, service: MagicMock): + """Test re-configuration of already existing entry by user.""" MockConfigEntry( domain=DOMAIN, - data={CONF_HOST: HOST, CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + data={ + CONF_HOST: "wrong_host", + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + }, unique_id=SERIAL, ).add_to_hass(hass) - # Should fail, same HOST:PORT (flow) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_HOST: HOST, CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "already_configured" + with patch( + "homeassistant.config_entries.ConfigEntries.async_reload", + return_value=True, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_HOST: HOST, CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "reconfigure_successful" async def test_login_failed(hass: HomeAssistant, service: MagicMock): @@ -379,33 +386,6 @@ async def test_missing_data_after_login(hass: HomeAssistant, service_failed: Mag assert result["errors"] == {"base": "missing_data"} -async def test_form_ssdp_already_configured(hass: HomeAssistant, service: MagicMock): - """Test ssdp abort when the serial number is already configured.""" - await setup.async_setup_component(hass, "persistent_notification", {}) - - MockConfigEntry( - domain=DOMAIN, - data={ - CONF_HOST: HOST, - CONF_USERNAME: USERNAME, - CONF_PASSWORD: PASSWORD, - CONF_MAC: MACS, - }, - unique_id=SERIAL, - ).add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_SSDP}, - data={ - ssdp.ATTR_SSDP_LOCATION: "http://192.168.1.5:5000", - ssdp.ATTR_UPNP_FRIENDLY_NAME: "mydsm", - ssdp.ATTR_UPNP_SERIAL: "001132XXXX59", # Existing in MACS[0], but SSDP does not have `-` - }, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - - async def test_form_ssdp(hass: HomeAssistant, service: MagicMock): """Test we can setup from ssdp.""" await setup.async_setup_component(hass, "persistent_notification", {}) @@ -442,6 +422,62 @@ async def test_form_ssdp(hass: HomeAssistant, service: MagicMock): assert result["data"].get(CONF_VOLUMES) is None +async def test_reconfig_ssdp(hass: HomeAssistant, service: MagicMock): + """Test re-configuration of already existing entry by ssdp.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "wrong_host", + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_MAC: MACS, + }, + unique_id=SERIAL, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_SSDP}, + data={ + ssdp.ATTR_SSDP_LOCATION: "http://192.168.1.5:5000", + ssdp.ATTR_UPNP_FRIENDLY_NAME: "mydsm", + ssdp.ATTR_UPNP_SERIAL: "001132XXXX59", # Existing in MACS[0], but SSDP does not have `-` + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "reconfigure_successful" + + +async def test_existing_ssdp(hass: HomeAssistant, service: MagicMock): + """Test abort of already existing entry by ssdp.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "192.168.1.5", + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_MAC: MACS, + }, + unique_id=SERIAL, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_SSDP}, + data={ + ssdp.ATTR_SSDP_LOCATION: "http://192.168.1.5:5000", + ssdp.ATTR_UPNP_FRIENDLY_NAME: "mydsm", + ssdp.ATTR_UPNP_SERIAL: "001132XXXX59", # Existing in MACS[0], but SSDP does not have `-` + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + async def test_options_flow(hass: HomeAssistant, service: MagicMock): """Test config flow options.""" config_entry = MockConfigEntry( From f91cc21bbdee9cb9c441edab71bcf663a082b9d5 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Sat, 28 Aug 2021 23:04:33 +0200 Subject: [PATCH 067/843] Solve modbus shutdown racing condition (#55373) --- homeassistant/components/modbus/modbus.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index c2e39542077..42505215622 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -292,13 +292,13 @@ class ModbusHub: for call in self.entity_timers: call() self.entity_timers = [] - if self._client: - async with self._lock: + async with self._lock: + if self._client: try: self._client.close() except ModbusException as exception_error: self._log_error(str(exception_error)) - self._client = None + self._client = None def _pymodbus_connect(self): """Connect client.""" @@ -327,9 +327,9 @@ class ModbusHub: """Convert async to sync pymodbus call.""" if self._config_delay: return None - if not self._client: - return None async with self._lock: + if not self._client: + return None result = await self.hass.async_add_executor_job( self._pymodbus_call, unit, address, value, use_call ) From f1ba98927cdb61781e5780fe12aad08780df427e Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 28 Aug 2021 23:07:06 +0200 Subject: [PATCH 068/843] Address late fritzbox comments (#55388) * correct imports * move platform specifics into platforms * move descriptions into platforms --- .../components/fritzbox/binary_sensor.py | 38 ++++++++- homeassistant/components/fritzbox/const.py | 70 ---------------- homeassistant/components/fritzbox/model.py | 31 ------- homeassistant/components/fritzbox/sensor.py | 81 ++++++++++++++++++- 4 files changed, 113 insertions(+), 107 deletions(-) diff --git a/homeassistant/components/fritzbox/binary_sensor.py b/homeassistant/components/fritzbox/binary_sensor.py index b7e78ceaf47..25831da957c 100644 --- a/homeassistant/components/fritzbox/binary_sensor.py +++ b/homeassistant/components/fritzbox/binary_sensor.py @@ -1,17 +1,49 @@ """Support for Fritzbox binary sensors.""" from __future__ import annotations +from dataclasses import dataclass +from typing import Callable, Final + from pyfritzhome.fritzhomedevice import FritzhomeDevice -from homeassistant.components.binary_sensor import BinarySensorEntity -from homeassistant.components.fritzbox.model import FritzBinarySensorEntityDescription +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_WINDOW, + BinarySensorEntity, + BinarySensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import FritzBoxEntity -from .const import BINARY_SENSOR_TYPES, CONF_COORDINATOR, DOMAIN as FRITZBOX_DOMAIN +from .const import CONF_COORDINATOR, DOMAIN as FRITZBOX_DOMAIN +from .model import FritzEntityDescriptionMixinBase + + +@dataclass +class FritzEntityDescriptionMixinBinarySensor(FritzEntityDescriptionMixinBase): + """BinarySensor description mixin for Fritz!Smarthome entities.""" + + is_on: Callable[[FritzhomeDevice], bool | None] + + +@dataclass +class FritzBinarySensorEntityDescription( + BinarySensorEntityDescription, FritzEntityDescriptionMixinBinarySensor +): + """Description for Fritz!Smarthome binary sensor entities.""" + + +BINARY_SENSOR_TYPES: Final[tuple[FritzBinarySensorEntityDescription, ...]] = ( + FritzBinarySensorEntityDescription( + key="alarm", + name="Alarm", + device_class=DEVICE_CLASS_WINDOW, + suitable=lambda device: device.has_alarm, # type: ignore[no-any-return] + is_on=lambda device: device.alert_state, # type: ignore[no-any-return] + ), +) async def async_setup_entry( diff --git a/homeassistant/components/fritzbox/const.py b/homeassistant/components/fritzbox/const.py index 79123abbda7..6af75449a29 100644 --- a/homeassistant/components/fritzbox/const.py +++ b/homeassistant/components/fritzbox/const.py @@ -4,26 +4,6 @@ from __future__ import annotations import logging from typing import Final -from homeassistant.components.binary_sensor import DEVICE_CLASS_WINDOW -from homeassistant.components.fritzbox.model import ( - FritzBinarySensorEntityDescription, - FritzSensorEntityDescription, -) -from homeassistant.components.sensor import ( - STATE_CLASS_MEASUREMENT, - STATE_CLASS_TOTAL_INCREASING, -) -from homeassistant.const import ( - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_ENERGY, - DEVICE_CLASS_POWER, - DEVICE_CLASS_TEMPERATURE, - ENERGY_KILO_WATT_HOUR, - PERCENTAGE, - POWER_WATT, - TEMP_CELSIUS, -) - ATTR_STATE_BATTERY_LOW: Final = "battery_low" ATTR_STATE_DEVICE_LOCKED: Final = "device_locked" ATTR_STATE_HOLIDAY_MODE: Final = "holiday_mode" @@ -44,53 +24,3 @@ DOMAIN: Final = "fritzbox" LOGGER: Final[logging.Logger] = logging.getLogger(__package__) PLATFORMS: Final[list[str]] = ["binary_sensor", "climate", "switch", "sensor"] - -BINARY_SENSOR_TYPES: Final[tuple[FritzBinarySensorEntityDescription, ...]] = ( - FritzBinarySensorEntityDescription( - key="alarm", - name="Alarm", - device_class=DEVICE_CLASS_WINDOW, - suitable=lambda device: device.has_alarm, # type: ignore[no-any-return] - is_on=lambda device: device.alert_state, # type: ignore[no-any-return] - ), -) - -SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( - FritzSensorEntityDescription( - key="temperature", - name="Temperature", - native_unit_of_measurement=TEMP_CELSIUS, - device_class=DEVICE_CLASS_TEMPERATURE, - state_class=STATE_CLASS_MEASUREMENT, - suitable=lambda device: ( - device.has_temperature_sensor and not device.has_thermostat - ), - native_value=lambda device: device.temperature, # type: ignore[no-any-return] - ), - FritzSensorEntityDescription( - key="battery", - name="Battery", - native_unit_of_measurement=PERCENTAGE, - device_class=DEVICE_CLASS_BATTERY, - suitable=lambda device: device.battery_level is not None, - native_value=lambda device: device.battery_level, # type: ignore[no-any-return] - ), - FritzSensorEntityDescription( - key="power_consumption", - name="Power Consumption", - native_unit_of_measurement=POWER_WATT, - device_class=DEVICE_CLASS_POWER, - state_class=STATE_CLASS_MEASUREMENT, - suitable=lambda device: device.has_powermeter, # type: ignore[no-any-return] - native_value=lambda device: device.power / 1000 if device.power else 0.0, - ), - FritzSensorEntityDescription( - key="total_energy", - name="Total Energy", - native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, - suitable=lambda device: device.has_powermeter, # type: ignore[no-any-return] - native_value=lambda device: device.energy / 1000 if device.energy else 0.0, - ), -) diff --git a/homeassistant/components/fritzbox/model.py b/homeassistant/components/fritzbox/model.py index fb694a97012..69aefb8071c 100644 --- a/homeassistant/components/fritzbox/model.py +++ b/homeassistant/components/fritzbox/model.py @@ -6,9 +6,6 @@ from typing import Callable, TypedDict from pyfritzhome import FritzhomeDevice -from homeassistant.components.binary_sensor import BinarySensorEntityDescription -from homeassistant.components.sensor import SensorEntityDescription - class EntityInfo(TypedDict): """TypedDict for EntityInfo.""" @@ -53,31 +50,3 @@ class FritzEntityDescriptionMixinBase: """Bases description mixin for Fritz!Smarthome entities.""" suitable: Callable[[FritzhomeDevice], bool] - - -@dataclass -class FritzEntityDescriptionMixinSensor(FritzEntityDescriptionMixinBase): - """Sensor description mixin for Fritz!Smarthome entities.""" - - native_value: Callable[[FritzhomeDevice], float | int | None] - - -@dataclass -class FritzEntityDescriptionMixinBinarySensor(FritzEntityDescriptionMixinBase): - """BinarySensor description mixin for Fritz!Smarthome entities.""" - - is_on: Callable[[FritzhomeDevice], bool | None] - - -@dataclass -class FritzSensorEntityDescription( - SensorEntityDescription, FritzEntityDescriptionMixinSensor -): - """Description for Fritz!Smarthome sensor entities.""" - - -@dataclass -class FritzBinarySensorEntityDescription( - BinarySensorEntityDescription, FritzEntityDescriptionMixinBinarySensor -): - """Description for Fritz!Smarthome binary sensor entities.""" diff --git a/homeassistant/components/fritzbox/sensor.py b/homeassistant/components/fritzbox/sensor.py index 2150c2359b3..6f1cf49129d 100644 --- a/homeassistant/components/fritzbox/sensor.py +++ b/homeassistant/components/fritzbox/sensor.py @@ -1,14 +1,89 @@ """Support for AVM FRITZ!SmartHome temperature sensor only devices.""" from __future__ import annotations -from homeassistant.components.fritzbox.model import FritzSensorEntityDescription -from homeassistant.components.sensor import SensorEntity +from dataclasses import dataclass +from typing import Callable, Final + +from pyfritzhome.fritzhomedevice import FritzhomeDevice + +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_TEMPERATURE, + ENERGY_KILO_WATT_HOUR, + PERCENTAGE, + POWER_WATT, + TEMP_CELSIUS, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import FritzBoxEntity -from .const import CONF_COORDINATOR, DOMAIN as FRITZBOX_DOMAIN, SENSOR_TYPES +from .const import CONF_COORDINATOR, DOMAIN as FRITZBOX_DOMAIN +from .model import FritzEntityDescriptionMixinBase + + +@dataclass +class FritzEntityDescriptionMixinSensor(FritzEntityDescriptionMixinBase): + """Sensor description mixin for Fritz!Smarthome entities.""" + + native_value: Callable[[FritzhomeDevice], float | int | None] + + +@dataclass +class FritzSensorEntityDescription( + SensorEntityDescription, FritzEntityDescriptionMixinSensor +): + """Description for Fritz!Smarthome sensor entities.""" + + +SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( + FritzSensorEntityDescription( + key="temperature", + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + suitable=lambda device: ( + device.has_temperature_sensor and not device.has_thermostat + ), + native_value=lambda device: device.temperature, # type: ignore[no-any-return] + ), + FritzSensorEntityDescription( + key="battery", + name="Battery", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_BATTERY, + suitable=lambda device: device.battery_level is not None, + native_value=lambda device: device.battery_level, # type: ignore[no-any-return] + ), + FritzSensorEntityDescription( + key="power_consumption", + name="Power Consumption", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + suitable=lambda device: device.has_powermeter, # type: ignore[no-any-return] + native_value=lambda device: device.power / 1000 if device.power else 0.0, + ), + FritzSensorEntityDescription( + key="total_energy", + name="Total Energy", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + suitable=lambda device: device.has_powermeter, # type: ignore[no-any-return] + native_value=lambda device: device.energy / 1000 if device.energy else 0.0, + ), +) async def async_setup_entry( From d41fa66bca5c15bba451179e9d0a2240f48d3a38 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 28 Aug 2021 18:30:07 -0500 Subject: [PATCH 069/843] Remove legacy discovery after_dependencies from apple_tv (#55390) - apple_tv devices are now discovered by zeroconf, and legacy discovery is no longer needed --- homeassistant/components/apple_tv/manifest.json | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/apple_tv/manifest.json b/homeassistant/components/apple_tv/manifest.json index a726e616641..1f3662b11d4 100644 --- a/homeassistant/components/apple_tv/manifest.json +++ b/homeassistant/components/apple_tv/manifest.json @@ -5,7 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/apple_tv", "requirements": ["pyatv==0.8.2"], "zeroconf": ["_mediaremotetv._tcp.local.", "_touch-able._tcp.local."], - "after_dependencies": ["discovery"], "codeowners": ["@postlund"], "iot_class": "local_push" } From 43288d3e1f88e37ae0b8fbed0991d523a4d310b8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 28 Aug 2021 18:30:20 -0500 Subject: [PATCH 070/843] Prevent storage loads from monopolizing the executor pool (#55389) * Prevent storage loads from monopolizing the executor pool - At startup there is an increasing demand to load data from storage. Similar to #49451 and #43085, we now prevent the thread pool from being monopolized by storage loads and allow other consumers that are doing network I/O to proceed without having to wait for a free executor thread. * Only create Semaphore instance when one is not already there --- homeassistant/helpers/storage.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/storage.py b/homeassistant/helpers/storage.py index 5700a7f854b..0d5e24b3b40 100644 --- a/homeassistant/helpers/storage.py +++ b/homeassistant/helpers/storage.py @@ -11,7 +11,7 @@ from typing import Any, Callable from homeassistant.const import EVENT_HOMEASSISTANT_FINAL_WRITE from homeassistant.core import CALLBACK_TYPE, CoreState, Event, HomeAssistant, callback from homeassistant.helpers.event import async_call_later -from homeassistant.loader import bind_hass +from homeassistant.loader import MAX_LOAD_CONCURRENTLY, bind_hass from homeassistant.util import json as json_util # mypy: allow-untyped-calls, allow-untyped-defs, no-warn-return-any @@ -20,6 +20,8 @@ from homeassistant.util import json as json_util STORAGE_DIR = ".storage" _LOGGER = logging.getLogger(__name__) +STORAGE_SEMAPHORE = "storage_semaphore" + @bind_hass async def async_migrator( @@ -109,8 +111,12 @@ class Store: async def _async_load(self): """Load the data and ensure the task is removed.""" + if STORAGE_SEMAPHORE not in self.hass.data: + self.hass.data[STORAGE_SEMAPHORE] = asyncio.Semaphore(MAX_LOAD_CONCURRENTLY) + try: - return await self._async_load_data() + async with self.hass.data[STORAGE_SEMAPHORE]: + return await self._async_load_data() finally: self._load_task = None From 291a2d625829d0469020631a6f136607e762ec7e Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sun, 29 Aug 2021 00:11:57 +0000 Subject: [PATCH 071/843] [ci skip] Translation update --- .../airvisual/translations/sensor.pl.json | 8 +++---- .../ambee/translations/sensor.pl.json | 8 +++---- .../binary_sensor/translations/pl.json | 10 ++++---- .../demo/translations/select.pl.json | 6 ++--- .../components/homekit/translations/he.json | 5 ++++ .../components/homekit/translations/pl.json | 2 +- .../input_number/translations/zh-Hans.json | 2 +- .../input_select/translations/zh-Hans.json | 2 +- .../components/mqtt/translations/he.json | 1 + .../components/mqtt/translations/pl.json | 1 + .../components/nanoleaf/translations/he.json | 23 +++++++++++++++++++ .../components/nanoleaf/translations/pl.json | 4 +++- .../components/netatmo/translations/pl.json | 4 ++-- .../number/translations/zh-Hans.json | 3 ++- .../components/openuv/translations/pl.json | 6 ++++- .../philips_js/translations/pl.json | 2 +- .../components/select/translations/pl.json | 4 ++-- .../select/translations/zh-Hans.json | 2 +- .../components/sensor/translations/pl.json | 22 ++++++++++-------- .../synology_dsm/translations/de.json | 3 +-- .../components/wemo/translations/pl.json | 2 +- .../components/zha/translations/pl.json | 3 ++- .../components/zwave_js/translations/pl.json | 18 +++++++-------- 23 files changed, 90 insertions(+), 51 deletions(-) create mode 100644 homeassistant/components/nanoleaf/translations/he.json diff --git a/homeassistant/components/airvisual/translations/sensor.pl.json b/homeassistant/components/airvisual/translations/sensor.pl.json index 48835f36f69..3ac9e2c2c28 100644 --- a/homeassistant/components/airvisual/translations/sensor.pl.json +++ b/homeassistant/components/airvisual/translations/sensor.pl.json @@ -1,12 +1,12 @@ { "state": { "airvisual__pollutant_label": { - "co": "Tlenek w\u0119gla", - "n2": "Dwutlenek azotu", - "o3": "Ozon", + "co": "tlenek w\u0119gla", + "n2": "dwutlenek azotu", + "o3": "ozon", "p1": "PM10", "p2": "PM2.5", - "s2": "Dwutlenek siarki" + "s2": "dwutlenek siarki" }, "airvisual__pollutant_level": { "good": "dobry", diff --git a/homeassistant/components/ambee/translations/sensor.pl.json b/homeassistant/components/ambee/translations/sensor.pl.json index 64d04cced48..d67bdec0879 100644 --- a/homeassistant/components/ambee/translations/sensor.pl.json +++ b/homeassistant/components/ambee/translations/sensor.pl.json @@ -1,10 +1,10 @@ { "state": { "ambee__risk": { - "high": "Wysoki", - "low": "Niski", - "moderate": "Umiarkowany", - "very high": "Bardzo wysoki" + "high": "wysoki", + "low": "niski", + "moderate": "umiarkowany", + "very high": "bardzo wysoki" } } } \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/pl.json b/homeassistant/components/binary_sensor/translations/pl.json index 6e6b272d869..7b89d566a63 100644 --- a/homeassistant/components/binary_sensor/translations/pl.json +++ b/homeassistant/components/binary_sensor/translations/pl.json @@ -17,7 +17,7 @@ "is_no_problem": "sensor {entity_name} nie wykrywa problemu", "is_no_smoke": "sensor {entity_name} nie wykrywa dymu", "is_no_sound": "sensor {entity_name} nie wykrywa d\u017awi\u0119ku", - "is_no_update": "{entity_name} jest aktualny(-a)", + "is_no_update": "dla {entity_name} nie ma dost\u0119pnej aktualizacji", "is_no_vibration": "sensor {entity_name} nie wykrywa wibracji", "is_not_bat_low": "bateria {entity_name} nie jest roz\u0142adowana", "is_not_cold": "sensor {entity_name} nie wykrywa zimna", @@ -43,7 +43,7 @@ "is_smoke": "sensor {entity_name} wykrywa dym", "is_sound": "sensor {entity_name} wykrywa d\u017awi\u0119k", "is_unsafe": "sensor {entity_name} wykrywa zagro\u017cenie", - "is_update": "{entity_name} ma dost\u0119pn\u0105 aktualizacj\u0119", + "is_update": "dla {entity_name} jest dost\u0119pna aktualizacja", "is_vibration": "sensor {entity_name} wykrywa wibracje" }, "trigger_type": { @@ -63,7 +63,7 @@ "no_problem": "sensor {entity_name} przestanie wykrywa\u0107 problem", "no_smoke": "sensor {entity_name} przestanie wykrywa\u0107 dym", "no_sound": "sensor {entity_name} przestanie wykrywa\u0107 d\u017awi\u0119k", - "no_update": "{entity_name} zosta\u0142 zaktualizowany(-a)", + "no_update": "wykonano aktualizacj\u0119 dla {entity_name}", "no_vibration": "sensor {entity_name} przestanie wykrywa\u0107 wibracje", "not_bat_low": "nast\u0105pi na\u0142adowanie baterii {entity_name}", "not_cold": "sensor {entity_name} przestanie wykrywa\u0107 zimno", @@ -183,8 +183,8 @@ "on": "wykryto" }, "update": { - "off": "Aktualny(-a)", - "on": "Dost\u0119pna aktualizacja" + "off": "brak aktualizacji", + "on": "dost\u0119pna aktualizacja" }, "vibration": { "off": "brak", diff --git a/homeassistant/components/demo/translations/select.pl.json b/homeassistant/components/demo/translations/select.pl.json index e90b2ccd0cb..276095d21fb 100644 --- a/homeassistant/components/demo/translations/select.pl.json +++ b/homeassistant/components/demo/translations/select.pl.json @@ -1,9 +1,9 @@ { "state": { "demo__speed": { - "light_speed": "Pr\u0119dko\u015b\u0107 \u015bwiat\u0142a", - "ludicrous_speed": "Absurdalna pr\u0119dko\u015b\u0107", - "ridiculous_speed": "Niewiarygodna pr\u0119dko\u015b\u0107" + "light_speed": "pr\u0119dko\u015b\u0107 \u015bwiat\u0142a", + "ludicrous_speed": "absurdalna pr\u0119dko\u015b\u0107", + "ridiculous_speed": "niewiarygodna pr\u0119dko\u015b\u0107" } } } \ No newline at end of file diff --git a/homeassistant/components/homekit/translations/he.json b/homeassistant/components/homekit/translations/he.json index 789298b7705..ee476b92b8c 100644 --- a/homeassistant/components/homekit/translations/he.json +++ b/homeassistant/components/homekit/translations/he.json @@ -10,6 +10,11 @@ }, "options": { "step": { + "advanced": { + "data": { + "devices": "\u05d4\u05ea\u05e7\u05e0\u05d9\u05dd (\u05d8\u05e8\u05d9\u05d2\u05e8\u05d9\u05dd)" + } + }, "include_exclude": { "data": { "mode": "\u05de\u05e6\u05d1" diff --git a/homeassistant/components/homekit/translations/pl.json b/homeassistant/components/homekit/translations/pl.json index cf415e4d735..300c730e66c 100644 --- a/homeassistant/components/homekit/translations/pl.json +++ b/homeassistant/components/homekit/translations/pl.json @@ -24,7 +24,7 @@ "auto_start": "Automatyczne uruchomienie (wy\u0142\u0105cz, je\u015bli r\u0119cznie uruchamiasz us\u0142ug\u0119 homekit.start)", "devices": "Urz\u0105dzenia (Wyzwalacze)" }, - "description": "Te ustawienia nale\u017cy dostosowa\u0107 tylko wtedy, gdy HomeKit nie dzia\u0142a.", + "description": "Dla ka\u017cdego wybranego urz\u0105dzenia stworzony zostanie programowalny prze\u0142\u0105cznik. Po uruchomieniu wyzwalacza urz\u0105dzenia, HomeKit mo\u017cna skonfigurowa\u0107 do uruchamiania automatyzacji lub sceny.", "title": "Konfiguracja zaawansowana" }, "cameras": { diff --git a/homeassistant/components/input_number/translations/zh-Hans.json b/homeassistant/components/input_number/translations/zh-Hans.json index b230db9fc60..4d976b841a8 100644 --- a/homeassistant/components/input_number/translations/zh-Hans.json +++ b/homeassistant/components/input_number/translations/zh-Hans.json @@ -1,3 +1,3 @@ { - "title": "\u6570\u503c\u9009\u62e9\u5668" + "title": "\u8f85\u52a9\u6570\u503c\u8f93\u5165\u5668" } \ No newline at end of file diff --git a/homeassistant/components/input_select/translations/zh-Hans.json b/homeassistant/components/input_select/translations/zh-Hans.json index 49380782fbd..365dadf0d09 100644 --- a/homeassistant/components/input_select/translations/zh-Hans.json +++ b/homeassistant/components/input_select/translations/zh-Hans.json @@ -1,3 +1,3 @@ { - "title": "\u591a\u9879\u9009\u62e9\u5668" + "title": "\u8f85\u52a9\u9009\u62e9\u5668" } \ No newline at end of file diff --git a/homeassistant/components/mqtt/translations/he.json b/homeassistant/components/mqtt/translations/he.json index f0c156b5fde..df987bd35a2 100644 --- a/homeassistant/components/mqtt/translations/he.json +++ b/homeassistant/components/mqtt/translations/he.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "\u05e9\u05d9\u05e8\u05d5\u05ea \u05d6\u05d4 \u05db\u05d1\u05e8 \u05de\u05d5\u05d2\u05d3\u05e8", "single_instance_allowed": "\u05ea\u05e6\u05d5\u05e8\u05ea\u05d5 \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e8\u05e7 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05d0\u05e4\u05e9\u05e8\u05d9\u05ea." }, "error": { diff --git a/homeassistant/components/mqtt/translations/pl.json b/homeassistant/components/mqtt/translations/pl.json index 2103cc2c441..8b57c465af1 100644 --- a/homeassistant/components/mqtt/translations/pl.json +++ b/homeassistant/components/mqtt/translations/pl.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "Us\u0142uga jest ju\u017c skonfigurowana", "single_instance_allowed": "Ju\u017c skonfigurowano. Mo\u017cliwa jest tylko jedna konfiguracja." }, "error": { diff --git a/homeassistant/components/nanoleaf/translations/he.json b/homeassistant/components/nanoleaf/translations/he.json new file mode 100644 index 00000000000..934fb6ab98a --- /dev/null +++ b/homeassistant/components/nanoleaf/translations/he.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_token": "\u05d0\u05e1\u05d9\u05de\u05d5\u05df \u05d2\u05d9\u05e9\u05d4 \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "reauth_successful": "\u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nanoleaf/translations/pl.json b/homeassistant/components/nanoleaf/translations/pl.json index fdf7e9a75b4..1c772fa940c 100644 --- a/homeassistant/components/nanoleaf/translations/pl.json +++ b/homeassistant/components/nanoleaf/translations/pl.json @@ -9,12 +9,14 @@ }, "error": { "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "not_allowing_new_tokens": "Nanoleaf nie zezwala na nowe tokeny, post\u0119puj zgodnie z powy\u017cszymi instrukcjami.", "unknown": "Nieoczekiwany b\u0142\u0105d" }, "flow_title": "{name}", "step": { "link": { - "title": "Po\u0142\u0105cz Nanoleaf" + "description": "Naci\u015bnij i przytrzymaj przycisk zasilania na Nanoleaf przez 5 sekund, a\u017c dioda LED przycisku zacznie miga\u0107, a nast\u0119pnie kliknij **WY\u015aLIJ** w ci\u0105gu 30 sekund.", + "title": "Po\u0142\u0105czenie z Nanoleaf" }, "user": { "data": { diff --git a/homeassistant/components/netatmo/translations/pl.json b/homeassistant/components/netatmo/translations/pl.json index 449e09bfa3a..58dfb34f7fa 100644 --- a/homeassistant/components/netatmo/translations/pl.json +++ b/homeassistant/components/netatmo/translations/pl.json @@ -30,8 +30,8 @@ "outdoor": "{entity_name} wykryje zdarzenie zewn\u0119trzne", "person": "{entity_name} wykryje osob\u0119", "person_away": "{entity_name} wykryje, \u017ce osoba wysz\u0142a", - "set_point": "temperatura docelowa {entity_name} zosta\u0142a ustawiona r\u0119cznie", - "therm_mode": "{entity_name} prze\u0142\u0105czy\u0142(a) si\u0119 na \u201e{subtype}\u201d", + "set_point": "temperatura docelowa {entity_name} zostanie ustawiona r\u0119cznie", + "therm_mode": "{entity_name} prze\u0142\u0105czy si\u0119 na \"{subtype}\"", "turned_off": "{entity_name} zostanie wy\u0142\u0105czony", "turned_on": "{entity_name} zostanie w\u0142\u0105czony", "vehicle": "{entity_name} wykryje pojazd" diff --git a/homeassistant/components/number/translations/zh-Hans.json b/homeassistant/components/number/translations/zh-Hans.json index de9720ed77a..de50170743e 100644 --- a/homeassistant/components/number/translations/zh-Hans.json +++ b/homeassistant/components/number/translations/zh-Hans.json @@ -3,5 +3,6 @@ "action_type": { "set_value": "\u8bbe\u7f6e {entity_name} \u7684\u503c" } - } + }, + "title": "\u6570\u503c\u8f93\u5165\u5668" } \ No newline at end of file diff --git a/homeassistant/components/openuv/translations/pl.json b/homeassistant/components/openuv/translations/pl.json index ad7f99fcdc3..6aff15beef1 100644 --- a/homeassistant/components/openuv/translations/pl.json +++ b/homeassistant/components/openuv/translations/pl.json @@ -21,7 +21,11 @@ "options": { "step": { "init": { - "title": "Skonfiguruj OpenUV" + "data": { + "from_window": "Pocz\u0105tkowy indeks UV", + "to_window": "Ko\u0144cowy indeks UV" + }, + "title": "Konfiguracja OpenUV" } } } diff --git a/homeassistant/components/philips_js/translations/pl.json b/homeassistant/components/philips_js/translations/pl.json index fce4ac34c83..7c1ef4b1b9e 100644 --- a/homeassistant/components/philips_js/translations/pl.json +++ b/homeassistant/components/philips_js/translations/pl.json @@ -27,7 +27,7 @@ }, "device_automation": { "trigger_type": { - "turn_on": "Urz\u0105dzenie zostanie poproszone o w\u0142\u0105czenie" + "turn_on": "urz\u0105dzenie zostanie poproszone o w\u0142\u0105czenie" } }, "options": { diff --git a/homeassistant/components/select/translations/pl.json b/homeassistant/components/select/translations/pl.json index 102cfa68534..3d19c05a80a 100644 --- a/homeassistant/components/select/translations/pl.json +++ b/homeassistant/components/select/translations/pl.json @@ -4,10 +4,10 @@ "select_option": "Zmie\u0144 opcj\u0119 {entity_name}" }, "condition_type": { - "selected_option": "Aktualnie wybrana opcja dla {entity_name}" + "selected_option": "aktualnie wybrana opcja dla {entity_name}" }, "trigger_type": { - "current_option_changed": "Zmieniono opcj\u0119 {entity_name}" + "current_option_changed": "zmieniono opcj\u0119 {entity_name}" } }, "title": "Wybierz" diff --git a/homeassistant/components/select/translations/zh-Hans.json b/homeassistant/components/select/translations/zh-Hans.json index 4857f798f3b..668d85bb2b0 100644 --- a/homeassistant/components/select/translations/zh-Hans.json +++ b/homeassistant/components/select/translations/zh-Hans.json @@ -10,5 +10,5 @@ "current_option_changed": "{entity_name} \u7684\u9009\u9879\u53d8\u5316" } }, - "title": "\u9009\u5b9a" + "title": "\u9009\u62e9\u5668" } \ No newline at end of file diff --git a/homeassistant/components/sensor/translations/pl.json b/homeassistant/components/sensor/translations/pl.json index 2a82919e42e..def1be5e06d 100644 --- a/homeassistant/components/sensor/translations/pl.json +++ b/homeassistant/components/sensor/translations/pl.json @@ -23,31 +23,33 @@ "is_sulphur_dioxide": "obecny poziom st\u0119\u017cenia dwutlenku siarki {entity_name}", "is_temperature": "obecna temperatura {entity_name}", "is_value": "obecna warto\u015b\u0107 {entity_name}", + "is_volatile_organic_compounds": "obecny poziom st\u0119\u017cenia lotnych zwi\u0105zk\u00f3w organicznych {entity_name}", "is_voltage": "obecne napi\u0119cie {entity_name}" }, "trigger_type": { "battery_level": "zmieni si\u0119 poziom baterii {entity_name}", - "carbon_dioxide": "Zmiana st\u0119\u017cenie dwutlenku w\u0119gla w {entity_name}", - "carbon_monoxide": "Zmiana st\u0119\u017cenia tlenku w\u0119gla w {entity_name}", + "carbon_dioxide": "{entity_name} wykryje zmian\u0119 st\u0119\u017cenia dwutlenku w\u0119gla", + "carbon_monoxide": "{entity_name} wykryje zmian\u0119 st\u0119\u017cenia tlenku w\u0119gla", "current": "zmieni si\u0119 nat\u0119\u017cenie pr\u0105du w {entity_name}", "energy": "zmieni si\u0119 energia {entity_name}", - "gas": "zmieni si\u0119 poziom gazu w {entity_name}", + "gas": "{entity_name} wykryje zmian\u0119 poziomu gazu", "humidity": "zmieni si\u0119 wilgotno\u015b\u0107 {entity_name}", "illuminance": "zmieni si\u0119 nat\u0119\u017cenie o\u015bwietlenia {entity_name}", "nitrogen_dioxide": "zmieni si\u0119 st\u0119\u017cenie dwutlenku azotu w {entity_name}", - "nitrogen_monoxide": "zmieni si\u0119 st\u0119\u017cenie tlenku azotu w {entity_name}", - "nitrous_oxide": "zmieni si\u0119 st\u0119\u017cenie podtlenku azotu w {entity_name}", - "ozone": "zmieni si\u0119 st\u0119\u017cenie ozonu w {entity_name}", - "pm1": "zmieni si\u0119 st\u0119\u017cenie PM1 w {entity_name}", - "pm10": "zmieni si\u0119 st\u0119\u017cenie PM10 w {entity_name}", - "pm25": "zmieni si\u0119 st\u0119\u017cenie PM2.5 w {entity_name}", + "nitrogen_monoxide": "{entity_name} wykryje zmian\u0119 st\u0119\u017cenia tlenku azotu", + "nitrous_oxide": "{entity_name} wykryje zmian\u0119 st\u0119\u017cenia podtlenku azotu", + "ozone": "{entity_name} wykryje zmian\u0119 st\u0119\u017cenia ozonu", + "pm1": "{entity_name} wykryje zmian\u0119 st\u0119\u017cenia PM1", + "pm10": "{entity_name} wykryje zmian\u0119 st\u0119\u017cenia PM10", + "pm25": "{entity_name} wykryje zmian\u0119 st\u0119\u017cenia PM2.5", "power": "zmieni si\u0119 moc {entity_name}", "power_factor": "zmieni si\u0119 wsp\u00f3\u0142czynnik mocy w {entity_name}", "pressure": "zmieni si\u0119 ci\u015bnienie {entity_name}", "signal_strength": "zmieni si\u0119 si\u0142a sygna\u0142u {entity_name}", - "sulphur_dioxide": "zmieni si\u0119 st\u0119\u017cenie dwutlenku siarki w {entity_name}", + "sulphur_dioxide": "{entity_name} wykryje zmian\u0119 st\u0119\u017cenia dwutlenku siarki", "temperature": "zmieni si\u0119 temperatura {entity_name}", "value": "zmieni si\u0119 warto\u015b\u0107 {entity_name}", + "volatile_organic_compounds": "{entity_name} wykryje zmian\u0119 st\u0119\u017cenia lotnych zwi\u0105zk\u00f3w organicznych", "voltage": "zmieni si\u0119 napi\u0119cie w {entity_name}" } }, diff --git a/homeassistant/components/synology_dsm/translations/de.json b/homeassistant/components/synology_dsm/translations/de.json index aca87d46d70..86c154e8567 100644 --- a/homeassistant/components/synology_dsm/translations/de.json +++ b/homeassistant/components/synology_dsm/translations/de.json @@ -2,8 +2,7 @@ "config": { "abort": { "already_configured": "Ger\u00e4t ist bereits konfiguriert", - "reauth_successful": "Die erneute Authentifizierung war erfolgreich", - "reconfigure_successful": "Die Anpassung der Konfiguration war erfolgreich" + "reauth_successful": "Die erneute Authentifizierung war erfolgreich" }, "error": { "cannot_connect": "Verbindung fehlgeschlagen", diff --git a/homeassistant/components/wemo/translations/pl.json b/homeassistant/components/wemo/translations/pl.json index d8b06f79e48..db308b52ace 100644 --- a/homeassistant/components/wemo/translations/pl.json +++ b/homeassistant/components/wemo/translations/pl.json @@ -12,7 +12,7 @@ }, "device_automation": { "trigger_type": { - "long_press": "Przycisk Wemo zosta\u0142 wci\u015bni\u0119ty przez 2 sekundy" + "long_press": "przycisk Wemo zostanie przytrzymany przez 2 sekundy" } } } \ No newline at end of file diff --git a/homeassistant/components/zha/translations/pl.json b/homeassistant/components/zha/translations/pl.json index 40a5257335f..6c67c6aea93 100644 --- a/homeassistant/components/zha/translations/pl.json +++ b/homeassistant/components/zha/translations/pl.json @@ -2,7 +2,8 @@ "config": { "abort": { "not_zha_device": "To urz\u0105dzenie nie jest urz\u0105dzeniem zha", - "single_instance_allowed": "Ju\u017c skonfigurowano. Mo\u017cliwa jest tylko jedna konfiguracja." + "single_instance_allowed": "Ju\u017c skonfigurowano. Mo\u017cliwa jest tylko jedna konfiguracja.", + "usb_probe_failed": "Nie uda\u0142o si\u0119 sondowa\u0107 urz\u0105dzenia USB" }, "error": { "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia" diff --git a/homeassistant/components/zwave_js/translations/pl.json b/homeassistant/components/zwave_js/translations/pl.json index bd842cb1359..0ae905a0854 100644 --- a/homeassistant/components/zwave_js/translations/pl.json +++ b/homeassistant/components/zwave_js/translations/pl.json @@ -59,17 +59,17 @@ }, "device_automation": { "condition_type": { - "config_parameter": "Warto\u015b\u0107 parametru jest {subtype}", - "node_status": "Stan w\u0119z\u0142a", - "value": "Aktualna warto\u015b\u0107 warto\u015bci Z-Wave" + "config_parameter": "warto\u015b\u0107 parametru jest {subtype}", + "node_status": "stan w\u0119z\u0142a", + "value": "aktualna warto\u015b\u0107 Z-Wave" }, "trigger_type": { - "event.notification.entry_control": "Wys\u0142ano powiadomienie kontroli wpisu", - "event.notification.notification": "Wys\u0142ano powiadomienie", - "event.value_notification.basic": "Podstawowe wydarzenie CC na {subtype}", - "event.value_notification.central_scene": "Akcja sceny centralnej na {subtype}", - "event.value_notification.scene_activation": "Aktywacja sceny na {subtype}", - "state.node_status": "Zmieni\u0142 si\u0119 stan w\u0119z\u0142a", + "event.notification.entry_control": "zostanie wys\u0142ane powiadomienie kontroli wpisu", + "event.notification.notification": "zostanie wys\u0142ane powiadomienie", + "event.value_notification.basic": "wyst\u0105pi podstawowe wydarzenie CC na {subtype}", + "event.value_notification.central_scene": "wyst\u0105pi akcja sceny centralnej na {subtype}", + "event.value_notification.scene_activation": "zostanie aktywowana scena na {subtype}", + "state.node_status": "zmieni si\u0119 stan w\u0119z\u0142a", "zwave_js.value_updated.config_parameter": "zmieni si\u0119 warto\u015b\u0107 parametru konfiguracji {subtype}", "zwave_js.value_updated.value": "zmieni si\u0119 warto\u015b\u0107 na Z-Wave JS" } From 923158cfbab1dad666208bae29c990cef71d1b6a Mon Sep 17 00:00:00 2001 From: uvjustin <46082645+uvjustin@users.noreply.github.com> Date: Sun, 29 Aug 2021 09:53:41 +0800 Subject: [PATCH 072/843] Add ll hls to stream (#49608) --- homeassistant/components/stream/__init__.py | 62 +- homeassistant/components/stream/const.py | 16 +- homeassistant/components/stream/core.py | 204 +++++- homeassistant/components/stream/hls.py | 366 ++++++++-- homeassistant/components/stream/recorder.py | 2 +- homeassistant/components/stream/worker.py | 108 ++- tests/components/stream/common.py | 18 +- tests/components/stream/conftest.py | 95 ++- tests/components/stream/test_hls.py | 117 ++-- tests/components/stream/test_ll_hls.py | 731 ++++++++++++++++++++ tests/components/stream/test_recorder.py | 19 +- tests/components/stream/test_worker.py | 74 +- 12 files changed, 1605 insertions(+), 207 deletions(-) create mode 100644 tests/components/stream/test_ll_hls.py diff --git a/homeassistant/components/stream/__init__.py b/homeassistant/components/stream/__init__.py index c7ca853c20c..1d3a46d0273 100644 --- a/homeassistant/components/stream/__init__.py +++ b/homeassistant/components/stream/__init__.py @@ -25,24 +25,33 @@ import time from types import MappingProxyType from typing import cast +import voluptuous as vol + from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import ( ATTR_ENDPOINTS, + ATTR_SETTINGS, ATTR_STREAMS, + CONF_LL_HLS, + CONF_PART_DURATION, + CONF_SEGMENT_DURATION, DOMAIN, HLS_PROVIDER, MAX_SEGMENTS, OUTPUT_IDLE_TIMEOUT, RECORDER_PROVIDER, + SEGMENT_DURATION_ADJUSTER, STREAM_RESTART_INCREMENT, STREAM_RESTART_RESET_TIME, + TARGET_SEGMENT_DURATION_NON_LL_HLS, ) -from .core import PROVIDERS, IdleTimer, StreamOutput -from .hls import async_setup_hls +from .core import PROVIDERS, IdleTimer, StreamOutput, StreamSettings +from .hls import HlsStreamOutput, async_setup_hls _LOGGER = logging.getLogger(__name__) @@ -78,6 +87,24 @@ def create_stream( return stream +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Optional(CONF_LL_HLS, default=False): cv.boolean, + vol.Optional(CONF_SEGMENT_DURATION, default=6): vol.All( + cv.positive_float, vol.Range(min=2, max=10) + ), + vol.Optional(CONF_PART_DURATION, default=1): vol.All( + cv.positive_float, vol.Range(min=0.2, max=1.5) + ), + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up stream.""" # Set log level to error for libav @@ -91,6 +118,26 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.data[DOMAIN] = {} hass.data[DOMAIN][ATTR_ENDPOINTS] = {} hass.data[DOMAIN][ATTR_STREAMS] = [] + if (conf := config.get(DOMAIN)) and conf[CONF_LL_HLS]: + assert isinstance(conf[CONF_SEGMENT_DURATION], float) + assert isinstance(conf[CONF_PART_DURATION], float) + hass.data[DOMAIN][ATTR_SETTINGS] = StreamSettings( + ll_hls=True, + min_segment_duration=conf[CONF_SEGMENT_DURATION] + - SEGMENT_DURATION_ADJUSTER, + part_target_duration=conf[CONF_PART_DURATION], + hls_advance_part_limit=max(int(3 / conf[CONF_PART_DURATION]), 3), + hls_part_timeout=2 * conf[CONF_PART_DURATION], + ) + else: + hass.data[DOMAIN][ATTR_SETTINGS] = StreamSettings( + ll_hls=False, + min_segment_duration=TARGET_SEGMENT_DURATION_NON_LL_HLS + - SEGMENT_DURATION_ADJUSTER, + part_target_duration=TARGET_SEGMENT_DURATION_NON_LL_HLS, + hls_advance_part_limit=3, + hls_part_timeout=TARGET_SEGMENT_DURATION_NON_LL_HLS, + ) # Setup HLS hls_endpoint = async_setup_hls(hass) @@ -206,11 +253,16 @@ class Stream: # pylint: disable=import-outside-toplevel from .worker import SegmentBuffer, stream_worker - segment_buffer = SegmentBuffer(self.outputs) + segment_buffer = SegmentBuffer(self.hass, self.outputs) wait_timeout = 0 while not self._thread_quit.wait(timeout=wait_timeout): start_time = time.time() - stream_worker(self.source, self.options, segment_buffer, self._thread_quit) + stream_worker( + self.source, + self.options, + segment_buffer, + self._thread_quit, + ) segment_buffer.discontinuity() if not self.keepalive or self._thread_quit.is_set(): if self._fast_restart_once: @@ -288,7 +340,7 @@ class Stream: _LOGGER.debug("Started a stream recording of %s seconds", duration) # Take advantage of lookback - hls = self.outputs().get(HLS_PROVIDER) + hls: HlsStreamOutput = cast(HlsStreamOutput, self.outputs().get(HLS_PROVIDER)) if lookback > 0 and hls: num_segments = min(int(lookback // hls.target_duration), MAX_SEGMENTS) # Wait for latest segment, then add the lookback diff --git a/homeassistant/components/stream/const.py b/homeassistant/components/stream/const.py index cf4a80d9705..50ae43df0d0 100644 --- a/homeassistant/components/stream/const.py +++ b/homeassistant/components/stream/const.py @@ -2,6 +2,7 @@ DOMAIN = "stream" ATTR_ENDPOINTS = "endpoints" +ATTR_SETTINGS = "settings" ATTR_STREAMS = "streams" HLS_PROVIDER = "hls" @@ -19,16 +20,15 @@ OUTPUT_IDLE_TIMEOUT = 300 # Idle timeout due to inactivity NUM_PLAYLIST_SEGMENTS = 3 # Number of segments to use in HLS playlist MAX_SEGMENTS = 5 # Max number of segments to keep around -TARGET_SEGMENT_DURATION = 2.0 # Each segment is about this many seconds -TARGET_PART_DURATION = 1.0 +TARGET_SEGMENT_DURATION_NON_LL_HLS = 2.0 # Each segment is about this many seconds SEGMENT_DURATION_ADJUSTER = 0.1 # Used to avoid missing keyframe boundaries -# Each segment is at least this many seconds -MIN_SEGMENT_DURATION = TARGET_SEGMENT_DURATION - SEGMENT_DURATION_ADJUSTER - # Number of target durations to start before the end of the playlist. # 1.5 should put us in the middle of the second to last segment even with # variable keyframe intervals. -EXT_X_START = 1.5 +EXT_X_START_NON_LL_HLS = 1.5 +# Number of part durations to start before the end of the playlist with LL-HLS +EXT_X_START_LL_HLS = 2 + PACKETS_TO_WAIT_FOR_AUDIO = 20 # Some streams have an audio stream with no audio MAX_TIMESTAMP_GAP = 10000 # seconds - anything from 10 to 50000 is probably reasonable @@ -38,3 +38,7 @@ SOURCE_TIMEOUT = 30 # Timeout for reading stream source STREAM_RESTART_INCREMENT = 10 # Increase wait_timeout by this amount each retry STREAM_RESTART_RESET_TIME = 300 # Reset wait_timeout after this many seconds + +CONF_LL_HLS = "ll_hls" +CONF_PART_DURATION = "part_duration" +CONF_SEGMENT_DURATION = "segment_duration" diff --git a/homeassistant/components/stream/core.py b/homeassistant/components/stream/core.py index d840bfaf858..77e41511b92 100644 --- a/homeassistant/components/stream/core.py +++ b/homeassistant/components/stream/core.py @@ -3,10 +3,13 @@ from __future__ import annotations import asyncio from collections import deque +from collections.abc import Generator, Iterable import datetime +import itertools from typing import TYPE_CHECKING from aiohttp import web +import async_timeout import attr from homeassistant.components.http.view import HomeAssistantView @@ -14,7 +17,7 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.event import async_call_later from homeassistant.util.decorator import Registry -from .const import ATTR_STREAMS, DOMAIN, TARGET_SEGMENT_DURATION +from .const import ATTR_STREAMS, DOMAIN if TYPE_CHECKING: from . import Stream @@ -22,6 +25,17 @@ if TYPE_CHECKING: PROVIDERS = Registry() +@attr.s(slots=True) +class StreamSettings: + """Stream settings.""" + + ll_hls: bool = attr.ib() + min_segment_duration: float = attr.ib() + part_target_duration: float = attr.ib() + hls_advance_part_limit: int = attr.ib() + hls_part_timeout: float = attr.ib() + + @attr.s(slots=True) class Part: """Represent a segment part.""" @@ -36,23 +50,170 @@ class Part: class Segment: """Represent a segment.""" - sequence: int = attr.ib(default=0) + sequence: int = attr.ib() # the init of the mp4 the segment is based on - init: bytes = attr.ib(default=None) - duration: float = attr.ib(default=0) + init: bytes = attr.ib() # For detecting discontinuities across stream restarts - stream_id: int = attr.ib(default=0) - parts: list[Part] = attr.ib(factory=list) - start_time: datetime.datetime = attr.ib(factory=datetime.datetime.utcnow) + stream_id: int = attr.ib() + start_time: datetime.datetime = attr.ib() + _stream_outputs: Iterable[StreamOutput] = attr.ib() + duration: float = attr.ib(default=0) + # Parts are stored in a dict indexed by byterange for easy lookup + # As of Python 3.7, insertion order is preserved, and we insert + # in sequential order, so the Parts are ordered + parts_by_byterange: dict[int, Part] = attr.ib(factory=dict) + # Store text of this segment's hls playlist for reuse + # Use list[str] for easy appends + hls_playlist_template: list[str] = attr.ib(factory=list) + hls_playlist_parts: list[str] = attr.ib(factory=list) + # Number of playlist parts rendered so far + hls_num_parts_rendered: int = attr.ib(default=0) + # Set to true when all the parts are rendered + hls_playlist_complete: bool = attr.ib(default=False) + + def __attrs_post_init__(self) -> None: + """Run after init.""" + for output in self._stream_outputs: + output.put(self) @property def complete(self) -> bool: """Return whether the Segment is complete.""" return self.duration > 0 - def get_bytes_without_init(self) -> bytes: + @property + def data_size_with_init(self) -> int: + """Return the size of all part data + init in bytes.""" + return len(self.init) + self.data_size + + @property + def data_size(self) -> int: + """Return the size of all part data without init in bytes.""" + # We can use the last part to quickly calculate the total data size. + if not self.parts_by_byterange: + return 0 + last_http_range_start, last_part = next( + reversed(self.parts_by_byterange.items()) + ) + return last_http_range_start + len(last_part.data) + + @callback + def async_add_part( + self, + part: Part, + duration: float, + ) -> None: + """Add a part to the Segment. + + Duration is non zero only for the last part. + """ + self.parts_by_byterange[self.data_size] = part + self.duration = duration + for output in self._stream_outputs: + output.part_put() + + def get_data(self) -> bytes: """Return reconstructed data for all parts as bytes, without init.""" - return b"".join([part.data for part in self.parts]) + return b"".join([part.data for part in self.parts_by_byterange.values()]) + + def get_aggregating_bytes( + self, start_loc: int, end_loc: int | float + ) -> Generator[bytes, None, None]: + """Yield available remaining data until segment is complete or end_loc is reached. + + Begin at start_loc. End at end_loc (exclusive). + Used to help serve a range request on a segment. + """ + pos = start_loc + while (part := self.parts_by_byterange.get(pos)) or not self.complete: + if not part: + yield b"" + continue + pos += len(part.data) + # Check stopping condition and trim output if necessary + if pos >= end_loc: + assert isinstance(end_loc, int) + # Trimming is probably not necessary, but it doesn't hurt + yield part.data[: len(part.data) + end_loc - pos] + return + yield part.data + + def _render_hls_template(self, last_stream_id: int, render_parts: bool) -> str: + """Render the HLS playlist section for the Segment. + + The Segment may still be in progress. + This method stores intermediate data in hls_playlist_parts, hls_num_parts_rendered, + and hls_playlist_complete to avoid redoing work on subsequent calls. + """ + if self.hls_playlist_complete: + return self.hls_playlist_template[0] + if not self.hls_playlist_template: + # This is a placeholder where the rendered parts will be inserted + self.hls_playlist_template.append("{}") + if render_parts: + for http_range_start, part in itertools.islice( + self.parts_by_byterange.items(), + self.hls_num_parts_rendered, + None, + ): + self.hls_playlist_parts.append( + f"#EXT-X-PART:DURATION={part.duration:.3f},URI=" + f'"./segment/{self.sequence}.m4s",BYTERANGE="{len(part.data)}' + f'@{http_range_start}"{",INDEPENDENT=YES" if part.has_keyframe else ""}' + ) + if self.complete: + # Construct the final playlist_template. The placeholder will share a line with + # the first element to avoid an extra newline when we don't render any parts. + # Append an empty string to create a trailing newline when we do render parts + self.hls_playlist_parts.append("") + self.hls_playlist_template = [] + # Logically EXT-X-DISCONTINUITY would make sense above the parts, but Apple's + # media stream validator seems to only want it before the segment + if last_stream_id != self.stream_id: + self.hls_playlist_template.append("#EXT-X-DISCONTINUITY") + # Add the remaining segment metadata + self.hls_playlist_template.extend( + [ + "#EXT-X-PROGRAM-DATE-TIME:" + + self.start_time.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + + "Z", + f"#EXTINF:{self.duration:.3f},\n./segment/{self.sequence}.m4s", + ] + ) + # The placeholder now goes on the same line as the first element + self.hls_playlist_template[0] = "{}" + self.hls_playlist_template[0] + + # Store intermediate playlist data in member variables for reuse + self.hls_playlist_template = ["\n".join(self.hls_playlist_template)] + # lstrip discards extra preceding newline in case first render was empty + self.hls_playlist_parts = ["\n".join(self.hls_playlist_parts).lstrip()] + self.hls_num_parts_rendered = len(self.parts_by_byterange) + self.hls_playlist_complete = self.complete + + return self.hls_playlist_template[0] + + def render_hls( + self, last_stream_id: int, render_parts: bool, add_hint: bool + ) -> str: + """Render the HLS playlist section for the Segment including a hint if requested.""" + playlist_template = self._render_hls_template(last_stream_id, render_parts) + playlist = playlist_template.format( + self.hls_playlist_parts[0] if render_parts else "" + ) + if not add_hint: + return playlist + # Preload hints help save round trips by informing the client about the next part. + # The next part will usually be in this segment but will be first part of the next + # segment if this segment is already complete. + # pylint: disable=undefined-loop-variable + if self.complete: # Next part belongs to next segment + sequence = self.sequence + 1 + start = 0 + else: # Next part is in the same segment + sequence = self.sequence + start = self.data_size + hint = f'#EXT-X-PRELOAD-HINT:TYPE=PART,URI="./segment/{sequence}.m4s",BYTERANGE-START={start}' + return (playlist + "\n" + hint) if playlist else hint class IdleTimer: @@ -110,6 +271,7 @@ class StreamOutput: self._hass = hass self.idle_timer = idle_timer self._event = asyncio.Event() + self._part_event = asyncio.Event() self._segments: deque[Segment] = deque(maxlen=deque_maxlen) @property @@ -141,13 +303,6 @@ class StreamOutput: return self._segments[-1] return None - @property - def target_duration(self) -> float: - """Return the max duration of any given segment in seconds.""" - if not (durations := [s.duration for s in self._segments if s.complete]): - return TARGET_SEGMENT_DURATION - return max(durations) - def get_segment(self, sequence: int) -> Segment | None: """Retrieve a specific segment.""" # Most hits will come in the most recent segments, so iterate reversed @@ -160,8 +315,23 @@ class StreamOutput: """Retrieve all segments.""" return self._segments + async def part_recv(self, timeout: float | None = None) -> bool: + """Wait for an event signalling the latest part segment.""" + try: + async with async_timeout.timeout(timeout): + await self._part_event.wait() + except asyncio.TimeoutError: + return False + return True + + def part_put(self) -> None: + """Set event signalling the latest part segment.""" + # Start idle timeout when we start receiving data + self._part_event.set() + self._part_event.clear() + async def recv(self) -> bool: - """Wait for and retrieve the latest segment.""" + """Wait for the latest segment.""" await self._event.wait() return self.last_segment is not None diff --git a/homeassistant/components/stream/hls.py b/homeassistant/components/stream/hls.py index 7f11bc09655..9b154e9236b 100644 --- a/homeassistant/components/stream/hls.py +++ b/homeassistant/components/stream/hls.py @@ -1,25 +1,31 @@ """Provide functionality to stream HLS.""" from __future__ import annotations -from typing import TYPE_CHECKING +import logging +from typing import TYPE_CHECKING, cast from aiohttp import web from homeassistant.core import HomeAssistant, callback from .const import ( - EXT_X_START, + ATTR_SETTINGS, + DOMAIN, + EXT_X_START_LL_HLS, + EXT_X_START_NON_LL_HLS, FORMAT_CONTENT_TYPE, HLS_PROVIDER, MAX_SEGMENTS, NUM_PLAYLIST_SEGMENTS, ) -from .core import PROVIDERS, IdleTimer, StreamOutput, StreamView +from .core import PROVIDERS, IdleTimer, StreamOutput, StreamSettings, StreamView from .fmp4utils import get_codec_string if TYPE_CHECKING: from . import Stream +_LOGGER = logging.getLogger(__name__) + @callback def async_setup_hls(hass: HomeAssistant) -> str: @@ -31,6 +37,38 @@ def async_setup_hls(hass: HomeAssistant) -> str: return "/api/hls/{}/master_playlist.m3u8" +@PROVIDERS.register(HLS_PROVIDER) +class HlsStreamOutput(StreamOutput): + """Represents HLS Output formats.""" + + def __init__(self, hass: HomeAssistant, idle_timer: IdleTimer) -> None: + """Initialize HLS output.""" + super().__init__(hass, idle_timer, deque_maxlen=MAX_SEGMENTS) + self.stream_settings: StreamSettings = hass.data[DOMAIN][ATTR_SETTINGS] + self._target_duration = 0.0 + + @property + def name(self) -> str: + """Return provider name.""" + return HLS_PROVIDER + + @property + def target_duration(self) -> float: + """ + Return the target duration. + + The target duration is calculated as the max duration of any given segment, + and it is calculated only one time to avoid changing during playback. + """ + if self._target_duration: + return self._target_duration + durations = [s.duration for s in self._segments if s.complete] + if len(durations) < 2: + return self.stream_settings.min_segment_duration + self._target_duration = max(durations) + return self._target_duration + + class HlsMasterPlaylistView(StreamView): """Stream view used only for Chromecast compatibility.""" @@ -46,12 +84,7 @@ class HlsMasterPlaylistView(StreamView): # hls spec already allows for 25% variation if not (segment := track.get_segment(track.sequences[-2])): return "" - bandwidth = round( - (len(segment.init) + sum(len(part.data) for part in segment.parts)) - * 8 - / segment.duration - * 1.2 - ) + bandwidth = round(segment.data_size_with_init * 8 / segment.duration * 1.2) codecs = get_codec_string(segment.init) lines = [ "#EXTM3U", @@ -71,8 +104,14 @@ class HlsMasterPlaylistView(StreamView): return web.HTTPNotFound() if len(track.sequences) == 1 and not await track.recv(): return web.HTTPNotFound() - headers = {"Content-Type": FORMAT_CONTENT_TYPE[HLS_PROVIDER]} - return web.Response(body=self.render(track).encode("utf-8"), headers=headers) + response = web.Response( + body=self.render(track).encode("utf-8"), + headers={ + "Content-Type": FORMAT_CONTENT_TYPE[HLS_PROVIDER], + }, + ) + response.enable_compression(web.ContentCoding.gzip) + return response class HlsPlaylistView(StreamView): @@ -82,9 +121,9 @@ class HlsPlaylistView(StreamView): name = "api:stream:hls:playlist" cors_allowed = True - @staticmethod - def render(track: StreamOutput) -> str: - """Render playlist.""" + @classmethod + def render(cls, track: HlsStreamOutput) -> str: + """Render HLS playlist file.""" # NUM_PLAYLIST_SEGMENTS+1 because most recent is probably not yet complete segments = list(track.get_segments())[-(NUM_PLAYLIST_SEGMENTS + 1) :] @@ -102,9 +141,17 @@ class HlsPlaylistView(StreamView): f"#EXT-X-TARGETDURATION:{track.target_duration:.0f}", f"#EXT-X-MEDIA-SEQUENCE:{first_segment.sequence}", f"#EXT-X-DISCONTINUITY-SEQUENCE:{first_segment.stream_id}", - "#EXT-X-PROGRAM-DATE-TIME:" - + first_segment.start_time.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] - + "Z", + ] + + if track.stream_settings.ll_hls: + playlist.extend( + [ + f"#EXT-X-PART-INF:PART-TARGET={track.stream_settings.part_target_duration:.3f}", + f"#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK={2*track.stream_settings.part_target_duration:.3f}", + f"#EXT-X-START:TIME-OFFSET=-{EXT_X_START_LL_HLS*track.stream_settings.part_target_duration:.3f},PRECISE=YES", + ] + ) + else: # Since our window doesn't have many segments, we don't want to start # at the beginning or we risk a behind live window exception in Exoplayer. # EXT-X-START is not supposed to be within 3 target durations of the end, @@ -113,47 +160,147 @@ class HlsPlaylistView(StreamView): # don't autoplay. Also, hls.js uses the player parameter liveSyncDuration # which seems to take precedence for setting target delay. Yet it also # doesn't seem to hurt, so we can stick with it for now. - f"#EXT-X-START:TIME-OFFSET=-{EXT_X_START * track.target_duration:.3f}", - ] + playlist.append( + f"#EXT-X-START:TIME-OFFSET=-{EXT_X_START_NON_LL_HLS*track.target_duration:.3f},PRECISE=YES" + ) last_stream_id = first_segment.stream_id - # Add playlist sections - for segment in segments: - # Skip last segment if it is not complete - if segment.complete: - if last_stream_id != segment.stream_id: - playlist.extend( - [ - "#EXT-X-DISCONTINUITY", - "#EXT-X-PROGRAM-DATE-TIME:" - + segment.start_time.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] - + "Z", - ] - ) - playlist.extend( - [ - f"#EXTINF:{segment.duration:.3f},", - f"./segment/{segment.sequence}.m4s", - ] + + # Add playlist sections for completed segments + # Enumeration used to only include EXT-X-PART data for last 3 segments. + # The RFC seems to suggest removing parts after 3 full segments, but Apple's + # own example shows removing after 2 full segments and 1 part one. + for i, segment in enumerate(segments[:-1], 3 - len(segments)): + playlist.append( + segment.render_hls( + last_stream_id=last_stream_id, + render_parts=i >= 0 and track.stream_settings.ll_hls, + add_hint=False, ) - last_stream_id = segment.stream_id + ) + last_stream_id = segment.stream_id + + playlist.append( + segments[-1].render_hls( + last_stream_id=last_stream_id, + render_parts=track.stream_settings.ll_hls, + add_hint=track.stream_settings.ll_hls, + ) + ) return "\n".join(playlist) + "\n" + @staticmethod + def bad_request(blocking: bool, target_duration: float) -> web.Response: + """Return a HTTP Bad Request response.""" + return web.Response( + body=None, + status=400, + # From Appendix B.1 of the RFC: + # Successful responses to blocking Playlist requests should be cached + # for six Target Durations. Unsuccessful responses (such as 404s) should + # be cached for four Target Durations. Successful responses to non-blocking + # Playlist requests should be cached for half the Target Duration. + # Unsuccessful responses to non-blocking Playlist requests should be + # cached for for one Target Duration. + headers={ + "Cache-Control": f"max-age={(4 if blocking else 1)*target_duration:.0f}" + }, + ) + + @staticmethod + def not_found(blocking: bool, target_duration: float) -> web.Response: + """Return a HTTP Not Found response.""" + return web.Response( + body=None, + status=404, + headers={ + "Cache-Control": f"max-age={(4 if blocking else 1)*target_duration:.0f}" + }, + ) + async def handle( self, request: web.Request, stream: Stream, sequence: str ) -> web.Response: """Return m3u8 playlist.""" - track = stream.add_provider(HLS_PROVIDER) + track: HlsStreamOutput = cast( + HlsStreamOutput, stream.add_provider(HLS_PROVIDER) + ) stream.start() - # Make sure at least two segments are ready (last one may not be complete) - if not track.sequences and not await track.recv(): - return web.HTTPNotFound() - if len(track.sequences) == 1 and not await track.recv(): - return web.HTTPNotFound() - headers = {"Content-Type": FORMAT_CONTENT_TYPE[HLS_PROVIDER]} + + hls_msn: str | int | None = request.query.get("_HLS_msn") + hls_part: str | int | None = request.query.get("_HLS_part") + blocking_request = bool(hls_msn or hls_part) + + # If the Playlist URI contains an _HLS_part directive but no _HLS_msn + # directive, the Server MUST return Bad Request, such as HTTP 400. + if hls_msn is None and hls_part: + return web.HTTPBadRequest() + + hls_msn = int(hls_msn or 0) + + # If the _HLS_msn is greater than the Media Sequence Number of the last + # Media Segment in the current Playlist plus two, or if the _HLS_part + # exceeds the last Part Segment in the current Playlist by the + # Advance Part Limit, then the server SHOULD immediately return Bad + # Request, such as HTTP 400. + if hls_msn > track.last_sequence + 2: + return self.bad_request(blocking_request, track.target_duration) + + if hls_part is None: + # We need to wait for the whole segment, so effectively the next msn + hls_part = -1 + hls_msn += 1 + else: + hls_part = int(hls_part) + + while hls_msn > track.last_sequence: + if not await track.recv(): + return self.not_found(blocking_request, track.target_duration) + if track.last_segment is None: + return self.not_found(blocking_request, 0) + if ( + (last_segment := track.last_segment) + and hls_msn == last_segment.sequence + and hls_part + >= len(last_segment.parts_by_byterange) + - 1 + + track.stream_settings.hls_advance_part_limit + ): + return self.bad_request(blocking_request, track.target_duration) + + # Receive parts until msn and part are met + while ( + (last_segment := track.last_segment) + and hls_msn == last_segment.sequence + and hls_part >= len(last_segment.parts_by_byterange) + ): + if not await track.part_recv( + timeout=track.stream_settings.hls_part_timeout + ): + return self.not_found(blocking_request, track.target_duration) + # Now we should have msn.part >= hls_msn.hls_part. However, in the case + # that we have a rollover part request from the previous segment, we need + # to make sure that the new segment has a part. From 6.2.5.2 of the RFC: + # If the Client requests a Part Index greater than that of the final + # Partial Segment of the Parent Segment, the Server MUST treat the + # request as one for Part Index 0 of the following Parent Segment. + if hls_msn + 1 == last_segment.sequence: + if not (previous_segment := track.get_segment(hls_msn)) or ( + hls_part >= len(previous_segment.parts_by_byterange) + and not last_segment.parts_by_byterange + and not await track.part_recv( + timeout=track.stream_settings.hls_part_timeout + ) + ): + return self.not_found(blocking_request, track.target_duration) + response = web.Response( - body=self.render(track).encode("utf-8"), headers=headers + body=self.render(track).encode("utf-8"), + headers={ + "Content-Type": FORMAT_CONTENT_TYPE[HLS_PROVIDER], + "Cache-Control": f"max-age={(6 if blocking_request else 0.5)*track.target_duration:.0f}", + }, ) response.enable_compression(web.ContentCoding.gzip) return response @@ -171,10 +318,11 @@ class HlsInitView(StreamView): ) -> web.Response: """Return init.mp4.""" track = stream.add_provider(HLS_PROVIDER) - if not (segments := track.get_segments()): + if not (segments := track.get_segments()) or not (body := segments[0].init): return web.HTTPNotFound() return web.Response( - body=segments[0].init, headers={"Content-Type": "video/mp4"} + body=body, + headers={"Content-Type": "video/mp4"}, ) @@ -187,28 +335,102 @@ class HlsSegmentView(StreamView): async def handle( self, request: web.Request, stream: Stream, sequence: str - ) -> web.Response: - """Return fmp4 segment.""" - track = stream.add_provider(HLS_PROVIDER) - track.idle_timer.awake() - if not (segment := track.get_segment(int(sequence))): - return web.HTTPNotFound() - headers = {"Content-Type": "video/iso.segment"} - return web.Response( - body=segment.get_bytes_without_init(), - headers=headers, + ) -> web.StreamResponse: + """Handle segments, part segments, and hinted segments. + + For part and hinted segments, the start of the requested range must align + with a part boundary. + """ + track: HlsStreamOutput = cast( + HlsStreamOutput, stream.add_provider(HLS_PROVIDER) ) - - -@PROVIDERS.register(HLS_PROVIDER) -class HlsStreamOutput(StreamOutput): - """Represents HLS Output formats.""" - - def __init__(self, hass: HomeAssistant, idle_timer: IdleTimer) -> None: - """Initialize recorder output.""" - super().__init__(hass, idle_timer, deque_maxlen=MAX_SEGMENTS) - - @property - def name(self) -> str: - """Return provider name.""" - return HLS_PROVIDER + track.idle_timer.awake() + # Ensure that we have a segment. If the request is from a hint for part 0 + # of a segment, there is a small chance it may have arrived before the + # segment has been put. If this happens, wait for one part and retry. + if not ( + (segment := track.get_segment(int(sequence))) + or ( + await track.part_recv(timeout=track.stream_settings.hls_part_timeout) + and (segment := track.get_segment(int(sequence))) + ) + ): + return web.Response( + body=None, + status=404, + headers={"Cache-Control": f"max-age={track.target_duration:.0f}"}, + ) + # If the segment is ready or has been hinted, the http_range start should be at most + # equal to the end of the currently available data. + # If the segment is complete, the http_range start should be less than the end of the + # currently available data. + # If these conditions aren't met then we return a 416. + # http_range_start can be None, so use a copy that uses 0 instead of None + if (http_start := request.http_range.start or 0) > segment.data_size or ( + segment.complete and http_start >= segment.data_size + ): + return web.HTTPRequestRangeNotSatisfiable( + headers={ + "Cache-Control": f"max-age={track.target_duration:.0f}", + "Content-Range": f"bytes */{segment.data_size}", + } + ) + headers = { + "Content-Type": "video/iso.segment", + "Cache-Control": f"max-age={6*track.target_duration:.0f}", + } + # For most cases we have a 206 partial content response. + status = 206 + # For the 206 responses we need to set a Content-Range header + # See https://datatracker.ietf.org/doc/html/rfc8673#section-2 + if request.http_range.stop is None: + if request.http_range.start is None: + status = 200 + if segment.complete: + # This is a request for a full segment which is already complete + # We should return a standard 200 response. + return web.Response( + body=segment.get_data(), headers=headers, status=status + ) + # Otherwise we still return a 200 response, but it is aggregating + http_stop = float("inf") + else: + # See https://datatracker.ietf.org/doc/html/rfc7233#section-2.1 + headers[ + "Content-Range" + ] = f"bytes {http_start}-{(http_stop:=segment.data_size)-1}/*" + else: # The remaining cases are all 206 responses + if segment.complete: + # If the segment is complete we have total size + headers["Content-Range"] = ( + f"bytes {http_start}-" + + str( + (http_stop := min(request.http_range.stop, segment.data_size)) + - 1 + ) + + f"/{segment.data_size}" + ) + else: + # If we don't have the total size we use a * + headers[ + "Content-Range" + ] = f"bytes {http_start}-{(http_stop:=request.http_range.stop)-1}/*" + # Set up streaming response that we can write to as data becomes available + response = web.StreamResponse(headers=headers, status=status) + # Waiting until we write to prepare *might* give clients more accurate TTFB + # and ABR measurements, but it is probably not very useful for us since we + # only have one rendition anyway. Just prepare here for now. + await response.prepare(request) + try: + for bytes_to_write in segment.get_aggregating_bytes( + start_loc=http_start, end_loc=http_stop + ): + if bytes_to_write: + await response.write(bytes_to_write) + elif not await track.part_recv( + timeout=track.stream_settings.hls_part_timeout + ): + break + except ConnectionResetError: + _LOGGER.warning("Connection reset while serving HLS partial segment") + return response diff --git a/homeassistant/components/stream/recorder.py b/homeassistant/components/stream/recorder.py index 99276d9763c..2fa612e631c 100644 --- a/homeassistant/components/stream/recorder.py +++ b/homeassistant/components/stream/recorder.py @@ -57,7 +57,7 @@ def recorder_save_worker(file_out: str, segments: deque[Segment]) -> None: # Open segment source = av.open( - BytesIO(segment.init + segment.get_bytes_without_init()), + BytesIO(segment.init + segment.get_data()), "r", format=SEGMENT_CONTAINER_FORMAT, ) diff --git a/homeassistant/components/stream/worker.py b/homeassistant/components/stream/worker.py index 039163c6cf5..314e4f33e80 100644 --- a/homeassistant/components/stream/worker.py +++ b/homeassistant/components/stream/worker.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections import defaultdict, deque from collections.abc import Generator, Iterator, Mapping +import datetime from io import BytesIO import logging from threading import Event @@ -10,18 +11,20 @@ from typing import Any, Callable, cast import av +from homeassistant.core import HomeAssistant + from . import redact_credentials from .const import ( + ATTR_SETTINGS, AUDIO_CODECS, + DOMAIN, MAX_MISSING_DTS, MAX_TIMESTAMP_GAP, - MIN_SEGMENT_DURATION, PACKETS_TO_WAIT_FOR_AUDIO, SEGMENT_CONTAINER_FORMAT, SOURCE_TIMEOUT, - TARGET_PART_DURATION, ) -from .core import Part, Segment, StreamOutput +from .core import Part, Segment, StreamOutput, StreamSettings _LOGGER = logging.getLogger(__name__) @@ -30,10 +33,13 @@ class SegmentBuffer: """Buffer for writing a sequence of packets to the output as a segment.""" def __init__( - self, outputs_callback: Callable[[], Mapping[str, StreamOutput]] + self, + hass: HomeAssistant, + outputs_callback: Callable[[], Mapping[str, StreamOutput]], ) -> None: """Initialize SegmentBuffer.""" self._stream_id: int = 0 + self._hass = hass self._outputs_callback: Callable[ [], Mapping[str, StreamOutput] ] = outputs_callback @@ -52,10 +58,14 @@ class SegmentBuffer: self._memory_file_pos: int = cast(int, None) self._part_start_dts: int = cast(int, None) self._part_has_keyframe = False + self._stream_settings: StreamSettings = hass.data[DOMAIN][ATTR_SETTINGS] + self._start_time = datetime.datetime.utcnow() - @staticmethod def make_new_av( - memory_file: BytesIO, sequence: int, input_vstream: av.video.VideoStream + self, + memory_file: BytesIO, + sequence: int, + input_vstream: av.video.VideoStream, ) -> av.container.OutputContainer: """Make a new av OutputContainer.""" return av.open( @@ -63,19 +73,38 @@ class SegmentBuffer: mode="w", format=SEGMENT_CONTAINER_FORMAT, container_options={ - # Removed skip_sidx - see https://github.com/home-assistant/core/pull/39970 - # "cmaf" flag replaces several of the movflags used, but too recent to use for now - "movflags": "empty_moov+default_base_moof+frag_discont+negative_cts_offsets+skip_trailer", - # Sometimes the first segment begins with negative timestamps, and this setting just - # adjusts the timestamps in the output from that segment to start from 0. Helps from - # having to make some adjustments in test_durations - "avoid_negative_ts": "make_non_negative", - "fragment_index": str(sequence + 1), - "video_track_timescale": str(int(1 / input_vstream.time_base)), - # Create a fragments every TARGET_PART_DURATION. The data from each fragment is stored in - # a "Part" that can be combined with the data from all the other "Part"s, plus an init - # section, to reconstitute the data in a "Segment". - "frag_duration": str(int(TARGET_PART_DURATION * 1e6)), + **{ + # Removed skip_sidx - see https://github.com/home-assistant/core/pull/39970 + # "cmaf" flag replaces several of the movflags used, but too recent to use for now + "movflags": "frag_custom+empty_moov+default_base_moof+frag_discont+negative_cts_offsets+skip_trailer", + # Sometimes the first segment begins with negative timestamps, and this setting just + # adjusts the timestamps in the output from that segment to start from 0. Helps from + # having to make some adjustments in test_durations + "avoid_negative_ts": "make_non_negative", + "fragment_index": str(sequence + 1), + "video_track_timescale": str(int(1 / input_vstream.time_base)), + }, + # Only do extra fragmenting if we are using ll_hls + # Let ffmpeg do the work using frag_duration + # Fragment durations may exceed the 15% allowed variance but it seems ok + **( + { + "movflags": "empty_moov+default_base_moof+frag_discont+negative_cts_offsets+skip_trailer", + # Create a fragment every TARGET_PART_DURATION. The data from each fragment is stored in + # a "Part" that can be combined with the data from all the other "Part"s, plus an init + # section, to reconstitute the data in a "Segment". + # frag_duration seems to be a minimum threshold for determining part boundaries, so some + # parts may have a higher duration. Since Part Target Duration is used in LL-HLS as a + # maximum threshold for part durations, we scale that number down here by .85 and hope + # that the output part durations stay below the maximum Part Target Duration threshold. + # See https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis#section-4.4.4.9 + "frag_duration": str( + self._stream_settings.part_target_duration * 1e6 + ), + } + if self._stream_settings.ll_hls + else {} + ), }, ) @@ -120,7 +149,7 @@ class SegmentBuffer: if ( packet.is_keyframe and (packet.dts - self._segment_start_dts) * packet.time_base - >= MIN_SEGMENT_DURATION + >= self._stream_settings.min_segment_duration ): # Flush segment (also flushes the stub part segment) self.flush(packet, last_part=True) @@ -148,13 +177,16 @@ class SegmentBuffer: sequence=self._sequence, stream_id=self._stream_id, init=self._memory_file.getvalue(), + # Fetch the latest StreamOutputs, which may have changed since the + # worker started. + stream_outputs=self._outputs_callback().values(), + start_time=self._start_time + + datetime.timedelta( + seconds=float(self._segment_start_dts * packet.time_base) + ), ) self._memory_file_pos = self._memory_file.tell() self._part_start_dts = self._segment_start_dts - # Fetch the latest StreamOutputs, which may have changed since the - # worker started. - for stream_output in self._outputs_callback().values(): - stream_output.put(self._segment) else: # These are the ends of the part segments self.flush(packet, last_part=False) @@ -164,27 +196,41 @@ class SegmentBuffer: If last_part is True, also close the segment, give it a duration, and clean up the av_output and memory_file. """ + # In some cases using the current packet's dts (which is the start + # dts of the next part) to calculate the part duration will result in a + # value which exceeds the part_target_duration. This can muck up the + # duration of both this part and the next part. An easy fix is to just + # use the current packet dts and cap it by the part target duration. + current_dts = min( + packet.dts, + self._part_start_dts + + self._stream_settings.part_target_duration / packet.time_base, + ) if last_part: # Closing the av_output will write the remaining buffered data to the # memory_file as a new moof/mdat. self._av_output.close() assert self._segment self._memory_file.seek(self._memory_file_pos) - self._segment.parts.append( + self._hass.loop.call_soon_threadsafe( + self._segment.async_add_part, Part( - duration=float((packet.dts - self._part_start_dts) * packet.time_base), + duration=float((current_dts - self._part_start_dts) * packet.time_base), has_keyframe=self._part_has_keyframe, data=self._memory_file.read(), - ) + ), + float((current_dts - self._segment_start_dts) * packet.time_base) + if last_part + else 0, ) if last_part: - self._segment.duration = float( - (packet.dts - self._segment_start_dts) * packet.time_base - ) + # If we've written the last part, we can close the memory_file. self._memory_file.close() # We don't need the BytesIO object anymore else: + # For the last part, these will get set again elsewhere so we can skip + # setting them here. self._memory_file_pos = self._memory_file.tell() - self._part_start_dts = packet.dts + self._part_start_dts = current_dts self._part_has_keyframe = False def discontinuity(self) -> None: diff --git a/tests/components/stream/common.py b/tests/components/stream/common.py index a39e8bdca21..19a4d2a9e6f 100644 --- a/tests/components/stream/common.py +++ b/tests/components/stream/common.py @@ -1,10 +1,25 @@ """Collection of test helpers.""" +from datetime import datetime from fractions import Fraction +from functools import partial import io import av import numpy as np +from homeassistant.components.stream.core import Segment + +FAKE_TIME = datetime.utcnow() +# Segment with defaults filled in for use in tests + +DefaultSegment = partial( + Segment, + init=None, + stream_id=0, + start_time=FAKE_TIME, + stream_outputs=[], +) + AUDIO_SAMPLE_RATE = 8000 @@ -22,14 +37,13 @@ def generate_audio_frame(pcm_mulaw=False): return audio_frame -def generate_h264_video(container_format="mp4"): +def generate_h264_video(container_format="mp4", duration=5): """ Generate a test video. See: http://docs.mikeboers.com/pyav/develop/cookbook/numpy.html """ - duration = 5 fps = 24 total_frames = duration * fps diff --git a/tests/components/stream/conftest.py b/tests/components/stream/conftest.py index a73678d763f..746cc05fcbd 100644 --- a/tests/components/stream/conftest.py +++ b/tests/components/stream/conftest.py @@ -17,11 +17,12 @@ import logging import threading from unittest.mock import patch +from aiohttp import web import async_timeout import pytest from homeassistant.components.stream import Stream -from homeassistant.components.stream.core import Segment +from homeassistant.components.stream.core import Segment, StreamOutput TEST_TIMEOUT = 7.0 # Lower than 9s home assistant timeout @@ -120,3 +121,95 @@ def record_worker_sync(hass): autospec=True, ): yield sync + + +class HLSSync: + """Test fixture that intercepts stream worker calls to StreamOutput.""" + + def __init__(self): + """Initialize HLSSync.""" + self._request_event = asyncio.Event() + self._original_recv = StreamOutput.recv + self._original_part_recv = StreamOutput.part_recv + self._original_bad_request = web.HTTPBadRequest + self._original_not_found = web.HTTPNotFound + self._original_response = web.Response + self._num_requests = 0 + self._num_recvs = 0 + self._num_finished = 0 + + def reset_request_pool(self, num_requests: int, reset_finished=True): + """Use to reset the request counter between segments.""" + self._num_recvs = 0 + if reset_finished: + self._num_finished = 0 + self._num_requests = num_requests + + async def wait_for_handler(self): + """Set up HLSSync to block calls to put until requests are set up.""" + if not self.check_requests_ready(): + await self._request_event.wait() + self.reset_request_pool(num_requests=self._num_requests, reset_finished=False) + + def check_requests_ready(self): + """Unblock the pending put call if the requests are all finished or blocking.""" + if self._num_recvs + self._num_finished == self._num_requests: + self._request_event.set() + self._request_event.clear() + return True + return False + + def bad_request(self): + """Intercept the HTTPBadRequest call so we know when the web handler is finished.""" + self._num_finished += 1 + self.check_requests_ready() + return self._original_bad_request() + + def not_found(self): + """Intercept the HTTPNotFound call so we know when the web handler is finished.""" + self._num_finished += 1 + self.check_requests_ready() + return self._original_not_found() + + def response(self, body, headers, status=200): + """Intercept the Response call so we know when the web handler is finished.""" + self._num_finished += 1 + self.check_requests_ready() + return self._original_response(body=body, headers=headers, status=status) + + async def recv(self, output: StreamOutput, **kw): + """Intercept the recv call so we know when the response is blocking on recv.""" + self._num_recvs += 1 + self.check_requests_ready() + return await self._original_recv(output) + + async def part_recv(self, output: StreamOutput, **kw): + """Intercept the recv call so we know when the response is blocking on recv.""" + self._num_recvs += 1 + self.check_requests_ready() + return await self._original_part_recv(output) + + +@pytest.fixture() +def hls_sync(): + """Patch HLSOutput to allow test to synchronize playlist requests and responses.""" + sync = HLSSync() + with patch( + "homeassistant.components.stream.core.StreamOutput.recv", + side_effect=sync.recv, + autospec=True, + ), patch( + "homeassistant.components.stream.core.StreamOutput.part_recv", + side_effect=sync.part_recv, + autospec=True, + ), patch( + "homeassistant.components.stream.hls.web.HTTPBadRequest", + side_effect=sync.bad_request, + ), patch( + "homeassistant.components.stream.hls.web.HTTPNotFound", + side_effect=sync.not_found, + ), patch( + "homeassistant.components.stream.hls.web.Response", + side_effect=sync.response, + ): + yield sync diff --git a/tests/components/stream/test_hls.py b/tests/components/stream/test_hls.py index 919f71c8509..4b0cb0322ce 100644 --- a/tests/components/stream/test_hls.py +++ b/tests/components/stream/test_hls.py @@ -1,5 +1,5 @@ """The tests for hls streams.""" -from datetime import datetime, timedelta +from datetime import timedelta from unittest.mock import patch from urllib.parse import urlparse @@ -8,17 +8,23 @@ import pytest from homeassistant.components.stream import create_stream from homeassistant.components.stream.const import ( + EXT_X_START_LL_HLS, + EXT_X_START_NON_LL_HLS, HLS_PROVIDER, MAX_SEGMENTS, NUM_PLAYLIST_SEGMENTS, ) -from homeassistant.components.stream.core import Part, Segment +from homeassistant.components.stream.core import Part from homeassistant.const import HTTP_NOT_FOUND from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from tests.common import async_fire_time_changed -from tests.components.stream.common import generate_h264_video +from tests.components.stream.common import ( + FAKE_TIME, + DefaultSegment as Segment, + generate_h264_video, +) STREAM_SOURCE = "some-stream-source" INIT_BYTES = b"init" @@ -26,7 +32,6 @@ FAKE_PAYLOAD = b"fake-payload" SEGMENT_DURATION = 10 TEST_TIMEOUT = 5.0 # Lower than 9s home assistant timeout MAX_ABORT_SEGMENTS = 20 # Abort test to avoid looping forever -FAKE_TIME = datetime.utcnow() class HlsClient: @@ -37,13 +42,13 @@ class HlsClient: self.http_client = http_client self.parsed_url = parsed_url - async def get(self, path=None): + async def get(self, path=None, headers=None): """Fetch the hls stream for the specified path.""" url = self.parsed_url.path if path: # Strip off the master playlist suffix and replace with path url = "/".join(self.parsed_url.path.split("/")[:-1]) + path - return await self.http_client.get(url) + return await self.http_client.get(url, headers=headers) @pytest.fixture @@ -60,36 +65,52 @@ def hls_stream(hass, hass_client): def make_segment(segment, discontinuity=False): """Create a playlist response for a segment.""" - response = [] - if discontinuity: - response.extend( - [ - "#EXT-X-DISCONTINUITY", - "#EXT-X-PROGRAM-DATE-TIME:" - + FAKE_TIME.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] - + "Z", - ] - ) - response.extend([f"#EXTINF:{SEGMENT_DURATION:.3f},", f"./segment/{segment}.m4s"]) + response = ["#EXT-X-DISCONTINUITY"] if discontinuity else [] + response.extend( + [ + "#EXT-X-PROGRAM-DATE-TIME:" + + FAKE_TIME.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + + "Z", + f"#EXTINF:{SEGMENT_DURATION:.3f},", + f"./segment/{segment}.m4s", + ] + ) return "\n".join(response) -def make_playlist(sequence, segments, discontinuity_sequence=0): +def make_playlist( + sequence, + discontinuity_sequence=0, + segments=None, + hint=None, + part_target_duration=None, +): """Create a an hls playlist response for tests to assert on.""" response = [ "#EXTM3U", "#EXT-X-VERSION:6", "#EXT-X-INDEPENDENT-SEGMENTS", '#EXT-X-MAP:URI="init.mp4"', - "#EXT-X-TARGETDURATION:10", + f"#EXT-X-TARGETDURATION:{SEGMENT_DURATION}", f"#EXT-X-MEDIA-SEQUENCE:{sequence}", f"#EXT-X-DISCONTINUITY-SEQUENCE:{discontinuity_sequence}", - "#EXT-X-PROGRAM-DATE-TIME:" - + FAKE_TIME.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] - + "Z", - f"#EXT-X-START:TIME-OFFSET=-{1.5*SEGMENT_DURATION:.3f}", ] - response.extend(segments) + if hint: + response.extend( + [ + f"#EXT-X-PART-INF:PART-TARGET={part_target_duration:.3f}", + f"#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK={2*part_target_duration:.3f}", + f"#EXT-X-START:TIME-OFFSET=-{EXT_X_START_LL_HLS*part_target_duration:.3f},PRECISE=YES", + ] + ) + else: + response.append( + f"#EXT-X-START:TIME-OFFSET=-{EXT_X_START_NON_LL_HLS*SEGMENT_DURATION:.3f},PRECISE=YES", + ) + if segments: + response.extend(segments) + if hint: + response.append(hint) response.append("") return "\n".join(response) @@ -115,18 +136,23 @@ async def test_hls_stream(hass, hls_stream, stream_worker_sync): hls_client = await hls_stream(stream) - # Fetch playlist - playlist_response = await hls_client.get() - assert playlist_response.status == 200 + # Fetch master playlist + master_playlist_response = await hls_client.get() + assert master_playlist_response.status == 200 # Fetch init - playlist = await playlist_response.text() + master_playlist = await master_playlist_response.text() init_response = await hls_client.get("/init.mp4") assert init_response.status == 200 + # Fetch playlist + playlist_url = "/" + master_playlist.splitlines()[-1] + playlist_response = await hls_client.get(playlist_url) + assert playlist_response.status == 200 + # Fetch segment playlist = await playlist_response.text() - segment_url = "/" + playlist.splitlines()[-1] + segment_url = "/" + [line for line in playlist.splitlines() if line][-1] segment_response = await hls_client.get(segment_url) assert segment_response.status == 200 @@ -243,7 +269,7 @@ async def test_stream_keepalive(hass): stream.stop() -async def test_hls_playlist_view_no_output(hass, hass_client, hls_stream): +async def test_hls_playlist_view_no_output(hass, hls_stream): """Test rendering the hls playlist with no output segments.""" await async_setup_component(hass, "stream", {"stream": {}}) @@ -265,7 +291,7 @@ async def test_hls_playlist_view(hass, hls_stream, stream_worker_sync): stream_worker_sync.pause() hls = stream.add_provider(HLS_PROVIDER) for i in range(2): - segment = Segment(sequence=i, duration=SEGMENT_DURATION, start_time=FAKE_TIME) + segment = Segment(sequence=i, duration=SEGMENT_DURATION) hls.put(segment) await hass.async_block_till_done() @@ -277,7 +303,7 @@ async def test_hls_playlist_view(hass, hls_stream, stream_worker_sync): sequence=0, segments=[make_segment(0), make_segment(1)] ) - segment = Segment(sequence=2, duration=SEGMENT_DURATION, start_time=FAKE_TIME) + segment = Segment(sequence=2, duration=SEGMENT_DURATION) hls.put(segment) await hass.async_block_till_done() resp = await hls_client.get("/playlist.m3u8") @@ -302,9 +328,7 @@ async def test_hls_max_segments(hass, hls_stream, stream_worker_sync): # Produce enough segments to overfill the output buffer by one for sequence in range(MAX_SEGMENTS + 1): - segment = Segment( - sequence=sequence, duration=SEGMENT_DURATION, start_time=FAKE_TIME - ) + segment = Segment(sequence=sequence, duration=SEGMENT_DURATION) hls.put(segment) await hass.async_block_till_done() @@ -321,16 +345,17 @@ async def test_hls_max_segments(hass, hls_stream, stream_worker_sync): # Fetch the actual segments with a fake byte payload for segment in hls.get_segments(): segment.init = INIT_BYTES - segment.parts = [ - Part( + segment.parts_by_byterange = { + 0: Part( duration=SEGMENT_DURATION, has_keyframe=True, data=FAKE_PAYLOAD, ) - ] + } # The segment that fell off the buffer is not accessible - segment_response = await hls_client.get("/segment/0.m4s") + with patch.object(hls.stream_settings, "hls_part_timeout", 0.1): + segment_response = await hls_client.get("/segment/0.m4s") assert segment_response.status == 404 # However all segments in the buffer are accessible, even those that were not in the playlist. @@ -350,19 +375,14 @@ async def test_hls_playlist_view_discontinuity(hass, hls_stream, stream_worker_s stream_worker_sync.pause() hls = stream.add_provider(HLS_PROVIDER) - segment = Segment( - sequence=0, stream_id=0, duration=SEGMENT_DURATION, start_time=FAKE_TIME - ) + segment = Segment(sequence=0, stream_id=0, duration=SEGMENT_DURATION) hls.put(segment) - segment = Segment( - sequence=1, stream_id=0, duration=SEGMENT_DURATION, start_time=FAKE_TIME - ) + segment = Segment(sequence=1, stream_id=0, duration=SEGMENT_DURATION) hls.put(segment) segment = Segment( sequence=2, stream_id=1, duration=SEGMENT_DURATION, - start_time=FAKE_TIME, ) hls.put(segment) await hass.async_block_till_done() @@ -394,9 +414,7 @@ async def test_hls_max_segments_discontinuity(hass, hls_stream, stream_worker_sy hls_client = await hls_stream(stream) - segment = Segment( - sequence=0, stream_id=0, duration=SEGMENT_DURATION, start_time=FAKE_TIME - ) + segment = Segment(sequence=0, stream_id=0, duration=SEGMENT_DURATION) hls.put(segment) # Produce enough segments to overfill the output buffer by one @@ -405,7 +423,6 @@ async def test_hls_max_segments_discontinuity(hass, hls_stream, stream_worker_sy sequence=sequence, stream_id=1, duration=SEGMENT_DURATION, - start_time=FAKE_TIME, ) hls.put(segment) await hass.async_block_till_done() diff --git a/tests/components/stream/test_ll_hls.py b/tests/components/stream/test_ll_hls.py new file mode 100644 index 00000000000..8e512e0723e --- /dev/null +++ b/tests/components/stream/test_ll_hls.py @@ -0,0 +1,731 @@ +"""The tests for hls streams.""" +import asyncio +import itertools +import re +from urllib.parse import urlparse + +import pytest + +from homeassistant.components.stream import create_stream +from homeassistant.components.stream.const import ( + ATTR_SETTINGS, + CONF_LL_HLS, + CONF_PART_DURATION, + CONF_SEGMENT_DURATION, + DOMAIN, + HLS_PROVIDER, +) +from homeassistant.components.stream.core import Part +from homeassistant.const import HTTP_NOT_FOUND +from homeassistant.setup import async_setup_component + +from .test_hls import SEGMENT_DURATION, STREAM_SOURCE, HlsClient, make_playlist + +from tests.components.stream.common import ( + FAKE_TIME, + DefaultSegment as Segment, + generate_h264_video, +) + +TEST_PART_DURATION = 1 +NUM_PART_SEGMENTS = int(-(-SEGMENT_DURATION // TEST_PART_DURATION)) +PART_INDEPENDENT_PERIOD = int(1 / TEST_PART_DURATION) or 1 +BYTERANGE_LENGTH = 1 +INIT_BYTES = b"init" +SEQUENCE_BYTES = bytearray(range(NUM_PART_SEGMENTS * BYTERANGE_LENGTH)) +ALT_SEQUENCE_BYTES = bytearray(range(20, 20 + NUM_PART_SEGMENTS * BYTERANGE_LENGTH)) +VERY_LARGE_LAST_BYTE_POS = 9007199254740991 + + +@pytest.fixture +def hls_stream(hass, hass_client): + """Create test fixture for creating an HLS client for a stream.""" + + async def create_client_for_stream(stream): + stream.ll_hls = True + http_client = await hass_client() + parsed_url = urlparse(stream.endpoint_url(HLS_PROVIDER)) + return HlsClient(http_client, parsed_url) + + return create_client_for_stream + + +def create_segment(sequence): + """Create an empty segment.""" + segment = Segment(sequence=sequence) + segment.init = INIT_BYTES + return segment + + +def complete_segment(segment): + """Completes a segment by setting its duration.""" + segment.duration = sum( + part.duration for part in segment.parts_by_byterange.values() + ) + + +def create_parts(source): + """Create parts from a source.""" + independent_cycle = itertools.cycle( + [True] + [False] * (PART_INDEPENDENT_PERIOD - 1) + ) + return [ + Part( + duration=TEST_PART_DURATION, + has_keyframe=next(independent_cycle), + data=bytes(source[i * BYTERANGE_LENGTH : (i + 1) * BYTERANGE_LENGTH]), + ) + for i in range(NUM_PART_SEGMENTS) + ] + + +def http_range_from_part(part): + """Return dummy byterange (length, start) given part number.""" + return BYTERANGE_LENGTH, part * BYTERANGE_LENGTH + + +def make_segment_with_parts( + segment, num_parts, independent_period, discontinuity=False +): + """Create a playlist response for a segment including part segments.""" + response = [] + for i in range(num_parts): + length, start = http_range_from_part(i) + response.append( + f'#EXT-X-PART:DURATION={TEST_PART_DURATION:.3f},URI="./segment/{segment}.m4s",BYTERANGE="{length}@{start}"{",INDEPENDENT=YES" if i%independent_period==0 else ""}' + ) + if discontinuity: + response.append("#EXT-X-DISCONTINUITY") + response.extend( + [ + "#EXT-X-PROGRAM-DATE-TIME:" + + FAKE_TIME.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + + "Z", + f"#EXTINF:{SEGMENT_DURATION:.3f},", + f"./segment/{segment}.m4s", + ] + ) + return "\n".join(response) + + +def make_hint(segment, part): + """Create a playlist response for the preload hint.""" + _, start = http_range_from_part(part) + return f'#EXT-X-PRELOAD-HINT:TYPE=PART,URI="./segment/{segment}.m4s",BYTERANGE-START={start}' + + +async def test_ll_hls_stream(hass, hls_stream, stream_worker_sync): + """ + Test hls stream. + + Purposefully not mocking anything here to test full + integration with the stream component. + """ + await async_setup_component( + hass, + "stream", + { + "stream": { + CONF_LL_HLS: True, + CONF_SEGMENT_DURATION: SEGMENT_DURATION, + CONF_PART_DURATION: TEST_PART_DURATION, + } + }, + ) + + stream_worker_sync.pause() + + # Setup demo HLS track + source = generate_h264_video(duration=SEGMENT_DURATION + 1) + stream = create_stream(hass, source, {}) + + # Request stream + stream.add_provider(HLS_PROVIDER) + stream.start() + + hls_client = await hls_stream(stream) + + # Fetch playlist + master_playlist_response = await hls_client.get() + assert master_playlist_response.status == 200 + + # Fetch init + master_playlist = await master_playlist_response.text() + init_response = await hls_client.get("/init.mp4") + assert init_response.status == 200 + + # Fetch playlist + playlist_url = "/" + master_playlist.splitlines()[-1] + playlist_response = await hls_client.get(playlist_url) + assert playlist_response.status == 200 + + # Fetch segments + playlist = await playlist_response.text() + segment_re = re.compile(r"^(?P./segment/\d+\.m4s)") + for line in playlist.splitlines(): + match = segment_re.match(line) + if match: + segment_url = "/" + match.group("segment_url") + segment_response = await hls_client.get(segment_url) + assert segment_response.status == 200 + + def check_part_is_moof_mdat(data: bytes): + if len(data) < 8 or data[4:8] != b"moof": + return False + moof_length = int.from_bytes(data[0:4], byteorder="big") + if ( + len(data) < moof_length + 8 + or data[moof_length + 4 : moof_length + 8] != b"mdat" + ): + return False + mdat_length = int.from_bytes( + data[moof_length : moof_length + 4], byteorder="big" + ) + if mdat_length + moof_length != len(data): + return False + return True + + # Fetch all completed part segments + part_re = re.compile( + r'#EXT-X-PART:DURATION=[0-9].[0-9]{5,5},URI="(?P.+?)",BYTERANGE="(?P[0-9]+?)@(?P[0-9]+?)"(,INDEPENDENT=YES)?' + ) + for line in playlist.splitlines(): + match = part_re.match(line) + if match: + part_segment_url = "/" + match.group("part_url") + byterange_end = ( + int(match.group("byterange_length")) + + int(match.group("byterange_start")) + - 1 + ) + part_segment_response = await hls_client.get( + part_segment_url, + headers={ + "Range": f'bytes={match.group("byterange_start")}-{byterange_end}' + }, + ) + assert part_segment_response.status == 206 + assert check_part_is_moof_mdat(await part_segment_response.read()) + + stream_worker_sync.resume() + + # Stop stream, if it hasn't quit already + stream.stop() + + # Ensure playlist not accessible after stream ends + fail_response = await hls_client.get() + assert fail_response.status == HTTP_NOT_FOUND + + +async def test_ll_hls_playlist_view(hass, hls_stream, stream_worker_sync): + """Test rendering the hls playlist with 1 and 2 output segments.""" + await async_setup_component( + hass, + "stream", + { + "stream": { + CONF_LL_HLS: True, + CONF_SEGMENT_DURATION: SEGMENT_DURATION, + CONF_PART_DURATION: TEST_PART_DURATION, + } + }, + ) + + stream = create_stream(hass, STREAM_SOURCE, {}) + stream_worker_sync.pause() + hls = stream.add_provider(HLS_PROVIDER) + + # Add 2 complete segments to output + for sequence in range(2): + segment = create_segment(sequence=sequence) + hls.put(segment) + for part in create_parts(SEQUENCE_BYTES): + segment.async_add_part(part, 0) + hls.part_put() + complete_segment(segment) + await hass.async_block_till_done() + + hls_client = await hls_stream(stream) + + resp = await hls_client.get("/playlist.m3u8") + assert resp.status == 200 + assert await resp.text() == make_playlist( + sequence=0, + segments=[ + make_segment_with_parts( + i, len(segment.parts_by_byterange), PART_INDEPENDENT_PERIOD + ) + for i in range(2) + ], + hint=make_hint(2, 0), + part_target_duration=hls.stream_settings.part_target_duration, + ) + + # add one more segment + segment = create_segment(sequence=2) + hls.put(segment) + for part in create_parts(SEQUENCE_BYTES): + segment.async_add_part(part, 0) + hls.part_put() + complete_segment(segment) + + await hass.async_block_till_done() + resp = await hls_client.get("/playlist.m3u8") + assert resp.status == 200 + assert await resp.text() == make_playlist( + sequence=0, + segments=[ + make_segment_with_parts( + i, len(segment.parts_by_byterange), PART_INDEPENDENT_PERIOD + ) + for i in range(3) + ], + hint=make_hint(3, 0), + part_target_duration=hls.stream_settings.part_target_duration, + ) + + stream_worker_sync.resume() + stream.stop() + + +async def test_ll_hls_msn(hass, hls_stream, stream_worker_sync, hls_sync): + """Test that requests using _HLS_msn get held and returned or rejected.""" + await async_setup_component( + hass, + "stream", + { + "stream": { + CONF_LL_HLS: True, + CONF_SEGMENT_DURATION: SEGMENT_DURATION, + CONF_PART_DURATION: TEST_PART_DURATION, + } + }, + ) + + stream = create_stream(hass, STREAM_SOURCE, {}) + stream_worker_sync.pause() + + hls = stream.add_provider(HLS_PROVIDER) + + hls_client = await hls_stream(stream) + + # Create 4 requests for sequences 0 through 3 + # 0 and 1 should hold then go through and 2 and 3 should fail immediately. + + hls_sync.reset_request_pool(4) + msn_requests = asyncio.gather( + *(hls_client.get(f"/playlist.m3u8?_HLS_msn={i}") for i in range(4)) + ) + + for sequence in range(3): + await hls_sync.wait_for_handler() + segment = Segment(sequence=sequence, duration=SEGMENT_DURATION) + hls.put(segment) + + msn_responses = await msn_requests + + assert msn_responses[0].status == 200 + assert msn_responses[1].status == 200 + assert msn_responses[2].status == 400 + assert msn_responses[3].status == 400 + + # Sequence number is now 2. Create six more requests for sequences 0 through 5. + # Calls for msn 0 through 4 should work, 5 should fail. + + hls_sync.reset_request_pool(6) + msn_requests = asyncio.gather( + *(hls_client.get(f"/playlist.m3u8?_HLS_msn={i}") for i in range(6)) + ) + for sequence in range(3, 6): + await hls_sync.wait_for_handler() + segment = Segment(sequence=sequence, duration=SEGMENT_DURATION) + hls.put(segment) + + msn_responses = await msn_requests + assert msn_responses[0].status == 200 + assert msn_responses[1].status == 200 + assert msn_responses[2].status == 200 + assert msn_responses[3].status == 200 + assert msn_responses[4].status == 200 + assert msn_responses[5].status == 400 + + stream_worker_sync.resume() + + +async def test_ll_hls_playlist_bad_msn_part(hass, hls_stream, stream_worker_sync): + """Test some playlist requests with invalid _HLS_msn/_HLS_part.""" + + await async_setup_component( + hass, + "stream", + { + "stream": { + CONF_LL_HLS: True, + CONF_SEGMENT_DURATION: SEGMENT_DURATION, + CONF_PART_DURATION: TEST_PART_DURATION, + } + }, + ) + + stream = create_stream(hass, STREAM_SOURCE, {}) + stream_worker_sync.pause() + + hls = stream.add_provider(HLS_PROVIDER) + + hls_client = await hls_stream(stream) + + # If the Playlist URI contains an _HLS_part directive but no _HLS_msn + # directive, the Server MUST return Bad Request, such as HTTP 400. + + assert (await hls_client.get("/playlist.m3u8?_HLS_part=1")).status == 400 + + # Seed hls with 1 complete segment and 1 in process segment + segment = create_segment(sequence=0) + hls.put(segment) + for part in create_parts(SEQUENCE_BYTES): + segment.async_add_part(part, 0) + hls.part_put() + complete_segment(segment) + + segment = create_segment(sequence=1) + hls.put(segment) + remaining_parts = create_parts(SEQUENCE_BYTES) + num_completed_parts = len(remaining_parts) // 2 + for part in remaining_parts[:num_completed_parts]: + segment.async_add_part(part, 0) + + # If the _HLS_msn is greater than the Media Sequence Number of the last + # Media Segment in the current Playlist plus two, or if the _HLS_part + # exceeds the last Partial Segment in the current Playlist by the + # Advance Part Limit, then the server SHOULD immediately return Bad + # Request, such as HTTP 400. The Advance Part Limit is three divided + # by the Part Target Duration if the Part Target Duration is less than + # one second, or three otherwise. + + # Current sequence number is 1 and part number is num_completed_parts-1 + # The following two tests should fail immediately: + # - request with a _HLS_msn of 4 + # - request with a _HLS_msn of 1 and a _HLS_part of num_completed_parts-1+advance_part_limit + assert (await hls_client.get("/playlist.m3u8?_HLS_msn=4")).status == 400 + assert ( + await hls_client.get( + f"/playlist.m3u8?_HLS_msn=1&_HLS_part={num_completed_parts-1+hass.data[DOMAIN][ATTR_SETTINGS].hls_advance_part_limit}" + ) + ).status == 400 + stream_worker_sync.resume() + + +async def test_ll_hls_playlist_rollover_part( + hass, hls_stream, stream_worker_sync, hls_sync +): + """Test playlist request rollover.""" + + await async_setup_component( + hass, + "stream", + { + "stream": { + CONF_LL_HLS: True, + CONF_SEGMENT_DURATION: SEGMENT_DURATION, + CONF_PART_DURATION: TEST_PART_DURATION, + } + }, + ) + + stream = create_stream(hass, STREAM_SOURCE, {}) + stream_worker_sync.pause() + + hls = stream.add_provider(HLS_PROVIDER) + + hls_client = await hls_stream(stream) + + # Seed hls with 1 complete segment and 1 in process segment + for sequence in range(2): + segment = create_segment(sequence=sequence) + hls.put(segment) + + for part in create_parts(SEQUENCE_BYTES): + segment.async_add_part(part, 0) + hls.part_put() + complete_segment(segment) + + await hass.async_block_till_done() + + hls_sync.reset_request_pool(4) + segment = hls.get_segment(1) + # the first request corresponds to the last part of segment 1 + # the remaining requests correspond to part 0 of segment 2 + requests = asyncio.gather( + *( + [ + hls_client.get( + f"/playlist.m3u8?_HLS_msn=1&_HLS_part={len(segment.parts_by_byterange)-1}" + ), + hls_client.get( + f"/playlist.m3u8?_HLS_msn=1&_HLS_part={len(segment.parts_by_byterange)}" + ), + hls_client.get( + f"/playlist.m3u8?_HLS_msn=1&_HLS_part={len(segment.parts_by_byterange)+1}" + ), + hls_client.get("/playlist.m3u8?_HLS_msn=2&_HLS_part=0"), + ] + ) + ) + + await hls_sync.wait_for_handler() + + segment = create_segment(sequence=2) + hls.put(segment) + await hass.async_block_till_done() + + remaining_parts = create_parts(SEQUENCE_BYTES) + segment.async_add_part(remaining_parts.pop(0), 0) + hls.part_put() + + await hls_sync.wait_for_handler() + + different_response, *same_responses = await requests + + assert different_response.status == 200 + assert all(response.status == 200 for response in same_responses) + different_playlist = await different_response.read() + same_playlists = [await response.read() for response in same_responses] + assert different_playlist != same_playlists[0] + assert all(playlist == same_playlists[0] for playlist in same_playlists[1:]) + + stream_worker_sync.resume() + + +async def test_ll_hls_playlist_msn_part(hass, hls_stream, stream_worker_sync, hls_sync): + """Test that requests using _HLS_msn and _HLS_part get held and returned.""" + + await async_setup_component( + hass, + "stream", + { + "stream": { + CONF_LL_HLS: True, + CONF_SEGMENT_DURATION: SEGMENT_DURATION, + CONF_PART_DURATION: TEST_PART_DURATION, + } + }, + ) + + stream = create_stream(hass, STREAM_SOURCE, {}) + stream_worker_sync.pause() + + hls = stream.add_provider(HLS_PROVIDER) + + hls_client = await hls_stream(stream) + + # Seed hls with 1 complete segment and 1 in process segment + segment = create_segment(sequence=0) + hls.put(segment) + for part in create_parts(SEQUENCE_BYTES): + segment.async_add_part(part, 0) + hls.part_put() + complete_segment(segment) + + segment = create_segment(sequence=1) + hls.put(segment) + remaining_parts = create_parts(SEQUENCE_BYTES) + num_completed_parts = len(remaining_parts) // 2 + for part in remaining_parts[:num_completed_parts]: + segment.async_add_part(part, 0) + del remaining_parts[:num_completed_parts] + + # Make requests for all the part segments up to n+ADVANCE_PART_LIMIT + hls_sync.reset_request_pool( + num_completed_parts + + int(-(-hass.data[DOMAIN][ATTR_SETTINGS].hls_advance_part_limit // 1)) + ) + msn_requests = asyncio.gather( + *( + hls_client.get(f"/playlist.m3u8?_HLS_msn=1&_HLS_part={i}") + for i in range( + num_completed_parts + + int(-(-hass.data[DOMAIN][ATTR_SETTINGS].hls_advance_part_limit // 1)) + ) + ) + ) + + while remaining_parts: + await hls_sync.wait_for_handler() + segment.async_add_part(remaining_parts.pop(0), 0) + hls.part_put() + + msn_responses = await msn_requests + + # All the responses should succeed except the last one which fails + assert all(response.status == 200 for response in msn_responses[:-1]) + assert msn_responses[-1].status == 400 + + stream_worker_sync.resume() + + +async def test_get_part_segments(hass, hls_stream, stream_worker_sync, hls_sync): + """Test requests for part segments and hinted parts.""" + await async_setup_component( + hass, + "stream", + { + "stream": { + CONF_LL_HLS: True, + CONF_SEGMENT_DURATION: SEGMENT_DURATION, + CONF_PART_DURATION: TEST_PART_DURATION, + } + }, + ) + + stream = create_stream(hass, STREAM_SOURCE, {}) + stream_worker_sync.pause() + + hls = stream.add_provider(HLS_PROVIDER) + + hls_client = await hls_stream(stream) + + # Seed hls with 1 complete segment and 1 in process segment + segment = create_segment(sequence=0) + hls.put(segment) + for part in create_parts(SEQUENCE_BYTES): + segment.async_add_part(part, 0) + hls.part_put() + complete_segment(segment) + + segment = create_segment(sequence=1) + hls.put(segment) + remaining_parts = create_parts(SEQUENCE_BYTES) + num_completed_parts = len(remaining_parts) // 2 + for _ in range(num_completed_parts): + segment.async_add_part(remaining_parts.pop(0), 0) + + # Make requests for all the existing part segments + # These should succeed with a status of 206 + requests = asyncio.gather( + *( + hls_client.get( + "/segment/1.m4s", + headers={ + "Range": f"bytes={http_range_from_part(part)[1]}-" + + str( + http_range_from_part(part)[0] + + http_range_from_part(part)[1] + - 1 + ) + }, + ) + for part in range(num_completed_parts) + ) + ) + responses = await requests + assert all(response.status == 206 for response in responses) + assert all( + responses[part].headers["Content-Range"] + == f"bytes {http_range_from_part(part)[1]}-" + + str(http_range_from_part(part)[0] + http_range_from_part(part)[1] - 1) + + "/*" + for part in range(num_completed_parts) + ) + parts = list(segment.parts_by_byterange.values()) + assert all( + [await responses[i].read() == parts[i].data for i in range(len(responses))] + ) + + # Make some non standard range requests. + # Request past end of previous closed segment + # Request should succeed but length will be limited to the segment length + response = await hls_client.get( + "/segment/0.m4s", + headers={"Range": f"bytes=0-{hls.get_segment(0).data_size+1}"}, + ) + assert response.status == 206 + assert ( + response.headers["Content-Range"] + == f"bytes 0-{hls.get_segment(0).data_size-1}/{hls.get_segment(0).data_size}" + ) + assert (await response.read()) == hls.get_segment(0).get_data() + + # Request with start range past end of current segment + # Since this is beyond the data we have (the largest starting position will be + # from a hinted request, and even that will have a starting position at + # segment.data_size), we expect a 416. + response = await hls_client.get( + "/segment/1.m4s", + headers={"Range": f"bytes={segment.data_size+1}-{VERY_LARGE_LAST_BYTE_POS}"}, + ) + assert response.status == 416 + + # Request for next segment which has not yet been hinted (we will only hint + # for this segment after segment 1 is complete). + # This should fail, but it will hold for one more part_put before failing. + hls_sync.reset_request_pool(1) + request = asyncio.create_task( + hls_client.get( + "/segment/2.m4s", headers={"Range": f"bytes=0-{VERY_LARGE_LAST_BYTE_POS}"} + ) + ) + await hls_sync.wait_for_handler() + hls.part_put() + response = await request + assert response.status == 404 + + # Make valid request for the current hint. This should succeed, but since + # it is open ended, it won't finish until the segment is complete. + hls_sync.reset_request_pool(1) + request_start = segment.data_size + request = asyncio.create_task( + hls_client.get( + "/segment/1.m4s", + headers={"Range": f"bytes={request_start}-{VERY_LARGE_LAST_BYTE_POS}"}, + ) + ) + # Put the remaining parts and complete the segment + while remaining_parts: + await hls_sync.wait_for_handler() + # Put one more part segment + segment.async_add_part(remaining_parts.pop(0), 0) + hls.part_put() + complete_segment(segment) + # Check the response + response = await request + assert response.status == 206 + assert ( + response.headers["Content-Range"] + == f"bytes {request_start}-{VERY_LARGE_LAST_BYTE_POS}/*" + ) + assert await response.read() == SEQUENCE_BYTES[request_start:] + + # Now the hint should have moved to segment 2 + # The request for segment 2 which failed before should work now + # Also make an equivalent request with no Range parameters that + # will return the same content but with different headers + hls_sync.reset_request_pool(2) + requests = asyncio.gather( + hls_client.get( + "/segment/2.m4s", headers={"Range": f"bytes=0-{VERY_LARGE_LAST_BYTE_POS}"} + ), + hls_client.get("/segment/2.m4s"), + ) + # Put an entire segment and its parts. + segment = create_segment(sequence=2) + hls.put(segment) + remaining_parts = create_parts(ALT_SEQUENCE_BYTES) + for part in remaining_parts: + await hls_sync.wait_for_handler() + segment.async_add_part(part, 0) + hls.part_put() + complete_segment(segment) + # Check the response + responses = await requests + assert responses[0].status == 206 + assert ( + responses[0].headers["Content-Range"] == f"bytes 0-{VERY_LARGE_LAST_BYTE_POS}/*" + ) + assert responses[1].status == 200 + assert "Content-Range" not in responses[1].headers + assert ( + await response.read() == ALT_SEQUENCE_BYTES[: hls.get_segment(2).data_size] + for response in responses + ) + + stream_worker_sync.resume() diff --git a/tests/components/stream/test_recorder.py b/tests/components/stream/test_recorder.py index 31661db3886..b8521205920 100644 --- a/tests/components/stream/test_recorder.py +++ b/tests/components/stream/test_recorder.py @@ -9,7 +9,7 @@ import pytest from homeassistant.components.stream import create_stream from homeassistant.components.stream.const import HLS_PROVIDER, RECORDER_PROVIDER -from homeassistant.components.stream.core import Part, Segment +from homeassistant.components.stream.core import Part from homeassistant.components.stream.fmp4utils import find_box from homeassistant.components.stream.recorder import recorder_save_worker from homeassistant.exceptions import HomeAssistantError @@ -17,7 +17,11 @@ from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from tests.common import async_fire_time_changed -from tests.components.stream.common import generate_h264_video, remux_with_audio +from tests.components.stream.common import ( + DefaultSegment as Segment, + generate_h264_video, + remux_with_audio, +) MAX_ABORT_SEGMENTS = 20 # Abort test to avoid looping forever @@ -122,15 +126,14 @@ def add_parts_to_segment(segment, source): """Add relevant part data to segment for testing recorder.""" moof_locs = list(find_box(source.getbuffer(), b"moof")) + [len(source.getbuffer())] segment.init = source.getbuffer()[: moof_locs[0]].tobytes() - segment.parts = [ - Part( + segment.parts_by_byterange = { + moof_locs[i]: Part( duration=None, has_keyframe=None, - http_range_start=None, data=source.getbuffer()[moof_locs[i] : moof_locs[i + 1]], ) - for i in range(1, len(moof_locs) - 1) - ] + for i in range(len(moof_locs) - 1) + } async def test_recorder_save(tmpdir): @@ -219,7 +222,7 @@ async def test_record_stream_audio( stream_worker_sync.resume() result = av.open( - BytesIO(last_segment.init + last_segment.get_bytes_without_init()), + BytesIO(last_segment.init + last_segment.get_data()), "r", format="mp4", ) diff --git a/tests/components/stream/test_worker.py b/tests/components/stream/test_worker.py index e62a190d7be..16412b28468 100644 --- a/tests/components/stream/test_worker.py +++ b/tests/components/stream/test_worker.py @@ -21,18 +21,27 @@ import threading from unittest.mock import patch import av +import pytest from homeassistant.components.stream import Stream, create_stream from homeassistant.components.stream.const import ( + ATTR_SETTINGS, + CONF_LL_HLS, + CONF_PART_DURATION, + CONF_SEGMENT_DURATION, + DOMAIN, HLS_PROVIDER, MAX_MISSING_DTS, PACKETS_TO_WAIT_FOR_AUDIO, - TARGET_SEGMENT_DURATION, + SEGMENT_DURATION_ADJUSTER, + TARGET_SEGMENT_DURATION_NON_LL_HLS, ) +from homeassistant.components.stream.core import StreamSettings from homeassistant.components.stream.worker import SegmentBuffer, stream_worker from homeassistant.setup import async_setup_component from tests.components.stream.common import generate_h264_video +from tests.components.stream.test_ll_hls import TEST_PART_DURATION STREAM_SOURCE = "some-stream-source" # Formats here are arbitrary, not exercised by tests @@ -43,7 +52,8 @@ AUDIO_SAMPLE_RATE = 11025 KEYFRAME_INTERVAL = 1 # in seconds PACKET_DURATION = fractions.Fraction(1, VIDEO_FRAME_RATE) # in seconds SEGMENT_DURATION = ( - math.ceil(TARGET_SEGMENT_DURATION / KEYFRAME_INTERVAL) * KEYFRAME_INTERVAL + math.ceil(TARGET_SEGMENT_DURATION_NON_LL_HLS / KEYFRAME_INTERVAL) + * KEYFRAME_INTERVAL ) # in seconds TEST_SEQUENCE_LENGTH = 5 * VIDEO_FRAME_RATE LONGER_TEST_SEQUENCE_LENGTH = 20 * VIDEO_FRAME_RATE @@ -53,6 +63,21 @@ SEGMENTS_PER_PACKET = PACKET_DURATION / SEGMENT_DURATION TIMEOUT = 15 +@pytest.fixture(autouse=True) +def mock_stream_settings(hass): + """Set the stream settings data in hass before each test.""" + hass.data[DOMAIN] = { + ATTR_SETTINGS: StreamSettings( + ll_hls=False, + min_segment_duration=TARGET_SEGMENT_DURATION_NON_LL_HLS + - SEGMENT_DURATION_ADJUSTER, + part_target_duration=TARGET_SEGMENT_DURATION_NON_LL_HLS, + hls_advance_part_limit=3, + hls_part_timeout=TARGET_SEGMENT_DURATION_NON_LL_HLS, + ) + } + + class FakeAvInputStream: """A fake pyav Stream.""" @@ -235,7 +260,7 @@ async def async_decode_stream(hass, packets, py_av=None): "homeassistant.components.stream.core.StreamOutput.put", side_effect=py_av.capture_buffer.capture_output_segment, ): - segment_buffer = SegmentBuffer(stream.outputs) + segment_buffer = SegmentBuffer(hass, stream.outputs) stream_worker(STREAM_SOURCE, {}, segment_buffer, threading.Event()) await hass.async_block_till_done() @@ -248,7 +273,7 @@ async def test_stream_open_fails(hass): stream.add_provider(HLS_PROVIDER) with patch("av.open") as av_open: av_open.side_effect = av.error.InvalidDataError(-2, "error") - segment_buffer = SegmentBuffer(stream.outputs) + segment_buffer = SegmentBuffer(hass, stream.outputs) stream_worker(STREAM_SOURCE, {}, segment_buffer, threading.Event()) await hass.async_block_till_done() av_open.assert_called_once() @@ -638,7 +663,7 @@ async def test_worker_log(hass, caplog): stream.add_provider(HLS_PROVIDER) with patch("av.open") as av_open: av_open.side_effect = av.error.InvalidDataError(-2, "error") - segment_buffer = SegmentBuffer(stream.outputs) + segment_buffer = SegmentBuffer(hass, stream.outputs) stream_worker( "https://abcd:efgh@foo.bar", {}, segment_buffer, threading.Event() ) @@ -649,7 +674,17 @@ async def test_worker_log(hass, caplog): async def test_durations(hass, record_worker_sync): """Test that the duration metadata matches the media.""" - await async_setup_component(hass, "stream", {"stream": {}}) + await async_setup_component( + hass, + "stream", + { + "stream": { + CONF_LL_HLS: True, + CONF_SEGMENT_DURATION: SEGMENT_DURATION, + CONF_PART_DURATION: TEST_PART_DURATION, + } + }, + ) source = generate_h264_video() stream = create_stream(hass, source, {}) @@ -664,7 +699,7 @@ async def test_durations(hass, record_worker_sync): # check that the Part duration metadata matches the durations in the media running_metadata_duration = 0 for segment in complete_segments: - for part in segment.parts: + for part in segment.parts_by_byterange.values(): av_part = av.open(io.BytesIO(segment.init + part.data)) running_metadata_duration += part.duration # av_part.duration will just return the largest dts in av_part. @@ -678,7 +713,9 @@ async def test_durations(hass, record_worker_sync): # check that the Part durations are consistent with the Segment durations for segment in complete_segments: assert math.isclose( - sum(part.duration for part in segment.parts), segment.duration, abs_tol=1e-6 + sum(part.duration for part in segment.parts_by_byterange.values()), + segment.duration, + abs_tol=1e-6, ) await record_worker_sync.join() @@ -688,7 +725,19 @@ async def test_durations(hass, record_worker_sync): async def test_has_keyframe(hass, record_worker_sync): """Test that the has_keyframe metadata matches the media.""" - await async_setup_component(hass, "stream", {"stream": {}}) + await async_setup_component( + hass, + "stream", + { + "stream": { + CONF_LL_HLS: True, + CONF_SEGMENT_DURATION: SEGMENT_DURATION, + # Our test video has keyframes every second. Use smaller parts so we have more + # part boundaries to better test keyframe logic. + CONF_PART_DURATION: 0.25, + } + }, + ) source = generate_h264_video() stream = create_stream(hass, source, {}) @@ -697,15 +746,12 @@ async def test_has_keyframe(hass, record_worker_sync): with patch.object(hass.config, "is_allowed_path", return_value=True): await stream.async_record("/example/path") - # Our test video has keyframes every second. Use smaller parts so we have more - # part boundaries to better test keyframe logic. - with patch("homeassistant.components.stream.worker.TARGET_PART_DURATION", 0.25): - complete_segments = list(await record_worker_sync.get_segments())[:-1] + complete_segments = list(await record_worker_sync.get_segments())[:-1] assert len(complete_segments) >= 1 # check that the Part has_keyframe metadata matches the keyframes in the media for segment in complete_segments: - for part in segment.parts: + for part in segment.parts_by_byterange.values(): av_part = av.open(io.BytesIO(segment.init + part.data)) media_has_keyframe = any( packet.is_keyframe for packet in av_part.demux(av_part.streams.video[0]) From 2dddd31d976d2fe4edf6da2b33b8b187a62c7118 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Sat, 28 Aug 2021 21:30:44 -0600 Subject: [PATCH 073/843] Simplify calcuation of Notion binary sensor state (#55387) --- .../components/notion/binary_sensor.py | 68 +++++++++++-------- 1 file changed, 41 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/notion/binary_sensor.py b/homeassistant/components/notion/binary_sensor.py index 15c5877ae77..bfd90010a94 100644 --- a/homeassistant/components/notion/binary_sensor.py +++ b/homeassistant/components/notion/binary_sensor.py @@ -1,6 +1,9 @@ """Support for Notion binary sensors.""" from __future__ import annotations +from dataclasses import dataclass +from typing import Literal + from homeassistant.components.binary_sensor import ( DEVICE_CLASS_BATTERY, DEVICE_CLASS_CONNECTIVITY, @@ -33,56 +36,81 @@ from .const import ( SENSOR_WINDOW_HINGED_VERTICAL, ) + +@dataclass +class NotionBinarySensorDescriptionMixin: + """Define an entity description mixin for binary and regular sensors.""" + + on_state: Literal["alarm", "critical", "leak", "not_missing", "open"] + + +@dataclass +class NotionBinarySensorDescription( + BinarySensorEntityDescription, NotionBinarySensorDescriptionMixin +): + """Describe a Notion binary sensor.""" + + BINARY_SENSOR_DESCRIPTIONS = ( - BinarySensorEntityDescription( + NotionBinarySensorDescription( key=SENSOR_BATTERY, name="Low Battery", device_class=DEVICE_CLASS_BATTERY, + on_state="critical", ), - BinarySensorEntityDescription( + NotionBinarySensorDescription( key=SENSOR_DOOR, name="Door", device_class=DEVICE_CLASS_DOOR, + on_state="open", ), - BinarySensorEntityDescription( + NotionBinarySensorDescription( key=SENSOR_GARAGE_DOOR, name="Garage Door", device_class=DEVICE_CLASS_GARAGE_DOOR, + on_state="open", ), - BinarySensorEntityDescription( + NotionBinarySensorDescription( key=SENSOR_LEAK, name="Leak Detector", device_class=DEVICE_CLASS_MOISTURE, + on_state="leak", ), - BinarySensorEntityDescription( + NotionBinarySensorDescription( key=SENSOR_MISSING, name="Missing", device_class=DEVICE_CLASS_CONNECTIVITY, + on_state="not_missing", ), - BinarySensorEntityDescription( + NotionBinarySensorDescription( key=SENSOR_SAFE, name="Safe", device_class=DEVICE_CLASS_DOOR, + on_state="open", ), - BinarySensorEntityDescription( + NotionBinarySensorDescription( key=SENSOR_SLIDING, name="Sliding Door/Window", device_class=DEVICE_CLASS_DOOR, + on_state="open", ), - BinarySensorEntityDescription( + NotionBinarySensorDescription( key=SENSOR_SMOKE_CO, name="Smoke/Carbon Monoxide Detector", device_class=DEVICE_CLASS_SMOKE, + on_state="alarm", ), - BinarySensorEntityDescription( + NotionBinarySensorDescription( key=SENSOR_WINDOW_HINGED_HORIZONTAL, name="Hinged Window", device_class=DEVICE_CLASS_WINDOW, + on_state="open", ), - BinarySensorEntityDescription( + NotionBinarySensorDescription( key=SENSOR_WINDOW_HINGED_VERTICAL, name="Hinged Window", device_class=DEVICE_CLASS_WINDOW, + on_state="open", ), ) @@ -114,6 +142,8 @@ async def async_setup_entry( class NotionBinarySensor(NotionEntity, BinarySensorEntity): """Define a Notion sensor.""" + entity_description: NotionBinarySensorDescription + @callback def _async_update_from_latest_data(self) -> None: """Fetch new state data for the sensor.""" @@ -127,20 +157,4 @@ class NotionBinarySensor(NotionEntity, BinarySensorEntity): LOGGER.warning("Unknown data payload: %s", task["status"]) state = None - if task["task_type"] == SENSOR_BATTERY: - self._attr_is_on = state == "critical" - elif task["task_type"] in ( - SENSOR_DOOR, - SENSOR_GARAGE_DOOR, - SENSOR_SAFE, - SENSOR_SLIDING, - SENSOR_WINDOW_HINGED_HORIZONTAL, - SENSOR_WINDOW_HINGED_VERTICAL, - ): - self._attr_is_on = state != "closed" - elif task["task_type"] == SENSOR_LEAK: - self._attr_is_on = state != "no_leak" - elif task["task_type"] == SENSOR_MISSING: - self._attr_is_on = state == "not_missing" - elif task["task_type"] == SENSOR_SMOKE_CO: - self._attr_is_on = state != "no_alarm" + self._attr_is_on = self.entity_description.on_state == state From 3647ada1430c1ac55bcb9ce781b3ad0a534d6717 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Sun, 29 Aug 2021 04:31:07 +0100 Subject: [PATCH 074/843] OVO Energy - Post #54952 Cleanup (#55393) --- homeassistant/components/ovo_energy/__init__.py | 2 -- homeassistant/components/ovo_energy/sensor.py | 3 +-- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/homeassistant/components/ovo_energy/__init__.py b/homeassistant/components/ovo_energy/__init__.py index aa05c83ae76..f8c23b4f4f0 100644 --- a/homeassistant/components/ovo_energy/__init__.py +++ b/homeassistant/components/ovo_energy/__init__.py @@ -99,12 +99,10 @@ class OVOEnergyEntity(CoordinatorEntity): self, coordinator: DataUpdateCoordinator, client: OVOEnergy, - key: str, ) -> None: """Initialize the OVO Energy entity.""" super().__init__(coordinator) self._client = client - self._attr_unique_id = key class OVOEnergyDeviceEntity(OVOEnergyEntity): diff --git a/homeassistant/components/ovo_energy/sensor.py b/homeassistant/components/ovo_energy/sensor.py index cd84fa5a5d6..16fd15bfbde 100644 --- a/homeassistant/components/ovo_energy/sensor.py +++ b/homeassistant/components/ovo_energy/sensor.py @@ -57,7 +57,6 @@ SENSOR_TYPES_ELECTRICITY: tuple[OVOEnergySensorEntityDescription, ...] = ( name="OVO Last Electricity Cost", device_class=DEVICE_CLASS_MONETARY, state_class=STATE_CLASS_TOTAL_INCREASING, - icon="mdi:cash-multiple", value=lambda usage: usage.electricity[-1].consumption, ), OVOEnergySensorEntityDescription( @@ -157,8 +156,8 @@ class OVOEnergySensor(OVOEnergyDeviceEntity, SensorEntity): super().__init__( coordinator, client, - f"{DOMAIN}_{client.account_id}_{description.key}", ) + self._attr_unique_id = f"{DOMAIN}_{client.account_id}_{description.key}" self.entity_description = description @property From 4aed0b6ccfb108bf9f57d79ed6d51187aa3a941d Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Sat, 28 Aug 2021 21:31:18 -0600 Subject: [PATCH 075/843] Use EntityDescription - ambient_station (#55366) --- .../components/ambient_station/__init__.py | 350 ++-------- .../ambient_station/binary_sensor.py | 269 ++++++-- .../components/ambient_station/const.py | 4 +- .../components/ambient_station/sensor.py | 614 ++++++++++++++++-- 4 files changed, 817 insertions(+), 420 deletions(-) diff --git a/homeassistant/components/ambient_station/__init__.py b/homeassistant/components/ambient_station/__init__.py index d719f9b3728..201f21c0f17 100644 --- a/homeassistant/components/ambient_station/__init__.py +++ b/homeassistant/components/ambient_station/__init__.py @@ -1,38 +1,17 @@ """Support for Ambient Weather Station Service.""" from __future__ import annotations +from typing import Any + from aioambient import Client from aioambient.errors import WebsocketError -from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_CONNECTIVITY, - DOMAIN as BINARY_SENSOR, -) -from homeassistant.components.sensor import DOMAIN as SENSOR from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_LOCATION, ATTR_NAME, - CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - CONCENTRATION_PARTS_PER_MILLION, CONF_API_KEY, - DEGREE, - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_CO2, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_ILLUMINANCE, - DEVICE_CLASS_PRESSURE, - DEVICE_CLASS_TEMPERATURE, - DEVICE_CLASS_TIMESTAMP, EVENT_HOMEASSISTANT_STOP, - IRRADIATION_WATTS_PER_SQUARE_METER, - LIGHT_LUX, - PERCENTAGE, - PRECIPITATION_INCHES, - PRECIPITATION_INCHES_PER_HOUR, - PRESSURE_INHG, - SPEED_MILES_PER_HOUR, - TEMP_FAHRENHEIT, ) from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady @@ -41,266 +20,43 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.event import async_call_later from .const import ( ATTR_LAST_DATA, - ATTR_MONITORED_CONDITIONS, CONF_APP_KEY, DATA_CLIENT, DOMAIN, LOGGER, + TYPE_SOLARRADIATION, + TYPE_SOLARRADIATION_LX, ) -PLATFORMS = [BINARY_SENSOR, SENSOR] +PLATFORMS = ["binary_sensor", "sensor"] DATA_CONFIG = "config" DEFAULT_SOCKET_MIN_RETRY = 15 -TYPE_24HOURRAININ = "24hourrainin" -TYPE_BAROMABSIN = "baromabsin" -TYPE_BAROMRELIN = "baromrelin" -TYPE_BATT1 = "batt1" -TYPE_BATT10 = "batt10" -TYPE_BATT2 = "batt2" -TYPE_BATT3 = "batt3" -TYPE_BATT4 = "batt4" -TYPE_BATT5 = "batt5" -TYPE_BATT6 = "batt6" -TYPE_BATT7 = "batt7" -TYPE_BATT8 = "batt8" -TYPE_BATT9 = "batt9" -TYPE_BATT_CO2 = "batt_co2" -TYPE_BATTOUT = "battout" -TYPE_CO2 = "co2" -TYPE_DAILYRAININ = "dailyrainin" -TYPE_DEWPOINT = "dewPoint" -TYPE_EVENTRAININ = "eventrainin" -TYPE_FEELSLIKE = "feelsLike" -TYPE_HOURLYRAININ = "hourlyrainin" -TYPE_HUMIDITY = "humidity" -TYPE_HUMIDITY1 = "humidity1" -TYPE_HUMIDITY10 = "humidity10" -TYPE_HUMIDITY2 = "humidity2" -TYPE_HUMIDITY3 = "humidity3" -TYPE_HUMIDITY4 = "humidity4" -TYPE_HUMIDITY5 = "humidity5" -TYPE_HUMIDITY6 = "humidity6" -TYPE_HUMIDITY7 = "humidity7" -TYPE_HUMIDITY8 = "humidity8" -TYPE_HUMIDITY9 = "humidity9" -TYPE_HUMIDITYIN = "humidityin" -TYPE_LASTRAIN = "lastRain" -TYPE_MAXDAILYGUST = "maxdailygust" -TYPE_MONTHLYRAININ = "monthlyrainin" -TYPE_PM25 = "pm25" -TYPE_PM25_24H = "pm25_24h" -TYPE_PM25_BATT = "batt_25" -TYPE_PM25_IN = "pm25_in" -TYPE_PM25_IN_24H = "pm25_in_24h" -TYPE_PM25IN_BATT = "batt_25in" -TYPE_RELAY1 = "relay1" -TYPE_RELAY10 = "relay10" -TYPE_RELAY2 = "relay2" -TYPE_RELAY3 = "relay3" -TYPE_RELAY4 = "relay4" -TYPE_RELAY5 = "relay5" -TYPE_RELAY6 = "relay6" -TYPE_RELAY7 = "relay7" -TYPE_RELAY8 = "relay8" -TYPE_RELAY9 = "relay9" -TYPE_SOILHUM1 = "soilhum1" -TYPE_SOILHUM10 = "soilhum10" -TYPE_SOILHUM2 = "soilhum2" -TYPE_SOILHUM3 = "soilhum3" -TYPE_SOILHUM4 = "soilhum4" -TYPE_SOILHUM5 = "soilhum5" -TYPE_SOILHUM6 = "soilhum6" -TYPE_SOILHUM7 = "soilhum7" -TYPE_SOILHUM8 = "soilhum8" -TYPE_SOILHUM9 = "soilhum9" -TYPE_SOILTEMP1F = "soiltemp1f" -TYPE_SOILTEMP10F = "soiltemp10f" -TYPE_SOILTEMP2F = "soiltemp2f" -TYPE_SOILTEMP3F = "soiltemp3f" -TYPE_SOILTEMP4F = "soiltemp4f" -TYPE_SOILTEMP5F = "soiltemp5f" -TYPE_SOILTEMP6F = "soiltemp6f" -TYPE_SOILTEMP7F = "soiltemp7f" -TYPE_SOILTEMP8F = "soiltemp8f" -TYPE_SOILTEMP9F = "soiltemp9f" -TYPE_SOLARRADIATION = "solarradiation" -TYPE_SOLARRADIATION_LX = "solarradiation_lx" -TYPE_TEMP10F = "temp10f" -TYPE_TEMP1F = "temp1f" -TYPE_TEMP2F = "temp2f" -TYPE_TEMP3F = "temp3f" -TYPE_TEMP4F = "temp4f" -TYPE_TEMP5F = "temp5f" -TYPE_TEMP6F = "temp6f" -TYPE_TEMP7F = "temp7f" -TYPE_TEMP8F = "temp8f" -TYPE_TEMP9F = "temp9f" -TYPE_TEMPF = "tempf" -TYPE_TEMPINF = "tempinf" -TYPE_TOTALRAININ = "totalrainin" -TYPE_UV = "uv" -TYPE_WEEKLYRAININ = "weeklyrainin" -TYPE_WINDDIR = "winddir" -TYPE_WINDDIR_AVG10M = "winddir_avg10m" -TYPE_WINDDIR_AVG2M = "winddir_avg2m" -TYPE_WINDGUSTDIR = "windgustdir" -TYPE_WINDGUSTMPH = "windgustmph" -TYPE_WINDSPDMPH_AVG10M = "windspdmph_avg10m" -TYPE_WINDSPDMPH_AVG2M = "windspdmph_avg2m" -TYPE_WINDSPEEDMPH = "windspeedmph" -TYPE_YEARLYRAININ = "yearlyrainin" -SENSOR_TYPES = { - TYPE_24HOURRAININ: ("24 Hr Rain", PRECIPITATION_INCHES, SENSOR, None), - TYPE_BAROMABSIN: ("Abs Pressure", PRESSURE_INHG, SENSOR, DEVICE_CLASS_PRESSURE), - TYPE_BAROMRELIN: ("Rel Pressure", PRESSURE_INHG, SENSOR, DEVICE_CLASS_PRESSURE), - TYPE_BATT10: ("Battery 10", None, BINARY_SENSOR, DEVICE_CLASS_BATTERY), - TYPE_BATT1: ("Battery 1", None, BINARY_SENSOR, DEVICE_CLASS_BATTERY), - TYPE_BATT2: ("Battery 2", None, BINARY_SENSOR, DEVICE_CLASS_BATTERY), - TYPE_BATT3: ("Battery 3", None, BINARY_SENSOR, DEVICE_CLASS_BATTERY), - TYPE_BATT4: ("Battery 4", None, BINARY_SENSOR, DEVICE_CLASS_BATTERY), - TYPE_BATT5: ("Battery 5", None, BINARY_SENSOR, DEVICE_CLASS_BATTERY), - TYPE_BATT6: ("Battery 6", None, BINARY_SENSOR, DEVICE_CLASS_BATTERY), - TYPE_BATT7: ("Battery 7", None, BINARY_SENSOR, DEVICE_CLASS_BATTERY), - TYPE_BATT8: ("Battery 8", None, BINARY_SENSOR, DEVICE_CLASS_BATTERY), - TYPE_BATT9: ("Battery 9", None, BINARY_SENSOR, DEVICE_CLASS_BATTERY), - TYPE_BATTOUT: ("Battery", None, BINARY_SENSOR, DEVICE_CLASS_BATTERY), - TYPE_BATT_CO2: ("CO2 Battery", None, BINARY_SENSOR, DEVICE_CLASS_BATTERY), - TYPE_CO2: ("co2", CONCENTRATION_PARTS_PER_MILLION, SENSOR, DEVICE_CLASS_CO2), - TYPE_DAILYRAININ: ("Daily Rain", PRECIPITATION_INCHES, SENSOR, None), - TYPE_DEWPOINT: ("Dew Point", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_EVENTRAININ: ("Event Rain", PRECIPITATION_INCHES, SENSOR, None), - TYPE_FEELSLIKE: ("Feels Like", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_HOURLYRAININ: ( - "Hourly Rain Rate", - PRECIPITATION_INCHES_PER_HOUR, - SENSOR, - None, - ), - TYPE_HUMIDITY10: ("Humidity 10", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_HUMIDITY1: ("Humidity 1", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_HUMIDITY2: ("Humidity 2", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_HUMIDITY3: ("Humidity 3", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_HUMIDITY4: ("Humidity 4", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_HUMIDITY5: ("Humidity 5", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_HUMIDITY6: ("Humidity 6", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_HUMIDITY7: ("Humidity 7", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_HUMIDITY8: ("Humidity 8", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_HUMIDITY9: ("Humidity 9", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_HUMIDITY: ("Humidity", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_HUMIDITYIN: ("Humidity In", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_LASTRAIN: ("Last Rain", None, SENSOR, DEVICE_CLASS_TIMESTAMP), - TYPE_MAXDAILYGUST: ("Max Gust", SPEED_MILES_PER_HOUR, SENSOR, None), - TYPE_MONTHLYRAININ: ("Monthly Rain", PRECIPITATION_INCHES, SENSOR, None), - TYPE_PM25_24H: ( - "PM25 24h Avg", - CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - SENSOR, - None, - ), - TYPE_PM25_BATT: ("PM25 Battery", None, BINARY_SENSOR, DEVICE_CLASS_BATTERY), - TYPE_PM25_IN: ( - "PM25 Indoor", - CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - SENSOR, - None, - ), - TYPE_PM25_IN_24H: ( - "PM25 Indoor 24h Avg", - CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - SENSOR, - None, - ), - TYPE_PM25: ("PM25", CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, SENSOR, None), - TYPE_PM25IN_BATT: ( - "PM25 Indoor Battery", - None, - BINARY_SENSOR, - DEVICE_CLASS_BATTERY, - ), - TYPE_RELAY10: ("Relay 10", None, BINARY_SENSOR, DEVICE_CLASS_CONNECTIVITY), - TYPE_RELAY1: ("Relay 1", None, BINARY_SENSOR, DEVICE_CLASS_CONNECTIVITY), - TYPE_RELAY2: ("Relay 2", None, BINARY_SENSOR, DEVICE_CLASS_CONNECTIVITY), - TYPE_RELAY3: ("Relay 3", None, BINARY_SENSOR, DEVICE_CLASS_CONNECTIVITY), - TYPE_RELAY4: ("Relay 4", None, BINARY_SENSOR, DEVICE_CLASS_CONNECTIVITY), - TYPE_RELAY5: ("Relay 5", None, BINARY_SENSOR, DEVICE_CLASS_CONNECTIVITY), - TYPE_RELAY6: ("Relay 6", None, BINARY_SENSOR, DEVICE_CLASS_CONNECTIVITY), - TYPE_RELAY7: ("Relay 7", None, BINARY_SENSOR, DEVICE_CLASS_CONNECTIVITY), - TYPE_RELAY8: ("Relay 8", None, BINARY_SENSOR, DEVICE_CLASS_CONNECTIVITY), - TYPE_RELAY9: ("Relay 9", None, BINARY_SENSOR, DEVICE_CLASS_CONNECTIVITY), - TYPE_SOILHUM10: ("Soil Humidity 10", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_SOILHUM1: ("Soil Humidity 1", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_SOILHUM2: ("Soil Humidity 2", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_SOILHUM3: ("Soil Humidity 3", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_SOILHUM4: ("Soil Humidity 4", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_SOILHUM5: ("Soil Humidity 5", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_SOILHUM6: ("Soil Humidity 6", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_SOILHUM7: ("Soil Humidity 7", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_SOILHUM8: ("Soil Humidity 8", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_SOILHUM9: ("Soil Humidity 9", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_SOILTEMP10F: ( - "Soil Temp 10", - TEMP_FAHRENHEIT, - SENSOR, - DEVICE_CLASS_TEMPERATURE, - ), - TYPE_SOILTEMP1F: ("Soil Temp 1", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_SOILTEMP2F: ("Soil Temp 2", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_SOILTEMP3F: ("Soil Temp 3", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_SOILTEMP4F: ("Soil Temp 4", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_SOILTEMP5F: ("Soil Temp 5", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_SOILTEMP6F: ("Soil Temp 6", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_SOILTEMP7F: ("Soil Temp 7", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_SOILTEMP8F: ("Soil Temp 8", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_SOILTEMP9F: ("Soil Temp 9", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_SOLARRADIATION: ( - "Solar Rad", - IRRADIATION_WATTS_PER_SQUARE_METER, - SENSOR, - None, - ), - TYPE_SOLARRADIATION_LX: ( - "Solar Rad (lx)", - LIGHT_LUX, - SENSOR, - DEVICE_CLASS_ILLUMINANCE, - ), - TYPE_TEMP10F: ("Temp 10", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_TEMP1F: ("Temp 1", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_TEMP2F: ("Temp 2", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_TEMP3F: ("Temp 3", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_TEMP4F: ("Temp 4", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_TEMP5F: ("Temp 5", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_TEMP6F: ("Temp 6", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_TEMP7F: ("Temp 7", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_TEMP8F: ("Temp 8", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_TEMP9F: ("Temp 9", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_TEMPF: ("Temp", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_TEMPINF: ("Inside Temp", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_TOTALRAININ: ("Lifetime Rain", PRECIPITATION_INCHES, SENSOR, None), - TYPE_UV: ("uv", "Index", SENSOR, None), - TYPE_WEEKLYRAININ: ("Weekly Rain", PRECIPITATION_INCHES, SENSOR, None), - TYPE_WINDDIR: ("Wind Dir", DEGREE, SENSOR, None), - TYPE_WINDDIR_AVG10M: ("Wind Dir Avg 10m", DEGREE, SENSOR, None), - TYPE_WINDDIR_AVG2M: ("Wind Dir Avg 2m", SPEED_MILES_PER_HOUR, SENSOR, None), - TYPE_WINDGUSTDIR: ("Gust Dir", DEGREE, SENSOR, None), - TYPE_WINDGUSTMPH: ("Wind Gust", SPEED_MILES_PER_HOUR, SENSOR, None), - TYPE_WINDSPDMPH_AVG10M: ("Wind Avg 10m", SPEED_MILES_PER_HOUR, SENSOR, None), - TYPE_WINDSPDMPH_AVG2M: ("Wind Avg 2m", SPEED_MILES_PER_HOUR, SENSOR, None), - TYPE_WINDSPEEDMPH: ("Wind Speed", SPEED_MILES_PER_HOUR, SENSOR, None), - TYPE_YEARLYRAININ: ("Yearly Rain", PRECIPITATION_INCHES, SENSOR, None), -} - CONFIG_SCHEMA = cv.deprecated(DOMAIN) +@callback +def async_wm2_to_lx(value: float) -> int: + """Calculate illuminance (in lux).""" + return round(value / 0.0079) + + +@callback +def async_hydrate_station_data(data: dict[str, Any]) -> dict[str, Any]: + """Hydrate station data with addition or normalized data.""" + if (irradiation := data.get(TYPE_SOLARRADIATION)) is not None: + data[TYPE_SOLARRADIATION_LX] = async_wm2_to_lx(irradiation) + + return data + + async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up the Ambient PWS as config entry.""" hass.data.setdefault(DOMAIN, {DATA_CLIENT: {}}) @@ -405,13 +161,14 @@ class AmbientStation: def on_data(data: dict) -> None: """Define a handler to fire when the data is received.""" - mac_address = data["macAddress"] - if data != self.stations[mac_address][ATTR_LAST_DATA]: - LOGGER.debug("New data received: %s", data) - self.stations[mac_address][ATTR_LAST_DATA] = data - async_dispatcher_send( - self._hass, f"ambient_station_data_update_{mac_address}" - ) + mac = data["macAddress"] + + if data == self.stations[mac][ATTR_LAST_DATA]: + return + + LOGGER.debug("New data received: %s", data) + self.stations[mac][ATTR_LAST_DATA] = async_hydrate_station_data(data) + async_dispatcher_send(self._hass, f"ambient_station_data_update_{mac}") def on_disconnect() -> None: """Define a handler to fire when the websocket is disconnected.""" @@ -420,26 +177,19 @@ class AmbientStation: def on_subscribed(data: dict) -> None: """Define a handler to fire when the subscription is set.""" for station in data["devices"]: - if station["macAddress"] in self.stations: + mac = station["macAddress"] + + if mac in self.stations: continue + LOGGER.debug("New station subscription: %s", data) - # Only create entities based on the data coming through the socket. - # If the user is monitoring brightness (in W/m^2), make sure we also - # add a calculated sensor for the same data measured in lx: - monitored_conditions = [ - k for k in station["lastData"] if k in SENSOR_TYPES - ] - if TYPE_SOLARRADIATION in monitored_conditions: - monitored_conditions.append(TYPE_SOLARRADIATION_LX) - self.stations[station["macAddress"]] = { - ATTR_LAST_DATA: station["lastData"], + self.stations[mac] = { + ATTR_LAST_DATA: async_hydrate_station_data(station["lastData"]), ATTR_LOCATION: station.get("info", {}).get("location"), - ATTR_MONITORED_CONDITIONS: monitored_conditions, - ATTR_NAME: station.get("info", {}).get( - "name", station["macAddress"] - ), + ATTR_NAME: station.get("info", {}).get("name", mac), } + # If the websocket disconnects and reconnects, the on_subscribed # handler will get called again; in that case, we don't want to # attempt forward setup of the config entry (because it will have @@ -466,28 +216,26 @@ class AmbientStation: class AmbientWeatherEntity(Entity): """Define a base Ambient PWS entity.""" + _attr_should_poll = False + def __init__( self, ambient: AmbientStation, mac_address: str, station_name: str, - sensor_type: str, - sensor_name: str, - device_class: str | None, + description: EntityDescription, ) -> None: """Initialize the sensor.""" self._ambient = ambient - self._attr_device_class = device_class self._attr_device_info = { "identifiers": {(DOMAIN, mac_address)}, "name": station_name, "manufacturer": "Ambient Weather", } - self._attr_name = f"{station_name}_{sensor_name}" - self._attr_should_poll = False - self._attr_unique_id = f"{mac_address}_{sensor_type}" + self._attr_name = f"{station_name}_{description.name}" + self._attr_unique_id = f"{mac_address}_{description.key}" self._mac_address = mac_address - self._sensor_type = sensor_type + self.entity_description = description async def async_added_to_hass(self) -> None: """Register callbacks.""" @@ -495,18 +243,18 @@ class AmbientWeatherEntity(Entity): @callback def update() -> None: """Update the state.""" - if self._sensor_type == TYPE_SOLARRADIATION_LX: + if self.entity_description.key == TYPE_SOLARRADIATION_LX: self._attr_available = ( - self._ambient.stations[self._mac_address][ATTR_LAST_DATA].get( + self._ambient.stations[self._mac_address][ATTR_LAST_DATA][ TYPE_SOLARRADIATION - ) + ] is not None ) else: self._attr_available = ( - self._ambient.stations[self._mac_address][ATTR_LAST_DATA].get( - self._sensor_type - ) + self._ambient.stations[self._mac_address][ATTR_LAST_DATA][ + self.entity_description.key + ] is not None ) diff --git a/homeassistant/components/ambient_station/binary_sensor.py b/homeassistant/components/ambient_station/binary_sensor.py index 093a582791e..e513486fb85 100644 --- a/homeassistant/components/ambient_station/binary_sensor.py +++ b/homeassistant/components/ambient_station/binary_sensor.py @@ -1,34 +1,209 @@ """Support for Ambient Weather Station binary sensors.""" from __future__ import annotations +from dataclasses import dataclass +from typing import Literal + from homeassistant.components.binary_sensor import ( - DOMAIN as BINARY_SENSOR, + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_CONNECTIVITY, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ( - SENSOR_TYPES, - TYPE_BATT1, - TYPE_BATT2, - TYPE_BATT3, - TYPE_BATT4, - TYPE_BATT5, - TYPE_BATT6, - TYPE_BATT7, - TYPE_BATT8, - TYPE_BATT9, - TYPE_BATT10, - TYPE_BATT_CO2, - TYPE_BATTOUT, - TYPE_PM25_BATT, - TYPE_PM25IN_BATT, - AmbientWeatherEntity, +from . import AmbientWeatherEntity +from .const import ATTR_LAST_DATA, DATA_CLIENT, DOMAIN + +TYPE_BATT1 = "batt1" +TYPE_BATT10 = "batt10" +TYPE_BATT2 = "batt2" +TYPE_BATT3 = "batt3" +TYPE_BATT4 = "batt4" +TYPE_BATT5 = "batt5" +TYPE_BATT6 = "batt6" +TYPE_BATT7 = "batt7" +TYPE_BATT8 = "batt8" +TYPE_BATT9 = "batt9" +TYPE_BATT_CO2 = "batt_co2" +TYPE_BATTOUT = "battout" +TYPE_PM25_BATT = "batt_25" +TYPE_PM25IN_BATT = "batt_25in" +TYPE_RELAY1 = "relay1" +TYPE_RELAY10 = "relay10" +TYPE_RELAY2 = "relay2" +TYPE_RELAY3 = "relay3" +TYPE_RELAY4 = "relay4" +TYPE_RELAY5 = "relay5" +TYPE_RELAY6 = "relay6" +TYPE_RELAY7 = "relay7" +TYPE_RELAY8 = "relay8" +TYPE_RELAY9 = "relay9" + + +@dataclass +class AmbientBinarySensorDescriptionMixin: + """Define an entity description mixin for binary sensors.""" + + on_state: Literal[0, 1] + + +@dataclass +class AmbientBinarySensorDescription( + BinarySensorEntityDescription, AmbientBinarySensorDescriptionMixin +): + """Describe an Ambient PWS binary sensor.""" + + +BINARY_SENSOR_DESCRIPTIONS = ( + AmbientBinarySensorDescription( + key=TYPE_BATTOUT, + name="Battery", + device_class=DEVICE_CLASS_BATTERY, + on_state=0, + ), + AmbientBinarySensorDescription( + key=TYPE_BATT1, + name="Battery 1", + device_class=DEVICE_CLASS_BATTERY, + on_state=0, + ), + AmbientBinarySensorDescription( + key=TYPE_BATT2, + name="Battery 2", + device_class=DEVICE_CLASS_BATTERY, + on_state=0, + ), + AmbientBinarySensorDescription( + key=TYPE_BATT3, + name="Battery 3", + device_class=DEVICE_CLASS_BATTERY, + on_state=0, + ), + AmbientBinarySensorDescription( + key=TYPE_BATT4, + name="Battery 4", + device_class=DEVICE_CLASS_BATTERY, + on_state=0, + ), + AmbientBinarySensorDescription( + key=TYPE_BATT5, + name="Battery 5", + device_class=DEVICE_CLASS_BATTERY, + on_state=0, + ), + AmbientBinarySensorDescription( + key=TYPE_BATT6, + name="Battery 6", + device_class=DEVICE_CLASS_BATTERY, + on_state=0, + ), + AmbientBinarySensorDescription( + key=TYPE_BATT7, + name="Battery 7", + device_class=DEVICE_CLASS_BATTERY, + on_state=0, + ), + AmbientBinarySensorDescription( + key=TYPE_BATT8, + name="Battery 8", + device_class=DEVICE_CLASS_BATTERY, + on_state=0, + ), + AmbientBinarySensorDescription( + key=TYPE_BATT9, + name="Battery 9", + device_class=DEVICE_CLASS_BATTERY, + on_state=0, + ), + AmbientBinarySensorDescription( + key=TYPE_BATT10, + name="Battery 10", + device_class=DEVICE_CLASS_BATTERY, + on_state=0, + ), + AmbientBinarySensorDescription( + key=TYPE_BATT_CO2, + name="CO2 Battery", + device_class=DEVICE_CLASS_BATTERY, + on_state=0, + ), + AmbientBinarySensorDescription( + key=TYPE_PM25IN_BATT, + name="PM25 Indoor Battery", + device_class=DEVICE_CLASS_BATTERY, + on_state=0, + ), + AmbientBinarySensorDescription( + key=TYPE_PM25_BATT, + name="PM25 Battery", + device_class=DEVICE_CLASS_BATTERY, + on_state=0, + ), + AmbientBinarySensorDescription( + key=TYPE_RELAY1, + name="Relay 1", + device_class=DEVICE_CLASS_CONNECTIVITY, + on_state=1, + ), + AmbientBinarySensorDescription( + key=TYPE_RELAY2, + name="Relay 2", + device_class=DEVICE_CLASS_CONNECTIVITY, + on_state=1, + ), + AmbientBinarySensorDescription( + key=TYPE_RELAY3, + name="Relay 3", + device_class=DEVICE_CLASS_CONNECTIVITY, + on_state=1, + ), + AmbientBinarySensorDescription( + key=TYPE_RELAY4, + name="Relay 4", + device_class=DEVICE_CLASS_CONNECTIVITY, + on_state=1, + ), + AmbientBinarySensorDescription( + key=TYPE_RELAY5, + name="Relay 5", + device_class=DEVICE_CLASS_CONNECTIVITY, + on_state=1, + ), + AmbientBinarySensorDescription( + key=TYPE_RELAY6, + name="Relay 6", + device_class=DEVICE_CLASS_CONNECTIVITY, + on_state=1, + ), + AmbientBinarySensorDescription( + key=TYPE_RELAY7, + name="Relay 7", + device_class=DEVICE_CLASS_CONNECTIVITY, + on_state=1, + ), + AmbientBinarySensorDescription( + key=TYPE_RELAY8, + name="Relay 8", + device_class=DEVICE_CLASS_CONNECTIVITY, + on_state=1, + ), + AmbientBinarySensorDescription( + key=TYPE_RELAY9, + name="Relay 9", + device_class=DEVICE_CLASS_CONNECTIVITY, + on_state=1, + ), + AmbientBinarySensorDescription( + key=TYPE_RELAY10, + name="Relay 10", + device_class=DEVICE_CLASS_CONNECTIVITY, + on_state=1, + ), ) -from .const import ATTR_LAST_DATA, ATTR_MONITORED_CONDITIONS, DATA_CLIENT, DOMAIN async def async_setup_entry( @@ -37,51 +212,29 @@ async def async_setup_entry( """Set up Ambient PWS binary sensors based on a config entry.""" ambient = hass.data[DOMAIN][DATA_CLIENT][entry.entry_id] - binary_sensor_list = [] - for mac_address, station in ambient.stations.items(): - for condition in station[ATTR_MONITORED_CONDITIONS]: - name, _, kind, device_class = SENSOR_TYPES[condition] - if kind == BINARY_SENSOR: - binary_sensor_list.append( - AmbientWeatherBinarySensor( - ambient, - mac_address, - station[ATTR_NAME], - condition, - name, - device_class, - ) - ) - - async_add_entities(binary_sensor_list) + async_add_entities( + [ + AmbientWeatherBinarySensor( + ambient, mac_address, station[ATTR_NAME], description + ) + for mac_address, station in ambient.stations.items() + for description in BINARY_SENSOR_DESCRIPTIONS + if description.key in station[ATTR_LAST_DATA] + ] + ) class AmbientWeatherBinarySensor(AmbientWeatherEntity, BinarySensorEntity): """Define an Ambient binary sensor.""" + entity_description: AmbientBinarySensorDescription + @callback def update_from_latest_data(self) -> None: """Fetch new state data for the entity.""" - state = self._ambient.stations[self._mac_address][ATTR_LAST_DATA].get( - self._sensor_type + self._attr_is_on = ( + self._ambient.stations[self._mac_address][ATTR_LAST_DATA][ + self.entity_description.key + ] + == self.entity_description.on_state ) - - if self._sensor_type in ( - TYPE_BATT1, - TYPE_BATT10, - TYPE_BATT2, - TYPE_BATT3, - TYPE_BATT4, - TYPE_BATT5, - TYPE_BATT6, - TYPE_BATT7, - TYPE_BATT8, - TYPE_BATT9, - TYPE_BATT_CO2, - TYPE_BATTOUT, - TYPE_PM25_BATT, - TYPE_PM25IN_BATT, - ): - self._attr_is_on = state == 0 - else: - self._attr_is_on = state == 1 diff --git a/homeassistant/components/ambient_station/const.py b/homeassistant/components/ambient_station/const.py index 87b5ff61877..cf5c97be045 100644 --- a/homeassistant/components/ambient_station/const.py +++ b/homeassistant/components/ambient_station/const.py @@ -5,8 +5,10 @@ DOMAIN = "ambient_station" LOGGER = logging.getLogger(__package__) ATTR_LAST_DATA = "last_data" -ATTR_MONITORED_CONDITIONS = "monitored_conditions" CONF_APP_KEY = "app_key" DATA_CLIENT = "data_client" + +TYPE_SOLARRADIATION = "solarradiation" +TYPE_SOLARRADIATION_LX = "solarradiation_lx" diff --git a/homeassistant/components/ambient_station/sensor.py b/homeassistant/components/ambient_station/sensor.py index 935a53e9384..0a77f6c7dd6 100644 --- a/homeassistant/components/ambient_station/sensor.py +++ b/homeassistant/components/ambient_station/sensor.py @@ -1,20 +1,554 @@ """Support for Ambient Weather Station sensors.""" from __future__ import annotations -from homeassistant.components.sensor import DOMAIN as SENSOR, SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_NAME +from homeassistant.const import ( + ATTR_NAME, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_MILLION, + DEGREE, + DEVICE_CLASS_CO2, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_PM25, + DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_TIMESTAMP, + IRRADIATION_WATTS_PER_SQUARE_METER, + LIGHT_LUX, + PERCENTAGE, + PRECIPITATION_INCHES, + PRECIPITATION_INCHES_PER_HOUR, + PRESSURE_INHG, + SPEED_MILES_PER_HOUR, + TEMP_FAHRENHEIT, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ( - SENSOR_TYPES, - TYPE_SOLARRADIATION, - TYPE_SOLARRADIATION_LX, - AmbientStation, - AmbientWeatherEntity, +from . import TYPE_SOLARRADIATION, TYPE_SOLARRADIATION_LX, AmbientWeatherEntity +from .const import ATTR_LAST_DATA, DATA_CLIENT, DOMAIN + +TYPE_24HOURRAININ = "24hourrainin" +TYPE_BAROMABSIN = "baromabsin" +TYPE_BAROMRELIN = "baromrelin" +TYPE_CO2 = "co2" +TYPE_DAILYRAININ = "dailyrainin" +TYPE_DEWPOINT = "dewPoint" +TYPE_EVENTRAININ = "eventrainin" +TYPE_FEELSLIKE = "feelsLike" +TYPE_HOURLYRAININ = "hourlyrainin" +TYPE_HUMIDITY = "humidity" +TYPE_HUMIDITY1 = "humidity1" +TYPE_HUMIDITY10 = "humidity10" +TYPE_HUMIDITY2 = "humidity2" +TYPE_HUMIDITY3 = "humidity3" +TYPE_HUMIDITY4 = "humidity4" +TYPE_HUMIDITY5 = "humidity5" +TYPE_HUMIDITY6 = "humidity6" +TYPE_HUMIDITY7 = "humidity7" +TYPE_HUMIDITY8 = "humidity8" +TYPE_HUMIDITY9 = "humidity9" +TYPE_HUMIDITYIN = "humidityin" +TYPE_LASTRAIN = "lastRain" +TYPE_MAXDAILYGUST = "maxdailygust" +TYPE_MONTHLYRAININ = "monthlyrainin" +TYPE_PM25 = "pm25" +TYPE_PM25_24H = "pm25_24h" +TYPE_PM25_IN = "pm25_in" +TYPE_PM25_IN_24H = "pm25_in_24h" +TYPE_SOILHUM1 = "soilhum1" +TYPE_SOILHUM10 = "soilhum10" +TYPE_SOILHUM2 = "soilhum2" +TYPE_SOILHUM3 = "soilhum3" +TYPE_SOILHUM4 = "soilhum4" +TYPE_SOILHUM5 = "soilhum5" +TYPE_SOILHUM6 = "soilhum6" +TYPE_SOILHUM7 = "soilhum7" +TYPE_SOILHUM8 = "soilhum8" +TYPE_SOILHUM9 = "soilhum9" +TYPE_SOILTEMP1F = "soiltemp1f" +TYPE_SOILTEMP10F = "soiltemp10f" +TYPE_SOILTEMP2F = "soiltemp2f" +TYPE_SOILTEMP3F = "soiltemp3f" +TYPE_SOILTEMP4F = "soiltemp4f" +TYPE_SOILTEMP5F = "soiltemp5f" +TYPE_SOILTEMP6F = "soiltemp6f" +TYPE_SOILTEMP7F = "soiltemp7f" +TYPE_SOILTEMP8F = "soiltemp8f" +TYPE_SOILTEMP9F = "soiltemp9f" +TYPE_TEMP10F = "temp10f" +TYPE_TEMP1F = "temp1f" +TYPE_TEMP2F = "temp2f" +TYPE_TEMP3F = "temp3f" +TYPE_TEMP4F = "temp4f" +TYPE_TEMP5F = "temp5f" +TYPE_TEMP6F = "temp6f" +TYPE_TEMP7F = "temp7f" +TYPE_TEMP8F = "temp8f" +TYPE_TEMP9F = "temp9f" +TYPE_TEMPF = "tempf" +TYPE_TEMPINF = "tempinf" +TYPE_TOTALRAININ = "totalrainin" +TYPE_UV = "uv" +TYPE_WEEKLYRAININ = "weeklyrainin" +TYPE_WINDDIR = "winddir" +TYPE_WINDDIR_AVG10M = "winddir_avg10m" +TYPE_WINDDIR_AVG2M = "winddir_avg2m" +TYPE_WINDGUSTDIR = "windgustdir" +TYPE_WINDGUSTMPH = "windgustmph" +TYPE_WINDSPDMPH_AVG10M = "windspdmph_avg10m" +TYPE_WINDSPDMPH_AVG2M = "windspdmph_avg2m" +TYPE_WINDSPEEDMPH = "windspeedmph" +TYPE_YEARLYRAININ = "yearlyrainin" + +SENSOR_DESCRIPTIONS = ( + SensorEntityDescription( + key=TYPE_24HOURRAININ, + name="24 Hr Rain", + icon="mdi:water", + native_unit_of_measurement=PRECIPITATION_INCHES, + ), + SensorEntityDescription( + key=TYPE_BAROMABSIN, + name="Abs Pressure", + native_unit_of_measurement=PRESSURE_INHG, + device_class=DEVICE_CLASS_PRESSURE, + ), + SensorEntityDescription( + key=TYPE_BAROMRELIN, + name="Rel Pressure", + native_unit_of_measurement=PRESSURE_INHG, + device_class=DEVICE_CLASS_PRESSURE, + ), + SensorEntityDescription( + key=TYPE_CO2, + name="co2", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + device_class=DEVICE_CLASS_CO2, + ), + SensorEntityDescription( + key=TYPE_DAILYRAININ, + name="Daily Rain", + icon="mdi:water", + native_unit_of_measurement=PRECIPITATION_INCHES, + ), + SensorEntityDescription( + key=TYPE_DEWPOINT, + name="Dew Point", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key=TYPE_EVENTRAININ, + name="Event Rain", + icon="mdi:water", + native_unit_of_measurement=PRECIPITATION_INCHES, + ), + SensorEntityDescription( + key=TYPE_FEELSLIKE, + name="Feels Like", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key=TYPE_HOURLYRAININ, + name="Hourly Rain Rate", + icon="mdi:water", + native_unit_of_measurement=PRECIPITATION_INCHES_PER_HOUR, + ), + SensorEntityDescription( + key=TYPE_HUMIDITY10, + name="Humidity 10", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_HUMIDITY1, + name="Humidity 1", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_HUMIDITY2, + name="Humidity 2", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_HUMIDITY3, + name="Humidity 3", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_HUMIDITY4, + name="Humidity 4", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_HUMIDITY5, + name="Humidity 5", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_HUMIDITY6, + name="Humidity 6", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_HUMIDITY7, + name="Humidity 7", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_HUMIDITY8, + name="Humidity 8", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_HUMIDITY9, + name="Humidity 9", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_HUMIDITY, + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_HUMIDITYIN, + name="Humidity In", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_LASTRAIN, + name="Last Rain", + icon="mdi:water", + device_class=DEVICE_CLASS_TIMESTAMP, + ), + SensorEntityDescription( + key=TYPE_MAXDAILYGUST, + name="Max Gust", + icon="mdi:weather-windy", + native_unit_of_measurement=SPEED_MILES_PER_HOUR, + ), + SensorEntityDescription( + key=TYPE_MONTHLYRAININ, + name="Monthly Rain", + icon="mdi:water", + native_unit_of_measurement=PRECIPITATION_INCHES, + ), + SensorEntityDescription( + key=TYPE_PM25_24H, + name="PM25 24h Avg", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=DEVICE_CLASS_PM25, + ), + SensorEntityDescription( + key=TYPE_PM25_IN, + name="PM25 Indoor", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=DEVICE_CLASS_PM25, + ), + SensorEntityDescription( + key=TYPE_PM25_IN_24H, + name="PM25 Indoor 24h Avg", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=DEVICE_CLASS_PM25, + ), + SensorEntityDescription( + key=TYPE_PM25, + name="PM25", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=DEVICE_CLASS_PM25, + ), + SensorEntityDescription( + key=TYPE_SOILHUM10, + name="Soil Humidity 10", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_SOILHUM1, + name="Soil Humidity 1", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_SOILHUM2, + name="Soil Humidity 2", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_SOILHUM3, + name="Soil Humidity 3", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_SOILHUM4, + name="Soil Humidity 4", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_SOILHUM5, + name="Soil Humidity 5", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_SOILHUM6, + name="Soil Humidity 6", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_SOILHUM7, + name="Soil Humidity 7", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_SOILHUM8, + name="Soil Humidity 8", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_SOILHUM9, + name="Soil Humidity 9", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_SOILTEMP10F, + name="Soil Temp 10", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key=TYPE_SOILTEMP1F, + name="Soil Temp 1", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key=TYPE_SOILTEMP2F, + name="Soil Temp 2", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key=TYPE_SOILTEMP3F, + name="Soil Temp 3", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key=TYPE_SOILTEMP4F, + name="Soil Temp 4", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key=TYPE_SOILTEMP5F, + name="Soil Temp 5", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key=TYPE_SOILTEMP6F, + name="Soil Temp 6", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key=TYPE_SOILTEMP7F, + name="Soil Temp 7", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key=TYPE_SOILTEMP8F, + name="Soil Temp 8", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key=TYPE_SOILTEMP9F, + name="Soil Temp 9", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key=TYPE_SOLARRADIATION, + name="Solar Rad", + native_unit_of_measurement=IRRADIATION_WATTS_PER_SQUARE_METER, + device_class=DEVICE_CLASS_ILLUMINANCE, + ), + SensorEntityDescription( + key=TYPE_SOLARRADIATION_LX, + name="Solar Rad (lx)", + native_unit_of_measurement=LIGHT_LUX, + device_class=DEVICE_CLASS_ILLUMINANCE, + ), + SensorEntityDescription( + key=TYPE_TEMP10F, + name="Temp 10", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key=TYPE_TEMP1F, + name="Temp 1", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key=TYPE_TEMP2F, + name="Temp 2", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key=TYPE_TEMP3F, + name="Temp 3", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key=TYPE_TEMP4F, + name="Temp 4", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key=TYPE_TEMP5F, + name="Temp 5", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key=TYPE_TEMP6F, + name="Temp 6", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key=TYPE_TEMP7F, + name="Temp 7", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key=TYPE_TEMP8F, + name="Temp 8", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key=TYPE_TEMP9F, + name="Temp 9", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key=TYPE_TEMPF, + name="Temp", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key=TYPE_TEMPINF, + name="Inside Temp", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key=TYPE_TOTALRAININ, + name="Lifetime Rain", + icon="mdi:water", + native_unit_of_measurement=PRECIPITATION_INCHES, + ), + SensorEntityDescription( + key=TYPE_UV, + name="UV Index", + native_unit_of_measurement="Index", + device_class=DEVICE_CLASS_ILLUMINANCE, + ), + SensorEntityDescription( + key=TYPE_WEEKLYRAININ, + name="Weekly Rain", + icon="mdi:water", + native_unit_of_measurement=PRECIPITATION_INCHES, + ), + SensorEntityDescription( + key=TYPE_WINDDIR, + name="Wind Dir", + icon="mdi:weather-windy", + native_unit_of_measurement=DEGREE, + ), + SensorEntityDescription( + key=TYPE_WINDDIR_AVG10M, + name="Wind Dir Avg 10m", + icon="mdi:weather-windy", + native_unit_of_measurement=DEGREE, + ), + SensorEntityDescription( + key=TYPE_WINDDIR_AVG2M, + name="Wind Dir Avg 2m", + icon="mdi:weather-windy", + native_unit_of_measurement=SPEED_MILES_PER_HOUR, + ), + SensorEntityDescription( + key=TYPE_WINDGUSTDIR, + name="Gust Dir", + icon="mdi:weather-windy", + native_unit_of_measurement=DEGREE, + ), + SensorEntityDescription( + key=TYPE_WINDGUSTMPH, + name="Wind Gust", + icon="mdi:weather-windy", + native_unit_of_measurement=SPEED_MILES_PER_HOUR, + ), + SensorEntityDescription( + key=TYPE_WINDSPDMPH_AVG10M, + name="Wind Avg 10m", + icon="mdi:weather-windy", + native_unit_of_measurement=SPEED_MILES_PER_HOUR, + ), + SensorEntityDescription( + key=TYPE_WINDSPDMPH_AVG2M, + name="Wind Avg 2m", + icon="mdi:weather-windy", + native_unit_of_measurement=SPEED_MILES_PER_HOUR, + ), + SensorEntityDescription( + key=TYPE_WINDSPEEDMPH, + name="Wind Speed", + icon="mdi:weather-windy", + native_unit_of_measurement=SPEED_MILES_PER_HOUR, + ), + SensorEntityDescription( + key=TYPE_YEARLYRAININ, + name="Yearly Rain", + icon="mdi:water", + native_unit_of_measurement=PRECIPITATION_INCHES, + ), ) -from .const import ATTR_LAST_DATA, ATTR_MONITORED_CONDITIONS, DATA_CLIENT, DOMAIN async def async_setup_entry( @@ -23,62 +557,22 @@ async def async_setup_entry( """Set up Ambient PWS sensors based on a config entry.""" ambient = hass.data[DOMAIN][DATA_CLIENT][entry.entry_id] - sensor_list = [] - for mac_address, station in ambient.stations.items(): - for condition in station[ATTR_MONITORED_CONDITIONS]: - name, unit, kind, device_class = SENSOR_TYPES[condition] - if kind == SENSOR: - sensor_list.append( - AmbientWeatherSensor( - ambient, - mac_address, - station[ATTR_NAME], - condition, - name, - device_class, - unit, - ) - ) - - async_add_entities(sensor_list) + async_add_entities( + [ + AmbientWeatherSensor(ambient, mac_address, station[ATTR_NAME], description) + for mac_address, station in ambient.stations.items() + for description in SENSOR_DESCRIPTIONS + if description.key in station[ATTR_LAST_DATA] + ] + ) class AmbientWeatherSensor(AmbientWeatherEntity, SensorEntity): """Define an Ambient sensor.""" - def __init__( - self, - ambient: AmbientStation, - mac_address: str, - station_name: str, - sensor_type: str, - sensor_name: str, - device_class: str | None, - unit: str | None, - ) -> None: - """Initialize the sensor.""" - super().__init__( - ambient, mac_address, station_name, sensor_type, sensor_name, device_class - ) - - self._attr_native_unit_of_measurement = unit - @callback def update_from_latest_data(self) -> None: """Fetch new state data for the sensor.""" - if self._sensor_type == TYPE_SOLARRADIATION_LX: - # If the user requests the solarradiation_lx sensor, use the - # value of the solarradiation sensor and apply a very accurate - # approximation of converting sunlight W/m^2 to lx: - w_m2_brightness_val = self._ambient.stations[self._mac_address][ - ATTR_LAST_DATA - ].get(TYPE_SOLARRADIATION) - - if w_m2_brightness_val is None: - self._attr_native_value = None - else: - self._attr_native_value = round(float(w_m2_brightness_val) / 0.0079) - else: - self._attr_native_value = self._ambient.stations[self._mac_address][ - ATTR_LAST_DATA - ].get(self._sensor_type) + self._attr_native_value = self._ambient.stations[self._mac_address][ + ATTR_LAST_DATA + ][self.entity_description.key] From 43b83535669816db50945dd465018ecb9c32b426 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Aug 2021 02:01:04 -0500 Subject: [PATCH 076/843] Show device_id in HomeKit when the device registry entry is missing a name (#55391) - Reported at: https://community.home-assistant.io/t/homekit-unknown-error-occurred/333385 --- homeassistant/components/homekit/config_flow.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/homekit/config_flow.py b/homeassistant/components/homekit/config_flow.py index fdad10f873f..03df55a9026 100644 --- a/homeassistant/components/homekit/config_flow.py +++ b/homeassistant/components/homekit/config_flow.py @@ -498,7 +498,10 @@ async def _async_get_supported_devices(hass): """Return all supported devices.""" results = await device_automation.async_get_device_automations(hass, "trigger") dev_reg = device_registry.async_get(hass) - unsorted = {device_id: dev_reg.async_get(device_id).name for device_id in results} + unsorted = { + device_id: dev_reg.async_get(device_id).name or device_id + for device_id in results + } return dict(sorted(unsorted.items(), key=lambda item: item[1])) From fd66120d6d13ff4719b8042e2cd6479741206124 Mon Sep 17 00:00:00 2001 From: Matt Krasowski <4535195+mkrasowski@users.noreply.github.com> Date: Sun, 29 Aug 2021 08:52:12 -0400 Subject: [PATCH 077/843] Handle incorrect values reported by some Shelly devices (#55042) --- homeassistant/components/shelly/binary_sensor.py | 6 ++++-- homeassistant/components/shelly/sensor.py | 2 ++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/shelly/binary_sensor.py b/homeassistant/components/shelly/binary_sensor.py index 96d62152830..f4b2daf8159 100644 --- a/homeassistant/components/shelly/binary_sensor.py +++ b/homeassistant/components/shelly/binary_sensor.py @@ -1,7 +1,7 @@ """Binary sensor for Shelly.""" from __future__ import annotations -from typing import Final +from typing import Final, cast from homeassistant.components.binary_sensor import ( DEVICE_CLASS_CONNECTIVITY, @@ -46,7 +46,9 @@ SENSORS: Final = { name="Overpowering", device_class=DEVICE_CLASS_PROBLEM ), ("sensor", "dwIsOpened"): BlockAttributeDescription( - name="Door", device_class=DEVICE_CLASS_OPENING + name="Door", + device_class=DEVICE_CLASS_OPENING, + available=lambda block: cast(bool, block.dwIsOpened != -1), ), ("sensor", "flood"): BlockAttributeDescription( name="Flood", device_class=DEVICE_CLASS_MOISTURE diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 13cf56d3b3d..d8d530ed94c 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -40,6 +40,7 @@ SENSORS: Final = { device_class=sensor.DEVICE_CLASS_BATTERY, state_class=sensor.STATE_CLASS_MEASUREMENT, removal_condition=lambda settings, _: settings.get("external_power") == 1, + available=lambda block: cast(bool, block.battery != -1), ), ("device", "deviceTemp"): BlockAttributeDescription( name="Device Temperature", @@ -176,6 +177,7 @@ SENSORS: Final = { unit=LIGHT_LUX, device_class=sensor.DEVICE_CLASS_ILLUMINANCE, state_class=sensor.STATE_CLASS_MEASUREMENT, + available=lambda block: cast(bool, block.luminosity != -1), ), ("sensor", "tilt"): BlockAttributeDescription( name="Tilt", From 8b436c43f76f7476073759555026c5d3e11fe354 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sun, 29 Aug 2021 18:57:18 +0200 Subject: [PATCH 078/843] Enable basic type checking for cert_expiry (#55335) --- homeassistant/components/cert_expiry/__init__.py | 3 ++- homeassistant/components/cert_expiry/config_flow.py | 4 +++- mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/cert_expiry/__init__.py b/homeassistant/components/cert_expiry/__init__.py index c4381b65c49..babf81048df 100644 --- a/homeassistant/components/cert_expiry/__init__.py +++ b/homeassistant/components/cert_expiry/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import datetime, timedelta import logging +from typing import Optional from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT @@ -44,7 +45,7 @@ async def async_unload_entry(hass, entry): return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -class CertExpiryDataUpdateCoordinator(DataUpdateCoordinator[datetime]): +class CertExpiryDataUpdateCoordinator(DataUpdateCoordinator[Optional[datetime]]): """Class to manage fetching Cert Expiry data from single endpoint.""" def __init__(self, hass, host, port): diff --git a/homeassistant/components/cert_expiry/config_flow.py b/homeassistant/components/cert_expiry/config_flow.py index d1b9588f5b1..13336c59771 100644 --- a/homeassistant/components/cert_expiry/config_flow.py +++ b/homeassistant/components/cert_expiry/config_flow.py @@ -1,4 +1,6 @@ """Config flow for the Cert Expiry platform.""" +from __future__ import annotations + import logging import voluptuous as vol @@ -25,7 +27,7 @@ class CertexpiryConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize the config flow.""" - self._errors = {} + self._errors: dict[str, str] = {} async def _test_connection(self, user_input=None): """Test connection to the server and try to get the certificate.""" diff --git a/mypy.ini b/mypy.ini index 92247205c10..e2e570c18c2 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1285,9 +1285,6 @@ ignore_errors = true [mypy-homeassistant.components.bmw_connected_drive.*] ignore_errors = true -[mypy-homeassistant.components.cert_expiry.*] -ignore_errors = true - [mypy-homeassistant.components.climacell.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 91bba97fa31..78707494ef5 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -17,7 +17,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.awair.*", "homeassistant.components.blueprint.*", "homeassistant.components.bmw_connected_drive.*", - "homeassistant.components.cert_expiry.*", "homeassistant.components.climacell.*", "homeassistant.components.cloud.*", "homeassistant.components.config.*", From 76ce33dc2403fe902d151b29a98ef026c10e666f Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Sun, 29 Aug 2021 21:10:18 +0200 Subject: [PATCH 079/843] Only return not return None (#55423) --- homeassistant/components/unifi/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index 6a009415163..5cbf61d9635 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -157,7 +157,7 @@ class UniFiUpTimeSensor(UniFiClient, SensorEntity): self.last_updated_time = self.client.uptime if not update_state: - return None + return super().async_update_callback() From fa201b6c2be0c827b6e68f63640e5cc86255b073 Mon Sep 17 00:00:00 2001 From: Andre Richter Date: Sun, 29 Aug 2021 22:02:52 +0200 Subject: [PATCH 080/843] Add myself to Vallox codeowners (#55428) --- CODEOWNERS | 1 + homeassistant/components/vallox/manifest.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CODEOWNERS b/CODEOWNERS index 645fdce52a2..c28973ec66f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -552,6 +552,7 @@ homeassistant/components/uptimerobot/* @ludeeus homeassistant/components/usb/* @bdraco homeassistant/components/usgs_earthquakes_feed/* @exxamalte homeassistant/components/utility_meter/* @dgomes +homeassistant/components/vallox/* @andre-richter homeassistant/components/velbus/* @Cereal2nd @brefra homeassistant/components/velux/* @Julius2342 homeassistant/components/vera/* @pavoni diff --git a/homeassistant/components/vallox/manifest.json b/homeassistant/components/vallox/manifest.json index b536270c336..c4b25644ed0 100644 --- a/homeassistant/components/vallox/manifest.json +++ b/homeassistant/components/vallox/manifest.json @@ -3,6 +3,6 @@ "name": "Vallox", "documentation": "https://www.home-assistant.io/integrations/vallox", "requirements": ["vallox-websocket-api==2.8.1"], - "codeowners": [], + "codeowners": ["@andre-richter"], "iot_class": "local_polling" } From b43c80ca215a66778114dcd4ba26310880ed8cb1 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Sun, 29 Aug 2021 14:03:09 -0600 Subject: [PATCH 081/843] Give ReCollect Waste sensor a friendlier label (#55427) --- homeassistant/components/recollect_waste/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/recollect_waste/sensor.py b/homeassistant/components/recollect_waste/sensor.py index 434d24be22a..f348b5b00f2 100644 --- a/homeassistant/components/recollect_waste/sensor.py +++ b/homeassistant/components/recollect_waste/sensor.py @@ -29,7 +29,7 @@ ATTR_NEXT_PICKUP_TYPES = "next_pickup_types" ATTR_NEXT_PICKUP_DATE = "next_pickup_date" DEFAULT_ATTRIBUTION = "Pickup data provided by ReCollect Waste" -DEFAULT_NAME = "recollect_waste" +DEFAULT_NAME = "Waste Pickup" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { From 32df2f7d8b14c7e5a8e0f03d95ed2260bae4cba7 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Sun, 29 Aug 2021 14:03:44 -0600 Subject: [PATCH 082/843] Deprecate YAML config for ReCollect Waste (#55426) --- .../components/recollect_waste/config_flow.py | 6 --- .../components/recollect_waste/sensor.py | 37 ++----------------- .../recollect_waste/test_config_flow.py | 18 +-------- 3 files changed, 5 insertions(+), 56 deletions(-) diff --git a/homeassistant/components/recollect_waste/config_flow.py b/homeassistant/components/recollect_waste/config_flow.py index 5d6b66d8abd..d542199e096 100644 --- a/homeassistant/components/recollect_waste/config_flow.py +++ b/homeassistant/components/recollect_waste/config_flow.py @@ -33,12 +33,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Define the config flow to handle options.""" return RecollectWasteOptionsFlowHandler(config_entry) - async def async_step_import( - self, import_config: dict[str, Any] | None = None - ) -> FlowResult: - """Handle configuration via YAML import.""" - return await self.async_step_user(import_config) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: diff --git a/homeassistant/components/recollect_waste/sensor.py b/homeassistant/components/recollect_waste/sensor.py index f348b5b00f2..21708c9c943 100644 --- a/homeassistant/components/recollect_waste/sensor.py +++ b/homeassistant/components/recollect_waste/sensor.py @@ -2,26 +2,23 @@ from __future__ import annotations from aiorecollect.client import PickupType -import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ATTRIBUTION, CONF_FRIENDLY_NAME, - CONF_NAME, DEVICE_CLASS_TIMESTAMP, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, ) -from .const import CONF_PLACE_ID, CONF_SERVICE_ID, DATA_COORDINATOR, DOMAIN, LOGGER +from .const import CONF_PLACE_ID, CONF_SERVICE_ID, DATA_COORDINATOR, DOMAIN ATTR_PICKUP_TYPES = "pickup_types" ATTR_AREA_NAME = "area_name" @@ -31,13 +28,7 @@ ATTR_NEXT_PICKUP_DATE = "next_pickup_date" DEFAULT_ATTRIBUTION = "Pickup data provided by ReCollect Waste" DEFAULT_NAME = "Waste Pickup" -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_PLACE_ID): cv.string, - vol.Required(CONF_SERVICE_ID): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - } -) +PLATFORM_SCHEMA = cv.deprecated(DOMAIN) @callback @@ -53,26 +44,6 @@ def async_get_pickup_type_names( ] -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Import Recollect Waste configuration from YAML.""" - LOGGER.warning( - "Loading ReCollect Waste via platform setup is deprecated; " - "Please remove it from your configuration" - ) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config, - ) - ) - - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: diff --git a/tests/components/recollect_waste/test_config_flow.py b/tests/components/recollect_waste/test_config_flow.py index 22f32983055..b55202d93b3 100644 --- a/tests/components/recollect_waste/test_config_flow.py +++ b/tests/components/recollect_waste/test_config_flow.py @@ -9,7 +9,7 @@ from homeassistant.components.recollect_waste import ( CONF_SERVICE_ID, DOMAIN, ) -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_FRIENDLY_NAME from tests.common import MockConfigEntry @@ -81,22 +81,6 @@ async def test_show_form(hass): assert result["step_id"] == "user" -async def test_step_import(hass): - """Test that the user step works.""" - conf = {CONF_PLACE_ID: "12345", CONF_SERVICE_ID: "12345"} - - with patch( - "homeassistant.components.recollect_waste.async_setup_entry", return_value=True - ), patch("aiorecollect.client.Client.async_get_pickup_events", return_value=True): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=conf - ) - await hass.async_block_till_done() - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "12345, 12345" - assert result["data"] == {CONF_PLACE_ID: "12345", CONF_SERVICE_ID: "12345"} - - async def test_step_user(hass): """Test that the user step works.""" conf = {CONF_PLACE_ID: "12345", CONF_SERVICE_ID: "12345"} From ea7f3c8bb3abf041f1ebda917a17154e19d470c5 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Mon, 30 Aug 2021 00:11:40 +0000 Subject: [PATCH 083/843] [ci skip] Translation update --- homeassistant/components/homekit/translations/nl.json | 3 ++- homeassistant/components/mqtt/translations/nl.json | 1 + homeassistant/components/openuv/translations/nl.json | 7 +++++++ homeassistant/components/synology_dsm/translations/ca.json | 3 ++- homeassistant/components/synology_dsm/translations/de.json | 3 ++- homeassistant/components/synology_dsm/translations/et.json | 3 ++- homeassistant/components/synology_dsm/translations/ru.json | 3 ++- .../components/synology_dsm/translations/zh-Hant.json | 3 ++- homeassistant/components/zha/translations/nl.json | 3 ++- 9 files changed, 22 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/homekit/translations/nl.json b/homeassistant/components/homekit/translations/nl.json index 368005985bf..e08364e038f 100644 --- a/homeassistant/components/homekit/translations/nl.json +++ b/homeassistant/components/homekit/translations/nl.json @@ -21,7 +21,8 @@ "step": { "advanced": { "data": { - "auto_start": "Autostart (deactiveer als je de homekit.start service handmatig aanroept)" + "auto_start": "Autostart (deactiveer als je de homekit.start service handmatig aanroept)", + "devices": "Apparaten (triggers)" }, "description": "Deze instellingen hoeven alleen te worden aangepast als HomeKit niet functioneert.", "title": "Geavanceerde configuratie" diff --git a/homeassistant/components/mqtt/translations/nl.json b/homeassistant/components/mqtt/translations/nl.json index e9c2469a061..542ae467e7a 100644 --- a/homeassistant/components/mqtt/translations/nl.json +++ b/homeassistant/components/mqtt/translations/nl.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "Dienst is al geconfigureerd", "single_instance_allowed": "Al geconfigureerd. Slechts een enkele configuratie mogelijk." }, "error": { diff --git a/homeassistant/components/openuv/translations/nl.json b/homeassistant/components/openuv/translations/nl.json index d7287b99ddf..0129e24e304 100644 --- a/homeassistant/components/openuv/translations/nl.json +++ b/homeassistant/components/openuv/translations/nl.json @@ -17,5 +17,12 @@ "title": "Vul uw gegevens in" } } + }, + "options": { + "step": { + "init": { + "title": "Configureer OpenUV" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/synology_dsm/translations/ca.json b/homeassistant/components/synology_dsm/translations/ca.json index 2ac5d16b286..89194754c54 100644 --- a/homeassistant/components/synology_dsm/translations/ca.json +++ b/homeassistant/components/synology_dsm/translations/ca.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "El dispositiu ja est\u00e0 configurat", - "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" + "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament", + "reconfigure_successful": "Re-configuraci\u00f3 realitzada correctament" }, "error": { "cannot_connect": "Ha fallat la connexi\u00f3", diff --git a/homeassistant/components/synology_dsm/translations/de.json b/homeassistant/components/synology_dsm/translations/de.json index 86c154e8567..c377ef2adc0 100644 --- a/homeassistant/components/synology_dsm/translations/de.json +++ b/homeassistant/components/synology_dsm/translations/de.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "Ger\u00e4t ist bereits konfiguriert", - "reauth_successful": "Die erneute Authentifizierung war erfolgreich" + "reauth_successful": "Die erneute Authentifizierung war erfolgreich", + "reconfigure_successful": "Die Neukonfiguration war erfolgreich" }, "error": { "cannot_connect": "Verbindung fehlgeschlagen", diff --git a/homeassistant/components/synology_dsm/translations/et.json b/homeassistant/components/synology_dsm/translations/et.json index eebfd25938b..1d81312305b 100644 --- a/homeassistant/components/synology_dsm/translations/et.json +++ b/homeassistant/components/synology_dsm/translations/et.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "Seade on juba h\u00e4\u00e4lestatud", - "reauth_successful": "Taastuvastamine \u00f5nnestus" + "reauth_successful": "Taastuvastamine \u00f5nnestus", + "reconfigure_successful": "\u00dcmberseadistamine \u00f5nnestus" }, "error": { "cannot_connect": "\u00dchendamine nurjus", diff --git a/homeassistant/components/synology_dsm/translations/ru.json b/homeassistant/components/synology_dsm/translations/ru.json index 4a2963dc5d5..37a2890e491 100644 --- a/homeassistant/components/synology_dsm/translations/ru.json +++ b/homeassistant/components/synology_dsm/translations/ru.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant.", - "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430 \u0443\u0441\u043f\u0435\u0448\u043d\u043e." + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430 \u0443\u0441\u043f\u0435\u0448\u043d\u043e.", + "reconfigure_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430 \u0443\u0441\u043f\u0435\u0448\u043d\u043e." }, "error": { "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", diff --git a/homeassistant/components/synology_dsm/translations/zh-Hant.json b/homeassistant/components/synology_dsm/translations/zh-Hant.json index c4d466832e7..30c97c853cb 100644 --- a/homeassistant/components/synology_dsm/translations/zh-Hant.json +++ b/homeassistant/components/synology_dsm/translations/zh-Hant.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", - "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f" + "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f", + "reconfigure_successful": "\u91cd\u65b0\u8a2d\u5b9a\u6210\u529f" }, "error": { "cannot_connect": "\u9023\u7dda\u5931\u6557", diff --git a/homeassistant/components/zha/translations/nl.json b/homeassistant/components/zha/translations/nl.json index 9d285499ba1..403d9e2fde6 100644 --- a/homeassistant/components/zha/translations/nl.json +++ b/homeassistant/components/zha/translations/nl.json @@ -2,7 +2,8 @@ "config": { "abort": { "not_zha_device": "Dit apparaat is niet een zha-apparaat.", - "single_instance_allowed": "Al geconfigureerd. Slechts een enkele configuratie mogelijk." + "single_instance_allowed": "Al geconfigureerd. Slechts een enkele configuratie mogelijk.", + "usb_probe_failed": "Kon het USB apparaat niet onderzoeken" }, "error": { "cannot_connect": "Kan geen verbinding maken" From ebc2a0103ed83ba3cbdf0219e011cf3ac8cc0ccf Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sun, 29 Aug 2021 23:25:47 -0400 Subject: [PATCH 084/843] Make zwave_js discovery log message more descriptive (#55432) --- homeassistant/components/zwave_js/__init__.py | 2 +- homeassistant/components/zwave_js/discovery.py | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index c8f2bd19776..f38594c1594 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -145,7 +145,7 @@ async def async_setup_entry( # noqa: C901 value_updates_disc_info: dict[str, ZwaveDiscoveryInfo] = {} # run discovery on all node values and create/update entities - for disc_info in async_discover_values(node): + for disc_info in async_discover_values(node, device): platform = disc_info.platform # This migration logic was added in 2021.3 to handle a breaking change to diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 7a4955d693a..7232279f4c6 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -13,6 +13,7 @@ from zwave_js_server.model.node import Node as ZwaveNode from zwave_js_server.model.value import Value as ZwaveValue from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceEntry from .const import LOGGER from .discovery_data_template import ( @@ -667,7 +668,9 @@ DISCOVERY_SCHEMAS = [ @callback -def async_discover_values(node: ZwaveNode) -> Generator[ZwaveDiscoveryInfo, None, None]: +def async_discover_values( + node: ZwaveNode, device: DeviceEntry +) -> Generator[ZwaveDiscoveryInfo, None, None]: """Run discovery on ZWave node and return matching (primary) values.""" for value in node.values.values(): for schema in DISCOVERY_SCHEMAS: @@ -758,7 +761,11 @@ def async_discover_values(node: ZwaveNode) -> Generator[ZwaveDiscoveryInfo, None resolved_data = schema.data_template.resolve_data(value) except UnknownValueData as err: LOGGER.error( - "Discovery for value %s will be skipped: %s", value, err + "Discovery for value %s on device '%s' (%s) will be skipped: %s", + value, + device.name_by_user or device.name, + node, + err, ) continue additional_value_ids_to_watch = schema.data_template.value_ids_to_watch( From 94e0db8ec4880e91f9b3b37e1855f74856eb6d8c Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Sun, 29 Aug 2021 21:27:34 -0600 Subject: [PATCH 085/843] Ensure ReCollect Waste shows pickups for midnight on the actual day (#55424) --- .../components/recollect_waste/sensor.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/recollect_waste/sensor.py b/homeassistant/components/recollect_waste/sensor.py index 21708c9c943..a7e50d33ff6 100644 --- a/homeassistant/components/recollect_waste/sensor.py +++ b/homeassistant/components/recollect_waste/sensor.py @@ -1,6 +1,8 @@ """Support for ReCollect Waste sensors.""" from __future__ import annotations +from datetime import date, datetime, time + from aiorecollect.client import PickupType from homeassistant.components.sensor import SensorEntity @@ -17,6 +19,7 @@ from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, ) +from homeassistant.util.dt import as_utc from .const import CONF_PLACE_ID, CONF_SERVICE_ID, DATA_COORDINATOR, DOMAIN @@ -44,6 +47,12 @@ def async_get_pickup_type_names( ] +@callback +def async_get_utc_midnight(target_date: date) -> datetime: + """Get UTC midnight for a given date.""" + return as_utc(datetime.combine(target_date, time(0))) + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: @@ -94,7 +103,9 @@ class ReCollectWasteSensor(CoordinatorEntity, SensorEntity): ATTR_NEXT_PICKUP_TYPES: async_get_pickup_type_names( self._entry, next_pickup_event.pickup_types ), - ATTR_NEXT_PICKUP_DATE: next_pickup_event.date.isoformat(), + ATTR_NEXT_PICKUP_DATE: async_get_utc_midnight( + next_pickup_event.date + ).isoformat(), } ) - self._attr_native_value = pickup_event.date.isoformat() + self._attr_native_value = async_get_utc_midnight(pickup_event.date).isoformat() From 6823b14d4cbe282f6182b224491b436aba4ec209 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Mon, 30 Aug 2021 05:29:37 +0200 Subject: [PATCH 086/843] Update entity names for P1 Monitor integration (#55430) --- .../components/p1_monitor/manifest.json | 2 +- homeassistant/components/p1_monitor/sensor.py | 20 +++++++++---------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/p1_monitor/test_sensor.py | 16 +++++++-------- 5 files changed, 21 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/p1_monitor/manifest.json b/homeassistant/components/p1_monitor/manifest.json index 1a4beb36f5d..00b50bb029b 100644 --- a/homeassistant/components/p1_monitor/manifest.json +++ b/homeassistant/components/p1_monitor/manifest.json @@ -3,7 +3,7 @@ "name": "P1 Monitor", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/p1_monitor", - "requirements": ["p1monitor==0.2.0"], + "requirements": ["p1monitor==1.0.0"], "codeowners": ["@klaasnicolaas"], "quality_scale": "platinum", "iot_class": "local_polling" diff --git a/homeassistant/components/p1_monitor/sensor.py b/homeassistant/components/p1_monitor/sensor.py index 36a991c7333..ea18854f748 100644 --- a/homeassistant/components/p1_monitor/sensor.py +++ b/homeassistant/components/p1_monitor/sensor.py @@ -192,33 +192,33 @@ SENSORS: dict[ ), SERVICE_SETTINGS: ( SensorEntityDescription( - key="gas_consumption_tariff", - name="Gas Consumption - Tariff", + key="gas_consumption_price", + name="Gas Consumption Price", entity_registry_enabled_default=False, device_class=DEVICE_CLASS_MONETARY, native_unit_of_measurement=CURRENCY_EURO, ), SensorEntityDescription( - key="energy_consumption_low_tariff", - name="Energy Consumption - Low Tariff", + key="energy_consumption_price_low", + name="Energy Consumption Price - Low", device_class=DEVICE_CLASS_MONETARY, native_unit_of_measurement=CURRENCY_EURO, ), SensorEntityDescription( - key="energy_consumption_high_tariff", - name="Energy Consumption - High Tariff", + key="energy_consumption_price_high", + name="Energy Consumption Price - High", device_class=DEVICE_CLASS_MONETARY, native_unit_of_measurement=CURRENCY_EURO, ), SensorEntityDescription( - key="energy_production_low_tariff", - name="Energy Production - Low Tariff", + key="energy_production_price_low", + name="Energy Production Price - Low", device_class=DEVICE_CLASS_MONETARY, native_unit_of_measurement=CURRENCY_EURO, ), SensorEntityDescription( - key="energy_production_high_tariff", - name="Energy Production - High Tariff", + key="energy_production_price_high", + name="Energy Production Price - High", device_class=DEVICE_CLASS_MONETARY, native_unit_of_measurement=CURRENCY_EURO, ), diff --git a/requirements_all.txt b/requirements_all.txt index 9a2b1c033a9..889f35954b0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1139,7 +1139,7 @@ orvibo==1.1.1 ovoenergy==1.1.12 # homeassistant.components.p1_monitor -p1monitor==0.2.0 +p1monitor==1.0.0 # homeassistant.components.mqtt # homeassistant.components.shiftr diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 28a2a7b8e3c..1335dbd8185 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -641,7 +641,7 @@ openerz-api==0.1.0 ovoenergy==1.1.12 # homeassistant.components.p1_monitor -p1monitor==0.2.0 +p1monitor==1.0.0 # homeassistant.components.mqtt # homeassistant.components.shiftr diff --git a/tests/components/p1_monitor/test_sensor.py b/tests/components/p1_monitor/test_sensor.py index baf73811636..90733ce8941 100644 --- a/tests/components/p1_monitor/test_sensor.py +++ b/tests/components/p1_monitor/test_sensor.py @@ -151,23 +151,23 @@ async def test_settings( entity_registry = er.async_get(hass) device_registry = dr.async_get(hass) - state = hass.states.get("sensor.monitor_energy_consumption_low_tariff") - entry = entity_registry.async_get("sensor.monitor_energy_consumption_low_tariff") + state = hass.states.get("sensor.monitor_energy_consumption_price_low") + entry = entity_registry.async_get("sensor.monitor_energy_consumption_price_low") assert entry assert state - assert entry.unique_id == f"{entry_id}_settings_energy_consumption_low_tariff" + assert entry.unique_id == f"{entry_id}_settings_energy_consumption_price_low" assert state.state == "0.20522" - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Energy Consumption - Low Tariff" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Energy Consumption Price - Low" assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_MONETARY assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CURRENCY_EURO - state = hass.states.get("sensor.monitor_energy_production_low_tariff") - entry = entity_registry.async_get("sensor.monitor_energy_production_low_tariff") + state = hass.states.get("sensor.monitor_energy_production_price_low") + entry = entity_registry.async_get("sensor.monitor_energy_production_price_low") assert entry assert state - assert entry.unique_id == f"{entry_id}_settings_energy_production_low_tariff" + assert entry.unique_id == f"{entry_id}_settings_energy_production_price_low" assert state.state == "0.20522" - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Energy Production - Low Tariff" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Energy Production Price - Low" assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_MONETARY assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CURRENCY_EURO From f37c541a501672951accc4074593a87206a86547 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Aug 2021 22:29:46 -0500 Subject: [PATCH 087/843] Bump zeroconf to 0.36.1 (#55425) - Fixes duplicate records in the cache - Changelog: https://github.com/jstasiak/python-zeroconf/compare/0.36.0...0.36.1 --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 84f9f4698e9..dea3b3c356e 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -2,7 +2,7 @@ "domain": "zeroconf", "name": "Zero-configuration networking (zeroconf)", "documentation": "https://www.home-assistant.io/integrations/zeroconf", - "requirements": ["zeroconf==0.36.0"], + "requirements": ["zeroconf==0.36.1"], "dependencies": ["network", "api"], "codeowners": ["@bdraco"], "quality_scale": "internal", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 510d27ccccb..8beb6789b54 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -34,7 +34,7 @@ sqlalchemy==1.4.23 voluptuous-serialize==2.4.0 voluptuous==0.12.1 yarl==1.6.3 -zeroconf==0.36.0 +zeroconf==0.36.1 pycryptodome>=3.6.6 diff --git a/requirements_all.txt b/requirements_all.txt index 889f35954b0..0264d83665b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2453,7 +2453,7 @@ youtube_dl==2021.04.26 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.36.0 +zeroconf==0.36.1 # homeassistant.components.zha zha-quirks==0.0.60 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1335dbd8185..dec96024ce1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1373,7 +1373,7 @@ yeelight==0.7.4 youless-api==0.12 # homeassistant.components.zeroconf -zeroconf==0.36.0 +zeroconf==0.36.1 # homeassistant.components.zha zha-quirks==0.0.60 From be04d7b92e02ce7676393dbdb50f374b9dfebe15 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 30 Aug 2021 05:30:54 +0200 Subject: [PATCH 088/843] Fix device_class - qnap drive_temp sensor (#55409) --- homeassistant/components/qnap/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/qnap/sensor.py b/homeassistant/components/qnap/sensor.py index 333ce46599a..b02c977d98d 100644 --- a/homeassistant/components/qnap/sensor.py +++ b/homeassistant/components/qnap/sensor.py @@ -91,7 +91,7 @@ _DRIVE_MON_COND = { "mdi:checkbox-marked-circle-outline", None, ], - "drive_temp": ["Temperature", TEMP_CELSIUS, None, None, DEVICE_CLASS_TEMPERATURE], + "drive_temp": ["Temperature", TEMP_CELSIUS, None, DEVICE_CLASS_TEMPERATURE], } _VOLUME_MON_COND = { "volume_size_used": ["Used Space", DATA_GIBIBYTES, "mdi:chart-pie", None], From 5549a925b8a72259c14617e8641a5a5b9162e218 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Aug 2021 22:38:41 -0500 Subject: [PATCH 089/843] Implement import of consider_home in nmap_tracker to avoid breaking change (#55379) --- .../components/nmap_tracker/__init__.py | 46 +++++++++++++++---- .../components/nmap_tracker/config_flow.py | 15 +++++- .../components/nmap_tracker/device_tracker.py | 15 +++++- .../components/nmap_tracker/strings.json | 1 + .../nmap_tracker/translations/en.json | 4 +- .../nmap_tracker/test_config_flow.py | 10 +++- 6 files changed, 75 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/nmap_tracker/__init__.py b/homeassistant/components/nmap_tracker/__init__.py index dfd8987484c..21469f197f4 100644 --- a/homeassistant/components/nmap_tracker/__init__.py +++ b/homeassistant/components/nmap_tracker/__init__.py @@ -14,7 +14,11 @@ from getmac import get_mac_address from mac_vendor_lookup import AsyncMacLookup from nmap import PortScanner, PortScannerError -from homeassistant.components.device_tracker.const import CONF_SCAN_INTERVAL +from homeassistant.components.device_tracker.const import ( + CONF_CONSIDER_HOME, + CONF_SCAN_INTERVAL, + DEFAULT_CONSIDER_HOME, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS, EVENT_HOMEASSISTANT_STARTED from homeassistant.core import CoreState, HomeAssistant, callback @@ -37,7 +41,6 @@ from .const import ( # Some version of nmap will fail with 'Assertion failed: htn.toclock_running == true (Target.cc: stopTimeOutClock: 503)\n' NMAP_TRANSIENT_FAILURE: Final = "Assertion failed: htn.toclock_running == true" MAX_SCAN_ATTEMPTS: Final = 16 -OFFLINE_SCANS_TO_MARK_UNAVAILABLE: Final = 3 def short_hostname(hostname: str) -> str: @@ -65,7 +68,7 @@ class NmapDevice: manufacturer: str reason: str last_update: datetime - offline_scans: int + first_offline: datetime | None class NmapTrackedDevices: @@ -137,6 +140,7 @@ class NmapDeviceScanner: """Initialize the scanner.""" self.devices = devices self.home_interval = None + self.consider_home = DEFAULT_CONSIDER_HOME self._hass = hass self._entry = entry @@ -170,6 +174,10 @@ class NmapDeviceScanner: self.home_interval = timedelta( minutes=cv.positive_int(config[CONF_HOME_INTERVAL]) ) + if config.get(CONF_CONSIDER_HOME): + self.consider_home = timedelta( + seconds=cv.positive_float(config[CONF_CONSIDER_HOME]) + ) self._scan_lock = asyncio.Lock() if self._hass.state == CoreState.running: await self._async_start_scanner() @@ -320,16 +328,35 @@ class NmapDeviceScanner: return result @callback - def _async_increment_device_offline(self, ipv4, reason): + def _async_device_offline(self, ipv4: str, reason: str, now: datetime) -> None: """Mark an IP offline.""" if not (formatted_mac := self.devices.ipv4_last_mac.get(ipv4)): return if not (device := self.devices.tracked.get(formatted_mac)): # Device was unloaded return - device.offline_scans += 1 - if device.offline_scans < OFFLINE_SCANS_TO_MARK_UNAVAILABLE: + if not device.first_offline: + _LOGGER.debug( + "Setting first_offline for %s (%s) to: %s", ipv4, formatted_mac, now + ) + device.first_offline = now return + if device.first_offline + self.consider_home > now: + _LOGGER.debug( + "Device %s (%s) has NOT been offline (first offline at: %s) long enough to be considered not home: %s", + ipv4, + formatted_mac, + device.first_offline, + self.consider_home, + ) + return + _LOGGER.debug( + "Device %s (%s) has been offline (first offline at: %s) long enough to be considered not home: %s", + ipv4, + formatted_mac, + device.first_offline, + self.consider_home, + ) device.reason = reason async_dispatcher_send(self._hass, signal_device_update(formatted_mac), False) del self.devices.ipv4_last_mac[ipv4] @@ -347,7 +374,7 @@ class NmapDeviceScanner: status = info["status"] reason = status["reason"] if status["state"] != "up": - self._async_increment_device_offline(ipv4, reason) + self._async_device_offline(ipv4, reason, now) continue # Mac address only returned if nmap ran as root mac = info["addresses"].get( @@ -356,12 +383,11 @@ class NmapDeviceScanner: partial(get_mac_address, ip=ipv4) ) if mac is None: - self._async_increment_device_offline(ipv4, "No MAC address found") + self._async_device_offline(ipv4, "No MAC address found", now) _LOGGER.info("No MAC address found for %s", ipv4) continue formatted_mac = format_mac(mac) - if ( devices.config_entry_owner.setdefault(formatted_mac, entry_id) != entry_id @@ -372,7 +398,7 @@ class NmapDeviceScanner: vendor = info.get("vendor", {}).get(mac) or self._async_get_vendor(mac) name = human_readable_name(hostname, vendor, mac) device = NmapDevice( - formatted_mac, hostname, name, ipv4, vendor, reason, now, 0 + formatted_mac, hostname, name, ipv4, vendor, reason, now, None ) new = formatted_mac not in devices.tracked diff --git a/homeassistant/components/nmap_tracker/config_flow.py b/homeassistant/components/nmap_tracker/config_flow.py index 2d25b62f1d2..c9e9706e4ba 100644 --- a/homeassistant/components/nmap_tracker/config_flow.py +++ b/homeassistant/components/nmap_tracker/config_flow.py @@ -8,7 +8,11 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import network -from homeassistant.components.device_tracker.const import CONF_SCAN_INTERVAL +from homeassistant.components.device_tracker.const import ( + CONF_CONSIDER_HOME, + CONF_SCAN_INTERVAL, + DEFAULT_CONSIDER_HOME, +) from homeassistant.components.network.const import MDNS_TARGET_IP from homeassistant.config_entries import ConfigEntry, OptionsFlow from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS @@ -24,6 +28,8 @@ from .const import ( TRACKER_SCAN_INTERVAL, ) +MAX_SCAN_INTERVAL = 3600 +MAX_CONSIDER_HOME = MAX_SCAN_INTERVAL * 6 DEFAULT_NETWORK_PREFIX = 24 @@ -116,7 +122,12 @@ async def _async_build_schema_with_user_input( vol.Optional( CONF_SCAN_INTERVAL, default=user_input.get(CONF_SCAN_INTERVAL, TRACKER_SCAN_INTERVAL), - ): vol.All(vol.Coerce(int), vol.Range(min=10, max=3600)), + ): vol.All(vol.Coerce(int), vol.Range(min=10, max=MAX_SCAN_INTERVAL)), + vol.Optional( + CONF_CONSIDER_HOME, + default=user_input.get(CONF_CONSIDER_HOME) + or DEFAULT_CONSIDER_HOME.total_seconds(), + ): vol.All(vol.Coerce(int), vol.Range(min=1, max=MAX_CONSIDER_HOME)), } ) return vol.Schema(schema) diff --git a/homeassistant/components/nmap_tracker/device_tracker.py b/homeassistant/components/nmap_tracker/device_tracker.py index 5ec9f2fcb9a..e475afd24c8 100644 --- a/homeassistant/components/nmap_tracker/device_tracker.py +++ b/homeassistant/components/nmap_tracker/device_tracker.py @@ -12,7 +12,11 @@ from homeassistant.components.device_tracker import ( SOURCE_TYPE_ROUTER, ) from homeassistant.components.device_tracker.config_entry import ScannerEntity -from homeassistant.components.device_tracker.const import CONF_SCAN_INTERVAL +from homeassistant.components.device_tracker.const import ( + CONF_CONSIDER_HOME, + CONF_SCAN_INTERVAL, + DEFAULT_CONSIDER_HOME, +) from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS from homeassistant.core import HomeAssistant, callback @@ -38,6 +42,9 @@ PLATFORM_SCHEMA = DEVICE_TRACKER_PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOSTS): cv.ensure_list, vol.Required(CONF_HOME_INTERVAL, default=0): cv.positive_int, + vol.Required( + CONF_CONSIDER_HOME, default=DEFAULT_CONSIDER_HOME.total_seconds() + ): cv.time_period, vol.Optional(CONF_EXCLUDE, default=[]): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_OPTIONS, default=DEFAULT_OPTIONS): cv.string, } @@ -53,9 +60,15 @@ async def async_get_scanner(hass: HomeAssistant, config: ConfigType) -> None: else: scan_interval = TRACKER_SCAN_INTERVAL + if CONF_CONSIDER_HOME in validated_config: + consider_home = validated_config[CONF_CONSIDER_HOME].total_seconds() + else: + consider_home = DEFAULT_CONSIDER_HOME.total_seconds() + import_config = { CONF_HOSTS: ",".join(validated_config[CONF_HOSTS]), CONF_HOME_INTERVAL: validated_config[CONF_HOME_INTERVAL], + CONF_CONSIDER_HOME: consider_home, CONF_EXCLUDE: ",".join(validated_config[CONF_EXCLUDE]), CONF_OPTIONS: validated_config[CONF_OPTIONS], CONF_SCAN_INTERVAL: scan_interval, diff --git a/homeassistant/components/nmap_tracker/strings.json b/homeassistant/components/nmap_tracker/strings.json index d42e1067503..ed5a8cb0b05 100644 --- a/homeassistant/components/nmap_tracker/strings.json +++ b/homeassistant/components/nmap_tracker/strings.json @@ -7,6 +7,7 @@ "data": { "hosts": "[%key:component::nmap_tracker::config::step::user::data::hosts%]", "home_interval": "[%key:component::nmap_tracker::config::step::user::data::home_interval%]", + "consider_home": "Seconds to wait till marking a device tracker as not home after not being seen.", "exclude": "[%key:component::nmap_tracker::config::step::user::data::exclude%]", "scan_options": "[%key:component::nmap_tracker::config::step::user::data::scan_options%]", "interval_seconds": "Scan interval" diff --git a/homeassistant/components/nmap_tracker/translations/en.json b/homeassistant/components/nmap_tracker/translations/en.json index 6b83532a0e2..feeea1ff8be 100644 --- a/homeassistant/components/nmap_tracker/translations/en.json +++ b/homeassistant/components/nmap_tracker/translations/en.json @@ -25,12 +25,12 @@ "step": { "init": { "data": { + "consider_home": "Seconds to wait till marking a device tracker as not home after not being seen.", "exclude": "Network addresses (comma seperated) to exclude from scanning", "home_interval": "Minimum number of minutes between scans of active devices (preserve battery)", "hosts": "Network addresses (comma seperated) to scan", "interval_seconds": "Scan interval", - "scan_options": "Raw configurable scan options for Nmap", - "track_new_devices": "Track new devices" + "scan_options": "Raw configurable scan options for Nmap" }, "description": "Configure hosts to be scanned by Nmap. Network address and excludes can be IP Addresses (192.168.1.1), IP Networks (192.168.0.0/24) or IP Ranges (192.168.1.0-32)." } diff --git a/tests/components/nmap_tracker/test_config_flow.py b/tests/components/nmap_tracker/test_config_flow.py index 6365dd7407a..74997df5a4f 100644 --- a/tests/components/nmap_tracker/test_config_flow.py +++ b/tests/components/nmap_tracker/test_config_flow.py @@ -4,7 +4,10 @@ from unittest.mock import patch import pytest from homeassistant import config_entries, data_entry_flow, setup -from homeassistant.components.device_tracker.const import CONF_SCAN_INTERVAL +from homeassistant.components.device_tracker.const import ( + CONF_CONSIDER_HOME, + CONF_SCAN_INTERVAL, +) from homeassistant.components.nmap_tracker.const import ( CONF_HOME_INTERVAL, CONF_OPTIONS, @@ -206,6 +209,7 @@ async def test_options_flow(hass: HomeAssistant) -> None: CONF_EXCLUDE: "4.4.4.4", CONF_HOME_INTERVAL: 3, CONF_HOSTS: "192.168.1.0/24", + CONF_CONSIDER_HOME: 180, CONF_SCAN_INTERVAL: 120, CONF_OPTIONS: "-F -T4 --min-rate 10 --host-timeout 5s", } @@ -219,6 +223,7 @@ async def test_options_flow(hass: HomeAssistant) -> None: user_input={ CONF_HOSTS: "192.168.1.0/24, 192.168.2.0/24", CONF_HOME_INTERVAL: 5, + CONF_CONSIDER_HOME: 500, CONF_OPTIONS: "-sn", CONF_EXCLUDE: "4.4.4.4, 5.5.5.5", CONF_SCAN_INTERVAL: 10, @@ -230,6 +235,7 @@ async def test_options_flow(hass: HomeAssistant) -> None: assert config_entry.options == { CONF_HOSTS: "192.168.1.0/24,192.168.2.0/24", CONF_HOME_INTERVAL: 5, + CONF_CONSIDER_HOME: 500, CONF_OPTIONS: "-sn", CONF_EXCLUDE: "4.4.4.4,5.5.5.5", CONF_SCAN_INTERVAL: 10, @@ -250,6 +256,7 @@ async def test_import(hass: HomeAssistant) -> None: data={ CONF_HOSTS: "1.2.3.4/20", CONF_HOME_INTERVAL: 3, + CONF_CONSIDER_HOME: 500, CONF_OPTIONS: DEFAULT_OPTIONS, CONF_EXCLUDE: "4.4.4.4, 6.4.3.2", CONF_SCAN_INTERVAL: 2000, @@ -263,6 +270,7 @@ async def test_import(hass: HomeAssistant) -> None: assert result["options"] == { CONF_HOSTS: "1.2.3.4/20", CONF_HOME_INTERVAL: 3, + CONF_CONSIDER_HOME: 500, CONF_OPTIONS: DEFAULT_OPTIONS, CONF_EXCLUDE: "4.4.4.4,6.4.3.2", CONF_SCAN_INTERVAL: 2000, From 071fcee9a9b554265a1c190e5620cfc0f176458d Mon Sep 17 00:00:00 2001 From: uvjustin <46082645+uvjustin@users.noreply.github.com> Date: Mon, 30 Aug 2021 13:20:19 +0800 Subject: [PATCH 090/843] Remove byte-range addressed parts in stream (#55396) Add individually addressed parts --- homeassistant/components/stream/core.py | 67 +++------- homeassistant/components/stream/hls.py | 150 ++++++++++------------- tests/components/stream/test_hls.py | 6 +- tests/components/stream/test_ll_hls.py | 125 ++++--------------- tests/components/stream/test_recorder.py | 6 +- tests/components/stream/test_worker.py | 6 +- 6 files changed, 112 insertions(+), 248 deletions(-) diff --git a/homeassistant/components/stream/core.py b/homeassistant/components/stream/core.py index 77e41511b92..998e27dcaec 100644 --- a/homeassistant/components/stream/core.py +++ b/homeassistant/components/stream/core.py @@ -3,9 +3,8 @@ from __future__ import annotations import asyncio from collections import deque -from collections.abc import Generator, Iterable +from collections.abc import Iterable import datetime -import itertools from typing import TYPE_CHECKING from aiohttp import web @@ -58,10 +57,7 @@ class Segment: start_time: datetime.datetime = attr.ib() _stream_outputs: Iterable[StreamOutput] = attr.ib() duration: float = attr.ib(default=0) - # Parts are stored in a dict indexed by byterange for easy lookup - # As of Python 3.7, insertion order is preserved, and we insert - # in sequential order, so the Parts are ordered - parts_by_byterange: dict[int, Part] = attr.ib(factory=dict) + parts: list[Part] = attr.ib(factory=list) # Store text of this segment's hls playlist for reuse # Use list[str] for easy appends hls_playlist_template: list[str] = attr.ib(factory=list) @@ -89,13 +85,7 @@ class Segment: @property def data_size(self) -> int: """Return the size of all part data without init in bytes.""" - # We can use the last part to quickly calculate the total data size. - if not self.parts_by_byterange: - return 0 - last_http_range_start, last_part = next( - reversed(self.parts_by_byterange.items()) - ) - return last_http_range_start + len(last_part.data) + return sum(len(part.data) for part in self.parts) @callback def async_add_part( @@ -107,36 +97,14 @@ class Segment: Duration is non zero only for the last part. """ - self.parts_by_byterange[self.data_size] = part + self.parts.append(part) self.duration = duration for output in self._stream_outputs: output.part_put() def get_data(self) -> bytes: """Return reconstructed data for all parts as bytes, without init.""" - return b"".join([part.data for part in self.parts_by_byterange.values()]) - - def get_aggregating_bytes( - self, start_loc: int, end_loc: int | float - ) -> Generator[bytes, None, None]: - """Yield available remaining data until segment is complete or end_loc is reached. - - Begin at start_loc. End at end_loc (exclusive). - Used to help serve a range request on a segment. - """ - pos = start_loc - while (part := self.parts_by_byterange.get(pos)) or not self.complete: - if not part: - yield b"" - continue - pos += len(part.data) - # Check stopping condition and trim output if necessary - if pos >= end_loc: - assert isinstance(end_loc, int) - # Trimming is probably not necessary, but it doesn't hurt - yield part.data[: len(part.data) + end_loc - pos] - return - yield part.data + return b"".join([part.data for part in self.parts]) def _render_hls_template(self, last_stream_id: int, render_parts: bool) -> str: """Render the HLS playlist section for the Segment. @@ -151,15 +119,12 @@ class Segment: # This is a placeholder where the rendered parts will be inserted self.hls_playlist_template.append("{}") if render_parts: - for http_range_start, part in itertools.islice( - self.parts_by_byterange.items(), - self.hls_num_parts_rendered, - None, + for part_num, part in enumerate( + self.parts[self.hls_num_parts_rendered :], self.hls_num_parts_rendered ): self.hls_playlist_parts.append( f"#EXT-X-PART:DURATION={part.duration:.3f},URI=" - f'"./segment/{self.sequence}.m4s",BYTERANGE="{len(part.data)}' - f'@{http_range_start}"{",INDEPENDENT=YES" if part.has_keyframe else ""}' + f'"./segment/{self.sequence}.{part_num}.m4s"{",INDEPENDENT=YES" if part.has_keyframe else ""}' ) if self.complete: # Construct the final playlist_template. The placeholder will share a line with @@ -187,7 +152,7 @@ class Segment: self.hls_playlist_template = ["\n".join(self.hls_playlist_template)] # lstrip discards extra preceding newline in case first render was empty self.hls_playlist_parts = ["\n".join(self.hls_playlist_parts).lstrip()] - self.hls_num_parts_rendered = len(self.parts_by_byterange) + self.hls_num_parts_rendered = len(self.parts) self.hls_playlist_complete = self.complete return self.hls_playlist_template[0] @@ -208,11 +173,13 @@ class Segment: # pylint: disable=undefined-loop-variable if self.complete: # Next part belongs to next segment sequence = self.sequence + 1 - start = 0 + part_num = 0 else: # Next part is in the same segment sequence = self.sequence - start = self.data_size - hint = f'#EXT-X-PRELOAD-HINT:TYPE=PART,URI="./segment/{sequence}.m4s",BYTERANGE-START={start}' + part_num = len(self.parts) + hint = ( + f'#EXT-X-PRELOAD-HINT:TYPE=PART,URI="./segment/{sequence}.{part_num}.m4s"' + ) return (playlist + "\n" + hint) if playlist else hint @@ -367,7 +334,7 @@ class StreamView(HomeAssistantView): platform = None async def get( - self, request: web.Request, token: str, sequence: str = "" + self, request: web.Request, token: str, sequence: str = "", part_num: str = "" ) -> web.StreamResponse: """Start a GET request.""" hass = request.app["hass"] @@ -383,10 +350,10 @@ class StreamView(HomeAssistantView): # Start worker if not already started stream.start() - return await self.handle(request, stream, sequence) + return await self.handle(request, stream, sequence, part_num) async def handle( - self, request: web.Request, stream: Stream, sequence: str + self, request: web.Request, stream: Stream, sequence: str, part_num: str ) -> web.StreamResponse: """Handle the stream request.""" raise NotImplementedError() diff --git a/homeassistant/components/stream/hls.py b/homeassistant/components/stream/hls.py index 9b154e9236b..39ea9a5e8c0 100644 --- a/homeassistant/components/stream/hls.py +++ b/homeassistant/components/stream/hls.py @@ -34,6 +34,7 @@ def async_setup_hls(hass: HomeAssistant) -> str: hass.http.register_view(HlsSegmentView()) hass.http.register_view(HlsInitView()) hass.http.register_view(HlsMasterPlaylistView()) + hass.http.register_view(HlsPartView()) return "/api/hls/{}/master_playlist.m3u8" @@ -94,7 +95,7 @@ class HlsMasterPlaylistView(StreamView): return "\n".join(lines) + "\n" async def handle( - self, request: web.Request, stream: Stream, sequence: str + self, request: web.Request, stream: Stream, sequence: str, part_num: str ) -> web.Response: """Return m3u8 playlist.""" track = stream.add_provider(HLS_PROVIDER) @@ -220,7 +221,7 @@ class HlsPlaylistView(StreamView): ) async def handle( - self, request: web.Request, stream: Stream, sequence: str + self, request: web.Request, stream: Stream, sequence: str, part_num: str ) -> web.Response: """Return m3u8 playlist.""" track: HlsStreamOutput = cast( @@ -263,7 +264,7 @@ class HlsPlaylistView(StreamView): (last_segment := track.last_segment) and hls_msn == last_segment.sequence and hls_part - >= len(last_segment.parts_by_byterange) + >= len(last_segment.parts) - 1 + track.stream_settings.hls_advance_part_limit ): @@ -273,7 +274,7 @@ class HlsPlaylistView(StreamView): while ( (last_segment := track.last_segment) and hls_msn == last_segment.sequence - and hls_part >= len(last_segment.parts_by_byterange) + and hls_part >= len(last_segment.parts) ): if not await track.part_recv( timeout=track.stream_settings.hls_part_timeout @@ -287,8 +288,8 @@ class HlsPlaylistView(StreamView): # request as one for Part Index 0 of the following Parent Segment. if hls_msn + 1 == last_segment.sequence: if not (previous_segment := track.get_segment(hls_msn)) or ( - hls_part >= len(previous_segment.parts_by_byterange) - and not last_segment.parts_by_byterange + hls_part >= len(previous_segment.parts) + and not last_segment.parts and not await track.part_recv( timeout=track.stream_settings.hls_part_timeout ) @@ -314,7 +315,7 @@ class HlsInitView(StreamView): cors_allowed = True async def handle( - self, request: web.Request, stream: Stream, sequence: str + self, request: web.Request, stream: Stream, sequence: str, part_num: str ) -> web.Response: """Return init.mp4.""" track = stream.add_provider(HLS_PROVIDER) @@ -326,21 +327,17 @@ class HlsInitView(StreamView): ) -class HlsSegmentView(StreamView): +class HlsPartView(StreamView): """Stream view to serve a HLS fmp4 segment.""" - url = r"/api/hls/{token:[a-f0-9]+}/segment/{sequence:\d+}.m4s" - name = "api:stream:hls:segment" + url = r"/api/hls/{token:[a-f0-9]+}/segment/{sequence:\d+}.{part_num:\d+}.m4s" + name = "api:stream:hls:part" cors_allowed = True async def handle( - self, request: web.Request, stream: Stream, sequence: str - ) -> web.StreamResponse: - """Handle segments, part segments, and hinted segments. - - For part and hinted segments, the start of the requested range must align - with a part boundary. - """ + self, request: web.Request, stream: Stream, sequence: str, part_num: str + ) -> web.Response: + """Handle part.""" track: HlsStreamOutput = cast( HlsStreamOutput, stream.add_provider(HLS_PROVIDER) ) @@ -360,77 +357,58 @@ class HlsSegmentView(StreamView): status=404, headers={"Cache-Control": f"max-age={track.target_duration:.0f}"}, ) - # If the segment is ready or has been hinted, the http_range start should be at most - # equal to the end of the currently available data. - # If the segment is complete, the http_range start should be less than the end of the - # currently available data. - # If these conditions aren't met then we return a 416. - # http_range_start can be None, so use a copy that uses 0 instead of None - if (http_start := request.http_range.start or 0) > segment.data_size or ( - segment.complete and http_start >= segment.data_size - ): + # If the part is ready or has been hinted, + if int(part_num) == len(segment.parts): + await track.part_recv(timeout=track.stream_settings.hls_part_timeout) + if int(part_num) >= len(segment.parts): return web.HTTPRequestRangeNotSatisfiable( headers={ "Cache-Control": f"max-age={track.target_duration:.0f}", - "Content-Range": f"bytes */{segment.data_size}", } ) - headers = { - "Content-Type": "video/iso.segment", - "Cache-Control": f"max-age={6*track.target_duration:.0f}", - } - # For most cases we have a 206 partial content response. - status = 206 - # For the 206 responses we need to set a Content-Range header - # See https://datatracker.ietf.org/doc/html/rfc8673#section-2 - if request.http_range.stop is None: - if request.http_range.start is None: - status = 200 - if segment.complete: - # This is a request for a full segment which is already complete - # We should return a standard 200 response. - return web.Response( - body=segment.get_data(), headers=headers, status=status - ) - # Otherwise we still return a 200 response, but it is aggregating - http_stop = float("inf") - else: - # See https://datatracker.ietf.org/doc/html/rfc7233#section-2.1 - headers[ - "Content-Range" - ] = f"bytes {http_start}-{(http_stop:=segment.data_size)-1}/*" - else: # The remaining cases are all 206 responses - if segment.complete: - # If the segment is complete we have total size - headers["Content-Range"] = ( - f"bytes {http_start}-" - + str( - (http_stop := min(request.http_range.stop, segment.data_size)) - - 1 - ) - + f"/{segment.data_size}" - ) - else: - # If we don't have the total size we use a * - headers[ - "Content-Range" - ] = f"bytes {http_start}-{(http_stop:=request.http_range.stop)-1}/*" - # Set up streaming response that we can write to as data becomes available - response = web.StreamResponse(headers=headers, status=status) - # Waiting until we write to prepare *might* give clients more accurate TTFB - # and ABR measurements, but it is probably not very useful for us since we - # only have one rendition anyway. Just prepare here for now. - await response.prepare(request) - try: - for bytes_to_write in segment.get_aggregating_bytes( - start_loc=http_start, end_loc=http_stop - ): - if bytes_to_write: - await response.write(bytes_to_write) - elif not await track.part_recv( - timeout=track.stream_settings.hls_part_timeout - ): - break - except ConnectionResetError: - _LOGGER.warning("Connection reset while serving HLS partial segment") - return response + return web.Response( + body=segment.parts[int(part_num)].data, + headers={ + "Content-Type": "video/iso.segment", + "Cache-Control": f"max-age={6*track.target_duration:.0f}", + }, + ) + + +class HlsSegmentView(StreamView): + """Stream view to serve a HLS fmp4 segment.""" + + url = r"/api/hls/{token:[a-f0-9]+}/segment/{sequence:\d+}.m4s" + name = "api:stream:hls:segment" + cors_allowed = True + + async def handle( + self, request: web.Request, stream: Stream, sequence: str, part_num: str + ) -> web.StreamResponse: + """Handle segments.""" + track: HlsStreamOutput = cast( + HlsStreamOutput, stream.add_provider(HLS_PROVIDER) + ) + track.idle_timer.awake() + # Ensure that we have a segment. If the request is from a hint for part 0 + # of a segment, there is a small chance it may have arrived before the + # segment has been put. If this happens, wait for one part and retry. + if not ( + (segment := track.get_segment(int(sequence))) + or ( + await track.part_recv(timeout=track.stream_settings.hls_part_timeout) + and (segment := track.get_segment(int(sequence))) + ) + ): + return web.Response( + body=None, + status=404, + headers={"Cache-Control": f"max-age={track.target_duration:.0f}"}, + ) + return web.Response( + body=segment.get_data(), + headers={ + "Content-Type": "video/iso.segment", + "Cache-Control": f"max-age={6*track.target_duration:.0f}", + }, + ) diff --git a/tests/components/stream/test_hls.py b/tests/components/stream/test_hls.py index 4b0cb0322ce..da040f6646a 100644 --- a/tests/components/stream/test_hls.py +++ b/tests/components/stream/test_hls.py @@ -345,13 +345,13 @@ async def test_hls_max_segments(hass, hls_stream, stream_worker_sync): # Fetch the actual segments with a fake byte payload for segment in hls.get_segments(): segment.init = INIT_BYTES - segment.parts_by_byterange = { - 0: Part( + segment.parts = [ + Part( duration=SEGMENT_DURATION, has_keyframe=True, data=FAKE_PAYLOAD, ) - } + ] # The segment that fell off the buffer is not accessible with patch.object(hls.stream_settings, "hls_part_timeout", 0.1): diff --git a/tests/components/stream/test_ll_hls.py b/tests/components/stream/test_ll_hls.py index 8e512e0723e..ab1c01adce8 100644 --- a/tests/components/stream/test_ll_hls.py +++ b/tests/components/stream/test_ll_hls.py @@ -59,9 +59,7 @@ def create_segment(sequence): def complete_segment(segment): """Completes a segment by setting its duration.""" - segment.duration = sum( - part.duration for part in segment.parts_by_byterange.values() - ) + segment.duration = sum(part.duration for part in segment.parts) def create_parts(source): @@ -90,9 +88,8 @@ def make_segment_with_parts( """Create a playlist response for a segment including part segments.""" response = [] for i in range(num_parts): - length, start = http_range_from_part(i) response.append( - f'#EXT-X-PART:DURATION={TEST_PART_DURATION:.3f},URI="./segment/{segment}.m4s",BYTERANGE="{length}@{start}"{",INDEPENDENT=YES" if i%independent_period==0 else ""}' + f'#EXT-X-PART:DURATION={TEST_PART_DURATION:.3f},URI="./segment/{segment}.{i}.m4s"{",INDEPENDENT=YES" if i%independent_period==0 else ""}' ) if discontinuity: response.append("#EXT-X-DISCONTINUITY") @@ -110,8 +107,7 @@ def make_segment_with_parts( def make_hint(segment, part): """Create a playlist response for the preload hint.""" - _, start = http_range_from_part(part) - return f'#EXT-X-PRELOAD-HINT:TYPE=PART,URI="./segment/{segment}.m4s",BYTERANGE-START={start}' + return f'#EXT-X-PRELOAD-HINT:TYPE=PART,URI="./segment/{segment}.{part}.m4s"' async def test_ll_hls_stream(hass, hls_stream, stream_worker_sync): @@ -252,9 +248,7 @@ async def test_ll_hls_playlist_view(hass, hls_stream, stream_worker_sync): assert await resp.text() == make_playlist( sequence=0, segments=[ - make_segment_with_parts( - i, len(segment.parts_by_byterange), PART_INDEPENDENT_PERIOD - ) + make_segment_with_parts(i, len(segment.parts), PART_INDEPENDENT_PERIOD) for i in range(2) ], hint=make_hint(2, 0), @@ -275,9 +269,7 @@ async def test_ll_hls_playlist_view(hass, hls_stream, stream_worker_sync): assert await resp.text() == make_playlist( sequence=0, segments=[ - make_segment_with_parts( - i, len(segment.parts_by_byterange), PART_INDEPENDENT_PERIOD - ) + make_segment_with_parts(i, len(segment.parts), PART_INDEPENDENT_PERIOD) for i in range(3) ], hint=make_hint(3, 0), @@ -459,13 +451,13 @@ async def test_ll_hls_playlist_rollover_part( *( [ hls_client.get( - f"/playlist.m3u8?_HLS_msn=1&_HLS_part={len(segment.parts_by_byterange)-1}" + f"/playlist.m3u8?_HLS_msn=1&_HLS_part={len(segment.parts)-1}" ), hls_client.get( - f"/playlist.m3u8?_HLS_msn=1&_HLS_part={len(segment.parts_by_byterange)}" + f"/playlist.m3u8?_HLS_msn=1&_HLS_part={len(segment.parts)}" ), hls_client.get( - f"/playlist.m3u8?_HLS_msn=1&_HLS_part={len(segment.parts_by_byterange)+1}" + f"/playlist.m3u8?_HLS_msn=1&_HLS_part={len(segment.parts)+1}" ), hls_client.get("/playlist.m3u8?_HLS_msn=2&_HLS_part=0"), ] @@ -600,85 +592,32 @@ async def test_get_part_segments(hass, hls_stream, stream_worker_sync, hls_sync) segment.async_add_part(remaining_parts.pop(0), 0) # Make requests for all the existing part segments - # These should succeed with a status of 206 + # These should succeed requests = asyncio.gather( *( - hls_client.get( - "/segment/1.m4s", - headers={ - "Range": f"bytes={http_range_from_part(part)[1]}-" - + str( - http_range_from_part(part)[0] - + http_range_from_part(part)[1] - - 1 - ) - }, - ) + hls_client.get(f"/segment/1.{part}.m4s") for part in range(num_completed_parts) ) ) responses = await requests - assert all(response.status == 206 for response in responses) + assert all(response.status == 200 for response in responses) assert all( - responses[part].headers["Content-Range"] - == f"bytes {http_range_from_part(part)[1]}-" - + str(http_range_from_part(part)[0] + http_range_from_part(part)[1] - 1) - + "/*" - for part in range(num_completed_parts) + [ + await responses[i].read() == segment.parts[i].data + for i in range(len(responses)) + ] ) - parts = list(segment.parts_by_byterange.values()) - assert all( - [await responses[i].read() == parts[i].data for i in range(len(responses))] - ) - - # Make some non standard range requests. - # Request past end of previous closed segment - # Request should succeed but length will be limited to the segment length - response = await hls_client.get( - "/segment/0.m4s", - headers={"Range": f"bytes=0-{hls.get_segment(0).data_size+1}"}, - ) - assert response.status == 206 - assert ( - response.headers["Content-Range"] - == f"bytes 0-{hls.get_segment(0).data_size-1}/{hls.get_segment(0).data_size}" - ) - assert (await response.read()) == hls.get_segment(0).get_data() - - # Request with start range past end of current segment - # Since this is beyond the data we have (the largest starting position will be - # from a hinted request, and even that will have a starting position at - # segment.data_size), we expect a 416. - response = await hls_client.get( - "/segment/1.m4s", - headers={"Range": f"bytes={segment.data_size+1}-{VERY_LARGE_LAST_BYTE_POS}"}, - ) - assert response.status == 416 # Request for next segment which has not yet been hinted (we will only hint # for this segment after segment 1 is complete). # This should fail, but it will hold for one more part_put before failing. hls_sync.reset_request_pool(1) - request = asyncio.create_task( - hls_client.get( - "/segment/2.m4s", headers={"Range": f"bytes=0-{VERY_LARGE_LAST_BYTE_POS}"} - ) - ) + request = asyncio.create_task(hls_client.get("/segment/2.0.m4s")) await hls_sync.wait_for_handler() hls.part_put() response = await request assert response.status == 404 - # Make valid request for the current hint. This should succeed, but since - # it is open ended, it won't finish until the segment is complete. - hls_sync.reset_request_pool(1) - request_start = segment.data_size - request = asyncio.create_task( - hls_client.get( - "/segment/1.m4s", - headers={"Range": f"bytes={request_start}-{VERY_LARGE_LAST_BYTE_POS}"}, - ) - ) # Put the remaining parts and complete the segment while remaining_parts: await hls_sync.wait_for_handler() @@ -686,26 +625,11 @@ async def test_get_part_segments(hass, hls_stream, stream_worker_sync, hls_sync) segment.async_add_part(remaining_parts.pop(0), 0) hls.part_put() complete_segment(segment) - # Check the response - response = await request - assert response.status == 206 - assert ( - response.headers["Content-Range"] - == f"bytes {request_start}-{VERY_LARGE_LAST_BYTE_POS}/*" - ) - assert await response.read() == SEQUENCE_BYTES[request_start:] # Now the hint should have moved to segment 2 # The request for segment 2 which failed before should work now - # Also make an equivalent request with no Range parameters that - # will return the same content but with different headers - hls_sync.reset_request_pool(2) - requests = asyncio.gather( - hls_client.get( - "/segment/2.m4s", headers={"Range": f"bytes=0-{VERY_LARGE_LAST_BYTE_POS}"} - ), - hls_client.get("/segment/2.m4s"), - ) + hls_sync.reset_request_pool(1) + request = asyncio.create_task(hls_client.get("/segment/2.0.m4s")) # Put an entire segment and its parts. segment = create_segment(sequence=2) hls.put(segment) @@ -716,16 +640,11 @@ async def test_get_part_segments(hass, hls_stream, stream_worker_sync, hls_sync) hls.part_put() complete_segment(segment) # Check the response - responses = await requests - assert responses[0].status == 206 + response = await request + assert response.status == 200 assert ( - responses[0].headers["Content-Range"] == f"bytes 0-{VERY_LARGE_LAST_BYTE_POS}/*" - ) - assert responses[1].status == 200 - assert "Content-Range" not in responses[1].headers - assert ( - await response.read() == ALT_SEQUENCE_BYTES[: hls.get_segment(2).data_size] - for response in responses + await response.read() + == ALT_SEQUENCE_BYTES[: len(hls.get_segment(2).parts[0].data)] ) stream_worker_sync.resume() diff --git a/tests/components/stream/test_recorder.py b/tests/components/stream/test_recorder.py index b8521205920..ba35b5a4b72 100644 --- a/tests/components/stream/test_recorder.py +++ b/tests/components/stream/test_recorder.py @@ -126,14 +126,14 @@ def add_parts_to_segment(segment, source): """Add relevant part data to segment for testing recorder.""" moof_locs = list(find_box(source.getbuffer(), b"moof")) + [len(source.getbuffer())] segment.init = source.getbuffer()[: moof_locs[0]].tobytes() - segment.parts_by_byterange = { - moof_locs[i]: Part( + segment.parts = [ + Part( duration=None, has_keyframe=None, data=source.getbuffer()[moof_locs[i] : moof_locs[i + 1]], ) for i in range(len(moof_locs) - 1) - } + ] async def test_recorder_save(tmpdir): diff --git a/tests/components/stream/test_worker.py b/tests/components/stream/test_worker.py index 16412b28468..e353f950aea 100644 --- a/tests/components/stream/test_worker.py +++ b/tests/components/stream/test_worker.py @@ -699,7 +699,7 @@ async def test_durations(hass, record_worker_sync): # check that the Part duration metadata matches the durations in the media running_metadata_duration = 0 for segment in complete_segments: - for part in segment.parts_by_byterange.values(): + for part in segment.parts: av_part = av.open(io.BytesIO(segment.init + part.data)) running_metadata_duration += part.duration # av_part.duration will just return the largest dts in av_part. @@ -713,7 +713,7 @@ async def test_durations(hass, record_worker_sync): # check that the Part durations are consistent with the Segment durations for segment in complete_segments: assert math.isclose( - sum(part.duration for part in segment.parts_by_byterange.values()), + sum(part.duration for part in segment.parts), segment.duration, abs_tol=1e-6, ) @@ -751,7 +751,7 @@ async def test_has_keyframe(hass, record_worker_sync): # check that the Part has_keyframe metadata matches the keyframes in the media for segment in complete_segments: - for part in segment.parts_by_byterange.values(): + for part in segment.parts: av_part = av.open(io.BytesIO(segment.init + part.data)) media_has_keyframe = any( packet.is_keyframe for packet in av_part.demux(av_part.streams.video[0]) From 25273c694a81aca727590ee27a8f92c4a0f8b4f1 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Mon, 30 Aug 2021 09:26:48 +0100 Subject: [PATCH 091/843] System Bridge 2.5.0 - Additional Sensors (#53892) * Update package to 2.1.0 * Add version sensor * Add graphics memory sensors * Change graphics memory data from MB * Add GPU usage sensor * Add gpu clock speed sensors * GPU sensors * GPU power usage * enumerate instead of range len * Updates from rebase * Add graphics * Add Per CPU load sensor * Cleanup * Use super class attributes * Suggested changes and fix * User, System, Idle sensors * Average, User, System and idle sensors instead of attrs * Remove unused attrs * Remove null/none sensor * Sensor entity descriptions * Fix index out of range error * Set state class * Use entity_registry_enabled_default * Use built in entity_registry_enabled_default * Use built in icon * Fix * Use binary sensor entity description * Fix pylint * Fix uom * Add to coveragerc * is_on * Move entity descriptions to platforms * Clearout default values * Fix docstring Co-authored-by: Martin Hjelmare * Cleanup and catch Co-authored-by: Martin Hjelmare --- .../components/system_bridge/__init__.py | 17 +- .../components/system_bridge/binary_sensor.py | 79 +- .../components/system_bridge/coordinator.py | 1 + .../components/system_bridge/manifest.json | 2 +- .../components/system_bridge/sensor.py | 790 +++++++++--------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 460 insertions(+), 433 deletions(-) diff --git a/homeassistant/components/system_bridge/__init__.py b/homeassistant/components/system_bridge/__init__.py index f016cca798d..cba21ac0271 100644 --- a/homeassistant/components/system_bridge/__init__.py +++ b/homeassistant/components/system_bridge/__init__.py @@ -85,6 +85,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator.bridge.battery is None or coordinator.bridge.cpu is None or coordinator.bridge.filesystem is None + or coordinator.bridge.graphics is None or coordinator.bridge.information is None or coordinator.bridge.memory is None or coordinator.bridge.network is None @@ -230,17 +231,13 @@ class SystemBridgeEntity(CoordinatorEntity): self, coordinator: SystemBridgeDataUpdateCoordinator, key: str, - name: str, - icon: str | None, - enabled_by_default: bool, + name: str | None, ) -> None: """Initialize the System Bridge entity.""" super().__init__(coordinator) bridge: Bridge = coordinator.data self._key = f"{bridge.information.host}_{key}" self._name = f"{bridge.information.host} {name}" - self._icon = icon - self._enabled_default = enabled_by_default self._hostname = bridge.information.host self._mac = bridge.information.mac self._manufacturer = bridge.system.system.manufacturer @@ -257,16 +254,6 @@ class SystemBridgeEntity(CoordinatorEntity): """Return the name of the entity.""" return self._name - @property - def icon(self) -> str | None: - """Return the mdi icon of the entity.""" - return self._icon - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return self._enabled_default - class SystemBridgeDeviceEntity(SystemBridgeEntity): """Defines a System Bridge device entity.""" diff --git a/homeassistant/components/system_bridge/binary_sensor.py b/homeassistant/components/system_bridge/binary_sensor.py index f6b765f8079..4280293a434 100644 --- a/homeassistant/components/system_bridge/binary_sensor.py +++ b/homeassistant/components/system_bridge/binary_sensor.py @@ -1,11 +1,15 @@ """Support for System Bridge binary sensors.""" from __future__ import annotations +from dataclasses import dataclass +from typing import Callable + from systembridge import Bridge from homeassistant.components.binary_sensor import ( DEVICE_CLASS_BATTERY_CHARGING, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -15,6 +19,32 @@ from .const import DOMAIN from .coordinator import SystemBridgeDataUpdateCoordinator +@dataclass +class SystemBridgeBinarySensorEntityDescription(BinarySensorEntityDescription): + """Class describing System Bridge binary sensor entities.""" + + value: Callable = round + + +BASE_BINARY_SENSOR_TYPES: tuple[SystemBridgeBinarySensorEntityDescription, ...] = ( + SystemBridgeBinarySensorEntityDescription( + key="version_available", + name="New Version Available", + icon="mdi:counter", + value=lambda bridge: bridge.information.updates.available, + ), +) + +BATTERY_BINARY_SENSOR_TYPES: tuple[SystemBridgeBinarySensorEntityDescription, ...] = ( + SystemBridgeBinarySensorEntityDescription( + key="battery_is_charging", + name="Battery Is Charging", + device_class=DEVICE_CLASS_BATTERY_CHARGING, + value=lambda bridge: bridge.information.updates.available, + ), +) + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities ) -> None: @@ -22,49 +52,38 @@ async def async_setup_entry( coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] bridge: Bridge = coordinator.data + entities = [] + for description in BASE_BINARY_SENSOR_TYPES: + entities.append(SystemBridgeBinarySensor(coordinator, description)) + if bridge.battery and bridge.battery.hasBattery: - async_add_entities([SystemBridgeBatteryIsChargingBinarySensor(coordinator)]) + for description in BATTERY_BINARY_SENSOR_TYPES: + entities.append(SystemBridgeBinarySensor(coordinator, description)) + + async_add_entities(entities) class SystemBridgeBinarySensor(SystemBridgeDeviceEntity, BinarySensorEntity): - """Defines a System Bridge binary sensor.""" + """Define a System Bridge binary sensor.""" + + coordinator: SystemBridgeDataUpdateCoordinator + entity_description: SystemBridgeBinarySensorEntityDescription def __init__( self, coordinator: SystemBridgeDataUpdateCoordinator, - key: str, - name: str, - icon: str | None, - device_class: str | None, - enabled_by_default: bool, + description: SystemBridgeBinarySensorEntityDescription, ) -> None: - """Initialize System Bridge binary sensor.""" - self._device_class = device_class - - super().__init__(coordinator, key, name, icon, enabled_by_default) - - @property - def device_class(self) -> str | None: - """Return the class of this binary sensor.""" - return self._device_class - - -class SystemBridgeBatteryIsChargingBinarySensor(SystemBridgeBinarySensor): - """Defines a Battery is charging binary sensor.""" - - def __init__(self, coordinator: SystemBridgeDataUpdateCoordinator) -> None: - """Initialize System Bridge binary sensor.""" + """Initialize.""" super().__init__( coordinator, - "battery_is_charging", - "Battery Is Charging", - None, - DEVICE_CLASS_BATTERY_CHARGING, - True, + description.key, + description.name, ) + self.entity_description = description @property def is_on(self) -> bool: - """Return if the state is on.""" + """Return the boolean state of the binary sensor.""" bridge: Bridge = self.coordinator.data - return bridge.battery.isCharging + return self.entity_description.value(bridge) diff --git a/homeassistant/components/system_bridge/coordinator.py b/homeassistant/components/system_bridge/coordinator.py index d34e1019a0b..177a09e5d25 100644 --- a/homeassistant/components/system_bridge/coordinator.py +++ b/homeassistant/components/system_bridge/coordinator.py @@ -66,6 +66,7 @@ class SystemBridgeDataUpdateCoordinator(DataUpdateCoordinator[Bridge]): "battery", "cpu", "filesystem", + "graphics", "memory", "network", "os", diff --git a/homeassistant/components/system_bridge/manifest.json b/homeassistant/components/system_bridge/manifest.json index 2f1ec0111cf..73d1d03618f 100644 --- a/homeassistant/components/system_bridge/manifest.json +++ b/homeassistant/components/system_bridge/manifest.json @@ -3,7 +3,7 @@ "name": "System Bridge", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/system_bridge", - "requirements": ["systembridge==2.0.6"], + "requirements": ["systembridge==2.1.0"], "codeowners": ["@timmo001"], "zeroconf": ["_system-bridge._udp.local."], "after_dependencies": ["zeroconf"], diff --git a/homeassistant/components/system_bridge/sensor.py b/homeassistant/components/system_bridge/sensor.py index acfcc54f05c..18227e60800 100644 --- a/homeassistant/components/system_bridge/sensor.py +++ b/homeassistant/components/system_bridge/sensor.py @@ -1,40 +1,195 @@ """Support for System Bridge sensors.""" from __future__ import annotations +from dataclasses import dataclass from datetime import datetime, timedelta -from typing import Any +from typing import Callable, Final, cast from systembridge import Bridge -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( DATA_GIGABYTES, DEVICE_CLASS_BATTERY, + DEVICE_CLASS_POWER, DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_TIMESTAMP, DEVICE_CLASS_VOLTAGE, ELECTRIC_POTENTIAL_VOLT, FREQUENCY_GIGAHERTZ, + FREQUENCY_MEGAHERTZ, PERCENTAGE, + POWER_WATT, TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import StateType from . import SystemBridgeDeviceEntity from .const import DOMAIN from .coordinator import SystemBridgeDataUpdateCoordinator -ATTR_AVAILABLE = "available" -ATTR_FILESYSTEM = "filesystem" -ATTR_LOAD_AVERAGE = "load_average" -ATTR_LOAD_IDLE = "load_idle" -ATTR_LOAD_SYSTEM = "load_system" -ATTR_LOAD_USER = "load_user" -ATTR_MOUNT = "mount" -ATTR_SIZE = "size" -ATTR_TYPE = "type" -ATTR_USED = "used" +ATTR_AVAILABLE: Final = "available" +ATTR_FILESYSTEM: Final = "filesystem" +ATTR_MOUNT: Final = "mount" +ATTR_SIZE: Final = "size" +ATTR_TYPE: Final = "type" +ATTR_USED: Final = "used" + + +@dataclass +class SystemBridgeSensorEntityDescription(SensorEntityDescription): + """Class describing System Bridge sensor entities.""" + + value: Callable = round + + +BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( + SystemBridgeSensorEntityDescription( + key="bios_version", + name="BIOS Version", + entity_registry_enabled_default=False, + icon="mdi:chip", + value=lambda bridge: bridge.system.bios.version, + ), + SystemBridgeSensorEntityDescription( + key="cpu_speed", + name="CPU Speed", + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=FREQUENCY_GIGAHERTZ, + icon="mdi:speedometer", + value=lambda bridge: bridge.cpu.currentSpeed.avg, + ), + SystemBridgeSensorEntityDescription( + key="cpu_temperature", + name="CPU Temperature", + entity_registry_enabled_default=False, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=TEMP_CELSIUS, + value=lambda bridge: bridge.cpu.temperature.main, + ), + SystemBridgeSensorEntityDescription( + key="cpu_voltage", + name="CPU Voltage", + entity_registry_enabled_default=False, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + value=lambda bridge: bridge.cpu.cpu.voltage, + ), + SystemBridgeSensorEntityDescription( + key="kernel", + name="Kernel", + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:devices", + value=lambda bridge: bridge.os.kernel, + ), + SystemBridgeSensorEntityDescription( + key="memory_free", + name="Memory Free", + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=DATA_GIGABYTES, + icon="mdi:memory", + value=lambda bridge: round(bridge.memory.free / 1000 ** 3, 2), + ), + SystemBridgeSensorEntityDescription( + key="memory_used_percentage", + name="Memory Used %", + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + icon="mdi:memory", + value=lambda bridge: round((bridge.memory.used / bridge.memory.total) * 100, 2), + ), + SystemBridgeSensorEntityDescription( + key="memory_used", + name="Memory Used", + entity_registry_enabled_default=False, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=DATA_GIGABYTES, + icon="mdi:memory", + value=lambda bridge: round(bridge.memory.used / 1000 ** 3, 2), + ), + SystemBridgeSensorEntityDescription( + key="os", + name="Operating System", + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:devices", + value=lambda bridge: f"{bridge.os.distro} {bridge.os.release}", + ), + SystemBridgeSensorEntityDescription( + key="processes_load", + name="Load", + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + icon="mdi:percent", + value=lambda bridge: round(bridge.processes.load.currentLoad, 2), + ), + SystemBridgeSensorEntityDescription( + key="processes_load_idle", + name="Idle Load", + entity_registry_enabled_default=False, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + icon="mdi:percent", + value=lambda bridge: round(bridge.processes.load.currentLoadIdle, 2), + ), + SystemBridgeSensorEntityDescription( + key="processes_load_system", + name="System Load", + entity_registry_enabled_default=False, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + icon="mdi:percent", + value=lambda bridge: round(bridge.processes.load.currentLoadSystem, 2), + ), + SystemBridgeSensorEntityDescription( + key="processes_load_user", + name="User Load", + entity_registry_enabled_default=False, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + icon="mdi:percent", + value=lambda bridge: round(bridge.processes.load.currentLoadUser, 2), + ), + SystemBridgeSensorEntityDescription( + key="version", + name="Version", + icon="mdi:counter", + value=lambda bridge: bridge.information.version, + ), + SystemBridgeSensorEntityDescription( + key="version_latest", + name="Latest Version", + icon="mdi:counter", + value=lambda bridge: bridge.information.updates.version.new, + ), +) + +BATTERY_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( + SystemBridgeSensorEntityDescription( + key="battery", + name="Battery", + device_class=DEVICE_CLASS_BATTERY, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + value=lambda bridge: bridge.battery.percent, + ), + SystemBridgeSensorEntityDescription( + key="battery_time_remaining", + name="Battery Time Remaining", + device_class=DEVICE_CLASS_TIMESTAMP, + state_class=STATE_CLASS_MEASUREMENT, + value=lambda bridge: str( + datetime.now() + timedelta(minutes=bridge.battery.timeRemaining) + ), + ), +) async def async_setup_entry( @@ -43,395 +198,260 @@ async def async_setup_entry( """Set up System Bridge sensor based on a config entry.""" coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - entities = [ - SystemBridgeCpuSpeedSensor(coordinator), - SystemBridgeCpuTemperatureSensor(coordinator), - SystemBridgeCpuVoltageSensor(coordinator), - *( - SystemBridgeFilesystemSensor(coordinator, key) - for key, _ in coordinator.data.filesystem.fsSize.items() - ), - SystemBridgeMemoryFreeSensor(coordinator), - SystemBridgeMemoryUsedSensor(coordinator), - SystemBridgeMemoryUsedPercentageSensor(coordinator), - SystemBridgeKernelSensor(coordinator), - SystemBridgeOsSensor(coordinator), - SystemBridgeProcessesLoadSensor(coordinator), - SystemBridgeBiosVersionSensor(coordinator), - ] + entities = [] + for description in BASE_SENSOR_TYPES: + entities.append(SystemBridgeSensor(coordinator, description)) + + for key, _ in coordinator.data.filesystem.fsSize.items(): + uid = key.replace(":", "") + entities.append( + SystemBridgeSensor( + coordinator, + SystemBridgeSensorEntityDescription( + key=f"filesystem_{uid}", + name=f"{key} Space Used", + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + icon="mdi:harddisk", + value=lambda bridge, i=key: round( + bridge.filesystem.fsSize[i]["use"], 2 + ), + ), + ) + ) if coordinator.data.battery.hasBattery: - entities.append(SystemBridgeBatterySensor(coordinator)) - entities.append(SystemBridgeBatteryTimeRemainingSensor(coordinator)) + for description in BATTERY_SENSOR_TYPES: + entities.append(SystemBridgeSensor(coordinator, description)) + + for index, _ in enumerate(coordinator.data.graphics.controllers): + if coordinator.data.graphics.controllers[index].name is not None: + # Remove vendor from name + name = ( + coordinator.data.graphics.controllers[index] + .name.replace(coordinator.data.graphics.controllers[index].vendor, "") + .strip() + ) + entities = [ + *entities, + SystemBridgeSensor( + coordinator, + SystemBridgeSensorEntityDescription( + key=f"gpu_{index}_core_clock_speed", + name=f"{name} Clock Speed", + entity_registry_enabled_default=False, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=FREQUENCY_MEGAHERTZ, + icon="mdi:speedometer", + value=lambda bridge, i=index: bridge.graphics.controllers[ + i + ].clockCore, + ), + ), + SystemBridgeSensor( + coordinator, + SystemBridgeSensorEntityDescription( + key=f"gpu_{index}_memory_clock_speed", + name=f"{name} Memory Clock Speed", + entity_registry_enabled_default=False, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=FREQUENCY_MEGAHERTZ, + icon="mdi:speedometer", + value=lambda bridge, i=index: bridge.graphics.controllers[ + i + ].clockMemory, + ), + ), + SystemBridgeSensor( + coordinator, + SystemBridgeSensorEntityDescription( + key=f"gpu_{index}_memory_free", + name=f"{name} Memory Free", + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=DATA_GIGABYTES, + icon="mdi:memory", + value=lambda bridge, i=index: round( + bridge.graphics.controllers[i].memoryFree / 10 ** 3, 2 + ), + ), + ), + SystemBridgeSensor( + coordinator, + SystemBridgeSensorEntityDescription( + key=f"gpu_{index}_memory_used_percentage", + name=f"{name} Memory Used %", + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + icon="mdi:memory", + value=lambda bridge, i=index: round( + ( + bridge.graphics.controllers[i].memoryUsed + / bridge.graphics.controllers[i].memoryTotal + ) + * 100, + 2, + ), + ), + ), + SystemBridgeSensor( + coordinator, + SystemBridgeSensorEntityDescription( + key=f"gpu_{index}_memory_used", + name=f"{name} Memory Used", + entity_registry_enabled_default=False, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=DATA_GIGABYTES, + icon="mdi:memory", + value=lambda bridge, i=index: round( + bridge.graphics.controllers[i].memoryUsed / 10 ** 3, 2 + ), + ), + ), + SystemBridgeSensor( + coordinator, + SystemBridgeSensorEntityDescription( + key=f"gpu_{index}_fan_speed", + name=f"{name} Fan Speed", + entity_registry_enabled_default=False, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + icon="mdi:fan", + value=lambda bridge, i=index: bridge.graphics.controllers[ + i + ].fanSpeed, + ), + ), + SystemBridgeSensor( + coordinator, + SystemBridgeSensorEntityDescription( + key=f"gpu_{index}_power_usage", + name=f"{name} Power Usage", + entity_registry_enabled_default=False, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=POWER_WATT, + value=lambda bridge, i=index: bridge.graphics.controllers[ + i + ].powerDraw, + ), + ), + SystemBridgeSensor( + coordinator, + SystemBridgeSensorEntityDescription( + key=f"gpu_{index}_temperature", + name=f"{name} Temperature", + entity_registry_enabled_default=False, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=TEMP_CELSIUS, + value=lambda bridge, i=index: bridge.graphics.controllers[ + i + ].temperatureGpu, + ), + ), + SystemBridgeSensor( + coordinator, + SystemBridgeSensorEntityDescription( + key=f"gpu_{index}_usage_percentage", + name=f"{name} Usage %", + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + icon="mdi:percent", + value=lambda bridge, i=index: bridge.graphics.controllers[ + i + ].utilizationGpu, + ), + ), + ] + + for index, _ in enumerate(coordinator.data.processes.load.cpus): + entities = [ + *entities, + SystemBridgeSensor( + coordinator, + SystemBridgeSensorEntityDescription( + key=f"processes_load_cpu_{index}", + name=f"Load CPU {index}", + entity_registry_enabled_default=False, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + icon="mdi:percent", + value=lambda bridge: round( + bridge.processes.load.cpus[index].load, 2 + ), + ), + ), + SystemBridgeSensor( + coordinator, + SystemBridgeSensorEntityDescription( + key=f"processes_load_cpu_{index}_idle", + name=f"Idle Load CPU {index}", + entity_registry_enabled_default=False, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + icon="mdi:percent", + value=lambda bridge: round( + bridge.processes.load.cpus[index].loadIdle, 2 + ), + ), + ), + SystemBridgeSensor( + coordinator, + SystemBridgeSensorEntityDescription( + key=f"processes_load_cpu_{index}_system", + name=f"System Load CPU {index}", + entity_registry_enabled_default=False, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + icon="mdi:percent", + value=lambda bridge: round( + bridge.processes.load.cpus[index].loadSystem, 2 + ), + ), + ), + SystemBridgeSensor( + coordinator, + SystemBridgeSensorEntityDescription( + key=f"processes_load_cpu_{index}_user", + name=f"User Load CPU {index}", + entity_registry_enabled_default=False, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + icon="mdi:percent", + value=lambda bridge: round( + bridge.processes.load.cpus[index].loadUser, 2 + ), + ), + ), + ] async_add_entities(entities) class SystemBridgeSensor(SystemBridgeDeviceEntity, SensorEntity): - """Defines a System Bridge sensor.""" + """Define a System Bridge sensor.""" + + coordinator: SystemBridgeDataUpdateCoordinator + entity_description: SystemBridgeSensorEntityDescription def __init__( self, coordinator: SystemBridgeDataUpdateCoordinator, - key: str, - name: str, - icon: str | None, - device_class: str | None, - unit_of_measurement: str | None, - enabled_by_default: bool, + description: SystemBridgeSensorEntityDescription, ) -> None: - """Initialize System Bridge sensor.""" - self._device_class = device_class - self._unit_of_measurement = unit_of_measurement - - super().__init__(coordinator, key, name, icon, enabled_by_default) - - @property - def device_class(self) -> str | None: - """Return the class of this sensor.""" - return self._device_class - - @property - def native_unit_of_measurement(self) -> str | None: - """Return the unit this state is expressed in.""" - return self._unit_of_measurement - - -class SystemBridgeBatterySensor(SystemBridgeSensor): - """Defines a Battery sensor.""" - - def __init__(self, coordinator: SystemBridgeDataUpdateCoordinator) -> None: - """Initialize System Bridge sensor.""" + """Initialize.""" super().__init__( coordinator, - "battery", - "Battery", - None, - DEVICE_CLASS_BATTERY, - PERCENTAGE, - True, + description.key, + description.name, ) + self.entity_description = description @property - def native_value(self) -> float: - """Return the state of the sensor.""" + def native_value(self) -> StateType: + """Return the state.""" bridge: Bridge = self.coordinator.data - return bridge.battery.percent - - -class SystemBridgeBatteryTimeRemainingSensor(SystemBridgeSensor): - """Defines the Battery Time Remaining sensor.""" - - def __init__(self, coordinator: SystemBridgeDataUpdateCoordinator) -> None: - """Initialize System Bridge sensor.""" - super().__init__( - coordinator, - "battery_time_remaining", - "Battery Time Remaining", - None, - DEVICE_CLASS_TIMESTAMP, - None, - True, - ) - - @property - def native_value(self) -> str | None: - """Return the state of the sensor.""" - bridge: Bridge = self.coordinator.data - if bridge.battery.timeRemaining is None: + try: + return cast(StateType, self.entity_description.value(bridge)) + except TypeError: return None - return str(datetime.now() + timedelta(minutes=bridge.battery.timeRemaining)) - - -class SystemBridgeCpuSpeedSensor(SystemBridgeSensor): - """Defines a CPU speed sensor.""" - - def __init__(self, coordinator: SystemBridgeDataUpdateCoordinator) -> None: - """Initialize System Bridge sensor.""" - super().__init__( - coordinator, - "cpu_speed", - "CPU Speed", - "mdi:speedometer", - None, - FREQUENCY_GIGAHERTZ, - True, - ) - - @property - def native_value(self) -> float: - """Return the state of the sensor.""" - bridge: Bridge = self.coordinator.data - return bridge.cpu.currentSpeed.avg - - -class SystemBridgeCpuTemperatureSensor(SystemBridgeSensor): - """Defines a CPU temperature sensor.""" - - def __init__(self, coordinator: SystemBridgeDataUpdateCoordinator) -> None: - """Initialize System Bridge sensor.""" - super().__init__( - coordinator, - "cpu_temperature", - "CPU Temperature", - None, - DEVICE_CLASS_TEMPERATURE, - TEMP_CELSIUS, - False, - ) - - @property - def native_value(self) -> float: - """Return the state of the sensor.""" - bridge: Bridge = self.coordinator.data - return bridge.cpu.temperature.main - - -class SystemBridgeCpuVoltageSensor(SystemBridgeSensor): - """Defines a CPU voltage sensor.""" - - def __init__(self, coordinator: SystemBridgeDataUpdateCoordinator) -> None: - """Initialize System Bridge sensor.""" - super().__init__( - coordinator, - "cpu_voltage", - "CPU Voltage", - None, - DEVICE_CLASS_VOLTAGE, - ELECTRIC_POTENTIAL_VOLT, - False, - ) - - @property - def native_value(self) -> float: - """Return the state of the sensor.""" - bridge: Bridge = self.coordinator.data - return bridge.cpu.cpu.voltage - - -class SystemBridgeFilesystemSensor(SystemBridgeSensor): - """Defines a filesystem sensor.""" - - def __init__( - self, coordinator: SystemBridgeDataUpdateCoordinator, key: str - ) -> None: - """Initialize System Bridge sensor.""" - uid_key = key.replace(":", "") - super().__init__( - coordinator, - f"filesystem_{uid_key}", - f"{key} Space Used", - "mdi:harddisk", - None, - PERCENTAGE, - True, - ) - self._fs_key = key - - @property - def native_value(self) -> float: - """Return the state of the sensor.""" - bridge: Bridge = self.coordinator.data - return ( - round(bridge.filesystem.fsSize[self._fs_key]["use"], 2) - if bridge.filesystem.fsSize[self._fs_key]["use"] is not None - else None - ) - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return the state attributes of the entity.""" - bridge: Bridge = self.coordinator.data - return { - ATTR_AVAILABLE: bridge.filesystem.fsSize[self._fs_key]["available"], - ATTR_FILESYSTEM: bridge.filesystem.fsSize[self._fs_key]["fs"], - ATTR_MOUNT: bridge.filesystem.fsSize[self._fs_key]["mount"], - ATTR_SIZE: bridge.filesystem.fsSize[self._fs_key]["size"], - ATTR_TYPE: bridge.filesystem.fsSize[self._fs_key]["type"], - ATTR_USED: bridge.filesystem.fsSize[self._fs_key]["used"], - } - - -class SystemBridgeMemoryFreeSensor(SystemBridgeSensor): - """Defines a memory free sensor.""" - - def __init__(self, coordinator: SystemBridgeDataUpdateCoordinator) -> None: - """Initialize System Bridge sensor.""" - super().__init__( - coordinator, - "memory_free", - "Memory Free", - "mdi:memory", - None, - DATA_GIGABYTES, - True, - ) - - @property - def native_value(self) -> float | None: - """Return the state of the sensor.""" - bridge: Bridge = self.coordinator.data - return ( - round(bridge.memory.free / 1000 ** 3, 2) - if bridge.memory.free is not None - else None - ) - - -class SystemBridgeMemoryUsedSensor(SystemBridgeSensor): - """Defines a memory used sensor.""" - - def __init__(self, coordinator: SystemBridgeDataUpdateCoordinator) -> None: - """Initialize System Bridge sensor.""" - super().__init__( - coordinator, - "memory_used", - "Memory Used", - "mdi:memory", - None, - DATA_GIGABYTES, - False, - ) - - @property - def native_value(self) -> str | None: - """Return the state of the sensor.""" - bridge: Bridge = self.coordinator.data - return ( - round(bridge.memory.used / 1000 ** 3, 2) - if bridge.memory.used is not None - else None - ) - - -class SystemBridgeMemoryUsedPercentageSensor(SystemBridgeSensor): - """Defines a memory used percentage sensor.""" - - def __init__(self, coordinator: SystemBridgeDataUpdateCoordinator) -> None: - """Initialize System Bridge sensor.""" - super().__init__( - coordinator, - "memory_used_percentage", - "Memory Used %", - "mdi:memory", - None, - PERCENTAGE, - True, - ) - - @property - def native_value(self) -> str | None: - """Return the state of the sensor.""" - bridge: Bridge = self.coordinator.data - return ( - round((bridge.memory.used / bridge.memory.total) * 100, 2) - if bridge.memory.used is not None and bridge.memory.total is not None - else None - ) - - -class SystemBridgeKernelSensor(SystemBridgeSensor): - """Defines a kernel sensor.""" - - def __init__(self, coordinator: SystemBridgeDataUpdateCoordinator) -> None: - """Initialize System Bridge sensor.""" - super().__init__( - coordinator, - "kernel", - "Kernel", - "mdi:devices", - None, - None, - True, - ) - - @property - def native_value(self) -> str: - """Return the state of the sensor.""" - bridge: Bridge = self.coordinator.data - return bridge.os.kernel - - -class SystemBridgeOsSensor(SystemBridgeSensor): - """Defines an OS sensor.""" - - def __init__(self, coordinator: SystemBridgeDataUpdateCoordinator) -> None: - """Initialize System Bridge sensor.""" - super().__init__( - coordinator, - "os", - "Operating System", - "mdi:devices", - None, - None, - True, - ) - - @property - def native_value(self) -> str: - """Return the state of the sensor.""" - bridge: Bridge = self.coordinator.data - return f"{bridge.os.distro} {bridge.os.release}" - - -class SystemBridgeProcessesLoadSensor(SystemBridgeSensor): - """Defines a Processes Load sensor.""" - - def __init__(self, coordinator: SystemBridgeDataUpdateCoordinator) -> None: - """Initialize System Bridge sensor.""" - super().__init__( - coordinator, - "processes_load", - "Load", - "mdi:percent", - None, - PERCENTAGE, - True, - ) - - @property - def native_value(self) -> float | None: - """Return the state of the sensor.""" - bridge: Bridge = self.coordinator.data - return ( - round(bridge.processes.load.currentLoad, 2) - if bridge.processes.load.currentLoad is not None - else None - ) - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return the state attributes of the entity.""" - bridge: Bridge = self.coordinator.data - attrs = {} - if bridge.processes.load.avgLoad is not None: - attrs[ATTR_LOAD_AVERAGE] = round(bridge.processes.load.avgLoad, 2) - if bridge.processes.load.currentLoadUser is not None: - attrs[ATTR_LOAD_USER] = round(bridge.processes.load.currentLoadUser, 2) - if bridge.processes.load.currentLoadSystem is not None: - attrs[ATTR_LOAD_SYSTEM] = round(bridge.processes.load.currentLoadSystem, 2) - if bridge.processes.load.currentLoadIdle is not None: - attrs[ATTR_LOAD_IDLE] = round(bridge.processes.load.currentLoadIdle, 2) - return attrs - - -class SystemBridgeBiosVersionSensor(SystemBridgeSensor): - """Defines a bios version sensor.""" - - def __init__(self, coordinator: SystemBridgeDataUpdateCoordinator) -> None: - """Initialize System Bridge sensor.""" - super().__init__( - coordinator, - "bios_version", - "BIOS Version", - "mdi:chip", - None, - None, - False, - ) - - @property - def native_value(self) -> str: - """Return the state of the sensor.""" - bridge: Bridge = self.coordinator.data - return bridge.system.bios.version diff --git a/requirements_all.txt b/requirements_all.txt index 0264d83665b..62b889ecde8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2251,7 +2251,7 @@ swisshydrodata==0.1.0 synology-srm==0.2.0 # homeassistant.components.system_bridge -systembridge==2.0.6 +systembridge==2.1.0 # homeassistant.components.tahoma tahoma-api==0.0.16 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dec96024ce1..0ed06dae246 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1264,7 +1264,7 @@ sunwatcher==0.2.1 surepy==0.7.0 # homeassistant.components.system_bridge -systembridge==2.0.6 +systembridge==2.1.0 # homeassistant.components.tellduslive tellduslive==0.10.11 From 1060630bbdccda416f4a00655069864bc851c3a9 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 30 Aug 2021 10:29:39 +0200 Subject: [PATCH 092/843] Fix crash in buienradar sensor due to self.hass not set (#55438) --- homeassistant/components/buienradar/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/buienradar/sensor.py b/homeassistant/components/buienradar/sensor.py index 6dfbef9f931..2c6390f959b 100644 --- a/homeassistant/components/buienradar/sensor.py +++ b/homeassistant/components/buienradar/sensor.py @@ -699,7 +699,7 @@ class BrSensor(SensorEntity): @callback def data_updated(self, data): """Update data.""" - if self._load_data(data) and self.hass: + if self.hass and self._load_data(data): self.async_write_ha_state() @callback From 8faec3da8d4ebbac431f9c2fb65807a2013c4aaf Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 30 Aug 2021 12:06:24 +0200 Subject: [PATCH 093/843] Correct setup of system_bridge sensors (#55442) --- homeassistant/components/system_bridge/sensor.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/system_bridge/sensor.py b/homeassistant/components/system_bridge/sensor.py index 18227e60800..720bfc78a72 100644 --- a/homeassistant/components/system_bridge/sensor.py +++ b/homeassistant/components/system_bridge/sensor.py @@ -376,7 +376,7 @@ async def async_setup_entry( state_class=STATE_CLASS_MEASUREMENT, native_unit_of_measurement=PERCENTAGE, icon="mdi:percent", - value=lambda bridge: round( + value=lambda bridge, index=index: round( bridge.processes.load.cpus[index].load, 2 ), ), @@ -390,7 +390,7 @@ async def async_setup_entry( state_class=STATE_CLASS_MEASUREMENT, native_unit_of_measurement=PERCENTAGE, icon="mdi:percent", - value=lambda bridge: round( + value=lambda bridge, index=index: round( bridge.processes.load.cpus[index].loadIdle, 2 ), ), @@ -404,7 +404,7 @@ async def async_setup_entry( state_class=STATE_CLASS_MEASUREMENT, native_unit_of_measurement=PERCENTAGE, icon="mdi:percent", - value=lambda bridge: round( + value=lambda bridge, index=index: round( bridge.processes.load.cpus[index].loadSystem, 2 ), ), @@ -418,7 +418,7 @@ async def async_setup_entry( state_class=STATE_CLASS_MEASUREMENT, native_unit_of_measurement=PERCENTAGE, icon="mdi:percent", - value=lambda bridge: round( + value=lambda bridge, index=index: round( bridge.processes.load.cpus[index].loadUser, 2 ), ), From 7e9f8de7e057068aa18dbd002fed693037b321d8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 30 Aug 2021 12:08:21 +0200 Subject: [PATCH 094/843] Fix exception when shutting down DSMR (#55441) * Fix exception when shutting down DSMR * Update homeassistant/components/dsmr/sensor.py Co-authored-by: Martin Hjelmare Co-authored-by: Martin Hjelmare --- homeassistant/components/dsmr/sensor.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index d3dfb68d425..bd02be7d63e 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -25,7 +25,7 @@ from homeassistant.const import ( from homeassistant.core import CoreState, HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, StateType +from homeassistant.helpers.typing import ConfigType, EventType, StateType from homeassistant.util import Throttle from .const import ( @@ -146,8 +146,15 @@ async def async_setup_entry( if transport: # Register listener to close transport on HA shutdown + @callback + def close_transport(_event: EventType) -> None: + """Close the transport on HA shutdown.""" + if not transport: + return + transport.close() + stop_listener = hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STOP, transport.close + EVENT_HOMEASSISTANT_STOP, close_transport ) # Wait for reader to close From 722aa0895ef0ba46981e96e40ca9d244d4b31f91 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 30 Aug 2021 12:51:46 +0200 Subject: [PATCH 095/843] Improve statistics error messages when sensor's unit is changing (#55436) * Improve error messages when sensor's unit is changing * Improve test coverage --- homeassistant/components/sensor/recorder.py | 13 +++- tests/components/sensor/test_recorder.py | 85 +++++++++++++++++++++ 2 files changed, 95 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 2b59592dd17..6ab75f88dbd 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -200,11 +200,18 @@ def _normalize_states( hass.data[WARN_UNSTABLE_UNIT] = set() if entity_id not in hass.data[WARN_UNSTABLE_UNIT]: hass.data[WARN_UNSTABLE_UNIT].add(entity_id) + extra = "" + if old_metadata := statistics.get_metadata(hass, entity_id): + extra = ( + " and matches the unit of already compiled statistics " + f"({old_metadata['unit_of_measurement']})" + ) _LOGGER.warning( - "The unit of %s is changing, got %s, generation of long term " - "statistics will be suppressed unless the unit is stable", + "The unit of %s is changing, got multiple %s, generation of long term " + "statistics will be suppressed unless the unit is stable%s", entity_id, all_units, + extra, ) return None, [] unit = fstates[0][1].attributes.get(ATTR_UNIT_OF_MEASUREMENT) @@ -320,7 +327,7 @@ def compile_statistics( entity_id, unit, old_metadata["unit_of_measurement"], - unit, + old_metadata["unit_of_measurement"], ) continue diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 2e300b9c748..6c4c899eb14 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -1028,6 +1028,7 @@ def test_compile_hourly_statistics_changing_units_2( recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(minutes=30)) wait_recording_done(hass) assert "The unit of sensor.test1 is changing" in caplog.text + assert "and matches the unit of already compiled statistics" not in caplog.text statistic_ids = list_statistic_ids(hass) assert statistic_ids == [ {"statistic_id": "sensor.test1", "unit_of_measurement": "cats"} @@ -1038,6 +1039,90 @@ def test_compile_hourly_statistics_changing_units_2( assert "Error while processing event StatisticsTask" not in caplog.text +@pytest.mark.parametrize( + "device_class,unit,native_unit,mean,min,max", + [ + (None, None, None, 16.440677, 10, 30), + (None, "%", "%", 16.440677, 10, 30), + ("battery", "%", "%", 16.440677, 10, 30), + ("battery", None, None, 16.440677, 10, 30), + ], +) +def test_compile_hourly_statistics_changing_units_3( + hass_recorder, caplog, device_class, unit, native_unit, mean, min, max +): + """Test compiling hourly statistics where units change from one hour to the next.""" + zero = dt_util.utcnow() + hass = hass_recorder() + recorder = hass.data[DATA_INSTANCE] + setup_component(hass, "sensor", {}) + attributes = { + "device_class": device_class, + "state_class": "measurement", + "unit_of_measurement": unit, + } + four, states = record_states(hass, zero, "sensor.test1", attributes) + four, _states = record_states( + hass, zero + timedelta(hours=1), "sensor.test1", attributes + ) + states["sensor.test1"] += _states["sensor.test1"] + attributes["unit_of_measurement"] = "cats" + four, _states = record_states( + hass, zero + timedelta(hours=2), "sensor.test1", attributes + ) + states["sensor.test1"] += _states["sensor.test1"] + hist = history.get_significant_states(hass, zero, four) + assert dict(states) == dict(hist) + + recorder.do_adhoc_statistics(period="hourly", start=zero) + wait_recording_done(hass) + assert "does not match the unit of already compiled" not in caplog.text + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + {"statistic_id": "sensor.test1", "unit_of_measurement": native_unit} + ] + stats = statistics_during_period(hass, zero) + assert stats == { + "sensor.test1": [ + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero), + "mean": approx(mean), + "min": approx(min), + "max": approx(max), + "last_reset": None, + "state": None, + "sum": None, + } + ] + } + + recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=2)) + wait_recording_done(hass) + assert "The unit of sensor.test1 is changing" in caplog.text + assert f"matches the unit of already compiled statistics ({unit})" in caplog.text + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + {"statistic_id": "sensor.test1", "unit_of_measurement": native_unit} + ] + stats = statistics_during_period(hass, zero) + assert stats == { + "sensor.test1": [ + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero), + "mean": approx(mean), + "min": approx(min), + "max": approx(max), + "last_reset": None, + "state": None, + "sum": None, + } + ] + } + assert "Error while processing event StatisticsTask" not in caplog.text + + @pytest.mark.parametrize( "device_class,unit,native_unit,mean,min,max", [ From a668300c2e038b40b2ea6bbc51cb47598f4a5688 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Mon, 30 Aug 2021 14:11:07 +0200 Subject: [PATCH 096/843] Use AwesomeVersion for account link service check (#55449) --- .../components/cloud/account_link.py | 38 +++---------------- 1 file changed, 5 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/cloud/account_link.py b/homeassistant/components/cloud/account_link.py index 5ad7ddcffed..5bb0db6d057 100644 --- a/homeassistant/components/cloud/account_link.py +++ b/homeassistant/components/cloud/account_link.py @@ -4,9 +4,10 @@ import logging from typing import Any import aiohttp +from awesomeversion import AwesomeVersion from hass_nabucasa import account_link -from homeassistant.const import MAJOR_VERSION, MINOR_VERSION, PATCH_VERSION +from homeassistant.const import __version__ as HA_VERSION from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_entry_oauth2_flow, event @@ -16,6 +17,8 @@ DATA_SERVICES = "cloud_account_link_services" CACHE_TIMEOUT = 3600 _LOGGER = logging.getLogger(__name__) +CURRENT_VERSION = AwesomeVersion(HA_VERSION) + @callback def async_setup(hass: HomeAssistant): @@ -30,43 +33,12 @@ async def async_provide_implementation(hass: HomeAssistant, domain: str): services = await _get_services(hass) for service in services: - if service["service"] == domain and _is_older(service["min_version"]): + if service["service"] == domain and CURRENT_VERSION >= service["min_version"]: return CloudOAuth2Implementation(hass, domain) return -@callback -def _is_older(version: str) -> bool: - """Test if a version is older than the current HA version.""" - version_parts = version.split(".") - - if len(version_parts) != 3: - return False - - try: - version_parts = [int(val) for val in version_parts] - except ValueError: - return False - - patch_number_str = "" - - for char in PATCH_VERSION: - if char.isnumeric(): - patch_number_str += char - else: - break - - try: - patch_number = int(patch_number_str) - except ValueError: - patch_number = 0 - - cur_version_parts = [MAJOR_VERSION, MINOR_VERSION, patch_number] - - return version_parts <= cur_version_parts - - async def _get_services(hass): """Get the available services.""" services = hass.data.get(DATA_SERVICES) From ed53bb1d91aff02af6de0dd179d3a00c168fabfa Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 30 Aug 2021 16:58:48 +0200 Subject: [PATCH 097/843] Revert "Deprecate last_reset options in MQTT sensor" (#55457) This reverts commit f9fa5fa804291cdc3c2ab9592b3841fb2444bb72. --- homeassistant/components/mqtt/sensor.py | 29 ++++++++++--------------- tests/components/mqtt/test_sensor.py | 22 ------------------- 2 files changed, 12 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index c5441840878..1e0b164a6b8 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -53,23 +53,18 @@ MQTT_SENSOR_ATTRIBUTES_BLOCKED = frozenset( DEFAULT_NAME = "MQTT Sensor" DEFAULT_FORCE_UPDATE = False -PLATFORM_SCHEMA = vol.All( - # Deprecated, remove in Home Assistant 2021.11 - cv.deprecated(CONF_LAST_RESET_TOPIC), - cv.deprecated(CONF_LAST_RESET_VALUE_TEMPLATE), - mqtt.MQTT_RO_PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_EXPIRE_AFTER): cv.positive_int, - vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, - vol.Optional(CONF_LAST_RESET_TOPIC): mqtt.valid_subscribe_topic, - vol.Optional(CONF_LAST_RESET_VALUE_TEMPLATE): cv.template, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA, - vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, - } - ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema), -) +PLATFORM_SCHEMA = mqtt.MQTT_RO_PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_EXPIRE_AFTER): cv.positive_int, + vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, + vol.Optional(CONF_LAST_RESET_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_LAST_RESET_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, + } +).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) async def async_setup_platform( diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 724dec1c93f..15ca9870077 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -306,28 +306,6 @@ async def test_setting_sensor_last_reset_via_mqtt_json_message(hass, mqtt_mock): assert state.attributes.get("last_reset") == "2020-01-02T08:11:00" -async def test_last_reset_deprecated(hass, mqtt_mock, caplog): - """Test the setting of the last_reset property via MQTT.""" - assert await async_setup_component( - hass, - sensor.DOMAIN, - { - sensor.DOMAIN: { - "platform": "mqtt", - "name": "test", - "state_topic": "test-topic", - "unit_of_measurement": "fav unit", - "last_reset_topic": "last-reset-topic", - "last_reset_value_template": "{{ value_json.last_reset }}", - } - }, - ) - await hass.async_block_till_done() - - assert "The 'last_reset_topic' option is deprecated" in caplog.text - assert "The 'last_reset_value_template' option is deprecated" in caplog.text - - async def test_force_update_disabled(hass, mqtt_mock): """Test force update option.""" assert await async_setup_component( From c4235edc41bc27648881ff9bdac9519892fa0308 Mon Sep 17 00:00:00 2001 From: Christopher Kochan <5183896+crkochan@users.noreply.github.com> Date: Mon, 30 Aug 2021 10:01:26 -0500 Subject: [PATCH 098/843] Add Sense energy sensors (#54833) Co-authored-by: Paulus Schoutsen --- homeassistant/components/sense/const.py | 10 ++++ homeassistant/components/sense/sensor.py | 71 +++++++++++++++++------- 2 files changed, 61 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/sense/const.py b/homeassistant/components/sense/const.py index 783fcb5508a..af8454bbeab 100644 --- a/homeassistant/components/sense/const.py +++ b/homeassistant/components/sense/const.py @@ -23,6 +23,16 @@ CONSUMPTION_NAME = "Usage" CONSUMPTION_ID = "usage" PRODUCTION_NAME = "Production" PRODUCTION_ID = "production" +PRODUCTION_PCT_NAME = "Net Production Percentage" +PRODUCTION_PCT_ID = "production_pct" +NET_PRODUCTION_NAME = "Net Production" +NET_PRODUCTION_ID = "net_production" +TO_GRID_NAME = "To Grid" +TO_GRID_ID = "to_grid" +FROM_GRID_NAME = "From Grid" +FROM_GRID_ID = "from_grid" +SOLAR_POWERED_NAME = "Solar Powered Percentage" +SOLAR_POWERED_ID = "solar_powered" ICON = "mdi:flash" diff --git a/homeassistant/components/sense/sensor.py b/homeassistant/components/sense/sensor.py index 6be24a73a21..ce22551eff2 100644 --- a/homeassistant/components/sense/sensor.py +++ b/homeassistant/components/sense/sensor.py @@ -10,6 +10,7 @@ from homeassistant.const import ( DEVICE_CLASS_POWER, ELECTRIC_POTENTIAL_VOLT, ENERGY_KILO_WATT_HOUR, + PERCENTAGE, POWER_WATT, ) from homeassistant.core import callback @@ -22,15 +23,25 @@ from .const import ( CONSUMPTION_ID, CONSUMPTION_NAME, DOMAIN, + FROM_GRID_ID, + FROM_GRID_NAME, ICON, MDI_ICONS, + NET_PRODUCTION_ID, + NET_PRODUCTION_NAME, PRODUCTION_ID, PRODUCTION_NAME, + PRODUCTION_PCT_ID, + PRODUCTION_PCT_NAME, SENSE_DATA, SENSE_DEVICE_UPDATE, SENSE_DEVICES_DATA, SENSE_DISCOVERED_DEVICES_DATA, SENSE_TRENDS_COORDINATOR, + SOLAR_POWERED_ID, + SOLAR_POWERED_NAME, + TO_GRID_ID, + TO_GRID_NAME, ) @@ -55,7 +66,16 @@ TRENDS_SENSOR_TYPES = { } # Production/consumption variants -SENSOR_VARIANTS = [PRODUCTION_ID, CONSUMPTION_ID] +SENSOR_VARIANTS = [(PRODUCTION_ID, PRODUCTION_NAME), (CONSUMPTION_ID, CONSUMPTION_NAME)] + +# Trend production/consumption variants +TREND_SENSOR_VARIANTS = SENSOR_VARIANTS + [ + (PRODUCTION_PCT_ID, PRODUCTION_PCT_NAME), + (NET_PRODUCTION_ID, NET_PRODUCTION_NAME), + (FROM_GRID_ID, FROM_GRID_NAME), + (TO_GRID_ID, TO_GRID_NAME), + (SOLAR_POWERED_ID, SOLAR_POWERED_NAME), +] def sense_to_mdi(sense_icon): @@ -86,15 +106,20 @@ async def async_setup_entry(hass, config_entry, async_add_entities): if device["tags"]["DeviceListAllowed"] == "true" ] - for var in SENSOR_VARIANTS: + for variant_id, variant_name in SENSOR_VARIANTS: name = ACTIVE_SENSOR_TYPE.name sensor_type = ACTIVE_SENSOR_TYPE.sensor_type - is_production = var == PRODUCTION_ID - unique_id = f"{sense_monitor_id}-active-{var}" + unique_id = f"{sense_monitor_id}-active-{variant_id}" devices.append( SenseActiveSensor( - data, name, sensor_type, is_production, sense_monitor_id, var, unique_id + data, + name, + sensor_type, + sense_monitor_id, + variant_id, + variant_name, + unique_id, ) ) @@ -102,18 +127,18 @@ async def async_setup_entry(hass, config_entry, async_add_entities): devices.append(SenseVoltageSensor(data, i, sense_monitor_id)) for type_id, typ in TRENDS_SENSOR_TYPES.items(): - for var in SENSOR_VARIANTS: + for variant_id, variant_name in TREND_SENSOR_VARIANTS: name = typ.name sensor_type = typ.sensor_type - is_production = var == PRODUCTION_ID - unique_id = f"{sense_monitor_id}-{type_id}-{var}" + unique_id = f"{sense_monitor_id}-{type_id}-{variant_id}" devices.append( SenseTrendsSensor( data, name, sensor_type, - is_production, + variant_id, + variant_name, trends_coordinator, unique_id, ) @@ -137,19 +162,19 @@ class SenseActiveSensor(SensorEntity): data, name, sensor_type, - is_production, sense_monitor_id, - sensor_id, + variant_id, + variant_name, unique_id, ): """Initialize the Sense sensor.""" - name_type = PRODUCTION_NAME if is_production else CONSUMPTION_NAME - self._attr_name = f"{name} {name_type}" + self._attr_name = f"{name} {variant_name}" self._attr_unique_id = unique_id self._data = data self._sense_monitor_id = sense_monitor_id self._sensor_type = sensor_type - self._is_production = is_production + self._variant_id = variant_id + self._variant_name = variant_name async def async_added_to_hass(self): """Register callbacks.""" @@ -166,7 +191,7 @@ class SenseActiveSensor(SensorEntity): """Update the sensor from the data. Must not do I/O.""" new_state = round( self._data.active_solar_power - if self._is_production + if self._variant_id == PRODUCTION_ID else self._data.active_power ) if self._attr_available and self._attr_native_value == new_state: @@ -235,24 +260,30 @@ class SenseTrendsSensor(SensorEntity): data, name, sensor_type, - is_production, + variant_id, + variant_name, trends_coordinator, unique_id, ): """Initialize the Sense sensor.""" - name_type = PRODUCTION_NAME if is_production else CONSUMPTION_NAME - self._attr_name = f"{name} {name_type}" + self._attr_name = f"{name} {variant_name}" self._attr_unique_id = unique_id self._data = data self._sensor_type = sensor_type self._coordinator = trends_coordinator - self._is_production = is_production + self._variant_id = variant_id self._had_any_update = False + if variant_id in [PRODUCTION_PCT_ID, SOLAR_POWERED_ID]: + self._attr_native_unit_of_measurement = PERCENTAGE + self._attr_entity_registry_enabled_default = False + self._attr_state_class = None + self._attr_device_class = None + @property def native_value(self): """Return the state of the sensor.""" - return round(self._data.get_trend(self._sensor_type, self._is_production), 1) + return round(self._data.get_trend(self._sensor_type, self._variant_id), 1) @property def available(self): From cbc68e45cde87d649c73cb54ce5c9743f7ba6f13 Mon Sep 17 00:00:00 2001 From: Andre Richter Date: Mon, 30 Aug 2021 17:01:45 +0200 Subject: [PATCH 099/843] Refactor vallox constants (#55456) --- homeassistant/components/vallox/__init__.py | 33 ++++++++++----------- homeassistant/components/vallox/const.py | 22 ++++++++++++++ homeassistant/components/vallox/fan.py | 10 ++++--- homeassistant/components/vallox/sensor.py | 5 ++-- 4 files changed, 46 insertions(+), 24 deletions(-) create mode 100644 homeassistant/components/vallox/const.py diff --git a/homeassistant/components/vallox/__init__.py b/homeassistant/components/vallox/__init__.py index 6f88afa66cf..031c47a1233 100644 --- a/homeassistant/components/vallox/__init__.py +++ b/homeassistant/components/vallox/__init__.py @@ -1,6 +1,5 @@ """Support for Vallox ventilation units.""" -from datetime import timedelta import ipaddress import logging @@ -15,19 +14,21 @@ from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval +from .const import ( + DEFAULT_FAN_SPEED_AWAY, + DEFAULT_FAN_SPEED_BOOST, + DEFAULT_FAN_SPEED_HOME, + DEFAULT_NAME, + DOMAIN, + METRIC_KEY_PROFILE_FAN_SPEED_AWAY, + METRIC_KEY_PROFILE_FAN_SPEED_BOOST, + METRIC_KEY_PROFILE_FAN_SPEED_HOME, + SIGNAL_VALLOX_STATE_UPDATE, + STATE_PROXY_SCAN_INTERVAL, +) + _LOGGER = logging.getLogger(__name__) -DOMAIN = "vallox" -DEFAULT_NAME = "Vallox" -SIGNAL_VALLOX_STATE_UPDATE = "vallox_state_update" -SCAN_INTERVAL = timedelta(seconds=60) - -# Various metric keys that are reused between profiles. -METRIC_KEY_MODE = "A_CYC_MODE" -METRIC_KEY_PROFILE_FAN_SPEED_HOME = "A_CYC_HOME_SPEED_SETTING" -METRIC_KEY_PROFILE_FAN_SPEED_AWAY = "A_CYC_AWAY_SPEED_SETTING" -METRIC_KEY_PROFILE_FAN_SPEED_BOOST = "A_CYC_BOOST_SPEED_SETTING" - CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( @@ -93,10 +94,6 @@ SERVICE_TO_METHOD = { }, } -DEFAULT_FAN_SPEED_HOME = 50 -DEFAULT_FAN_SPEED_AWAY = 25 -DEFAULT_FAN_SPEED_BOOST = 65 - async def async_setup(hass, config): """Set up the client and boot the platforms.""" @@ -126,7 +123,7 @@ async def async_setup(hass, config): hass.async_create_task(async_load_platform(hass, "sensor", DOMAIN, {}, config)) hass.async_create_task(async_load_platform(hass, "fan", DOMAIN, {}, config)) - async_track_time_interval(hass, state_proxy.async_update, SCAN_INTERVAL) + async_track_time_interval(hass, state_proxy.async_update, STATE_PROXY_SCAN_INTERVAL) return True @@ -218,7 +215,7 @@ class ValloxServiceHandler: async def async_set_profile_fan_speed_away( self, fan_speed: int = DEFAULT_FAN_SPEED_AWAY ) -> bool: - """Set the fan speed in percent for the Home profile.""" + """Set the fan speed in percent for the Away profile.""" _LOGGER.debug("Setting Away fan speed to: %d%%", fan_speed) try: diff --git a/homeassistant/components/vallox/const.py b/homeassistant/components/vallox/const.py new file mode 100644 index 00000000000..038e46043da --- /dev/null +++ b/homeassistant/components/vallox/const.py @@ -0,0 +1,22 @@ +"""Constants for the Vallox integration.""" + +from datetime import timedelta + +DOMAIN = "vallox" +DEFAULT_NAME = "Vallox" + +SIGNAL_VALLOX_STATE_UPDATE = "vallox_state_update" +STATE_PROXY_SCAN_INTERVAL = timedelta(seconds=60) + +# Common metric keys and (default) values. +METRIC_KEY_MODE = "A_CYC_MODE" +METRIC_KEY_PROFILE_FAN_SPEED_HOME = "A_CYC_HOME_SPEED_SETTING" +METRIC_KEY_PROFILE_FAN_SPEED_AWAY = "A_CYC_AWAY_SPEED_SETTING" +METRIC_KEY_PROFILE_FAN_SPEED_BOOST = "A_CYC_BOOST_SPEED_SETTING" + +MODE_ON = 0 +MODE_OFF = 5 + +DEFAULT_FAN_SPEED_HOME = 50 +DEFAULT_FAN_SPEED_AWAY = 25 +DEFAULT_FAN_SPEED_BOOST = 65 diff --git a/homeassistant/components/vallox/fan.py b/homeassistant/components/vallox/fan.py index e167791e702..0c887daaef5 100644 --- a/homeassistant/components/vallox/fan.py +++ b/homeassistant/components/vallox/fan.py @@ -6,12 +6,14 @@ from homeassistant.components.fan import FanEntity from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from . import ( +from .const import ( DOMAIN, METRIC_KEY_MODE, METRIC_KEY_PROFILE_FAN_SPEED_AWAY, METRIC_KEY_PROFILE_FAN_SPEED_BOOST, METRIC_KEY_PROFILE_FAN_SPEED_HOME, + MODE_OFF, + MODE_ON, SIGNAL_VALLOX_STATE_UPDATE, ) @@ -109,7 +111,7 @@ class ValloxFan(FanEntity): try: # Fetch if the whole device is in regular operation state. mode = self._state_proxy.fetch_metric(METRIC_KEY_MODE) - if mode == 0: + if mode == MODE_ON: self._state = True else: self._state = False @@ -161,7 +163,7 @@ class ValloxFan(FanEntity): if self._state is False: try: - await self._client.set_values({METRIC_KEY_MODE: 0}) + await self._client.set_values({METRIC_KEY_MODE: MODE_ON}) # This state change affects other entities like sensors. Force # an immediate update that can be observed by all parties @@ -178,7 +180,7 @@ class ValloxFan(FanEntity): """Turn the device off.""" if self._state is True: try: - await self._client.set_values({METRIC_KEY_MODE: 5}) + await self._client.set_values({METRIC_KEY_MODE: MODE_OFF}) # Same as for turn_on method. await self._state_proxy.async_update(None) diff --git a/homeassistant/components/vallox/sensor.py b/homeassistant/components/vallox/sensor.py index 4e4dc6cdddf..99365762977 100644 --- a/homeassistant/components/vallox/sensor.py +++ b/homeassistant/components/vallox/sensor.py @@ -22,7 +22,8 @@ from homeassistant.const import ( from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from . import DOMAIN, METRIC_KEY_MODE, SIGNAL_VALLOX_STATE_UPDATE, ValloxStateProxy +from . import ValloxStateProxy +from .const import DOMAIN, METRIC_KEY_MODE, MODE_ON, SIGNAL_VALLOX_STATE_UPDATE _LOGGER = logging.getLogger(__name__) @@ -101,7 +102,7 @@ class ValloxFanSpeedSensor(ValloxSensor): """Fetch state from the ventilation unit.""" try: # If device is in regular operation, continue. - if self._state_proxy.fetch_metric(METRIC_KEY_MODE) == 0: + if self._state_proxy.fetch_metric(METRIC_KEY_MODE) == MODE_ON: await super().async_update() else: # Report zero percent otherwise. From de5a22953d22fc22e3fa7a2656b3a72050c91dc0 Mon Sep 17 00:00:00 2001 From: Ian <83995775+TheBassEngineer@users.noreply.github.com> Date: Mon, 30 Aug 2021 10:20:02 -0500 Subject: [PATCH 100/843] Whole-string match reqs in comment_requirement (#55192) Co-authored-by: Franck Nijhof Co-authored-by: Paulus Schoutsen --- script/gen_requirements_all.py | 23 ++++++++++++++++++++++- script/hassfest/requirements.py | 16 ++-------------- 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index f535958412d..6581c89bc63 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -45,6 +45,10 @@ COMMENT_REQUIREMENTS = ( "VL53L1X2", ) +COMMENT_REQUIREMENTS_NORMALIZED = { + commented.lower().replace("_", "-") for commented in COMMENT_REQUIREMENTS +} + IGNORE_PIN = ("colorlog>2.1,<3", "urllib3") URL_PIN = ( @@ -108,6 +112,8 @@ IGNORE_PRE_COMMIT_HOOK_ID = ( "python-typing-update", ) +PACKAGE_REGEX = re.compile(r"^(?:--.+\s)?([-_\.\w\d]+).*==.+$") + def has_tests(module: str): """Test if a module has tests. @@ -171,9 +177,24 @@ def gather_recursive_requirements(domain, seen=None): return reqs +def normalize_package_name(requirement: str) -> str: + """Return a normalized package name from a requirement string.""" + # This function is also used in hassfest. + match = PACKAGE_REGEX.search(requirement) + if not match: + return "" + + # pipdeptree needs lowercase and dash instead of underscore as separator + package = match.group(1).lower().replace("_", "-") + + return package + + def comment_requirement(req): """Comment out requirement. Some don't install on all systems.""" - return any(ign.lower() in req.lower() for ign in COMMENT_REQUIREMENTS) + return any( + normalize_package_name(req) == ign for ign in COMMENT_REQUIREMENTS_NORMALIZED + ) def gather_modules(): diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index f72562f7f2f..4d111265b1e 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -15,7 +15,7 @@ from tqdm import tqdm from homeassistant.const import REQUIRED_PYTHON_VER import homeassistant.util.package as pkg_util -from script.gen_requirements_all import COMMENT_REQUIREMENTS +from script.gen_requirements_all import COMMENT_REQUIREMENTS, normalize_package_name from .model import Config, Integration @@ -48,18 +48,6 @@ IGNORE_VIOLATIONS = { } -def normalize_package_name(requirement: str) -> str: - """Return a normalized package name from a requirement string.""" - match = PACKAGE_REGEX.search(requirement) - if not match: - return "" - - # pipdeptree needs lowercase and dash instead of underscore as separator - package = match.group(1).lower().replace("_", "-") - - return package - - def validate(integrations: dict[str, Integration], config: Config): """Handle requirements for integrations.""" # Check if we are doing format-only validation. @@ -134,7 +122,7 @@ def validate_requirements(integration: Integration): f"Failed to normalize package name from requirement {req}", ) return - if package in IGNORE_PACKAGES: + if (package == ign for ign in IGNORE_PACKAGES): continue integration_requirements.add(req) integration_packages.add(package) From fa7873dc6df9f262fcb2b1638daf47e92eefe657 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 30 Aug 2021 17:43:11 +0200 Subject: [PATCH 101/843] Fix noise/attenuation units to UI display for Fritz (#55447) --- homeassistant/components/fritz/sensor.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py index 7b6a6528eab..bc579b1125e 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -113,28 +113,28 @@ def _retrieve_link_noise_margin_sent_state( status: FritzStatus, last_value: str ) -> float: """Return upload noise margin.""" - return status.noise_margin[0] # type: ignore[no-any-return] + return status.noise_margin[0] / 10 # type: ignore[no-any-return] def _retrieve_link_noise_margin_received_state( status: FritzStatus, last_value: str ) -> float: """Return download noise margin.""" - return status.noise_margin[1] # type: ignore[no-any-return] + return status.noise_margin[1] / 10 # type: ignore[no-any-return] def _retrieve_link_attenuation_sent_state( status: FritzStatus, last_value: str ) -> float: """Return upload line attenuation.""" - return status.attenuation[0] # type: ignore[no-any-return] + return status.attenuation[0] / 10 # type: ignore[no-any-return] def _retrieve_link_attenuation_received_state( status: FritzStatus, last_value: str ) -> float: """Return download line attenuation.""" - return status.attenuation[1] # type: ignore[no-any-return] + return status.attenuation[1] / 10 # type: ignore[no-any-return] class SensorData(TypedDict, total=False): From d62a78ae6196226f24a5bb5a1240c2f1bf787be8 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Mon, 30 Aug 2021 11:48:36 -0400 Subject: [PATCH 102/843] Don't set zwave_js sensor device class to energy when unit is wrong (#55434) --- homeassistant/components/zwave_js/const.py | 2 ++ .../zwave_js/discovery_data_template.py | 16 ++++++++++++++++ homeassistant/components/zwave_js/sensor.py | 12 ++++++++++++ 3 files changed, 30 insertions(+) diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index 8e545975faa..e4486a681e1 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -96,3 +96,5 @@ ENTITY_DESC_KEY_SIGNAL_STRENGTH = "signal_strength" ENTITY_DESC_KEY_TEMPERATURE = "temperature" ENTITY_DESC_KEY_TARGET_TEMPERATURE = "target_temperature" ENTITY_DESC_KEY_TIMESTAMP = "timestamp" +ENTITY_DESC_KEY_MEASUREMENT = "measurement" +ENTITY_DESC_KEY_TOTAL_INCREASING = "total_increasing" diff --git a/homeassistant/components/zwave_js/discovery_data_template.py b/homeassistant/components/zwave_js/discovery_data_template.py index 3ef74a7e17d..dd338de63eb 100644 --- a/homeassistant/components/zwave_js/discovery_data_template.py +++ b/homeassistant/components/zwave_js/discovery_data_template.py @@ -24,6 +24,7 @@ from zwave_js_server.const import ( VOLTAGE_METER_TYPES, VOLTAGE_SENSORS, CommandClass, + ElectricScale, MeterScaleType, MultilevelSensorType, ) @@ -43,6 +44,7 @@ from .const import ( ENTITY_DESC_KEY_ENERGY_TOTAL_INCREASING, ENTITY_DESC_KEY_HUMIDITY, ENTITY_DESC_KEY_ILLUMINANCE, + ENTITY_DESC_KEY_MEASUREMENT, ENTITY_DESC_KEY_POWER, ENTITY_DESC_KEY_POWER_FACTOR, ENTITY_DESC_KEY_PRESSURE, @@ -50,6 +52,7 @@ from .const import ( ENTITY_DESC_KEY_TARGET_TEMPERATURE, ENTITY_DESC_KEY_TEMPERATURE, ENTITY_DESC_KEY_TIMESTAMP, + ENTITY_DESC_KEY_TOTAL_INCREASING, ENTITY_DESC_KEY_VOLTAGE, ) @@ -187,6 +190,19 @@ class NumericSensorDataTemplate(BaseDiscoverySchemaDataTemplate): if value.command_class == CommandClass.METER: scale_type = get_meter_scale_type(value) + # We do this because even though these are energy scales, they don't meet + # the unit requirements for the energy device class. + if scale_type in ( + ElectricScale.PULSE, + ElectricScale.KILOVOLT_AMPERE_HOUR, + ElectricScale.KILOVOLT_AMPERE_REACTIVE_HOUR, + ): + return ENTITY_DESC_KEY_TOTAL_INCREASING + # We do this because even though these are power scales, they don't meet + # the unit requirements for the energy power class. + if scale_type == ElectricScale.KILOVOLT_AMPERE_REACTIVE: + return ENTITY_DESC_KEY_MEASUREMENT + for key, scale_type_set in METER_DEVICE_CLASS_MAP.items(): if scale_type in scale_type_set: return key diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 40159b383a6..c71a1d87653 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -63,6 +63,7 @@ from .const import ( ENTITY_DESC_KEY_ENERGY_TOTAL_INCREASING, ENTITY_DESC_KEY_HUMIDITY, ENTITY_DESC_KEY_ILLUMINANCE, + ENTITY_DESC_KEY_MEASUREMENT, ENTITY_DESC_KEY_POWER, ENTITY_DESC_KEY_POWER_FACTOR, ENTITY_DESC_KEY_PRESSURE, @@ -70,6 +71,7 @@ from .const import ( ENTITY_DESC_KEY_TARGET_TEMPERATURE, ENTITY_DESC_KEY_TEMPERATURE, ENTITY_DESC_KEY_TIMESTAMP, + ENTITY_DESC_KEY_TOTAL_INCREASING, ENTITY_DESC_KEY_VOLTAGE, SERVICE_RESET_METER, ) @@ -168,6 +170,16 @@ ENTITY_DESCRIPTION_KEY_MAP: dict[str, ZwaveSensorEntityDescription] = { device_class=DEVICE_CLASS_TEMPERATURE, state_class=None, ), + ENTITY_DESC_KEY_MEASUREMENT: ZwaveSensorEntityDescription( + ENTITY_DESC_KEY_MEASUREMENT, + device_class=None, + state_class=STATE_CLASS_MEASUREMENT, + ), + ENTITY_DESC_KEY_TOTAL_INCREASING: ZwaveSensorEntityDescription( + ENTITY_DESC_KEY_TOTAL_INCREASING, + device_class=None, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), } From 27ecd43da36da22f68e74cfab977c310d720aafd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 30 Aug 2021 10:59:41 -0500 Subject: [PATCH 103/843] Bump zeroconf to 0.36.2 (#55459) - Now sends NSEC records when requesting non-existent address types Implements RFC6762 sec 6.2 (http://datatracker.ietf.org/doc/html/rfc6762#section-6.2) - This solves a case where a HomeKit bridge can take a while to update because it is waiting to see if an AAAA (IPv6) address is available --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index dea3b3c356e..6ed4c8d09dd 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -2,7 +2,7 @@ "domain": "zeroconf", "name": "Zero-configuration networking (zeroconf)", "documentation": "https://www.home-assistant.io/integrations/zeroconf", - "requirements": ["zeroconf==0.36.1"], + "requirements": ["zeroconf==0.36.2"], "dependencies": ["network", "api"], "codeowners": ["@bdraco"], "quality_scale": "internal", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 8beb6789b54..c91a512fdd0 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -34,7 +34,7 @@ sqlalchemy==1.4.23 voluptuous-serialize==2.4.0 voluptuous==0.12.1 yarl==1.6.3 -zeroconf==0.36.1 +zeroconf==0.36.2 pycryptodome>=3.6.6 diff --git a/requirements_all.txt b/requirements_all.txt index 62b889ecde8..b51b543013f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2453,7 +2453,7 @@ youtube_dl==2021.04.26 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.36.1 +zeroconf==0.36.2 # homeassistant.components.zha zha-quirks==0.0.60 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0ed06dae246..66e17ede6d8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1373,7 +1373,7 @@ yeelight==0.7.4 youless-api==0.12 # homeassistant.components.zeroconf -zeroconf==0.36.1 +zeroconf==0.36.2 # homeassistant.components.zha zha-quirks==0.0.60 From 331726ec2f4d8268910e06738bed240db2a63511 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Mon, 30 Aug 2021 12:40:56 -0400 Subject: [PATCH 104/843] Bump zwave-js-server-python to 0.29.1 (#55460) --- homeassistant/components/zwave_js/climate.py | 4 +-- homeassistant/components/zwave_js/cover.py | 2 +- .../components/zwave_js/discovery.py | 5 ++- .../zwave_js/discovery_data_template.py | 32 ++++++++++--------- homeassistant/components/zwave_js/light.py | 3 +- homeassistant/components/zwave_js/lock.py | 4 +-- .../components/zwave_js/manifest.json | 8 ++--- homeassistant/components/zwave_js/select.py | 3 +- homeassistant/components/zwave_js/sensor.py | 5 ++- homeassistant/components/zwave_js/siren.py | 2 +- homeassistant/components/zwave_js/switch.py | 4 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/zwave_js/test_lock.py | 2 +- 14 files changed, 43 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/zwave_js/climate.py b/homeassistant/components/zwave_js/climate.py index 1621e87cfab..1ec5ccbcc01 100644 --- a/homeassistant/components/zwave_js/climate.py +++ b/homeassistant/components/zwave_js/climate.py @@ -4,14 +4,14 @@ from __future__ import annotations from typing import Any, cast from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const import ( +from zwave_js_server.const import CommandClass +from zwave_js_server.const.command_class.thermostat import ( THERMOSTAT_CURRENT_TEMP_PROPERTY, THERMOSTAT_MODE_PROPERTY, THERMOSTAT_MODE_SETPOINT_MAP, THERMOSTAT_MODES, THERMOSTAT_OPERATING_STATE_PROPERTY, THERMOSTAT_SETPOINT_PROPERTY, - CommandClass, ThermostatMode, ThermostatOperatingState, ThermostatSetpointType, diff --git a/homeassistant/components/zwave_js/cover.py b/homeassistant/components/zwave_js/cover.py index f8e575521dc..7fceaf64c0e 100644 --- a/homeassistant/components/zwave_js/cover.py +++ b/homeassistant/components/zwave_js/cover.py @@ -5,7 +5,7 @@ import logging from typing import Any from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const import BarrierState +from zwave_js_server.const.command_class.barrior_operator import BarrierState from zwave_js_server.model.value import Value as ZwaveValue from homeassistant.components.cover import ( diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 7232279f4c6..d5af1c072ee 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -6,7 +6,10 @@ from dataclasses import asdict, dataclass, field from typing import Any from awesomeversion import AwesomeVersion -from zwave_js_server.const import THERMOSTAT_CURRENT_TEMP_PROPERTY, CommandClass +from zwave_js_server.const import CommandClass +from zwave_js_server.const.command_class.thermostat import ( + THERMOSTAT_CURRENT_TEMP_PROPERTY, +) from zwave_js_server.exceptions import UnknownValueData from zwave_js_server.model.device_class import DeviceClassItem from zwave_js_server.model.node import Node as ZwaveNode diff --git a/homeassistant/components/zwave_js/discovery_data_template.py b/homeassistant/components/zwave_js/discovery_data_template.py index dd338de63eb..974cd2bfa44 100644 --- a/homeassistant/components/zwave_js/discovery_data_template.py +++ b/homeassistant/components/zwave_js/discovery_data_template.py @@ -5,27 +5,29 @@ from collections.abc import Iterable from dataclasses import dataclass from typing import Any -from zwave_js_server.const import ( - CO2_SENSORS, - CO_SENSORS, +from zwave_js_server.const import CommandClass +from zwave_js_server.const.command_class.meter import ( CURRENT_METER_TYPES, - CURRENT_SENSORS, - ENERGY_METER_TYPES, - ENERGY_SENSORS, - HUMIDITY_SENSORS, - ILLUMINANCE_SENSORS, + ENERGY_TOTAL_INCREASING_METER_TYPES, POWER_FACTOR_METER_TYPES, POWER_METER_TYPES, + VOLTAGE_METER_TYPES, + ElectricScale, + MeterScaleType, +) +from zwave_js_server.const.command_class.multilevel_sensor import ( + CO2_SENSORS, + CO_SENSORS, + CURRENT_SENSORS, + ENERGY_MEASUREMENT_SENSORS, + HUMIDITY_SENSORS, + ILLUMINANCE_SENSORS, POWER_SENSORS, PRESSURE_SENSORS, SIGNAL_STRENGTH_SENSORS, TEMPERATURE_SENSORS, TIMESTAMP_SENSORS, - VOLTAGE_METER_TYPES, VOLTAGE_SENSORS, - CommandClass, - ElectricScale, - MeterScaleType, MultilevelSensorType, ) from zwave_js_server.model.node import Node as ZwaveNode @@ -59,7 +61,7 @@ from .const import ( METER_DEVICE_CLASS_MAP: dict[str, set[MeterScaleType]] = { ENTITY_DESC_KEY_CURRENT: CURRENT_METER_TYPES, ENTITY_DESC_KEY_VOLTAGE: VOLTAGE_METER_TYPES, - ENTITY_DESC_KEY_ENERGY_TOTAL_INCREASING: ENERGY_METER_TYPES, + ENTITY_DESC_KEY_ENERGY_TOTAL_INCREASING: ENERGY_TOTAL_INCREASING_METER_TYPES, ENTITY_DESC_KEY_POWER: POWER_METER_TYPES, ENTITY_DESC_KEY_POWER_FACTOR: POWER_FACTOR_METER_TYPES, } @@ -68,7 +70,7 @@ MULTILEVEL_SENSOR_DEVICE_CLASS_MAP: dict[str, set[MultilevelSensorType]] = { ENTITY_DESC_KEY_CO: CO_SENSORS, ENTITY_DESC_KEY_CO2: CO2_SENSORS, ENTITY_DESC_KEY_CURRENT: CURRENT_SENSORS, - ENTITY_DESC_KEY_ENERGY_MEASUREMENT: ENERGY_SENSORS, + ENTITY_DESC_KEY_ENERGY_MEASUREMENT: ENERGY_MEASUREMENT_SENSORS, ENTITY_DESC_KEY_HUMIDITY: HUMIDITY_SENSORS, ENTITY_DESC_KEY_ILLUMINANCE: ILLUMINANCE_SENSORS, ENTITY_DESC_KEY_POWER: POWER_SENSORS, @@ -193,7 +195,7 @@ class NumericSensorDataTemplate(BaseDiscoverySchemaDataTemplate): # We do this because even though these are energy scales, they don't meet # the unit requirements for the energy device class. if scale_type in ( - ElectricScale.PULSE, + ElectricScale.PULSE_COUNT, ElectricScale.KILOVOLT_AMPERE_HOUR, ElectricScale.KILOVOLT_AMPERE_REACTIVE_HOUR, ): diff --git a/homeassistant/components/zwave_js/light.py b/homeassistant/components/zwave_js/light.py index 91a7f191e5d..0857b43e4ee 100644 --- a/homeassistant/components/zwave_js/light.py +++ b/homeassistant/components/zwave_js/light.py @@ -5,7 +5,8 @@ import logging from typing import Any from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const import ColorComponent, CommandClass +from zwave_js_server.const import CommandClass +from zwave_js_server.const.command_class.color_switch import ColorComponent from homeassistant.components.light import ( ATTR_BRIGHTNESS, diff --git a/homeassistant/components/zwave_js/lock.py b/homeassistant/components/zwave_js/lock.py index 696310b5ad1..0f2a0862d7f 100644 --- a/homeassistant/components/zwave_js/lock.py +++ b/homeassistant/components/zwave_js/lock.py @@ -6,12 +6,12 @@ from typing import Any import voluptuous as vol from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const import ( +from zwave_js_server.const import CommandClass +from zwave_js_server.const.command_class.lock import ( ATTR_CODE_SLOT, ATTR_USERCODE, LOCK_CMD_CLASS_TO_LOCKED_STATE_MAP, LOCK_CMD_CLASS_TO_PROPERTY_MAP, - CommandClass, DoorLockMode, ) from zwave_js_server.model.value import Value as ZwaveValue diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 23a1546a421..7953e33d6e3 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -3,13 +3,13 @@ "name": "Z-Wave JS", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zwave_js", - "requirements": ["zwave-js-server-python==0.29.0"], + "requirements": ["zwave-js-server-python==0.29.1"], "codeowners": ["@home-assistant/z-wave"], "dependencies": ["usb", "http", "websocket_api"], "iot_class": "local_push", "usb": [ - {"vid":"0658","pid":"0200","known_devices":["Aeotec Z-Stick Gen5+", "Z-WaveMe UZB"]}, - {"vid":"10C4","pid":"8A2A","known_devices":["Nortek HUSBZB-1"]}, - {"vid":"10C4","pid":"EA60","known_devices":["Aeotec Z-Stick 7", "Silicon Labs UZB-7", "Zooz ZST10 700"]} + {"vid":"0658","pid":"0200","known_devices":["Aeotec Z-Stick Gen5+", "Z-WaveMe UZB"]}, + {"vid":"10C4","pid":"8A2A","known_devices":["Nortek HUSBZB-1"]}, + {"vid":"10C4","pid":"EA60","known_devices":["Aeotec Z-Stick 7", "Silicon Labs UZB-7", "Zooz ZST10 700"]} ] } diff --git a/homeassistant/components/zwave_js/select.py b/homeassistant/components/zwave_js/select.py index 7aedc6521d9..fae87fd24de 100644 --- a/homeassistant/components/zwave_js/select.py +++ b/homeassistant/components/zwave_js/select.py @@ -2,7 +2,8 @@ from __future__ import annotations from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const import CommandClass, ToneID +from zwave_js_server.const import CommandClass +from zwave_js_server.const.command_class.sound_switch import ToneID from homeassistant.components.select import DOMAIN as SELECT_DOMAIN, SelectEntity from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index c71a1d87653..09d44f7f24a 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -8,11 +8,10 @@ from typing import cast import voluptuous as vol from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const import ( +from zwave_js_server.const import CommandClass, ConfigurationValueType +from zwave_js_server.const.command_class.meter import ( RESET_METER_OPTION_TARGET_VALUE, RESET_METER_OPTION_TYPE, - CommandClass, - ConfigurationValueType, ) from zwave_js_server.model.node import Node as ZwaveNode from zwave_js_server.model.value import ConfigurationValue diff --git a/homeassistant/components/zwave_js/siren.py b/homeassistant/components/zwave_js/siren.py index c1b354f4faa..4ef89b9f4cd 100644 --- a/homeassistant/components/zwave_js/siren.py +++ b/homeassistant/components/zwave_js/siren.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const import ToneID +from zwave_js_server.const.command_class.sound_switch import ToneID from homeassistant.components.siren import DOMAIN as SIREN_DOMAIN, SirenEntity from homeassistant.components.siren.const import ( diff --git a/homeassistant/components/zwave_js/switch.py b/homeassistant/components/zwave_js/switch.py index 0bc6b8d5349..bd86a3b8377 100644 --- a/homeassistant/components/zwave_js/switch.py +++ b/homeassistant/components/zwave_js/switch.py @@ -5,7 +5,9 @@ import logging from typing import Any from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const import BarrierEventSignalingSubsystemState +from zwave_js_server.const.command_class.barrior_operator import ( + BarrierEventSignalingSubsystemState, +) from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SwitchEntity from homeassistant.config_entries import ConfigEntry diff --git a/requirements_all.txt b/requirements_all.txt index b51b543013f..bcd80654764 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2486,4 +2486,4 @@ zigpy==0.37.1 zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.29.0 +zwave-js-server-python==0.29.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 66e17ede6d8..8ea844b7d57 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1397,4 +1397,4 @@ zigpy-znp==0.5.4 zigpy==0.37.1 # homeassistant.components.zwave_js -zwave-js-server-python==0.29.0 +zwave-js-server-python==0.29.1 diff --git a/tests/components/zwave_js/test_lock.py b/tests/components/zwave_js/test_lock.py index 3727ab9d288..9a0735d3dc6 100644 --- a/tests/components/zwave_js/test_lock.py +++ b/tests/components/zwave_js/test_lock.py @@ -1,5 +1,5 @@ """Test the Z-Wave JS lock platform.""" -from zwave_js_server.const import ATTR_CODE_SLOT, ATTR_USERCODE +from zwave_js_server.const.command_class.lock import ATTR_CODE_SLOT, ATTR_USERCODE from zwave_js_server.event import Event from zwave_js_server.model.node import NodeStatus From 1aa30ea87b2332e40f53808736022185c3a4e4ab Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Mon, 30 Aug 2021 11:04:21 -0600 Subject: [PATCH 105/843] Add long-term statistics for SimpliSafe sensors (#55419) --- homeassistant/components/simplisafe/sensor.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/simplisafe/sensor.py b/homeassistant/components/simplisafe/sensor.py index c3f8d7c3ab0..fceb90fc9eb 100644 --- a/homeassistant/components/simplisafe/sensor.py +++ b/homeassistant/components/simplisafe/sensor.py @@ -1,7 +1,7 @@ """Support for SimpliSafe freeze sensor.""" from simplipy.entity import EntityTypes -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import DEVICE_CLASS_TEMPERATURE, TEMP_FAHRENHEIT from homeassistant.core import HomeAssistant, callback @@ -35,6 +35,7 @@ class SimplisafeFreezeSensor(SimpliSafeBaseSensor, SensorEntity): _attr_device_class = DEVICE_CLASS_TEMPERATURE _attr_native_unit_of_measurement = TEMP_FAHRENHEIT + _attr_state_class = STATE_CLASS_MEASUREMENT @callback def async_update_from_rest_api(self) -> None: From daa9c8d8566144a2a0cc170c2527e20f304d77a2 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Mon, 30 Aug 2021 11:07:05 -0600 Subject: [PATCH 106/843] Add -term statistics for Notion sensors (#55414) --- homeassistant/components/notion/sensor.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/notion/sensor.py b/homeassistant/components/notion/sensor.py index 803cfce3360..204074ed884 100644 --- a/homeassistant/components/notion/sensor.py +++ b/homeassistant/components/notion/sensor.py @@ -1,5 +1,9 @@ """Support for Notion sensors.""" -from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS from homeassistant.core import HomeAssistant, callback @@ -14,6 +18,7 @@ SENSOR_DESCRIPTIONS = ( name="Temperature", device_class=DEVICE_CLASS_TEMPERATURE, native_unit_of_measurement=TEMP_CELSIUS, + state_class=STATE_CLASS_MEASUREMENT, ), ) From 3bd9be2f6d1f0e977eeeb32210d7fbed6000e96f Mon Sep 17 00:00:00 2001 From: Greg Date: Mon, 30 Aug 2021 12:52:29 -0700 Subject: [PATCH 107/843] Add IoTaWatt integration (#55364) Co-authored-by: Paulus Schoutsen --- CODEOWNERS | 1 + homeassistant/components/iotawatt/__init__.py | 24 ++ .../components/iotawatt/config_flow.py | 107 +++++++++ homeassistant/components/iotawatt/const.py | 12 + .../components/iotawatt/coordinator.py | 56 +++++ .../components/iotawatt/manifest.json | 13 ++ homeassistant/components/iotawatt/sensor.py | 213 ++++++++++++++++++ .../components/iotawatt/strings.json | 23 ++ .../components/iotawatt/translations/en.json | 24 ++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/iotawatt/__init__.py | 21 ++ tests/components/iotawatt/conftest.py | 27 +++ tests/components/iotawatt/test_config_flow.py | 143 ++++++++++++ tests/components/iotawatt/test_init.py | 31 +++ tests/components/iotawatt/test_sensor.py | 76 +++++++ 17 files changed, 778 insertions(+) create mode 100644 homeassistant/components/iotawatt/__init__.py create mode 100644 homeassistant/components/iotawatt/config_flow.py create mode 100644 homeassistant/components/iotawatt/const.py create mode 100644 homeassistant/components/iotawatt/coordinator.py create mode 100644 homeassistant/components/iotawatt/manifest.json create mode 100644 homeassistant/components/iotawatt/sensor.py create mode 100644 homeassistant/components/iotawatt/strings.json create mode 100644 homeassistant/components/iotawatt/translations/en.json create mode 100644 tests/components/iotawatt/__init__.py create mode 100644 tests/components/iotawatt/conftest.py create mode 100644 tests/components/iotawatt/test_config_flow.py create mode 100644 tests/components/iotawatt/test_init.py create mode 100644 tests/components/iotawatt/test_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index c28973ec66f..1a9e4bb62f7 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -248,6 +248,7 @@ homeassistant/components/integration/* @dgomes homeassistant/components/intent/* @home-assistant/core homeassistant/components/intesishome/* @jnimmo homeassistant/components/ios/* @robbiet480 +homeassistant/components/iotawatt/* @gtdiehl homeassistant/components/iperf3/* @rohankapoorcom homeassistant/components/ipma/* @dgomes @abmantis homeassistant/components/ipp/* @ctalkington diff --git a/homeassistant/components/iotawatt/__init__.py b/homeassistant/components/iotawatt/__init__.py new file mode 100644 index 00000000000..7987004e594 --- /dev/null +++ b/homeassistant/components/iotawatt/__init__.py @@ -0,0 +1,24 @@ +"""The iotawatt integration.""" +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import IotawattUpdater + +PLATFORMS = ("sensor",) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up iotawatt from a config entry.""" + coordinator = IotawattUpdater(hass, entry) + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok diff --git a/homeassistant/components/iotawatt/config_flow.py b/homeassistant/components/iotawatt/config_flow.py new file mode 100644 index 00000000000..9ec860ea76a --- /dev/null +++ b/homeassistant/components/iotawatt/config_flow.py @@ -0,0 +1,107 @@ +"""Config flow for iotawatt integration.""" +from __future__ import annotations + +import logging + +from iotawattpy.iotawatt import Iotawatt +import voluptuous as vol + +from homeassistant import config_entries, core, exceptions +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers import httpx_client + +from .const import CONNECTION_ERRORS, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def validate_input( + hass: core.HomeAssistant, data: dict[str, str] +) -> dict[str, str]: + """Validate the user input allows us to connect.""" + iotawatt = Iotawatt( + "", + data[CONF_HOST], + httpx_client.get_async_client(hass), + data.get(CONF_USERNAME), + data.get(CONF_PASSWORD), + ) + try: + is_connected = await iotawatt.connect() + except CONNECTION_ERRORS: + return {"base": "cannot_connect"} + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + return {"base": "unknown"} + + if not is_connected: + return {"base": "invalid_auth"} + + return {} + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for iotawatt.""" + + VERSION = 1 + + def __init__(self): + """Initialize.""" + self._data = {} + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + if user_input is None: + user_input = {} + + schema = vol.Schema( + { + vol.Required(CONF_HOST, default=user_input.get(CONF_HOST, "")): str, + } + ) + if not user_input: + return self.async_show_form(step_id="user", data_schema=schema) + + if not (errors := await validate_input(self.hass, user_input)): + return self.async_create_entry(title=user_input[CONF_HOST], data=user_input) + + if errors == {"base": "invalid_auth"}: + self._data.update(user_input) + return await self.async_step_auth() + + return self.async_show_form(step_id="user", data_schema=schema, errors=errors) + + async def async_step_auth(self, user_input=None): + """Authenticate user if authentication is enabled on the IoTaWatt device.""" + if user_input is None: + user_input = {} + + data_schema = vol.Schema( + { + vol.Required( + CONF_USERNAME, default=user_input.get(CONF_USERNAME, "") + ): str, + vol.Required( + CONF_PASSWORD, default=user_input.get(CONF_PASSWORD, "") + ): str, + } + ) + if not user_input: + return self.async_show_form(step_id="auth", data_schema=data_schema) + + data = {**self._data, **user_input} + + if errors := await validate_input(self.hass, data): + return self.async_show_form( + step_id="auth", data_schema=data_schema, errors=errors + ) + + return self.async_create_entry(title=data[CONF_HOST], data=data) + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(exceptions.HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/iotawatt/const.py b/homeassistant/components/iotawatt/const.py new file mode 100644 index 00000000000..db847f3dfe8 --- /dev/null +++ b/homeassistant/components/iotawatt/const.py @@ -0,0 +1,12 @@ +"""Constants for the IoTaWatt integration.""" +from __future__ import annotations + +import json + +import httpx + +DOMAIN = "iotawatt" +VOLT_AMPERE_REACTIVE = "VAR" +VOLT_AMPERE_REACTIVE_HOURS = "VARh" + +CONNECTION_ERRORS = (KeyError, json.JSONDecodeError, httpx.HTTPError) diff --git a/homeassistant/components/iotawatt/coordinator.py b/homeassistant/components/iotawatt/coordinator.py new file mode 100644 index 00000000000..1a722d52a1e --- /dev/null +++ b/homeassistant/components/iotawatt/coordinator.py @@ -0,0 +1,56 @@ +"""IoTaWatt DataUpdateCoordinator.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +from iotawattpy.iotawatt import Iotawatt + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers import httpx_client +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONNECTION_ERRORS + +_LOGGER = logging.getLogger(__name__) + + +class IotawattUpdater(DataUpdateCoordinator): + """Class to manage fetching update data from the IoTaWatt Energy Device.""" + + api: Iotawatt | None = None + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize IotaWattUpdater object.""" + self.entry = entry + super().__init__( + hass=hass, + logger=_LOGGER, + name=entry.title, + update_interval=timedelta(seconds=30), + ) + + async def _async_update_data(self): + """Fetch sensors from IoTaWatt device.""" + if self.api is None: + api = Iotawatt( + self.entry.title, + self.entry.data[CONF_HOST], + httpx_client.get_async_client(self.hass), + self.entry.data.get(CONF_USERNAME), + self.entry.data.get(CONF_PASSWORD), + ) + try: + is_authenticated = await api.connect() + except CONNECTION_ERRORS as err: + raise UpdateFailed("Connection failed") from err + + if not is_authenticated: + raise UpdateFailed("Authentication error") + + self.api = api + + await self.api.update() + return self.api.getSensors() diff --git a/homeassistant/components/iotawatt/manifest.json b/homeassistant/components/iotawatt/manifest.json new file mode 100644 index 00000000000..d78e546d71f --- /dev/null +++ b/homeassistant/components/iotawatt/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "iotawatt", + "name": "IoTaWatt", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/iotawatt", + "requirements": [ + "iotawattpy==0.0.8" + ], + "codeowners": [ + "@gtdiehl" + ], + "iot_class": "local_polling" +} \ No newline at end of file diff --git a/homeassistant/components/iotawatt/sensor.py b/homeassistant/components/iotawatt/sensor.py new file mode 100644 index 00000000000..8a8c92a8c51 --- /dev/null +++ b/homeassistant/components/iotawatt/sensor.py @@ -0,0 +1,213 @@ +"""Support for IoTaWatt Energy monitor.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable + +from iotawattpy.sensor import Sensor + +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.const import ( + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_POWER_FACTOR, + DEVICE_CLASS_VOLTAGE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, + ENERGY_WATT_HOUR, + FREQUENCY_HERTZ, + PERCENTAGE, + POWER_VOLT_AMPERE, + POWER_WATT, +) +from homeassistant.core import callback +from homeassistant.helpers import entity, entity_registry, update_coordinator +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC + +from .const import DOMAIN, VOLT_AMPERE_REACTIVE, VOLT_AMPERE_REACTIVE_HOURS +from .coordinator import IotawattUpdater + + +@dataclass +class IotaWattSensorEntityDescription(SensorEntityDescription): + """Class describing IotaWatt sensor entities.""" + + value: Callable | None = None + + +ENTITY_DESCRIPTION_KEY_MAP: dict[str, IotaWattSensorEntityDescription] = { + "Amps": IotaWattSensorEntityDescription( + "Amps", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_CURRENT, + ), + "Hz": IotaWattSensorEntityDescription( + "Hz", + native_unit_of_measurement=FREQUENCY_HERTZ, + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:flash", + ), + "PF": IotaWattSensorEntityDescription( + "PF", + native_unit_of_measurement=PERCENTAGE, + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_POWER_FACTOR, + value=lambda value: value * 100, + ), + "Watts": IotaWattSensorEntityDescription( + "Watts", + native_unit_of_measurement=POWER_WATT, + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_POWER, + ), + "WattHours": IotaWattSensorEntityDescription( + "WattHours", + native_unit_of_measurement=ENERGY_WATT_HOUR, + state_class=STATE_CLASS_TOTAL_INCREASING, + device_class=DEVICE_CLASS_ENERGY, + ), + "VA": IotaWattSensorEntityDescription( + "VA", + native_unit_of_measurement=POWER_VOLT_AMPERE, + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:flash", + ), + "VAR": IotaWattSensorEntityDescription( + "VAR", + native_unit_of_measurement=VOLT_AMPERE_REACTIVE, + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:flash", + ), + "VARh": IotaWattSensorEntityDescription( + "VARh", + native_unit_of_measurement=VOLT_AMPERE_REACTIVE_HOURS, + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:flash", + ), + "Volts": IotaWattSensorEntityDescription( + "Volts", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_VOLTAGE, + ), +} + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Add sensors for passed config_entry in HA.""" + coordinator: IotawattUpdater = hass.data[DOMAIN][config_entry.entry_id] + created = set() + + @callback + def _create_entity(key: str) -> IotaWattSensor: + """Create a sensor entity.""" + created.add(key) + return IotaWattSensor( + coordinator=coordinator, + key=key, + mac_address=coordinator.data["sensors"][key].hub_mac_address, + name=coordinator.data["sensors"][key].getName(), + entity_description=ENTITY_DESCRIPTION_KEY_MAP.get( + coordinator.data["sensors"][key].getUnit(), + IotaWattSensorEntityDescription("base_sensor"), + ), + ) + + async_add_entities(_create_entity(key) for key in coordinator.data["sensors"]) + + @callback + def new_data_received(): + """Check for new sensors.""" + entities = [ + _create_entity(key) + for key in coordinator.data["sensors"] + if key not in created + ] + if entities: + async_add_entities(entities) + + coordinator.async_add_listener(new_data_received) + + +class IotaWattSensor(update_coordinator.CoordinatorEntity, SensorEntity): + """Defines a IoTaWatt Energy Sensor.""" + + entity_description: IotaWattSensorEntityDescription + _attr_force_update = True + + def __init__( + self, + coordinator, + key, + mac_address, + name, + entity_description: IotaWattSensorEntityDescription, + ): + """Initialize the sensor.""" + super().__init__(coordinator=coordinator) + + self._key = key + data = self._sensor_data + if data.getType() == "Input": + self._attr_unique_id = ( + f"{data.hub_mac_address}-input-{data.getChannel()}-{data.getUnit()}" + ) + self.entity_description = entity_description + + @property + def _sensor_data(self) -> Sensor: + """Return sensor data.""" + return self.coordinator.data["sensors"][self._key] + + @property + def name(self) -> str | None: + """Return name of the entity.""" + return self._sensor_data.getName() + + @property + def device_info(self) -> entity.DeviceInfo | None: + """Return device info.""" + return { + "connections": { + (CONNECTION_NETWORK_MAC, self._sensor_data.hub_mac_address) + }, + "manufacturer": "IoTaWatt", + "model": "IoTaWatt", + } + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + if self._key not in self.coordinator.data["sensors"]: + if self._attr_unique_id: + entity_registry.async_get(self.hass).async_remove(self.entity_id) + else: + self.hass.async_create_task(self.async_remove()) + return + + super()._handle_coordinator_update() + + @property + def extra_state_attributes(self): + """Return the extra state attributes of the entity.""" + data = self._sensor_data + attrs = {"type": data.getType()} + if attrs["type"] == "Input": + attrs["channel"] = data.getChannel() + + return attrs + + @property + def native_value(self) -> entity.StateType: + """Return the state of the sensor.""" + if func := self.entity_description.value: + return func(self._sensor_data.getValue()) + + return self._sensor_data.getValue() diff --git a/homeassistant/components/iotawatt/strings.json b/homeassistant/components/iotawatt/strings.json new file mode 100644 index 00000000000..f21dfe0cd09 --- /dev/null +++ b/homeassistant/components/iotawatt/strings.json @@ -0,0 +1,23 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + }, + "auth": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "description": "The IoTawatt device requires authentication. Please enter the username and password and click the Submit button." + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + } +} diff --git a/homeassistant/components/iotawatt/translations/en.json b/homeassistant/components/iotawatt/translations/en.json new file mode 100644 index 00000000000..cbda4b41bea --- /dev/null +++ b/homeassistant/components/iotawatt/translations/en.json @@ -0,0 +1,24 @@ +{ + "config": { + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "auth": { + "data": { + "password": "Password", + "username": "Username" + }, + "description": "The IoTawatt device requires authentication. Please enter the username and password and click the Submit button." + }, + "user": { + "data": { + "host": "Host" + } + } + } + }, + "title": "iotawatt" +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index ec2947443de..2eb4e43fe32 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -130,6 +130,7 @@ FLOWS = [ "ifttt", "insteon", "ios", + "iotawatt", "ipma", "ipp", "iqvia", diff --git a/requirements_all.txt b/requirements_all.txt index bcd80654764..209d9c78696 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -864,6 +864,9 @@ influxdb-client==1.14.0 # homeassistant.components.influxdb influxdb==5.2.3 +# homeassistant.components.iotawatt +iotawattpy==0.0.8 + # homeassistant.components.iperf3 iperf3==0.1.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8ea844b7d57..36b928d23c5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -504,6 +504,9 @@ influxdb-client==1.14.0 # homeassistant.components.influxdb influxdb==5.2.3 +# homeassistant.components.iotawatt +iotawattpy==0.0.8 + # homeassistant.components.gogogate2 ismartgate==4.0.0 diff --git a/tests/components/iotawatt/__init__.py b/tests/components/iotawatt/__init__.py new file mode 100644 index 00000000000..3d1afe1b88b --- /dev/null +++ b/tests/components/iotawatt/__init__.py @@ -0,0 +1,21 @@ +"""Tests for the IoTaWatt integration.""" +from iotawattpy.sensor import Sensor + +INPUT_SENSOR = Sensor( + channel="1", + name="My Sensor", + io_type="Input", + unit="WattHours", + value="23", + begin="", + mac_addr="mock-mac", +) +OUTPUT_SENSOR = Sensor( + channel="N/A", + name="My WattHour Sensor", + io_type="Output", + unit="WattHours", + value="243", + begin="", + mac_addr="mock-mac", +) diff --git a/tests/components/iotawatt/conftest.py b/tests/components/iotawatt/conftest.py new file mode 100644 index 00000000000..f96201ba50e --- /dev/null +++ b/tests/components/iotawatt/conftest.py @@ -0,0 +1,27 @@ +"""Test fixtures for IoTaWatt.""" +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.iotawatt import DOMAIN + +from tests.common import MockConfigEntry + + +@pytest.fixture +def entry(hass): + """Mock config entry added to HA.""" + entry = MockConfigEntry(domain=DOMAIN, data={"host": "1.2.3.4"}) + entry.add_to_hass(hass) + return entry + + +@pytest.fixture +def mock_iotawatt(entry): + """Mock iotawatt.""" + with patch("homeassistant.components.iotawatt.coordinator.Iotawatt") as mock: + instance = mock.return_value + instance.connect = AsyncMock(return_value=True) + instance.update = AsyncMock() + instance.getSensors.return_value = {"sensors": {}} + yield instance diff --git a/tests/components/iotawatt/test_config_flow.py b/tests/components/iotawatt/test_config_flow.py new file mode 100644 index 00000000000..e028f365431 --- /dev/null +++ b/tests/components/iotawatt/test_config_flow.py @@ -0,0 +1,143 @@ +"""Test the IoTawatt config flow.""" +from unittest.mock import patch + +import httpx + +from homeassistant import config_entries, setup +from homeassistant.components.iotawatt.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == config_entries.SOURCE_USER + + with patch( + "homeassistant.components.iotawatt.async_setup_entry", + return_value=True, + ), patch( + "homeassistant.components.iotawatt.config_flow.Iotawatt.connect", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + }, + ) + await hass.async_block_till_done() + + assert len(mock_setup_entry.mock_calls) == 1 + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["data"] == { + "host": "1.1.1.1", + } + + +async def test_form_auth(hass: HomeAssistant) -> None: + """Test we handle auth.""" + + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.iotawatt.config_flow.Iotawatt.connect", + return_value=False, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"host": "1.1.1.1"}, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["step_id"] == "auth" + + with patch( + "homeassistant.components.iotawatt.config_flow.Iotawatt.connect", + return_value=False, + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "mock-user", + "password": "mock-pass", + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == RESULT_TYPE_FORM + assert result3["step_id"] == "auth" + assert result3["errors"] == {"base": "invalid_auth"} + + with patch( + "homeassistant.components.iotawatt.async_setup_entry", + return_value=True, + ) as mock_setup_entry, patch( + "homeassistant.components.iotawatt.config_flow.Iotawatt.connect", + return_value=True, + ): + result4 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "mock-user", + "password": "mock-pass", + }, + ) + await hass.async_block_till_done() + + assert result4["type"] == RESULT_TYPE_CREATE_ENTRY + assert len(mock_setup_entry.mock_calls) == 1 + assert result4["data"] == { + "host": "1.1.1.1", + "username": "mock-user", + "password": "mock-pass", + } + + +async def test_form_cannot_connect(hass: HomeAssistant) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.iotawatt.config_flow.Iotawatt.connect", + side_effect=httpx.HTTPError("any"), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"host": "1.1.1.1"}, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_setup_exception(hass: HomeAssistant) -> None: + """Test we handle broad exception.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.iotawatt.config_flow.Iotawatt.connect", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"host": "1.1.1.1"}, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "unknown"} diff --git a/tests/components/iotawatt/test_init.py b/tests/components/iotawatt/test_init.py new file mode 100644 index 00000000000..b43a3d9aa88 --- /dev/null +++ b/tests/components/iotawatt/test_init.py @@ -0,0 +1,31 @@ +"""Test init.""" +import httpx + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.setup import async_setup_component + +from . import INPUT_SENSOR + + +async def test_setup_unload(hass, mock_iotawatt, entry): + """Test we can setup and unload an entry.""" + mock_iotawatt.getSensors.return_value["sensors"]["my_sensor_key"] = INPUT_SENSOR + assert await async_setup_component(hass, "iotawatt", {}) + await hass.async_block_till_done() + assert await hass.config_entries.async_unload(entry.entry_id) + + +async def test_setup_connection_failed(hass, mock_iotawatt, entry): + """Test connection error during startup.""" + mock_iotawatt.connect.side_effect = httpx.ConnectError("") + assert await async_setup_component(hass, "iotawatt", {}) + await hass.async_block_till_done() + assert entry.state == ConfigEntryState.SETUP_RETRY + + +async def test_setup_auth_failed(hass, mock_iotawatt, entry): + """Test auth error during startup.""" + mock_iotawatt.connect.return_value = False + assert await async_setup_component(hass, "iotawatt", {}) + await hass.async_block_till_done() + assert entry.state == ConfigEntryState.SETUP_RETRY diff --git a/tests/components/iotawatt/test_sensor.py b/tests/components/iotawatt/test_sensor.py new file mode 100644 index 00000000000..556da8cc2b0 --- /dev/null +++ b/tests/components/iotawatt/test_sensor.py @@ -0,0 +1,76 @@ +"""Test setting up sensors.""" +from datetime import timedelta + +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + DEVICE_CLASS_ENERGY, + STATE_CLASS_TOTAL_INCREASING, +) +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_FRIENDLY_NAME, + ATTR_UNIT_OF_MEASUREMENT, + ENERGY_WATT_HOUR, +) +from homeassistant.setup import async_setup_component +from homeassistant.util.dt import utcnow + +from . import INPUT_SENSOR, OUTPUT_SENSOR + +from tests.common import async_fire_time_changed + + +async def test_sensor_type_input(hass, mock_iotawatt): + """Test input sensors work.""" + assert await async_setup_component(hass, "iotawatt", {}) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids()) == 0 + + # Discover this sensor during a regular update. + mock_iotawatt.getSensors.return_value["sensors"]["my_sensor_key"] = INPUT_SENSOR + async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids()) == 1 + + state = hass.states.get("sensor.my_sensor") + assert state is not None + assert state.state == "23" + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING + assert state.attributes[ATTR_FRIENDLY_NAME] == "My Sensor" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == ENERGY_WATT_HOUR + assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_ENERGY + assert state.attributes["channel"] == "1" + assert state.attributes["type"] == "Input" + + mock_iotawatt.getSensors.return_value["sensors"].pop("my_sensor_key") + async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done() + + assert hass.states.get("sensor.my_sensor") is None + + +async def test_sensor_type_output(hass, mock_iotawatt): + """Tests the sensor type of Output.""" + mock_iotawatt.getSensors.return_value["sensors"][ + "my_watthour_sensor_key" + ] = OUTPUT_SENSOR + assert await async_setup_component(hass, "iotawatt", {}) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids()) == 1 + + state = hass.states.get("sensor.my_watthour_sensor") + assert state is not None + assert state.state == "243" + assert state.attributes[ATTR_FRIENDLY_NAME] == "My WattHour Sensor" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == ENERGY_WATT_HOUR + assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_ENERGY + assert state.attributes["type"] == "Output" + + mock_iotawatt.getSensors.return_value["sensors"].pop("my_watthour_sensor_key") + async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done() + + assert hass.states.get("sensor.my_watthour_sensor") is None From 1c01ff401fa5784de1acee75678a9bd7533eeca6 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 30 Aug 2021 21:59:50 +0200 Subject: [PATCH 108/843] Use EntityDescription - qnap (#55410) * Use EntityDescription - qnap * Remove default values --- homeassistant/components/qnap/sensor.py | 291 +++++++++++++++--------- 1 file changed, 183 insertions(+), 108 deletions(-) diff --git a/homeassistant/components/qnap/sensor.py b/homeassistant/components/qnap/sensor.py index b02c977d98d..91df03947a0 100644 --- a/homeassistant/components/qnap/sensor.py +++ b/homeassistant/components/qnap/sensor.py @@ -1,11 +1,17 @@ """Support for QNAP NAS Sensors.""" +from __future__ import annotations + from datetime import timedelta import logging from qnapstats import QNAPStats import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( ATTR_NAME, CONF_HOST, @@ -56,57 +62,117 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) NOTIFICATION_ID = "qnap_notification" NOTIFICATION_TITLE = "QNAP Sensor Setup" -_SYSTEM_MON_COND = { - "status": ["Status", None, "mdi:checkbox-marked-circle-outline", None], - "system_temp": ["System Temperature", TEMP_CELSIUS, None, DEVICE_CLASS_TEMPERATURE], -} -_CPU_MON_COND = { - "cpu_temp": ["CPU Temperature", TEMP_CELSIUS, None, DEVICE_CLASS_TEMPERATURE], - "cpu_usage": ["CPU Usage", PERCENTAGE, "mdi:chip", None], -} -_MEMORY_MON_COND = { - "memory_free": ["Memory Available", DATA_GIBIBYTES, "mdi:memory", None], - "memory_used": ["Memory Used", DATA_GIBIBYTES, "mdi:memory", None], - "memory_percent_used": ["Memory Usage", PERCENTAGE, "mdi:memory", None], -} -_NETWORK_MON_COND = { - "network_link_status": [ - "Network Link", - None, - "mdi:checkbox-marked-circle-outline", - None, - ], - "network_tx": ["Network Up", DATA_RATE_MEBIBYTES_PER_SECOND, "mdi:upload", None], - "network_rx": [ - "Network Down", - DATA_RATE_MEBIBYTES_PER_SECOND, - "mdi:download", - None, - ], -} -_DRIVE_MON_COND = { - "drive_smart_status": [ - "SMART Status", - None, - "mdi:checkbox-marked-circle-outline", - None, - ], - "drive_temp": ["Temperature", TEMP_CELSIUS, None, DEVICE_CLASS_TEMPERATURE], -} -_VOLUME_MON_COND = { - "volume_size_used": ["Used Space", DATA_GIBIBYTES, "mdi:chart-pie", None], - "volume_size_free": ["Free Space", DATA_GIBIBYTES, "mdi:chart-pie", None], - "volume_percentage_used": ["Volume Used", PERCENTAGE, "mdi:chart-pie", None], -} - -_MONITORED_CONDITIONS = ( - list(_SYSTEM_MON_COND) - + list(_CPU_MON_COND) - + list(_MEMORY_MON_COND) - + list(_NETWORK_MON_COND) - + list(_DRIVE_MON_COND) - + list(_VOLUME_MON_COND) +_SYSTEM_MON_COND: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="status", + name="Status", + icon="mdi:checkbox-marked-circle-outline", + ), + SensorEntityDescription( + key="system_temp", + name="System Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), ) +_CPU_MON_COND: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="cpu_temp", + name="CPU Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key="cpu_usage", + name="CPU Usage", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:chip", + ), +) +_MEMORY_MON_COND: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="memory_free", + name="Memory Available", + native_unit_of_measurement=DATA_GIBIBYTES, + icon="mdi:memory", + ), + SensorEntityDescription( + key="memory_used", + name="Memory Used", + native_unit_of_measurement=DATA_GIBIBYTES, + icon="mdi:memory", + ), + SensorEntityDescription( + key="memory_percent_used", + name="Memory Usage", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:memory", + ), +) +_NETWORK_MON_COND: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="network_link_status", + name="Network Link", + icon="mdi:checkbox-marked-circle-outline", + ), + SensorEntityDescription( + key="network_tx", + name="Network Up", + native_unit_of_measurement=DATA_RATE_MEBIBYTES_PER_SECOND, + icon="mdi:upload", + ), + SensorEntityDescription( + key="network_rx", + name="Network Down", + native_unit_of_measurement=DATA_RATE_MEBIBYTES_PER_SECOND, + icon="mdi:download", + ), +) +_DRIVE_MON_COND: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="drive_smart_status", + name="SMART Status", + icon="mdi:checkbox-marked-circle-outline", + ), + SensorEntityDescription( + key="drive_temp", + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), +) +_VOLUME_MON_COND: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="volume_size_used", + name="Used Space", + native_unit_of_measurement=DATA_GIBIBYTES, + icon="mdi:chart-pie", + ), + SensorEntityDescription( + key="volume_size_free", + name="Free Space", + native_unit_of_measurement=DATA_GIBIBYTES, + icon="mdi:chart-pie", + ), + SensorEntityDescription( + key="volume_percentage_used", + name="Volume Used", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:chart-pie", + ), +) + +SENSOR_KEYS: list[str] = [ + desc.key + for desc in ( + *_SYSTEM_MON_COND, + *_CPU_MON_COND, + *_MEMORY_MON_COND, + *_NETWORK_MON_COND, + *_DRIVE_MON_COND, + *_VOLUME_MON_COND, + ) +] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -118,7 +184,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, vol.Optional(CONF_MONITORED_CONDITIONS): vol.All( - cv.ensure_list, [vol.In(_MONITORED_CONDITIONS)] + cv.ensure_list, [vol.In(SENSOR_KEYS)] ), vol.Optional(CONF_NICS): cv.ensure_list, vol.Optional(CONF_DRIVES): cv.ensure_list, @@ -136,40 +202,61 @@ def setup_platform(hass, config, add_entities, discovery_info=None): if not api.data: raise PlatformNotReady + monitored_conditions = config[CONF_MONITORED_CONDITIONS] sensors = [] # Basic sensors - for variable in config[CONF_MONITORED_CONDITIONS]: - if variable in _SYSTEM_MON_COND: - sensors.append(QNAPSystemSensor(api, variable, _SYSTEM_MON_COND[variable])) - if variable in _CPU_MON_COND: - sensors.append(QNAPCPUSensor(api, variable, _CPU_MON_COND[variable])) - if variable in _MEMORY_MON_COND: - sensors.append(QNAPMemorySensor(api, variable, _MEMORY_MON_COND[variable])) + sensors.extend( + [ + QNAPSystemSensor(api, description) + for description in _SYSTEM_MON_COND + if description.key in monitored_conditions + ] + ) + sensors.extend( + [ + QNAPCPUSensor(api, description) + for description in _CPU_MON_COND + if description.key in monitored_conditions + ] + ) + sensors.extend( + [ + QNAPMemorySensor(api, description) + for description in _MEMORY_MON_COND + if description.key in monitored_conditions + ] + ) # Network sensors - for nic in config.get(CONF_NICS, api.data["system_stats"]["nics"]): - sensors += [ - QNAPNetworkSensor(api, variable, _NETWORK_MON_COND[variable], nic) - for variable in config[CONF_MONITORED_CONDITIONS] - if variable in _NETWORK_MON_COND + sensors.extend( + [ + QNAPNetworkSensor(api, description, nic) + for nic in config.get(CONF_NICS, api.data["system_stats"]["nics"]) + for description in _NETWORK_MON_COND + if description.key in monitored_conditions ] + ) # Drive sensors - for drive in config.get(CONF_DRIVES, api.data["smart_drive_health"]): - sensors += [ - QNAPDriveSensor(api, variable, _DRIVE_MON_COND[variable], drive) - for variable in config[CONF_MONITORED_CONDITIONS] - if variable in _DRIVE_MON_COND + sensors.extend( + [ + QNAPDriveSensor(api, description, drive) + for drive in config.get(CONF_DRIVES, api.data["smart_drive_health"]) + for description in _DRIVE_MON_COND + if description.key in monitored_conditions ] + ) # Volume sensors - for volume in config.get(CONF_VOLUMES, api.data["volumes"]): - sensors += [ - QNAPVolumeSensor(api, variable, _VOLUME_MON_COND[variable], volume) - for variable in config[CONF_MONITORED_CONDITIONS] - if variable in _VOLUME_MON_COND + sensors.extend( + [ + QNAPVolumeSensor(api, description, volume) + for volume in config.get(CONF_VOLUMES, api.data["volumes"]) + for description in _VOLUME_MON_COND + if description.key in monitored_conditions ] + ) add_entities(sensors) @@ -218,15 +305,11 @@ class QNAPStatsAPI: class QNAPSensor(SensorEntity): """Base class for a QNAP sensor.""" - def __init__(self, api, variable, variable_info, monitor_device=None): + def __init__(self, api, description: SensorEntityDescription, monitor_device=None): """Initialize the sensor.""" - self.var_id = variable - self.var_name = variable_info[0] - self.var_units = variable_info[1] - self.var_icon = variable_info[2] + self.entity_description = description self.monitor_device = monitor_device self._api = api - self._attr_device_class = variable_info[3] @property def name(self): @@ -234,18 +317,10 @@ class QNAPSensor(SensorEntity): server_name = self._api.data["system_stats"]["system"]["name"] if self.monitor_device is not None: - return f"{server_name} {self.var_name} ({self.monitor_device})" - return f"{server_name} {self.var_name}" - - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return self.var_icon - - @property - def native_unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return self.var_units + return ( + f"{server_name} {self.entity_description.name} ({self.monitor_device})" + ) + return f"{server_name} {self.entity_description.name}" def update(self): """Get the latest data for the states.""" @@ -258,9 +333,9 @@ class QNAPCPUSensor(QNAPSensor): @property def native_value(self): """Return the state of the sensor.""" - if self.var_id == "cpu_temp": + if self.entity_description.key == "cpu_temp": return self._api.data["system_stats"]["cpu"]["temp_c"] - if self.var_id == "cpu_usage": + if self.entity_description.key == "cpu_usage": return self._api.data["system_stats"]["cpu"]["usage_percent"] @@ -271,16 +346,16 @@ class QNAPMemorySensor(QNAPSensor): def native_value(self): """Return the state of the sensor.""" free = float(self._api.data["system_stats"]["memory"]["free"]) / 1024 - if self.var_id == "memory_free": + if self.entity_description.key == "memory_free": return round_nicely(free) total = float(self._api.data["system_stats"]["memory"]["total"]) / 1024 used = total - free - if self.var_id == "memory_used": + if self.entity_description.key == "memory_used": return round_nicely(used) - if self.var_id == "memory_percent_used": + if self.entity_description.key == "memory_percent_used": return round(used / total * 100) @property @@ -298,15 +373,15 @@ class QNAPNetworkSensor(QNAPSensor): @property def native_value(self): """Return the state of the sensor.""" - if self.var_id == "network_link_status": + if self.entity_description.key == "network_link_status": nic = self._api.data["system_stats"]["nics"][self.monitor_device] return nic["link_status"] data = self._api.data["bandwidth"][self.monitor_device] - if self.var_id == "network_tx": + if self.entity_description.key == "network_tx": return round_nicely(data["tx"] / 1024 / 1024) - if self.var_id == "network_rx": + if self.entity_description.key == "network_rx": return round_nicely(data["rx"] / 1024 / 1024) @property @@ -331,10 +406,10 @@ class QNAPSystemSensor(QNAPSensor): @property def native_value(self): """Return the state of the sensor.""" - if self.var_id == "status": + if self.entity_description.key == "status": return self._api.data["system_health"] - if self.var_id == "system_temp": + if self.entity_description.key == "system_temp": return int(self._api.data["system_stats"]["system"]["temp_c"]) @property @@ -362,10 +437,10 @@ class QNAPDriveSensor(QNAPSensor): """Return the state of the sensor.""" data = self._api.data["smart_drive_health"][self.monitor_device] - if self.var_id == "drive_smart_status": + if self.entity_description.key == "drive_smart_status": return data["health"] - if self.var_id == "drive_temp": + if self.entity_description.key == "drive_temp": return int(data["temp_c"]) if data["temp_c"] is not None else 0 @property @@ -373,7 +448,7 @@ class QNAPDriveSensor(QNAPSensor): """Return the name of the sensor, if any.""" server_name = self._api.data["system_stats"]["system"]["name"] - return f"{server_name} {self.var_name} (Drive {self.monitor_device})" + return f"{server_name} {self.entity_description.name} (Drive {self.monitor_device})" @property def extra_state_attributes(self): @@ -397,16 +472,16 @@ class QNAPVolumeSensor(QNAPSensor): data = self._api.data["volumes"][self.monitor_device] free_gb = int(data["free_size"]) / 1024 / 1024 / 1024 - if self.var_id == "volume_size_free": + if self.entity_description.key == "volume_size_free": return round_nicely(free_gb) total_gb = int(data["total_size"]) / 1024 / 1024 / 1024 used_gb = total_gb - free_gb - if self.var_id == "volume_size_used": + if self.entity_description.key == "volume_size_used": return round_nicely(used_gb) - if self.var_id == "volume_percentage_used": + if self.entity_description.key == "volume_percentage_used": return round(used_gb / total_gb * 100) @property From 1d1b5ab3451c0001ca8d64598ed8f7b54fa359a3 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Mon, 30 Aug 2021 16:09:41 -0400 Subject: [PATCH 109/843] Fix area_id and area_name template functions (#55470) --- homeassistant/helpers/template.py | 23 ++++++++++++++++++----- tests/helpers/test_template.py | 20 ++++++++++++++++++++ 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index a831e8d156d..ade580694c8 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -957,6 +957,7 @@ def area_id(hass: HomeAssistant, lookup_value: str) -> str | None: return area.id ent_reg = entity_registry.async_get(hass) + dev_reg = device_registry.async_get(hass) # Import here, not at top-level to avoid circular import from homeassistant.helpers import ( # pylint: disable=import-outside-toplevel config_validation as cv, @@ -968,10 +969,14 @@ def area_id(hass: HomeAssistant, lookup_value: str) -> str | None: pass else: if entity := ent_reg.async_get(lookup_value): - return entity.area_id + # If entity has an area ID, return that + if entity.area_id: + return entity.area_id + # If entity has a device ID, return the area ID for the device + if entity.device_id and (device := dev_reg.async_get(entity.device_id)): + return device.area_id - # Check if this could be a device ID (hex string) - dev_reg = device_registry.async_get(hass) + # Check if this could be a device ID if device := dev_reg.async_get(lookup_value): return device.area_id @@ -992,6 +997,7 @@ def area_name(hass: HomeAssistant, lookup_value: str) -> str | None: if area: return area.name + dev_reg = device_registry.async_get(hass) ent_reg = entity_registry.async_get(hass) # Import here, not at top-level to avoid circular import from homeassistant.helpers import ( # pylint: disable=import-outside-toplevel @@ -1004,11 +1010,18 @@ def area_name(hass: HomeAssistant, lookup_value: str) -> str | None: pass else: if entity := ent_reg.async_get(lookup_value): + # If entity has an area ID, get the area name for that if entity.area_id: return _get_area_name(area_reg, entity.area_id) - return None + # If entity has a device ID and the device exists with an area ID, get the + # area name for that + if ( + entity.device_id + and (device := dev_reg.async_get(entity.device_id)) + and device.area_id + ): + return _get_area_name(area_reg, device.area_id) - dev_reg = device_registry.async_get(hass) if (device := dev_reg.async_get(lookup_value)) and device.area_id: return _get_area_name(area_reg, device.area_id) diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 7a2776fd5b2..64b075b685a 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -1828,6 +1828,16 @@ async def test_area_id(hass): assert_result_info(info, area_entry_entity_id.id) assert info.rate_limit is None + # Make sure that when entity doesn't have an area but its device does, that's what + # gets returned + entity_entry = entity_registry.async_update_entity( + entity_entry.entity_id, area_id=area_entry_entity_id.id + ) + + info = render_to_info(hass, f"{{{{ area_id('{entity_entry.entity_id}') }}}}") + assert_result_info(info, area_entry_entity_id.id) + assert info.rate_limit is None + async def test_area_name(hass): """Test area_name function.""" @@ -1897,6 +1907,16 @@ async def test_area_name(hass): assert_result_info(info, area_entry.name) assert info.rate_limit is None + # Make sure that when entity doesn't have an area but its device does, that's what + # gets returned + entity_entry = entity_registry.async_update_entity( + entity_entry.entity_id, area_id=None + ) + + info = render_to_info(hass, f"{{{{ area_name('{entity_entry.entity_id}') }}}}") + assert_result_info(info, area_entry.name) + assert info.rate_limit is None + def test_closest_function_to_coord(hass): """Test closest function to coord.""" From 3d9d104482cc411abbbb4dcbf2d9fc37ad4b87cc Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Mon, 30 Aug 2021 14:12:09 -0600 Subject: [PATCH 110/843] Bump pyiqvia to 1.1.0 (#55466) --- homeassistant/components/iqvia/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/iqvia/manifest.json b/homeassistant/components/iqvia/manifest.json index da50819c9a0..e8914507657 100644 --- a/homeassistant/components/iqvia/manifest.json +++ b/homeassistant/components/iqvia/manifest.json @@ -3,7 +3,7 @@ "name": "IQVIA", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/iqvia", - "requirements": ["numpy==1.21.1", "pyiqvia==1.0.0"], + "requirements": ["numpy==1.21.1", "pyiqvia==1.1.0"], "codeowners": ["@bachya"], "iot_class": "cloud_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index 209d9c78696..619d49d389f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1530,7 +1530,7 @@ pyipma==2.0.5 pyipp==0.11.0 # homeassistant.components.iqvia -pyiqvia==1.0.0 +pyiqvia==1.1.0 # homeassistant.components.irish_rail_transport pyirishrail==0.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 36b928d23c5..5fb96b169ca 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -879,7 +879,7 @@ pyipma==2.0.5 pyipp==0.11.0 # homeassistant.components.iqvia -pyiqvia==1.0.0 +pyiqvia==1.1.0 # homeassistant.components.isy994 pyisy==3.0.0 From 46f05ca2790a326c13d8a0a74c8af84b4f0cb692 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Mon, 30 Aug 2021 14:12:27 -0600 Subject: [PATCH 111/843] Bump pyopenuv to 2.2.0 (#55464) --- homeassistant/components/openuv/__init__.py | 1 + homeassistant/components/openuv/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/openuv/__init__.py b/homeassistant/components/openuv/__init__.py index d14760d6cb1..5d165c498e2 100644 --- a/homeassistant/components/openuv/__init__.py +++ b/homeassistant/components/openuv/__init__.py @@ -66,6 +66,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b config_entry.data.get(CONF_LONGITUDE, hass.config.longitude), altitude=config_entry.data.get(CONF_ELEVATION, hass.config.elevation), session=websession, + logger=LOGGER, ), ) await openuv.async_update() diff --git a/homeassistant/components/openuv/manifest.json b/homeassistant/components/openuv/manifest.json index 842d4966805..24af3f3a3af 100644 --- a/homeassistant/components/openuv/manifest.json +++ b/homeassistant/components/openuv/manifest.json @@ -3,7 +3,7 @@ "name": "OpenUV", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/openuv", - "requirements": ["pyopenuv==2.1.0"], + "requirements": ["pyopenuv==2.2.0"], "codeowners": ["@bachya"], "iot_class": "cloud_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index 619d49d389f..9672be86725 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1668,7 +1668,7 @@ pyobihai==1.3.1 pyombi==0.1.10 # homeassistant.components.openuv -pyopenuv==2.1.0 +pyopenuv==2.2.0 # homeassistant.components.opnsense pyopnsense==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5fb96b169ca..969517aa00f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -966,7 +966,7 @@ pynx584==0.5 pynzbgetapi==0.2.0 # homeassistant.components.openuv -pyopenuv==2.1.0 +pyopenuv==2.2.0 # homeassistant.components.opnsense pyopnsense==0.2.0 From 433775cf4b0f1de0c004bd8601a6854cde5c4a74 Mon Sep 17 00:00:00 2001 From: ha0y <30557072+ha0y@users.noreply.github.com> Date: Mon, 30 Aug 2021 13:28:26 -0700 Subject: [PATCH 112/843] Add input_select and select domain support for HomeKit (#54760) Co-authored-by: J. Nick Koston --- .../components/homekit/accessories.py | 3 + .../components/homekit/config_flow.py | 2 + .../components/homekit/type_switches.py | 47 ++++++++++++++ .../homekit/test_get_accessories.py | 2 + .../components/homekit/test_type_switches.py | 64 ++++++++++++++++++- 5 files changed, 117 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index 8298cdd9c83..8aa7878bfba 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -198,6 +198,9 @@ def get_accessory(hass, driver, state, aid, config): # noqa: C901 elif state.domain in ("automation", "input_boolean", "remote", "scene", "script"): a_type = "Switch" + elif state.domain in ("input_select", "select"): + a_type = "SelectSwitch" + elif state.domain == "water_heater": a_type = "WaterHeater" diff --git a/homeassistant/components/homekit/config_flow.py b/homeassistant/components/homekit/config_flow.py index 03df55a9026..6f0e9d9ba5f 100644 --- a/homeassistant/components/homekit/config_flow.py +++ b/homeassistant/components/homekit/config_flow.py @@ -84,6 +84,7 @@ SUPPORTED_DOMAINS = [ "fan", "humidifier", "input_boolean", + "input_select", "light", "lock", MEDIA_PLAYER_DOMAIN, @@ -91,6 +92,7 @@ SUPPORTED_DOMAINS = [ REMOTE_DOMAIN, "scene", "script", + "select", "sensor", "switch", "vacuum", diff --git a/homeassistant/components/homekit/type_switches.py b/homeassistant/components/homekit/type_switches.py index 3bb496a2abc..4e76b0369fe 100644 --- a/homeassistant/components/homekit/type_switches.py +++ b/homeassistant/components/homekit/type_switches.py @@ -9,6 +9,7 @@ from pyhap.const import ( CATEGORY_SWITCH, ) +from homeassistant.components.input_select import ATTR_OPTIONS, SERVICE_SELECT_OPTION from homeassistant.components.switch import DOMAIN from homeassistant.components.vacuum import ( DOMAIN as VACUUM_DOMAIN, @@ -33,9 +34,11 @@ from .accessories import TYPES, HomeAccessory from .const import ( CHAR_ACTIVE, CHAR_IN_USE, + CHAR_NAME, CHAR_ON, CHAR_OUTLET_IN_USE, CHAR_VALVE_TYPE, + MAX_NAME_LENGTH, SERV_OUTLET, SERV_SWITCH, SERV_VALVE, @@ -226,3 +229,47 @@ class Valve(HomeAccessory): self.char_active.set_value(current_state) _LOGGER.debug("%s: Set in_use state to %s", self.entity_id, current_state) self.char_in_use.set_value(current_state) + + +@TYPES.register("SelectSwitch") +class SelectSwitch(HomeAccessory): + """Generate a Switch accessory that contains multiple switches.""" + + def __init__(self, *args): + """Initialize a Switch accessory object.""" + super().__init__(*args, category=CATEGORY_SWITCH) + self.domain = split_entity_id(self.entity_id)[0] + state = self.hass.states.get(self.entity_id) + self.select_chars = {} + options = state.attributes[ATTR_OPTIONS] + for option in options: + serv_option = self.add_preload_service( + SERV_OUTLET, [CHAR_NAME, CHAR_IN_USE] + ) + serv_option.configure_char( + CHAR_NAME, + value=f"{option}"[:MAX_NAME_LENGTH], + ) + serv_option.configure_char(CHAR_IN_USE, value=False) + self.select_chars[option] = serv_option.configure_char( + CHAR_ON, + value=False, + setter_callback=lambda value, option=option: self.select_option(option), + ) + self.set_primary_service(self.select_chars[options[0]]) + # Set the state so it is in sync on initial + # GET to avoid an event storm after homekit startup + self.async_update_state(state) + + def select_option(self, option): + """Set option from HomeKit.""" + _LOGGER.debug("%s: Set option to %s", self.entity_id, option) + params = {ATTR_ENTITY_ID: self.entity_id, "option": option} + self.async_call_service(self.domain, SERVICE_SELECT_OPTION, params) + + @callback + def async_update_state(self, new_state): + """Update switch state after state changed.""" + current_option = new_state.state + for option, char in self.select_chars.items(): + char.set_value(option == current_option) diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py index af98f6a45f9..be2429c79cf 100644 --- a/tests/components/homekit/test_get_accessories.py +++ b/tests/components/homekit/test_get_accessories.py @@ -274,6 +274,8 @@ def test_type_sensors(type_name, entity_id, state, attrs): ("Switch", "remote.test", "on", {}, {}), ("Switch", "scene.test", "on", {}, {}), ("Switch", "script.test", "on", {}, {}), + ("SelectSwitch", "input_select.test", "option1", {}, {}), + ("SelectSwitch", "select.test", "option1", {}, {}), ("Switch", "switch.test", "on", {}, {}), ("Switch", "switch.test", "on", {}, {CONF_TYPE: TYPE_SWITCH}), ("Valve", "switch.test", "on", {}, {CONF_TYPE: TYPE_FAUCET}), diff --git a/tests/components/homekit/test_type_switches.py b/tests/components/homekit/test_type_switches.py index 6df1f0182ed..c13f7ea2538 100644 --- a/tests/components/homekit/test_type_switches.py +++ b/tests/components/homekit/test_type_switches.py @@ -10,7 +10,14 @@ from homeassistant.components.homekit.const import ( TYPE_SPRINKLER, TYPE_VALVE, ) -from homeassistant.components.homekit.type_switches import Outlet, Switch, Vacuum, Valve +from homeassistant.components.homekit.type_switches import ( + Outlet, + SelectSwitch, + Switch, + Vacuum, + Valve, +) +from homeassistant.components.select.const import ATTR_OPTIONS from homeassistant.components.vacuum import ( DOMAIN as VACUUM_DOMAIN, SERVICE_RETURN_TO_BASE, @@ -26,6 +33,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, CONF_TYPE, + SERVICE_SELECT_OPTION, STATE_OFF, STATE_ON, ) @@ -387,3 +395,57 @@ async def test_script_switch(hass, hk_driver, events): await hass.async_block_till_done() assert acc.char_on.value is False assert len(events) == 1 + + +@pytest.mark.parametrize( + "domain", + ["input_select", "select"], +) +async def test_input_select_switch(hass, hk_driver, events, domain): + """Test if select switch accessory is handled correctly.""" + entity_id = f"{domain}.test" + + hass.states.async_set( + entity_id, "option1", {ATTR_OPTIONS: ["option1", "option2", "option3"]} + ) + await hass.async_block_till_done() + acc = SelectSwitch(hass, hk_driver, "SelectSwitch", entity_id, 2, None) + await acc.run() + await hass.async_block_till_done() + + assert acc.select_chars["option1"].value is True + assert acc.select_chars["option2"].value is False + assert acc.select_chars["option3"].value is False + + call_select_option = async_mock_service(hass, domain, SERVICE_SELECT_OPTION) + acc.select_chars["option2"].client_update_value(True) + await hass.async_block_till_done() + + assert call_select_option + assert call_select_option[0].data == {"entity_id": entity_id, "option": "option2"} + assert len(events) == 1 + assert events[-1].data[ATTR_VALUE] is None + + hass.states.async_set( + entity_id, "option2", {ATTR_OPTIONS: ["option1", "option2", "option3"]} + ) + await hass.async_block_till_done() + assert acc.select_chars["option1"].value is False + assert acc.select_chars["option2"].value is True + assert acc.select_chars["option3"].value is False + + hass.states.async_set( + entity_id, "option3", {ATTR_OPTIONS: ["option1", "option2", "option3"]} + ) + await hass.async_block_till_done() + assert acc.select_chars["option1"].value is False + assert acc.select_chars["option2"].value is False + assert acc.select_chars["option3"].value is True + + hass.states.async_set( + entity_id, "invalid", {ATTR_OPTIONS: ["option1", "option2", "option3"]} + ) + await hass.async_block_till_done() + assert acc.select_chars["option1"].value is False + assert acc.select_chars["option2"].value is False + assert acc.select_chars["option3"].value is False From 76f21452eed81a71db0a896a2f2d40285885e6b1 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Mon, 30 Aug 2021 15:05:28 -0600 Subject: [PATCH 113/843] Bump aioambient to 1.3.0 (#55468) --- homeassistant/components/ambient_station/__init__.py | 1 + homeassistant/components/ambient_station/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ambient_station/__init__.py b/homeassistant/components/ambient_station/__init__.py index 201f21c0f17..4a73d699d59 100644 --- a/homeassistant/components/ambient_station/__init__.py +++ b/homeassistant/components/ambient_station/__init__.py @@ -75,6 +75,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b config_entry.data[CONF_API_KEY], config_entry.data[CONF_APP_KEY], session=session, + logger=LOGGER, ), ) hass.loop.create_task(ambient.ws_connect()) diff --git a/homeassistant/components/ambient_station/manifest.json b/homeassistant/components/ambient_station/manifest.json index 35b4770e872..b95f4a8f13c 100644 --- a/homeassistant/components/ambient_station/manifest.json +++ b/homeassistant/components/ambient_station/manifest.json @@ -3,7 +3,7 @@ "name": "Ambient Weather Station", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ambient_station", - "requirements": ["aioambient==1.2.6"], + "requirements": ["aioambient==1.3.0"], "codeowners": ["@bachya"], "iot_class": "cloud_push" } diff --git a/requirements_all.txt b/requirements_all.txt index 9672be86725..551199999af 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -136,7 +136,7 @@ aio_geojson_nsw_rfs_incidents==0.4 aio_georss_gdacs==0.5 # homeassistant.components.ambient_station -aioambient==1.2.6 +aioambient==1.3.0 # homeassistant.components.asuswrt aioasuswrt==1.3.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 969517aa00f..2f2f50fc5ef 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -78,7 +78,7 @@ aio_geojson_nsw_rfs_incidents==0.4 aio_georss_gdacs==0.5 # homeassistant.components.ambient_station -aioambient==1.2.6 +aioambient==1.3.0 # homeassistant.components.asuswrt aioasuswrt==1.3.4 From 9b3346bc801fc767ae741549fc7d85fa1b2c4830 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 30 Aug 2021 23:32:19 +0200 Subject: [PATCH 114/843] Update frontend to 20210830.0 (#55472) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 6224916246a..076420656fd 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", "requirements": [ - "home-assistant-frontend==20210825.0" + "home-assistant-frontend==20210830.0" ], "dependencies": [ "api", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c91a512fdd0..cb6d10e4084 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -16,7 +16,7 @@ cryptography==3.3.2 defusedxml==0.7.1 emoji==1.2.0 hass-nabucasa==0.46.0 -home-assistant-frontend==20210825.0 +home-assistant-frontend==20210830.0 httpx==0.19.0 ifaddr==0.1.7 jinja2==3.0.1 diff --git a/requirements_all.txt b/requirements_all.txt index 551199999af..c9bd684bfd9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -793,7 +793,7 @@ hole==0.5.1 holidays==0.11.2 # homeassistant.components.frontend -home-assistant-frontend==20210825.0 +home-assistant-frontend==20210830.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2f2f50fc5ef..eb8ebeaea65 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -462,7 +462,7 @@ hole==0.5.1 holidays==0.11.2 # homeassistant.components.frontend -home-assistant-frontend==20210825.0 +home-assistant-frontend==20210830.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 From 18c03e2f8db172d7f6386a59f3364705b6ebb87b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 30 Aug 2021 23:32:35 +0200 Subject: [PATCH 115/843] Fix race in MQTT sensor when last_reset_topic is configured (#55463) --- homeassistant/components/mqtt/sensor.py | 100 +++++++++++++++++------- tests/components/mqtt/test_sensor.py | 46 ++++++++++- 2 files changed, 117 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 1e0b164a6b8..0e5cca03ceb 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -53,18 +53,48 @@ MQTT_SENSOR_ATTRIBUTES_BLOCKED = frozenset( DEFAULT_NAME = "MQTT Sensor" DEFAULT_FORCE_UPDATE = False -PLATFORM_SCHEMA = mqtt.MQTT_RO_PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_EXPIRE_AFTER): cv.positive_int, - vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, - vol.Optional(CONF_LAST_RESET_TOPIC): mqtt.valid_subscribe_topic, - vol.Optional(CONF_LAST_RESET_VALUE_TEMPLATE): cv.template, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA, - vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, - } -).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) + + +def validate_options(conf): + """Validate options. + + If last reset topic is present it must be same as the state topic. + """ + if ( + CONF_LAST_RESET_TOPIC in conf + and CONF_STATE_TOPIC in conf + and conf[CONF_LAST_RESET_TOPIC] != conf[CONF_STATE_TOPIC] + ): + _LOGGER.warning( + "'%s' must be same as '%s'", CONF_LAST_RESET_TOPIC, CONF_STATE_TOPIC + ) + + if CONF_LAST_RESET_TOPIC in conf and CONF_LAST_RESET_VALUE_TEMPLATE not in conf: + _LOGGER.warning( + "'%s' must be set if '%s' is set", + CONF_LAST_RESET_VALUE_TEMPLATE, + CONF_LAST_RESET_TOPIC, + ) + + return conf + + +PLATFORM_SCHEMA = vol.All( + cv.deprecated(CONF_LAST_RESET_TOPIC), + mqtt.MQTT_RO_PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_EXPIRE_AFTER): cv.positive_int, + vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, + vol.Optional(CONF_LAST_RESET_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_LAST_RESET_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, + } + ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema), + validate_options, +) async def async_setup_platform( @@ -127,10 +157,7 @@ class MqttSensor(MqttEntity, SensorEntity): """(Re)Subscribe to topics.""" topics = {} - @callback - @log_messages(self.hass, self.entity_id) - def message_received(msg): - """Handle new MQTT messages.""" + def _update_state(msg): payload = msg.payload # auto-expire enabled? expire_after = self._config.get(CONF_EXPIRE_AFTER) @@ -159,18 +186,8 @@ class MqttSensor(MqttEntity, SensorEntity): variables=variables, ) self._state = payload - self.async_write_ha_state() - topics["state_topic"] = { - "topic": self._config[CONF_STATE_TOPIC], - "msg_callback": message_received, - "qos": self._config[CONF_QOS], - } - - @callback - @log_messages(self.hass, self.entity_id) - def last_reset_message_received(msg): - """Handle new last_reset messages.""" + def _update_last_reset(msg): payload = msg.payload template = self._config.get(CONF_LAST_RESET_VALUE_TEMPLATE) @@ -193,9 +210,36 @@ class MqttSensor(MqttEntity, SensorEntity): _LOGGER.warning( "Invalid last_reset message '%s' from '%s'", msg.payload, msg.topic ) + + @callback + @log_messages(self.hass, self.entity_id) + def message_received(msg): + """Handle new MQTT messages.""" + _update_state(msg) + if CONF_LAST_RESET_VALUE_TEMPLATE in self._config and ( + CONF_LAST_RESET_TOPIC not in self._config + or self._config[CONF_LAST_RESET_TOPIC] == self._config[CONF_STATE_TOPIC] + ): + _update_last_reset(msg) self.async_write_ha_state() - if CONF_LAST_RESET_TOPIC in self._config: + topics["state_topic"] = { + "topic": self._config[CONF_STATE_TOPIC], + "msg_callback": message_received, + "qos": self._config[CONF_QOS], + } + + @callback + @log_messages(self.hass, self.entity_id) + def last_reset_message_received(msg): + """Handle new last_reset messages.""" + _update_last_reset(msg) + self.async_write_ha_state() + + if ( + CONF_LAST_RESET_TOPIC in self._config + and self._config[CONF_LAST_RESET_TOPIC] != self._config[CONF_STATE_TOPIC] + ): topics["last_reset_topic"] = { "topic": self._config[CONF_LAST_RESET_TOPIC], "msg_callback": last_reset_message_received, diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 15ca9870077..46c06f0d3b3 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -208,7 +208,7 @@ async def test_setting_sensor_value_via_mqtt_json_message(hass, mqtt_mock): assert state.state == "100" -async def test_setting_sensor_last_reset_via_mqtt_message(hass, mqtt_mock): +async def test_setting_sensor_last_reset_via_mqtt_message(hass, mqtt_mock, caplog): """Test the setting of the last_reset property via MQTT.""" assert await async_setup_component( hass, @@ -228,6 +228,11 @@ async def test_setting_sensor_last_reset_via_mqtt_message(hass, mqtt_mock): async_fire_mqtt_message(hass, "last-reset-topic", "2020-01-02 08:11:00") state = hass.states.get("sensor.test") assert state.attributes.get("last_reset") == "2020-01-02T08:11:00" + assert "'last_reset_topic' must be same as 'state_topic'" in caplog.text + assert ( + "'last_reset_value_template' must be set if 'last_reset_topic' is set" + in caplog.text + ) @pytest.mark.parametrize("datestring", ["2020-21-02 08:11:00", "Hello there!"]) @@ -306,6 +311,45 @@ async def test_setting_sensor_last_reset_via_mqtt_json_message(hass, mqtt_mock): assert state.attributes.get("last_reset") == "2020-01-02T08:11:00" +@pytest.mark.parametrize("extra", [{}, {"last_reset_topic": "test-topic"}]) +async def test_setting_sensor_last_reset_via_mqtt_json_message_2( + hass, mqtt_mock, caplog, extra +): + """Test the setting of the value via MQTT with JSON payload.""" + assert await async_setup_component( + hass, + sensor.DOMAIN, + { + sensor.DOMAIN: { + **{ + "platform": "mqtt", + "name": "test", + "state_topic": "test-topic", + "unit_of_measurement": "kWh", + "value_template": "{{ value_json.value | float / 60000 }}", + "last_reset_value_template": "{{ utcnow().fromtimestamp(value_json.time / 1000, tz=utcnow().tzinfo) }}", + }, + **extra, + } + }, + ) + await hass.async_block_till_done() + + async_fire_mqtt_message( + hass, + "test-topic", + '{"type":"minute","time":1629385500000,"value":947.7706166666667}', + ) + state = hass.states.get("sensor.test") + assert float(state.state) == pytest.approx(0.015796176944444445) + assert state.attributes.get("last_reset") == "2021-08-19T15:05:00+00:00" + assert "'last_reset_topic' must be same as 'state_topic'" not in caplog.text + assert ( + "'last_reset_value_template' must be set if 'last_reset_topic' is set" + not in caplog.text + ) + + async def test_force_update_disabled(hass, mqtt_mock): """Test force update option.""" assert await async_setup_component( From 368cac7e5db598ff6373efe975ff952cc7c63dbe Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Tue, 31 Aug 2021 00:17:01 +0000 Subject: [PATCH 116/843] [ci skip] Translation update --- .../components/homekit/translations/fr.json | 3 ++- .../components/iotawatt/translations/ca.json | 23 +++++++++++++++++++ .../components/iotawatt/translations/en.json | 3 +-- .../components/iotawatt/translations/pl.json | 22 ++++++++++++++++++ .../components/mqtt/translations/it.json | 1 + .../components/nanoleaf/translations/fr.json | 3 ++- .../components/nanoleaf/translations/nl.json | 1 + .../nmap_tracker/translations/ca.json | 1 + .../nmap_tracker/translations/de.json | 1 + .../nmap_tracker/translations/en.json | 3 ++- .../nmap_tracker/translations/et.json | 1 + .../nmap_tracker/translations/fr.json | 1 + .../nmap_tracker/translations/no.json | 1 + .../nmap_tracker/translations/ru.json | 1 + .../nmap_tracker/translations/zh-Hant.json | 1 + .../components/openuv/translations/fr.json | 4 ++++ .../p1_monitor/translations/fr.json | 3 ++- .../components/sensor/translations/fr.json | 2 ++ .../synology_dsm/translations/fr.json | 3 ++- .../synology_dsm/translations/nl.json | 3 ++- .../synology_dsm/translations/no.json | 3 ++- .../synology_dsm/translations/pl.json | 3 ++- .../components/zha/translations/fr.json | 6 ++++- .../components/zwave_js/translations/fr.json | 8 +++++-- 24 files changed, 88 insertions(+), 13 deletions(-) create mode 100644 homeassistant/components/iotawatt/translations/ca.json create mode 100644 homeassistant/components/iotawatt/translations/pl.json diff --git a/homeassistant/components/homekit/translations/fr.json b/homeassistant/components/homekit/translations/fr.json index 018e93e18c9..ef931792193 100644 --- a/homeassistant/components/homekit/translations/fr.json +++ b/homeassistant/components/homekit/translations/fr.json @@ -21,7 +21,8 @@ "step": { "advanced": { "data": { - "auto_start": "D\u00e9marrage automatique (d\u00e9sactiver si vous utilisez Z-Wave ou un autre syst\u00e8me de d\u00e9marrage diff\u00e9r\u00e9)" + "auto_start": "D\u00e9marrage automatique (d\u00e9sactiver si vous utilisez Z-Wave ou un autre syst\u00e8me de d\u00e9marrage diff\u00e9r\u00e9)", + "devices": "P\u00e9riph\u00e9riques (d\u00e9clencheurs)" }, "description": "Ces param\u00e8tres ne doivent \u00eatre ajust\u00e9s que si le pont HomeKit n'est pas fonctionnel.", "title": "Configuration avanc\u00e9e" diff --git a/homeassistant/components/iotawatt/translations/ca.json b/homeassistant/components/iotawatt/translations/ca.json new file mode 100644 index 00000000000..d6a771b3f9b --- /dev/null +++ b/homeassistant/components/iotawatt/translations/ca.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "unknown": "Error inesperat" + }, + "step": { + "auth": { + "data": { + "password": "Contrasenya", + "username": "Nom d'usuari" + }, + "description": "El dispositiu IoTawatt necessita autenticaci\u00f3. Introdueix el nom d'usuari i la contrasenya i fes clic al bot\u00f3 Envia." + }, + "user": { + "data": { + "host": "Amfitri\u00f3" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/iotawatt/translations/en.json b/homeassistant/components/iotawatt/translations/en.json index cbda4b41bea..679fc6c6805 100644 --- a/homeassistant/components/iotawatt/translations/en.json +++ b/homeassistant/components/iotawatt/translations/en.json @@ -19,6 +19,5 @@ } } } - }, - "title": "iotawatt" + } } \ No newline at end of file diff --git a/homeassistant/components/iotawatt/translations/pl.json b/homeassistant/components/iotawatt/translations/pl.json new file mode 100644 index 00000000000..2ea6be8ca81 --- /dev/null +++ b/homeassistant/components/iotawatt/translations/pl.json @@ -0,0 +1,22 @@ +{ + "config": { + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "invalid_auth": "Niepoprawne uwierzytelnienie", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "step": { + "auth": { + "data": { + "password": "Has\u0142o", + "username": "Nazwa u\u017cytkownika" + } + }, + "user": { + "data": { + "host": "Nazwa hosta lub adres IP" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mqtt/translations/it.json b/homeassistant/components/mqtt/translations/it.json index 9636e0ea446..c1a2a3745c6 100644 --- a/homeassistant/components/mqtt/translations/it.json +++ b/homeassistant/components/mqtt/translations/it.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "Il servizio \u00e8 gi\u00e0 configurato", "single_instance_allowed": "Gi\u00e0 configurato. \u00c8 possibile una sola configurazione." }, "error": { diff --git a/homeassistant/components/nanoleaf/translations/fr.json b/homeassistant/components/nanoleaf/translations/fr.json index 2486a74c0dd..5893635580b 100644 --- a/homeassistant/components/nanoleaf/translations/fr.json +++ b/homeassistant/components/nanoleaf/translations/fr.json @@ -15,7 +15,8 @@ "flow_title": "{name}", "step": { "link": { - "description": "Appuyez sur le bouton d'alimentation de votre Nanoleaf et maintenez-le enfonc\u00e9 pendant 5 secondes jusqu'\u00e0 ce que les voyants du bouton commencent \u00e0 clignoter, puis cliquez sur **ENVOYER** dans les 30 secondes." + "description": "Appuyez sur le bouton d'alimentation de votre Nanoleaf et maintenez-le enfonc\u00e9 pendant 5 secondes jusqu'\u00e0 ce que les voyants du bouton commencent \u00e0 clignoter, puis cliquez sur **ENVOYER** dans les 30 secondes.", + "title": "Lier Nanoleaf" }, "user": { "data": { diff --git a/homeassistant/components/nanoleaf/translations/nl.json b/homeassistant/components/nanoleaf/translations/nl.json index 8dd31fc0f44..29a9f7ac58b 100644 --- a/homeassistant/components/nanoleaf/translations/nl.json +++ b/homeassistant/components/nanoleaf/translations/nl.json @@ -4,6 +4,7 @@ "already_configured": "Apparaat is al geconfigureerd", "cannot_connect": "Kan geen verbinding maken", "invalid_token": "Ongeldig toegangstoken", + "reauth_successful": "Herauthenticatie was succesvol", "unknown": "Onverwachte fout" }, "error": { diff --git a/homeassistant/components/nmap_tracker/translations/ca.json b/homeassistant/components/nmap_tracker/translations/ca.json index 857772081d8..e72c03cc63a 100644 --- a/homeassistant/components/nmap_tracker/translations/ca.json +++ b/homeassistant/components/nmap_tracker/translations/ca.json @@ -25,6 +25,7 @@ "step": { "init": { "data": { + "consider_home": "Segons d'espera abans de considerar un dispositiu de seguiment com 'a fora' despr\u00e9s de no ser vist.", "exclude": "Adreces de xarxa a excloure de l'escaneig (separades per comes)", "home_interval": "Nombre m\u00ednim de minuts entre escanejos de dispositius actius (conserva la bateria)", "hosts": "Adreces de xarxa a escanejar (separades per comes)", diff --git a/homeassistant/components/nmap_tracker/translations/de.json b/homeassistant/components/nmap_tracker/translations/de.json index 729a964059f..48f06bf320b 100644 --- a/homeassistant/components/nmap_tracker/translations/de.json +++ b/homeassistant/components/nmap_tracker/translations/de.json @@ -25,6 +25,7 @@ "step": { "init": { "data": { + "consider_home": "Wartezeit in Sekunden, bis ein Ger\u00e4tetracker als nicht zu Hause markiert wird, nachdem er nicht gesehen wurde.", "exclude": "Netzwerkadressen (kommagetrennt), die von der \u00dcberpr\u00fcfung ausgeschlossen werden sollen", "home_interval": "Mindestanzahl von Minuten zwischen den Scans aktiver Ger\u00e4te (Batterie schonen)", "hosts": "Netzwerkadressen (kommagetrennt) zum Scannen", diff --git a/homeassistant/components/nmap_tracker/translations/en.json b/homeassistant/components/nmap_tracker/translations/en.json index feeea1ff8be..9ded6eae4c2 100644 --- a/homeassistant/components/nmap_tracker/translations/en.json +++ b/homeassistant/components/nmap_tracker/translations/en.json @@ -30,7 +30,8 @@ "home_interval": "Minimum number of minutes between scans of active devices (preserve battery)", "hosts": "Network addresses (comma seperated) to scan", "interval_seconds": "Scan interval", - "scan_options": "Raw configurable scan options for Nmap" + "scan_options": "Raw configurable scan options for Nmap", + "track_new_devices": "Track new devices" }, "description": "Configure hosts to be scanned by Nmap. Network address and excludes can be IP Addresses (192.168.1.1), IP Networks (192.168.0.0/24) or IP Ranges (192.168.1.0-32)." } diff --git a/homeassistant/components/nmap_tracker/translations/et.json b/homeassistant/components/nmap_tracker/translations/et.json index 09b46a15889..538f1127448 100644 --- a/homeassistant/components/nmap_tracker/translations/et.json +++ b/homeassistant/components/nmap_tracker/translations/et.json @@ -25,6 +25,7 @@ "step": { "init": { "data": { + "consider_home": "Aeg sekundites mille j\u00e4rel seadme olekuks m\u00e4\u00e4ratakse Eemal peale seadme v\u00f5rgust kadumist.", "exclude": "V\u00e4listatud IP aadresside vahemik (komadega eraldatud list)", "home_interval": "Minimaalne sk\u00e4nnimiste intervall minutites (eeldus on aku s\u00e4\u00e4stmine)", "hosts": "V\u00f5rguaadresside vahemik (komaga eraldatud)", diff --git a/homeassistant/components/nmap_tracker/translations/fr.json b/homeassistant/components/nmap_tracker/translations/fr.json index 0f0ce5fe728..59e02ea14cc 100644 --- a/homeassistant/components/nmap_tracker/translations/fr.json +++ b/homeassistant/components/nmap_tracker/translations/fr.json @@ -25,6 +25,7 @@ "step": { "init": { "data": { + "consider_home": "Secondes \u00e0 attendre avant de marquer un tracker d'appareil comme n'\u00e9tant pas \u00e0 la maison apr\u00e8s ne pas avoir \u00e9t\u00e9 vu.", "exclude": "Adresses r\u00e9seau (s\u00e9par\u00e9es par des virgules) \u00e0 exclure de l'analyse", "home_interval": "Nombre minimum de minutes entre les analyses des appareils actifs (pr\u00e9server la batterie)", "hosts": "Adresses r\u00e9seau (s\u00e9par\u00e9es par des virgules) \u00e0 analyser", diff --git a/homeassistant/components/nmap_tracker/translations/no.json b/homeassistant/components/nmap_tracker/translations/no.json index 03a241bc3a2..acd25607c4f 100644 --- a/homeassistant/components/nmap_tracker/translations/no.json +++ b/homeassistant/components/nmap_tracker/translations/no.json @@ -25,6 +25,7 @@ "step": { "init": { "data": { + "consider_home": "Sekunder \u00e5 vente til du merker en enhetssporing som ikke hjemme etter at den ikke er blitt sett.", "exclude": "Nettverksadresser (kommaseparert) for \u00e5 ekskludere fra skanning", "home_interval": "Minimum antall minutter mellom skanninger av aktive enheter (lagre batteri)", "hosts": "Nettverksadresser (kommaseparert) for \u00e5 skanne", diff --git a/homeassistant/components/nmap_tracker/translations/ru.json b/homeassistant/components/nmap_tracker/translations/ru.json index 1a790358c73..b899d63ce83 100644 --- a/homeassistant/components/nmap_tracker/translations/ru.json +++ b/homeassistant/components/nmap_tracker/translations/ru.json @@ -25,6 +25,7 @@ "step": { "init": { "data": { + "consider_home": "\u0412\u0440\u0435\u043c\u044f \u043e\u0436\u0438\u0434\u0430\u043d\u0438\u044f (\u0432 \u0441\u0435\u043a\u0443\u043d\u0434\u0430\u0445), \u043f\u043e \u0438\u0441\u0442\u0435\u0447\u0435\u043d\u0438\u044e \u043a\u043e\u0442\u043e\u0440\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043f\u043e\u043b\u0443\u0447\u0438\u0442 \u0441\u0442\u0430\u0442\u0443\u0441 \"\u041d\u0435 \u0434\u043e\u043c\u0430\"", "exclude": "\u0421\u0435\u0442\u0435\u0432\u044b\u0435 \u0430\u0434\u0440\u0435\u0441\u0430 \u0434\u043b\u044f \u0438\u0441\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f \u0438\u0437 \u0441\u043a\u0430\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f (\u0447\u0435\u0440\u0435\u0437 \u0437\u0430\u043f\u044f\u0442\u0443\u044e)", "home_interval": "\u041c\u0438\u043d\u0438\u043c\u0430\u043b\u044c\u043d\u043e\u0435 \u043a\u043e\u043b\u0438\u0447\u0435\u0441\u0442\u0432\u043e \u043c\u0438\u043d\u0443\u0442 \u043c\u0435\u0436\u0434\u0443 \u0441\u043a\u0430\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f\u043c\u0438 \u0430\u043a\u0442\u0438\u0432\u043d\u044b\u0445 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432 (\u044d\u043a\u043e\u043d\u043e\u043c\u0438\u044f \u0437\u0430\u0440\u044f\u0434\u0430 \u0431\u0430\u0442\u0430\u0440\u0435\u0438)", "hosts": "\u0421\u0435\u0442\u0435\u0432\u044b\u0435 \u0430\u0434\u0440\u0435\u0441\u0430 \u0434\u043b\u044f \u0441\u043a\u0430\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f (\u0447\u0435\u0440\u0435\u0437 \u0437\u0430\u043f\u044f\u0442\u0443\u044e)", diff --git a/homeassistant/components/nmap_tracker/translations/zh-Hant.json b/homeassistant/components/nmap_tracker/translations/zh-Hant.json index a2c396fdec0..65b0b7afed4 100644 --- a/homeassistant/components/nmap_tracker/translations/zh-Hant.json +++ b/homeassistant/components/nmap_tracker/translations/zh-Hant.json @@ -25,6 +25,7 @@ "step": { "init": { "data": { + "consider_home": "\u8996\u70ba\u8ffd\u8e64\u88dd\u7f6e\u96e2\u958b\u7684\u7b49\u5019\u79d2\u6578", "exclude": "\u6392\u9664\u6383\u63cf\u7684\u7db2\u8def\u4f4d\u5740\uff08\u4ee5\u9017\u865f\u5206\u9694\uff09", "home_interval": "\u6383\u63cf\u6d3b\u52d5\u88dd\u7f6e\u7684\u6700\u4f4e\u9593\u9694\u5206\u9418\uff08\u8003\u91cf\u7701\u96fb\uff09", "hosts": "\u6240\u8981\u6383\u63cf\u7684\u7db2\u8def\u4f4d\u5740\uff08\u4ee5\u9017\u865f\u5206\u9694\uff09", diff --git a/homeassistant/components/openuv/translations/fr.json b/homeassistant/components/openuv/translations/fr.json index cd6002c5b65..6acd3144a10 100644 --- a/homeassistant/components/openuv/translations/fr.json +++ b/homeassistant/components/openuv/translations/fr.json @@ -21,6 +21,10 @@ "options": { "step": { "init": { + "data": { + "from_window": "Indice UV de d\u00e9part pour la fen\u00eatre de protection", + "to_window": "Indice UV de fin pour la fen\u00eatre de protection" + }, "title": "Configurer OpenUV" } } diff --git a/homeassistant/components/p1_monitor/translations/fr.json b/homeassistant/components/p1_monitor/translations/fr.json index 156b5150330..34c0a37fb2e 100644 --- a/homeassistant/components/p1_monitor/translations/fr.json +++ b/homeassistant/components/p1_monitor/translations/fr.json @@ -9,7 +9,8 @@ "data": { "host": "H\u00f4te", "name": "Nom" - } + }, + "description": "Configurez P1 Monitor pour l'int\u00e9grer \u00e0 Home Assistant." } } } diff --git a/homeassistant/components/sensor/translations/fr.json b/homeassistant/components/sensor/translations/fr.json index 1aeee79ddeb..d25cb4766cb 100644 --- a/homeassistant/components/sensor/translations/fr.json +++ b/homeassistant/components/sensor/translations/fr.json @@ -14,6 +14,7 @@ "is_signal_strength": "Force du signal de {entity_name}", "is_temperature": "Temp\u00e9rature de {entity_name}", "is_value": "La valeur actuelle de {entity_name}", + "is_volatile_organic_compounds": "Niveau actuel de concentration en compos\u00e9s organiques volatils de {entity_name}", "is_voltage": "Tension actuelle pour {entity_name}" }, "trigger_type": { @@ -30,6 +31,7 @@ "signal_strength": "{entity_name} modification de la force du signal", "temperature": "{entity_name} modification de temp\u00e9rature", "value": "Changements de valeur de {entity_name}", + "volatile_organic_compounds": "{entity_name} changements de concentration de compos\u00e9s organiques volatils", "voltage": "{entity_name} changement de tension" } }, diff --git a/homeassistant/components/synology_dsm/translations/fr.json b/homeassistant/components/synology_dsm/translations/fr.json index 41ce8fdb788..449752106c7 100644 --- a/homeassistant/components/synology_dsm/translations/fr.json +++ b/homeassistant/components/synology_dsm/translations/fr.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", - "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi", + "reconfigure_successful": "La reconfiguration a r\u00e9ussi" }, "error": { "cannot_connect": "\u00c9chec de connexion", diff --git a/homeassistant/components/synology_dsm/translations/nl.json b/homeassistant/components/synology_dsm/translations/nl.json index 6ce9f1b63b9..d33ed48ce10 100644 --- a/homeassistant/components/synology_dsm/translations/nl.json +++ b/homeassistant/components/synology_dsm/translations/nl.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "Apparaat is al geconfigureerd", - "reauth_successful": "Herauthenticatie was succesvol" + "reauth_successful": "Herauthenticatie was succesvol", + "reconfigure_successful": "Herconfiguratie was succesvol" }, "error": { "cannot_connect": "Kan geen verbinding maken", diff --git a/homeassistant/components/synology_dsm/translations/no.json b/homeassistant/components/synology_dsm/translations/no.json index d1e2d084f0d..6ba7531cfd8 100644 --- a/homeassistant/components/synology_dsm/translations/no.json +++ b/homeassistant/components/synology_dsm/translations/no.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "Enheten er allerede konfigurert", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket", + "reconfigure_successful": "Omkonfigurasjonen var vellykket" }, "error": { "cannot_connect": "Tilkobling mislyktes", diff --git a/homeassistant/components/synology_dsm/translations/pl.json b/homeassistant/components/synology_dsm/translations/pl.json index 2979aa2c416..9150333a171 100644 --- a/homeassistant/components/synology_dsm/translations/pl.json +++ b/homeassistant/components/synology_dsm/translations/pl.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", - "reauth_successful": "Ponowne uwierzytelnienie powiod\u0142o si\u0119" + "reauth_successful": "Ponowne uwierzytelnienie powiod\u0142o si\u0119", + "reconfigure_successful": "Ponowna konfiguracja powiod\u0142a si\u0119" }, "error": { "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", diff --git a/homeassistant/components/zha/translations/fr.json b/homeassistant/components/zha/translations/fr.json index 1acb42a169b..c8b930a14d5 100644 --- a/homeassistant/components/zha/translations/fr.json +++ b/homeassistant/components/zha/translations/fr.json @@ -2,13 +2,17 @@ "config": { "abort": { "not_zha_device": "Cet appareil n'est pas un appareil zha", - "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." + "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible.", + "usb_probe_failed": "\u00c9chec de la v\u00e9rification du p\u00e9riph\u00e9rique USB" }, "error": { "cannot_connect": "\u00c9chec de connexion" }, "flow_title": "ZHA: {name}", "step": { + "confirm": { + "description": "Voulez-vous configurer {name} ?" + }, "pick_radio": { "data": { "radio_type": "Type de radio" diff --git a/homeassistant/components/zwave_js/translations/fr.json b/homeassistant/components/zwave_js/translations/fr.json index d898e3e33ac..bca57b9da68 100644 --- a/homeassistant/components/zwave_js/translations/fr.json +++ b/homeassistant/components/zwave_js/translations/fr.json @@ -8,7 +8,9 @@ "addon_start_failed": "\u00c9chec du d\u00e9marrage du module compl\u00e9mentaire Z-Wave JS.", "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", - "cannot_connect": "\u00c9chec de connexion" + "cannot_connect": "\u00c9chec de connexion", + "discovery_requires_supervisor": "La d\u00e9couverte n\u00e9cessite le superviseur.", + "not_zwave_device": "L'appareil d\u00e9couvert n'est pas un appareil Z-Wave." }, "error": { "addon_start_failed": "\u00c9chec du d\u00e9marrage du module compl\u00e9mentaire Z-Wave JS. V\u00e9rifiez la configuration.", @@ -67,7 +69,9 @@ "event.value_notification.basic": "\u00c9v\u00e9nement CC de base sur {subtype}", "event.value_notification.central_scene": "Action de la sc\u00e8ne centrale sur {subtype}", "event.value_notification.scene_activation": "Activation de la sc\u00e8ne sur {sous-type}", - "state.node_status": "Changement de statut du noeud" + "state.node_status": "Changement de statut du noeud", + "zwave_js.value_updated.config_parameter": "Changement de valeur sur le param\u00e8tre de configuration {subtype}", + "zwave_js.value_updated.value": "Changement de valeur sur une valeur Z-Wave JS" } }, "options": { From dd21bf73fc5af18e0d2302f8015f5dc797ac6669 Mon Sep 17 00:00:00 2001 From: Matthew Garrett Date: Mon, 30 Aug 2021 20:33:06 -0700 Subject: [PATCH 117/843] Assistant sensors (#55480) --- .../components/google_assistant/const.py | 1 + .../components/google_assistant/trait.py | 59 +++++++++++++++++++ .../components/google_assistant/test_trait.py | 53 +++++++++++++++++ 3 files changed, 113 insertions(+) diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index 2e43e20f124..d23560b85c1 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -133,6 +133,7 @@ DOMAIN_TO_GOOGLE_TYPES = { media_player.DOMAIN: TYPE_SETTOP, scene.DOMAIN: TYPE_SCENE, script.DOMAIN: TYPE_SCENE, + sensor.DOMAIN: TYPE_SENSOR, select.DOMAIN: TYPE_SENSOR, switch.DOMAIN: TYPE_SWITCH, vacuum.DOMAIN: TYPE_VACUUM, diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index dda8a04c2ed..d1ed328703e 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -108,6 +108,7 @@ TRAIT_MEDIA_STATE = f"{PREFIX_TRAITS}MediaState" TRAIT_CHANNEL = f"{PREFIX_TRAITS}Channel" TRAIT_LOCATOR = f"{PREFIX_TRAITS}Locator" TRAIT_ENERGYSTORAGE = f"{PREFIX_TRAITS}EnergyStorage" +TRAIT_SENSOR_STATE = f"{PREFIX_TRAITS}SensorState" PREFIX_COMMANDS = "action.devices.commands." COMMAND_ONOFF = f"{PREFIX_COMMANDS}OnOff" @@ -2286,3 +2287,61 @@ class ChannelTrait(_Trait): blocking=True, context=data.context, ) + + +@register_trait +class SensorStateTrait(_Trait): + """Trait to get sensor state. + + https://developers.google.com/actions/smarthome/traits/sensorstate + """ + + sensor_types = { + sensor.DEVICE_CLASS_AQI: ("AirQuality", "AQI"), + sensor.DEVICE_CLASS_CO: ("CarbonDioxideLevel", "PARTS_PER_MILLION"), + sensor.DEVICE_CLASS_CO2: ("CarbonMonoxideLevel", "PARTS_PER_MILLION"), + sensor.DEVICE_CLASS_PM25: ("PM2.5", "MICROGRAMS_PER_CUBIC_METER"), + sensor.DEVICE_CLASS_PM10: ("PM10", "MICROGRAMS_PER_CUBIC_METER"), + sensor.DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS: ( + "VolatileOrganicCompounds", + "PARTS_PER_MILLION", + ), + } + + name = TRAIT_SENSOR_STATE + commands = [] + + @staticmethod + def supported(domain, features, device_class, _): + """Test if state is supported.""" + return domain == sensor.DOMAIN and device_class in ( + sensor.DEVICE_CLASS_AQI, + sensor.DEVICE_CLASS_CO, + sensor.DEVICE_CLASS_CO2, + sensor.DEVICE_CLASS_PM25, + sensor.DEVICE_CLASS_PM10, + sensor.DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, + ) + + def sync_attributes(self): + """Return attributes for a sync request.""" + device_class = self.state.attributes.get(ATTR_DEVICE_CLASS) + data = self.sensor_types.get(device_class) + if data is not None: + return { + "sensorStatesSupported": { + "name": data[0], + "numericCapabilities": {"rawValueUnit": data[1]}, + } + } + + def query_attributes(self): + """Return the attributes of this trait for this entity.""" + device_class = self.state.attributes.get(ATTR_DEVICE_CLASS) + data = self.sensor_types.get(device_class) + if data is not None: + return { + "currentSensorStateData": [ + {"name": data[0], "rawValue": self.state.state} + ] + } diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index 50006060f51..290aa00bb47 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -3003,3 +3003,56 @@ async def test_channel(hass): with pytest.raises(SmartHomeError, match="Unsupported command"): await trt.execute("Unknown command", BASIC_DATA, {"channelNumber": "1"}, {}) assert len(media_player_calls) == 1 + + +async def test_sensorstate(hass): + """Test SensorState trait support for sensor domain.""" + sensor_types = { + sensor.DEVICE_CLASS_AQI: ("AirQuality", "AQI"), + sensor.DEVICE_CLASS_CO: ("CarbonDioxideLevel", "PARTS_PER_MILLION"), + sensor.DEVICE_CLASS_CO2: ("CarbonMonoxideLevel", "PARTS_PER_MILLION"), + sensor.DEVICE_CLASS_PM25: ("PM2.5", "MICROGRAMS_PER_CUBIC_METER"), + sensor.DEVICE_CLASS_PM10: ("PM10", "MICROGRAMS_PER_CUBIC_METER"), + sensor.DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS: ( + "VolatileOrganicCompounds", + "PARTS_PER_MILLION", + ), + } + + for sensor_type in sensor_types: + assert helpers.get_google_type(sensor.DOMAIN, None) is not None + assert trait.SensorStateTrait.supported(sensor.DOMAIN, None, sensor_type, None) + + trt = trait.SensorStateTrait( + hass, + State( + "sensor.test", + 100.0, + { + "device_class": sensor_type, + }, + ), + BASIC_CONFIG, + ) + + name = sensor_types[sensor_type][0] + unit = sensor_types[sensor_type][1] + + assert trt.sync_attributes() == { + "sensorStatesSupported": { + "name": name, + "numericCapabilities": {"rawValueUnit": unit}, + } + } + + assert trt.query_attributes() == { + "currentSensorStateData": [{"name": name, "rawValue": "100.0"}] + } + + assert helpers.get_google_type(sensor.DOMAIN, None) is not None + assert ( + trait.SensorStateTrait.supported( + sensor.DOMAIN, None, sensor.DEVICE_CLASS_MONETARY, None + ) + is False + ) From 13b001cd9b47e69518b942a8ad543ae48c824378 Mon Sep 17 00:00:00 2001 From: Feliksas The Lion <3208508+Feliksas@users.noreply.github.com> Date: Tue, 31 Aug 2021 06:33:52 +0300 Subject: [PATCH 118/843] Fix Zone 2 and Zone 3 detection in onkyo (#55471) --- homeassistant/components/onkyo/media_player.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/onkyo/media_player.py b/homeassistant/components/onkyo/media_player.py index ef20c1054f3..614612ecc27 100644 --- a/homeassistant/components/onkyo/media_player.py +++ b/homeassistant/components/onkyo/media_player.py @@ -145,16 +145,22 @@ def determine_zones(receiver): out = {"zone2": False, "zone3": False} try: _LOGGER.debug("Checking for zone 2 capability") - receiver.raw("ZPWQSTN") - out["zone2"] = True + response = receiver.raw("ZPWQSTN") + if response != "ZPWN/A": # Zone 2 Available + out["zone2"] = True + else: + _LOGGER.debug("Zone 2 not available") except ValueError as error: if str(error) != TIMEOUT_MESSAGE: raise error _LOGGER.debug("Zone 2 timed out, assuming no functionality") try: _LOGGER.debug("Checking for zone 3 capability") - receiver.raw("PW3QSTN") - out["zone3"] = True + response = receiver.raw("PW3QSTN") + if response != "PW3N/A": + out["zone3"] = True + else: + _LOGGER.debug("Zone 3 not available") except ValueError as error: if str(error) != TIMEOUT_MESSAGE: raise error From d277e0fb03a55d2bb733d4bed8b85a023d87e225 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 30 Aug 2021 23:45:35 -0700 Subject: [PATCH 119/843] Add Eagle 200 name back (#55477) * Add Eagle 200 name back * add comment * update tests --- homeassistant/components/rainforest_eagle/sensor.py | 7 ++++--- tests/components/rainforest_eagle/test_sensor.py | 12 ++++++------ 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/rainforest_eagle/sensor.py b/homeassistant/components/rainforest_eagle/sensor.py index 4b24a3abdaa..6f6b496cfca 100644 --- a/homeassistant/components/rainforest_eagle/sensor.py +++ b/homeassistant/components/rainforest_eagle/sensor.py @@ -38,21 +38,22 @@ _LOGGER = logging.getLogger(__name__) SENSORS = ( SensorEntityDescription( key="zigbee:InstantaneousDemand", - name="Meter Power Demand", + # We can drop the "Eagle-200" part of the name in HA 2021.12 + name="Eagle-200 Meter Power Demand", native_unit_of_measurement=POWER_KILO_WATT, device_class=DEVICE_CLASS_POWER, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key="zigbee:CurrentSummationDelivered", - name="Total Meter Energy Delivered", + name="Eagle-200 Total Meter Energy Delivered", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, state_class=STATE_CLASS_TOTAL_INCREASING, ), SensorEntityDescription( key="zigbee:CurrentSummationReceived", - name="Total Meter Energy Received", + name="Eagle-200 Total Meter Energy Received", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, state_class=STATE_CLASS_TOTAL_INCREASING, diff --git a/tests/components/rainforest_eagle/test_sensor.py b/tests/components/rainforest_eagle/test_sensor.py index a090c6dc318..e895f2ac4fc 100644 --- a/tests/components/rainforest_eagle/test_sensor.py +++ b/tests/components/rainforest_eagle/test_sensor.py @@ -114,17 +114,17 @@ async def test_sensors_200(hass, setup_rainforest_200): """Test the sensors.""" assert len(hass.states.async_all()) == 3 - demand = hass.states.get("sensor.meter_power_demand") + demand = hass.states.get("sensor.eagle_200_meter_power_demand") assert demand is not None assert demand.state == "1.152000" assert demand.attributes["unit_of_measurement"] == "kW" - delivered = hass.states.get("sensor.total_meter_energy_delivered") + delivered = hass.states.get("sensor.eagle_200_total_meter_energy_delivered") assert delivered is not None assert delivered.state == "45251.285000" assert delivered.attributes["unit_of_measurement"] == "kWh" - received = hass.states.get("sensor.total_meter_energy_received") + received = hass.states.get("sensor.eagle_200_total_meter_energy_received") assert received is not None assert received.state == "232.232000" assert received.attributes["unit_of_measurement"] == "kWh" @@ -147,17 +147,17 @@ async def test_sensors_100(hass, setup_rainforest_100): """Test the sensors.""" assert len(hass.states.async_all()) == 3 - demand = hass.states.get("sensor.meter_power_demand") + demand = hass.states.get("sensor.eagle_200_meter_power_demand") assert demand is not None assert demand.state == "1.152000" assert demand.attributes["unit_of_measurement"] == "kW" - delivered = hass.states.get("sensor.total_meter_energy_delivered") + delivered = hass.states.get("sensor.eagle_200_total_meter_energy_delivered") assert delivered is not None assert delivered.state == "45251.285000" assert delivered.attributes["unit_of_measurement"] == "kWh" - received = hass.states.get("sensor.total_meter_energy_received") + received = hass.states.get("sensor.eagle_200_total_meter_energy_received") assert received is not None assert received.state == "232.232000" assert received.attributes["unit_of_measurement"] == "kWh" From 88a08fdf57df12913071a26ace852588f2c716b8 Mon Sep 17 00:00:00 2001 From: Eric Severance Date: Tue, 31 Aug 2021 00:32:26 -0700 Subject: [PATCH 120/843] Wemo Insight devices need polling when off (#55348) --- homeassistant/components/wemo/wemo_device.py | 21 ++++++-- tests/components/wemo/test_wemo_device.py | 56 +++++++++++++++++++- 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/wemo/wemo_device.py b/homeassistant/components/wemo/wemo_device.py index 9423d0b8d1c..1690d30e082 100644 --- a/homeassistant/components/wemo/wemo_device.py +++ b/homeassistant/components/wemo/wemo_device.py @@ -3,7 +3,7 @@ import asyncio from datetime import timedelta import logging -from pywemo import WeMoDevice +from pywemo import Insight, WeMoDevice from pywemo.exceptions import ActionException from pywemo.subscribe import EVENT_TYPE_LONG_PRESS @@ -81,11 +81,26 @@ class DeviceCoordinator(DataUpdateCoordinator): else: self.async_set_updated_data(None) + @property + def should_poll(self) -> bool: + """Return True if polling is needed to update the state for the device. + + The alternative, when this returns False, is to rely on the subscription + "push updates" to update the device state in Home Assistant. + """ + if isinstance(self.wemo, Insight) and self.wemo.get_state() == 0: + # The WeMo Insight device does not send subscription updates for the + # insight_params values when the device is off. Polling is required in + # this case so the Sensor entities are properly populated. + return True + + registry = self.hass.data[DOMAIN]["registry"] + return not (registry.is_subscribed(self.wemo) and self.last_update_success) + async def _async_update_data(self) -> None: """Update WeMo state.""" # No need to poll if the device will push updates. - registry = self.hass.data[DOMAIN]["registry"] - if registry.is_subscribed(self.wemo) and self.last_update_success: + if not self.should_poll: return # If an update is in progress, we don't do anything. diff --git a/tests/components/wemo/test_wemo_device.py b/tests/components/wemo/test_wemo_device.py index 6f3cc12a81a..e756e816a47 100644 --- a/tests/components/wemo/test_wemo_device.py +++ b/tests/components/wemo/test_wemo_device.py @@ -1,6 +1,7 @@ """Tests for wemo_device.py.""" import asyncio -from unittest.mock import patch +from datetime import timedelta +from unittest.mock import call, patch import async_timeout import pytest @@ -14,9 +15,12 @@ from homeassistant.core import callback from homeassistant.helpers import device_registry from homeassistant.helpers.update_coordinator import UpdateFailed from homeassistant.setup import async_setup_component +from homeassistant.util.dt import utcnow from .conftest import MOCK_HOST +from tests.common import async_fire_time_changed + asyncio.set_event_loop_policy(runner.HassEventLoopPolicy(True)) @@ -148,3 +152,53 @@ async def test_async_update_data_subscribed( pywemo_device.get_state.reset_mock() await device._async_update_data() pywemo_device.get_state.assert_not_called() + + +class TestInsight: + """Tests specific to the WeMo Insight device.""" + + @pytest.fixture + def pywemo_model(self): + """Pywemo Dimmer models use the light platform (WemoDimmer class).""" + return "Insight" + + @pytest.fixture(name="pywemo_device") + def pywemo_device_fixture(self, pywemo_device): + """Fixture for WeMoDevice instances.""" + pywemo_device.insight_params = { + "currentpower": 1.0, + "todaymw": 200000000.0, + "state": 0, + "onfor": 0, + "ontoday": 0, + "ontotal": 0, + "powerthreshold": 0, + } + yield pywemo_device + + @pytest.mark.parametrize( + "subscribed,state,expected_calls", + [ + (False, 0, [call(), call(True), call(), call()]), + (False, 1, [call(), call(True), call(), call()]), + (True, 0, [call(), call(True), call(), call()]), + (True, 1, [call(), call(), call()]), + ], + ) + async def test_should_poll( + self, + hass, + subscribed, + state, + expected_calls, + wemo_entity, + pywemo_device, + pywemo_registry, + ): + """Validate the should_poll returns the correct value.""" + pywemo_registry.is_subscribed.return_value = subscribed + pywemo_device.get_state.reset_mock() + pywemo_device.get_state.return_value = state + async_fire_time_changed(hass, utcnow() + timedelta(seconds=31)) + await hass.async_block_till_done() + pywemo_device.get_state.assert_has_calls(expected_calls) From f9225bad5fd2395afe036113adbb3c8b8ee9ccdf Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 31 Aug 2021 10:45:17 +0200 Subject: [PATCH 121/843] Make new cycles for sensor sum statistics start with 0 as zero-point (#55473) --- homeassistant/components/sensor/recorder.py | 7 ++----- tests/components/sensor/test_recorder.py | 20 ++++++++++---------- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 6ab75f88dbd..cc1b6865d81 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -403,11 +403,8 @@ def compile_statistics( # ..and update the starting point new_state = fstate old_last_reset = last_reset - # Force a new cycle for STATE_CLASS_TOTAL_INCREASING to start at 0 - if ( - state_class == STATE_CLASS_TOTAL_INCREASING - and old_state is not None - ): + # Force a new cycle for an existing sensor to start at 0 + if old_state is not None: old_state = 0.0 else: old_state = new_state diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 6c4c899eb14..b3f0ab075c6 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -258,7 +258,7 @@ def test_compile_hourly_sum_statistics_amount( "min": None, "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(factor * seq[5]), - "sum": approx(factor * 10.0), + "sum": approx(factor * 40.0), }, { "statistic_id": "sensor.test1", @@ -268,7 +268,7 @@ def test_compile_hourly_sum_statistics_amount( "min": None, "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(factor * seq[8]), - "sum": approx(factor * 40.0), + "sum": approx(factor * 70.0), }, ] } @@ -512,7 +512,7 @@ def test_compile_hourly_energy_statistics_unsupported(hass_recorder, caplog): "min": None, "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(40.0), - "sum": approx(10.0), + "sum": approx(40.0), }, { "statistic_id": "sensor.test1", @@ -522,7 +522,7 @@ def test_compile_hourly_energy_statistics_unsupported(hass_recorder, caplog): "min": None, "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(70.0), - "sum": approx(40.0), + "sum": approx(70.0), }, ] } @@ -595,7 +595,7 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "min": None, "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(40.0), - "sum": approx(10.0), + "sum": approx(40.0), }, { "statistic_id": "sensor.test1", @@ -605,7 +605,7 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "min": None, "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(70.0), - "sum": approx(40.0), + "sum": approx(70.0), }, ], "sensor.test2": [ @@ -627,7 +627,7 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "min": None, "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(45.0), - "sum": approx(-95.0), + "sum": approx(-65.0), }, { "statistic_id": "sensor.test2", @@ -637,7 +637,7 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "min": None, "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(75.0), - "sum": approx(-65.0), + "sum": approx(-35.0), }, ], "sensor.test3": [ @@ -659,7 +659,7 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "min": None, "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(50.0 / 1000), - "sum": approx(30.0 / 1000), + "sum": approx(60.0 / 1000), }, { "statistic_id": "sensor.test3", @@ -669,7 +669,7 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "min": None, "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(90.0 / 1000), - "sum": approx(70.0 / 1000), + "sum": approx(100.0 / 1000), }, ], } From 1849eae0ffc77ebefa01fd0a252e294be21deb66 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 31 Aug 2021 11:06:54 +0200 Subject: [PATCH 122/843] Renault code quality improvements (#55454) --- .../components/renault/renault_coordinator.py | 3 +- .../components/renault/renault_entities.py | 7 +- .../components/renault/renault_vehicle.py | 107 +++++++++--------- homeassistant/components/renault/sensor.py | 5 +- 4 files changed, 58 insertions(+), 64 deletions(-) diff --git a/homeassistant/components/renault/renault_coordinator.py b/homeassistant/components/renault/renault_coordinator.py index b47a8507030..64e414a9ab7 100644 --- a/homeassistant/components/renault/renault_coordinator.py +++ b/homeassistant/components/renault/renault_coordinator.py @@ -11,11 +11,12 @@ from renault_api.kamereon.exceptions import ( KamereonResponseException, NotSupportedException, ) +from renault_api.kamereon.models import KamereonVehicleDataAttributes from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -T = TypeVar("T") +T = TypeVar("T", bound=KamereonVehicleDataAttributes) class RenaultDataUpdateCoordinator(DataUpdateCoordinator[T]): diff --git a/homeassistant/components/renault/renault_entities.py b/homeassistant/components/renault/renault_entities.py index 003103d52d3..29d1aa4b860 100644 --- a/homeassistant/components/renault/renault_entities.py +++ b/homeassistant/components/renault/renault_entities.py @@ -3,14 +3,13 @@ from __future__ import annotations from collections.abc import Mapping from dataclasses import dataclass -from typing import Any, Optional, TypeVar, cast - -from renault_api.kamereon.models import KamereonVehicleDataAttributes +from typing import Any, Optional, cast from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity +from .renault_coordinator import T from .renault_vehicle import RenaultVehicleProxy @@ -28,8 +27,6 @@ class RenaultEntityDescription(EntityDescription, RenaultRequiredKeysMixin): ATTR_LAST_UPDATE = "last_update" -T = TypeVar("T", bound=KamereonVehicleDataAttributes) - class RenaultDataEntity(CoordinatorEntity[Optional[T]], Entity): """Implementation of a Renault entity with a data coordinator.""" diff --git a/homeassistant/components/renault/renault_vehicle.py b/homeassistant/components/renault/renault_vehicle.py index 8d4cfea53ee..c955e5bfa65 100644 --- a/homeassistant/components/renault/renault_vehicle.py +++ b/homeassistant/components/renault/renault_vehicle.py @@ -2,9 +2,11 @@ from __future__ import annotations import asyncio +from collections.abc import Awaitable +from dataclasses import dataclass from datetime import timedelta import logging -from typing import cast +from typing import Callable, cast from renault_api.kamereon import models from renault_api.renault_vehicle import RenaultVehicle @@ -25,6 +27,20 @@ from .renault_coordinator import RenaultDataUpdateCoordinator LOGGER = logging.getLogger(__name__) +@dataclass +class RenaultCoordinatorDescription: + """Class describing Renault coordinators.""" + + endpoint: str + key: str + update_method: Callable[ + [RenaultVehicle], + Callable[[], Awaitable[models.KamereonVehicleDataAttributes]], + ] + # Optional keys + requires_electricity: bool = False + + class RenaultVehicleProxy: """Handle vehicle communication with Renault servers.""" @@ -61,48 +77,23 @@ class RenaultVehicleProxy: return self._device_info async def async_initialise(self) -> None: - """Load available sensors.""" - if await self.endpoint_available("cockpit"): - self.coordinators["cockpit"] = RenaultDataUpdateCoordinator( + """Load available coordinators.""" + self.coordinators = { + coord.key: RenaultDataUpdateCoordinator( self.hass, LOGGER, # Name of the data. For logging purposes. - name=f"{self.details.vin} cockpit", - update_method=self.get_cockpit, + name=f"{self.details.vin} {coord.key}", + update_method=coord.update_method(self._vehicle), # Polling interval. Will only be polled if there are subscribers. update_interval=self._scan_interval, ) - if await self.endpoint_available("hvac-status"): - self.coordinators["hvac_status"] = RenaultDataUpdateCoordinator( - self.hass, - LOGGER, - # Name of the data. For logging purposes. - name=f"{self.details.vin} hvac_status", - update_method=self.get_hvac_status, - # Polling interval. Will only be polled if there are subscribers. - update_interval=self._scan_interval, + for coord in COORDINATORS + if ( + self.details.supports_endpoint(coord.endpoint) + and (not coord.requires_electricity or self.details.uses_electricity()) ) - if self.details.uses_electricity(): - if await self.endpoint_available("battery-status"): - self.coordinators["battery"] = RenaultDataUpdateCoordinator( - self.hass, - LOGGER, - # Name of the data. For logging purposes. - name=f"{self.details.vin} battery", - update_method=self.get_battery_status, - # Polling interval. Will only be polled if there are subscribers. - update_interval=self._scan_interval, - ) - if await self.endpoint_available("charge-mode"): - self.coordinators["charge_mode"] = RenaultDataUpdateCoordinator( - self.hass, - LOGGER, - # Name of the data. For logging purposes. - name=f"{self.details.vin} charge_mode", - update_method=self.get_charge_mode, - # Polling interval. Will only be polled if there are subscribers. - update_interval=self._scan_interval, - ) + } # Check all coordinators await asyncio.gather( *( @@ -130,24 +121,28 @@ class RenaultVehicleProxy: ) del self.coordinators[key] - async def endpoint_available(self, endpoint: str) -> bool: - """Ensure the endpoint is available to avoid unnecessary queries.""" - return await self._vehicle.supports_endpoint( - endpoint - ) and await self._vehicle.has_contract_for_endpoint(endpoint) - async def get_battery_status(self) -> models.KamereonVehicleBatteryStatusData: - """Get battery status information from vehicle.""" - return await self._vehicle.get_battery_status() - - async def get_charge_mode(self) -> models.KamereonVehicleChargeModeData: - """Get charge mode information from vehicle.""" - return await self._vehicle.get_charge_mode() - - async def get_cockpit(self) -> models.KamereonVehicleCockpitData: - """Get cockpit information from vehicle.""" - return await self._vehicle.get_cockpit() - - async def get_hvac_status(self) -> models.KamereonVehicleHvacStatusData: - """Get hvac status information from vehicle.""" - return await self._vehicle.get_hvac_status() +COORDINATORS: tuple[RenaultCoordinatorDescription, ...] = ( + RenaultCoordinatorDescription( + endpoint="cockpit", + key="cockpit", + update_method=lambda x: x.get_cockpit, + ), + RenaultCoordinatorDescription( + endpoint="hvac-status", + key="hvac_status", + update_method=lambda x: x.get_hvac_status, + ), + RenaultCoordinatorDescription( + endpoint="battery-status", + key="battery", + requires_electricity=True, + update_method=lambda x: x.get_battery_status, + ), + RenaultCoordinatorDescription( + endpoint="charge-mode", + key="charge_mode", + requires_electricity=True, + update_method=lambda x: x.get_charge_mode, + ), +) diff --git a/homeassistant/components/renault/sensor.py b/homeassistant/components/renault/sensor.py index 537d708f391..62903702df0 100644 --- a/homeassistant/components/renault/sensor.py +++ b/homeassistant/components/renault/sensor.py @@ -42,7 +42,8 @@ from .const import ( DEVICE_CLASS_PLUG_STATE, DOMAIN, ) -from .renault_entities import RenaultDataEntity, RenaultEntityDescription, T +from .renault_coordinator import T +from .renault_entities import RenaultDataEntity, RenaultEntityDescription from .renault_hub import RenaultHub @@ -61,7 +62,7 @@ class RenaultSensorEntityDescription( """Class describing Renault sensor entities.""" icon_lambda: Callable[[RenaultSensor[T]], str] | None = None - requires_fuel: bool | None = None + requires_fuel: bool = False value_lambda: Callable[[RenaultSensor[T]], StateType] | None = None From afc0a1f3769ccd84998ef8a8f26c668eef0a1fb3 Mon Sep 17 00:00:00 2001 From: JasperPlant <78851352+JasperPlant@users.noreply.github.com> Date: Tue, 31 Aug 2021 11:55:23 +0200 Subject: [PATCH 123/843] Add TLX daily power meter. for Growatt (#55445) --- .../components/growatt_server/sensor.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/homeassistant/components/growatt_server/sensor.py b/homeassistant/components/growatt_server/sensor.py index 03da4fe4b57..894d096fcd0 100644 --- a/homeassistant/components/growatt_server/sensor.py +++ b/homeassistant/components/growatt_server/sensor.py @@ -285,6 +285,15 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( state_class=STATE_CLASS_TOTAL_INCREASING, precision=1, ), + GrowattSensorEntityDescription( + key="tlx_energy_today_input_1", + name="Energy Today Input 1", + api_key="epv1Today", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + precision=1, + ), GrowattSensorEntityDescription( key="tlx_voltage_input_1", name="Input 1 voltage", @@ -318,6 +327,15 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( state_class=STATE_CLASS_TOTAL_INCREASING, precision=1, ), + GrowattSensorEntityDescription( + key="tlx_energy_today_input_2", + name="Energy Today Input 2", + api_key="epv2Today", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + precision=1, + ), GrowattSensorEntityDescription( key="tlx_voltage_input_2", name="Input 2 voltage", From 3e38dc0fd90c8e74d21f4d160fb87510100b1d3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Tue, 31 Aug 2021 14:45:28 +0200 Subject: [PATCH 124/843] Add cache-control headers to supervisor entrypoint (#55493) --- homeassistant/components/hassio/http.py | 10 ++++++++-- tests/components/hassio/test_http.py | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index 4a0def62b4d..fe01cbe3197 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -10,6 +10,7 @@ import aiohttp from aiohttp import web from aiohttp.client import ClientTimeout from aiohttp.hdrs import ( + CACHE_CONTROL, CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE, @@ -51,6 +52,8 @@ NO_AUTH = re.compile( r"^(?:" r"|app/.*" r"|addons/[^/]+/logo" r"|addons/[^/]+/icon" r")$" ) +NO_STORE = re.compile(r"^(?:" r"|app/entrypoint.js" r")$") + class HassIOView(HomeAssistantView): """Hass.io view to handle base part.""" @@ -104,7 +107,7 @@ class HassIOView(HomeAssistantView): # Stream response response = web.StreamResponse( - status=client.status, headers=_response_header(client) + status=client.status, headers=_response_header(client, path) ) response.content_type = client.content_type @@ -139,7 +142,7 @@ def _init_header(request: web.Request) -> dict[str, str]: return headers -def _response_header(response: aiohttp.ClientResponse) -> dict[str, str]: +def _response_header(response: aiohttp.ClientResponse, path: str) -> dict[str, str]: """Create response header.""" headers = {} @@ -153,6 +156,9 @@ def _response_header(response: aiohttp.ClientResponse) -> dict[str, str]: continue headers[name] = value + if NO_STORE.match(path): + headers[CACHE_CONTROL] = "no-store, max-age=0" + return headers diff --git a/tests/components/hassio/test_http.py b/tests/components/hassio/test_http.py index f411b465774..16121393170 100644 --- a/tests/components/hassio/test_http.py +++ b/tests/components/hassio/test_http.py @@ -185,3 +185,21 @@ async def test_stream(hassio_client, aioclient_mock): aioclient_mock.get("http://127.0.0.1/test") await hassio_client.get("/api/hassio/test", data="test") assert isinstance(aioclient_mock.mock_calls[-1][2], StreamReader) + + +async def test_entrypoint_cache_control(hassio_client, aioclient_mock): + """Test that we return cache control for requests to the entrypoint only.""" + aioclient_mock.get("http://127.0.0.1/app/entrypoint.js") + aioclient_mock.get("http://127.0.0.1/app/entrypoint.fdhkusd8y43r.js") + + resp1 = await hassio_client.get("/api/hassio/app/entrypoint.js") + resp2 = await hassio_client.get("/api/hassio/app/entrypoint.fdhkusd8y43r.js") + + # Check we got right response + assert resp1.status == 200 + assert resp2.status == 200 + + assert len(aioclient_mock.mock_calls) == 2 + assert resp1.headers["Cache-Control"] == "no-store, max-age=0" + + assert "Cache-Control" not in resp2.headers From 4d98a7e1562e6e11746f34031fb0b2b86cf89506 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Tue, 31 Aug 2021 08:56:47 -0400 Subject: [PATCH 125/843] Allow device_id template function to use device name as input (#55474) --- homeassistant/helpers/template.py | 24 ++++++++++++++++-------- tests/helpers/test_template.py | 15 +++++++++------ 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index ade580694c8..4e9f9c432e0 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -914,15 +914,23 @@ def device_entities(hass: HomeAssistant, _device_id: str) -> Iterable[str]: return [entry.entity_id for entry in entries] -def device_id(hass: HomeAssistant, entity_id: str) -> str | None: - """Get a device ID from an entity ID.""" - if not isinstance(entity_id, str) or "." not in entity_id: - raise TemplateError(f"Must provide an entity ID, got {entity_id}") # type: ignore +def device_id(hass: HomeAssistant, entity_id_or_device_name: str) -> str | None: + """Get a device ID from an entity ID or device name.""" entity_reg = entity_registry.async_get(hass) - entity = entity_reg.async_get(entity_id) - if entity is None: - return None - return entity.device_id + entity = entity_reg.async_get(entity_id_or_device_name) + if entity is not None: + return entity.device_id + + dev_reg = device_registry.async_get(hass) + return next( + ( + id + for id, device in dev_reg.devices.items() + if (name := device.name_by_user or device.name) + and (str(entity_id_or_device_name) == name) + ), + None, + ) def device_attr(hass: HomeAssistant, device_or_entity_id: str, attr_name: str) -> Any: diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 64b075b685a..d10ef114992 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -1599,6 +1599,7 @@ async def test_device_id(hass): config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, model="test", + name="test", ) entity_entry = entity_registry.async_get_or_create( "sensor", "test", "test", suggested_object_id="test", device_id=device_entry.id @@ -1611,13 +1612,11 @@ async def test_device_id(hass): assert_result_info(info, None) assert info.rate_limit is None - with pytest.raises(TemplateError): - info = render_to_info(hass, "{{ 56 | device_id }}") - assert_result_info(info, None) + info = render_to_info(hass, "{{ 56 | device_id }}") + assert_result_info(info, None) - with pytest.raises(TemplateError): - info = render_to_info(hass, "{{ 'not_a_real_entity_id' | device_id }}") - assert_result_info(info, None) + info = render_to_info(hass, "{{ 'not_a_real_entity_id' | device_id }}") + assert_result_info(info, None) info = render_to_info( hass, f"{{{{ device_id('{entity_entry_no_device.entity_id}') }}}}" @@ -1629,6 +1628,10 @@ async def test_device_id(hass): assert_result_info(info, device_entry.id) assert info.rate_limit is None + info = render_to_info(hass, "{{ device_id('test') }}") + assert_result_info(info, device_entry.id) + assert info.rate_limit is None + async def test_device_attr(hass): """Test device_attr and is_device_attr functions.""" From 08a0377dcb241262e26387449d525b93f090e4da Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 31 Aug 2021 16:44:13 +0200 Subject: [PATCH 126/843] Add support for Xiaomi Miio Air Purifier 3C (#55484) --- .../components/xiaomi_miio/__init__.py | 4 ++ homeassistant/components/xiaomi_miio/const.py | 33 ++++++++---- homeassistant/components/xiaomi_miio/fan.py | 52 +++++++++++++++++-- .../components/xiaomi_miio/number.py | 42 +++++++++++++++ .../components/xiaomi_miio/select.py | 3 ++ .../components/xiaomi_miio/sensor.py | 11 +++- .../components/xiaomi_miio/switch.py | 3 ++ 7 files changed, 131 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/__init__.py b/homeassistant/components/xiaomi_miio/__init__.py index cde597432df..de28ae78701 100644 --- a/homeassistant/components/xiaomi_miio/__init__.py +++ b/homeassistant/components/xiaomi_miio/__init__.py @@ -9,6 +9,7 @@ from miio import ( AirHumidifierMiot, AirHumidifierMjjsq, AirPurifier, + AirPurifierMB4, AirPurifierMiot, DeviceException, Fan, @@ -31,6 +32,7 @@ from .const import ( DOMAIN, KEY_COORDINATOR, KEY_DEVICE, + MODEL_AIRPURIFIER_3C, MODEL_FAN_P5, MODELS_AIR_MONITOR, MODELS_FAN, @@ -139,6 +141,8 @@ async def async_create_miio_device_and_coordinator( device = AirHumidifier(host, token, model=model) migrate = True # Airpurifiers and Airfresh + elif model in MODEL_AIRPURIFIER_3C: + device = AirPurifierMB4(host, token) elif model in MODELS_PURIFIER_MIOT: device = AirPurifierMiot(host, token) elif model.startswith("zhimi.airpurifier."): diff --git a/homeassistant/components/xiaomi_miio/const.py b/homeassistant/components/xiaomi_miio/const.py index b670582c069..b63143c0f41 100644 --- a/homeassistant/components/xiaomi_miio/const.py +++ b/homeassistant/components/xiaomi_miio/const.py @@ -30,23 +30,24 @@ SERVER_COUNTRY_CODES = ["cn", "de", "i2", "ru", "sg", "us"] DEFAULT_CLOUD_COUNTRY = "cn" # Fan Models -MODEL_AIRPURIFIER_V1 = "zhimi.airpurifier.v1" -MODEL_AIRPURIFIER_V2 = "zhimi.airpurifier.v2" -MODEL_AIRPURIFIER_V3 = "zhimi.airpurifier.v3" -MODEL_AIRPURIFIER_V5 = "zhimi.airpurifier.v5" -MODEL_AIRPURIFIER_PRO = "zhimi.airpurifier.v6" -MODEL_AIRPURIFIER_PRO_V7 = "zhimi.airpurifier.v7" +MODEL_AIRPURIFIER_2H = "zhimi.airpurifier.mc2" +MODEL_AIRPURIFIER_2S = "zhimi.airpurifier.mc1" +MODEL_AIRPURIFIER_3 = "zhimi.airpurifier.ma4" +MODEL_AIRPURIFIER_3C = "zhimi.airpurifier.mb4" +MODEL_AIRPURIFIER_3H = "zhimi.airpurifier.mb3" MODEL_AIRPURIFIER_M1 = "zhimi.airpurifier.m1" MODEL_AIRPURIFIER_M2 = "zhimi.airpurifier.m2" MODEL_AIRPURIFIER_MA1 = "zhimi.airpurifier.ma1" MODEL_AIRPURIFIER_MA2 = "zhimi.airpurifier.ma2" +MODEL_AIRPURIFIER_PRO = "zhimi.airpurifier.v6" +MODEL_AIRPURIFIER_PROH = "zhimi.airpurifier.va1" +MODEL_AIRPURIFIER_PRO_V7 = "zhimi.airpurifier.v7" MODEL_AIRPURIFIER_SA1 = "zhimi.airpurifier.sa1" MODEL_AIRPURIFIER_SA2 = "zhimi.airpurifier.sa2" -MODEL_AIRPURIFIER_2S = "zhimi.airpurifier.mc1" -MODEL_AIRPURIFIER_2H = "zhimi.airpurifier.mc2" -MODEL_AIRPURIFIER_3 = "zhimi.airpurifier.ma4" -MODEL_AIRPURIFIER_3H = "zhimi.airpurifier.mb3" -MODEL_AIRPURIFIER_PROH = "zhimi.airpurifier.va1" +MODEL_AIRPURIFIER_V1 = "zhimi.airpurifier.v1" +MODEL_AIRPURIFIER_V2 = "zhimi.airpurifier.v2" +MODEL_AIRPURIFIER_V3 = "zhimi.airpurifier.v3" +MODEL_AIRPURIFIER_V5 = "zhimi.airpurifier.v5" MODEL_AIRHUMIDIFIER_V1 = "zhimi.humidifier.v1" MODEL_AIRHUMIDIFIER_CA1 = "zhimi.humidifier.ca1" @@ -78,6 +79,7 @@ MODELS_FAN_MIIO = [ MODELS_PURIFIER_MIOT = [ MODEL_AIRPURIFIER_3, + MODEL_AIRPURIFIER_3C, MODEL_AIRPURIFIER_3H, MODEL_AIRPURIFIER_PROH, ] @@ -229,6 +231,8 @@ FEATURE_SET_CLEAN = 16384 FEATURE_SET_OSCILLATION_ANGLE = 32768 FEATURE_SET_OSCILLATION_ANGLE_MAX_140 = 65536 FEATURE_SET_DELAY_OFF_COUNTDOWN = 131072 +FEATURE_SET_LED_BRIGHTNESS_LEVEL = 262144 +FEATURE_SET_FAVORITE_RPM = 524288 FEATURE_FLAGS_AIRPURIFIER_MIIO = ( FEATURE_SET_BUZZER @@ -248,6 +252,13 @@ FEATURE_FLAGS_AIRPURIFIER_MIOT = ( | FEATURE_SET_LED_BRIGHTNESS ) +FEATURE_FLAGS_AIRPURIFIER_3C = ( + FEATURE_SET_BUZZER + | FEATURE_SET_CHILD_LOCK + | FEATURE_SET_LED_BRIGHTNESS_LEVEL + | FEATURE_SET_FAVORITE_RPM +) + FEATURE_FLAGS_AIRPURIFIER_PRO = ( FEATURE_SET_CHILD_LOCK | FEATURE_SET_LED diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index 42828943d93..e62f7aa870c 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -34,6 +34,7 @@ from .const import ( DOMAIN, FEATURE_FLAGS_AIRFRESH, FEATURE_FLAGS_AIRPURIFIER_2S, + FEATURE_FLAGS_AIRPURIFIER_3C, FEATURE_FLAGS_AIRPURIFIER_MIIO, FEATURE_FLAGS_AIRPURIFIER_MIOT, FEATURE_FLAGS_AIRPURIFIER_PRO, @@ -47,6 +48,7 @@ from .const import ( KEY_DEVICE, MODEL_AIRPURIFIER_2H, MODEL_AIRPURIFIER_2S, + MODEL_AIRPURIFIER_3C, MODEL_AIRPURIFIER_PRO, MODEL_AIRPURIFIER_PRO_V7, MODEL_AIRPURIFIER_V3, @@ -193,7 +195,15 @@ async def async_setup_entry(hass, config_entry, async_add_entities): coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR] device = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] - if model in MODELS_PURIFIER_MIOT: + if model == MODEL_AIRPURIFIER_3C: + entity = XiaomiAirPurifierMB4( + name, + device, + config_entry, + unique_id, + coordinator, + ) + elif model in MODELS_PURIFIER_MIOT: entity = XiaomiAirPurifierMiot( name, device, @@ -567,11 +577,35 @@ class XiaomiAirPurifierMiot(XiaomiAirPurifier): self._fan_level = fan_level self.async_write_ha_state() - async def async_set_preset_mode(self, preset_mode: str) -> None: - """Set the preset mode of the fan. - This method is a coroutine. - """ +class XiaomiAirPurifierMB4(XiaomiGenericDevice): + """Representation of a Xiaomi Air Purifier MB4.""" + + PRESET_MODE_MAPPING = { + "Auto": AirpurifierMiotOperationMode.Auto, + "Silent": AirpurifierMiotOperationMode.Silent, + "Favorite": AirpurifierMiotOperationMode.Favorite, + } + + def __init__(self, name, device, entry, unique_id, coordinator): + """Initialize Air Purifier MB4.""" + super().__init__(name, device, entry, unique_id, coordinator) + + self._device_features = FEATURE_FLAGS_AIRPURIFIER_3C + self._preset_modes = list(self.PRESET_MODE_MAPPING) + self._supported_features = SUPPORT_PRESET_MODE + + @property + def preset_mode(self): + """Get the active preset mode.""" + if self.coordinator.data.is_on: + preset_mode = AirpurifierMiotOperationMode(self._mode).name + return preset_mode if preset_mode in self._preset_modes else None + + return None + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode of the fan.""" if preset_mode not in self.preset_modes: _LOGGER.warning("'%s'is not a valid preset mode", preset_mode) return @@ -583,6 +617,14 @@ class XiaomiAirPurifierMiot(XiaomiAirPurifier): self._mode = self.PRESET_MODE_MAPPING[preset_mode].value self.async_write_ha_state() + @callback + def _handle_coordinator_update(self): + """Fetch state from the device.""" + self._available = True + self._state = self.coordinator.data.is_on + self._mode = self.coordinator.data.mode.value + self.async_write_ha_state() + class XiaomiAirFresh(XiaomiGenericDevice): """Representation of a Xiaomi Air Fresh.""" diff --git a/homeassistant/components/xiaomi_miio/number.py b/homeassistant/components/xiaomi_miio/number.py index a31478df1f3..2547c33bbfa 100644 --- a/homeassistant/components/xiaomi_miio/number.py +++ b/homeassistant/components/xiaomi_miio/number.py @@ -17,6 +17,7 @@ from .const import ( FEATURE_FLAGS_AIRHUMIDIFIER_CA4, FEATURE_FLAGS_AIRHUMIDIFIER_CA_AND_CB, FEATURE_FLAGS_AIRPURIFIER_2S, + FEATURE_FLAGS_AIRPURIFIER_3C, FEATURE_FLAGS_AIRPURIFIER_MIIO, FEATURE_FLAGS_AIRPURIFIER_MIOT, FEATURE_FLAGS_AIRPURIFIER_PRO, @@ -28,6 +29,8 @@ from .const import ( FEATURE_SET_DELAY_OFF_COUNTDOWN, FEATURE_SET_FAN_LEVEL, FEATURE_SET_FAVORITE_LEVEL, + FEATURE_SET_FAVORITE_RPM, + FEATURE_SET_LED_BRIGHTNESS_LEVEL, FEATURE_SET_MOTOR_SPEED, FEATURE_SET_OSCILLATION_ANGLE, FEATURE_SET_OSCILLATION_ANGLE_MAX_140, @@ -39,6 +42,7 @@ from .const import ( MODEL_AIRHUMIDIFIER_CA4, MODEL_AIRHUMIDIFIER_CB1, MODEL_AIRPURIFIER_2S, + MODEL_AIRPURIFIER_3C, MODEL_AIRPURIFIER_PRO, MODEL_AIRPURIFIER_PRO_V7, MODEL_AIRPURIFIER_V1, @@ -58,6 +62,8 @@ from .device import XiaomiCoordinatedMiioEntity ATTR_DELAY_OFF_COUNTDOWN = "delay_off_countdown" ATTR_FAN_LEVEL = "fan_level" ATTR_FAVORITE_LEVEL = "favorite_level" +ATTR_FAVORITE_RPM = "favorite_rpm" +ATTR_LED_BRIGHTNESS_LEVEL = "led_brightness_level" ATTR_MOTOR_SPEED = "motor_speed" ATTR_OSCILLATION_ANGLE = "angle" ATTR_VOLUME = "volume" @@ -143,6 +149,25 @@ NUMBER_TYPES = { step=1, method="async_set_delay_off_countdown", ), + FEATURE_SET_LED_BRIGHTNESS_LEVEL: XiaomiMiioNumberDescription( + key=ATTR_LED_BRIGHTNESS_LEVEL, + name="Led Brightness", + icon="mdi:brightness-6", + min_value=0, + max_value=8, + step=1, + method="async_set_led_brightness_level", + ), + FEATURE_SET_FAVORITE_RPM: XiaomiMiioNumberDescription( + key=ATTR_FAVORITE_RPM, + name="Favorite Motor Speed", + icon="mdi:star-cog", + unit_of_measurement="rpm", + min_value=300, + max_value=2300, + step=10, + method="async_set_favorite_rpm", + ), } MODEL_TO_FEATURES_MAP = { @@ -151,6 +176,7 @@ MODEL_TO_FEATURES_MAP = { MODEL_AIRHUMIDIFIER_CA4: FEATURE_FLAGS_AIRHUMIDIFIER_CA4, MODEL_AIRHUMIDIFIER_CB1: FEATURE_FLAGS_AIRHUMIDIFIER_CA_AND_CB, MODEL_AIRPURIFIER_2S: FEATURE_FLAGS_AIRPURIFIER_2S, + MODEL_AIRPURIFIER_3C: FEATURE_FLAGS_AIRPURIFIER_3C, MODEL_AIRPURIFIER_PRO: FEATURE_FLAGS_AIRPURIFIER_PRO, MODEL_AIRPURIFIER_PRO_V7: FEATURE_FLAGS_AIRPURIFIER_PRO_V7, MODEL_AIRPURIFIER_V1: FEATURE_FLAGS_AIRPURIFIER_V1, @@ -293,3 +319,19 @@ class XiaomiNumberEntity(XiaomiCoordinatedMiioEntity, NumberEntity): self._device.delay_off, delay_off_countdown * 60, ) + + async def async_set_led_brightness_level(self, level: int): + """Set the led brightness level.""" + return await self._try_command( + "Setting the led brightness level of the miio device failed.", + self._device.set_led_brightness_level, + level, + ) + + async def async_set_favorite_rpm(self, rpm: int): + """Set the target motor speed.""" + return await self._try_command( + "Setting the favorite rpm of the miio device failed.", + self._device.set_favorite_rpm, + rpm, + ) diff --git a/homeassistant/components/xiaomi_miio/select.py b/homeassistant/components/xiaomi_miio/select.py index b43291dfeef..daa721fef95 100644 --- a/homeassistant/components/xiaomi_miio/select.py +++ b/homeassistant/components/xiaomi_miio/select.py @@ -23,6 +23,7 @@ from .const import ( KEY_COORDINATOR, KEY_DEVICE, MODEL_AIRFRESH_VA2, + MODEL_AIRPURIFIER_3C, MODEL_AIRPURIFIER_M1, MODEL_AIRPURIFIER_M2, MODEL_FAN_SA1, @@ -75,6 +76,8 @@ async def async_setup_entry(hass, config_entry, async_add_entities): model = config_entry.data[CONF_MODEL] device = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] + if model == MODEL_AIRPURIFIER_3C: + return if model in MODELS_HUMIDIFIER_MIIO: entity_class = XiaomiAirHumidifierSelector elif model in MODELS_HUMIDIFIER_MIOT: diff --git a/homeassistant/components/xiaomi_miio/sensor.py b/homeassistant/components/xiaomi_miio/sensor.py index 63535e88a2d..96bcdf9145d 100644 --- a/homeassistant/components/xiaomi_miio/sensor.py +++ b/homeassistant/components/xiaomi_miio/sensor.py @@ -33,6 +33,7 @@ from homeassistant.const import ( DEVICE_CLASS_GAS, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_PM25, DEVICE_CLASS_POWER, DEVICE_CLASS_PRESSURE, DEVICE_CLASS_TEMPERATURE, @@ -56,6 +57,7 @@ from .const import ( MODEL_AIRFRESH_VA2, MODEL_AIRHUMIDIFIER_CA1, MODEL_AIRHUMIDIFIER_CB1, + MODEL_AIRPURIFIER_3C, MODEL_AIRPURIFIER_PRO, MODEL_AIRPURIFIER_PRO_V7, MODEL_AIRPURIFIER_V2, @@ -194,7 +196,7 @@ SENSOR_TYPES = { key=ATTR_AQI, name="PM2.5", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - icon="mdi:blur", + device_class=DEVICE_CLASS_PM25, state_class=STATE_CLASS_MEASUREMENT, ), ATTR_FILTER_LIFE_REMAINING: XiaomiMiioSensorDescription( @@ -268,6 +270,12 @@ PURIFIER_MIOT_SENSORS = ( ATTR_PURIFY_VOLUME, ATTR_TEMPERATURE, ) +PURIFIER_3C_SENSORS = ( + ATTR_FILTER_LIFE_REMAINING, + ATTR_FILTER_USE, + ATTR_MOTOR_SPEED, + ATTR_PM25, +) PURIFIER_V2_SENSORS = ( ATTR_FILTER_LIFE_REMAINING, ATTR_FILTER_USE, @@ -326,6 +334,7 @@ MODEL_TO_SENSORS_MAP = { MODEL_AIRFRESH_VA2: AIRFRESH_SENSORS, MODEL_AIRHUMIDIFIER_CA1: HUMIDIFIER_CA1_CB1_SENSORS, MODEL_AIRHUMIDIFIER_CB1: HUMIDIFIER_CA1_CB1_SENSORS, + MODEL_AIRPURIFIER_3C: PURIFIER_3C_SENSORS, MODEL_AIRPURIFIER_PRO: PURIFIER_PRO_SENSORS, MODEL_AIRPURIFIER_PRO_V7: PURIFIER_PRO_V7_SENSORS, MODEL_AIRPURIFIER_V2: PURIFIER_V2_SENSORS, diff --git a/homeassistant/components/xiaomi_miio/switch.py b/homeassistant/components/xiaomi_miio/switch.py index c40711f5266..7859ff75ec6 100644 --- a/homeassistant/components/xiaomi_miio/switch.py +++ b/homeassistant/components/xiaomi_miio/switch.py @@ -38,6 +38,7 @@ from .const import ( FEATURE_FLAGS_AIRHUMIDIFIER_CA_AND_CB, FEATURE_FLAGS_AIRHUMIDIFIER_MJSSQ, FEATURE_FLAGS_AIRPURIFIER_2S, + FEATURE_FLAGS_AIRPURIFIER_3C, FEATURE_FLAGS_AIRPURIFIER_MIIO, FEATURE_FLAGS_AIRPURIFIER_MIOT, FEATURE_FLAGS_AIRPURIFIER_PRO, @@ -61,6 +62,7 @@ from .const import ( MODEL_AIRHUMIDIFIER_CB1, MODEL_AIRPURIFIER_2H, MODEL_AIRPURIFIER_2S, + MODEL_AIRPURIFIER_3C, MODEL_AIRPURIFIER_PRO, MODEL_AIRPURIFIER_PRO_V7, MODEL_AIRPURIFIER_V1, @@ -158,6 +160,7 @@ MODEL_TO_FEATURES_MAP = { MODEL_AIRHUMIDIFIER_CB1: FEATURE_FLAGS_AIRHUMIDIFIER_CA_AND_CB, MODEL_AIRPURIFIER_2H: FEATURE_FLAGS_AIRPURIFIER_2S, MODEL_AIRPURIFIER_2S: FEATURE_FLAGS_AIRPURIFIER_2S, + MODEL_AIRPURIFIER_3C: FEATURE_FLAGS_AIRPURIFIER_3C, MODEL_AIRPURIFIER_PRO: FEATURE_FLAGS_AIRPURIFIER_PRO, MODEL_AIRPURIFIER_PRO_V7: FEATURE_FLAGS_AIRPURIFIER_PRO_V7, MODEL_AIRPURIFIER_V1: FEATURE_FLAGS_AIRPURIFIER_V1, From bd60a58765264d7b5252a57b613e8431a388d7f0 Mon Sep 17 00:00:00 2001 From: Ernst Klamer Date: Tue, 31 Aug 2021 16:46:19 +0200 Subject: [PATCH 127/843] Improvements to the solarlog integration (#55405) --- homeassistant/components/solarlog/__init__.py | 37 ++----------- homeassistant/components/solarlog/const.py | 52 +++++++------------ homeassistant/components/solarlog/sensor.py | 7 ++- 3 files changed, 28 insertions(+), 68 deletions(-) diff --git a/homeassistant/components/solarlog/__init__.py b/homeassistant/components/solarlog/__init__.py index e32f1d85564..190898abb27 100644 --- a/homeassistant/components/solarlog/__init__.py +++ b/homeassistant/components/solarlog/__init__.py @@ -54,49 +54,18 @@ class SolarlogData(update_coordinator.DataUpdateCoordinator): async def _async_update_data(self): """Update the data from the SolarLog device.""" try: - api = await self.hass.async_add_executor_job(SolarLog, self.host) + data = await self.hass.async_add_executor_job(SolarLog, self.host) except (OSError, Timeout, HTTPError) as err: raise update_coordinator.UpdateFailed(err) - if api.time.year == 1999: + if data.time.year == 1999: raise update_coordinator.UpdateFailed( "Invalid data returned (can happen after Solarlog restart)." ) self.logger.debug( "Connection to Solarlog successful. Retrieving latest Solarlog update of %s", - api.time, + data.time, ) - data = {} - - try: - data["TIME"] = api.time - data["powerAC"] = api.power_ac - data["powerDC"] = api.power_dc - data["voltageAC"] = api.voltage_ac - data["voltageDC"] = api.voltage_dc - data["yieldDAY"] = api.yield_day / 1000 - data["yieldYESTERDAY"] = api.yield_yesterday / 1000 - data["yieldMONTH"] = api.yield_month / 1000 - data["yieldYEAR"] = api.yield_year / 1000 - data["yieldTOTAL"] = api.yield_total / 1000 - data["consumptionAC"] = api.consumption_ac - data["consumptionDAY"] = api.consumption_day / 1000 - data["consumptionYESTERDAY"] = api.consumption_yesterday / 1000 - data["consumptionMONTH"] = api.consumption_month / 1000 - data["consumptionYEAR"] = api.consumption_year / 1000 - data["consumptionTOTAL"] = api.consumption_total / 1000 - data["totalPOWER"] = api.total_power - data["alternatorLOSS"] = api.alternator_loss - data["CAPACITY"] = round(api.capacity * 100, 0) - data["EFFICIENCY"] = round(api.efficiency * 100, 0) - data["powerAVAILABLE"] = api.power_available - data["USAGE"] = round(api.usage * 100, 0) - except AttributeError as err: - raise update_coordinator.UpdateFailed( - f"Missing details data in Solarlog response: {err}" - ) from err - - _LOGGER.debug("Updated Solarlog overview data: %s", data) return data diff --git a/homeassistant/components/solarlog/const.py b/homeassistant/components/solarlog/const.py index eecf73b6a09..3ee767f1513 100644 --- a/homeassistant/components/solarlog/const.py +++ b/homeassistant/components/solarlog/const.py @@ -19,6 +19,7 @@ from homeassistant.const import ( PERCENTAGE, POWER_WATT, ) +from homeassistant.util import dt DOMAIN = "solarlog" @@ -28,29 +29,20 @@ DEFAULT_NAME = "solarlog" @dataclass -class SolarlogRequiredKeysMixin: - """Mixin for required keys.""" - - json_key: str - - -@dataclass -class SolarLogSensorEntityDescription( - SensorEntityDescription, SolarlogRequiredKeysMixin -): +class SolarLogSensorEntityDescription(SensorEntityDescription): """Describes Solarlog sensor entity.""" + factor: float | None = None + SENSOR_TYPES: tuple[SolarLogSensorEntityDescription, ...] = ( SolarLogSensorEntityDescription( key="time", - json_key="TIME", name="last update", device_class=DEVICE_CLASS_TIMESTAMP, ), SolarLogSensorEntityDescription( key="power_ac", - json_key="powerAC", name="power AC", icon="mdi:solar-power", native_unit_of_measurement=POWER_WATT, @@ -58,7 +50,6 @@ SENSOR_TYPES: tuple[SolarLogSensorEntityDescription, ...] = ( ), SolarLogSensorEntityDescription( key="power_dc", - json_key="powerDC", name="power DC", icon="mdi:solar-power", native_unit_of_measurement=POWER_WATT, @@ -66,7 +57,6 @@ SENSOR_TYPES: tuple[SolarLogSensorEntityDescription, ...] = ( ), SolarLogSensorEntityDescription( key="voltage_ac", - json_key="voltageAC", name="voltage AC", native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, device_class=DEVICE_CLASS_VOLTAGE, @@ -74,7 +64,6 @@ SENSOR_TYPES: tuple[SolarLogSensorEntityDescription, ...] = ( ), SolarLogSensorEntityDescription( key="voltage_dc", - json_key="voltageDC", name="voltage DC", native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, device_class=DEVICE_CLASS_VOLTAGE, @@ -82,43 +71,42 @@ SENSOR_TYPES: tuple[SolarLogSensorEntityDescription, ...] = ( ), SolarLogSensorEntityDescription( key="yield_day", - json_key="yieldDAY", name="yield day", icon="mdi:solar-power", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + factor=0.001, ), SolarLogSensorEntityDescription( key="yield_yesterday", - json_key="yieldYESTERDAY", name="yield yesterday", icon="mdi:solar-power", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + factor=0.001, ), SolarLogSensorEntityDescription( key="yield_month", - json_key="yieldMONTH", name="yield month", icon="mdi:solar-power", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + factor=0.001, ), SolarLogSensorEntityDescription( key="yield_year", - json_key="yieldYEAR", name="yield year", icon="mdi:solar-power", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + factor=0.001, ), SolarLogSensorEntityDescription( key="yield_total", - json_key="yieldTOTAL", name="yield total", icon="mdi:solar-power", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, state_class=STATE_CLASS_TOTAL_INCREASING, + factor=0.001, ), SolarLogSensorEntityDescription( key="consumption_ac", - json_key="consumptionAC", name="consumption AC", native_unit_of_measurement=POWER_WATT, device_class=DEVICE_CLASS_POWER, @@ -126,43 +114,43 @@ SENSOR_TYPES: tuple[SolarLogSensorEntityDescription, ...] = ( ), SolarLogSensorEntityDescription( key="consumption_day", - json_key="consumptionDAY", name="consumption day", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, + factor=0.001, ), SolarLogSensorEntityDescription( key="consumption_yesterday", - json_key="consumptionYESTERDAY", name="consumption yesterday", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, + factor=0.001, ), SolarLogSensorEntityDescription( key="consumption_month", - json_key="consumptionMONTH", name="consumption month", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, + factor=0.001, ), SolarLogSensorEntityDescription( key="consumption_year", - json_key="consumptionYEAR", name="consumption year", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, + factor=0.001, ), SolarLogSensorEntityDescription( key="consumption_total", - json_key="consumptionTOTAL", name="consumption total", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, + state_class=STATE_CLASS_MEASUREMENT, + last_reset=dt.utc_from_timestamp(0), + factor=0.001, ), SolarLogSensorEntityDescription( key="total_power", - json_key="totalPOWER", name="installed peak power", icon="mdi:solar-power", native_unit_of_measurement=POWER_WATT, @@ -170,7 +158,6 @@ SENSOR_TYPES: tuple[SolarLogSensorEntityDescription, ...] = ( ), SolarLogSensorEntityDescription( key="alternator_loss", - json_key="alternatorLOSS", name="alternator loss", icon="mdi:solar-power", native_unit_of_measurement=POWER_WATT, @@ -179,24 +166,23 @@ SENSOR_TYPES: tuple[SolarLogSensorEntityDescription, ...] = ( ), SolarLogSensorEntityDescription( key="capacity", - json_key="CAPACITY", name="capacity", icon="mdi:solar-power", native_unit_of_measurement=PERCENTAGE, device_class=DEVICE_CLASS_POWER_FACTOR, state_class=STATE_CLASS_MEASUREMENT, + factor=100, ), SolarLogSensorEntityDescription( key="efficiency", - json_key="EFFICIENCY", name="efficiency", native_unit_of_measurement=PERCENTAGE, device_class=DEVICE_CLASS_POWER_FACTOR, state_class=STATE_CLASS_MEASUREMENT, + factor=100, ), SolarLogSensorEntityDescription( key="power_available", - json_key="powerAVAILABLE", name="power available", icon="mdi:solar-power", native_unit_of_measurement=POWER_WATT, @@ -205,10 +191,10 @@ SENSOR_TYPES: tuple[SolarLogSensorEntityDescription, ...] = ( ), SolarLogSensorEntityDescription( key="usage", - json_key="USAGE", name="usage", native_unit_of_measurement=PERCENTAGE, device_class=DEVICE_CLASS_POWER_FACTOR, state_class=STATE_CLASS_MEASUREMENT, + factor=100, ), ) diff --git a/homeassistant/components/solarlog/sensor.py b/homeassistant/components/solarlog/sensor.py index ee7425cf2d7..5918c397a7b 100644 --- a/homeassistant/components/solarlog/sensor.py +++ b/homeassistant/components/solarlog/sensor.py @@ -39,4 +39,9 @@ class SolarlogSensor(update_coordinator.CoordinatorEntity, SensorEntity): @property def native_value(self) -> StateType: """Return the native sensor value.""" - return self.coordinator.data[self.entity_description.json_key] + result = getattr(self.coordinator.data, self.entity_description.key) + if self.entity_description.factor: + state = round(result * self.entity_description.factor, 3) + else: + state = result + return state From 71c6f99d3134df127c0d404106032980fcb6fc24 Mon Sep 17 00:00:00 2001 From: uvjustin <46082645+uvjustin@users.noreply.github.com> Date: Tue, 31 Aug 2021 23:30:05 +0800 Subject: [PATCH 128/843] Fix ArestSwitchBase missing is on attribute (#55483) --- homeassistant/components/arest/switch.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/arest/switch.py b/homeassistant/components/arest/switch.py index d20eb7a5f8d..ecbf24c23ca 100644 --- a/homeassistant/components/arest/switch.py +++ b/homeassistant/components/arest/switch.py @@ -88,6 +88,7 @@ class ArestSwitchBase(SwitchEntity): self._resource = resource self._attr_name = f"{location.title()} {name.title()}" self._attr_available = True + self._attr_is_on = False class ArestSwitchFunction(ArestSwitchBase): From 5d1a193ecac14d7549d1a1a30b80626190e1d4f4 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 31 Aug 2021 19:15:22 +0200 Subject: [PATCH 129/843] Improve log for sum statistics (#55502) --- homeassistant/components/sensor/recorder.py | 17 +++++++++++++++-- tests/components/sensor/test_recorder.py | 6 ++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index cc1b6865d81..1c8bbf89b89 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -282,7 +282,7 @@ def reset_detected( return state < 0.9 * previous_state -def compile_statistics( +def compile_statistics( # noqa: C901 hass: HomeAssistant, start: datetime.datetime, end: datetime.datetime ) -> dict: """Compile statistics for all entities during start-end. @@ -376,6 +376,19 @@ def compile_statistics( and (last_reset := state.attributes.get("last_reset")) != old_last_reset ): + if old_state is None: + _LOGGER.info( + "Compiling initial sum statistics for %s, zero point set to %s", + entity_id, + fstate, + ) + else: + _LOGGER.info( + "Detected new cycle for %s, last_reset set to %s (old last_reset %s)", + entity_id, + last_reset, + old_last_reset, + ) reset = True elif old_state is None and last_reset is None: reset = True @@ -390,7 +403,7 @@ def compile_statistics( ): reset = True _LOGGER.info( - "Detected new cycle for %s, zero point set to %s (old zero point %s)", + "Detected new cycle for %s, value dropped from %s to %s", entity_id, fstate, new_state, diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index b3f0ab075c6..d26ecbc1c71 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -273,6 +273,9 @@ def test_compile_hourly_sum_statistics_amount( ] } assert "Error while processing event StatisticsTask" not in caplog.text + assert "Detected new cycle for sensor.test1, last_reset set to" in caplog.text + assert "Compiling initial sum statistics for sensor.test1" in caplog.text + assert "Detected new cycle for sensor.test1, value dropped" not in caplog.text @pytest.mark.parametrize( @@ -353,6 +356,9 @@ def test_compile_hourly_sum_statistics_total_increasing( ] } assert "Error while processing event StatisticsTask" not in caplog.text + assert "Detected new cycle for sensor.test1, last_reset set to" not in caplog.text + assert "Compiling initial sum statistics for sensor.test1" in caplog.text + assert "Detected new cycle for sensor.test1, value dropped" in caplog.text @pytest.mark.parametrize( From cc4b2fbcfa5120023c3cc2534b41037b313d10e6 Mon Sep 17 00:00:00 2001 From: gjong Date: Tue, 31 Aug 2021 19:22:00 +0200 Subject: [PATCH 130/843] Remove Youless native unit of measurement (#55492) --- homeassistant/components/youless/sensor.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/homeassistant/components/youless/sensor.py b/homeassistant/components/youless/sensor.py index 22fecfe1ec6..0b081ab15a2 100644 --- a/homeassistant/components/youless/sensor.py +++ b/homeassistant/components/youless/sensor.py @@ -82,14 +82,6 @@ class YoulessBaseSensor(CoordinatorEntity, SensorEntity): """Property to get the underlying sensor object.""" return None - @property - def native_unit_of_measurement(self) -> str | None: - """Return the unit of measurement for the sensor.""" - if self.get_sensor is None: - return None - - return self.get_sensor.unit_of_measurement - @property def native_value(self) -> StateType: """Determine the state value, only if a sensor is initialized.""" From ff229dd599df322b590dc33ea468f57ab236f7cd Mon Sep 17 00:00:00 2001 From: gjong Date: Tue, 31 Aug 2021 21:24:09 +0200 Subject: [PATCH 131/843] Increase YouLess polling interval (#55490) --- homeassistant/components/youless/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/youless/__init__.py b/homeassistant/components/youless/__init__.py index 83c8209f558..0980e451028 100644 --- a/homeassistant/components/youless/__init__.py +++ b/homeassistant/components/youless/__init__.py @@ -37,7 +37,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER, name="youless_gateway", update_method=async_update_data, - update_interval=timedelta(seconds=2), + update_interval=timedelta(seconds=10), ) await coordinator.async_config_entry_first_refresh() From 9e41a37284b8796bf3a190fe4bd2a4aee8616ec2 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Wed, 1 Sep 2021 00:19:48 +0000 Subject: [PATCH 132/843] [ci skip] Translation update --- .../components/iotawatt/translations/cs.json | 22 ++++++++++++++++++ .../components/iotawatt/translations/de.json | 23 +++++++++++++++++++ .../components/iotawatt/translations/et.json | 23 +++++++++++++++++++ .../components/iotawatt/translations/ru.json | 23 +++++++++++++++++++ .../iotawatt/translations/zh-Hant.json | 23 +++++++++++++++++++ .../rainforest_eagle/translations/cs.json | 7 ++++++ 6 files changed, 121 insertions(+) create mode 100644 homeassistant/components/iotawatt/translations/cs.json create mode 100644 homeassistant/components/iotawatt/translations/de.json create mode 100644 homeassistant/components/iotawatt/translations/et.json create mode 100644 homeassistant/components/iotawatt/translations/ru.json create mode 100644 homeassistant/components/iotawatt/translations/zh-Hant.json diff --git a/homeassistant/components/iotawatt/translations/cs.json b/homeassistant/components/iotawatt/translations/cs.json new file mode 100644 index 00000000000..4223dcfb237 --- /dev/null +++ b/homeassistant/components/iotawatt/translations/cs.json @@ -0,0 +1,22 @@ +{ + "config": { + "error": { + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", + "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed", + "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" + }, + "step": { + "auth": { + "data": { + "password": "Heslo", + "username": "U\u017eivatelsk\u00e9 jm\u00e9no" + } + }, + "user": { + "data": { + "host": "Hostitel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/iotawatt/translations/de.json b/homeassistant/components/iotawatt/translations/de.json new file mode 100644 index 00000000000..b1dda29414b --- /dev/null +++ b/homeassistant/components/iotawatt/translations/de.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "auth": { + "data": { + "password": "Passwort", + "username": "Benutzername" + }, + "description": "Das IoTawatt-Ger\u00e4t erfordert eine Authentifizierung. Bitte gib den Benutzernamen und das Passwort ein und klicke auf die Schaltfl\u00e4che Senden." + }, + "user": { + "data": { + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/iotawatt/translations/et.json b/homeassistant/components/iotawatt/translations/et.json new file mode 100644 index 00000000000..786e73a8858 --- /dev/null +++ b/homeassistant/components/iotawatt/translations/et.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "invalid_auth": "Viga tuvastamisel", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "auth": { + "data": { + "password": "Salas\u00f5na", + "username": "Kasutajanimi" + }, + "description": "IoTawatt seade n\u00f5uab tuvastamist. Sisesta kasutajanimi ja salas\u00f5na ning kl\u00f5psa nuppu Edasta." + }, + "user": { + "data": { + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/iotawatt/translations/ru.json b/homeassistant/components/iotawatt/translations/ru.json new file mode 100644 index 00000000000..d0042988d99 --- /dev/null +++ b/homeassistant/components/iotawatt/translations/ru.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "auth": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" + }, + "description": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e IoTawatt \u0442\u0440\u0435\u0431\u0443\u0435\u0442 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u0432\u0432\u0435\u0434\u0438\u0442\u0435 \u0438\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u0438 \u043f\u0430\u0440\u043e\u043b\u044c \u0438 \u043d\u0430\u0436\u043c\u0438\u0442\u0435 \u043a\u043d\u043e\u043f\u043a\u0443 \u041f\u041e\u0414\u0422\u0412\u0415\u0420\u0414\u0418\u0422\u042c." + }, + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/iotawatt/translations/zh-Hant.json b/homeassistant/components/iotawatt/translations/zh-Hant.json new file mode 100644 index 00000000000..d30fb424935 --- /dev/null +++ b/homeassistant/components/iotawatt/translations/zh-Hant.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "auth": { + "data": { + "password": "\u5bc6\u78bc", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + }, + "description": "IoTawatt \u88dd\u7f6e\u9700\u8981\u8a8d\u8b49\uff0c\u8acb\u8f38\u5165\u4f7f\u7528\u8005\u540d\u7a31\u8207\u5bc6\u78bc\u3001\u4e26\u9ede\u9078\u50b3\u9001\u3002" + }, + "user": { + "data": { + "host": "\u4e3b\u6a5f\u7aef" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rainforest_eagle/translations/cs.json b/homeassistant/components/rainforest_eagle/translations/cs.json index 19a31a3f9cb..aae081e61fe 100644 --- a/homeassistant/components/rainforest_eagle/translations/cs.json +++ b/homeassistant/components/rainforest_eagle/translations/cs.json @@ -7,6 +7,13 @@ "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed", "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" + }, + "step": { + "user": { + "data": { + "host": "Hostitel" + } + } } } } \ No newline at end of file From 93c086d830f25cf4b197cffca7ab1f79d3eeec5c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 1 Sep 2021 06:30:52 +0200 Subject: [PATCH 133/843] Correct sum statistics when only last_reset has changed (#55498) Co-authored-by: Paulus Schoutsen --- homeassistant/components/sensor/recorder.py | 58 +++++++++---- tests/components/sensor/test_recorder.py | 93 +++++++++++++++++++++ 2 files changed, 137 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 1c8bbf89b89..0054b01abd2 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -282,6 +282,21 @@ def reset_detected( return state < 0.9 * previous_state +def _wanted_statistics( + entities: list[tuple[str, str, str | None]] +) -> dict[str, set[str]]: + """Prepare a dict with wanted statistics for entities.""" + wanted_statistics = {} + for entity_id, state_class, device_class in entities: + if device_class in DEVICE_CLASS_STATISTICS[state_class]: + wanted_statistics[entity_id] = DEVICE_CLASS_STATISTICS[state_class][ + device_class + ] + else: + wanted_statistics[entity_id] = DEFAULT_STATISTICS[state_class] + return wanted_statistics + + def compile_statistics( # noqa: C901 hass: HomeAssistant, start: datetime.datetime, end: datetime.datetime ) -> dict: @@ -293,17 +308,32 @@ def compile_statistics( # noqa: C901 entities = _get_entities(hass) + wanted_statistics = _wanted_statistics(entities) + # Get history between start and end - history_list = history.get_significant_states( # type: ignore - hass, start - datetime.timedelta.resolution, end, [i[0] for i in entities] - ) + entities_full_history = [i[0] for i in entities if "sum" in wanted_statistics[i[0]]] + history_list = {} + if entities_full_history: + history_list = history.get_significant_states( # type: ignore + hass, + start - datetime.timedelta.resolution, + end, + entity_ids=entities_full_history, + significant_changes_only=False, + ) + entities_significant_history = [ + i[0] for i in entities if "sum" not in wanted_statistics[i[0]] + ] + if entities_significant_history: + _history_list = history.get_significant_states( # type: ignore + hass, + start - datetime.timedelta.resolution, + end, + entity_ids=entities_significant_history, + ) + history_list = {**history_list, **_history_list} for entity_id, state_class, device_class in entities: - if device_class in DEVICE_CLASS_STATISTICS[state_class]: - wanted_statistics = DEVICE_CLASS_STATISTICS[state_class][device_class] - else: - wanted_statistics = DEFAULT_STATISTICS[state_class] - if entity_id not in history_list: continue @@ -336,21 +366,21 @@ def compile_statistics( # noqa: C901 # Set meta data result[entity_id]["meta"] = { "unit_of_measurement": unit, - "has_mean": "mean" in wanted_statistics, - "has_sum": "sum" in wanted_statistics, + "has_mean": "mean" in wanted_statistics[entity_id], + "has_sum": "sum" in wanted_statistics[entity_id], } # Make calculations stat: dict = {} - if "max" in wanted_statistics: + if "max" in wanted_statistics[entity_id]: stat["max"] = max(*itertools.islice(zip(*fstates), 1)) - if "min" in wanted_statistics: + if "min" in wanted_statistics[entity_id]: stat["min"] = min(*itertools.islice(zip(*fstates), 1)) - if "mean" in wanted_statistics: + if "mean" in wanted_statistics[entity_id]: stat["mean"] = _time_weighted_average(fstates, start, end) - if "sum" in wanted_statistics: + if "sum" in wanted_statistics[entity_id]: last_reset = old_last_reset = None new_state = old_state = None _sum = 0 diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index d26ecbc1c71..115473c23de 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -278,6 +278,77 @@ def test_compile_hourly_sum_statistics_amount( assert "Detected new cycle for sensor.test1, value dropped" not in caplog.text +@pytest.mark.parametrize("state_class", ["measurement"]) +@pytest.mark.parametrize( + "device_class,unit,native_unit,factor", + [ + ("energy", "kWh", "kWh", 1), + ("energy", "Wh", "kWh", 1 / 1000), + ("monetary", "EUR", "EUR", 1), + ("monetary", "SEK", "SEK", 1), + ("gas", "m³", "m³", 1), + ("gas", "ft³", "m³", 0.0283168466), + ], +) +def test_compile_hourly_sum_statistics_amount_reset_every_state_change( + hass_recorder, caplog, state_class, device_class, unit, native_unit, factor +): + """Test compiling hourly statistics.""" + zero = dt_util.utcnow() + hass = hass_recorder() + recorder = hass.data[DATA_INSTANCE] + setup_component(hass, "sensor", {}) + attributes = { + "device_class": device_class, + "state_class": state_class, + "unit_of_measurement": unit, + "last_reset": None, + } + seq = [10, 15, 15, 15, 20, 20, 20, 10] + # Make sure the sequence has consecutive equal states + assert seq[1] == seq[2] == seq[3] + + states = {"sensor.test1": []} + one = zero + for i in range(len(seq)): + one = one + timedelta(minutes=1) + _states = record_meter_state( + hass, one, "sensor.test1", attributes, seq[i : i + 1] + ) + states["sensor.test1"].extend(_states["sensor.test1"]) + + hist = history.get_significant_states( + hass, + zero - timedelta.resolution, + one + timedelta.resolution, + significant_changes_only=False, + ) + assert dict(states)["sensor.test1"] == dict(hist)["sensor.test1"] + + recorder.do_adhoc_statistics(period="hourly", start=zero) + wait_recording_done(hass) + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + {"statistic_id": "sensor.test1", "unit_of_measurement": native_unit} + ] + stats = statistics_during_period(hass, zero) + assert stats == { + "sensor.test1": [ + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero), + "max": None, + "mean": None, + "min": None, + "last_reset": process_timestamp_to_utc_isoformat(one), + "state": approx(factor * seq[7]), + "sum": approx(factor * (sum(seq) - seq[0])), + }, + ] + } + assert "Error while processing event StatisticsTask" not in caplog.text + + @pytest.mark.parametrize( "device_class,unit,native_unit,factor", [ @@ -1309,6 +1380,28 @@ def record_meter_states(hass, zero, entity_id, _attributes, seq): return four, eight, states +def record_meter_state(hass, zero, entity_id, _attributes, seq): + """Record test state. + + We inject a state update for meter sensor. + """ + + def set_state(entity_id, state, **kwargs): + """Set the state.""" + hass.states.set(entity_id, state, **kwargs) + wait_recording_done(hass) + return hass.states.get(entity_id) + + attributes = dict(_attributes) + attributes["last_reset"] = zero.isoformat() + + states = {entity_id: []} + with patch("homeassistant.components.recorder.dt_util.utcnow", return_value=zero): + states[entity_id].append(set_state(entity_id, seq[0], attributes=attributes)) + + return states + + def record_states_partially_unavailable(hass, zero, entity_id, attributes): """Record some test states. From 343054494c5a67e17999e56d18c4cfc99ce84a5f Mon Sep 17 00:00:00 2001 From: muppet3000 Date: Wed, 1 Sep 2021 06:18:20 +0100 Subject: [PATCH 134/843] Added trailing slash to US growatt URL (#55504) --- homeassistant/components/growatt_server/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/growatt_server/const.py b/homeassistant/components/growatt_server/const.py index 0b11e9994ca..e0297de5eff 100644 --- a/homeassistant/components/growatt_server/const.py +++ b/homeassistant/components/growatt_server/const.py @@ -7,7 +7,7 @@ DEFAULT_NAME = "Growatt" SERVER_URLS = [ "https://server.growatt.com/", - "https://server-us.growatt.com", + "https://server-us.growatt.com/", "http://server.smten.com/", ] From 3bc58f9750356763b05a42b03fb1f95115b5c9b7 Mon Sep 17 00:00:00 2001 From: Felipe Martins Diel <41558831+felipediel@users.noreply.github.com> Date: Wed, 1 Sep 2021 02:49:56 -0300 Subject: [PATCH 135/843] Fix BroadlinkSwitch._attr_assumed_state (#55505) --- homeassistant/components/broadlink/switch.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/homeassistant/components/broadlink/switch.py b/homeassistant/components/broadlink/switch.py index 9fb7215e2a9..5ed1e424f53 100644 --- a/homeassistant/components/broadlink/switch.py +++ b/homeassistant/components/broadlink/switch.py @@ -142,9 +142,6 @@ class BroadlinkSwitch(BroadlinkEntity, SwitchEntity, RestoreEntity, ABC): super().__init__(device) self._command_on = command_on self._command_off = command_off - - self._attr_assumed_state = True - self._attr_device_class = DEVICE_CLASS_SWITCH self._attr_name = f"{device.name} Switch" async def async_added_to_hass(self): From 36b37b6db3695a107894782c85d2b4a38c9ebc9e Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 1 Sep 2021 15:50:32 +1000 Subject: [PATCH 136/843] Add missing device class for temperature sensor in Advantage Air (#55508) --- homeassistant/components/advantage_air/sensor.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/advantage_air/sensor.py b/homeassistant/components/advantage_air/sensor.py index 5912101fd65..4f3258e824e 100644 --- a/homeassistant/components/advantage_air/sensor.py +++ b/homeassistant/components/advantage_air/sensor.py @@ -1,7 +1,11 @@ """Sensor platform for Advantage Air integration.""" import voluptuous as vol -from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntity +from homeassistant.components.sensor import ( + DEVICE_CLASS_TEMPERATURE, + STATE_CLASS_MEASUREMENT, + SensorEntity, +) from homeassistant.const import PERCENTAGE, TEMP_CELSIUS from homeassistant.helpers import config_validation as cv, entity_platform @@ -138,11 +142,11 @@ class AdvantageAirZoneSignal(AdvantageAirEntity, SensorEntity): class AdvantageAirZoneTemp(AdvantageAirEntity, SensorEntity): - """Representation of Advantage Air Zone wireless signal sensor.""" + """Representation of Advantage Air Zone temperature sensor.""" _attr_native_unit_of_measurement = TEMP_CELSIUS + _attr_device_class = DEVICE_CLASS_TEMPERATURE _attr_state_class = STATE_CLASS_MEASUREMENT - _attr_icon = "mdi:thermometer" _attr_entity_registry_enabled_default = False def __init__(self, instance, ac_key, zone_key): From 889aced3b632330e801703b0f6f6c926d7c10c9d Mon Sep 17 00:00:00 2001 From: Brian Egge Date: Wed, 1 Sep 2021 02:26:09 -0400 Subject: [PATCH 137/843] Fix None support_color_modes TypeError (#55497) * Fix None support_color_modes TypeError https://github.com/home-assistant/core/issues/55451 * Update __init__.py --- homeassistant/components/light/__init__.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 6865ae165bc..4a0025126c8 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -445,7 +445,11 @@ async def async_setup(hass, config): # noqa: C901 ) # If both white and brightness are specified, override white - if ATTR_WHITE in params and COLOR_MODE_WHITE in supported_color_modes: + if ( + supported_color_modes + and ATTR_WHITE in params + and COLOR_MODE_WHITE in supported_color_modes + ): params[ATTR_WHITE] = params.pop(ATTR_BRIGHTNESS, params[ATTR_WHITE]) # Remove deprecated white value if the light supports color mode From a28593f1335b9dc59d9d5fe6bb68566717a150c8 Mon Sep 17 00:00:00 2001 From: mbo18 Date: Wed, 1 Sep 2021 09:34:21 +0200 Subject: [PATCH 138/843] Add vacation mode to manual alarm_control_panel (#55340) * Add vacation mode * Add vacation to demo * Deduplicate code in tests --- .../components/demo/alarm_control_panel.py | 6 + .../components/manual/alarm_control_panel.py | 16 + .../manual/test_alarm_control_panel.py | 667 ++++-------------- 3 files changed, 171 insertions(+), 518 deletions(-) diff --git a/homeassistant/components/demo/alarm_control_panel.py b/homeassistant/components/demo/alarm_control_panel.py index d5bb71da67b..5a4485c7365 100644 --- a/homeassistant/components/demo/alarm_control_panel.py +++ b/homeassistant/components/demo/alarm_control_panel.py @@ -10,6 +10,7 @@ from homeassistant.const import ( STATE_ALARM_ARMED_CUSTOM_BYPASS, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMED_VACATION, STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, ) @@ -42,6 +43,11 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= CONF_DELAY_TIME: datetime.timedelta(seconds=0), CONF_TRIGGER_TIME: datetime.timedelta(seconds=10), }, + STATE_ALARM_ARMED_VACATION: { + CONF_ARMING_TIME: datetime.timedelta(seconds=5), + CONF_DELAY_TIME: datetime.timedelta(seconds=0), + CONF_TRIGGER_TIME: datetime.timedelta(seconds=10), + }, STATE_ALARM_DISARMED: { CONF_DELAY_TIME: datetime.timedelta(seconds=0), CONF_TRIGGER_TIME: datetime.timedelta(seconds=10), diff --git a/homeassistant/components/manual/alarm_control_panel.py b/homeassistant/components/manual/alarm_control_panel.py index 00c155615ee..a78476cf5d3 100644 --- a/homeassistant/components/manual/alarm_control_panel.py +++ b/homeassistant/components/manual/alarm_control_panel.py @@ -12,6 +12,7 @@ from homeassistant.components.alarm_control_panel.const import ( SUPPORT_ALARM_ARM_CUSTOM_BYPASS, SUPPORT_ALARM_ARM_HOME, SUPPORT_ALARM_ARM_NIGHT, + SUPPORT_ALARM_ARM_VACATION, SUPPORT_ALARM_TRIGGER, ) from homeassistant.const import ( @@ -26,6 +27,7 @@ from homeassistant.const import ( STATE_ALARM_ARMED_CUSTOM_BYPASS, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMED_VACATION, STATE_ALARM_ARMING, STATE_ALARM_DISARMED, STATE_ALARM_PENDING, @@ -53,6 +55,7 @@ SUPPORTED_STATES = [ STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMED_VACATION, STATE_ALARM_ARMED_CUSTOM_BYPASS, STATE_ALARM_TRIGGERED, ] @@ -132,6 +135,9 @@ PLATFORM_SCHEMA = vol.Schema( vol.Optional(STATE_ALARM_ARMED_NIGHT, default={}): _state_schema( STATE_ALARM_ARMED_NIGHT ), + vol.Optional(STATE_ALARM_ARMED_VACATION, default={}): _state_schema( + STATE_ALARM_ARMED_VACATION + ), vol.Optional(STATE_ALARM_ARMED_CUSTOM_BYPASS, default={}): _state_schema( STATE_ALARM_ARMED_CUSTOM_BYPASS ), @@ -250,6 +256,7 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY | SUPPORT_ALARM_ARM_NIGHT + | SUPPORT_ALARM_ARM_VACATION | SUPPORT_ALARM_TRIGGER | SUPPORT_ALARM_ARM_CUSTOM_BYPASS ) @@ -327,6 +334,15 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): self._update_state(STATE_ALARM_ARMED_NIGHT) + def alarm_arm_vacation(self, code=None): + """Send arm vacation command.""" + if self._code_arm_required and not self._validate_code( + code, STATE_ALARM_ARMED_VACATION + ): + return + + self._update_state(STATE_ALARM_ARMED_VACATION) + def alarm_arm_custom_bypass(self, code=None): """Send arm custom bypass command.""" if self._code_arm_required and not self._validate_code( diff --git a/tests/components/manual/test_alarm_control_panel.py b/tests/components/manual/test_alarm_control_panel.py index f3cf3ccce39..2db5b827838 100644 --- a/tests/components/manual/test_alarm_control_panel.py +++ b/tests/components/manual/test_alarm_control_panel.py @@ -2,13 +2,23 @@ from datetime import timedelta from unittest.mock import MagicMock, patch +import pytest + from homeassistant.components import alarm_control_panel from homeassistant.components.demo import alarm_control_panel as demo from homeassistant.const import ( + ATTR_CODE, + ATTR_ENTITY_ID, + SERVICE_ALARM_ARM_AWAY, + SERVICE_ALARM_ARM_CUSTOM_BYPASS, + SERVICE_ALARM_ARM_HOME, + SERVICE_ALARM_ARM_NIGHT, + SERVICE_ALARM_ARM_VACATION, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_CUSTOM_BYPASS, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMED_VACATION, STATE_ALARM_ARMING, STATE_ALARM_DISARMED, STATE_ALARM_PENDING, @@ -32,8 +42,18 @@ async def test_setup_demo_platform(hass): assert add_entities.call_count == 1 -async def test_arm_home_no_pending(hass): - """Test arm home method.""" +@pytest.mark.parametrize( + "service,expected_state", + [ + (SERVICE_ALARM_ARM_AWAY, STATE_ALARM_ARMED_AWAY), + (SERVICE_ALARM_ARM_CUSTOM_BYPASS, STATE_ALARM_ARMED_CUSTOM_BYPASS), + (SERVICE_ALARM_ARM_HOME, STATE_ALARM_ARMED_HOME), + (SERVICE_ALARM_ARM_NIGHT, STATE_ALARM_ARMED_NIGHT), + (SERVICE_ALARM_ARM_VACATION, STATE_ALARM_ARMED_VACATION), + ], +) +async def test_no_pending(hass, service, expected_state): + """Test no pending after arming.""" assert await async_setup_component( hass, alarm_control_panel.DOMAIN, @@ -53,13 +73,28 @@ async def test_arm_home_no_pending(hass): assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED - await common.async_alarm_arm_home(hass, CODE) + await hass.services.async_call( + alarm_control_panel.DOMAIN, + service, + {ATTR_ENTITY_ID: "alarm_control_panel.test", ATTR_CODE: CODE}, + blocking=True, + ) - assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_HOME + assert hass.states.get(entity_id).state == expected_state -async def test_arm_home_no_pending_when_code_not_req(hass): - """Test arm home method.""" +@pytest.mark.parametrize( + "service,expected_state", + [ + (SERVICE_ALARM_ARM_AWAY, STATE_ALARM_ARMED_AWAY), + (SERVICE_ALARM_ARM_CUSTOM_BYPASS, STATE_ALARM_ARMED_CUSTOM_BYPASS), + (SERVICE_ALARM_ARM_HOME, STATE_ALARM_ARMED_HOME), + (SERVICE_ALARM_ARM_NIGHT, STATE_ALARM_ARMED_NIGHT), + (SERVICE_ALARM_ARM_VACATION, STATE_ALARM_ARMED_VACATION), + ], +) +async def test_no_pending_when_code_not_req(hass, service, expected_state): + """Test no pending when code not required.""" assert await async_setup_component( hass, alarm_control_panel.DOMAIN, @@ -80,13 +115,28 @@ async def test_arm_home_no_pending_when_code_not_req(hass): assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED - await common.async_alarm_arm_home(hass, 0) + await hass.services.async_call( + alarm_control_panel.DOMAIN, + service, + {ATTR_ENTITY_ID: "alarm_control_panel.test", ATTR_CODE: CODE}, + blocking=True, + ) - assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_HOME + assert hass.states.get(entity_id).state == expected_state -async def test_arm_home_with_pending(hass): - """Test arm home method.""" +@pytest.mark.parametrize( + "service,expected_state", + [ + (SERVICE_ALARM_ARM_AWAY, STATE_ALARM_ARMED_AWAY), + (SERVICE_ALARM_ARM_CUSTOM_BYPASS, STATE_ALARM_ARMED_CUSTOM_BYPASS), + (SERVICE_ALARM_ARM_HOME, STATE_ALARM_ARMED_HOME), + (SERVICE_ALARM_ARM_NIGHT, STATE_ALARM_ARMED_NIGHT), + (SERVICE_ALARM_ARM_VACATION, STATE_ALARM_ARMED_VACATION), + ], +) +async def test_with_pending(hass, service, expected_state): + """Test with pending after arming.""" assert await async_setup_component( hass, alarm_control_panel.DOMAIN, @@ -106,12 +156,17 @@ async def test_arm_home_with_pending(hass): assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED - await common.async_alarm_arm_home(hass, CODE, entity_id) + await hass.services.async_call( + alarm_control_panel.DOMAIN, + service, + {ATTR_ENTITY_ID: "alarm_control_panel.test", ATTR_CODE: CODE}, + blocking=True, + ) assert hass.states.get(entity_id).state == STATE_ALARM_ARMING state = hass.states.get(entity_id) - assert state.attributes["next_state"] == STATE_ALARM_ARMED_HOME + assert state.attributes["next_state"] == expected_state future = dt_util.utcnow() + timedelta(seconds=1) with patch( @@ -122,11 +177,31 @@ async def test_arm_home_with_pending(hass): await hass.async_block_till_done() state = hass.states.get(entity_id) - assert state.state == STATE_ALARM_ARMED_HOME + assert state.state == expected_state + + # Do not go to the pending state when updating to the same state + await hass.services.async_call( + alarm_control_panel.DOMAIN, + service, + {ATTR_ENTITY_ID: "alarm_control_panel.test", ATTR_CODE: CODE}, + blocking=True, + ) + + assert hass.states.get(entity_id).state == expected_state -async def test_arm_home_with_invalid_code(hass): - """Attempt to arm home without a valid code.""" +@pytest.mark.parametrize( + "service,expected_state", + [ + (SERVICE_ALARM_ARM_AWAY, STATE_ALARM_ARMED_AWAY), + (SERVICE_ALARM_ARM_CUSTOM_BYPASS, STATE_ALARM_ARMED_CUSTOM_BYPASS), + (SERVICE_ALARM_ARM_HOME, STATE_ALARM_ARMED_HOME), + (SERVICE_ALARM_ARM_NIGHT, STATE_ALARM_ARMED_NIGHT), + (SERVICE_ALARM_ARM_VACATION, STATE_ALARM_ARMED_VACATION), + ], +) +async def test_with_invalid_code(hass, service, expected_state): + """Attempt to arm without a valid code.""" assert await async_setup_component( hass, alarm_control_panel.DOMAIN, @@ -146,65 +221,27 @@ async def test_arm_home_with_invalid_code(hass): assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED - await common.async_alarm_arm_home(hass, CODE + "2") - - assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED - - -async def test_arm_away_no_pending(hass): - """Test arm home method.""" - assert await async_setup_component( - hass, + await hass.services.async_call( alarm_control_panel.DOMAIN, - { - "alarm_control_panel": { - "platform": "manual", - "name": "test", - "code": CODE, - "arming_time": 0, - "disarm_after_trigger": False, - } - }, + service, + {ATTR_ENTITY_ID: "alarm_control_panel.test", ATTR_CODE: CODE + "2"}, + blocking=True, ) - await hass.async_block_till_done() - - entity_id = "alarm_control_panel.test" assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED - await common.async_alarm_arm_away(hass, CODE, entity_id) - assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_AWAY - - -async def test_arm_away_no_pending_when_code_not_req(hass): - """Test arm home method.""" - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - { - "alarm_control_panel": { - "platform": "manual", - "name": "test", - "code": CODE, - "code_arm_required": False, - "arming_time": 0, - "disarm_after_trigger": False, - } - }, - ) - await hass.async_block_till_done() - - entity_id = "alarm_control_panel.test" - - assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED - - await common.async_alarm_arm_away(hass, 0, entity_id) - - assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_AWAY - - -async def test_arm_home_with_template_code(hass): +@pytest.mark.parametrize( + "service,expected_state", + [ + (SERVICE_ALARM_ARM_AWAY, STATE_ALARM_ARMED_AWAY), + (SERVICE_ALARM_ARM_CUSTOM_BYPASS, STATE_ALARM_ARMED_CUSTOM_BYPASS), + (SERVICE_ALARM_ARM_HOME, STATE_ALARM_ARMED_HOME), + (SERVICE_ALARM_ARM_NIGHT, STATE_ALARM_ARMED_NIGHT), + (SERVICE_ALARM_ARM_VACATION, STATE_ALARM_ARMED_VACATION), + ], +) +async def test_with_template_code(hass, service, expected_state): """Attempt to arm with a template-based code.""" assert await async_setup_component( hass, @@ -225,14 +262,29 @@ async def test_arm_home_with_template_code(hass): assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED - await common.async_alarm_arm_home(hass, "abc") + await hass.services.async_call( + alarm_control_panel.DOMAIN, + service, + {ATTR_ENTITY_ID: "alarm_control_panel.test", ATTR_CODE: "abc"}, + blocking=True, + ) state = hass.states.get(entity_id) - assert state.state == STATE_ALARM_ARMED_HOME + assert state.state == expected_state -async def test_arm_away_with_pending(hass): - """Test arm home method.""" +@pytest.mark.parametrize( + "service,expected_state,", + [ + (SERVICE_ALARM_ARM_AWAY, STATE_ALARM_ARMED_AWAY), + (SERVICE_ALARM_ARM_CUSTOM_BYPASS, STATE_ALARM_ARMED_CUSTOM_BYPASS), + (SERVICE_ALARM_ARM_HOME, STATE_ALARM_ARMED_HOME), + (SERVICE_ALARM_ARM_NIGHT, STATE_ALARM_ARMED_NIGHT), + (SERVICE_ALARM_ARM_VACATION, STATE_ALARM_ARMED_VACATION), + ], +) +async def test_with_specific_pending(hass, service, expected_state): + """Test arming with specific pending.""" assert await async_setup_component( hass, alarm_control_panel.DOMAIN, @@ -240,9 +292,8 @@ async def test_arm_away_with_pending(hass): "alarm_control_panel": { "platform": "manual", "name": "test", - "code": CODE, - "arming_time": 1, - "disarm_after_trigger": False, + "arming_time": 10, + expected_state: {"arming_time": 2}, } }, ) @@ -250,16 +301,16 @@ async def test_arm_away_with_pending(hass): entity_id = "alarm_control_panel.test" - assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED - - await common.async_alarm_arm_away(hass, CODE) + await hass.services.async_call( + alarm_control_panel.DOMAIN, + service, + {ATTR_ENTITY_ID: "alarm_control_panel.test"}, + blocking=True, + ) assert hass.states.get(entity_id).state == STATE_ALARM_ARMING - state = hass.states.get(entity_id) - assert state.attributes["next_state"] == STATE_ALARM_ARMED_AWAY - - future = dt_util.utcnow() + timedelta(seconds=1) + future = dt_util.utcnow() + timedelta(seconds=2) with patch( ("homeassistant.components.manual.alarm_control_panel.dt_util.utcnow"), return_value=future, @@ -267,158 +318,7 @@ async def test_arm_away_with_pending(hass): async_fire_time_changed(hass, future) await hass.async_block_till_done() - state = hass.states.get(entity_id) - assert state.state == STATE_ALARM_ARMED_AWAY - - -async def test_arm_away_with_invalid_code(hass): - """Attempt to arm away without a valid code.""" - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - { - "alarm_control_panel": { - "platform": "manual", - "name": "test", - "code": CODE, - "arming_time": 1, - "disarm_after_trigger": False, - } - }, - ) - await hass.async_block_till_done() - - entity_id = "alarm_control_panel.test" - - assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED - - await common.async_alarm_arm_away(hass, CODE + "2") - - assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED - - -async def test_arm_night_no_pending(hass): - """Test arm night method.""" - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - { - "alarm_control_panel": { - "platform": "manual", - "name": "test", - "code": CODE, - "arming_time": 0, - "disarm_after_trigger": False, - } - }, - ) - await hass.async_block_till_done() - - entity_id = "alarm_control_panel.test" - - assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED - - await common.async_alarm_arm_night(hass, CODE) - - assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_NIGHT - - -async def test_arm_night_no_pending_when_code_not_req(hass): - """Test arm night method.""" - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - { - "alarm_control_panel": { - "platform": "manual", - "name": "test", - "code": CODE, - "code_arm_required": False, - "arming_time": 0, - "disarm_after_trigger": False, - } - }, - ) - await hass.async_block_till_done() - - entity_id = "alarm_control_panel.test" - - assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED - - await common.async_alarm_arm_night(hass, 0) - - assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_NIGHT - - -async def test_arm_night_with_pending(hass): - """Test arm night method.""" - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - { - "alarm_control_panel": { - "platform": "manual", - "name": "test", - "code": CODE, - "arming_time": 1, - "disarm_after_trigger": False, - } - }, - ) - await hass.async_block_till_done() - - entity_id = "alarm_control_panel.test" - - assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED - - await common.async_alarm_arm_night(hass, CODE, entity_id) - - assert hass.states.get(entity_id).state == STATE_ALARM_ARMING - - state = hass.states.get(entity_id) - assert state.attributes["next_state"] == STATE_ALARM_ARMED_NIGHT - - future = dt_util.utcnow() + timedelta(seconds=1) - with patch( - ("homeassistant.components.manual.alarm_control_panel.dt_util.utcnow"), - return_value=future, - ): - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - state = hass.states.get(entity_id) - assert state.state == STATE_ALARM_ARMED_NIGHT - - # Do not go to the pending state when updating to the same state - await common.async_alarm_arm_night(hass, CODE, entity_id) - - assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_NIGHT - - -async def test_arm_night_with_invalid_code(hass): - """Attempt to night home without a valid code.""" - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - { - "alarm_control_panel": { - "platform": "manual", - "name": "test", - "code": CODE, - "arming_time": 1, - "disarm_after_trigger": False, - } - }, - ) - await hass.async_block_till_done() - - entity_id = "alarm_control_panel.test" - - assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED - - await common.async_alarm_arm_night(hass, CODE + "2") - - assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED + assert hass.states.get(entity_id).state == expected_state async def test_trigger_no_pending(hass): @@ -806,105 +706,6 @@ async def test_trigger_with_pending_and_specific_delay(hass): assert state.state == STATE_ALARM_TRIGGERED -async def test_armed_home_with_specific_pending(hass): - """Test arm home method.""" - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - { - "alarm_control_panel": { - "platform": "manual", - "name": "test", - "arming_time": 10, - "armed_home": {"arming_time": 2}, - } - }, - ) - await hass.async_block_till_done() - - entity_id = "alarm_control_panel.test" - - await common.async_alarm_arm_home(hass) - - assert hass.states.get(entity_id).state == STATE_ALARM_ARMING - - future = dt_util.utcnow() + timedelta(seconds=2) - with patch( - ("homeassistant.components.manual.alarm_control_panel.dt_util.utcnow"), - return_value=future, - ): - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_HOME - - -async def test_armed_away_with_specific_pending(hass): - """Test arm home method.""" - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - { - "alarm_control_panel": { - "platform": "manual", - "name": "test", - "arming_time": 10, - "armed_away": {"arming_time": 2}, - } - }, - ) - await hass.async_block_till_done() - - entity_id = "alarm_control_panel.test" - - await common.async_alarm_arm_away(hass) - - assert hass.states.get(entity_id).state == STATE_ALARM_ARMING - - future = dt_util.utcnow() + timedelta(seconds=2) - with patch( - ("homeassistant.components.manual.alarm_control_panel.dt_util.utcnow"), - return_value=future, - ): - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_AWAY - - -async def test_armed_night_with_specific_pending(hass): - """Test arm home method.""" - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - { - "alarm_control_panel": { - "platform": "manual", - "name": "test", - "arming_time": 10, - "armed_night": {"arming_time": 2}, - } - }, - ) - await hass.async_block_till_done() - - entity_id = "alarm_control_panel.test" - - await common.async_alarm_arm_night(hass) - - assert hass.states.get(entity_id).state == STATE_ALARM_ARMING - - future = dt_util.utcnow() + timedelta(seconds=2) - with patch( - ("homeassistant.components.manual.alarm_control_panel.dt_util.utcnow"), - return_value=future, - ): - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_NIGHT - - async def test_trigger_with_specific_pending(hass): """Test arm home method.""" assert await async_setup_component( @@ -1298,158 +1099,6 @@ async def test_disarm_with_template_code(hass): assert state.state == STATE_ALARM_DISARMED -async def test_arm_custom_bypass_no_pending(hass): - """Test arm custom bypass method.""" - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - { - "alarm_control_panel": { - "platform": "manual", - "name": "test", - "code": CODE, - "arming_time": 0, - "disarm_after_trigger": False, - } - }, - ) - await hass.async_block_till_done() - - entity_id = "alarm_control_panel.test" - - assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED - - await common.async_alarm_arm_custom_bypass(hass, CODE) - - assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_CUSTOM_BYPASS - - -async def test_arm_custom_bypass_no_pending_when_code_not_req(hass): - """Test arm custom bypass method.""" - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - { - "alarm_control_panel": { - "platform": "manual", - "name": "test", - "code": CODE, - "code_arm_required": False, - "arming_time": 0, - "disarm_after_trigger": False, - } - }, - ) - await hass.async_block_till_done() - - entity_id = "alarm_control_panel.test" - - assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED - - await common.async_alarm_arm_custom_bypass(hass, 0) - - assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_CUSTOM_BYPASS - - -async def test_arm_custom_bypass_with_pending(hass): - """Test arm custom bypass method.""" - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - { - "alarm_control_panel": { - "platform": "manual", - "name": "test", - "code": CODE, - "arming_time": 1, - "disarm_after_trigger": False, - } - }, - ) - await hass.async_block_till_done() - - entity_id = "alarm_control_panel.test" - - assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED - - await common.async_alarm_arm_custom_bypass(hass, CODE, entity_id) - - assert hass.states.get(entity_id).state == STATE_ALARM_ARMING - - state = hass.states.get(entity_id) - assert state.attributes["next_state"] == STATE_ALARM_ARMED_CUSTOM_BYPASS - - future = dt_util.utcnow() + timedelta(seconds=1) - with patch( - ("homeassistant.components.manual.alarm_control_panel.dt_util.utcnow"), - return_value=future, - ): - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - state = hass.states.get(entity_id) - assert state.state == STATE_ALARM_ARMED_CUSTOM_BYPASS - - -async def test_arm_custom_bypass_with_invalid_code(hass): - """Attempt to custom bypass without a valid code.""" - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - { - "alarm_control_panel": { - "platform": "manual", - "name": "test", - "code": CODE, - "arming_time": 1, - "disarm_after_trigger": False, - } - }, - ) - await hass.async_block_till_done() - - entity_id = "alarm_control_panel.test" - - assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED - - await common.async_alarm_arm_custom_bypass(hass, CODE + "2") - - assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED - - -async def test_armed_custom_bypass_with_specific_pending(hass): - """Test arm custom bypass method.""" - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - { - "alarm_control_panel": { - "platform": "manual", - "name": "test", - "arming_time": 10, - "armed_custom_bypass": {"arming_time": 2}, - } - }, - ) - await hass.async_block_till_done() - - entity_id = "alarm_control_panel.test" - - await common.async_alarm_arm_custom_bypass(hass) - - assert hass.states.get(entity_id).state == STATE_ALARM_ARMING - - future = dt_util.utcnow() + timedelta(seconds=2) - with patch( - ("homeassistant.components.manual.alarm_control_panel.dt_util.utcnow"), - return_value=future, - ): - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_CUSTOM_BYPASS - - async def test_arm_away_after_disabled_disarmed(hass, legacy_patchable_time): """Test pending state with and without zero trigger time.""" assert await async_setup_component( @@ -1518,11 +1167,20 @@ async def test_arm_away_after_disabled_disarmed(hass, legacy_patchable_time): assert state.state == STATE_ALARM_TRIGGERED -async def test_restore_armed_state(hass): - """Ensure armed state is restored on startup.""" - mock_restore_cache( - hass, (State("alarm_control_panel.test", STATE_ALARM_ARMED_AWAY),) - ) +@pytest.mark.parametrize( + "expected_state", + [ + (STATE_ALARM_ARMED_AWAY), + (STATE_ALARM_ARMED_CUSTOM_BYPASS), + (STATE_ALARM_ARMED_HOME), + (STATE_ALARM_ARMED_NIGHT), + (STATE_ALARM_ARMED_VACATION), + (STATE_ALARM_DISARMED), + ], +) +async def test_restore_state(hass, expected_state): + """Ensure state is restored on startup.""" + mock_restore_cache(hass, (State("alarm_control_panel.test", expected_state),)) hass.state = CoreState.starting mock_component(hass, "recorder") @@ -1544,31 +1202,4 @@ async def test_restore_armed_state(hass): state = hass.states.get("alarm_control_panel.test") assert state - assert state.state == STATE_ALARM_ARMED_AWAY - - -async def test_restore_disarmed_state(hass): - """Ensure disarmed state is restored on startup.""" - mock_restore_cache(hass, (State("alarm_control_panel.test", STATE_ALARM_DISARMED),)) - - hass.state = CoreState.starting - mock_component(hass, "recorder") - - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - { - "alarm_control_panel": { - "platform": "manual", - "name": "test", - "arming_time": 0, - "trigger_time": 0, - "disarm_after_trigger": False, - } - }, - ) - await hass.async_block_till_done() - - state = hass.states.get("alarm_control_panel.test") - assert state - assert state.state == STATE_ALARM_DISARMED + assert state.state == expected_state From 46159c3f18bf3f405bced2f5280003e905748e1b Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Wed, 1 Sep 2021 10:03:41 +0200 Subject: [PATCH 139/843] ESPHome light color mode use capabilities (#55206) Co-authored-by: Oxan van Leeuwen --- homeassistant/components/esphome/light.py | 195 +++++++++++++----- .../components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 150 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/esphome/light.py b/homeassistant/components/esphome/light.py index 73339769121..9e7f544f610 100644 --- a/homeassistant/components/esphome/light.py +++ b/homeassistant/components/esphome/light.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any, cast -from aioesphomeapi import APIVersion, LightColorMode, LightInfo, LightState +from aioesphomeapi import APIVersion, LightColorCapability, LightInfo, LightState from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -34,12 +34,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ( - EsphomeEntity, - EsphomeEnumMapper, - esphome_state_property, - platform_async_setup_entry, -) +from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry FLASH_LENGTHS = {FLASH_SHORT: 2, FLASH_LONG: 10} @@ -59,20 +54,81 @@ async def async_setup_entry( ) -_COLOR_MODES: EsphomeEnumMapper[LightColorMode, str] = EsphomeEnumMapper( - { - LightColorMode.UNKNOWN: COLOR_MODE_UNKNOWN, - LightColorMode.ON_OFF: COLOR_MODE_ONOFF, - LightColorMode.BRIGHTNESS: COLOR_MODE_BRIGHTNESS, - LightColorMode.WHITE: COLOR_MODE_WHITE, - LightColorMode.COLOR_TEMPERATURE: COLOR_MODE_COLOR_TEMP, - LightColorMode.COLD_WARM_WHITE: COLOR_MODE_COLOR_TEMP, - LightColorMode.RGB: COLOR_MODE_RGB, - LightColorMode.RGB_WHITE: COLOR_MODE_RGBW, - LightColorMode.RGB_COLOR_TEMPERATURE: COLOR_MODE_RGBWW, - LightColorMode.RGB_COLD_WARM_WHITE: COLOR_MODE_RGBWW, - } -) +_COLOR_MODE_MAPPING = { + COLOR_MODE_ONOFF: [ + LightColorCapability.ON_OFF, + ], + COLOR_MODE_BRIGHTNESS: [ + LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, + # for compatibility with older clients (2021.8.x) + LightColorCapability.BRIGHTNESS, + ], + COLOR_MODE_COLOR_TEMP: [ + LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + | LightColorCapability.COLOR_TEMPERATURE, + LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + | LightColorCapability.COLD_WARM_WHITE, + ], + COLOR_MODE_RGB: [ + LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + | LightColorCapability.RGB, + ], + COLOR_MODE_RGBW: [ + LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + | LightColorCapability.RGB + | LightColorCapability.WHITE, + ], + COLOR_MODE_RGBWW: [ + LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + | LightColorCapability.RGB + | LightColorCapability.WHITE + | LightColorCapability.COLOR_TEMPERATURE, + LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + | LightColorCapability.RGB + | LightColorCapability.COLD_WARM_WHITE, + ], + COLOR_MODE_WHITE: [ + LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + | LightColorCapability.WHITE + ], +} + + +def _color_mode_to_ha(mode: int) -> str: + """Convert an esphome color mode to a HA color mode constant. + + Choses the color mode that best matches the feature-set. + """ + candidates = [] + for ha_mode, cap_lists in _COLOR_MODE_MAPPING.items(): + for caps in cap_lists: + if caps == mode: + # exact match + return ha_mode + if (mode & caps) == caps: + # all requirements met + candidates.append((ha_mode, caps)) + + if not candidates: + return COLOR_MODE_UNKNOWN + + # choose the color mode with the most bits set + candidates.sort(key=lambda key: bin(key[1]).count("1")) + return candidates[-1][0] + + +def _filter_color_modes( + supported: list[int], features: LightColorCapability +) -> list[int]: + """Filter the given supported color modes, excluding all values that don't have the requested features.""" + return [mode for mode in supported if mode & features] # https://github.com/PyCQA/pylint/issues/3150 for all @esphome_state_property @@ -95,10 +151,17 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" data: dict[str, Any] = {"key": self._static_info.key, "state": True} + # The list of color modes that would fit this service call + color_modes = self._native_supported_color_modes + try_keep_current_mode = True + # rgb/brightness input is in range 0-255, but esphome uses 0-1 if (brightness_ha := kwargs.get(ATTR_BRIGHTNESS)) is not None: data["brightness"] = brightness_ha / 255 + color_modes = _filter_color_modes( + color_modes, LightColorCapability.BRIGHTNESS + ) if (rgb_ha := kwargs.get(ATTR_RGB_COLOR)) is not None: rgb = tuple(x / 255 for x in rgb_ha) @@ -106,8 +169,8 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): # normalize rgb data["rgb"] = tuple(x / (color_bri or 1) for x in rgb) data["color_brightness"] = color_bri - if self._supports_color_mode: - data["color_mode"] = LightColorMode.RGB + color_modes = _filter_color_modes(color_modes, LightColorCapability.RGB) + try_keep_current_mode = False if (rgbw_ha := kwargs.get(ATTR_RGBW_COLOR)) is not None: # pylint: disable=invalid-name @@ -117,8 +180,10 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): data["rgb"] = tuple(x / (color_bri or 1) for x in rgb) data["white"] = w data["color_brightness"] = color_bri - if self._supports_color_mode: - data["color_mode"] = LightColorMode.RGB_WHITE + color_modes = _filter_color_modes( + color_modes, LightColorCapability.RGB | LightColorCapability.WHITE + ) + try_keep_current_mode = False if (rgbww_ha := kwargs.get(ATTR_RGBWW_COLOR)) is not None: # pylint: disable=invalid-name @@ -126,14 +191,14 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): color_bri = max(rgb) # normalize rgb data["rgb"] = tuple(x / (color_bri or 1) for x in rgb) - modes = self._native_supported_color_modes - if ( - self._supports_color_mode - and LightColorMode.RGB_COLD_WARM_WHITE in modes - ): + color_modes = _filter_color_modes(color_modes, LightColorCapability.RGB) + if _filter_color_modes(color_modes, LightColorCapability.COLD_WARM_WHITE): + # Device supports setting cwww values directly data["cold_white"] = cw data["warm_white"] = ww - target_mode = LightColorMode.RGB_COLD_WARM_WHITE + color_modes = _filter_color_modes( + color_modes, LightColorCapability.COLD_WARM_WHITE + ) else: # need to convert cw+ww part to white+color_temp white = data["white"] = max(cw, ww) @@ -142,11 +207,13 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): max_ct = self.max_mireds ct_ratio = ww / (cw + ww) data["color_temperature"] = min_ct + ct_ratio * (max_ct - min_ct) - target_mode = LightColorMode.RGB_COLOR_TEMPERATURE + color_modes = _filter_color_modes( + color_modes, + LightColorCapability.COLOR_TEMPERATURE | LightColorCapability.WHITE, + ) + try_keep_current_mode = False data["color_brightness"] = color_bri - if self._supports_color_mode: - data["color_mode"] = target_mode if (flash := kwargs.get(ATTR_FLASH)) is not None: data["flash_length"] = FLASH_LENGTHS[flash] @@ -156,12 +223,15 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): if (color_temp := kwargs.get(ATTR_COLOR_TEMP)) is not None: data["color_temperature"] = color_temp - if self._supports_color_mode: - supported_modes = self._native_supported_color_modes - if LightColorMode.COLOR_TEMPERATURE in supported_modes: - data["color_mode"] = LightColorMode.COLOR_TEMPERATURE - elif LightColorMode.COLD_WARM_WHITE in supported_modes: - data["color_mode"] = LightColorMode.COLD_WARM_WHITE + if _filter_color_modes(color_modes, LightColorCapability.COLOR_TEMPERATURE): + color_modes = _filter_color_modes( + color_modes, LightColorCapability.COLOR_TEMPERATURE + ) + else: + color_modes = _filter_color_modes( + color_modes, LightColorCapability.COLD_WARM_WHITE + ) + try_keep_current_mode = False if (effect := kwargs.get(ATTR_EFFECT)) is not None: data["effect"] = effect @@ -171,7 +241,30 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): # HA only sends `white` in turn_on, and reads total brightness through brightness property data["brightness"] = white_ha / 255 data["white"] = 1.0 - data["color_mode"] = LightColorMode.WHITE + color_modes = _filter_color_modes( + color_modes, + LightColorCapability.BRIGHTNESS | LightColorCapability.WHITE, + ) + try_keep_current_mode = False + + if self._supports_color_mode and color_modes: + # try the color mode with the least complexity (fewest capabilities set) + # popcount with bin() function because it appears to be the best way: https://stackoverflow.com/a/9831671 + color_modes.sort(key=lambda mode: bin(mode).count("1")) + data["color_mode"] = color_modes[0] + if self._supports_color_mode and color_modes: + if ( + try_keep_current_mode + and self._state is not None + and self._state.color_mode in color_modes + ): + # if possible, stay with the color mode that is already set + data["color_mode"] = self._state.color_mode + else: + # otherwise try the color mode with the least complexity (fewest capabilities set) + # popcount with bin() function because it appears to be the best way: https://stackoverflow.com/a/9831671 + color_modes.sort(key=lambda mode: bin(mode).count("1")) + data["color_mode"] = color_modes[0] await self._client.light_command(**data) @@ -198,7 +291,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): return None return next(iter(supported)) - return _COLOR_MODES.from_esphome(self._state.color_mode) + return _color_mode_to_ha(self._state.color_mode) @esphome_state_property def rgb_color(self) -> tuple[int, int, int] | None: @@ -227,9 +320,8 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): def rgbww_color(self) -> tuple[int, int, int, int, int] | None: """Return the rgbww color value [int, int, int, int, int].""" rgb = cast("tuple[int, int, int]", self.rgb_color) - if ( - not self._supports_color_mode - or self._state.color_mode != LightColorMode.RGB_COLD_WARM_WHITE + if not _filter_color_modes( + self._native_supported_color_modes, LightColorCapability.COLD_WARM_WHITE ): # Try to reverse white + color temp to cwww min_ct = self._static_info.min_mireds @@ -262,7 +354,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): return self._state.effect @property - def _native_supported_color_modes(self) -> list[LightColorMode]: + def _native_supported_color_modes(self) -> list[int]: return self._static_info.supported_color_modes_compat(self._api_version) @property @@ -272,7 +364,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): # All color modes except UNKNOWN,ON_OFF support transition modes = self._native_supported_color_modes - if any(m not in (LightColorMode.UNKNOWN, LightColorMode.ON_OFF) for m in modes): + if any(m not in (0, LightColorCapability.ON_OFF) for m in modes): flags |= SUPPORT_TRANSITION if self._static_info.effects: flags |= SUPPORT_EFFECT @@ -281,7 +373,14 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): @property def supported_color_modes(self) -> set[str] | None: """Flag supported color modes.""" - return set(map(_COLOR_MODES.from_esphome, self._native_supported_color_modes)) + supported = set(map(_color_mode_to_ha, self._native_supported_color_modes)) + if COLOR_MODE_ONOFF in supported and len(supported) > 1: + supported.remove(COLOR_MODE_ONOFF) + if COLOR_MODE_BRIGHTNESS in supported and len(supported) > 1: + supported.remove(COLOR_MODE_BRIGHTNESS) + if COLOR_MODE_WHITE in supported and len(supported) == 1: + supported.remove(COLOR_MODE_WHITE) + return supported @property def effect_list(self) -> list[str]: diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 96ac632d990..a78d2efb763 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -3,7 +3,7 @@ "name": "ESPHome", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/esphome", - "requirements": ["aioesphomeapi==7.0.0"], + "requirements": ["aioesphomeapi==8.0.0"], "zeroconf": ["_esphomelib._tcp.local."], "codeowners": ["@OttoWinter", "@jesserockz"], "after_dependencies": ["zeroconf", "tag"], diff --git a/requirements_all.txt b/requirements_all.txt index c9bd684bfd9..179017806ea 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -164,7 +164,7 @@ aioeagle==1.1.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==7.0.0 +aioesphomeapi==8.0.0 # homeassistant.components.flo aioflo==0.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index eb8ebeaea65..22f16c40b79 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -106,7 +106,7 @@ aioeagle==1.1.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==7.0.0 +aioesphomeapi==8.0.0 # homeassistant.components.flo aioflo==0.4.1 From 02b735659648b47e7e9ea1cc186189e37669d51e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Sep 2021 11:23:54 +0200 Subject: [PATCH 140/843] Add services to Renault integration (#54820) * Add services * Add tests * Cleanup async * Fix pylint * Update services.yaml * Add extra schema validation * Rename constants * Simplify code * Move constants * Fix pylint * Cleanup constants * Drop charge_set_mode as moved to select platform * Only register the services if no config entry has registered them yet * Replace VIN with device selector to select vehicle * Update logging * Adjust type checking * Use a shared base SERVICE_VEHICLE_SCHEMA * Add selectors for ac_start (temperature/when) * Add object selector for charge_set_schedules service --- homeassistant/components/renault/__init__.py | 6 + .../components/renault/renault_vehicle.py | 5 + homeassistant/components/renault/services.py | 165 +++++++++++ .../components/renault/services.yaml | 88 ++++++ tests/components/renault/test_services.py | 269 ++++++++++++++++++ .../fixtures/renault/action.set_ac_start.json | 7 + .../fixtures/renault/action.set_ac_stop.json | 7 + .../renault/action.set_charge_schedules.json | 38 +++ .../renault/action.set_charge_start.json | 7 + tests/fixtures/renault/charging_settings.json | 87 ++++++ 10 files changed, 679 insertions(+) create mode 100644 homeassistant/components/renault/services.py create mode 100644 homeassistant/components/renault/services.yaml create mode 100644 tests/components/renault/test_services.py create mode 100644 tests/fixtures/renault/action.set_ac_start.json create mode 100644 tests/fixtures/renault/action.set_ac_stop.json create mode 100644 tests/fixtures/renault/action.set_charge_schedules.json create mode 100644 tests/fixtures/renault/action.set_charge_start.json create mode 100644 tests/fixtures/renault/charging_settings.json diff --git a/homeassistant/components/renault/__init__.py b/homeassistant/components/renault/__init__.py index d4c065e52ca..781f81ab745 100644 --- a/homeassistant/components/renault/__init__.py +++ b/homeassistant/components/renault/__init__.py @@ -8,6 +8,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from .const import CONF_LOCALE, DOMAIN, PLATFORMS from .renault_hub import RenaultHub +from .services import SERVICE_AC_START, setup_services, unload_services async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: @@ -30,6 +31,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b hass.config_entries.async_setup_platforms(config_entry, PLATFORMS) + if not hass.services.has_service(DOMAIN, SERVICE_AC_START): + setup_services(hass) + return True @@ -41,5 +45,7 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> if unload_ok: hass.data[DOMAIN].pop(config_entry.entry_id) + if not hass.data[DOMAIN]: + unload_services(hass) return unload_ok diff --git a/homeassistant/components/renault/renault_vehicle.py b/homeassistant/components/renault/renault_vehicle.py index c955e5bfa65..1c21b21843d 100644 --- a/homeassistant/components/renault/renault_vehicle.py +++ b/homeassistant/components/renault/renault_vehicle.py @@ -76,6 +76,11 @@ class RenaultVehicleProxy: """Return a device description for device registry.""" return self._device_info + @property + def vehicle(self) -> RenaultVehicle: + """Return the underlying vehicle.""" + return self._vehicle + async def async_initialise(self) -> None: """Load available coordinators.""" self.coordinators = { diff --git a/homeassistant/components/renault/services.py b/homeassistant/components/renault/services.py new file mode 100644 index 00000000000..972befcec6d --- /dev/null +++ b/homeassistant/components/renault/services.py @@ -0,0 +1,165 @@ +"""Support for Renault services.""" +from __future__ import annotations + +from datetime import datetime +import logging +from types import MappingProxyType +from typing import TYPE_CHECKING, Any + +import voluptuous as vol + +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import config_validation as cv, device_registry as dr + +from .const import DOMAIN +from .renault_hub import RenaultHub +from .renault_vehicle import RenaultVehicleProxy + +LOGGER = logging.getLogger(__name__) + +ATTR_SCHEDULES = "schedules" +ATTR_TEMPERATURE = "temperature" +ATTR_VEHICLE = "vehicle" +ATTR_WHEN = "when" + +SERVICE_VEHICLE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_VEHICLE): cv.string, + } +) +SERVICE_AC_START_SCHEMA = SERVICE_VEHICLE_SCHEMA.extend( + { + vol.Required(ATTR_TEMPERATURE): cv.positive_float, + vol.Optional(ATTR_WHEN): cv.datetime, + } +) +SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA = vol.Schema( + { + vol.Required("startTime"): cv.string, + vol.Required("duration"): cv.positive_int, + } +) +SERVICE_CHARGE_SET_SCHEDULE_SCHEMA = vol.Schema( + { + vol.Required("id"): cv.positive_int, + vol.Optional("activated"): cv.boolean, + vol.Optional("monday"): vol.Schema(SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA), + vol.Optional("tuesday"): vol.Schema(SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA), + vol.Optional("wednesday"): vol.Schema(SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA), + vol.Optional("thursday"): vol.Schema(SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA), + vol.Optional("friday"): vol.Schema(SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA), + vol.Optional("saturday"): vol.Schema(SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA), + vol.Optional("sunday"): vol.Schema(SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA), + } +) +SERVICE_CHARGE_SET_SCHEDULES_SCHEMA = SERVICE_VEHICLE_SCHEMA.extend( + { + vol.Required(ATTR_SCHEDULES): vol.All( + cv.ensure_list, [SERVICE_CHARGE_SET_SCHEDULE_SCHEMA] + ), + } +) + +SERVICE_AC_CANCEL = "ac_cancel" +SERVICE_AC_START = "ac_start" +SERVICE_CHARGE_SET_SCHEDULES = "charge_set_schedules" +SERVICE_CHARGE_START = "charge_start" +SERVICES = [ + SERVICE_AC_CANCEL, + SERVICE_AC_START, + SERVICE_CHARGE_SET_SCHEDULES, + SERVICE_CHARGE_START, +] + + +def setup_services(hass: HomeAssistant) -> None: + """Register the Renault services.""" + + async def ac_cancel(service_call: ServiceCall) -> None: + """Cancel A/C.""" + proxy = get_vehicle_proxy(service_call.data) + + LOGGER.debug("A/C cancel attempt") + result = await proxy.vehicle.set_ac_stop() + LOGGER.debug("A/C cancel result: %s", result) + + async def ac_start(service_call: ServiceCall) -> None: + """Start A/C.""" + temperature: float = service_call.data[ATTR_TEMPERATURE] + when: datetime | None = service_call.data.get(ATTR_WHEN) + proxy = get_vehicle_proxy(service_call.data) + + LOGGER.debug("A/C start attempt: %s / %s", temperature, when) + result = await proxy.vehicle.set_ac_start(temperature, when) + LOGGER.debug("A/C start result: %s", result.raw_data) + + async def charge_set_schedules(service_call: ServiceCall) -> None: + """Set charge schedules.""" + schedules: list[dict[str, Any]] = service_call.data[ATTR_SCHEDULES] + proxy = get_vehicle_proxy(service_call.data) + charge_schedules = await proxy.vehicle.get_charging_settings() + for schedule in schedules: + charge_schedules.update(schedule) + + if TYPE_CHECKING: + assert charge_schedules.schedules is not None + LOGGER.debug("Charge set schedules attempt: %s", schedules) + result = await proxy.vehicle.set_charge_schedules(charge_schedules.schedules) + LOGGER.debug("Charge set schedules result: %s", result) + LOGGER.debug( + "It may take some time before these changes are reflected in your vehicle" + ) + + async def charge_start(service_call: ServiceCall) -> None: + """Start charge.""" + proxy = get_vehicle_proxy(service_call.data) + + LOGGER.debug("Charge start attempt") + result = await proxy.vehicle.set_charge_start() + LOGGER.debug("Charge start result: %s", result) + + def get_vehicle_proxy(service_call_data: MappingProxyType) -> RenaultVehicleProxy: + """Get vehicle from service_call data.""" + device_registry = dr.async_get(hass) + device_id = service_call_data[ATTR_VEHICLE] + device_entry = device_registry.async_get(device_id) + if device_entry is None: + raise ValueError(f"Unable to find device with id: {device_id}") + + proxy: RenaultHub + for proxy in hass.data[DOMAIN].values(): + for vin, vehicle in proxy.vehicles.items(): + if (DOMAIN, vin) in device_entry.identifiers: + return vehicle + raise ValueError(f"Unable to find vehicle with VIN: {device_entry.identifiers}") + + hass.services.async_register( + DOMAIN, + SERVICE_AC_CANCEL, + ac_cancel, + schema=SERVICE_VEHICLE_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_AC_START, + ac_start, + schema=SERVICE_AC_START_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_CHARGE_SET_SCHEDULES, + charge_set_schedules, + schema=SERVICE_CHARGE_SET_SCHEDULES_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_CHARGE_START, + charge_start, + schema=SERVICE_VEHICLE_SCHEMA, + ) + + +def unload_services(hass: HomeAssistant) -> None: + """Unload Renault services.""" + for service in SERVICES: + hass.services.async_remove(DOMAIN, service) diff --git a/homeassistant/components/renault/services.yaml b/homeassistant/components/renault/services.yaml new file mode 100644 index 00000000000..7dd2f73ef4b --- /dev/null +++ b/homeassistant/components/renault/services.yaml @@ -0,0 +1,88 @@ +ac_start: + description: Start A/C on vehicle. + fields: + vehicle: + name: Vehicle + description: The vehicle to send the command to. + required: true + selector: + device: + integration: renault + temperature: + description: Target A/C temperature in °C. + example: "21" + required: true + selector: + number: + min: 15 + max: 25 + step: 0.5 + unit_of_measurement: °C + when: + description: Timestamp for the start of the A/C (optional - defaults to now). + example: "2020-05-01T17:45:00" + selector: + text: + +ac_cancel: + description: Cancel A/C on vehicle. + fields: + vehicle: + name: Vehicle + description: The vehicle to send the command to. + required: true + selector: + device: + integration: renault + +charge_set_schedules: + description: Update charge schedule on vehicle. + fields: + vehicle: + name: Vehicle + description: The vehicle to send the command to. + required: true + selector: + device: + integration: renault + schedules: + description: Schedule details. + example: >- + [ + { + 'id':1, + 'activated':true, + 'monday':{'startTime':'T12:00Z','duration':15}, + 'tuesday':{'startTime':'T12:00Z','duration':15}, + 'wednesday':{'startTime':'T12:00Z','duration':15}, + 'thursday':{'startTime':'T12:00Z','duration':15}, + 'friday':{'startTime':'T12:00Z','duration':15}, + 'saturday':{'startTime':'T12:00Z','duration':15}, + 'sunday':{'startTime':'T12:00Z','duration':15} + }, + { + 'id':2, + 'activated':false, + 'monday':{'startTime':'T12:00Z','duration':240}, + 'tuesday':{'startTime':'T12:00Z','duration':240}, + 'wednesday':{'startTime':'T12:00Z','duration':240}, + 'thursday':{'startTime':'T12:00Z','duration':240}, + 'friday':{'startTime':'T12:00Z','duration':240}, + 'saturday':{'startTime':'T12:00Z','duration':240}, + 'sunday':{'startTime':'T12:00Z','duration':240} + }, + ] + required: true + selector: + object: + +charge_start: + description: Start charge on vehicle. + fields: + vehicle: + name: Vehicle + description: The vehicle to send the command to. + required: true + selector: + device: + integration: renault diff --git a/tests/components/renault/test_services.py b/tests/components/renault/test_services.py new file mode 100644 index 00000000000..37c3d71af61 --- /dev/null +++ b/tests/components/renault/test_services.py @@ -0,0 +1,269 @@ +"""Tests for Renault sensors.""" +from datetime import datetime +from unittest.mock import patch + +import pytest +from renault_api.kamereon import schemas +from renault_api.kamereon.models import ChargeSchedule + +from homeassistant.components.renault.const import DOMAIN +from homeassistant.components.renault.services import ( + ATTR_SCHEDULES, + ATTR_TEMPERATURE, + ATTR_VEHICLE, + ATTR_WHEN, + SERVICE_AC_CANCEL, + SERVICE_AC_START, + SERVICE_CHARGE_SET_SCHEDULES, + SERVICE_CHARGE_START, + SERVICES, +) +from homeassistant.const import ( + ATTR_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_NAME, + ATTR_SW_VERSION, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import setup_renault_integration_simple, setup_renault_integration_vehicle + +from tests.common import load_fixture +from tests.components.renault.const import MOCK_VEHICLES + + +def get_device_id(hass: HomeAssistant) -> str: + """Get device_id.""" + device_registry = dr.async_get(hass) + identifiers = {(DOMAIN, "VF1AAAAA555777999")} + device = device_registry.async_get_device(identifiers) + return device.id + + +async def test_service_registration(hass: HomeAssistant): + """Test entry setup and unload.""" + with patch("homeassistant.components.renault.PLATFORMS", []): + config_entry = await setup_renault_integration_simple(hass) + + # Check that all services are registered. + for service in SERVICES: + assert hass.services.has_service(DOMAIN, service) + + # Unload the entry + await hass.config_entries.async_unload(config_entry.entry_id) + + # Check that all services are un-registered. + for service in SERVICES: + assert not hass.services.has_service(DOMAIN, service) + + +async def test_service_set_ac_cancel(hass: HomeAssistant): + """Test that service invokes renault_api with correct data.""" + await setup_renault_integration_vehicle(hass, "zoe_40") + + data = { + ATTR_VEHICLE: get_device_id(hass), + } + + with patch( + "renault_api.renault_vehicle.RenaultVehicle.set_ac_stop", + return_value=( + schemas.KamereonVehicleHvacStartActionDataSchema.loads( + load_fixture("renault/action.set_ac_stop.json") + ) + ), + ) as mock_action: + await hass.services.async_call( + DOMAIN, SERVICE_AC_CANCEL, service_data=data, blocking=True + ) + assert len(mock_action.mock_calls) == 1 + assert mock_action.mock_calls[0][1] == () + + +async def test_service_set_ac_start_simple(hass: HomeAssistant): + """Test that service invokes renault_api with correct data.""" + await setup_renault_integration_vehicle(hass, "zoe_40") + + temperature = 13.5 + data = { + ATTR_VEHICLE: get_device_id(hass), + ATTR_TEMPERATURE: temperature, + } + + with patch( + "renault_api.renault_vehicle.RenaultVehicle.set_ac_start", + return_value=( + schemas.KamereonVehicleHvacStartActionDataSchema.loads( + load_fixture("renault/action.set_ac_start.json") + ) + ), + ) as mock_action: + await hass.services.async_call( + DOMAIN, SERVICE_AC_START, service_data=data, blocking=True + ) + assert len(mock_action.mock_calls) == 1 + assert mock_action.mock_calls[0][1] == (temperature, None) + + +async def test_service_set_ac_start_with_date(hass: HomeAssistant): + """Test that service invokes renault_api with correct data.""" + await setup_renault_integration_vehicle(hass, "zoe_40") + + temperature = 13.5 + when = datetime(2025, 8, 23, 17, 12, 45) + data = { + ATTR_VEHICLE: get_device_id(hass), + ATTR_TEMPERATURE: temperature, + ATTR_WHEN: when, + } + + with patch( + "renault_api.renault_vehicle.RenaultVehicle.set_ac_start", + return_value=( + schemas.KamereonVehicleHvacStartActionDataSchema.loads( + load_fixture("renault/action.set_ac_start.json") + ) + ), + ) as mock_action: + await hass.services.async_call( + DOMAIN, SERVICE_AC_START, service_data=data, blocking=True + ) + assert len(mock_action.mock_calls) == 1 + assert mock_action.mock_calls[0][1] == (temperature, when) + + +async def test_service_set_charge_schedule(hass: HomeAssistant): + """Test that service invokes renault_api with correct data.""" + await setup_renault_integration_vehicle(hass, "zoe_40") + + schedules = {"id": 2} + data = { + ATTR_VEHICLE: get_device_id(hass), + ATTR_SCHEDULES: schedules, + } + + with patch( + "renault_api.renault_vehicle.RenaultVehicle.get_charging_settings", + return_value=schemas.KamereonVehicleDataResponseSchema.loads( + load_fixture("renault/charging_settings.json") + ).get_attributes(schemas.KamereonVehicleChargingSettingsDataSchema), + ), patch( + "renault_api.renault_vehicle.RenaultVehicle.set_charge_schedules", + return_value=( + schemas.KamereonVehicleHvacStartActionDataSchema.loads( + load_fixture("renault/action.set_charge_schedules.json") + ) + ), + ) as mock_action: + await hass.services.async_call( + DOMAIN, SERVICE_CHARGE_SET_SCHEDULES, service_data=data, blocking=True + ) + assert len(mock_action.mock_calls) == 1 + mock_call_data: list[ChargeSchedule] = mock_action.mock_calls[0][1][0] + assert mock_action.mock_calls[0][1] == (mock_call_data,) + + +async def test_service_set_charge_schedule_multi(hass: HomeAssistant): + """Test that service invokes renault_api with correct data.""" + await setup_renault_integration_vehicle(hass, "zoe_40") + + schedules = [ + { + "id": 2, + "activated": True, + "monday": {"startTime": "T12:00Z", "duration": 15}, + "tuesday": {"startTime": "T12:00Z", "duration": 15}, + "wednesday": {"startTime": "T12:00Z", "duration": 15}, + "thursday": {"startTime": "T12:00Z", "duration": 15}, + "friday": {"startTime": "T12:00Z", "duration": 15}, + "saturday": {"startTime": "T12:00Z", "duration": 15}, + "sunday": {"startTime": "T12:00Z", "duration": 15}, + }, + {"id": 3}, + ] + data = { + ATTR_VEHICLE: get_device_id(hass), + ATTR_SCHEDULES: schedules, + } + + with patch( + "renault_api.renault_vehicle.RenaultVehicle.get_charging_settings", + return_value=schemas.KamereonVehicleDataResponseSchema.loads( + load_fixture("renault/charging_settings.json") + ).get_attributes(schemas.KamereonVehicleChargingSettingsDataSchema), + ), patch( + "renault_api.renault_vehicle.RenaultVehicle.set_charge_schedules", + return_value=( + schemas.KamereonVehicleHvacStartActionDataSchema.loads( + load_fixture("renault/action.set_charge_schedules.json") + ) + ), + ) as mock_action: + await hass.services.async_call( + DOMAIN, SERVICE_CHARGE_SET_SCHEDULES, service_data=data, blocking=True + ) + assert len(mock_action.mock_calls) == 1 + mock_call_data: list[ChargeSchedule] = mock_action.mock_calls[0][1][0] + assert mock_action.mock_calls[0][1] == (mock_call_data,) + + +async def test_service_set_charge_start(hass: HomeAssistant): + """Test that service invokes renault_api with correct data.""" + await setup_renault_integration_vehicle(hass, "zoe_40") + + data = { + ATTR_VEHICLE: get_device_id(hass), + } + + with patch( + "renault_api.renault_vehicle.RenaultVehicle.set_charge_start", + return_value=( + schemas.KamereonVehicleHvacStartActionDataSchema.loads( + load_fixture("renault/action.set_charge_start.json") + ) + ), + ) as mock_action: + await hass.services.async_call( + DOMAIN, SERVICE_CHARGE_START, service_data=data, blocking=True + ) + assert len(mock_action.mock_calls) == 1 + assert mock_action.mock_calls[0][1] == () + + +async def test_service_invalid_device_id(hass: HomeAssistant): + """Test that service fails with ValueError if device_id not found in registry.""" + await setup_renault_integration_vehicle(hass, "zoe_40") + + data = {ATTR_VEHICLE: "VF1AAAAA555777999"} + + with pytest.raises(ValueError): + await hass.services.async_call( + DOMAIN, SERVICE_AC_CANCEL, service_data=data, blocking=True + ) + + +async def test_service_invalid_device_id2(hass: HomeAssistant): + """Test that service fails with ValueError if device_id not found in vehicles.""" + config_entry = await setup_renault_integration_vehicle(hass, "zoe_40") + + extra_vehicle = MOCK_VEHICLES["captur_phev"]["expected_device"] + + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers=extra_vehicle[ATTR_IDENTIFIERS], + manufacturer=extra_vehicle[ATTR_MANUFACTURER], + name=extra_vehicle[ATTR_NAME], + model=extra_vehicle[ATTR_MODEL], + sw_version=extra_vehicle[ATTR_SW_VERSION], + ) + device_id = device_registry.async_get_device(extra_vehicle[ATTR_IDENTIFIERS]).id + + data = {ATTR_VEHICLE: device_id} + + with pytest.raises(ValueError): + await hass.services.async_call( + DOMAIN, SERVICE_AC_CANCEL, service_data=data, blocking=True + ) diff --git a/tests/fixtures/renault/action.set_ac_start.json b/tests/fixtures/renault/action.set_ac_start.json new file mode 100644 index 00000000000..7aca3269a61 --- /dev/null +++ b/tests/fixtures/renault/action.set_ac_start.json @@ -0,0 +1,7 @@ +{ + "data": { + "type": "HvacStart", + "id": "guid", + "attributes": { "action": "start", "targetTemperature": 21.0 } + } +} diff --git a/tests/fixtures/renault/action.set_ac_stop.json b/tests/fixtures/renault/action.set_ac_stop.json new file mode 100644 index 00000000000..df7a94cbf78 --- /dev/null +++ b/tests/fixtures/renault/action.set_ac_stop.json @@ -0,0 +1,7 @@ +{ + "data": { + "type": "HvacStart", + "id": "guid", + "attributes": { "action": "cancel" } + } +} diff --git a/tests/fixtures/renault/action.set_charge_schedules.json b/tests/fixtures/renault/action.set_charge_schedules.json new file mode 100644 index 00000000000..7f60826b826 --- /dev/null +++ b/tests/fixtures/renault/action.set_charge_schedules.json @@ -0,0 +1,38 @@ +{ + "data": { + "type": "ChargeSchedule", + "id": "guid", + "attributes": { + "schedules": [ + { + "id": 1, + "activated": true, + "tuesday": { + "startTime": "T04:30Z", + "duration": 420 + }, + "wednesday": { + "startTime": "T22:30Z", + "duration": 420 + }, + "thursday": { + "startTime": "T22:00Z", + "duration": 420 + }, + "friday": { + "startTime": "T23:30Z", + "duration": 480 + }, + "saturday": { + "startTime": "T18:30Z", + "duration": 120 + }, + "sunday": { + "startTime": "T12:45Z", + "duration": 45 + } + } + ] + } + } +} diff --git a/tests/fixtures/renault/action.set_charge_start.json b/tests/fixtures/renault/action.set_charge_start.json new file mode 100644 index 00000000000..3adb70514b4 --- /dev/null +++ b/tests/fixtures/renault/action.set_charge_start.json @@ -0,0 +1,7 @@ +{ + "data": { + "type": "ChargingStart", + "id": "guid", + "attributes": { "action": "start" } + } +} diff --git a/tests/fixtures/renault/charging_settings.json b/tests/fixtures/renault/charging_settings.json new file mode 100644 index 00000000000..466353bb081 --- /dev/null +++ b/tests/fixtures/renault/charging_settings.json @@ -0,0 +1,87 @@ +{ + "data": { + "type": "Car", + "id": "VF1AAAAA555777999", + "attributes": { + "mode": "scheduled", + "schedules": [ + { + "id": 1, + "activated": true, + "monday": { + "startTime": "T00:00Z", + "duration": 450 + }, + "tuesday": { + "startTime": "T00:00Z", + "duration": 450 + }, + "wednesday": { + "startTime": "T00:00Z", + "duration": 450 + }, + "thursday": { + "startTime": "T00:00Z", + "duration": 450 + }, + "friday": { + "startTime": "T00:00Z", + "duration": 450 + }, + "saturday": { + "startTime": "T00:00Z", + "duration": 450 + }, + "sunday": { + "startTime": "T00:00Z", + "duration": 450 + } + }, + { + "id": 2, + "activated": true, + "monday": { + "startTime": "T23:30Z", + "duration": 15 + }, + "tuesday": { + "startTime": "T23:30Z", + "duration": 15 + }, + "wednesday": { + "startTime": "T23:30Z", + "duration": 15 + }, + "thursday": { + "startTime": "T23:30Z", + "duration": 15 + }, + "friday": { + "startTime": "T23:30Z", + "duration": 15 + }, + "saturday": { + "startTime": "T23:30Z", + "duration": 15 + }, + "sunday": { + "startTime": "T23:30Z", + "duration": 15 + } + }, + { + "id": 3, + "activated": false + }, + { + "id": 4, + "activated": false + }, + { + "id": 5, + "activated": false + } + ] + } + } +} From befcafbc49aeed56a3928b210f80d8b8160df81f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Wed, 1 Sep 2021 11:27:21 +0200 Subject: [PATCH 141/843] Mock setup in spotify tests (#55515) --- tests/components/spotify/test_config_flow.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/components/spotify/test_config_flow.py b/tests/components/spotify/test_config_flow.py index cd0be3f7cc8..fbc5bde9e58 100644 --- a/tests/components/spotify/test_config_flow.py +++ b/tests/components/spotify/test_config_flow.py @@ -93,7 +93,9 @@ async def test_full_flow( }, ) - with patch("homeassistant.components.spotify.config_flow.Spotify") as spotify_mock: + with patch( + "homeassistant.components.spotify.async_setup_entry", return_value=True + ), patch("homeassistant.components.spotify.config_flow.Spotify") as spotify_mock: spotify_mock.return_value.current_user.return_value = { "id": "fake_id", "display_name": "frenck", @@ -210,7 +212,9 @@ async def test_reauthentication( }, ) - with patch("homeassistant.components.spotify.config_flow.Spotify") as spotify_mock: + with patch( + "homeassistant.components.spotify.async_setup_entry", return_value=True + ), patch("homeassistant.components.spotify.config_flow.Spotify") as spotify_mock: spotify_mock.return_value.current_user.return_value = {"id": "frenck"} result = await hass.config_entries.flow.async_configure(result["flow_id"]) From 04a052a37d586e298751dd35df94845aff494ba9 Mon Sep 17 00:00:00 2001 From: Stefan <37924749+stefanroelofs@users.noreply.github.com> Date: Wed, 1 Sep 2021 12:26:56 +0200 Subject: [PATCH 142/843] Fix moon phases (#55518) --- homeassistant/components/moon/sensor.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/moon/sensor.py b/homeassistant/components/moon/sensor.py index 223ee831779..138c842b7e8 100644 --- a/homeassistant/components/moon/sensor.py +++ b/homeassistant/components/moon/sensor.py @@ -62,19 +62,19 @@ class MoonSensor(SensorEntity): @property def native_value(self): """Return the state of the device.""" - if self._state == 0: + if self._state < 0.5 or self._state > 27.5: return STATE_NEW_MOON - if self._state < 7: + if self._state < 6.5: return STATE_WAXING_CRESCENT - if self._state == 7: + if self._state < 7.5: return STATE_FIRST_QUARTER - if self._state < 14: + if self._state < 13.5: return STATE_WAXING_GIBBOUS - if self._state == 14: + if self._state < 14.5: return STATE_FULL_MOON - if self._state < 21: + if self._state < 20.5: return STATE_WANING_GIBBOUS - if self._state == 21: + if self._state < 21.5: return STATE_LAST_QUARTER return STATE_WANING_CRESCENT From bcf97cb3081b5dfd70e1bdd3a4a013c74b1ee8b2 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Sep 2021 13:10:48 +0200 Subject: [PATCH 143/843] Add device tracker platform to Renault integration (#54745) --- homeassistant/components/renault/const.py | 2 + .../components/renault/device_tracker.py | 61 +++++++ .../components/renault/renault_entities.py | 20 ++- .../components/renault/renault_vehicle.py | 5 + tests/components/renault/__init__.py | 14 ++ tests/components/renault/const.py | 101 +++++++---- .../components/renault/test_device_tracker.py | 164 ++++++++++++++++++ tests/fixtures/renault/location.json | 11 ++ 8 files changed, 343 insertions(+), 35 deletions(-) create mode 100644 homeassistant/components/renault/device_tracker.py create mode 100644 tests/components/renault/test_device_tracker.py create mode 100644 tests/fixtures/renault/location.json diff --git a/homeassistant/components/renault/const.py b/homeassistant/components/renault/const.py index 824779a4d3e..e080e2b5962 100644 --- a/homeassistant/components/renault/const.py +++ b/homeassistant/components/renault/const.py @@ -1,5 +1,6 @@ """Constants for the Renault component.""" from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN DOMAIN = "renault" @@ -11,6 +12,7 @@ DEFAULT_SCAN_INTERVAL = 300 # 5 minutes PLATFORMS = [ BINARY_SENSOR_DOMAIN, + DEVICE_TRACKER_DOMAIN, SENSOR_DOMAIN, ] diff --git a/homeassistant/components/renault/device_tracker.py b/homeassistant/components/renault/device_tracker.py new file mode 100644 index 00000000000..466a1f9e4a6 --- /dev/null +++ b/homeassistant/components/renault/device_tracker.py @@ -0,0 +1,61 @@ +"""Support for Renault device trackers.""" +from __future__ import annotations + +from renault_api.kamereon.models import KamereonVehicleLocationData + +from homeassistant.components.device_tracker import SOURCE_TYPE_GPS +from homeassistant.components.device_tracker.config_entry import TrackerEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .renault_entities import RenaultDataEntity, RenaultEntityDescription +from .renault_hub import RenaultHub + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Renault entities from config entry.""" + proxy: RenaultHub = hass.data[DOMAIN][config_entry.entry_id] + entities: list[RenaultDeviceTracker] = [ + RenaultDeviceTracker(vehicle, description) + for vehicle in proxy.vehicles.values() + for description in DEVICE_TRACKER_TYPES + if description.coordinator in vehicle.coordinators + ] + async_add_entities(entities) + + +class RenaultDeviceTracker( + RenaultDataEntity[KamereonVehicleLocationData], TrackerEntity +): + """Mixin for device tracker specific attributes.""" + + @property + def latitude(self) -> float | None: + """Return latitude value of the device.""" + return self.coordinator.data.gpsLatitude if self.coordinator.data else None + + @property + def longitude(self) -> float | None: + """Return longitude value of the device.""" + return self.coordinator.data.gpsLongitude if self.coordinator.data else None + + @property + def source_type(self) -> str: + """Return the source type of the device.""" + return SOURCE_TYPE_GPS + + +DEVICE_TRACKER_TYPES: tuple[RenaultEntityDescription, ...] = ( + RenaultEntityDescription( + key="location", + coordinator="location", + icon="mdi:car", + name="Location", + ), +) diff --git a/homeassistant/components/renault/renault_entities.py b/homeassistant/components/renault/renault_entities.py index 29d1aa4b860..e0aae72298b 100644 --- a/homeassistant/components/renault/renault_entities.py +++ b/homeassistant/components/renault/renault_entities.py @@ -3,11 +3,12 @@ from __future__ import annotations from collections.abc import Mapping from dataclasses import dataclass -from typing import Any, Optional, cast +from typing import TYPE_CHECKING, Any, Optional, cast from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util.dt import as_utc, parse_datetime from .renault_coordinator import T from .renault_vehicle import RenaultVehicleProxy @@ -54,8 +55,19 @@ class RenaultDataEntity(CoordinatorEntity[Optional[T]], Entity): @property def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return the state attributes of this entity.""" + last_update: str | None = None if self.entity_description.coordinator == "battery": - last_update = self._get_data_attr("timestamp") - if last_update: - return {ATTR_LAST_UPDATE: last_update} + last_update = cast(str, self._get_data_attr("timestamp")) + elif self.entity_description.coordinator == "location": + last_update = cast(str, self._get_data_attr("lastUpdateTime")) + if last_update: + return {ATTR_LAST_UPDATE: _convert_to_utc_string(last_update)} return None + + +def _convert_to_utc_string(value: str) -> str: + """Convert date to UTC iso format.""" + original_dt = parse_datetime(value) + if TYPE_CHECKING: + assert original_dt is not None + return as_utc(original_dt).isoformat() diff --git a/homeassistant/components/renault/renault_vehicle.py b/homeassistant/components/renault/renault_vehicle.py index 1c21b21843d..90bc4a2def4 100644 --- a/homeassistant/components/renault/renault_vehicle.py +++ b/homeassistant/components/renault/renault_vehicle.py @@ -138,6 +138,11 @@ COORDINATORS: tuple[RenaultCoordinatorDescription, ...] = ( key="hvac_status", update_method=lambda x: x.get_hvac_status, ), + RenaultCoordinatorDescription( + endpoint="location", + key="location", + update_method=lambda x: x.get_location, + ), RenaultCoordinatorDescription( endpoint="battery-status", key="battery", diff --git a/tests/components/renault/__init__.py b/tests/components/renault/__init__.py index f77c4bcd40a..bbca3a74139 100644 --- a/tests/components/renault/__init__.py +++ b/tests/components/renault/__init__.py @@ -63,6 +63,11 @@ def get_fixtures(vehicle_type: str) -> dict[str, Any]: if "hvac_status" in mock_vehicle["endpoints"] else load_fixture("renault/no_data.json") ).get_attributes(schemas.KamereonVehicleHvacStatusDataSchema), + "location": schemas.KamereonVehicleDataResponseSchema.loads( + load_fixture(f"renault/{mock_vehicle['endpoints']['location']}") + if "location" in mock_vehicle["endpoints"] + else load_fixture("renault/no_data.json") + ).get_attributes(schemas.KamereonVehicleLocationDataSchema), } @@ -132,6 +137,9 @@ async def setup_renault_integration_vehicle(hass: HomeAssistant, vehicle_type: s ), patch( "renault_api.renault_vehicle.RenaultVehicle.get_hvac_status", return_value=mock_fixtures["hvac_status"], + ), patch( + "renault_api.renault_vehicle.RenaultVehicle.get_location", + return_value=mock_fixtures["location"], ): await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -181,6 +189,9 @@ async def setup_renault_integration_vehicle_with_no_data( ), patch( "renault_api.renault_vehicle.RenaultVehicle.get_hvac_status", return_value=mock_fixtures["hvac_status"], + ), patch( + "renault_api.renault_vehicle.RenaultVehicle.get_location", + return_value=mock_fixtures["location"], ): await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -229,6 +240,9 @@ async def setup_renault_integration_vehicle_with_side_effect( ), patch( "renault_api.renault_vehicle.RenaultVehicle.get_hvac_status", side_effect=side_effect, + ), patch( + "renault_api.renault_vehicle.RenaultVehicle.get_location", + side_effect=side_effect, ): await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/renault/const.py b/tests/components/renault/const.py index e955f24018a..cbc94c61bf4 100644 --- a/tests/components/renault/const.py +++ b/tests/components/renault/const.py @@ -4,6 +4,7 @@ from homeassistant.components.binary_sensor import ( DEVICE_CLASS_PLUG, DOMAIN as BINARY_SENSOR_DOMAIN, ) +from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN from homeassistant.components.renault.const import ( CONF_KAMEREON_ACCOUNT_ID, CONF_LOCALE, @@ -33,6 +34,7 @@ from homeassistant.const import ( LENGTH_KILOMETERS, PERCENTAGE, POWER_KILO_WATT, + STATE_NOT_HOME, STATE_OFF, STATE_ON, STATE_UNKNOWN, @@ -77,6 +79,7 @@ MOCK_VEHICLES = { "endpoints_available": [ True, # cockpit True, # hvac-status + False, # location True, # battery-status True, # charge-mode ], @@ -92,23 +95,24 @@ MOCK_VEHICLES = { "unique_id": "vf1aaaaa555777999_plugged_in", "result": STATE_ON, ATTR_DEVICE_CLASS: DEVICE_CLASS_PLUG, - ATTR_LAST_UPDATE: "2020-01-12T21:40:16Z", + ATTR_LAST_UPDATE: "2020-01-12T21:40:16+00:00", }, { "entity_id": "binary_sensor.charging", "unique_id": "vf1aaaaa555777999_charging", "result": STATE_ON, ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY_CHARGING, - ATTR_LAST_UPDATE: "2020-01-12T21:40:16Z", + ATTR_LAST_UPDATE: "2020-01-12T21:40:16+00:00", }, ], + DEVICE_TRACKER_DOMAIN: [], SENSOR_DOMAIN: [ { "entity_id": "sensor.battery_autonomy", "unique_id": "vf1aaaaa555777999_battery_autonomy", "result": "141", ATTR_ICON: "mdi:ev-station", - ATTR_LAST_UPDATE: "2020-01-12T21:40:16Z", + ATTR_LAST_UPDATE: "2020-01-12T21:40:16+00:00", ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, ATTR_UNIT_OF_MEASUREMENT: LENGTH_KILOMETERS, }, @@ -117,7 +121,7 @@ MOCK_VEHICLES = { "unique_id": "vf1aaaaa555777999_battery_available_energy", "result": "31", ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - ATTR_LAST_UPDATE: "2020-01-12T21:40:16Z", + ATTR_LAST_UPDATE: "2020-01-12T21:40:16+00:00", ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, }, @@ -126,7 +130,7 @@ MOCK_VEHICLES = { "unique_id": "vf1aaaaa555777999_battery_level", "result": "60", ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY, - ATTR_LAST_UPDATE: "2020-01-12T21:40:16Z", + ATTR_LAST_UPDATE: "2020-01-12T21:40:16+00:00", ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, }, @@ -135,7 +139,7 @@ MOCK_VEHICLES = { "unique_id": "vf1aaaaa555777999_battery_temperature", "result": "20", ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_LAST_UPDATE: "2020-01-12T21:40:16Z", + ATTR_LAST_UPDATE: "2020-01-12T21:40:16+00:00", ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, }, @@ -152,14 +156,14 @@ MOCK_VEHICLES = { "result": "charge_in_progress", ATTR_DEVICE_CLASS: DEVICE_CLASS_CHARGE_STATE, ATTR_ICON: "mdi:flash", - ATTR_LAST_UPDATE: "2020-01-12T21:40:16Z", + ATTR_LAST_UPDATE: "2020-01-12T21:40:16+00:00", }, { "entity_id": "sensor.charging_power", "unique_id": "vf1aaaaa555777999_charging_power", "result": "0.027", ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, - ATTR_LAST_UPDATE: "2020-01-12T21:40:16Z", + ATTR_LAST_UPDATE: "2020-01-12T21:40:16+00:00", ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, ATTR_UNIT_OF_MEASUREMENT: POWER_KILO_WATT, }, @@ -168,7 +172,7 @@ MOCK_VEHICLES = { "unique_id": "vf1aaaaa555777999_charging_remaining_time", "result": "145", ATTR_ICON: "mdi:timer", - ATTR_LAST_UPDATE: "2020-01-12T21:40:16Z", + ATTR_LAST_UPDATE: "2020-01-12T21:40:16+00:00", ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, ATTR_UNIT_OF_MEASUREMENT: TIME_MINUTES, }, @@ -194,7 +198,7 @@ MOCK_VEHICLES = { "result": "plugged", ATTR_DEVICE_CLASS: DEVICE_CLASS_PLUG_STATE, ATTR_ICON: "mdi:power-plug", - ATTR_LAST_UPDATE: "2020-01-12T21:40:16Z", + ATTR_LAST_UPDATE: "2020-01-12T21:40:16+00:00", }, ], }, @@ -209,6 +213,7 @@ MOCK_VEHICLES = { "endpoints_available": [ True, # cockpit False, # hvac-status + True, # location True, # battery-status True, # charge-mode ], @@ -216,6 +221,7 @@ MOCK_VEHICLES = { "battery_status": "battery_status_not_charging.json", "charge_mode": "charge_mode_schedule.json", "cockpit": "cockpit_ev.json", + "location": "location.json", }, BINARY_SENSOR_DOMAIN: [ { @@ -223,23 +229,32 @@ MOCK_VEHICLES = { "unique_id": "vf1aaaaa555777999_plugged_in", "result": STATE_OFF, ATTR_DEVICE_CLASS: DEVICE_CLASS_PLUG, - ATTR_LAST_UPDATE: "2020-11-17T09:06:48+01:00", + ATTR_LAST_UPDATE: "2020-11-17T08:06:48+00:00", }, { "entity_id": "binary_sensor.charging", "unique_id": "vf1aaaaa555777999_charging", "result": STATE_OFF, ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY_CHARGING, - ATTR_LAST_UPDATE: "2020-11-17T09:06:48+01:00", + ATTR_LAST_UPDATE: "2020-11-17T08:06:48+00:00", }, ], + DEVICE_TRACKER_DOMAIN: [ + { + "entity_id": "device_tracker.location", + "unique_id": "vf1aaaaa555777999_location", + "result": STATE_NOT_HOME, + ATTR_ICON: "mdi:car", + ATTR_LAST_UPDATE: "2020-02-18T16:58:38+00:00", + } + ], SENSOR_DOMAIN: [ { "entity_id": "sensor.battery_autonomy", "unique_id": "vf1aaaaa555777999_battery_autonomy", "result": "128", ATTR_ICON: "mdi:ev-station", - ATTR_LAST_UPDATE: "2020-11-17T09:06:48+01:00", + ATTR_LAST_UPDATE: "2020-11-17T08:06:48+00:00", ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, ATTR_UNIT_OF_MEASUREMENT: LENGTH_KILOMETERS, }, @@ -248,7 +263,7 @@ MOCK_VEHICLES = { "unique_id": "vf1aaaaa555777999_battery_available_energy", "result": "0", ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - ATTR_LAST_UPDATE: "2020-11-17T09:06:48+01:00", + ATTR_LAST_UPDATE: "2020-11-17T08:06:48+00:00", ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, }, @@ -257,7 +272,7 @@ MOCK_VEHICLES = { "unique_id": "vf1aaaaa555777999_battery_level", "result": "50", ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY, - ATTR_LAST_UPDATE: "2020-11-17T09:06:48+01:00", + ATTR_LAST_UPDATE: "2020-11-17T08:06:48+00:00", ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, }, @@ -266,7 +281,7 @@ MOCK_VEHICLES = { "unique_id": "vf1aaaaa555777999_battery_temperature", "result": STATE_UNKNOWN, ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_LAST_UPDATE: "2020-11-17T09:06:48+01:00", + ATTR_LAST_UPDATE: "2020-11-17T08:06:48+00:00", ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, }, @@ -283,14 +298,14 @@ MOCK_VEHICLES = { "result": "charge_error", ATTR_DEVICE_CLASS: DEVICE_CLASS_CHARGE_STATE, ATTR_ICON: "mdi:flash-off", - ATTR_LAST_UPDATE: "2020-11-17T09:06:48+01:00", + ATTR_LAST_UPDATE: "2020-11-17T08:06:48+00:00", }, { "entity_id": "sensor.charging_power", "unique_id": "vf1aaaaa555777999_charging_power", "result": STATE_UNKNOWN, ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, - ATTR_LAST_UPDATE: "2020-11-17T09:06:48+01:00", + ATTR_LAST_UPDATE: "2020-11-17T08:06:48+00:00", ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, ATTR_UNIT_OF_MEASUREMENT: POWER_KILO_WATT, }, @@ -299,7 +314,7 @@ MOCK_VEHICLES = { "unique_id": "vf1aaaaa555777999_charging_remaining_time", "result": STATE_UNKNOWN, ATTR_ICON: "mdi:timer", - ATTR_LAST_UPDATE: "2020-11-17T09:06:48+01:00", + ATTR_LAST_UPDATE: "2020-11-17T08:06:48+00:00", ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, ATTR_UNIT_OF_MEASUREMENT: TIME_MINUTES, }, @@ -317,7 +332,7 @@ MOCK_VEHICLES = { "result": "unplugged", ATTR_DEVICE_CLASS: DEVICE_CLASS_PLUG_STATE, ATTR_ICON: "mdi:power-plug-off", - ATTR_LAST_UPDATE: "2020-11-17T09:06:48+01:00", + ATTR_LAST_UPDATE: "2020-11-17T08:06:48+00:00", }, ], }, @@ -332,6 +347,7 @@ MOCK_VEHICLES = { "endpoints_available": [ True, # cockpit False, # hvac-status + True, # location True, # battery-status True, # charge-mode ], @@ -339,6 +355,7 @@ MOCK_VEHICLES = { "battery_status": "battery_status_charging.json", "charge_mode": "charge_mode_always.json", "cockpit": "cockpit_fuel.json", + "location": "location.json", }, BINARY_SENSOR_DOMAIN: [ { @@ -346,23 +363,32 @@ MOCK_VEHICLES = { "unique_id": "vf1aaaaa555777123_plugged_in", "result": STATE_ON, ATTR_DEVICE_CLASS: DEVICE_CLASS_PLUG, - ATTR_LAST_UPDATE: "2020-01-12T21:40:16Z", + ATTR_LAST_UPDATE: "2020-01-12T21:40:16+00:00", }, { "entity_id": "binary_sensor.charging", "unique_id": "vf1aaaaa555777123_charging", "result": STATE_ON, ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY_CHARGING, - ATTR_LAST_UPDATE: "2020-01-12T21:40:16Z", + ATTR_LAST_UPDATE: "2020-01-12T21:40:16+00:00", }, ], + DEVICE_TRACKER_DOMAIN: [ + { + "entity_id": "device_tracker.location", + "unique_id": "vf1aaaaa555777123_location", + "result": STATE_NOT_HOME, + ATTR_ICON: "mdi:car", + ATTR_LAST_UPDATE: "2020-02-18T16:58:38+00:00", + } + ], SENSOR_DOMAIN: [ { "entity_id": "sensor.battery_autonomy", "unique_id": "vf1aaaaa555777123_battery_autonomy", "result": "141", ATTR_ICON: "mdi:ev-station", - ATTR_LAST_UPDATE: "2020-01-12T21:40:16Z", + ATTR_LAST_UPDATE: "2020-01-12T21:40:16+00:00", ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, ATTR_UNIT_OF_MEASUREMENT: LENGTH_KILOMETERS, }, @@ -371,7 +397,7 @@ MOCK_VEHICLES = { "unique_id": "vf1aaaaa555777123_battery_available_energy", "result": "31", ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - ATTR_LAST_UPDATE: "2020-01-12T21:40:16Z", + ATTR_LAST_UPDATE: "2020-01-12T21:40:16+00:00", ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, }, @@ -380,7 +406,7 @@ MOCK_VEHICLES = { "unique_id": "vf1aaaaa555777123_battery_level", "result": "60", ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY, - ATTR_LAST_UPDATE: "2020-01-12T21:40:16Z", + ATTR_LAST_UPDATE: "2020-01-12T21:40:16+00:00", ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, }, @@ -389,7 +415,7 @@ MOCK_VEHICLES = { "unique_id": "vf1aaaaa555777123_battery_temperature", "result": "20", ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_LAST_UPDATE: "2020-01-12T21:40:16Z", + ATTR_LAST_UPDATE: "2020-01-12T21:40:16+00:00", ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, }, @@ -406,14 +432,14 @@ MOCK_VEHICLES = { "result": "charge_in_progress", ATTR_DEVICE_CLASS: DEVICE_CLASS_CHARGE_STATE, ATTR_ICON: "mdi:flash", - ATTR_LAST_UPDATE: "2020-01-12T21:40:16Z", + ATTR_LAST_UPDATE: "2020-01-12T21:40:16+00:00", }, { "entity_id": "sensor.charging_power", "unique_id": "vf1aaaaa555777123_charging_power", "result": "27.0", ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, - ATTR_LAST_UPDATE: "2020-01-12T21:40:16Z", + ATTR_LAST_UPDATE: "2020-01-12T21:40:16+00:00", ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, ATTR_UNIT_OF_MEASUREMENT: POWER_KILO_WATT, }, @@ -422,7 +448,7 @@ MOCK_VEHICLES = { "unique_id": "vf1aaaaa555777123_charging_remaining_time", "result": "145", ATTR_ICON: "mdi:timer", - ATTR_LAST_UPDATE: "2020-01-12T21:40:16Z", + ATTR_LAST_UPDATE: "2020-01-12T21:40:16+00:00", ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, ATTR_UNIT_OF_MEASUREMENT: TIME_MINUTES, }, @@ -456,7 +482,7 @@ MOCK_VEHICLES = { "result": "plugged", ATTR_DEVICE_CLASS: DEVICE_CLASS_PLUG_STATE, ATTR_ICON: "mdi:power-plug", - ATTR_LAST_UPDATE: "2020-01-12T21:40:16Z", + ATTR_LAST_UPDATE: "2020-01-12T21:40:16+00:00", }, ], }, @@ -471,11 +497,24 @@ MOCK_VEHICLES = { "endpoints_available": [ True, # cockpit False, # hvac-status + True, # location # Ignore, # battery-status # Ignore, # charge-mode ], - "endpoints": {"cockpit": "cockpit_fuel.json"}, + "endpoints": { + "cockpit": "cockpit_fuel.json", + "location": "location.json", + }, BINARY_SENSOR_DOMAIN: [], + DEVICE_TRACKER_DOMAIN: [ + { + "entity_id": "device_tracker.location", + "unique_id": "vf1aaaaa555777123_location", + "result": STATE_NOT_HOME, + ATTR_ICON: "mdi:car", + ATTR_LAST_UPDATE: "2020-02-18T16:58:38+00:00", + } + ], SENSOR_DOMAIN: [ { "entity_id": "sensor.fuel_autonomy", diff --git a/tests/components/renault/test_device_tracker.py b/tests/components/renault/test_device_tracker.py new file mode 100644 index 00000000000..f6cac06380b --- /dev/null +++ b/tests/components/renault/test_device_tracker.py @@ -0,0 +1,164 @@ +"""Tests for Renault sensors.""" +from unittest.mock import patch + +import pytest +from renault_api.kamereon import exceptions + +from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN +from homeassistant.components.renault.renault_entities import ATTR_LAST_UPDATE +from homeassistant.const import ATTR_ICON, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from . import ( + check_device_registry, + get_no_data_icon, + setup_renault_integration_vehicle, + setup_renault_integration_vehicle_with_no_data, + setup_renault_integration_vehicle_with_side_effect, +) +from .const import DYNAMIC_ATTRIBUTES, FIXED_ATTRIBUTES, MOCK_VEHICLES + +from tests.common import mock_device_registry, mock_registry + + +@pytest.mark.parametrize("vehicle_type", MOCK_VEHICLES.keys()) +async def test_device_trackers(hass: HomeAssistant, vehicle_type: str): + """Test for Renault device trackers.""" + await async_setup_component(hass, "persistent_notification", {}) + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + with patch("homeassistant.components.renault.PLATFORMS", [DEVICE_TRACKER_DOMAIN]): + await setup_renault_integration_vehicle(hass, vehicle_type) + await hass.async_block_till_done() + + mock_vehicle = MOCK_VEHICLES[vehicle_type] + check_device_registry(device_registry, mock_vehicle["expected_device"]) + + expected_entities = mock_vehicle[DEVICE_TRACKER_DOMAIN] + assert len(entity_registry.entities) == len(expected_entities) + for expected_entity in expected_entities: + entity_id = expected_entity["entity_id"] + registry_entry = entity_registry.entities.get(entity_id) + assert registry_entry is not None + assert registry_entry.unique_id == expected_entity["unique_id"] + state = hass.states.get(entity_id) + assert state.state == expected_entity["result"] + for attr in FIXED_ATTRIBUTES + DYNAMIC_ATTRIBUTES: + assert state.attributes.get(attr) == expected_entity.get(attr) + + +@pytest.mark.parametrize("vehicle_type", MOCK_VEHICLES.keys()) +async def test_device_tracker_empty(hass: HomeAssistant, vehicle_type: str): + """Test for Renault device trackers with empty data from Renault.""" + await async_setup_component(hass, "persistent_notification", {}) + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + with patch("homeassistant.components.renault.PLATFORMS", [DEVICE_TRACKER_DOMAIN]): + await setup_renault_integration_vehicle_with_no_data(hass, vehicle_type) + await hass.async_block_till_done() + + mock_vehicle = MOCK_VEHICLES[vehicle_type] + check_device_registry(device_registry, mock_vehicle["expected_device"]) + + expected_entities = mock_vehicle[DEVICE_TRACKER_DOMAIN] + assert len(entity_registry.entities) == len(expected_entities) + for expected_entity in expected_entities: + entity_id = expected_entity["entity_id"] + registry_entry = entity_registry.entities.get(entity_id) + assert registry_entry is not None + assert registry_entry.unique_id == expected_entity["unique_id"] + state = hass.states.get(entity_id) + assert state.state == STATE_UNKNOWN + for attr in FIXED_ATTRIBUTES: + assert state.attributes.get(attr) == expected_entity.get(attr) + # Check dynamic attributes: + assert state.attributes.get(ATTR_ICON) == get_no_data_icon(expected_entity) + assert ATTR_LAST_UPDATE not in state.attributes + + +@pytest.mark.parametrize("vehicle_type", MOCK_VEHICLES.keys()) +async def test_device_tracker_errors(hass: HomeAssistant, vehicle_type: str): + """Test for Renault device trackers with temporary failure.""" + await async_setup_component(hass, "persistent_notification", {}) + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + invalid_upstream_exception = exceptions.InvalidUpstreamException( + "err.tech.500", + "Invalid response from the upstream server (The request sent to the GDC is erroneous) ; 502 Bad Gateway", + ) + + with patch("homeassistant.components.renault.PLATFORMS", [DEVICE_TRACKER_DOMAIN]): + await setup_renault_integration_vehicle_with_side_effect( + hass, vehicle_type, invalid_upstream_exception + ) + await hass.async_block_till_done() + + mock_vehicle = MOCK_VEHICLES[vehicle_type] + check_device_registry(device_registry, mock_vehicle["expected_device"]) + + expected_entities = mock_vehicle[DEVICE_TRACKER_DOMAIN] + assert len(entity_registry.entities) == len(expected_entities) + for expected_entity in expected_entities: + entity_id = expected_entity["entity_id"] + registry_entry = entity_registry.entities.get(entity_id) + assert registry_entry is not None + assert registry_entry.unique_id == expected_entity["unique_id"] + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE + for attr in FIXED_ATTRIBUTES: + assert state.attributes.get(attr) == expected_entity.get(attr) + # Check dynamic attributes: + assert state.attributes.get(ATTR_ICON) == get_no_data_icon(expected_entity) + assert ATTR_LAST_UPDATE not in state.attributes + + +async def test_device_tracker_access_denied(hass: HomeAssistant): + """Test for Renault device trackers with access denied failure.""" + await async_setup_component(hass, "persistent_notification", {}) + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + vehicle_type = "zoe_40" + access_denied_exception = exceptions.AccessDeniedException( + "err.func.403", + "Access is denied for this resource", + ) + + with patch("homeassistant.components.renault.PLATFORMS", [DEVICE_TRACKER_DOMAIN]): + await setup_renault_integration_vehicle_with_side_effect( + hass, vehicle_type, access_denied_exception + ) + await hass.async_block_till_done() + + mock_vehicle = MOCK_VEHICLES[vehicle_type] + check_device_registry(device_registry, mock_vehicle["expected_device"]) + + assert len(entity_registry.entities) == 0 + + +async def test_device_tracker_not_supported(hass: HomeAssistant): + """Test for Renault device trackers with not supported failure.""" + await async_setup_component(hass, "persistent_notification", {}) + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + vehicle_type = "zoe_40" + not_supported_exception = exceptions.NotSupportedException( + "err.tech.501", + "This feature is not technically supported by this gateway", + ) + + with patch("homeassistant.components.renault.PLATFORMS", [DEVICE_TRACKER_DOMAIN]): + await setup_renault_integration_vehicle_with_side_effect( + hass, vehicle_type, not_supported_exception + ) + await hass.async_block_till_done() + + mock_vehicle = MOCK_VEHICLES[vehicle_type] + check_device_registry(device_registry, mock_vehicle["expected_device"]) + + assert len(entity_registry.entities) == 0 diff --git a/tests/fixtures/renault/location.json b/tests/fixtures/renault/location.json new file mode 100644 index 00000000000..bae4474521f --- /dev/null +++ b/tests/fixtures/renault/location.json @@ -0,0 +1,11 @@ +{ + "data": { + "type": "Car", + "id": "VF1AAAAA555777999", + "attributes": { + "gpsLatitude": 48.1234567, + "gpsLongitude": 11.1234567, + "lastUpdateTime": "2020-02-18T16:58:38Z" + } + } +} From 9284f7b147d90b8185feba7214898260f7af42ff Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 1 Sep 2021 04:18:50 -0700 Subject: [PATCH 144/843] Tweaks for the iotawatt integration (#55510) --- homeassistant/components/iotawatt/sensor.py | 9 +++++++-- tests/components/iotawatt/test_sensor.py | 8 ++------ 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/iotawatt/sensor.py b/homeassistant/components/iotawatt/sensor.py index 8a8c92a8c51..1b4c166eb27 100644 --- a/homeassistant/components/iotawatt/sensor.py +++ b/homeassistant/components/iotawatt/sensor.py @@ -8,7 +8,6 @@ from iotawattpy.sensor import Sensor from homeassistant.components.sensor import ( STATE_CLASS_MEASUREMENT, - STATE_CLASS_TOTAL_INCREASING, SensorEntity, SensorEntityDescription, ) @@ -47,12 +46,14 @@ ENTITY_DESCRIPTION_KEY_MAP: dict[str, IotaWattSensorEntityDescription] = { native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, state_class=STATE_CLASS_MEASUREMENT, device_class=DEVICE_CLASS_CURRENT, + entity_registry_enabled_default=False, ), "Hz": IotaWattSensorEntityDescription( "Hz", native_unit_of_measurement=FREQUENCY_HERTZ, state_class=STATE_CLASS_MEASUREMENT, icon="mdi:flash", + entity_registry_enabled_default=False, ), "PF": IotaWattSensorEntityDescription( "PF", @@ -60,6 +61,7 @@ ENTITY_DESCRIPTION_KEY_MAP: dict[str, IotaWattSensorEntityDescription] = { state_class=STATE_CLASS_MEASUREMENT, device_class=DEVICE_CLASS_POWER_FACTOR, value=lambda value: value * 100, + entity_registry_enabled_default=False, ), "Watts": IotaWattSensorEntityDescription( "Watts", @@ -70,7 +72,6 @@ ENTITY_DESCRIPTION_KEY_MAP: dict[str, IotaWattSensorEntityDescription] = { "WattHours": IotaWattSensorEntityDescription( "WattHours", native_unit_of_measurement=ENERGY_WATT_HOUR, - state_class=STATE_CLASS_TOTAL_INCREASING, device_class=DEVICE_CLASS_ENERGY, ), "VA": IotaWattSensorEntityDescription( @@ -78,24 +79,28 @@ ENTITY_DESCRIPTION_KEY_MAP: dict[str, IotaWattSensorEntityDescription] = { native_unit_of_measurement=POWER_VOLT_AMPERE, state_class=STATE_CLASS_MEASUREMENT, icon="mdi:flash", + entity_registry_enabled_default=False, ), "VAR": IotaWattSensorEntityDescription( "VAR", native_unit_of_measurement=VOLT_AMPERE_REACTIVE, state_class=STATE_CLASS_MEASUREMENT, icon="mdi:flash", + entity_registry_enabled_default=False, ), "VARh": IotaWattSensorEntityDescription( "VARh", native_unit_of_measurement=VOLT_AMPERE_REACTIVE_HOURS, state_class=STATE_CLASS_MEASUREMENT, icon="mdi:flash", + entity_registry_enabled_default=False, ), "Volts": IotaWattSensorEntityDescription( "Volts", native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, state_class=STATE_CLASS_MEASUREMENT, device_class=DEVICE_CLASS_VOLTAGE, + entity_registry_enabled_default=False, ), } diff --git a/tests/components/iotawatt/test_sensor.py b/tests/components/iotawatt/test_sensor.py index 556da8cc2b0..a5fc2250b84 100644 --- a/tests/components/iotawatt/test_sensor.py +++ b/tests/components/iotawatt/test_sensor.py @@ -1,11 +1,7 @@ """Test setting up sensors.""" from datetime import timedelta -from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, - DEVICE_CLASS_ENERGY, - STATE_CLASS_TOTAL_INCREASING, -) +from homeassistant.components.sensor import ATTR_STATE_CLASS, DEVICE_CLASS_ENERGY from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, @@ -37,7 +33,7 @@ async def test_sensor_type_input(hass, mock_iotawatt): state = hass.states.get("sensor.my_sensor") assert state is not None assert state.state == "23" - assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING + assert ATTR_STATE_CLASS not in state.attributes assert state.attributes[ATTR_FRIENDLY_NAME] == "My Sensor" assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == ENERGY_WATT_HOUR assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_ENERGY From 33fb080c1e7dc5db8e24cc142fd9b1972ef337f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Wed, 1 Sep 2021 13:23:50 +0200 Subject: [PATCH 145/843] Add remote server to cloud system health (#55506) * Add sintun server to cloud system health * Update name * Adjust test --- homeassistant/components/cloud/strings.json | 3 ++- homeassistant/components/cloud/system_health.py | 1 + homeassistant/components/cloud/translations/en.json | 1 + tests/components/cloud/test_system_health.py | 3 ++- 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/cloud/strings.json b/homeassistant/components/cloud/strings.json index 357575c7bd0..d38a0c272a7 100644 --- a/homeassistant/components/cloud/strings.json +++ b/homeassistant/components/cloud/strings.json @@ -7,10 +7,11 @@ "relayer_connected": "Relayer Connected", "remote_connected": "Remote Connected", "remote_enabled": "Remote Enabled", + "remote_server": "Remote Server", "alexa_enabled": "Alexa Enabled", "google_enabled": "Google Enabled", "logged_in": "Logged In", "subscription_expiration": "Subscription Expiration" } } -} +} \ No newline at end of file diff --git a/homeassistant/components/cloud/system_health.py b/homeassistant/components/cloud/system_health.py index 6d700c4fb8e..4d8a6eab64c 100644 --- a/homeassistant/components/cloud/system_health.py +++ b/homeassistant/components/cloud/system_health.py @@ -33,6 +33,7 @@ async def system_health_info(hass): data["remote_connected"] = cloud.remote.is_connected data["alexa_enabled"] = client.prefs.alexa_enabled data["google_enabled"] = client.prefs.google_enabled + data["remote_server"] = cloud.remote.snitun_server data["can_reach_cert_server"] = system_health.async_check_can_reach_url( hass, cloud.acme_directory_server diff --git a/homeassistant/components/cloud/translations/en.json b/homeassistant/components/cloud/translations/en.json index 34af1f57cfa..dbaae234f83 100644 --- a/homeassistant/components/cloud/translations/en.json +++ b/homeassistant/components/cloud/translations/en.json @@ -7,6 +7,7 @@ "can_reach_cloud_auth": "Reach Authentication Server", "google_enabled": "Google Enabled", "logged_in": "Logged In", + "remote_server": "Remote Server", "relayer_connected": "Relayer Connected", "remote_connected": "Remote Connected", "remote_enabled": "Remote Enabled", diff --git a/tests/components/cloud/test_system_health.py b/tests/components/cloud/test_system_health.py index 65ffd859f33..cc37788bc4c 100644 --- a/tests/components/cloud/test_system_health.py +++ b/tests/components/cloud/test_system_health.py @@ -28,7 +28,7 @@ async def test_cloud_system_health(hass, aioclient_mock): relayer="wss://cloud.bla.com/websocket_api", acme_directory_server="https://cert-server", is_logged_in=True, - remote=Mock(is_connected=False), + remote=Mock(is_connected=False, snitun_server="us-west-1"), expiration_date=now, is_connected=True, client=Mock( @@ -52,6 +52,7 @@ async def test_cloud_system_health(hass, aioclient_mock): "relayer_connected": True, "remote_enabled": True, "remote_connected": False, + "remote_server": "us-west-1", "alexa_enabled": True, "google_enabled": False, "can_reach_cert_server": "ok", From f8ec85686a3040cdd376d700c4b2a871005f8c85 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Sep 2021 14:44:10 +0200 Subject: [PATCH 146/843] Add select platform to Renault integration (#55494) * Add select platform to Renault integration * Fix pylint --- homeassistant/components/renault/const.py | 2 + homeassistant/components/renault/select.py | 101 +++++++++ homeassistant/components/renault/sensor.py | 24 +-- tests/components/renault/const.py | 57 +++-- tests/components/renault/test_select.py | 194 ++++++++++++++++++ .../renault/action.set_charge_mode.json | 7 + 6 files changed, 340 insertions(+), 45 deletions(-) create mode 100644 homeassistant/components/renault/select.py create mode 100644 tests/components/renault/test_select.py create mode 100644 tests/fixtures/renault/action.set_charge_mode.json diff --git a/homeassistant/components/renault/const.py b/homeassistant/components/renault/const.py index e080e2b5962..4c1376288f0 100644 --- a/homeassistant/components/renault/const.py +++ b/homeassistant/components/renault/const.py @@ -1,6 +1,7 @@ """Constants for the Renault component.""" from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN +from homeassistant.components.select import DOMAIN as SELECT_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN DOMAIN = "renault" @@ -13,6 +14,7 @@ DEFAULT_SCAN_INTERVAL = 300 # 5 minutes PLATFORMS = [ BINARY_SENSOR_DOMAIN, DEVICE_TRACKER_DOMAIN, + SELECT_DOMAIN, SENSOR_DOMAIN, ] diff --git a/homeassistant/components/renault/select.py b/homeassistant/components/renault/select.py new file mode 100644 index 00000000000..fa9c491030d --- /dev/null +++ b/homeassistant/components/renault/select.py @@ -0,0 +1,101 @@ +"""Support for Renault sensors.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable, cast + +from renault_api.kamereon.models import KamereonVehicleBatteryStatusData + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .const import DEVICE_CLASS_CHARGE_MODE, DOMAIN +from .renault_entities import RenaultDataEntity, RenaultEntityDescription +from .renault_hub import RenaultHub + + +@dataclass +class RenaultSelectRequiredKeysMixin: + """Mixin for required keys.""" + + data_key: str + icon_lambda: Callable[[RenaultSelectEntity], str] + options: list[str] + + +@dataclass +class RenaultSelectEntityDescription( + SelectEntityDescription, RenaultEntityDescription, RenaultSelectRequiredKeysMixin +): + """Class describing Renault select entities.""" + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Renault entities from config entry.""" + proxy: RenaultHub = hass.data[DOMAIN][config_entry.entry_id] + entities: list[RenaultSelectEntity] = [ + RenaultSelectEntity(vehicle, description) + for vehicle in proxy.vehicles.values() + for description in SENSOR_TYPES + if description.coordinator in vehicle.coordinators + ] + async_add_entities(entities) + + +class RenaultSelectEntity( + RenaultDataEntity[KamereonVehicleBatteryStatusData], SelectEntity +): + """Mixin for sensor specific attributes.""" + + entity_description: RenaultSelectEntityDescription + + @property + def current_option(self) -> str | None: + """Return the selected entity option to represent the entity state.""" + return cast(str, self.data) + + @property + def data(self) -> StateType: + """Return the state of this entity.""" + return self._get_data_attr(self.entity_description.data_key) + + @property + def icon(self) -> str | None: + """Icon handling.""" + return self.entity_description.icon_lambda(self) + + @property + def options(self) -> list[str]: + """Return a set of selectable options.""" + return self.entity_description.options + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + await self.vehicle.vehicle.set_charge_mode(option) + + +def _get_charge_mode_icon(entity: RenaultSelectEntity) -> str: + """Return the icon of this entity.""" + if entity.data == "schedule_mode": + return "mdi:calendar-clock" + return "mdi:calendar-remove" + + +SENSOR_TYPES: tuple[RenaultSelectEntityDescription, ...] = ( + RenaultSelectEntityDescription( + key="charge_mode", + coordinator="charge_mode", + data_key="chargeMode", + device_class=DEVICE_CLASS_CHARGE_MODE, + icon_lambda=_get_charge_mode_icon, + name="Charge Mode", + options=["always", "always_charging", "schedule_mode"], + ), +) diff --git a/homeassistant/components/renault/sensor.py b/homeassistant/components/renault/sensor.py index 62903702df0..b2161fc9adf 100644 --- a/homeassistant/components/renault/sensor.py +++ b/homeassistant/components/renault/sensor.py @@ -7,7 +7,6 @@ from typing import Callable, cast from renault_api.kamereon.enums import ChargeState, PlugState from renault_api.kamereon.models import ( KamereonVehicleBatteryStatusData, - KamereonVehicleChargeModeData, KamereonVehicleCockpitData, KamereonVehicleHvacStatusData, ) @@ -36,12 +35,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from .const import ( - DEVICE_CLASS_CHARGE_MODE, - DEVICE_CLASS_CHARGE_STATE, - DEVICE_CLASS_PLUG_STATE, - DOMAIN, -) +from .const import DEVICE_CLASS_CHARGE_STATE, DEVICE_CLASS_PLUG_STATE, DOMAIN from .renault_coordinator import T from .renault_entities import RenaultDataEntity, RenaultEntityDescription from .renault_hub import RenaultHub @@ -110,13 +104,6 @@ class RenaultSensor(RenaultDataEntity[T], SensorEntity): return self.entity_description.value_lambda(self) -def _get_charge_mode_icon(entity: RenaultSensor[T]) -> str: - """Return the icon of this entity.""" - if entity.data == "schedule_mode": - return "mdi:calendar-clock" - return "mdi:calendar-remove" - - def _get_charging_power(entity: RenaultSensor[T]) -> StateType: """Return the charging_power of this entity.""" if entity.vehicle.details.reports_charging_power_in_watts(): @@ -284,13 +271,4 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription, ...] = ( native_unit_of_measurement=TEMP_CELSIUS, state_class=STATE_CLASS_MEASUREMENT, ), - RenaultSensorEntityDescription( - key="charge_mode", - coordinator="charge_mode", - data_key="chargeMode", - device_class=DEVICE_CLASS_CHARGE_MODE, - entity_class=RenaultSensor[KamereonVehicleChargeModeData], - icon_lambda=_get_charge_mode_icon, - name="Charge Mode", - ), ) diff --git a/tests/components/renault/const.py b/tests/components/renault/const.py index cbc94c61bf4..4ffc08587e3 100644 --- a/tests/components/renault/const.py +++ b/tests/components/renault/const.py @@ -14,6 +14,8 @@ from homeassistant.components.renault.const import ( DOMAIN, ) from homeassistant.components.renault.renault_entities import ATTR_LAST_UPDATE +from homeassistant.components.select import DOMAIN as SELECT_DOMAIN +from homeassistant.components.select.const import ATTR_OPTIONS from homeassistant.components.sensor import ( ATTR_STATE_CLASS, DOMAIN as SENSOR_DOMAIN, @@ -45,6 +47,7 @@ from homeassistant.const import ( FIXED_ATTRIBUTES = ( ATTR_DEVICE_CLASS, + ATTR_OPTIONS, ATTR_STATE_CLASS, ATTR_UNIT_OF_MEASUREMENT, ) @@ -54,7 +57,7 @@ DYNAMIC_ATTRIBUTES = ( ) ICON_FOR_EMPTY_VALUES = { - "sensor.charge_mode": "mdi:calendar-remove", + "select.charge_mode": "mdi:calendar-remove", "sensor.charge_state": "mdi:flash-off", "sensor.plug_state": "mdi:power-plug-off", } @@ -106,6 +109,16 @@ MOCK_VEHICLES = { }, ], DEVICE_TRACKER_DOMAIN: [], + SELECT_DOMAIN: [ + { + "entity_id": "select.charge_mode", + "unique_id": "vf1aaaaa555777999_charge_mode", + "result": "always", + ATTR_DEVICE_CLASS: DEVICE_CLASS_CHARGE_MODE, + ATTR_ICON: "mdi:calendar-remove", + ATTR_OPTIONS: ["always", "always_charging", "schedule_mode"], + }, + ], SENSOR_DOMAIN: [ { "entity_id": "sensor.battery_autonomy", @@ -143,13 +156,6 @@ MOCK_VEHICLES = { ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, }, - { - "entity_id": "sensor.charge_mode", - "unique_id": "vf1aaaaa555777999_charge_mode", - "result": "always", - ATTR_DEVICE_CLASS: DEVICE_CLASS_CHARGE_MODE, - ATTR_ICON: "mdi:calendar-remove", - }, { "entity_id": "sensor.charge_state", "unique_id": "vf1aaaaa555777999_charge_state", @@ -248,6 +254,16 @@ MOCK_VEHICLES = { ATTR_LAST_UPDATE: "2020-02-18T16:58:38+00:00", } ], + SELECT_DOMAIN: [ + { + "entity_id": "select.charge_mode", + "unique_id": "vf1aaaaa555777999_charge_mode", + "result": "schedule_mode", + ATTR_DEVICE_CLASS: DEVICE_CLASS_CHARGE_MODE, + ATTR_ICON: "mdi:calendar-clock", + ATTR_OPTIONS: ["always", "always_charging", "schedule_mode"], + }, + ], SENSOR_DOMAIN: [ { "entity_id": "sensor.battery_autonomy", @@ -285,13 +301,6 @@ MOCK_VEHICLES = { ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, }, - { - "entity_id": "sensor.charge_mode", - "unique_id": "vf1aaaaa555777999_charge_mode", - "result": "schedule_mode", - ATTR_DEVICE_CLASS: DEVICE_CLASS_CHARGE_MODE, - ATTR_ICON: "mdi:calendar-clock", - }, { "entity_id": "sensor.charge_state", "unique_id": "vf1aaaaa555777999_charge_state", @@ -382,6 +391,16 @@ MOCK_VEHICLES = { ATTR_LAST_UPDATE: "2020-02-18T16:58:38+00:00", } ], + SELECT_DOMAIN: [ + { + "entity_id": "select.charge_mode", + "unique_id": "vf1aaaaa555777123_charge_mode", + "result": "always", + ATTR_DEVICE_CLASS: DEVICE_CLASS_CHARGE_MODE, + ATTR_ICON: "mdi:calendar-remove", + ATTR_OPTIONS: ["always", "always_charging", "schedule_mode"], + }, + ], SENSOR_DOMAIN: [ { "entity_id": "sensor.battery_autonomy", @@ -419,13 +438,6 @@ MOCK_VEHICLES = { ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, }, - { - "entity_id": "sensor.charge_mode", - "unique_id": "vf1aaaaa555777123_charge_mode", - "result": "always", - ATTR_DEVICE_CLASS: DEVICE_CLASS_CHARGE_MODE, - ATTR_ICON: "mdi:calendar-remove", - }, { "entity_id": "sensor.charge_state", "unique_id": "vf1aaaaa555777123_charge_state", @@ -515,6 +527,7 @@ MOCK_VEHICLES = { ATTR_LAST_UPDATE: "2020-02-18T16:58:38+00:00", } ], + SELECT_DOMAIN: [], SENSOR_DOMAIN: [ { "entity_id": "sensor.fuel_autonomy", diff --git a/tests/components/renault/test_select.py b/tests/components/renault/test_select.py new file mode 100644 index 00000000000..113db099447 --- /dev/null +++ b/tests/components/renault/test_select.py @@ -0,0 +1,194 @@ +"""Tests for Renault selects.""" +from unittest.mock import patch + +import pytest +from renault_api.kamereon import exceptions, schemas + +from homeassistant.components.renault.renault_entities import ATTR_LAST_UPDATE +from homeassistant.components.select import DOMAIN as SELECT_DOMAIN +from homeassistant.components.select.const import ATTR_OPTION, SERVICE_SELECT_OPTION +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_ICON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from . import ( + check_device_registry, + get_no_data_icon, + setup_renault_integration_vehicle, + setup_renault_integration_vehicle_with_no_data, + setup_renault_integration_vehicle_with_side_effect, +) +from .const import DYNAMIC_ATTRIBUTES, FIXED_ATTRIBUTES, MOCK_VEHICLES + +from tests.common import load_fixture, mock_device_registry, mock_registry + + +@pytest.mark.parametrize("vehicle_type", MOCK_VEHICLES.keys()) +async def test_selects(hass: HomeAssistant, vehicle_type: str): + """Test for Renault selects.""" + await async_setup_component(hass, "persistent_notification", {}) + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + with patch("homeassistant.components.renault.PLATFORMS", [SELECT_DOMAIN]): + await setup_renault_integration_vehicle(hass, vehicle_type) + await hass.async_block_till_done() + + mock_vehicle = MOCK_VEHICLES[vehicle_type] + check_device_registry(device_registry, mock_vehicle["expected_device"]) + + expected_entities = mock_vehicle[SELECT_DOMAIN] + assert len(entity_registry.entities) == len(expected_entities) + for expected_entity in expected_entities: + entity_id = expected_entity["entity_id"] + registry_entry = entity_registry.entities.get(entity_id) + assert registry_entry is not None + assert registry_entry.unique_id == expected_entity["unique_id"] + state = hass.states.get(entity_id) + assert state.state == expected_entity["result"] + for attr in FIXED_ATTRIBUTES + DYNAMIC_ATTRIBUTES: + assert state.attributes.get(attr) == expected_entity.get(attr) + + +@pytest.mark.parametrize("vehicle_type", MOCK_VEHICLES.keys()) +async def test_select_empty(hass: HomeAssistant, vehicle_type: str): + """Test for Renault selects with empty data from Renault.""" + await async_setup_component(hass, "persistent_notification", {}) + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + with patch("homeassistant.components.renault.PLATFORMS", [SELECT_DOMAIN]): + await setup_renault_integration_vehicle_with_no_data(hass, vehicle_type) + await hass.async_block_till_done() + + mock_vehicle = MOCK_VEHICLES[vehicle_type] + check_device_registry(device_registry, mock_vehicle["expected_device"]) + + expected_entities = mock_vehicle[SELECT_DOMAIN] + assert len(entity_registry.entities) == len(expected_entities) + for expected_entity in expected_entities: + entity_id = expected_entity["entity_id"] + registry_entry = entity_registry.entities.get(entity_id) + assert registry_entry is not None + assert registry_entry.unique_id == expected_entity["unique_id"] + state = hass.states.get(entity_id) + assert state.state == STATE_UNKNOWN + for attr in FIXED_ATTRIBUTES: + assert state.attributes.get(attr) == expected_entity.get(attr) + # Check dynamic attributes: + assert state.attributes.get(ATTR_ICON) == get_no_data_icon(expected_entity) + assert ATTR_LAST_UPDATE not in state.attributes + + +@pytest.mark.parametrize("vehicle_type", MOCK_VEHICLES.keys()) +async def test_select_errors(hass: HomeAssistant, vehicle_type: str): + """Test for Renault selects with temporary failure.""" + await async_setup_component(hass, "persistent_notification", {}) + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + invalid_upstream_exception = exceptions.InvalidUpstreamException( + "err.tech.500", + "Invalid response from the upstream server (The request sent to the GDC is erroneous) ; 502 Bad Gateway", + ) + + with patch("homeassistant.components.renault.PLATFORMS", [SELECT_DOMAIN]): + await setup_renault_integration_vehicle_with_side_effect( + hass, vehicle_type, invalid_upstream_exception + ) + await hass.async_block_till_done() + + mock_vehicle = MOCK_VEHICLES[vehicle_type] + check_device_registry(device_registry, mock_vehicle["expected_device"]) + + expected_entities = mock_vehicle[SELECT_DOMAIN] + assert len(entity_registry.entities) == len(expected_entities) + for expected_entity in expected_entities: + entity_id = expected_entity["entity_id"] + registry_entry = entity_registry.entities.get(entity_id) + assert registry_entry is not None + assert registry_entry.unique_id == expected_entity["unique_id"] + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE + for attr in FIXED_ATTRIBUTES: + assert state.attributes.get(attr) == expected_entity.get(attr) + # Check dynamic attributes: + assert state.attributes.get(ATTR_ICON) == get_no_data_icon(expected_entity) + assert ATTR_LAST_UPDATE not in state.attributes + + +async def test_select_access_denied(hass: HomeAssistant): + """Test for Renault selects with access denied failure.""" + await async_setup_component(hass, "persistent_notification", {}) + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + vehicle_type = "zoe_40" + access_denied_exception = exceptions.AccessDeniedException( + "err.func.403", + "Access is denied for this resource", + ) + + with patch("homeassistant.components.renault.PLATFORMS", [SELECT_DOMAIN]): + await setup_renault_integration_vehicle_with_side_effect( + hass, vehicle_type, access_denied_exception + ) + await hass.async_block_till_done() + + mock_vehicle = MOCK_VEHICLES[vehicle_type] + check_device_registry(device_registry, mock_vehicle["expected_device"]) + + assert len(entity_registry.entities) == 0 + + +async def test_select_not_supported(hass: HomeAssistant): + """Test for Renault selects with access denied failure.""" + await async_setup_component(hass, "persistent_notification", {}) + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + vehicle_type = "zoe_40" + not_supported_exception = exceptions.NotSupportedException( + "err.tech.501", + "This feature is not technically supported by this gateway", + ) + + with patch("homeassistant.components.renault.PLATFORMS", [SELECT_DOMAIN]): + await setup_renault_integration_vehicle_with_side_effect( + hass, vehicle_type, not_supported_exception + ) + await hass.async_block_till_done() + + mock_vehicle = MOCK_VEHICLES[vehicle_type] + check_device_registry(device_registry, mock_vehicle["expected_device"]) + + assert len(entity_registry.entities) == 0 + + +async def test_select_charge_mode(hass: HomeAssistant): + """Test that service invokes renault_api with correct data.""" + await setup_renault_integration_vehicle(hass, "zoe_40") + + data = { + ATTR_ENTITY_ID: "select.charge_mode", + ATTR_OPTION: "always", + } + + with patch( + "renault_api.renault_vehicle.RenaultVehicle.set_charge_mode", + return_value=( + schemas.KamereonVehicleHvacStartActionDataSchema.loads( + load_fixture("renault/action.set_charge_mode.json") + ) + ), + ) as mock_action: + await hass.services.async_call( + SELECT_DOMAIN, SERVICE_SELECT_OPTION, service_data=data, blocking=True + ) + assert len(mock_action.mock_calls) == 1 + assert mock_action.mock_calls[0][1] == ("always",) diff --git a/tests/fixtures/renault/action.set_charge_mode.json b/tests/fixtures/renault/action.set_charge_mode.json new file mode 100644 index 00000000000..60fa5a19e74 --- /dev/null +++ b/tests/fixtures/renault/action.set_charge_mode.json @@ -0,0 +1,7 @@ +{ + "data": { + "type": "ChargeMode", + "id": "guid", + "attributes": { "action": "schedule_mode" } + } +} \ No newline at end of file From 80af2f4279828419a9112a5478029f2c506896e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Wed, 1 Sep 2021 15:16:10 +0200 Subject: [PATCH 147/843] Open garage, add closing and opening to state (#55372) --- homeassistant/components/opengarage/cover.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/opengarage/cover.py b/homeassistant/components/opengarage/cover.py index 154cb4df3ae..398de003965 100644 --- a/homeassistant/components/opengarage/cover.py +++ b/homeassistant/components/opengarage/cover.py @@ -116,7 +116,21 @@ class OpenGarageCover(CoverEntity): """Return if the cover is closed.""" if self._state is None: return None - return self._state in [STATE_CLOSED, STATE_OPENING] + return self._state == STATE_CLOSED + + @property + def is_closing(self): + """Return if the cover is closing.""" + if self._state is None: + return None + return self._state == STATE_CLOSING + + @property + def is_opening(self): + """Return if the cover is opening.""" + if self._state is None: + return None + return self._state == STATE_OPENING async def async_close_cover(self, **kwargs): """Close the cover.""" From c68e87c40efb5bf0308931b1f7808a7f4df3d2d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Wed, 1 Sep 2021 18:33:56 +0200 Subject: [PATCH 148/843] OpenGarage, change to attributes (#55528) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Daniel Hjelseth Høyer --- homeassistant/components/opengarage/cover.py | 47 +++++--------------- 1 file changed, 12 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/opengarage/cover.py b/homeassistant/components/opengarage/cover.py index 398de003965..95743146a5f 100644 --- a/homeassistant/components/opengarage/cover.py +++ b/homeassistant/components/opengarage/cover.py @@ -86,25 +86,17 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class OpenGarageCover(CoverEntity): """Representation of a OpenGarage cover.""" + _attr_device_class = DEVICE_CLASS_GARAGE + _attr_supported_features = SUPPORT_OPEN | SUPPORT_CLOSE + def __init__(self, name, open_garage, device_id): """Initialize the cover.""" - self._name = name + self._attr_name = name self._open_garage = open_garage self._state = None self._state_before_move = None self._extra_state_attributes = {} - self._available = True - self._device_id = device_id - - @property - def name(self): - """Return the name of the cover.""" - return self._name - - @property - def available(self): - """Return True if entity is available.""" - return self._available + self._attr_unique_id = device_id @property def extra_state_attributes(self): @@ -153,11 +145,11 @@ class OpenGarageCover(CoverEntity): status = await self._open_garage.update_state() if status is None: _LOGGER.error("Unable to connect to OpenGarage device") - self._available = False + self._attr_available = False return - if self._name is None and status["name"] is not None: - self._name = status["name"] + if self.name is None and status["name"] is not None: + self._attr_name = status["name"] state = STATES_MAP.get(status.get("door")) if self._state_before_move is not None: if self._state_before_move != state: @@ -166,7 +158,7 @@ class OpenGarageCover(CoverEntity): else: self._state = state - _LOGGER.debug("%s status: %s", self._name, self._state) + _LOGGER.debug("%s status: %s", self.name, self._state) if status.get("rssi") is not None: self._extra_state_attributes[ATTR_SIGNAL_STRENGTH] = status.get("rssi") if status.get("dist") is not None: @@ -174,7 +166,7 @@ class OpenGarageCover(CoverEntity): if self._state is not None: self._extra_state_attributes[ATTR_DOOR_STATE] = self._state - self._available = True + self._attr_available = True async def _push_button(self): """Send commands to API.""" @@ -185,24 +177,9 @@ class OpenGarageCover(CoverEntity): return if result == 2: - _LOGGER.error("Unable to control %s: Device key is incorrect", self._name) + _LOGGER.error("Unable to control %s: Device key is incorrect", self.name) elif result > 2: - _LOGGER.error("Unable to control %s: Error code %s", self._name, result) + _LOGGER.error("Unable to control %s: Error code %s", self.name, result) self._state = self._state_before_move self._state_before_move = None - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return DEVICE_CLASS_GARAGE - - @property - def supported_features(self): - """Flag supported features.""" - return SUPPORT_OPEN | SUPPORT_CLOSE - - @property - def unique_id(self): - """Return a unique ID.""" - return self._device_id From 27e29b714cacadf308f983e8ae2dbc8f5e010a83 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 1 Sep 2021 09:54:54 -0700 Subject: [PATCH 149/843] Bump cloud to 0.47.1 (#55312) * Bump cloud to 0.47.0 * Bump reqs * Bump to 0.47.1 * Do not load hass_nabucasa during http startup * fix some tests * Fix test Co-authored-by: Ludeeus --- homeassistant/components/cloud/__init__.py | 2 +- homeassistant/components/cloud/client.py | 9 ++++-- .../components/cloud/google_config.py | 2 +- homeassistant/components/cloud/manifest.json | 2 +- .../components/google_assistant/helpers.py | 9 +++--- homeassistant/components/http/forwarded.py | 29 ++++++++++++------- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/cloud/__init__.py | 2 +- tests/components/cloud/conftest.py | 2 ++ tests/components/cloud/test_client.py | 8 ++--- tests/components/cloud/test_google_config.py | 22 ++++++++++++++ tests/components/cloud/test_init.py | 4 +-- 14 files changed, 66 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 038bc227fcd..33b5e248561 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -228,7 +228,7 @@ async def async_setup(hass, config): cloud.iot.register_on_connect(_on_connect) - await cloud.start() + await cloud.initialize() await http_api.async_setup(hass) account_link.async_setup(hass) diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index 93c6fcd9086..4c039f3888c 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -108,8 +108,8 @@ class CloudClient(Interface): return self._google_config - async def logged_in(self) -> None: - """When user logs in.""" + async def cloud_started(self) -> None: + """When cloud is started.""" is_new_user = await self.prefs.async_set_username(self.cloud.username) async def enable_alexa(_): @@ -150,7 +150,10 @@ class CloudClient(Interface): if tasks: await asyncio.gather(*(task(None) for task in tasks)) - async def cleanups(self) -> None: + async def cloud_stopped(self) -> None: + """When the cloud is stopped.""" + + async def logout_cleanups(self) -> None: """Cleanup some stuff after logout.""" await self.prefs.async_set_username(None) diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py index 65cbe8bb342..aed66ae179d 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -62,7 +62,7 @@ class CloudGoogleConfig(AbstractConfig): @property def should_report_state(self): """Return if states should be proactively reported.""" - return self._cloud.is_logged_in and self._prefs.google_report_state + return self.enabled and self._prefs.google_report_state @property def local_sdk_webhook_id(self): diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 129b9f83819..d5e93a2a370 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -2,7 +2,7 @@ "domain": "cloud", "name": "Home Assistant Cloud", "documentation": "https://www.home-assistant.io/integrations/cloud", - "requirements": ["hass-nabucasa==0.46.0"], + "requirements": ["hass-nabucasa==0.47.1"], "dependencies": ["http", "webhook"], "after_dependencies": ["google_assistant", "alexa"], "codeowners": ["@home-assistant/cloud"], diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index ebbed89347e..4e3ade38e39 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -15,10 +15,10 @@ from homeassistant.const import ( ATTR_SUPPORTED_FEATURES, CLOUD_NEVER_EXPOSED_ENTITIES, CONF_NAME, - EVENT_HOMEASSISTANT_STARTED, STATE_UNAVAILABLE, ) -from homeassistant.core import Context, CoreState, HomeAssistant, State, callback +from homeassistant.core import Context, HomeAssistant, State, callback +from homeassistant.helpers import start from homeassistant.helpers.area_registry import AreaEntry from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.entity_registry import RegistryEntry @@ -105,15 +105,14 @@ class AbstractConfig(ABC): self._store = GoogleConfigStore(self.hass) await self._store.async_load() - if self.hass.state == CoreState.running: - await self.async_sync_entities_all() + if not self.enabled: return async def sync_google(_): """Sync entities to Google.""" await self.async_sync_entities_all() - self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, sync_google) + start.async_at_start(self.hass, sync_google) @property def enabled(self): diff --git a/homeassistant/components/http/forwarded.py b/homeassistant/components/http/forwarded.py index 6dd2d9adb8a..4cc330a85ed 100644 --- a/homeassistant/components/http/forwarded.py +++ b/homeassistant/components/http/forwarded.py @@ -4,6 +4,8 @@ from __future__ import annotations from collections.abc import Awaitable, Callable from ipaddress import ip_address import logging +from types import ModuleType +from typing import Literal from aiohttp.hdrs import X_FORWARDED_FOR, X_FORWARDED_HOST, X_FORWARDED_PROTO from aiohttp.web import Application, HTTPBadRequest, Request, StreamResponse, middleware @@ -63,23 +65,30 @@ def async_setup_forwarded( an HTTP 400 status code is thrown. """ - try: - from hass_nabucasa import remote # pylint: disable=import-outside-toplevel - - # venv users might have already loaded it before it got upgraded so guard for this - # This can only happen when people upgrade from before 2021.8.5. - if not hasattr(remote, "is_cloud_request"): - remote = None - except ImportError: - remote = None + remote: Literal[False] | None | ModuleType = None @middleware async def forwarded_middleware( request: Request, handler: Callable[[Request], Awaitable[StreamResponse]] ) -> StreamResponse: """Process forwarded data by a reverse proxy.""" + nonlocal remote + + if remote is None: + # Initialize remote method + try: + from hass_nabucasa import ( # pylint: disable=import-outside-toplevel + remote, + ) + + # venv users might have an old version installed if they don't have cloud around anymore + if not hasattr(remote, "is_cloud_request"): + remote = False + except ImportError: + remote = False + # Skip requests from Remote UI - if remote is not None and remote.is_cloud_request.get(): + if remote and remote.is_cloud_request.get(): # type: ignore return await handler(request) # Handle X-Forwarded-For diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index cb6d10e4084..c59fdafdc7a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -15,7 +15,7 @@ ciso8601==2.1.3 cryptography==3.3.2 defusedxml==0.7.1 emoji==1.2.0 -hass-nabucasa==0.46.0 +hass-nabucasa==0.47.1 home-assistant-frontend==20210830.0 httpx==0.19.0 ifaddr==0.1.7 diff --git a/requirements_all.txt b/requirements_all.txt index 179017806ea..2e2c63a9d25 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -760,7 +760,7 @@ habitipy==0.2.0 hangups==0.4.14 # homeassistant.components.cloud -hass-nabucasa==0.46.0 +hass-nabucasa==0.47.1 # homeassistant.components.splunk hass_splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 22f16c40b79..d48136e74b7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -441,7 +441,7 @@ habitipy==0.2.0 hangups==0.4.14 # homeassistant.components.cloud -hass-nabucasa==0.46.0 +hass-nabucasa==0.47.1 # homeassistant.components.tasmota hatasmota==0.2.20 diff --git a/tests/components/cloud/__init__.py b/tests/components/cloud/__init__.py index 8613c6408fe..40809d2759c 100644 --- a/tests/components/cloud/__init__.py +++ b/tests/components/cloud/__init__.py @@ -12,7 +12,7 @@ async def mock_cloud(hass, config=None): assert await async_setup_component(hass, cloud.DOMAIN, {"cloud": config or {}}) cloud_inst = hass.data["cloud"] with patch("hass_nabucasa.Cloud.run_executor", AsyncMock(return_value=None)): - await cloud_inst.start() + await cloud_inst.initialize() def mock_cloud_prefs(hass, prefs={}): diff --git a/tests/components/cloud/conftest.py b/tests/components/cloud/conftest.py index 75276a9f2e2..baa1dd6bae8 100644 --- a/tests/components/cloud/conftest.py +++ b/tests/components/cloud/conftest.py @@ -48,6 +48,8 @@ def mock_cloud_login(hass, mock_cloud_setup): }, "test", ) + with patch.object(hass.data[const.DOMAIN].auth, "async_check_token"): + yield @pytest.fixture diff --git a/tests/components/cloud/test_client.py b/tests/components/cloud/test_client.py index dfea8f80cee..c3890fb17ec 100644 --- a/tests/components/cloud/test_client.py +++ b/tests/components/cloud/test_client.py @@ -134,7 +134,7 @@ async def test_handler_google_actions_disabled(hass, mock_cloud_fixture): """Test handler Google Actions when user has disabled it.""" mock_cloud_fixture._prefs[PREF_ENABLE_GOOGLE] = False - with patch("hass_nabucasa.Cloud.start"): + with patch("hass_nabucasa.Cloud.initialize"): assert await async_setup_component(hass, "cloud", {}) reqid = "5711642932632160983" @@ -149,7 +149,7 @@ async def test_handler_google_actions_disabled(hass, mock_cloud_fixture): async def test_webhook_msg(hass, caplog): """Test webhook msg.""" - with patch("hass_nabucasa.Cloud.start"): + with patch("hass_nabucasa.Cloud.initialize"): setup = await async_setup_component(hass, "cloud", {"cloud": {}}) assert setup cloud = hass.data["cloud"] @@ -261,7 +261,7 @@ async def test_set_username(hass): ) client = CloudClient(hass, prefs, None, {}, {}) client.cloud = MagicMock(is_logged_in=True, username="mock-username") - await client.logged_in() + await client.cloud_started() assert len(prefs.async_set_username.mock_calls) == 1 assert prefs.async_set_username.mock_calls[0][1][0] == "mock-username" @@ -279,7 +279,7 @@ async def test_login_recovers_bad_internet(hass, caplog): client._alexa_config = Mock( async_enable_proactive_mode=Mock(side_effect=aiohttp.ClientError) ) - await client.logged_in() + await client.cloud_started() assert len(client._alexa_config.async_enable_proactive_mode.mock_calls) == 1 assert "Unable to activate Alexa Report State" in caplog.text diff --git a/tests/components/cloud/test_google_config.py b/tests/components/cloud/test_google_config.py index 64d50250259..1f513dbf53e 100644 --- a/tests/components/cloud/test_google_config.py +++ b/tests/components/cloud/test_google_config.py @@ -31,6 +31,8 @@ async def test_google_update_report_state(mock_conf, hass, cloud_prefs): await mock_conf.async_initialize() await mock_conf.async_connect_agent_user("mock-user-id") + mock_conf._cloud.subscription_expired = False + with patch.object(mock_conf, "async_sync_entities") as mock_sync, patch( "homeassistant.components.google_assistant.report_state.async_enable_report_state" ) as mock_report_state: @@ -41,6 +43,25 @@ async def test_google_update_report_state(mock_conf, hass, cloud_prefs): assert len(mock_report_state.mock_calls) == 1 +async def test_google_update_report_state_subscription_expired( + mock_conf, hass, cloud_prefs +): + """Test Google config not reporting state when subscription has expired.""" + await mock_conf.async_initialize() + await mock_conf.async_connect_agent_user("mock-user-id") + + assert mock_conf._cloud.subscription_expired + + with patch.object(mock_conf, "async_sync_entities") as mock_sync, patch( + "homeassistant.components.google_assistant.report_state.async_enable_report_state" + ) as mock_report_state: + await cloud_prefs.async_update(google_report_state=True) + await hass.async_block_till_done() + + assert len(mock_sync.mock_calls) == 0 + assert len(mock_report_state.mock_calls) == 0 + + async def test_sync_entities(mock_conf, hass, cloud_prefs): """Test sync devices.""" await mock_conf.async_initialize() @@ -172,6 +193,7 @@ async def test_sync_google_when_started(hass, mock_cloud_login, cloud_prefs): with patch.object(config, "async_sync_entities_all") as mock_sync: await config.async_initialize() await config.async_connect_agent_user("mock-user-id") + await hass.async_block_till_done() assert len(mock_sync.mock_calls) == 1 diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index 7202c8a0b39..e478849d3ef 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -15,7 +15,7 @@ from homeassistant.setup import async_setup_component async def test_constructor_loads_info_from_config(hass): """Test non-dev mode loads info from SERVERS constant.""" - with patch("hass_nabucasa.Cloud.start"): + with patch("hass_nabucasa.Cloud.initialize"): result = await async_setup_component( hass, "cloud", @@ -109,7 +109,7 @@ async def test_setup_existing_cloud_user(hass, hass_storage): """Test setup with API push default data.""" user = await hass.auth.async_create_system_user("Cloud test") hass_storage[STORAGE_KEY] = {"version": 1, "data": {"cloud_user": user.id}} - with patch("hass_nabucasa.Cloud.start"): + with patch("hass_nabucasa.Cloud.initialize"): result = await async_setup_component( hass, "cloud", From e631671832fb760cf3bfa782f3ce61d1d0e9c39e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 1 Sep 2021 20:45:29 +0200 Subject: [PATCH 150/843] Use respx.mock in generic camera tests (#55521) --- tests/components/generic/test_camera.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/tests/components/generic/test_camera.py b/tests/components/generic/test_camera.py index 1a1edc4eece..8642a6a7fac 100644 --- a/tests/components/generic/test_camera.py +++ b/tests/components/generic/test_camera.py @@ -50,9 +50,10 @@ async def test_fetching_url(hass, hass_client): assert respx.calls.call_count == 2 -async def test_fetching_without_verify_ssl(aioclient_mock, hass, hass_client): +@respx.mock +async def test_fetching_without_verify_ssl(hass, hass_client): """Test that it fetches the given url when ssl verify is off.""" - aioclient_mock.get("https://example.com", text="hello world") + respx.get("https://example.com").respond(text="hello world") await async_setup_component( hass, @@ -77,9 +78,10 @@ async def test_fetching_without_verify_ssl(aioclient_mock, hass, hass_client): assert resp.status == 200 -async def test_fetching_url_with_verify_ssl(aioclient_mock, hass, hass_client): +@respx.mock +async def test_fetching_url_with_verify_ssl(hass, hass_client): """Test that it fetches the given url when ssl verify is explicitly on.""" - aioclient_mock.get("https://example.com", text="hello world") + respx.get("https://example.com").respond(text="hello world") await async_setup_component( hass, @@ -169,7 +171,7 @@ async def test_limit_refetch(hass, hass_client): assert body == "hello planet" -async def test_stream_source(aioclient_mock, hass, hass_client, hass_ws_client): +async def test_stream_source(hass, hass_client, hass_ws_client): """Test that the stream source is rendered.""" assert await async_setup_component( hass, @@ -209,7 +211,7 @@ async def test_stream_source(aioclient_mock, hass, hass_client, hass_ws_client): assert msg["result"]["url"][-13:] == "playlist.m3u8" -async def test_stream_source_error(aioclient_mock, hass, hass_client, hass_ws_client): +async def test_stream_source_error(hass, hass_client, hass_ws_client): """Test that the stream source has an error.""" assert await async_setup_component( hass, @@ -273,7 +275,7 @@ async def test_setup_alternative_options(hass, hass_ws_client): assert hass.data["camera"].get_entity("camera.config_test") -async def test_no_stream_source(aioclient_mock, hass, hass_client, hass_ws_client): +async def test_no_stream_source(hass, hass_client, hass_ws_client): """Test a stream request without stream source option set.""" assert await async_setup_component( hass, From 7dbd0e52741fd5470ae43c4f4cdbccb95f75e13d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 1 Sep 2021 22:38:00 +0200 Subject: [PATCH 151/843] Fix zeroconf mock and use it in CI group 1's tests (#55526) * Fix zeroconf mock and use it in CI group 1's tests * Mock HaAsyncServiceBrowser --- .../components/bosch_shc/test_config_flow.py | 26 +++++++++---------- .../devolo_home_control/test_init.py | 4 +-- tests/components/esphome/test_config_flow.py | 18 ++++++++----- .../components/homekit_controller/conftest.py | 7 ----- tests/conftest.py | 6 +++-- 5 files changed, 31 insertions(+), 30 deletions(-) diff --git a/tests/components/bosch_shc/test_config_flow.py b/tests/components/bosch_shc/test_config_flow.py index 0e760b899c1..6d8ef9bd32e 100644 --- a/tests/components/bosch_shc/test_config_flow.py +++ b/tests/components/bosch_shc/test_config_flow.py @@ -28,7 +28,7 @@ DISCOVERY_INFO = { } -async def test_form_user(hass): +async def test_form_user(hass, mock_zeroconf): """Test we get the form.""" await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( @@ -92,7 +92,7 @@ async def test_form_user(hass): assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_get_info_connection_error(hass): +async def test_form_get_info_connection_error(hass, mock_zeroconf): """Test we handle connection error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -136,7 +136,7 @@ async def test_form_get_info_exception(hass): assert result2["errors"] == {"base": "unknown"} -async def test_form_pairing_error(hass): +async def test_form_pairing_error(hass, mock_zeroconf): """Test we handle pairing error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -178,7 +178,7 @@ async def test_form_pairing_error(hass): assert result3["errors"] == {"base": "pairing_failed"} -async def test_form_user_invalid_auth(hass): +async def test_form_user_invalid_auth(hass, mock_zeroconf): """Test we handle invalid auth.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -227,7 +227,7 @@ async def test_form_user_invalid_auth(hass): assert result3["errors"] == {"base": "invalid_auth"} -async def test_form_validate_connection_error(hass): +async def test_form_validate_connection_error(hass, mock_zeroconf): """Test we handle connection error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -276,7 +276,7 @@ async def test_form_validate_connection_error(hass): assert result3["errors"] == {"base": "cannot_connect"} -async def test_form_validate_session_error(hass): +async def test_form_validate_session_error(hass, mock_zeroconf): """Test we handle session error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -325,7 +325,7 @@ async def test_form_validate_session_error(hass): assert result3["errors"] == {"base": "session_error"} -async def test_form_validate_exception(hass): +async def test_form_validate_exception(hass, mock_zeroconf): """Test we handle exception.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -374,7 +374,7 @@ async def test_form_validate_exception(hass): assert result3["errors"] == {"base": "unknown"} -async def test_form_already_configured(hass): +async def test_form_already_configured(hass, mock_zeroconf): """Test we get the form.""" await setup.async_setup_component(hass, "persistent_notification", {}) entry = MockConfigEntry( @@ -410,7 +410,7 @@ async def test_form_already_configured(hass): assert entry.data["host"] == "1.1.1.1" -async def test_zeroconf(hass): +async def test_zeroconf(hass, mock_zeroconf): """Test we get the form.""" await setup.async_setup_component(hass, "persistent_notification", {}) @@ -479,7 +479,7 @@ async def test_zeroconf(hass): assert len(mock_setup_entry.mock_calls) == 1 -async def test_zeroconf_already_configured(hass): +async def test_zeroconf_already_configured(hass, mock_zeroconf): """Test we get the form.""" await setup.async_setup_component(hass, "persistent_notification", {}) entry = MockConfigEntry( @@ -512,7 +512,7 @@ async def test_zeroconf_already_configured(hass): assert entry.data["host"] == "1.1.1.1" -async def test_zeroconf_cannot_connect(hass): +async def test_zeroconf_cannot_connect(hass, mock_zeroconf): """Test we get the form.""" with patch( "boschshcpy.session.SHCSession.mdns_info", side_effect=SHCConnectionError @@ -526,7 +526,7 @@ async def test_zeroconf_cannot_connect(hass): assert result["reason"] == "cannot_connect" -async def test_zeroconf_not_bosch_shc(hass): +async def test_zeroconf_not_bosch_shc(hass, mock_zeroconf): """Test we filter out non-bosch_shc devices.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -537,7 +537,7 @@ async def test_zeroconf_not_bosch_shc(hass): assert result["reason"] == "not_bosch_shc" -async def test_reauth(hass): +async def test_reauth(hass, mock_zeroconf): """Test we get the form.""" await setup.async_setup_component(hass, "persistent_notification", {}) mock_config = MockConfigEntry( diff --git a/tests/components/devolo_home_control/test_init.py b/tests/components/devolo_home_control/test_init.py index 657836f9d16..311da47aac0 100644 --- a/tests/components/devolo_home_control/test_init.py +++ b/tests/components/devolo_home_control/test_init.py @@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant from tests.components.devolo_home_control import configure_integration -async def test_setup_entry(hass: HomeAssistant): +async def test_setup_entry(hass: HomeAssistant, mock_zeroconf): """Test setup entry.""" entry = configure_integration(hass) with patch("homeassistant.components.devolo_home_control.HomeControl"): @@ -34,7 +34,7 @@ async def test_setup_entry_maintenance(hass: HomeAssistant): assert entry.state is ConfigEntryState.SETUP_RETRY -async def test_setup_gateway_offline(hass: HomeAssistant): +async def test_setup_gateway_offline(hass: HomeAssistant, mock_zeroconf): """Test setup entry fails on gateway offline.""" entry = configure_integration(hass) with patch( diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index a5de14d946d..27f0c853615 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -55,7 +55,7 @@ def mock_setup_entry(): yield -async def test_user_connection_works(hass, mock_client): +async def test_user_connection_works(hass, mock_client, mock_zeroconf): """Test we can finish a config flow.""" result = await hass.config_entries.flow.async_init( "esphome", @@ -86,7 +86,9 @@ async def test_user_connection_works(hass, mock_client): assert mock_client.password == "" -async def test_user_resolve_error(hass, mock_api_connection_error, mock_client): +async def test_user_resolve_error( + hass, mock_api_connection_error, mock_client, mock_zeroconf +): """Test user step with IP resolve error.""" class MockResolveError(mock_api_connection_error): @@ -116,7 +118,9 @@ async def test_user_resolve_error(hass, mock_api_connection_error, mock_client): assert len(mock_client.disconnect.mock_calls) == 1 -async def test_user_connection_error(hass, mock_api_connection_error, mock_client): +async def test_user_connection_error( + hass, mock_api_connection_error, mock_client, mock_zeroconf +): """Test user step with connection error.""" mock_client.device_info.side_effect = mock_api_connection_error @@ -135,7 +139,7 @@ async def test_user_connection_error(hass, mock_api_connection_error, mock_clien assert len(mock_client.disconnect.mock_calls) == 1 -async def test_user_with_password(hass, mock_client): +async def test_user_with_password(hass, mock_client, mock_zeroconf): """Test user step with password.""" mock_client.device_info = AsyncMock(return_value=MockDeviceInfo(True, "test")) @@ -161,7 +165,9 @@ async def test_user_with_password(hass, mock_client): assert mock_client.password == "password1" -async def test_user_invalid_password(hass, mock_api_connection_error, mock_client): +async def test_user_invalid_password( + hass, mock_api_connection_error, mock_client, mock_zeroconf +): """Test user step with invalid password.""" mock_client.device_info = AsyncMock(return_value=MockDeviceInfo(True, "test")) @@ -185,7 +191,7 @@ async def test_user_invalid_password(hass, mock_api_connection_error, mock_clien assert result["errors"] == {"base": "invalid_auth"} -async def test_discovery_initiation(hass, mock_client): +async def test_discovery_initiation(hass, mock_client, mock_zeroconf): """Test discovery importing works.""" mock_client.device_info = AsyncMock(return_value=MockDeviceInfo(False, "test8266")) diff --git a/tests/components/homekit_controller/conftest.py b/tests/components/homekit_controller/conftest.py index 266fa177fb2..4e095b1d2d9 100644 --- a/tests/components/homekit_controller/conftest.py +++ b/tests/components/homekit_controller/conftest.py @@ -11,13 +11,6 @@ import homeassistant.util.dt as dt_util from tests.components.light.conftest import mock_light_profiles # noqa: F401 -@pytest.fixture(autouse=True) -def mock_zeroconf(): - """Mock zeroconf.""" - with mock.patch("homeassistant.components.zeroconf.models.HaZeroconf") as mock_zc: - yield mock_zc.return_value - - @pytest.fixture def utcnow(request): """Freeze time at a known point.""" diff --git a/tests/conftest.py b/tests/conftest.py index ce8e244f420..36cba8d8e47 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -480,8 +480,10 @@ async def mqtt_mock(hass, mqtt_client_mock, mqtt_config): @pytest.fixture def mock_zeroconf(): """Mock zeroconf.""" - with patch("homeassistant.components.zeroconf.models.HaZeroconf") as mock_zc: - yield mock_zc.return_value + with patch("homeassistant.components.zeroconf.HaZeroconf", autospec=True), patch( + "homeassistant.components.zeroconf.HaAsyncServiceBrowser", autospec=True + ): + yield @pytest.fixture From 02eba22068b2759966eff8d47f76bd7f262b8898 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Wed, 1 Sep 2021 17:22:17 -0400 Subject: [PATCH 152/843] Add additional test coverage for zwave_js meter sensors (#55465) --- .../zwave_js/discovery_data_template.py | 2 +- tests/components/zwave_js/test_sensor.py | 89 +++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/zwave_js/discovery_data_template.py b/homeassistant/components/zwave_js/discovery_data_template.py index 974cd2bfa44..23482bd33fe 100644 --- a/homeassistant/components/zwave_js/discovery_data_template.py +++ b/homeassistant/components/zwave_js/discovery_data_template.py @@ -201,7 +201,7 @@ class NumericSensorDataTemplate(BaseDiscoverySchemaDataTemplate): ): return ENTITY_DESC_KEY_TOTAL_INCREASING # We do this because even though these are power scales, they don't meet - # the unit requirements for the energy power class. + # the unit requirements for the power device class. if scale_type == ElectricScale.KILOVOLT_AMPERE_REACTIVE: return ENTITY_DESC_KEY_MEASUREMENT diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index b595b6462b3..fe17b071175 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -1,17 +1,24 @@ """Test the Z-Wave JS sensor platform.""" +import copy + +from zwave_js_server.const.command_class.meter import MeterType from zwave_js_server.event import Event +from zwave_js_server.model.node import Node from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, STATE_CLASS_MEASUREMENT, STATE_CLASS_TOTAL_INCREASING, ) from homeassistant.components.zwave_js.const import ( ATTR_METER_TYPE, + ATTR_METER_TYPE_NAME, ATTR_VALUE, DOMAIN, SERVICE_RESET_METER, ) from homeassistant.const import ( + ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, @@ -268,3 +275,85 @@ async def test_reset_meter( assert args["args"] == [{"type": 1, "targetValue": 2}] client.async_send_command_no_wait.reset_mock() + + +async def test_meter_attributes( + hass, + client, + aeon_smart_switch_6, + integration, +): + """Test meter entity attributes.""" + state = hass.states.get(METER_ENERGY_SENSOR) + assert state + assert state.attributes[ATTR_METER_TYPE] == MeterType.ELECTRIC.value + assert state.attributes[ATTR_METER_TYPE_NAME] == MeterType.ELECTRIC.name + assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_ENERGY + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING + + +async def test_special_meters(hass, aeon_smart_switch_6_state, client, integration): + """Test meters that have special handling.""" + node_data = copy.deepcopy( + aeon_smart_switch_6_state + ) # Copy to allow modification in tests. + # Add an ElectricScale.KILOVOLT_AMPERE_HOUR value to the state so we can test that + # it is handled differently (no device class) + node_data["values"].append( + { + "endpoint": 10, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 65537, + "propertyName": "value", + "propertyKeyName": "Electric_kVah_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": True, + "writeable": False, + "label": "Electric Consumed [kVah]", + "unit": "kVah", + "ccSpecific": {"meterType": 1, "rateType": 1, "scale": 1}, + }, + "value": 659.813, + }, + ) + # Add an ElectricScale.KILOVOLT_AMPERE_REACTIVE value to the state so we can test that + # it is handled differently (no device class) + node_data["values"].append( + { + "endpoint": 11, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 65537, + "propertyName": "value", + "propertyKeyName": "Electric_kVa_reactive_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": True, + "writeable": False, + "label": "Electric Consumed [kVa reactive]", + "unit": "kVa reactive", + "ccSpecific": {"meterType": 1, "rateType": 1, "scale": 7}, + }, + "value": 659.813, + }, + ) + node = Node(client, node_data) + event = {"node": node} + client.driver.controller.emit("node added", event) + await hass.async_block_till_done() + + state = hass.states.get("sensor.smart_switch_6_electric_consumed_kvah_10") + assert state + assert ATTR_DEVICE_CLASS not in state.attributes + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING + + state = hass.states.get("sensor.smart_switch_6_electric_consumed_kva_reactive_11") + assert state + assert ATTR_DEVICE_CLASS not in state.attributes + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_MEASUREMENT From aef4a69cd09ed787827baa06c5022d306870ba64 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Thu, 2 Sep 2021 00:18:12 +0200 Subject: [PATCH 153/843] xiaomi_miio: bump python-miio dependency (#55549) --- homeassistant/components/xiaomi_miio/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/manifest.json b/homeassistant/components/xiaomi_miio/manifest.json index 18aa7f75ce1..28f3c2da0c5 100644 --- a/homeassistant/components/xiaomi_miio/manifest.json +++ b/homeassistant/components/xiaomi_miio/manifest.json @@ -3,7 +3,7 @@ "name": "Xiaomi Miio", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/xiaomi_miio", - "requirements": ["construct==2.10.56", "micloud==0.3", "python-miio==0.5.7"], + "requirements": ["construct==2.10.56", "micloud==0.3", "python-miio==0.5.8"], "codeowners": ["@rytilahti", "@syssi", "@starkillerOG", "@bieniu"], "zeroconf": ["_miio._udp.local."], "iot_class": "local_polling" diff --git a/requirements_all.txt b/requirements_all.txt index 2e2c63a9d25..b9b7f6d91b9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1880,7 +1880,7 @@ python-juicenet==1.0.2 # python-lirc==1.2.3 # homeassistant.components.xiaomi_miio -python-miio==0.5.7 +python-miio==0.5.8 # homeassistant.components.mpd python-mpd2==3.0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d48136e74b7..7be6a427d5e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1067,7 +1067,7 @@ python-izone==1.1.6 python-juicenet==1.0.2 # homeassistant.components.xiaomi_miio -python-miio==0.5.7 +python-miio==0.5.8 # homeassistant.components.nest python-nest==4.1.0 From 6b4f2e6f8f48eb55c399c2e701cfd33e9ed53296 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Thu, 2 Sep 2021 00:20:52 +0000 Subject: [PATCH 154/843] [ci skip] Translation update --- .../alarm_control_panel/translations/nl.json | 8 +++---- .../components/cloud/translations/ca.json | 1 + .../components/cloud/translations/de.json | 1 + .../components/cloud/translations/en.json | 2 +- .../components/cloud/translations/it.json | 1 + .../components/iotawatt/translations/it.json | 23 +++++++++++++++++++ .../components/iotawatt/translations/no.json | 23 +++++++++++++++++++ .../nmap_tracker/translations/it.json | 1 + .../synology_dsm/translations/it.json | 3 ++- .../components/zha/translations/it.json | 3 ++- .../components/zha/translations/nl.json | 4 ++-- 11 files changed, 61 insertions(+), 9 deletions(-) create mode 100644 homeassistant/components/iotawatt/translations/it.json create mode 100644 homeassistant/components/iotawatt/translations/no.json diff --git a/homeassistant/components/alarm_control_panel/translations/nl.json b/homeassistant/components/alarm_control_panel/translations/nl.json index 0d81ed505f9..5527101589b 100644 --- a/homeassistant/components/alarm_control_panel/translations/nl.json +++ b/homeassistant/components/alarm_control_panel/translations/nl.json @@ -4,7 +4,7 @@ "arm_away": "Schakel {entity_name} in voor vertrek", "arm_home": "Schakel {entity_name} in voor thuis", "arm_night": "Schakel {entity_name} in voor 's nachts", - "arm_vacation": "Schakel {entity_name} in op vakantie", + "arm_vacation": "Schakel {entity_name} in voor vakantie", "disarm": "Schakel {entity_name} uit", "trigger": "Laat {entity_name} afgaan" }, @@ -12,7 +12,7 @@ "is_armed_away": "{entity_name} ingeschakeld voor vertrek", "is_armed_home": "{entity_name} ingeschakeld voor thuis", "is_armed_night": "{entity_name} is ingeschakeld voor 's nachts", - "is_armed_vacation": "{entity_name} is in vakantie geschakeld", + "is_armed_vacation": "{entity_name} is ingeschakeld voor vakantie", "is_disarmed": "{entity_name} is uitgeschakeld", "is_triggered": "{entity_name} gaat af" }, @@ -20,7 +20,7 @@ "armed_away": "{entity_name} ingeschakeld voor vertrek", "armed_home": "{entity_name} ingeschakeld voor thuis", "armed_night": "{entity_name} ingeschakeld voor 's nachts", - "armed_vacation": "{entity_name} schakelde vakantie in", + "armed_vacation": "{entity_name} schakelde in voor vakantie", "disarmed": "{entity_name} uitgeschakeld", "triggered": "{entity_name} afgegaan" } @@ -40,5 +40,5 @@ "triggered": "Gaat af" } }, - "title": "Alarm bedieningspaneel" + "title": "Alarmbedieningspaneel" } \ No newline at end of file diff --git a/homeassistant/components/cloud/translations/ca.json b/homeassistant/components/cloud/translations/ca.json index 4e6a14cd2f0..c5fec79a89d 100644 --- a/homeassistant/components/cloud/translations/ca.json +++ b/homeassistant/components/cloud/translations/ca.json @@ -10,6 +10,7 @@ "relayer_connected": "Encaminador connectat", "remote_connected": "Connexi\u00f3 remota establerta", "remote_enabled": "Connexi\u00f3 remota activada", + "remote_server": "Servidor remot", "subscription_expiration": "Caducitat de la subscripci\u00f3" } } diff --git a/homeassistant/components/cloud/translations/de.json b/homeassistant/components/cloud/translations/de.json index fd5598fa026..0b924c65428 100644 --- a/homeassistant/components/cloud/translations/de.json +++ b/homeassistant/components/cloud/translations/de.json @@ -10,6 +10,7 @@ "relayer_connected": "Relay Verbunden", "remote_connected": "Remote verbunden", "remote_enabled": "Remote aktiviert", + "remote_server": "Remote-Server", "subscription_expiration": "Ablauf des Abonnements" } } diff --git a/homeassistant/components/cloud/translations/en.json b/homeassistant/components/cloud/translations/en.json index dbaae234f83..7577a9a51e4 100644 --- a/homeassistant/components/cloud/translations/en.json +++ b/homeassistant/components/cloud/translations/en.json @@ -7,10 +7,10 @@ "can_reach_cloud_auth": "Reach Authentication Server", "google_enabled": "Google Enabled", "logged_in": "Logged In", - "remote_server": "Remote Server", "relayer_connected": "Relayer Connected", "remote_connected": "Remote Connected", "remote_enabled": "Remote Enabled", + "remote_server": "Remote Server", "subscription_expiration": "Subscription Expiration" } } diff --git a/homeassistant/components/cloud/translations/it.json b/homeassistant/components/cloud/translations/it.json index fbe13abc41e..e867bbacc26 100644 --- a/homeassistant/components/cloud/translations/it.json +++ b/homeassistant/components/cloud/translations/it.json @@ -10,6 +10,7 @@ "relayer_connected": "Relayer connesso", "remote_connected": "Connesso in remoto", "remote_enabled": "Remoto abilitato", + "remote_server": "Server remoto", "subscription_expiration": "Scadenza abbonamento" } } diff --git a/homeassistant/components/iotawatt/translations/it.json b/homeassistant/components/iotawatt/translations/it.json new file mode 100644 index 00000000000..ecb7d5b48af --- /dev/null +++ b/homeassistant/components/iotawatt/translations/it.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "cannot_connect": "Impossibile connettersi", + "invalid_auth": "Autenticazione non valida", + "unknown": "Errore imprevisto" + }, + "step": { + "auth": { + "data": { + "password": "Password", + "username": "Nome utente" + }, + "description": "Il dispositivo IoTawatt richiede l'autenticazione. Inserisci il nome utente e la password e fai clic sul pulsante Invia." + }, + "user": { + "data": { + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/iotawatt/translations/no.json b/homeassistant/components/iotawatt/translations/no.json new file mode 100644 index 00000000000..bf350e5d7e5 --- /dev/null +++ b/homeassistant/components/iotawatt/translations/no.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "cannot_connect": "Tilkobling mislyktes", + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, + "step": { + "auth": { + "data": { + "password": "Passord", + "username": "Brukernavn" + }, + "description": "IoTawatt -enheten krever autentisering. Skriv inn brukernavn og passord og klikk p\u00e5 Send -knappen." + }, + "user": { + "data": { + "host": "Vert" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nmap_tracker/translations/it.json b/homeassistant/components/nmap_tracker/translations/it.json index 921d131c3bb..8a7de165778 100644 --- a/homeassistant/components/nmap_tracker/translations/it.json +++ b/homeassistant/components/nmap_tracker/translations/it.json @@ -25,6 +25,7 @@ "step": { "init": { "data": { + "consider_home": "Secondi di attesa per contrassegnare un localizzatore di dispositivi come non in casa dopo non essere stato visto.", "exclude": "Indirizzi di rete (separati da virgole) da escludere dalla scansione", "home_interval": "Numero minimo di minuti tra le scansioni dei dispositivi attivi (preserva la batteria)", "hosts": "Indirizzi di rete (separati da virgole) da scansionare", diff --git a/homeassistant/components/synology_dsm/translations/it.json b/homeassistant/components/synology_dsm/translations/it.json index bb6965255bb..a9ea23bf08a 100644 --- a/homeassistant/components/synology_dsm/translations/it.json +++ b/homeassistant/components/synology_dsm/translations/it.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", - "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente" + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente", + "reconfigure_successful": "La riconfigurazione \u00e8 andata a buon fine" }, "error": { "cannot_connect": "Impossibile connettersi", diff --git a/homeassistant/components/zha/translations/it.json b/homeassistant/components/zha/translations/it.json index 2b58e486a2c..ba2427ebd4c 100644 --- a/homeassistant/components/zha/translations/it.json +++ b/homeassistant/components/zha/translations/it.json @@ -2,7 +2,8 @@ "config": { "abort": { "not_zha_device": "Questo dispositivo non \u00e8 un dispositivo zha", - "single_instance_allowed": "Gi\u00e0 configurato. \u00c8 possibile una sola configurazione." + "single_instance_allowed": "Gi\u00e0 configurato. \u00c8 possibile una sola configurazione.", + "usb_probe_failed": "Impossibile interrogare il dispositivo USB" }, "error": { "cannot_connect": "Impossibile connettersi" diff --git a/homeassistant/components/zha/translations/nl.json b/homeassistant/components/zha/translations/nl.json index 403d9e2fde6..54692b22598 100644 --- a/homeassistant/components/zha/translations/nl.json +++ b/homeassistant/components/zha/translations/nl.json @@ -42,8 +42,8 @@ "zha_alarm_options": { "alarm_arm_requires_code": "Code vereist voor inschakelacties", "alarm_failed_tries": "Het aantal opeenvolgende foute codes om het alarm te activeren", - "alarm_master_code": "Mastercode voor het alarm bedieningspaneel", - "title": "Alarm bedieningspaneel Opties" + "alarm_master_code": "Mastercode voor het alarmbedieningspaneel", + "title": "Alarmbedieningspaneelopties" }, "zha_options": { "consider_unavailable_battery": "Beschouw apparaten met batterijvoeding als onbeschikbaar na (seconden)", From cb1e0666c8678c38d683312850d8581dc0013962 Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Wed, 1 Sep 2021 22:54:35 -0400 Subject: [PATCH 155/843] Pick right coordinator (#55555) --- homeassistant/components/zha/core/gateway.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index d093c02d568..50da16802b3 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -174,7 +174,7 @@ class ZHAGateway: """Restore ZHA devices from zigpy application state.""" for zigpy_device in self.application_controller.devices.values(): zha_device = self._async_get_or_create_device(zigpy_device, restored=True) - if zha_device.nwk == 0x0000: + if zha_device.ieee == self.application_controller.ieee: self.coordinator_zha_device = zha_device zha_dev_entry = self.zha_storage.devices.get(str(zigpy_device.ieee)) delta_msg = "not known" From b3b9fb0a7ca75cfb4ed3d53369fb9e9bdc589f2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Thu, 2 Sep 2021 11:40:32 +0200 Subject: [PATCH 156/843] Bump pyuptimerobot to 21.9.0 (#55546) --- homeassistant/components/uptimerobot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/uptimerobot/manifest.json b/homeassistant/components/uptimerobot/manifest.json index 279bf6eb43e..66b1dc9abe4 100644 --- a/homeassistant/components/uptimerobot/manifest.json +++ b/homeassistant/components/uptimerobot/manifest.json @@ -3,7 +3,7 @@ "name": "Uptime Robot", "documentation": "https://www.home-assistant.io/integrations/uptimerobot", "requirements": [ - "pyuptimerobot==21.8.2" + "pyuptimerobot==21.9.0" ], "codeowners": [ "@ludeeus" diff --git a/requirements_all.txt b/requirements_all.txt index b9b7f6d91b9..542d461344b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1968,7 +1968,7 @@ pytrafikverket==0.1.6.2 pyudev==0.22.0 # homeassistant.components.uptimerobot -pyuptimerobot==21.8.2 +pyuptimerobot==21.9.0 # homeassistant.components.keyboard # pyuserinput==0.1.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7be6a427d5e..2836e160dae 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1109,7 +1109,7 @@ pytradfri[async]==7.0.6 pyudev==0.22.0 # homeassistant.components.uptimerobot -pyuptimerobot==21.8.2 +pyuptimerobot==21.9.0 # homeassistant.components.vera pyvera==0.3.13 From cdaba62d2c87eac9805b31599f8d0fb3bd379bd0 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 2 Sep 2021 13:09:16 +0200 Subject: [PATCH 157/843] Add test fixture for unauthenticated HTTP client (#55561) * Add test fixture for unauthenticated HTTP client * Remove things from the future --- .../config_flow_oauth2/tests/test_config_flow.py | 4 ++-- tests/components/almond/test_config_flow.py | 4 ++-- tests/conftest.py | 11 +++++++++++ 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/script/scaffold/templates/config_flow_oauth2/tests/test_config_flow.py b/script/scaffold/templates/config_flow_oauth2/tests/test_config_flow.py index ff9c5bfb848..3ed8d9d293f 100644 --- a/script/scaffold/templates/config_flow_oauth2/tests/test_config_flow.py +++ b/script/scaffold/templates/config_flow_oauth2/tests/test_config_flow.py @@ -16,7 +16,7 @@ CLIENT_SECRET = "5678" async def test_full_flow( hass: HomeAssistant, - aiohttp_client, + hass_client_no_auth, aioclient_mock, current_request_with_host, ) -> None: @@ -47,7 +47,7 @@ async def test_full_flow( f"&state={state}" ) - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" diff --git a/tests/components/almond/test_config_flow.py b/tests/components/almond/test_config_flow.py index b5a3d90fbdb..bd1f23d956c 100644 --- a/tests/components/almond/test_config_flow.py +++ b/tests/components/almond/test_config_flow.py @@ -92,7 +92,7 @@ async def test_abort_if_existing_entry(hass): async def test_full_flow( - hass, aiohttp_client, aioclient_mock, current_request_with_host + hass, hass_client_no_auth, aioclient_mock, current_request_with_host ): """Check full flow.""" assert await setup.async_setup_component( @@ -127,7 +127,7 @@ async def test_full_flow( f"&state={state}&scope=profile+user-read+user-read-results+user-exec-command" ) - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" diff --git a/tests/conftest.py b/tests/conftest.py index 36cba8d8e47..c437b70d965 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -331,6 +331,17 @@ def hass_client(hass, aiohttp_client, hass_access_token): return auth_client +@pytest.fixture +def hass_client_no_auth(hass, aiohttp_client): + """Return an unauthenticated HTTP client.""" + + async def client(): + """Return an authenticated client.""" + return await aiohttp_client(hass.http.app) + + return client + + @pytest.fixture def current_request(): """Mock current request.""" From 69aba2a6a1f797a291abab9fc98706667a4a8a99 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Thu, 2 Sep 2021 13:53:38 +0200 Subject: [PATCH 158/843] Correct duplicate address. (#55578) --- homeassistant/components/modbus/validators.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py index fdfffaebd61..a4177a7ff30 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -10,6 +10,8 @@ import voluptuous as vol from homeassistant.const import ( CONF_ADDRESS, + CONF_COMMAND_OFF, + CONF_COMMAND_ON, CONF_COUNT, CONF_HOST, CONF_NAME, @@ -201,15 +203,19 @@ def scan_interval_validator(config: dict) -> dict: def duplicate_entity_validator(config: dict) -> dict: """Control scan_interval.""" for hub_index, hub in enumerate(config): - addresses: set[str] = set() for component, conf_key in PLATFORMS: if conf_key not in hub: continue names: set[str] = set() errors: list[int] = [] + addresses: set[str] = set() for index, entry in enumerate(hub[conf_key]): name = entry[CONF_NAME] addr = str(entry[CONF_ADDRESS]) + if CONF_COMMAND_ON in entry: + addr += "_" + str(entry[CONF_COMMAND_ON]) + if CONF_COMMAND_OFF in entry: + addr += "_" + str(entry[CONF_COMMAND_OFF]) if CONF_SLAVE in entry: addr += "_" + str(entry[CONF_SLAVE]) if addr in addresses: From d5b6dc4f2678b34515bbe5540fa7b03b178dfbe7 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 2 Sep 2021 14:49:20 +0200 Subject: [PATCH 159/843] Use hass_client_no_auth test fixture in integrations a-g (#55581) --- tests/components/api/test_init.py | 6 ++++-- tests/components/august/test_camera.py | 4 ++-- tests/components/dialogflow/test_init.py | 4 ++-- tests/components/emulated_hue/test_hue_api.py | 8 ++++---- tests/components/geofency/test_init.py | 4 ++-- .../components/google_assistant/test_google_assistant.py | 4 ++-- tests/components/gpslogger/test_init.py | 4 ++-- 7 files changed, 18 insertions(+), 16 deletions(-) diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py index ffda908a29b..cb3247f43cb 100644 --- a/tests/components/api/test_init.py +++ b/tests/components/api/test_init.py @@ -382,11 +382,13 @@ def _listen_count(hass): return sum(hass.bus.async_listeners().values()) -async def test_api_error_log(hass, aiohttp_client, hass_access_token, hass_admin_user): +async def test_api_error_log( + hass, hass_client_no_auth, hass_access_token, hass_admin_user +): """Test if we can fetch the error log.""" hass.data[DATA_LOGGING] = "/some/path" await async_setup_component(hass, "api", {}) - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() resp = await client.get(const.URL_API_ERROR_LOG) # Verify auth required diff --git a/tests/components/august/test_camera.py b/tests/components/august/test_camera.py index 151f7972e1e..bc9cd5d2bd7 100644 --- a/tests/components/august/test_camera.py +++ b/tests/components/august/test_camera.py @@ -10,7 +10,7 @@ from tests.components.august.mocks import ( ) -async def test_create_doorbell(hass, aiohttp_client): +async def test_create_doorbell(hass, hass_client_no_auth): """Test creation of a doorbell.""" doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.json") @@ -28,7 +28,7 @@ async def test_create_doorbell(hass, aiohttp_client): "entity_picture" ] - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() resp = await client.get(url) assert resp.status == 200 body = await resp.text() diff --git a/tests/components/dialogflow/test_init.py b/tests/components/dialogflow/test_init.py index c2d0316245a..1f5b5bccfa9 100644 --- a/tests/components/dialogflow/test_init.py +++ b/tests/components/dialogflow/test_init.py @@ -34,7 +34,7 @@ async def calls(hass, fixture): @pytest.fixture -async def fixture(hass, aiohttp_client): +async def fixture(hass, hass_client_no_auth): """Initialize a Home Assistant server for testing this module.""" await async_setup_component(hass, dialogflow.DOMAIN, {"dialogflow": {}}) await async_setup_component( @@ -92,7 +92,7 @@ async def fixture(hass, aiohttp_client): assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY webhook_id = result["result"].data["webhook_id"] - return await aiohttp_client(hass.http.app), webhook_id + return await hass_client_no_auth(), webhook_id class _Data: diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index f9df29e16ae..8515d4e4b0c 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -209,7 +209,7 @@ def hass_hue(loop, hass): @pytest.fixture -def hue_client(loop, hass_hue, aiohttp_client): +def hue_client(loop, hass_hue, hass_client_no_auth): """Create web client for emulated hue api.""" web_app = hass_hue.http.app config = Config( @@ -255,7 +255,7 @@ def hue_client(loop, hass_hue, aiohttp_client): HueFullStateView(config).register(web_app, web_app.router) HueConfigView(config).register(web_app, web_app.router) - return loop.run_until_complete(aiohttp_client(web_app)) + return loop.run_until_complete(hass_client_no_auth()) async def test_discover_lights(hue_client): @@ -302,7 +302,7 @@ async def test_light_without_brightness_supported(hass_hue, hue_client): assert light_without_brightness_json["type"] == "On/Off light" -async def test_lights_all_dimmable(hass, aiohttp_client): +async def test_lights_all_dimmable(hass, hass_client_no_auth): """Test CONF_LIGHTS_ALL_DIMMABLE.""" # create a lamp without brightness support hass.states.async_set("light.no_brightness", "on", {}) @@ -326,7 +326,7 @@ async def test_lights_all_dimmable(hass, aiohttp_client): config.numbers = ENTITY_IDS_BY_NUMBER web_app = hass.http.app HueOneLightStateView(config).register(web_app, web_app.router) - client = await aiohttp_client(web_app) + client = await hass_client_no_auth() light_without_brightness_json = await perform_get_light_state( client, "light.no_brightness", HTTP_OK ) diff --git a/tests/components/geofency/test_init.py b/tests/components/geofency/test_init.py index 169cfebae17..8646eac19a2 100644 --- a/tests/components/geofency/test_init.py +++ b/tests/components/geofency/test_init.py @@ -118,7 +118,7 @@ def mock_dev_track(mock_device_tracker_conf): @pytest.fixture -async def geofency_client(loop, hass, aiohttp_client): +async def geofency_client(loop, hass, hass_client_no_auth): """Geofency mock client (unauthenticated).""" assert await async_setup_component(hass, "persistent_notification", {}) @@ -128,7 +128,7 @@ async def geofency_client(loop, hass, aiohttp_client): await hass.async_block_till_done() with patch("homeassistant.components.device_tracker.legacy.update_config"): - return await aiohttp_client(hass.http.app) + return await hass_client_no_auth() @pytest.fixture(autouse=True) diff --git a/tests/components/google_assistant/test_google_assistant.py b/tests/components/google_assistant/test_google_assistant.py index 2c3a61b8beb..397b4c309a7 100644 --- a/tests/components/google_assistant/test_google_assistant.py +++ b/tests/components/google_assistant/test_google_assistant.py @@ -36,7 +36,7 @@ def auth_header(hass_access_token): @pytest.fixture -def assistant_client(loop, hass, aiohttp_client): +def assistant_client(loop, hass, hass_client_no_auth): """Create web client for the Google Assistant API.""" loop.run_until_complete( setup.async_setup_component( @@ -56,7 +56,7 @@ def assistant_client(loop, hass, aiohttp_client): ) ) - return loop.run_until_complete(aiohttp_client(hass.http.app)) + return loop.run_until_complete(hass_client_no_auth()) @pytest.fixture diff --git a/tests/components/gpslogger/test_init.py b/tests/components/gpslogger/test_init.py index 61e5862d3b1..4305b8d5642 100644 --- a/tests/components/gpslogger/test_init.py +++ b/tests/components/gpslogger/test_init.py @@ -31,7 +31,7 @@ def mock_dev_track(mock_device_tracker_conf): @pytest.fixture -async def gpslogger_client(loop, hass, aiohttp_client): +async def gpslogger_client(loop, hass, hass_client_no_auth): """Mock client for GPSLogger (unauthenticated).""" assert await async_setup_component(hass, "persistent_notification", {}) @@ -40,7 +40,7 @@ async def gpslogger_client(loop, hass, aiohttp_client): await hass.async_block_till_done() with patch("homeassistant.components.device_tracker.legacy.update_config"): - return await aiohttp_client(hass.http.app) + return await hass_client_no_auth() @pytest.fixture(autouse=True) From acdddabe1fee371104044a77b910c1450e438ca4 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 2 Sep 2021 14:49:40 +0200 Subject: [PATCH 160/843] Use hass_client_no_auth test fixture in integrations h-p (#55583) --- .../home_connect/test_config_flow.py | 4 +-- .../home_plus_control/test_config_flow.py | 8 +++--- tests/components/http/test_init.py | 4 +-- tests/components/ifttt/test_init.py | 4 +-- tests/components/neato/test_config_flow.py | 8 +++--- tests/components/netatmo/test_config_flow.py | 4 +-- tests/components/onboarding/test_views.py | 26 ++++++++++--------- .../components/ondilo_ico/test_config_flow.py | 4 +-- tests/components/owntracks/test_init.py | 4 +-- tests/components/plex/test_config_flow.py | 4 +-- tests/components/push/test_camera.py | 8 +++--- 11 files changed, 40 insertions(+), 38 deletions(-) diff --git a/tests/components/home_connect/test_config_flow.py b/tests/components/home_connect/test_config_flow.py index 2852dc4fb57..1f4120115ea 100644 --- a/tests/components/home_connect/test_config_flow.py +++ b/tests/components/home_connect/test_config_flow.py @@ -15,7 +15,7 @@ CLIENT_SECRET = "5678" async def test_full_flow( - hass, aiohttp_client, aioclient_mock, current_request_with_host + hass, hass_client_no_auth, aioclient_mock, current_request_with_host ): """Check full flow.""" assert await setup.async_setup_component( @@ -48,7 +48,7 @@ async def test_full_flow( f"&state={state}" ) - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" diff --git a/tests/components/home_plus_control/test_config_flow.py b/tests/components/home_plus_control/test_config_flow.py index 4a7dbd3d3ee..5eb4115f031 100644 --- a/tests/components/home_plus_control/test_config_flow.py +++ b/tests/components/home_plus_control/test_config_flow.py @@ -20,7 +20,7 @@ from tests.components.home_plus_control.conftest import ( async def test_full_flow( - hass, aiohttp_client, aioclient_mock, current_request_with_host + hass, hass_client_no_auth, aioclient_mock, current_request_with_host ): """Check full flow.""" assert await setup.async_setup_component( @@ -54,7 +54,7 @@ async def test_full_flow( f"&state={state}" ) - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" @@ -138,7 +138,7 @@ async def test_abort_if_entry_exists(hass, current_request_with_host): async def test_abort_if_invalid_token( - hass, aiohttp_client, aioclient_mock, current_request_with_host + hass, hass_client_no_auth, aioclient_mock, current_request_with_host ): """Check flow abort when the token has an invalid value.""" assert await setup.async_setup_component( @@ -172,7 +172,7 @@ async def test_abort_if_invalid_token( f"&state={state}" ) - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 65f01118c71..446b6c218bb 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -64,10 +64,10 @@ async def test_registering_view_while_running( hass.http.register_view(TestView) -async def test_not_log_password(hass, aiohttp_client, caplog, legacy_auth): +async def test_not_log_password(hass, hass_client_no_auth, caplog, legacy_auth): """Test access with password doesn't get logged.""" assert await async_setup_component(hass, "api", {"http": {}}) - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() logging.getLogger("aiohttp.access").setLevel(logging.INFO) resp = await client.get("/api/", params={"api_password": "test-password"}) diff --git a/tests/components/ifttt/test_init.py b/tests/components/ifttt/test_init.py index 077fb6d7470..1ca8395e11f 100644 --- a/tests/components/ifttt/test_init.py +++ b/tests/components/ifttt/test_init.py @@ -5,7 +5,7 @@ from homeassistant.config import async_process_ha_core_config from homeassistant.core import callback -async def test_config_flow_registers_webhook(hass, aiohttp_client): +async def test_config_flow_registers_webhook(hass, hass_client_no_auth): """Test setting up IFTTT and sending webhook.""" await async_process_ha_core_config( hass, @@ -30,7 +30,7 @@ async def test_config_flow_registers_webhook(hass, aiohttp_client): hass.bus.async_listen(ifttt.EVENT_RECEIVED, handle_event) - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() await client.post(f"/api/webhook/{webhook_id}", json={"hello": "ifttt"}) assert len(ifttt_events) == 1 diff --git a/tests/components/neato/test_config_flow.py b/tests/components/neato/test_config_flow.py index 3f07e4c4b0a..48bdf247f51 100644 --- a/tests/components/neato/test_config_flow.py +++ b/tests/components/neato/test_config_flow.py @@ -19,7 +19,7 @@ OAUTH2_TOKEN = VENDOR.token_endpoint async def test_full_flow( - hass, aiohttp_client, aioclient_mock, current_request_with_host + hass, hass_client_no_auth, aioclient_mock, current_request_with_host ): """Check full flow.""" assert await setup.async_setup_component( @@ -50,7 +50,7 @@ async def test_full_flow( "&scope=public_profile+control_robots+maps" ) - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" @@ -91,7 +91,7 @@ async def test_abort_if_already_setup(hass: HomeAssistant): async def test_reauth( - hass: HomeAssistant, aiohttp_client, aioclient_mock, current_request_with_host + hass: HomeAssistant, hass_client_no_auth, aioclient_mock, current_request_with_host ): """Test initialization of the reauth flow.""" assert await setup.async_setup_component( @@ -127,7 +127,7 @@ async def test_reauth( }, ) - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") assert resp.status == 200 diff --git a/tests/components/netatmo/test_config_flow.py b/tests/components/netatmo/test_config_flow.py index 6bd8086c820..8f18ae1410a 100644 --- a/tests/components/netatmo/test_config_flow.py +++ b/tests/components/netatmo/test_config_flow.py @@ -44,7 +44,7 @@ async def test_abort_if_existing_entry(hass): async def test_full_flow( - hass, aiohttp_client, aioclient_mock, current_request_with_host + hass, hass_client_no_auth, aioclient_mock, current_request_with_host ): """Check full flow.""" assert await setup.async_setup_component( @@ -89,7 +89,7 @@ async def test_full_flow( f"&state={state}&scope={scope}" ) - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index 75fcb9c0746..66f68ad8b33 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -91,14 +91,14 @@ async def mock_supervisor_fixture(hass, aioclient_mock): yield -async def test_onboarding_progress(hass, hass_storage, aiohttp_client): +async def test_onboarding_progress(hass, hass_storage, hass_client_no_auth): """Test fetching progress.""" mock_storage(hass_storage, {"done": ["hello"]}) assert await async_setup_component(hass, "onboarding", {}) await hass.async_block_till_done() - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() with patch.object(views, "STEPS", ["hello", "world"]): resp = await client.get("/api/onboarding") @@ -110,7 +110,7 @@ async def test_onboarding_progress(hass, hass_storage, aiohttp_client): assert data[1] == {"step": "world", "done": False} -async def test_onboarding_user_already_done(hass, hass_storage, aiohttp_client): +async def test_onboarding_user_already_done(hass, hass_storage, hass_client_no_auth): """Test creating a new user when user step already done.""" mock_storage(hass_storage, {"done": [views.STEP_USER]}) @@ -118,7 +118,7 @@ async def test_onboarding_user_already_done(hass, hass_storage, aiohttp_client): assert await async_setup_component(hass, "onboarding", {}) await hass.async_block_till_done() - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() resp = await client.post( "/api/onboarding/users", @@ -134,13 +134,13 @@ async def test_onboarding_user_already_done(hass, hass_storage, aiohttp_client): assert resp.status == HTTP_FORBIDDEN -async def test_onboarding_user(hass, hass_storage, aiohttp_client): +async def test_onboarding_user(hass, hass_storage, hass_client_no_auth): """Test creating a new user.""" assert await async_setup_component(hass, "person", {}) assert await async_setup_component(hass, "onboarding", {}) await hass.async_block_till_done() - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() resp = await client.post( "/api/onboarding/users", @@ -194,14 +194,14 @@ async def test_onboarding_user(hass, hass_storage, aiohttp_client): ] -async def test_onboarding_user_invalid_name(hass, hass_storage, aiohttp_client): +async def test_onboarding_user_invalid_name(hass, hass_storage, hass_client_no_auth): """Test not providing name.""" mock_storage(hass_storage, {"done": []}) assert await async_setup_component(hass, "onboarding", {}) await hass.async_block_till_done() - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() resp = await client.post( "/api/onboarding/users", @@ -216,14 +216,14 @@ async def test_onboarding_user_invalid_name(hass, hass_storage, aiohttp_client): assert resp.status == 400 -async def test_onboarding_user_race(hass, hass_storage, aiohttp_client): +async def test_onboarding_user_race(hass, hass_storage, hass_client_no_auth): """Test race condition on creating new user.""" mock_storage(hass_storage, {"done": ["hello"]}) assert await async_setup_component(hass, "onboarding", {}) await hass.async_block_till_done() - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() resp1 = client.post( "/api/onboarding/users", @@ -340,14 +340,16 @@ async def test_onboarding_integration_invalid_redirect_uri( assert len(user.refresh_tokens) == 1, user -async def test_onboarding_integration_requires_auth(hass, hass_storage, aiohttp_client): +async def test_onboarding_integration_requires_auth( + hass, hass_storage, hass_client_no_auth +): """Test finishing integration step.""" mock_storage(hass_storage, {"done": [const.STEP_USER]}) assert await async_setup_component(hass, "onboarding", {}) await hass.async_block_till_done() - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() resp = await client.post( "/api/onboarding/integration", json={"client_id": CLIENT_ID} diff --git a/tests/components/ondilo_ico/test_config_flow.py b/tests/components/ondilo_ico/test_config_flow.py index 69d69e06b7c..e1edfc2a63c 100644 --- a/tests/components/ondilo_ico/test_config_flow.py +++ b/tests/components/ondilo_ico/test_config_flow.py @@ -30,7 +30,7 @@ async def test_abort_if_existing_entry(hass): async def test_full_flow( - hass, aiohttp_client, aioclient_mock, current_request_with_host + hass, hass_client_no_auth, aioclient_mock, current_request_with_host ): """Check full flow.""" assert await setup.async_setup_component( @@ -60,7 +60,7 @@ async def test_full_flow( "&scope=api" ) - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" diff --git a/tests/components/owntracks/test_init.py b/tests/components/owntracks/test_init.py index 0946358548e..856d3ece298 100644 --- a/tests/components/owntracks/test_init.py +++ b/tests/components/owntracks/test_init.py @@ -39,7 +39,7 @@ def mock_dev_track(mock_device_tracker_conf): @pytest.fixture -def mock_client(hass, aiohttp_client): +def mock_client(hass, hass_client_no_auth): """Start the Home Assistant HTTP component.""" mock_component(hass, "group") mock_component(hass, "zone") @@ -50,7 +50,7 @@ def mock_client(hass, aiohttp_client): ).add_to_hass(hass) hass.loop.run_until_complete(async_setup_component(hass, "owntracks", {})) - return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) + return hass.loop.run_until_complete(hass_client_no_auth()) async def test_handle_valid_message(mock_client): diff --git a/tests/components/plex/test_config_flow.py b/tests/components/plex/test_config_flow.py index 45904588a10..9f9e4e1cdfb 100644 --- a/tests/components/plex/test_config_flow.py +++ b/tests/components/plex/test_config_flow.py @@ -509,7 +509,7 @@ async def test_external_timed_out(hass, current_request_with_host): assert result["reason"] == "token_request_timeout" -async def test_callback_view(hass, aiohttp_client, current_request_with_host): +async def test_callback_view(hass, hass_client_no_auth, current_request_with_host): """Test callback view.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -525,7 +525,7 @@ async def test_callback_view(hass, aiohttp_client, current_request_with_host): ) assert result["type"] == "external" - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() forward_url = f'{config_flow.AUTH_CALLBACK_PATH}?flow_id={result["flow_id"]}' resp = await client.get(forward_url) diff --git a/tests/components/push/test_camera.py b/tests/components/push/test_camera.py index 644db2b9dd5..d4759350341 100644 --- a/tests/components/push/test_camera.py +++ b/tests/components/push/test_camera.py @@ -9,7 +9,7 @@ from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed -async def test_bad_posting(hass, aiohttp_client): +async def test_bad_posting(hass, hass_client_no_auth): """Test that posting to wrong api endpoint fails.""" await async_process_ha_core_config( hass, @@ -30,7 +30,7 @@ async def test_bad_posting(hass, aiohttp_client): await hass.async_block_till_done() assert hass.states.get("camera.config_test") is not None - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() # missing file async with client.post("/api/webhook/camera.config_test") as resp: @@ -40,7 +40,7 @@ async def test_bad_posting(hass, aiohttp_client): assert camera_state.state == "idle" # no file supplied we are still idle -async def test_posting_url(hass, aiohttp_client): +async def test_posting_url(hass, hass_client_no_auth): """Test that posting to api endpoint works.""" await async_process_ha_core_config( hass, @@ -60,7 +60,7 @@ async def test_posting_url(hass, aiohttp_client): ) await hass.async_block_till_done() - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() files = {"image": io.BytesIO(b"fake")} # initial state From bfd799dc045401f48d1e1d254696089b2ab4e122 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 2 Sep 2021 14:50:10 +0200 Subject: [PATCH 161/843] Use hass_client_no_auth test fixture in integrations s-x (#55585) --- tests/components/smappee/test_config_flow.py | 4 ++-- tests/components/somfy/test_config_flow.py | 4 ++-- tests/components/spotify/test_config_flow.py | 16 ++++++------- tests/components/toon/test_config_flow.py | 24 +++++++++---------- tests/components/traccar/test_init.py | 4 ++-- tests/components/twilio/test_init.py | 4 ++-- tests/components/webhook/test_trigger.py | 16 ++++++------- tests/components/websocket_api/conftest.py | 4 ++-- tests/components/websocket_api/test_auth.py | 18 +++++++------- .../components/websocket_api/test_commands.py | 6 +++-- tests/components/websocket_api/test_sensor.py | 4 ++-- tests/components/xbox/test_config_flow.py | 4 ++-- .../helpers/test_config_entry_oauth2_flow.py | 8 +++---- 13 files changed, 60 insertions(+), 56 deletions(-) diff --git a/tests/components/smappee/test_config_flow.py b/tests/components/smappee/test_config_flow.py index bc9175a3b46..73fbf81fce0 100644 --- a/tests/components/smappee/test_config_flow.py +++ b/tests/components/smappee/test_config_flow.py @@ -395,7 +395,7 @@ async def test_abort_cloud_flow_if_local_device_exists(hass): async def test_full_user_flow( - hass, aiohttp_client, aioclient_mock, current_request_with_host + hass, hass_client_no_auth, aioclient_mock, current_request_with_host ): """Check full flow.""" assert await setup.async_setup_component( @@ -422,7 +422,7 @@ async def test_full_user_flow( }, ) - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" diff --git a/tests/components/somfy/test_config_flow.py b/tests/components/somfy/test_config_flow.py index 6a1c32e4138..bcfb617db96 100644 --- a/tests/components/somfy/test_config_flow.py +++ b/tests/components/somfy/test_config_flow.py @@ -34,7 +34,7 @@ async def test_abort_if_existing_entry(hass): async def test_full_flow( - hass, aiohttp_client, aioclient_mock, current_request_with_host + hass, hass_client_no_auth, aioclient_mock, current_request_with_host ): """Check full flow.""" assert await setup.async_setup_component( @@ -67,7 +67,7 @@ async def test_full_flow( f"&state={state}" ) - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" diff --git a/tests/components/spotify/test_config_flow.py b/tests/components/spotify/test_config_flow.py index fbc5bde9e58..0d0d4a50a3d 100644 --- a/tests/components/spotify/test_config_flow.py +++ b/tests/components/spotify/test_config_flow.py @@ -42,7 +42,7 @@ async def test_zeroconf_abort_if_existing_entry(hass): async def test_full_flow( - hass, aiohttp_client, aioclient_mock, current_request_with_host + hass, hass_client_no_auth, aioclient_mock, current_request_with_host ): """Check a full flow.""" assert await setup.async_setup_component( @@ -78,7 +78,7 @@ async def test_full_flow( "user-top-read,user-read-playback-position,user-read-recently-played,user-follow-read" ) - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" @@ -114,7 +114,7 @@ async def test_full_flow( async def test_abort_if_spotify_error( - hass, aiohttp_client, aioclient_mock, current_request_with_host + hass, hass_client_no_auth, aioclient_mock, current_request_with_host ): """Check Spotify errors causes flow to abort.""" await setup.async_setup_component( @@ -138,7 +138,7 @@ async def test_abort_if_spotify_error( "redirect_uri": "https://example.com/auth/external/callback", }, ) - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() await client.get(f"/auth/external/callback?code=abcd&state={state}") aioclient_mock.post( @@ -162,7 +162,7 @@ async def test_abort_if_spotify_error( async def test_reauthentication( - hass, aiohttp_client, aioclient_mock, current_request_with_host + hass, hass_client_no_auth, aioclient_mock, current_request_with_host ): """Test Spotify reauthentication.""" await setup.async_setup_component( @@ -199,7 +199,7 @@ async def test_reauthentication( "redirect_uri": "https://example.com/auth/external/callback", }, ) - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() await client.get(f"/auth/external/callback?code=abcd&state={state}") aioclient_mock.post( @@ -229,7 +229,7 @@ async def test_reauthentication( async def test_reauth_account_mismatch( - hass, aiohttp_client, aioclient_mock, current_request_with_host + hass, hass_client_no_auth, aioclient_mock, current_request_with_host ): """Test Spotify reauthentication with different account.""" await setup.async_setup_component( @@ -264,7 +264,7 @@ async def test_reauth_account_mismatch( "redirect_uri": "https://example.com/auth/external/callback", }, ) - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() await client.get(f"/auth/external/callback?code=abcd&state={state}") aioclient_mock.post( diff --git a/tests/components/toon/test_config_flow.py b/tests/components/toon/test_config_flow.py index f3240991a37..a98db508bb4 100644 --- a/tests/components/toon/test_config_flow.py +++ b/tests/components/toon/test_config_flow.py @@ -41,7 +41,7 @@ async def test_abort_if_no_configuration(hass): async def test_full_flow_implementation( - hass, aiohttp_client, aioclient_mock, current_request_with_host + hass, hass_client_no_auth, aioclient_mock, current_request_with_host ): """Test registering an integration and finishing flow works.""" await setup_component(hass) @@ -75,7 +75,7 @@ async def test_full_flow_implementation( "&tenant_id=eneco&issuer=identity.toon.eu" ) - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" @@ -105,7 +105,7 @@ async def test_full_flow_implementation( async def test_no_agreements( - hass, aiohttp_client, aioclient_mock, current_request_with_host + hass, hass_client_no_auth, aioclient_mock, current_request_with_host ): """Test abort when there are no displays.""" await setup_component(hass) @@ -125,7 +125,7 @@ async def test_no_agreements( result["flow_id"], {"implementation": "eneco"} ) - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() await client.get(f"/auth/external/callback?code=abcd&state={state}") aioclient_mock.post( "https://api.toon.eu/token", @@ -145,7 +145,7 @@ async def test_no_agreements( async def test_multiple_agreements( - hass, aiohttp_client, aioclient_mock, current_request_with_host + hass, hass_client_no_auth, aioclient_mock, current_request_with_host ): """Test abort when there are no displays.""" await setup_component(hass) @@ -165,7 +165,7 @@ async def test_multiple_agreements( result["flow_id"], {"implementation": "eneco"} ) - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() await client.get(f"/auth/external/callback?code=abcd&state={state}") aioclient_mock.post( @@ -195,7 +195,7 @@ async def test_multiple_agreements( async def test_agreement_already_set_up( - hass, aiohttp_client, aioclient_mock, current_request_with_host + hass, hass_client_no_auth, aioclient_mock, current_request_with_host ): """Test showing display form again if display already exists.""" await setup_component(hass) @@ -216,7 +216,7 @@ async def test_agreement_already_set_up( result["flow_id"], {"implementation": "eneco"} ) - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() await client.get(f"/auth/external/callback?code=abcd&state={state}") aioclient_mock.post( "https://api.toon.eu/token", @@ -236,7 +236,7 @@ async def test_agreement_already_set_up( async def test_toon_abort( - hass, aiohttp_client, aioclient_mock, current_request_with_host + hass, hass_client_no_auth, aioclient_mock, current_request_with_host ): """Test we abort on Toon error.""" await setup_component(hass) @@ -255,7 +255,7 @@ async def test_toon_abort( result["flow_id"], {"implementation": "eneco"} ) - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() await client.get(f"/auth/external/callback?code=abcd&state={state}") aioclient_mock.post( "https://api.toon.eu/token", @@ -289,7 +289,7 @@ async def test_import(hass, current_request_with_host): async def test_import_migration( - hass, aiohttp_client, aioclient_mock, current_request_with_host + hass, hass_client_no_auth, aioclient_mock, current_request_with_host ): """Test if importing step with migration works.""" old_entry = MockConfigEntry(domain=DOMAIN, unique_id=123, version=1) @@ -317,7 +317,7 @@ async def test_import_migration( flows[0]["flow_id"], {"implementation": "eneco"} ) - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() await client.get(f"/auth/external/callback?code=abcd&state={state}") aioclient_mock.post( "https://api.toon.eu/token", diff --git a/tests/components/traccar/test_init.py b/tests/components/traccar/test_init.py index 5e995e10e92..2bc46bc94a7 100644 --- a/tests/components/traccar/test_init.py +++ b/tests/components/traccar/test_init.py @@ -28,7 +28,7 @@ def mock_dev_track(mock_device_tracker_conf): @pytest.fixture(name="client") -async def traccar_client(loop, hass, aiohttp_client): +async def traccar_client(loop, hass, hass_client_no_auth): """Mock client for Traccar (unauthenticated).""" assert await async_setup_component(hass, "persistent_notification", {}) @@ -37,7 +37,7 @@ async def traccar_client(loop, hass, aiohttp_client): await hass.async_block_till_done() with patch("homeassistant.components.device_tracker.legacy.update_config"): - return await aiohttp_client(hass.http.app) + return await hass_client_no_auth() @pytest.fixture(autouse=True) diff --git a/tests/components/twilio/test_init.py b/tests/components/twilio/test_init.py index 3529159eae1..8490f7541eb 100644 --- a/tests/components/twilio/test_init.py +++ b/tests/components/twilio/test_init.py @@ -5,7 +5,7 @@ from homeassistant.config import async_process_ha_core_config from homeassistant.core import callback -async def test_config_flow_registers_webhook(hass, aiohttp_client): +async def test_config_flow_registers_webhook(hass, hass_client_no_auth): """Test setting up Twilio and sending webhook.""" await async_process_ha_core_config( hass, @@ -29,7 +29,7 @@ async def test_config_flow_registers_webhook(hass, aiohttp_client): hass.bus.async_listen(twilio.RECEIVED_DATA, handle_event) - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() await client.post(f"/api/webhook/{webhook_id}", data={"hello": "twilio"}) assert len(twilio_events) == 1 diff --git a/tests/components/webhook/test_trigger.py b/tests/components/webhook/test_trigger.py index ae70460de5d..2deac022b1e 100644 --- a/tests/components/webhook/test_trigger.py +++ b/tests/components/webhook/test_trigger.py @@ -17,7 +17,7 @@ async def setup_http(hass): await hass.async_block_till_done() -async def test_webhook_json(hass, aiohttp_client): +async def test_webhook_json(hass, hass_client_no_auth): """Test triggering with a JSON webhook.""" events = [] @@ -46,7 +46,7 @@ async def test_webhook_json(hass, aiohttp_client): ) await hass.async_block_till_done() - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() await client.post("/api/webhook/json_webhook", json={"hello": "world"}) await hass.async_block_till_done() @@ -56,7 +56,7 @@ async def test_webhook_json(hass, aiohttp_client): assert events[0].data["id"] == 0 -async def test_webhook_post(hass, aiohttp_client): +async def test_webhook_post(hass, hass_client_no_auth): """Test triggering with a POST webhook.""" events = [] @@ -82,7 +82,7 @@ async def test_webhook_post(hass, aiohttp_client): ) await hass.async_block_till_done() - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() await client.post("/api/webhook/post_webhook", data={"hello": "world"}) await hass.async_block_till_done() @@ -91,7 +91,7 @@ async def test_webhook_post(hass, aiohttp_client): assert events[0].data["hello"] == "yo world" -async def test_webhook_query(hass, aiohttp_client): +async def test_webhook_query(hass, hass_client_no_auth): """Test triggering with a query POST webhook.""" events = [] @@ -117,7 +117,7 @@ async def test_webhook_query(hass, aiohttp_client): ) await hass.async_block_till_done() - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() await client.post("/api/webhook/query_webhook?hello=world") await hass.async_block_till_done() @@ -126,7 +126,7 @@ async def test_webhook_query(hass, aiohttp_client): assert events[0].data["hello"] == "yo world" -async def test_webhook_reload(hass, aiohttp_client): +async def test_webhook_reload(hass, hass_client_no_auth): """Test reloading a webhook.""" events = [] @@ -152,7 +152,7 @@ async def test_webhook_reload(hass, aiohttp_client): ) await hass.async_block_till_done() - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() await client.post("/api/webhook/post_webhook", data={"hello": "world"}) await hass.async_block_till_done() diff --git a/tests/components/websocket_api/conftest.py b/tests/components/websocket_api/conftest.py index 016fdfebc11..53569c3fa6a 100644 --- a/tests/components/websocket_api/conftest.py +++ b/tests/components/websocket_api/conftest.py @@ -13,12 +13,12 @@ async def websocket_client(hass, hass_ws_client): @pytest.fixture -async def no_auth_websocket_client(hass, aiohttp_client): +async def no_auth_websocket_client(hass, hass_client_no_auth): """Websocket connection that requires authentication.""" assert await async_setup_component(hass, "websocket_api", {}) await hass.async_block_till_done() - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() ws = await client.ws_connect(URL) auth_ok = await ws.receive_json() diff --git a/tests/components/websocket_api/test_auth.py b/tests/components/websocket_api/test_auth.py index c0313794783..a57faf4a895 100644 --- a/tests/components/websocket_api/test_auth.py +++ b/tests/components/websocket_api/test_auth.py @@ -121,14 +121,14 @@ async def test_auth_active_with_token( assert auth_msg["type"] == TYPE_AUTH_OK -async def test_auth_active_user_inactive(hass, aiohttp_client, hass_access_token): +async def test_auth_active_user_inactive(hass, hass_client_no_auth, hass_access_token): """Test authenticating with a token.""" refresh_token = await hass.auth.async_validate_access_token(hass_access_token) refresh_token.user.is_active = False assert await async_setup_component(hass, "websocket_api", {}) await hass.async_block_till_done() - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() async with client.ws_connect(URL) as ws: auth_msg = await ws.receive_json() @@ -140,12 +140,12 @@ async def test_auth_active_user_inactive(hass, aiohttp_client, hass_access_token assert auth_msg["type"] == TYPE_AUTH_INVALID -async def test_auth_active_with_password_not_allow(hass, aiohttp_client): +async def test_auth_active_with_password_not_allow(hass, hass_client_no_auth): """Test authenticating with a token.""" assert await async_setup_component(hass, "websocket_api", {}) await hass.async_block_till_done() - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() async with client.ws_connect(URL) as ws: auth_msg = await ws.receive_json() @@ -157,12 +157,14 @@ async def test_auth_active_with_password_not_allow(hass, aiohttp_client): assert auth_msg["type"] == TYPE_AUTH_INVALID -async def test_auth_legacy_support_with_password(hass, aiohttp_client, legacy_auth): +async def test_auth_legacy_support_with_password( + hass, hass_client_no_auth, legacy_auth +): """Test authenticating with a token.""" assert await async_setup_component(hass, "websocket_api", {}) await hass.async_block_till_done() - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() async with client.ws_connect(URL) as ws: auth_msg = await ws.receive_json() @@ -174,12 +176,12 @@ async def test_auth_legacy_support_with_password(hass, aiohttp_client, legacy_au assert auth_msg["type"] == TYPE_AUTH_INVALID -async def test_auth_with_invalid_token(hass, aiohttp_client): +async def test_auth_with_invalid_token(hass, hass_client_no_auth): """Test authenticating with a token.""" assert await async_setup_component(hass, "websocket_api", {}) await hass.async_block_till_done() - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() async with client.ws_connect(URL) as ws: auth_msg = await ws.receive_json() diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index bb74bbe8ca8..7c43d34d9a6 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -459,12 +459,14 @@ async def test_ping(websocket_client): assert msg["type"] == "pong" -async def test_call_service_context_with_user(hass, aiohttp_client, hass_access_token): +async def test_call_service_context_with_user( + hass, hass_client_no_auth, hass_access_token +): """Test that the user is set in the service call context.""" assert await async_setup_component(hass, "websocket_api", {}) calls = async_mock_service(hass, "domain_test", "test_service") - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() async with client.ws_connect(URL) as ws: auth_msg = await ws.receive_json() diff --git a/tests/components/websocket_api/test_sensor.py b/tests/components/websocket_api/test_sensor.py index 429876cd365..b7565de650b 100644 --- a/tests/components/websocket_api/test_sensor.py +++ b/tests/components/websocket_api/test_sensor.py @@ -7,14 +7,14 @@ from homeassistant.components.websocket_api.http import URL from .test_auth import test_auth_active_with_token -async def test_websocket_api(hass, aiohttp_client, hass_access_token, legacy_auth): +async def test_websocket_api(hass, hass_client_no_auth, hass_access_token, legacy_auth): """Test API streams.""" await async_setup_component( hass, "sensor", {"sensor": {"platform": "websocket_api"}} ) await hass.async_block_till_done() - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() ws = await client.ws_connect(URL) auth_ok = await ws.receive_json() diff --git a/tests/components/xbox/test_config_flow.py b/tests/components/xbox/test_config_flow.py index 7e2863a5861..794814c284f 100644 --- a/tests/components/xbox/test_config_flow.py +++ b/tests/components/xbox/test_config_flow.py @@ -23,7 +23,7 @@ async def test_abort_if_existing_entry(hass): async def test_full_flow( - hass, aiohttp_client, aioclient_mock, current_request_with_host + hass, hass_client_no_auth, aioclient_mock, current_request_with_host ): """Check full flow.""" assert await setup.async_setup_component( @@ -54,7 +54,7 @@ async def test_full_flow( f"&state={state}&scope={scope}" ) - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" diff --git a/tests/helpers/test_config_entry_oauth2_flow.py b/tests/helpers/test_config_entry_oauth2_flow.py index b5257e635af..52dda703f1e 100644 --- a/tests/helpers/test_config_entry_oauth2_flow.py +++ b/tests/helpers/test_config_entry_oauth2_flow.py @@ -154,7 +154,7 @@ async def test_abort_if_oauth_error( hass, flow_handler, local_impl, - aiohttp_client, + hass_client_no_auth, aioclient_mock, current_request_with_host, ): @@ -191,7 +191,7 @@ async def test_abort_if_oauth_error( f"&state={state}&scope=read+write" ) - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" @@ -274,7 +274,7 @@ async def test_full_flow( hass, flow_handler, local_impl, - aiohttp_client, + hass_client_no_auth, aioclient_mock, current_request_with_host, ): @@ -311,7 +311,7 @@ async def test_full_flow( f"&state={state}&scope=read+write" ) - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" From d4a2b366386fff557a059eefc2bb272979124edc Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Thu, 2 Sep 2021 18:09:30 +0200 Subject: [PATCH 162/843] Downgrade sqlite-libs on docker image (#55591) --- Dockerfile | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/Dockerfile b/Dockerfile index 6bcb080a06e..c802ba9b273 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,21 @@ RUN \ -e ./homeassistant \ && python3 -m compileall homeassistant/homeassistant +# Fix Bug with Alpine 3.14 and sqlite 3.35 +# https://gitlab.alpinelinux.org/alpine/aports/-/issues/12524 +ARG BUILD_ARCH +RUN \ + if [ "${BUILD_ARCH}" = "amd64" ]; then \ + export APK_ARCH=x86_64; \ + elif [ "${BUILD_ARCH}" = "i386" ]; then \ + export APK_ARCH=x86; \ + else \ + export APK_ARCH=${BUILD_ARCH}; \ + fi \ + && curl -O http://dl-cdn.alpinelinux.org/alpine/v3.13/main/${APK_ARCH}/sqlite-libs-3.34.1-r0.apk \ + && apk add --no-cache sqlite-libs-3.34.1-r0.apk \ + && rm -f sqlite-libs-3.34.1-r0.apk + # Home Assistant S6-Overlay COPY rootfs / From cabb9c0ea4c5bcde7b4013dfe4c97cd4ea88f4be Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 2 Sep 2021 19:03:24 +0200 Subject: [PATCH 163/843] Prevent 3rd party lib from opening sockets in broadlink tests (#55593) --- tests/components/broadlink/test_device.py | 8 ++++++++ tests/components/broadlink/test_heartbeat.py | 9 +++++++++ 2 files changed, 17 insertions(+) diff --git a/tests/components/broadlink/test_device.py b/tests/components/broadlink/test_device.py index 5430af9e311..4ebfead007b 100644 --- a/tests/components/broadlink/test_device.py +++ b/tests/components/broadlink/test_device.py @@ -2,6 +2,7 @@ from unittest.mock import patch import broadlink.exceptions as blke +import pytest from homeassistant.components.broadlink.const import DOMAIN from homeassistant.components.broadlink.device import get_domains @@ -15,6 +16,13 @@ from tests.common import mock_device_registry, mock_registry DEVICE_FACTORY = "homeassistant.components.broadlink.device.blk.gendevice" +@pytest.fixture(autouse=True) +def mock_heartbeat(): + """Mock broadlink heartbeat.""" + with patch("homeassistant.components.broadlink.heartbeat.blk.ping"): + yield + + async def test_device_setup(hass): """Test a successful setup.""" device = get_device("Office") diff --git a/tests/components/broadlink/test_heartbeat.py b/tests/components/broadlink/test_heartbeat.py index de47a16c0b9..5065bded881 100644 --- a/tests/components/broadlink/test_heartbeat.py +++ b/tests/components/broadlink/test_heartbeat.py @@ -1,6 +1,8 @@ """Tests for Broadlink heartbeats.""" from unittest.mock import call, patch +import pytest + from homeassistant.components.broadlink.heartbeat import BroadlinkHeartbeat from homeassistant.util import dt @@ -11,6 +13,13 @@ from tests.common import async_fire_time_changed DEVICE_PING = "homeassistant.components.broadlink.heartbeat.blk.ping" +@pytest.fixture(autouse=True) +def mock_heartbeat(): + """Mock broadlink heartbeat.""" + with patch("homeassistant.components.broadlink.heartbeat.blk.ping"): + yield + + async def test_heartbeat_trigger_startup(hass): """Test that the heartbeat is initialized with the first config entry.""" device = get_device("Office") From 4f336792558adc1faf81693b434e32b38c7ff16a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Thu, 2 Sep 2021 19:17:33 +0200 Subject: [PATCH 164/843] Fix url lookup in telegram_bot webhook (#55587) --- homeassistant/components/telegram_bot/webhooks.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/telegram_bot/webhooks.py b/homeassistant/components/telegram_bot/webhooks.py index 7fd6cb24efd..bd0dde7c02c 100644 --- a/homeassistant/components/telegram_bot/webhooks.py +++ b/homeassistant/components/telegram_bot/webhooks.py @@ -33,9 +33,8 @@ async def async_setup_platform(hass, config): bot = initialize_bot(config) current_status = await hass.async_add_executor_job(bot.getWebhookInfo) - base_url = config.get( - CONF_URL, get_url(hass, require_ssl=True, allow_internal=False) - ) + if not (base_url := config.get(CONF_URL)): + base_url = get_url(hass, require_ssl=True, allow_internal=False) # Some logging of Bot current status: last_error_date = getattr(current_status, "last_error_date", None) From 348bdca6470cede09f8932ccea9bb28db02c07fa Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 2 Sep 2021 19:30:53 +0200 Subject: [PATCH 165/843] Prevent 3rd party lib from opening sockets in epson tests (#55595) --- tests/components/epson/test_config_flow.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/components/epson/test_config_flow.py b/tests/components/epson/test_config_flow.py index 9c02feadc1a..088b1a5435c 100644 --- a/tests/components/epson/test_config_flow.py +++ b/tests/components/epson/test_config_flow.py @@ -21,6 +21,9 @@ async def test_form(hass): with patch( "homeassistant.components.epson.Projector.get_power", return_value="01", + ), patch( + "homeassistant.components.epson.Projector.get_serial_number", + return_value="12345", ), patch( "homeassistant.components.epson.async_setup_entry", return_value=True, From 2e5c1236f99dd4b9061cbc72e4023c5246a14535 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 2 Sep 2021 19:32:19 +0200 Subject: [PATCH 166/843] Prevent 3rd party lib from opening sockets in freedompro tests (#55596) --- tests/components/freedompro/conftest.py | 32 +++++++++++-------- .../components/freedompro/test_config_flow.py | 2 +- tests/components/freedompro/test_light.py | 11 +++++++ 3 files changed, 31 insertions(+), 14 deletions(-) diff --git a/tests/components/freedompro/conftest.py b/tests/components/freedompro/conftest.py index c43887fa487..36070c1a0d5 100644 --- a/tests/components/freedompro/conftest.py +++ b/tests/components/freedompro/conftest.py @@ -9,6 +9,22 @@ from tests.common import MockConfigEntry from tests.components.freedompro.const import DEVICES, DEVICES_STATE +@pytest.fixture(autouse=True) +def mock_freedompro(): + """Mock freedompro get_list and get_states.""" + with patch( + "homeassistant.components.freedompro.get_list", + return_value={ + "state": True, + "devices": DEVICES, + }, + ), patch( + "homeassistant.components.freedompro.get_states", + return_value=DEVICES_STATE, + ): + yield + + @pytest.fixture async def init_integration(hass) -> MockConfigEntry: """Set up the Freedompro integration in Home Assistant.""" @@ -21,19 +37,9 @@ async def init_integration(hass) -> MockConfigEntry: }, ) - with patch( - "homeassistant.components.freedompro.get_list", - return_value={ - "state": True, - "devices": DEVICES, - }, - ), patch( - "homeassistant.components.freedompro.get_states", - return_value=DEVICES_STATE, - ): - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() return entry diff --git a/tests/components/freedompro/test_config_flow.py b/tests/components/freedompro/test_config_flow.py index f44cbd232ad..42dc0674d07 100644 --- a/tests/components/freedompro/test_config_flow.py +++ b/tests/components/freedompro/test_config_flow.py @@ -26,7 +26,7 @@ async def test_show_form(hass): async def test_invalid_auth(hass): """Test that errors are shown when API key is invalid.""" with patch( - "homeassistant.components.freedompro.config_flow.list", + "homeassistant.components.freedompro.config_flow.get_list", return_value={ "state": False, "code": -201, diff --git a/tests/components/freedompro/test_light.py b/tests/components/freedompro/test_light.py index 09a945ada03..b23ebf85676 100644 --- a/tests/components/freedompro/test_light.py +++ b/tests/components/freedompro/test_light.py @@ -1,4 +1,8 @@ """Tests for the Freedompro light.""" +from unittest.mock import patch + +import pytest + from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_HS_COLOR, @@ -9,6 +13,13 @@ from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, STATE_OFF, STA from homeassistant.helpers import entity_registry as er +@pytest.fixture(autouse=True) +def mock_freedompro_put_state(): + """Mock freedompro put_state.""" + with patch("homeassistant.components.freedompro.light.put_state"): + yield + + async def test_light_get_state(hass, init_integration): """Test states of the light.""" init_integration From 363320eedbed1ccceff6a7cc5d12743ba59d59f7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 2 Sep 2021 13:44:42 -0500 Subject: [PATCH 167/843] Mock sockets in the network integration tests (#55594) --- tests/components/network/test_init.py | 50 +++++++++++++++++---------- 1 file changed, 31 insertions(+), 19 deletions(-) diff --git a/tests/components/network/test_init.py b/tests/components/network/test_init.py index 6a85f5ea9e8..70cee5f847c 100644 --- a/tests/components/network/test_init.py +++ b/tests/components/network/test_init.py @@ -1,5 +1,5 @@ """Test the Network Configuration.""" -from unittest.mock import Mock, patch +from unittest.mock import MagicMock, Mock, patch import ifaddr @@ -17,6 +17,18 @@ _NO_LOOPBACK_IPADDR = "192.168.1.5" _LOOPBACK_IPADDR = "127.0.0.1" +def _mock_socket(sockname): + mock_socket = MagicMock() + mock_socket.getsockname = Mock(return_value=sockname) + return mock_socket + + +def _mock_socket_exception(exc): + mock_socket = MagicMock() + mock_socket.getsockname = Mock(side_effect=exc) + return mock_socket + + def _generate_mock_adapters(): mock_lo0 = Mock(spec=ifaddr.Adapter) mock_lo0.nice_name = "lo0" @@ -40,8 +52,8 @@ def _generate_mock_adapters(): async def test_async_detect_interfaces_setting_non_loopback_route(hass, hass_storage): """Test without default interface config and the route returns a non-loopback address.""" with patch( - "homeassistant.components.network.util.socket.socket.getsockname", - return_value=[_NO_LOOPBACK_IPADDR], + "homeassistant.components.network.util.socket.socket", + return_value=_mock_socket([_NO_LOOPBACK_IPADDR]), ), patch( "homeassistant.components.network.util.ifaddr.get_adapters", return_value=_generate_mock_adapters(), @@ -102,8 +114,8 @@ async def test_async_detect_interfaces_setting_non_loopback_route(hass, hass_sto async def test_async_detect_interfaces_setting_loopback_route(hass, hass_storage): """Test without default interface config and the route returns a loopback address.""" with patch( - "homeassistant.components.network.util.socket.socket.getsockname", - return_value=[_LOOPBACK_IPADDR], + "homeassistant.components.network.util.socket.socket", + return_value=_mock_socket([_LOOPBACK_IPADDR]), ), patch( "homeassistant.components.network.util.ifaddr.get_adapters", return_value=_generate_mock_adapters(), @@ -163,8 +175,8 @@ async def test_async_detect_interfaces_setting_loopback_route(hass, hass_storage async def test_async_detect_interfaces_setting_empty_route(hass, hass_storage): """Test without default interface config and the route returns nothing.""" with patch( - "homeassistant.components.network.util.socket.socket.getsockname", - return_value=[], + "homeassistant.components.network.util.socket.socket", + return_value=_mock_socket([]), ), patch( "homeassistant.components.network.util.ifaddr.get_adapters", return_value=_generate_mock_adapters(), @@ -224,8 +236,8 @@ async def test_async_detect_interfaces_setting_empty_route(hass, hass_storage): async def test_async_detect_interfaces_setting_exception(hass, hass_storage): """Test without default interface config and the route throws an exception.""" with patch( - "homeassistant.components.network.util.socket.socket.getsockname", - side_effect=AttributeError, + "homeassistant.components.network.util.socket.socket", + return_value=_mock_socket_exception(AttributeError), ), patch( "homeassistant.components.network.util.ifaddr.get_adapters", return_value=_generate_mock_adapters(), @@ -290,8 +302,8 @@ async def test_interfaces_configured_from_storage(hass, hass_storage): "data": {ATTR_CONFIGURED_ADAPTERS: ["eth0", "eth1", "vtun0"]}, } with patch( - "homeassistant.components.network.util.socket.socket.getsockname", - return_value=[_NO_LOOPBACK_IPADDR], + "homeassistant.components.network.util.socket.socket", + return_value=_mock_socket([_NO_LOOPBACK_IPADDR]), ), patch( "homeassistant.components.network.util.ifaddr.get_adapters", return_value=_generate_mock_adapters(), @@ -359,8 +371,8 @@ async def test_interfaces_configured_from_storage_websocket_update( "data": {ATTR_CONFIGURED_ADAPTERS: ["eth0", "eth1", "vtun0"]}, } with patch( - "homeassistant.components.network.util.socket.socket.getsockname", - return_value=[_NO_LOOPBACK_IPADDR], + "homeassistant.components.network.util.socket.socket", + return_value=_mock_socket([_NO_LOOPBACK_IPADDR]), ), patch( "homeassistant.components.network.util.ifaddr.get_adapters", return_value=_generate_mock_adapters(), @@ -491,8 +503,8 @@ async def test_async_get_source_ip_matching_interface(hass, hass_storage): "homeassistant.components.network.util.ifaddr.get_adapters", return_value=_generate_mock_adapters(), ), patch( - "homeassistant.components.network.util.socket.socket.getsockname", - return_value=["192.168.1.5"], + "homeassistant.components.network.util.socket.socket", + return_value=_mock_socket(["192.168.1.5"]), ): assert await async_setup_component(hass, network.DOMAIN, {network.DOMAIN: {}}) await hass.async_block_till_done() @@ -512,8 +524,8 @@ async def test_async_get_source_ip_interface_not_match(hass, hass_storage): "homeassistant.components.network.util.ifaddr.get_adapters", return_value=_generate_mock_adapters(), ), patch( - "homeassistant.components.network.util.socket.socket.getsockname", - return_value=["192.168.1.5"], + "homeassistant.components.network.util.socket.socket", + return_value=_mock_socket(["192.168.1.5"]), ): assert await async_setup_component(hass, network.DOMAIN, {network.DOMAIN: {}}) await hass.async_block_till_done() @@ -533,8 +545,8 @@ async def test_async_get_source_ip_cannot_determine_target(hass, hass_storage): "homeassistant.components.network.util.ifaddr.get_adapters", return_value=_generate_mock_adapters(), ), patch( - "homeassistant.components.network.util.socket.socket.getsockname", - return_value=[None], + "homeassistant.components.network.util.socket.socket", + return_value=_mock_socket([None]), ): assert await async_setup_component(hass, network.DOMAIN, {network.DOMAIN: {}}) await hass.async_block_till_done() From 7dbe8070f7823c9fa65827b4b9cfb376eaaf609c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 2 Sep 2021 20:44:50 +0200 Subject: [PATCH 168/843] Mock out network.util.async_get_source_ip in tests (#55592) --- .../emulated_roku/test_config_flow.py | 4 +- tests/components/emulated_roku/test_init.py | 4 +- tests/components/fritz/test_config_flow.py | 44 +++++++++++------ tests/components/homekit/test_config_flow.py | 32 +++++++------ tests/components/homekit/test_homekit.py | 9 +++- .../nmap_tracker/test_config_flow.py | 18 +++---- tests/components/ssdp/test_init.py | 46 +++++++++++------- tests/components/upnp/test_config_flow.py | 16 +++---- tests/components/upnp/test_init.py | 2 +- .../yamaha_musiccast/test_config_flow.py | 47 ++++++++++++++----- tests/components/yeelight/conftest.py | 7 +++ tests/conftest.py | 10 ++++ 12 files changed, 159 insertions(+), 80 deletions(-) diff --git a/tests/components/emulated_roku/test_config_flow.py b/tests/components/emulated_roku/test_config_flow.py index 23c807cbfa3..3d1438dafb9 100644 --- a/tests/components/emulated_roku/test_config_flow.py +++ b/tests/components/emulated_roku/test_config_flow.py @@ -5,7 +5,7 @@ from homeassistant.components.emulated_roku import config_flow from tests.common import MockConfigEntry -async def test_flow_works(hass): +async def test_flow_works(hass, mock_get_source_ip): """Test that config flow works.""" result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, @@ -18,7 +18,7 @@ async def test_flow_works(hass): assert result["data"] == {"name": "Emulated Roku Test", "listen_port": 8060} -async def test_flow_already_registered_entry(hass): +async def test_flow_already_registered_entry(hass, mock_get_source_ip): """Test that config flow doesn't allow existing names.""" MockConfigEntry( domain="emulated_roku", data={"name": "Emulated Roku Test", "listen_port": 8062} diff --git a/tests/components/emulated_roku/test_init.py b/tests/components/emulated_roku/test_init.py index d69df5a1fbe..93db9124414 100644 --- a/tests/components/emulated_roku/test_init.py +++ b/tests/components/emulated_roku/test_init.py @@ -5,7 +5,7 @@ from homeassistant.components import emulated_roku from homeassistant.setup import async_setup_component -async def test_config_required_fields(hass): +async def test_config_required_fields(hass, mock_get_source_ip): """Test that configuration is successful with required fields.""" with patch.object(emulated_roku, "configured_servers", return_value=[]), patch( "homeassistant.components.emulated_roku.binding.EmulatedRokuServer", @@ -30,7 +30,7 @@ async def test_config_required_fields(hass): ) -async def test_config_already_registered_not_configured(hass): +async def test_config_already_registered_not_configured(hass, mock_get_source_ip): """Test that an already registered name causes the entry to be ignored.""" with patch( "homeassistant.components.emulated_roku.binding.EmulatedRokuServer", diff --git a/tests/components/fritz/test_config_flow.py b/tests/components/fritz/test_config_flow.py index 1551a508277..1b2a89f0450 100644 --- a/tests/components/fritz/test_config_flow.py +++ b/tests/components/fritz/test_config_flow.py @@ -67,7 +67,7 @@ def fc_class_mock(): yield result -async def test_user(hass: HomeAssistant, fc_class_mock): +async def test_user(hass: HomeAssistant, fc_class_mock, mock_get_source_ip): """Test starting a flow by user.""" with patch( "homeassistant.components.fritz.common.FritzConnection", @@ -108,7 +108,9 @@ async def test_user(hass: HomeAssistant, fc_class_mock): assert mock_setup_entry.called -async def test_user_already_configured(hass: HomeAssistant, fc_class_mock): +async def test_user_already_configured( + hass: HomeAssistant, fc_class_mock, mock_get_source_ip +): """Test starting a flow by user with an already configured device.""" mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) @@ -142,7 +144,7 @@ async def test_user_already_configured(hass: HomeAssistant, fc_class_mock): assert result["errors"]["base"] == "already_configured" -async def test_exception_security(hass: HomeAssistant): +async def test_exception_security(hass: HomeAssistant, mock_get_source_ip): """Test starting a flow by user with invalid credentials.""" result = await hass.config_entries.flow.async_init( @@ -165,7 +167,7 @@ async def test_exception_security(hass: HomeAssistant): assert result["errors"]["base"] == ERROR_AUTH_INVALID -async def test_exception_connection(hass: HomeAssistant): +async def test_exception_connection(hass: HomeAssistant, mock_get_source_ip): """Test starting a flow by user with a connection error.""" result = await hass.config_entries.flow.async_init( @@ -188,7 +190,7 @@ async def test_exception_connection(hass: HomeAssistant): assert result["errors"]["base"] == ERROR_CANNOT_CONNECT -async def test_exception_unknown(hass: HomeAssistant): +async def test_exception_unknown(hass: HomeAssistant, mock_get_source_ip): """Test starting a flow by user with an unknown exception.""" result = await hass.config_entries.flow.async_init( @@ -211,7 +213,9 @@ async def test_exception_unknown(hass: HomeAssistant): assert result["errors"]["base"] == ERROR_UNKNOWN -async def test_reauth_successful(hass: HomeAssistant, fc_class_mock): +async def test_reauth_successful( + hass: HomeAssistant, fc_class_mock, mock_get_source_ip +): """Test starting a reauthentication flow.""" mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) @@ -256,7 +260,9 @@ async def test_reauth_successful(hass: HomeAssistant, fc_class_mock): assert mock_setup_entry.called -async def test_reauth_not_successful(hass: HomeAssistant, fc_class_mock): +async def test_reauth_not_successful( + hass: HomeAssistant, fc_class_mock, mock_get_source_ip +): """Test starting a reauthentication flow but no connection found.""" mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) @@ -289,7 +295,9 @@ async def test_reauth_not_successful(hass: HomeAssistant, fc_class_mock): assert result["errors"]["base"] == "cannot_connect" -async def test_ssdp_already_configured(hass: HomeAssistant, fc_class_mock): +async def test_ssdp_already_configured( + hass: HomeAssistant, fc_class_mock, mock_get_source_ip +): """Test starting a flow from discovery with an already configured device.""" mock_config = MockConfigEntry( @@ -311,7 +319,9 @@ async def test_ssdp_already_configured(hass: HomeAssistant, fc_class_mock): assert result["reason"] == "already_configured" -async def test_ssdp_already_configured_host(hass: HomeAssistant, fc_class_mock): +async def test_ssdp_already_configured_host( + hass: HomeAssistant, fc_class_mock, mock_get_source_ip +): """Test starting a flow from discovery with an already configured host.""" mock_config = MockConfigEntry( @@ -333,7 +343,9 @@ async def test_ssdp_already_configured_host(hass: HomeAssistant, fc_class_mock): assert result["reason"] == "already_configured" -async def test_ssdp_already_configured_host_uuid(hass: HomeAssistant, fc_class_mock): +async def test_ssdp_already_configured_host_uuid( + hass: HomeAssistant, fc_class_mock, mock_get_source_ip +): """Test starting a flow from discovery with an already configured uuid.""" mock_config = MockConfigEntry( @@ -355,7 +367,9 @@ async def test_ssdp_already_configured_host_uuid(hass: HomeAssistant, fc_class_m assert result["reason"] == "already_configured" -async def test_ssdp_already_in_progress_host(hass: HomeAssistant, fc_class_mock): +async def test_ssdp_already_in_progress_host( + hass: HomeAssistant, fc_class_mock, mock_get_source_ip +): """Test starting a flow from discovery twice.""" with patch( "homeassistant.components.fritz.common.FritzConnection", @@ -377,7 +391,7 @@ async def test_ssdp_already_in_progress_host(hass: HomeAssistant, fc_class_mock) assert result["reason"] == "already_in_progress" -async def test_ssdp(hass: HomeAssistant, fc_class_mock): +async def test_ssdp(hass: HomeAssistant, fc_class_mock, mock_get_source_ip): """Test starting a flow from discovery.""" with patch( "homeassistant.components.fritz.common.FritzConnection", @@ -417,7 +431,7 @@ async def test_ssdp(hass: HomeAssistant, fc_class_mock): assert mock_setup_entry.called -async def test_ssdp_exception(hass: HomeAssistant): +async def test_ssdp_exception(hass: HomeAssistant, mock_get_source_ip): """Test starting a flow from discovery but no device found.""" with patch( "homeassistant.components.fritz.common.FritzConnection", @@ -442,7 +456,7 @@ async def test_ssdp_exception(hass: HomeAssistant): assert result["step_id"] == "confirm" -async def test_import(hass: HomeAssistant, fc_class_mock): +async def test_import(hass: HomeAssistant, fc_class_mock, mock_get_source_ip): """Test importing.""" with patch( "homeassistant.components.fritz.common.FritzConnection", @@ -473,7 +487,7 @@ async def test_import(hass: HomeAssistant, fc_class_mock): assert mock_setup_entry.called -async def test_options_flow(hass: HomeAssistant, fc_class_mock): +async def test_options_flow(hass: HomeAssistant, fc_class_mock, mock_get_source_ip): """Test options flow.""" mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) diff --git a/tests/components/homekit/test_config_flow.py b/tests/components/homekit/test_config_flow.py index af803d50cf4..b2e9af3816a 100644 --- a/tests/components/homekit/test_config_flow.py +++ b/tests/components/homekit/test_config_flow.py @@ -33,7 +33,7 @@ def _mock_config_entry_with_options_populated(): ) -async def test_setup_in_bridge_mode(hass): +async def test_setup_in_bridge_mode(hass, mock_get_source_ip): """Test we can setup a new instance in bridge mode.""" await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( @@ -83,7 +83,7 @@ async def test_setup_in_bridge_mode(hass): assert len(mock_setup_entry.mock_calls) == 1 -async def test_setup_in_bridge_mode_name_taken(hass): +async def test_setup_in_bridge_mode_name_taken(hass, mock_get_source_ip): """Test we can setup a new instance in bridge mode when the name is taken.""" await setup.async_setup_component(hass, "persistent_notification", {}) @@ -141,7 +141,9 @@ async def test_setup_in_bridge_mode_name_taken(hass): assert len(mock_setup_entry.mock_calls) == 2 -async def test_setup_creates_entries_for_accessory_mode_devices(hass): +async def test_setup_creates_entries_for_accessory_mode_devices( + hass, mock_get_source_ip +): """Test we can setup a new instance and we create entries for accessory mode devices.""" hass.states.async_set("camera.one", "on") hass.states.async_set("camera.existing", "on") @@ -231,7 +233,7 @@ async def test_setup_creates_entries_for_accessory_mode_devices(hass): assert len(mock_setup_entry.mock_calls) == 7 -async def test_import(hass): +async def test_import(hass, mock_get_source_ip): """Test we can import instance.""" await setup.async_setup_component(hass, "persistent_notification", {}) @@ -275,7 +277,7 @@ async def test_import(hass): @pytest.mark.parametrize("auto_start", [True, False]) -async def test_options_flow_exclude_mode_advanced(auto_start, hass): +async def test_options_flow_exclude_mode_advanced(auto_start, hass, mock_get_source_ip): """Test config flow options in exclude mode with advanced options.""" config_entry = _mock_config_entry_with_options_populated() @@ -326,7 +328,7 @@ async def test_options_flow_exclude_mode_advanced(auto_start, hass): } -async def test_options_flow_exclude_mode_basic(hass): +async def test_options_flow_exclude_mode_basic(hass, mock_get_source_ip): """Test config flow options in exclude mode.""" config_entry = _mock_config_entry_with_options_populated() @@ -368,7 +370,7 @@ async def test_options_flow_exclude_mode_basic(hass): async def test_options_flow_devices( - mock_hap, hass, demo_cleanup, device_reg, entity_reg + mock_hap, hass, demo_cleanup, device_reg, entity_reg, mock_get_source_ip ): """Test devices can be bridged.""" config_entry = _mock_config_entry_with_options_populated() @@ -431,7 +433,9 @@ async def test_options_flow_devices( } -async def test_options_flow_devices_preserved_when_advanced_off(mock_hap, hass): +async def test_options_flow_devices_preserved_when_advanced_off( + mock_hap, hass, mock_get_source_ip +): """Test devices are preserved if they were added in advanced mode but it was turned off.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -499,7 +503,7 @@ async def test_options_flow_devices_preserved_when_advanced_off(mock_hap, hass): } -async def test_options_flow_include_mode_basic(hass): +async def test_options_flow_include_mode_basic(hass, mock_get_source_ip): """Test config flow options in include mode.""" config_entry = _mock_config_entry_with_options_populated() @@ -542,7 +546,7 @@ async def test_options_flow_include_mode_basic(hass): } -async def test_options_flow_exclude_mode_with_cameras(hass): +async def test_options_flow_exclude_mode_with_cameras(hass, mock_get_source_ip): """Test config flow options in exclude mode with cameras.""" config_entry = _mock_config_entry_with_options_populated() @@ -645,7 +649,7 @@ async def test_options_flow_exclude_mode_with_cameras(hass): } -async def test_options_flow_include_mode_with_cameras(hass): +async def test_options_flow_include_mode_with_cameras(hass, mock_get_source_ip): """Test config flow options in include mode with cameras.""" config_entry = _mock_config_entry_with_options_populated() @@ -772,7 +776,7 @@ async def test_options_flow_include_mode_with_cameras(hass): } -async def test_options_flow_blocked_when_from_yaml(hass): +async def test_options_flow_blocked_when_from_yaml(hass, mock_get_source_ip): """Test config flow options.""" config_entry = MockConfigEntry( @@ -812,7 +816,7 @@ async def test_options_flow_blocked_when_from_yaml(hass): assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY -async def test_options_flow_include_mode_basic_accessory(hass): +async def test_options_flow_include_mode_basic_accessory(hass, mock_get_source_ip): """Test config flow options in include mode with a single accessory.""" config_entry = _mock_config_entry_with_options_populated() @@ -867,7 +871,7 @@ async def test_options_flow_include_mode_basic_accessory(hass): } -async def test_converting_bridge_to_accessory_mode(hass, hk_driver): +async def test_converting_bridge_to_accessory_mode(hass, hk_driver, mock_get_source_ip): """Test we can convert a bridge to accessory mode.""" await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index 4976985fa15..b1ea2ab2a1d 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -102,6 +102,13 @@ def always_patch_driver(hk_driver): """Load the hk_driver fixture.""" +@pytest.fixture(autouse=True) +def patch_source_ip(mock_get_source_ip): + """Patch homeassistant and pyhap functions for getting local address.""" + with patch("pyhap.util.get_local_address", return_value="10.10.10.10"): + yield + + def _mock_homekit(hass, entry, homekit_mode, entity_filter=None, devices=None): return HomeKit( hass=hass, @@ -1301,7 +1308,7 @@ async def test_homekit_uses_system_zeroconf(hass, hk_driver, mock_zeroconf): with patch("pyhap.accessory_driver.AccessoryDriver.async_start"), patch( f"{PATH_HOMEKIT}.HomeKit.async_stop" - ): + ), patch(f"{PATH_HOMEKIT}.port_is_available"): entry.add_to_hass(hass) assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/nmap_tracker/test_config_flow.py b/tests/components/nmap_tracker/test_config_flow.py index 74997df5a4f..0c6e8f8f7e8 100644 --- a/tests/components/nmap_tracker/test_config_flow.py +++ b/tests/components/nmap_tracker/test_config_flow.py @@ -23,7 +23,7 @@ from tests.common import MockConfigEntry @pytest.mark.parametrize( "hosts", ["1.1.1.1", "192.168.1.0/24", "192.168.1.0/24,192.168.2.0/24"] ) -async def test_form(hass: HomeAssistant, hosts: str) -> None: +async def test_form(hass: HomeAssistant, hosts: str, mock_get_source_ip) -> None: """Test we get the form.""" await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( @@ -62,7 +62,7 @@ async def test_form(hass: HomeAssistant, hosts: str) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_range(hass: HomeAssistant) -> None: +async def test_form_range(hass: HomeAssistant, mock_get_source_ip) -> None: """Test we get the form and can take an ip range.""" await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( @@ -98,7 +98,7 @@ async def test_form_range(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_invalid_hosts(hass: HomeAssistant) -> None: +async def test_form_invalid_hosts(hass: HomeAssistant, mock_get_source_ip) -> None: """Test invalid hosts passed in.""" await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( @@ -122,7 +122,7 @@ async def test_form_invalid_hosts(hass: HomeAssistant) -> None: assert result2["errors"] == {CONF_HOSTS: "invalid_hosts"} -async def test_form_already_configured(hass: HomeAssistant) -> None: +async def test_form_already_configured(hass: HomeAssistant, mock_get_source_ip) -> None: """Test duplicate host list.""" await setup.async_setup_component(hass, "persistent_notification", {}) config_entry = MockConfigEntry( @@ -157,7 +157,7 @@ async def test_form_already_configured(hass: HomeAssistant) -> None: assert result2["reason"] == "already_configured" -async def test_form_invalid_excludes(hass: HomeAssistant) -> None: +async def test_form_invalid_excludes(hass: HomeAssistant, mock_get_source_ip) -> None: """Test invalid excludes passed in.""" await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( @@ -181,7 +181,7 @@ async def test_form_invalid_excludes(hass: HomeAssistant) -> None: assert result2["errors"] == {CONF_EXCLUDE: "invalid_hosts"} -async def test_options_flow(hass: HomeAssistant) -> None: +async def test_options_flow(hass: HomeAssistant, mock_get_source_ip) -> None: """Test we can edit options.""" config_entry = MockConfigEntry( @@ -243,7 +243,7 @@ async def test_options_flow(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_import(hass: HomeAssistant) -> None: +async def test_import(hass: HomeAssistant, mock_get_source_ip) -> None: """Test we can import from yaml.""" await setup.async_setup_component(hass, "persistent_notification", {}) with patch( @@ -278,7 +278,9 @@ async def test_import(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_import_aborts_if_matching(hass: HomeAssistant) -> None: +async def test_import_aborts_if_matching( + hass: HomeAssistant, mock_get_source_ip +) -> None: """Test we can import from yaml.""" config_entry = MockConfigEntry( domain=DOMAIN, diff --git a/tests/components/ssdp/test_init.py b/tests/components/ssdp/test_init.py index 43b7fd98cd0..c50d71ed4c7 100644 --- a/tests/components/ssdp/test_init.py +++ b/tests/components/ssdp/test_init.py @@ -64,7 +64,7 @@ async def _async_run_mocked_scan(hass, mock_ssdp_response, mock_get_ssdp): return mock_init -async def test_scan_match_st(hass, caplog): +async def test_scan_match_st(hass, caplog, mock_get_source_ip): """Test matching based on ST.""" mock_ssdp_response = { "st": "mock-st", @@ -91,7 +91,7 @@ async def test_scan_match_st(hass, caplog): assert "Failed to fetch ssdp data" not in caplog.text -async def test_partial_response(hass, caplog): +async def test_partial_response(hass, caplog, mock_get_source_ip): """Test location and st missing.""" mock_ssdp_response = { "usn": "mock-usn", @@ -107,7 +107,9 @@ async def test_partial_response(hass, caplog): @pytest.mark.parametrize( "key", (ssdp.ATTR_UPNP_MANUFACTURER, ssdp.ATTR_UPNP_DEVICE_TYPE) ) -async def test_scan_match_upnp_devicedesc(hass, aioclient_mock, key): +async def test_scan_match_upnp_devicedesc( + hass, aioclient_mock, key, mock_get_source_ip +): """Test matching based on UPnP device description data.""" aioclient_mock.get( "http://1.1.1.1", @@ -134,7 +136,7 @@ async def test_scan_match_upnp_devicedesc(hass, aioclient_mock, key): } -async def test_scan_not_all_present(hass, aioclient_mock): +async def test_scan_not_all_present(hass, aioclient_mock, mock_get_source_ip): """Test match fails if some specified attributes are not present.""" aioclient_mock.get( "http://1.1.1.1", @@ -163,7 +165,7 @@ async def test_scan_not_all_present(hass, aioclient_mock): assert not mock_init.mock_calls -async def test_scan_not_all_match(hass, aioclient_mock): +async def test_scan_not_all_match(hass, aioclient_mock, mock_get_source_ip): """Test match fails if some specified attribute values differ.""" aioclient_mock.get( "http://1.1.1.1", @@ -194,7 +196,9 @@ async def test_scan_not_all_match(hass, aioclient_mock): @pytest.mark.parametrize("exc", [asyncio.TimeoutError, aiohttp.ClientError]) -async def test_scan_description_fetch_fail(hass, aioclient_mock, exc): +async def test_scan_description_fetch_fail( + hass, aioclient_mock, exc, mock_get_source_ip +): """Test failing to fetch description.""" aioclient_mock.get("http://1.1.1.1", exc=exc) mock_ssdp_response = { @@ -224,7 +228,7 @@ async def test_scan_description_fetch_fail(hass, aioclient_mock, exc): ] -async def test_scan_description_parse_fail(hass, aioclient_mock): +async def test_scan_description_parse_fail(hass, aioclient_mock, mock_get_source_ip): """Test invalid XML.""" aioclient_mock.get( "http://1.1.1.1", @@ -250,7 +254,7 @@ async def test_scan_description_parse_fail(hass, aioclient_mock): assert not mock_init.mock_calls -async def test_invalid_characters(hass, aioclient_mock): +async def test_invalid_characters(hass, aioclient_mock, mock_get_source_ip): """Test that we replace bad characters with placeholders.""" aioclient_mock.get( "http://1.1.1.1", @@ -295,7 +299,7 @@ async def test_invalid_characters(hass, aioclient_mock): @patch("homeassistant.components.ssdp.SSDPListener.async_search") @patch("homeassistant.components.ssdp.SSDPListener.async_stop") async def test_start_stop_scanner( - async_stop_mock, async_search_mock, async_start_mock, hass + async_stop_mock, async_search_mock, async_start_mock, hass, mock_get_source_ip ): """Test we start and stop the scanner.""" assert await async_setup_component(hass, ssdp.DOMAIN, {ssdp.DOMAIN: {}}) @@ -318,7 +322,9 @@ async def test_start_stop_scanner( assert async_stop_mock.call_count == 1 -async def test_unexpected_exception_while_fetching(hass, aioclient_mock, caplog): +async def test_unexpected_exception_while_fetching( + hass, aioclient_mock, caplog, mock_get_source_ip +): """Test unexpected exception while fetching.""" aioclient_mock.get( "http://1.1.1.1", @@ -355,7 +361,9 @@ async def test_unexpected_exception_while_fetching(hass, aioclient_mock, caplog) assert "Failed to fetch ssdp data from: http://1.1.1.1" in caplog.text -async def test_scan_with_registered_callback(hass, aioclient_mock, caplog): +async def test_scan_with_registered_callback( + hass, aioclient_mock, caplog, mock_get_source_ip +): """Test matching based on callback.""" aioclient_mock.get( "http://1.1.1.1", @@ -490,7 +498,9 @@ async def test_scan_with_registered_callback(hass, aioclient_mock, caplog): assert "Failed to callback info" in caplog.text -async def test_unsolicited_ssdp_registered_callback(hass, aioclient_mock, caplog): +async def test_unsolicited_ssdp_registered_callback( + hass, aioclient_mock, caplog, mock_get_source_ip +): """Test matching based on callback can handle unsolicited ssdp traffic without st.""" aioclient_mock.get( "http://10.6.9.12:1400/xml/device_description.xml", @@ -578,7 +588,7 @@ async def test_unsolicited_ssdp_registered_callback(hass, aioclient_mock, caplog assert "Failed to callback info" not in caplog.text -async def test_scan_second_hit(hass, aioclient_mock, caplog): +async def test_scan_second_hit(hass, aioclient_mock, caplog, mock_get_source_ip): """Test matching on second scan.""" aioclient_mock.get( "http://1.1.1.1", @@ -752,7 +762,7 @@ _ADAPTERS_WITH_MANUAL_CONFIG = [ ] -async def test_async_detect_interfaces_setting_empty_route(hass): +async def test_async_detect_interfaces_setting_empty_route(hass, mock_get_source_ip): """Test without default interface config and the route returns nothing.""" mock_get_ssdp = { "mock-domain": [ @@ -803,7 +813,7 @@ async def test_async_detect_interfaces_setting_empty_route(hass): } -async def test_bind_failure_skips_adapter(hass, caplog): +async def test_bind_failure_skips_adapter(hass, caplog, mock_get_source_ip): """Test that an adapter with a bind failure is skipped.""" mock_get_ssdp = { "mock-domain": [ @@ -872,7 +882,7 @@ async def test_bind_failure_skips_adapter(hass, caplog): } -async def test_ipv4_does_additional_search_for_sonos(hass, caplog): +async def test_ipv4_does_additional_search_for_sonos(hass, caplog, mock_get_source_ip): """Test that only ipv4 does an additional search for Sonos.""" mock_get_ssdp = { "mock-domain": [ @@ -928,7 +938,9 @@ async def test_ipv4_does_additional_search_for_sonos(hass, caplog): } -async def test_location_change_evicts_prior_location_from_cache(hass, aioclient_mock): +async def test_location_change_evicts_prior_location_from_cache( + hass, aioclient_mock, mock_get_source_ip +): """Test that a location change for a UDN will evict the prior location from the cache.""" mock_get_ssdp = { "hue": [{"manufacturer": "Signify", "modelName": "Philips hue bridge 2015"}] diff --git a/tests/components/upnp/test_config_flow.py b/tests/components/upnp/test_config_flow.py index 907fa709c84..a83b9ac41dd 100644 --- a/tests/components/upnp/test_config_flow.py +++ b/tests/components/upnp/test_config_flow.py @@ -34,7 +34,7 @@ from .mock_upnp_device import mock_upnp_device # noqa: F401 from tests.common import MockConfigEntry, async_fire_time_changed -@pytest.mark.usefixtures("mock_ssdp_scanner", "mock_upnp_device") +@pytest.mark.usefixtures("mock_ssdp_scanner", "mock_upnp_device", "mock_get_source_ip") async def test_flow_ssdp_discovery( hass: HomeAssistant, ): @@ -70,7 +70,7 @@ async def test_flow_ssdp_discovery( } -@pytest.mark.usefixtures("mock_ssdp_scanner") +@pytest.mark.usefixtures("mock_ssdp_scanner", "mock_get_source_ip") async def test_flow_ssdp_incomplete_discovery(hass: HomeAssistant): """Test config flow: incomplete discovery through ssdp.""" # Discovered via step ssdp. @@ -88,7 +88,7 @@ async def test_flow_ssdp_incomplete_discovery(hass: HomeAssistant): assert result["reason"] == "incomplete_discovery" -@pytest.mark.usefixtures("mock_ssdp_scanner") +@pytest.mark.usefixtures("mock_ssdp_scanner", "mock_get_source_ip") async def test_flow_ssdp_discovery_ignored(hass: HomeAssistant): """Test config flow: discovery through ssdp, but ignored, as hostname is used by existing config entry.""" # Existing entry. @@ -113,7 +113,7 @@ async def test_flow_ssdp_discovery_ignored(hass: HomeAssistant): assert result["reason"] == "discovery_ignored" -@pytest.mark.usefixtures("mock_ssdp_scanner", "mock_upnp_device") +@pytest.mark.usefixtures("mock_ssdp_scanner", "mock_upnp_device", "mock_get_source_ip") async def test_flow_user(hass: HomeAssistant): """Test config flow: discovered + configured through user.""" # Ensure we have a ssdp Scanner. @@ -145,7 +145,7 @@ async def test_flow_user(hass: HomeAssistant): } -@pytest.mark.usefixtures("mock_ssdp_scanner", "mock_upnp_device") +@pytest.mark.usefixtures("mock_ssdp_scanner", "mock_upnp_device", "mock_get_source_ip") async def test_flow_import(hass: HomeAssistant): """Test config flow: configured through configuration.yaml.""" # Ensure we have a ssdp Scanner. @@ -169,7 +169,7 @@ async def test_flow_import(hass: HomeAssistant): } -@pytest.mark.usefixtures("mock_ssdp_scanner") +@pytest.mark.usefixtures("mock_ssdp_scanner", "mock_get_source_ip") async def test_flow_import_already_configured(hass: HomeAssistant): """Test config flow: configured through configuration.yaml, but existing config entry.""" # Existing entry. @@ -193,7 +193,7 @@ async def test_flow_import_already_configured(hass: HomeAssistant): assert result["reason"] == "already_configured" -@pytest.mark.usefixtures("mock_ssdp_scanner") +@pytest.mark.usefixtures("mock_ssdp_scanner", "mock_get_source_ip") async def test_flow_import_no_devices_found(hass: HomeAssistant): """Test config flow: no devices found, configured through configuration.yaml.""" # Ensure we have a ssdp Scanner. @@ -213,7 +213,7 @@ async def test_flow_import_no_devices_found(hass: HomeAssistant): assert result["reason"] == "no_devices_found" -@pytest.mark.usefixtures("mock_ssdp_scanner", "mock_upnp_device") +@pytest.mark.usefixtures("mock_ssdp_scanner", "mock_upnp_device", "mock_get_source_ip") async def test_options_flow(hass: HomeAssistant): """Test options flow.""" # Ensure we have a ssdp Scanner. diff --git a/tests/components/upnp/test_init.py b/tests/components/upnp/test_init.py index 9ccdbf02f4b..6f0aa438310 100644 --- a/tests/components/upnp/test_init.py +++ b/tests/components/upnp/test_init.py @@ -19,7 +19,7 @@ from .mock_upnp_device import mock_upnp_device # noqa: F401 from tests.common import MockConfigEntry -@pytest.mark.usefixtures("mock_ssdp_scanner", "mock_upnp_device") +@pytest.mark.usefixtures("mock_ssdp_scanner", "mock_upnp_device", "mock_get_source_ip") async def test_async_setup_entry_default(hass: HomeAssistant): """Test async_setup_entry.""" entry = MockConfigEntry( diff --git a/tests/components/yamaha_musiccast/test_config_flow.py b/tests/components/yamaha_musiccast/test_config_flow.py index 08900b1dfad..0e52d598bf6 100644 --- a/tests/components/yamaha_musiccast/test_config_flow.py +++ b/tests/components/yamaha_musiccast/test_config_flow.py @@ -104,7 +104,9 @@ def mock_empty_discovery_information(): # User Flows -async def test_user_input_device_not_found(hass, mock_get_device_info_mc_exception): +async def test_user_input_device_not_found( + hass, mock_get_device_info_mc_exception, mock_get_source_ip +): """Test when user specifies a non-existing device.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -120,7 +122,9 @@ async def test_user_input_device_not_found(hass, mock_get_device_info_mc_excepti assert result2["errors"] == {"base": "cannot_connect"} -async def test_user_input_non_yamaha_device_found(hass, mock_get_device_info_invalid): +async def test_user_input_non_yamaha_device_found( + hass, mock_get_device_info_invalid, mock_get_source_ip +): """Test when user specifies an existing device, which does not provide the musiccast API.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -136,7 +140,9 @@ async def test_user_input_non_yamaha_device_found(hass, mock_get_device_info_inv assert result2["errors"] == {"base": "no_musiccast_device"} -async def test_user_input_device_already_existing(hass, mock_get_device_info_valid): +async def test_user_input_device_already_existing( + hass, mock_get_device_info_valid, mock_get_source_ip +): """Test when user specifies an existing device.""" mock_entry = MockConfigEntry( domain=DOMAIN, @@ -158,7 +164,9 @@ async def test_user_input_device_already_existing(hass, mock_get_device_info_val assert result2["reason"] == "already_configured" -async def test_user_input_unknown_error(hass, mock_get_device_info_exception): +async def test_user_input_unknown_error( + hass, mock_get_device_info_exception, mock_get_source_ip +): """Test when user specifies an existing device, which does not provide the musiccast API.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -175,7 +183,10 @@ async def test_user_input_unknown_error(hass, mock_get_device_info_exception): async def test_user_input_device_found( - hass, mock_get_device_info_valid, mock_valid_discovery_information + hass, + mock_get_device_info_valid, + mock_valid_discovery_information, + mock_get_source_ip, ): """Test when user specifies an existing device.""" result = await hass.config_entries.flow.async_init( @@ -198,7 +209,10 @@ async def test_user_input_device_found( async def test_user_input_device_found_no_ssdp( - hass, mock_get_device_info_valid, mock_empty_discovery_information + hass, + mock_get_device_info_valid, + mock_empty_discovery_information, + mock_get_source_ip, ): """Test when user specifies an existing device, which no discovery data are present for.""" result = await hass.config_entries.flow.async_init( @@ -220,7 +234,9 @@ async def test_user_input_device_found_no_ssdp( } -async def test_import_device_already_existing(hass, mock_get_device_info_valid): +async def test_import_device_already_existing( + hass, mock_get_device_info_valid, mock_get_source_ip +): """Test when the configurations.yaml contains an existing device.""" mock_entry = MockConfigEntry( domain=DOMAIN, @@ -239,7 +255,7 @@ async def test_import_device_already_existing(hass, mock_get_device_info_valid): assert result["reason"] == "already_configured" -async def test_import_error(hass, mock_get_device_info_exception): +async def test_import_error(hass, mock_get_device_info_exception, mock_get_source_ip): """Test when in the configuration.yaml a device is configured, which cannot be added..""" config = {"platform": "yamaha_musiccast", "host": "192.168.188.18", "port": 5006} @@ -252,7 +268,10 @@ async def test_import_error(hass, mock_get_device_info_exception): async def test_import_device_successful( - hass, mock_get_device_info_valid, mock_valid_discovery_information + hass, + mock_get_device_info_valid, + mock_valid_discovery_information, + mock_get_source_ip, ): """Test when the device was imported successfully.""" config = {"platform": "yamaha_musiccast", "host": "127.0.0.1", "port": 5006} @@ -273,7 +292,7 @@ async def test_import_device_successful( # SSDP Flows -async def test_ssdp_discovery_failed(hass, mock_ssdp_no_yamaha): +async def test_ssdp_discovery_failed(hass, mock_ssdp_no_yamaha, mock_get_source_ip): """Test when an SSDP discovered device is not a musiccast device.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -289,7 +308,9 @@ async def test_ssdp_discovery_failed(hass, mock_ssdp_no_yamaha): assert result["reason"] == "yxc_control_url_missing" -async def test_ssdp_discovery_successful_add_device(hass, mock_ssdp_yamaha): +async def test_ssdp_discovery_successful_add_device( + hass, mock_ssdp_yamaha, mock_get_source_ip +): """Test when the SSDP discovered device is a musiccast device and the user confirms it.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -319,7 +340,9 @@ async def test_ssdp_discovery_successful_add_device(hass, mock_ssdp_yamaha): } -async def test_ssdp_discovery_existing_device_update(hass, mock_ssdp_yamaha): +async def test_ssdp_discovery_existing_device_update( + hass, mock_ssdp_yamaha, mock_get_source_ip +): """Test when the SSDP discovered device is a musiccast device, but it already exists with another IP.""" mock_entry = MockConfigEntry( domain=DOMAIN, diff --git a/tests/components/yeelight/conftest.py b/tests/components/yeelight/conftest.py index f418e90e848..9a9b9d19ec2 100644 --- a/tests/components/yeelight/conftest.py +++ b/tests/components/yeelight/conftest.py @@ -1,2 +1,9 @@ """yeelight conftest.""" +import pytest + from tests.components.light.conftest import mock_light_profiles # noqa: F401 + + +@pytest.fixture(autouse=True) +def yeelight_mock_get_source_ip(mock_get_source_ip): + """Mock network util's async_get_source_ip.""" diff --git a/tests/conftest.py b/tests/conftest.py index c437b70d965..0b3db0bf832 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -488,6 +488,16 @@ async def mqtt_mock(hass, mqtt_client_mock, mqtt_config): return component +@pytest.fixture +def mock_get_source_ip(): + """Mock network util's async_get_source_ip.""" + with patch( + "homeassistant.components.network.util.async_get_source_ip", + return_value="10.10.10.10", + ): + yield + + @pytest.fixture def mock_zeroconf(): """Mock zeroconf.""" From 02db4dbe5e9eb0a410e1c8a46411f1a0f2047a0e Mon Sep 17 00:00:00 2001 From: bsmappee <58250533+bsmappee@users.noreply.github.com> Date: Thu, 2 Sep 2021 21:01:16 +0200 Subject: [PATCH 169/843] Bump pysmappee to 0.2.27 (#55257) * bump * bump --- homeassistant/components/smappee/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smappee/manifest.json b/homeassistant/components/smappee/manifest.json index b4250332120..91192a13484 100644 --- a/homeassistant/components/smappee/manifest.json +++ b/homeassistant/components/smappee/manifest.json @@ -5,7 +5,7 @@ "documentation": "https://www.home-assistant.io/integrations/smappee", "dependencies": ["http"], "requirements": [ - "pysmappee==0.2.25" + "pysmappee==0.2.27" ], "codeowners": [ "@bsmappee" diff --git a/requirements_all.txt b/requirements_all.txt index 542d461344b..e8bdb1db15e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1784,7 +1784,7 @@ pyskyqhub==0.1.3 pysma==0.6.5 # homeassistant.components.smappee -pysmappee==0.2.25 +pysmappee==0.2.27 # homeassistant.components.smartthings pysmartapp==0.3.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2836e160dae..652eb373c52 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1034,7 +1034,7 @@ pysignalclirestapi==0.3.4 pysma==0.6.5 # homeassistant.components.smappee -pysmappee==0.2.25 +pysmappee==0.2.27 # homeassistant.components.smartthings pysmartapp==0.3.3 From 8af0cb9e65fab297d97229a7ab802b0a44daa9e0 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Fri, 3 Sep 2021 00:16:18 +0000 Subject: [PATCH 170/843] [ci skip] Translation update --- .../components/asuswrt/translations/he.json | 1 + .../components/cloud/translations/no.json | 1 + .../cloud/translations/zh-Hant.json | 1 + .../components/iotawatt/translations/he.json | 22 +++++++++++++++++++ .../synology_dsm/translations/he.json | 3 ++- .../components/tasmota/translations/he.json | 2 +- 6 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/iotawatt/translations/he.json diff --git a/homeassistant/components/asuswrt/translations/he.json b/homeassistant/components/asuswrt/translations/he.json index 7b859e5af0c..2d2cebaa7e3 100644 --- a/homeassistant/components/asuswrt/translations/he.json +++ b/homeassistant/components/asuswrt/translations/he.json @@ -8,6 +8,7 @@ "invalid_host": "\u05e9\u05dd \u05de\u05d0\u05e8\u05d7 \u05d0\u05d5 \u05db\u05ea\u05d5\u05d1\u05ea IP \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9\u05d9\u05dd", "pwd_and_ssh": "\u05e1\u05e4\u05e7 \u05e8\u05e7 \u05e1\u05d9\u05e1\u05de\u05d4 \u05d0\u05d5 \u05e7\u05d5\u05d1\u05e5 \u05de\u05e4\u05ea\u05d7 SSH", "pwd_or_ssh": "\u05d0\u05e0\u05d0 \u05e1\u05e4\u05e7 \u05e1\u05d9\u05e1\u05de\u05d4 \u05d0\u05d5 \u05e7\u05d5\u05d1\u05e5 \u05de\u05e4\u05ea\u05d7 SSH", + "ssh_not_file": "\u05e7\u05d5\u05d1\u05e5 \u05de\u05e4\u05ea\u05d7 SSH \u05dc\u05d0 \u05e0\u05de\u05e6\u05d0", "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" }, "step": { diff --git a/homeassistant/components/cloud/translations/no.json b/homeassistant/components/cloud/translations/no.json index 63779e7fa94..e3ae7a4f766 100644 --- a/homeassistant/components/cloud/translations/no.json +++ b/homeassistant/components/cloud/translations/no.json @@ -10,6 +10,7 @@ "relayer_connected": "Relayer tilkoblet", "remote_connected": "Ekstern tilkobling", "remote_enabled": "Ekstern aktivert", + "remote_server": "Ekstern server", "subscription_expiration": "Abonnementets utl\u00f8p" } } diff --git a/homeassistant/components/cloud/translations/zh-Hant.json b/homeassistant/components/cloud/translations/zh-Hant.json index 8b97fd51a03..619b0dde71c 100644 --- a/homeassistant/components/cloud/translations/zh-Hant.json +++ b/homeassistant/components/cloud/translations/zh-Hant.json @@ -10,6 +10,7 @@ "relayer_connected": "\u4e2d\u7e7c\u5df2\u9023\u7dda", "remote_connected": "\u9060\u7aef\u63a7\u5236\u5df2\u9023\u7dda", "remote_enabled": "\u9060\u7aef\u63a7\u5236\u5df2\u555f\u7528", + "remote_server": "\u9060\u7aef\u4f3a\u670d\u5668", "subscription_expiration": "\u8a02\u95b1\u5230\u671f" } } diff --git a/homeassistant/components/iotawatt/translations/he.json b/homeassistant/components/iotawatt/translations/he.json new file mode 100644 index 00000000000..ce440eb97d6 --- /dev/null +++ b/homeassistant/components/iotawatt/translations/he.json @@ -0,0 +1,22 @@ +{ + "config": { + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "auth": { + "data": { + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + }, + "user": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/synology_dsm/translations/he.json b/homeassistant/components/synology_dsm/translations/he.json index 4d95d5c2c3c..974f36f501a 100644 --- a/homeassistant/components/synology_dsm/translations/he.json +++ b/homeassistant/components/synology_dsm/translations/he.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", - "reauth_successful": "\u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7" + "reauth_successful": "\u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7", + "reconfigure_successful": "\u05e7\u05d1\u05d9\u05e2\u05ea \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4 \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7\u05d4" }, "error": { "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", diff --git a/homeassistant/components/tasmota/translations/he.json b/homeassistant/components/tasmota/translations/he.json index eefc72310d4..a2a5db62b37 100644 --- a/homeassistant/components/tasmota/translations/he.json +++ b/homeassistant/components/tasmota/translations/he.json @@ -11,7 +11,7 @@ "data": { "discovery_prefix": "\u05d2\u05d9\u05dc\u05d5\u05d9 \u05dc\u05e4\u05d9 \u05e7\u05d9\u05d3\u05d5\u05de\u05ea \u05e0\u05d5\u05e9\u05d0" }, - "description": "\u05d0\u05e0\u05d0 \u05d4\u05db\u05e0\u05e1 \u05d0\u05ea \u05ea\u05e6\u05d5\u05e8\u05ea Tasmota.", + "description": "\u05e0\u05d0 \u05dc\u05d4\u05d6\u05d9\u05df \u05d0\u05ea \u05d0\u05ea \u05ea\u05e6\u05d5\u05e8\u05ea Tasmota.", "title": "Tasmota" }, "confirm": { From d8a81a54d8f5910d8fe7142e022d921185e27785 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 2 Sep 2021 17:11:03 -1000 Subject: [PATCH 171/843] Narrow zwave_js USB discovery (#55613) - Avoid triggering discovery when we can know in advance the device is not a Z-Wave stick --- homeassistant/components/zwave_js/config_flow.py | 5 ----- homeassistant/components/zwave_js/manifest.json | 2 +- homeassistant/generated/usb.py | 3 ++- tests/components/zwave_js/test_config_flow.py | 5 +---- 4 files changed, 4 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 55266d02389..a4f7343f0e0 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -326,11 +326,6 @@ class ConfigFlow(BaseZwaveJSFlow, config_entries.ConfigFlow, domain=DOMAIN): device = discovery_info["device"] manufacturer = discovery_info["manufacturer"] description = discovery_info["description"] - # The Nortek sticks are a special case since they - # have a Z-Wave and a Zigbee radio. We need to reject - # the Zigbee radio. - if vid == "10C4" and pid == "8A2A" and "Z-Wave" not in description: - return self.async_abort(reason="not_zwave_device") # Zooz uses this vid/pid, but so do 2652 sticks if vid == "10C4" and pid == "EA60" and "2652" in description: return self.async_abort(reason="not_zwave_device") diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 7953e33d6e3..ad8ec22befb 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "usb": [ {"vid":"0658","pid":"0200","known_devices":["Aeotec Z-Stick Gen5+", "Z-WaveMe UZB"]}, - {"vid":"10C4","pid":"8A2A","known_devices":["Nortek HUSBZB-1"]}, + {"vid":"10C4","pid":"8A2A","description":"*z-wave*","known_devices":["Nortek HUSBZB-1"]}, {"vid":"10C4","pid":"EA60","known_devices":["Aeotec Z-Stick 7", "Silicon Labs UZB-7", "Zooz ZST10 700"]} ] } diff --git a/homeassistant/generated/usb.py b/homeassistant/generated/usb.py index 477a762ae62..844c09fea40 100644 --- a/homeassistant/generated/usb.py +++ b/homeassistant/generated/usb.py @@ -32,7 +32,8 @@ USB = [ { "domain": "zwave_js", "vid": "10C4", - "pid": "8A2A" + "pid": "8A2A", + "description": "*z-wave*" }, { "domain": "zwave_js", diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index 5e994a2ac7a..757dc6d5364 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -756,10 +756,7 @@ async def test_usb_discovery_already_running(hass, supervisor, addon_running): @pytest.mark.parametrize( "discovery_info", - [ - NORTEK_ZIGBEE_DISCOVERY_INFO, - CP2652_ZIGBEE_DISCOVERY_INFO, - ], + [CP2652_ZIGBEE_DISCOVERY_INFO], ) async def test_abort_usb_discovery_aborts_specific_devices( hass, supervisor, addon_options, discovery_info From 8319f232b85f72ef0330f42d180cdb946ab6d3a5 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 3 Sep 2021 08:05:37 +0200 Subject: [PATCH 172/843] Disable observer for USB on containers (#55570) * Disable observer for USB on containers * remove operating system test Co-authored-by: J. Nick Koston --- homeassistant/components/usb/__init__.py | 2 +- tests/components/usb/test_init.py | 49 ------------------------ 2 files changed, 1 insertion(+), 50 deletions(-) diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index 679f2e1caa2..13f18216cca 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -112,7 +112,7 @@ class USBDiscovery: if not sys.platform.startswith("linux"): return info = await system_info.async_get_system_info(self.hass) - if info.get("docker") and not info.get("hassio"): + if info.get("docker"): return from pyudev import ( # pylint: disable=import-outside-toplevel diff --git a/tests/components/usb/test_init.py b/tests/components/usb/test_init.py index 6ba21222052..b09dad9ebe4 100644 --- a/tests/components/usb/test_init.py +++ b/tests/components/usb/test_init.py @@ -52,55 +52,6 @@ def mock_venv(): yield -@pytest.mark.skipif( - not sys.platform.startswith("linux"), - reason="Only works on linux", -) -async def test_discovered_by_observer_before_started(hass, operating_system): - """Test a device is discovered by the observer before started.""" - - async def _mock_monitor_observer_callback(callback): - await hass.async_add_executor_job( - callback, MagicMock(action="add", device_path="/dev/new") - ) - - def _create_mock_monitor_observer(monitor, callback, name): - hass.async_create_task(_mock_monitor_observer_callback(callback)) - return MagicMock() - - new_usb = [{"domain": "test1", "vid": "3039", "pid": "3039"}] - - mock_comports = [ - MagicMock( - device=slae_sh_device.device, - vid=12345, - pid=12345, - serial_number=slae_sh_device.serial_number, - manufacturer=slae_sh_device.manufacturer, - description=slae_sh_device.description, - ) - ] - - with patch( - "homeassistant.components.usb.async_get_usb", return_value=new_usb - ), patch( - "homeassistant.components.usb.comports", return_value=mock_comports - ), patch( - "pyudev.MonitorObserver", new=_create_mock_monitor_observer - ): - assert await async_setup_component(hass, "usb", {"usb": {}}) - await hass.async_block_till_done() - - with patch("homeassistant.components.usb.comports", return_value=[]), patch.object( - hass.config_entries.flow, "async_init" - ) as mock_config_flow: - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) - await hass.async_block_till_done() - - assert len(mock_config_flow.mock_calls) == 1 - assert mock_config_flow.mock_calls[0][1][0] == "test1" - - @pytest.mark.skipif( not sys.platform.startswith("linux"), reason="Only works on linux", From 0c2772e0be7f53c034b565096b31ce41845b4218 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 3 Sep 2021 00:02:45 -0700 Subject: [PATCH 173/843] Fix template sensor availability (#55635) --- .../components/template/trigger_entity.py | 2 +- tests/components/template/test_sensor.py | 42 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/template/trigger_entity.py b/homeassistant/components/template/trigger_entity.py index 84ad4072b66..c80620b0453 100644 --- a/homeassistant/components/template/trigger_entity.py +++ b/homeassistant/components/template/trigger_entity.py @@ -69,7 +69,7 @@ class TriggerEntity(update_coordinator.CoordinatorEntity): # We make a copy so our initial render is 'unknown' and not 'unavailable' self._rendered = dict(self._static_rendered) - self._parse_result = set() + self._parse_result = {CONF_AVAILABILITY} @property def name(self): diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py index df5c43aa58b..a606c2ec62b 100644 --- a/tests/components/template/test_sensor.py +++ b/tests/components/template/test_sensor.py @@ -1038,6 +1038,7 @@ async def test_trigger_entity(hass): "unique_id": "via_list-id", "device_class": "battery", "unit_of_measurement": "%", + "availability": "{{ True }}", "state": "{{ trigger.event.data.beer + 1 }}", "picture": "{{ '/local/dogs.png' }}", "icon": "{{ 'mdi:pirate' }}", @@ -1197,3 +1198,44 @@ async def test_config_top_level(hass): assert state.state == "5" assert state.attributes["device_class"] == "battery" assert state.attributes["state_class"] == "measurement" + + +async def test_trigger_entity_available(hass): + """Test trigger entity availability works.""" + assert await async_setup_component( + hass, + "template", + { + "template": [ + { + "trigger": {"platform": "event", "event_type": "test_event"}, + "sensor": [ + { + "name": "Maybe Available", + "availability": "{{ trigger and trigger.event.data.beer == 2 }}", + "state": "{{ trigger.event.data.beer }}", + }, + ], + }, + ], + }, + ) + + await hass.async_block_till_done() + + # Sensors are unknown if never triggered + state = hass.states.get("sensor.maybe_available") + assert state is not None + assert state.state == STATE_UNKNOWN + + hass.bus.async_fire("test_event", {"beer": 2}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.maybe_available") + assert state.state == "2" + + hass.bus.async_fire("test_event", {"beer": 1}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.maybe_available") + assert state.state == "unavailable" From 4684ea2d144621b6f66dcfd7da4ad0e1dfed0364 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 3 Sep 2021 10:13:35 +0200 Subject: [PATCH 174/843] Prevent 3rd party lib from opening sockets in broadlink tests (#55636) --- tests/components/broadlink/conftest.py | 11 +++++++++++ tests/components/broadlink/test_device.py | 8 -------- tests/components/broadlink/test_heartbeat.py | 9 --------- 3 files changed, 11 insertions(+), 17 deletions(-) create mode 100644 tests/components/broadlink/conftest.py diff --git a/tests/components/broadlink/conftest.py b/tests/components/broadlink/conftest.py new file mode 100644 index 00000000000..0a9ee4813da --- /dev/null +++ b/tests/components/broadlink/conftest.py @@ -0,0 +1,11 @@ +"""Broadlink test helpers.""" +from unittest.mock import patch + +import pytest + + +@pytest.fixture(autouse=True) +def mock_heartbeat(): + """Mock broadlink heartbeat.""" + with patch("homeassistant.components.broadlink.heartbeat.blk.ping"): + yield diff --git a/tests/components/broadlink/test_device.py b/tests/components/broadlink/test_device.py index 4ebfead007b..5430af9e311 100644 --- a/tests/components/broadlink/test_device.py +++ b/tests/components/broadlink/test_device.py @@ -2,7 +2,6 @@ from unittest.mock import patch import broadlink.exceptions as blke -import pytest from homeassistant.components.broadlink.const import DOMAIN from homeassistant.components.broadlink.device import get_domains @@ -16,13 +15,6 @@ from tests.common import mock_device_registry, mock_registry DEVICE_FACTORY = "homeassistant.components.broadlink.device.blk.gendevice" -@pytest.fixture(autouse=True) -def mock_heartbeat(): - """Mock broadlink heartbeat.""" - with patch("homeassistant.components.broadlink.heartbeat.blk.ping"): - yield - - async def test_device_setup(hass): """Test a successful setup.""" device = get_device("Office") diff --git a/tests/components/broadlink/test_heartbeat.py b/tests/components/broadlink/test_heartbeat.py index 5065bded881..de47a16c0b9 100644 --- a/tests/components/broadlink/test_heartbeat.py +++ b/tests/components/broadlink/test_heartbeat.py @@ -1,8 +1,6 @@ """Tests for Broadlink heartbeats.""" from unittest.mock import call, patch -import pytest - from homeassistant.components.broadlink.heartbeat import BroadlinkHeartbeat from homeassistant.util import dt @@ -13,13 +11,6 @@ from tests.common import async_fire_time_changed DEVICE_PING = "homeassistant.components.broadlink.heartbeat.blk.ping" -@pytest.fixture(autouse=True) -def mock_heartbeat(): - """Mock broadlink heartbeat.""" - with patch("homeassistant.components.broadlink.heartbeat.blk.ping"): - yield - - async def test_heartbeat_trigger_startup(hass): """Test that the heartbeat is initialized with the first config entry.""" device = get_device("Office") From 91cd6951f335e90b72fbc6df02732cdb671388d7 Mon Sep 17 00:00:00 2001 From: Yuval Aboulafia Date: Fri, 3 Sep 2021 11:54:32 +0300 Subject: [PATCH 175/843] Minor cleanup in Waze travel times (#55422) * reduce imports and clean the attriburts * add icons * use entity class attributes * fix icon * add misc types * fix update * do not change icon yet * address review --- .../components/waze_travel_time/const.py | 10 --- .../components/waze_travel_time/sensor.py | 79 +++++++------------ 2 files changed, 28 insertions(+), 61 deletions(-) diff --git a/homeassistant/components/waze_travel_time/const.py b/homeassistant/components/waze_travel_time/const.py index 1b89fd5e282..554f3ecf6d8 100644 --- a/homeassistant/components/waze_travel_time/const.py +++ b/homeassistant/components/waze_travel_time/const.py @@ -3,14 +3,6 @@ from homeassistant.const import CONF_UNIT_SYSTEM_IMPERIAL, CONF_UNIT_SYSTEM_METR DOMAIN = "waze_travel_time" -ATTR_DESTINATION = "destination" -ATTR_DURATION = "duration" -ATTR_DISTANCE = "distance" -ATTR_ORIGIN = "origin" -ATTR_ROUTE = "route" - -ATTRIBUTION = "Powered by Waze" - CONF_DESTINATION = "destination" CONF_ORIGIN = "origin" CONF_INCL_FILTER = "incl_filter" @@ -29,8 +21,6 @@ DEFAULT_AVOID_TOLL_ROADS = False DEFAULT_AVOID_SUBSCRIPTION_ROADS = False DEFAULT_AVOID_FERRIES = False -ICON = "mdi:car" - UNITS = [CONF_UNIT_SYSTEM_METRIC, CONF_UNIT_SYSTEM_IMPERIAL] REGIONS = ["US", "NA", "EU", "IL", "AU"] diff --git a/homeassistant/components/waze_travel_time/sensor.py b/homeassistant/components/waze_travel_time/sensor.py index 43265062998..81ee48ebd2f 100644 --- a/homeassistant/components/waze_travel_time/sensor.py +++ b/homeassistant/components/waze_travel_time/sensor.py @@ -22,16 +22,10 @@ from homeassistant.const import ( ) from homeassistant.core import Config, CoreState, HomeAssistant import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import DiscoveryInfoType from .const import ( - ATTR_DESTINATION, - ATTR_DISTANCE, - ATTR_DURATION, - ATTR_ORIGIN, - ATTR_ROUTE, - ATTRIBUTION, CONF_AVOID_FERRIES, CONF_AVOID_SUBSCRIPTION_ROADS, CONF_AVOID_TOLL_ROADS, @@ -50,7 +44,6 @@ from .const import ( DEFAULT_VEHICLE_TYPE, DOMAIN, ENTITY_ID_PATTERN, - ICON, REGIONS, UNITS, VEHICLE_TYPES, @@ -90,8 +83,11 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( async def async_setup_platform( - hass: HomeAssistant, config: Config, async_add_entities, discovery_info=None -): + hass: HomeAssistant, + config: Config, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up the Waze travel time sensor platform.""" hass.async_create_task( @@ -166,14 +162,23 @@ async def async_setup_entry( class WazeTravelTime(SensorEntity): """Representation of a Waze travel time sensor.""" + _attr_native_unit_of_measurement = TIME_MINUTES + _attr_device_info = { + "name": "Waze", + "identifiers": {(DOMAIN, DOMAIN)}, + "entry_type": "service", + } + def __init__(self, unique_id, name, origin, destination, waze_data): """Initialize the Waze travel time sensor.""" - self._unique_id = unique_id + self._attr_unique_id = unique_id self._waze_data = waze_data - self._name = name + self._attr_name = name + self._attr_icon = "mdi:car" self._state = None self._origin_entity_id = None self._destination_entity_id = None + cmpl_re = re.compile(ENTITY_ID_PATTERN) if cmpl_re.fullmatch(origin): _LOGGER.debug("Found origin source entity %s", origin) @@ -197,12 +202,7 @@ class WazeTravelTime(SensorEntity): await self.first_update() @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def native_value(self): + def native_value(self) -> float | None: """Return the state of the sensor.""" if self._waze_data.duration is not None: return round(self._waze_data.duration) @@ -210,28 +210,19 @@ class WazeTravelTime(SensorEntity): return None @property - def native_unit_of_measurement(self): - """Return the unit of measurement.""" - return TIME_MINUTES - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - - @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict | None: """Return the state attributes of the last update.""" if self._waze_data.duration is None: return None - res = {ATTR_ATTRIBUTION: ATTRIBUTION} - res[ATTR_DURATION] = self._waze_data.duration - res[ATTR_DISTANCE] = self._waze_data.distance - res[ATTR_ROUTE] = self._waze_data.route - res[ATTR_ORIGIN] = self._waze_data.origin - res[ATTR_DESTINATION] = self._waze_data.destination - return res + return { + ATTR_ATTRIBUTION: "Powered by Waze", + "duration": self._waze_data.duration, + "distance": self._waze_data.distance, + "route": self._waze_data.route, + "origin": self._waze_data.origin, + "destination": self._waze_data.destination, + } async def first_update(self, _=None): """Run first update and write state.""" @@ -240,7 +231,7 @@ class WazeTravelTime(SensorEntity): def update(self): """Fetch new state data for the sensor.""" - _LOGGER.debug("Fetching Route for %s", self._name) + _LOGGER.debug("Fetching Route for %s", self._attr_name) # Get origin latitude and longitude from entity_id. if self._origin_entity_id is not None: self._waze_data.origin = get_location_from_entity( @@ -263,20 +254,6 @@ class WazeTravelTime(SensorEntity): self._waze_data.update() - @property - def device_info(self) -> DeviceInfo: - """Return device specific attributes.""" - return { - "name": "Waze", - "identifiers": {(DOMAIN, DOMAIN)}, - "entry_type": "service", - } - - @property - def unique_id(self) -> str: - """Return unique ID of entity.""" - return self._unique_id - class WazeTravelTimeData: """WazeTravelTime Data object.""" From 173b87e675948cb3237c0ac15f7e4eac4695a0e6 Mon Sep 17 00:00:00 2001 From: Yuval Aboulafia Date: Fri, 3 Sep 2021 12:07:53 +0300 Subject: [PATCH 176/843] Clean holiday attributes code in Jewish calendar (#55080) * Clean repetitive code in jewish calendar * do not return none * fix holiday --- homeassistant/components/jewish_calendar/sensor.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/jewish_calendar/sensor.py b/homeassistant/components/jewish_calendar/sensor.py index 4e90dd00058..824eba46973 100644 --- a/homeassistant/components/jewish_calendar/sensor.py +++ b/homeassistant/components/jewish_calendar/sensor.py @@ -230,9 +230,11 @@ class JewishCalendarSensor(SensorEntity): # Compute the weekly portion based on the upcoming shabbat. return after_tzais_date.upcoming_shabbat.parasha if self.entity_description.key == "holiday": - self._holiday_attrs["id"] = after_shkia_date.holiday_name - self._holiday_attrs["type"] = after_shkia_date.holiday_type.name - self._holiday_attrs["type_id"] = after_shkia_date.holiday_type.value + self._holiday_attrs = { + "id": after_shkia_date.holiday_name, + "type": after_shkia_date.holiday_type.name, + "type_id": after_shkia_date.holiday_type.value, + } return after_shkia_date.holiday_description if self.entity_description.key == "omer_count": return after_shkia_date.omer_day From 70338da50efd6f5537fb8463ac42450bf7010e19 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 3 Sep 2021 11:22:41 +0200 Subject: [PATCH 177/843] Remove wheels for alpine 3.13 (#55650) --- .github/workflows/wheels.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 95f7f1fda4d..578cc024738 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -65,7 +65,6 @@ jobs: matrix: arch: ${{ fromJson(needs.init.outputs.architectures) }} tag: - - "3.9-alpine3.13" - "3.9-alpine3.14" steps: - name: Checkout the repository @@ -106,7 +105,6 @@ jobs: matrix: arch: ${{ fromJson(needs.init.outputs.architectures) }} tag: - - "3.9-alpine3.13" - "3.9-alpine3.14" steps: - name: Checkout the repository From b4d4fe4ef87cc4213626a9006d3c2f5fa01bb0ed Mon Sep 17 00:00:00 2001 From: Nikolay Vasilchuk Date: Fri, 3 Sep 2021 15:04:56 +0300 Subject: [PATCH 178/843] Fix Starline sensor state AttributeError (#55654) * Fix starline sensors state * Black --- homeassistant/components/starline/sensor.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/starline/sensor.py b/homeassistant/components/starline/sensor.py index 26834cc384c..9ce3aa3bc08 100644 --- a/homeassistant/components/starline/sensor.py +++ b/homeassistant/components/starline/sensor.py @@ -90,7 +90,8 @@ async def async_setup_entry(hass, entry, async_add_entities): sensor for device in account.api.devices.values() for description in SENSOR_TYPES - if (sensor := StarlineSensor(account, device, description)).state is not None + if (sensor := StarlineSensor(account, device, description)).native_value + is not None ] async_add_entities(entities) From ae9e3c237a23fb8462ba89bc7b7f813295696065 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 3 Sep 2021 14:11:19 +0200 Subject: [PATCH 179/843] Fix CONFIG_SCHEMA validation in Speedtest.net (#55612) --- homeassistant/components/speedtestdotnet/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/speedtestdotnet/__init__.py b/homeassistant/components/speedtestdotnet/__init__.py index b049b3a2d2c..62f7b2dbd73 100644 --- a/homeassistant/components/speedtestdotnet/__init__.py +++ b/homeassistant/components/speedtestdotnet/__init__.py @@ -32,6 +32,8 @@ from .const import ( _LOGGER = logging.getLogger(__name__) +SENSOR_KEYS = [desc.key for desc in SENSOR_TYPES] + CONFIG_SCHEMA = vol.Schema( vol.All( # Deprecated in Home Assistant 2021.6 @@ -46,8 +48,8 @@ CONFIG_SCHEMA = vol.Schema( ): cv.positive_time_period, vol.Optional(CONF_MANUAL, default=False): cv.boolean, vol.Optional( - CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES) - ): vol.All(cv.ensure_list, [vol.In(list(SENSOR_TYPES))]), + CONF_MONITORED_CONDITIONS, default=list(SENSOR_KEYS) + ): vol.All(cv.ensure_list, [vol.In(list(SENSOR_KEYS))]), } ) }, From 4310a7d814977ef5a64fcfe7d1821d12c488ac44 Mon Sep 17 00:00:00 2001 From: ehendrix23 Date: Fri, 3 Sep 2021 09:15:28 -0600 Subject: [PATCH 180/843] Add upnp sensor for IP, Status, and Uptime (#54780) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Joakim Sørensen --- homeassistant/components/upnp/__init__.py | 35 ++- .../components/upnp/binary_sensor.py | 35 +-- homeassistant/components/upnp/const.py | 9 +- homeassistant/components/upnp/device.py | 14 +- homeassistant/components/upnp/sensor.py | 224 ++++++++++-------- tests/components/upnp/mock_upnp_device.py | 12 +- 6 files changed, 191 insertions(+), 138 deletions(-) diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py index 80a7753ec8c..9541331fe0b 100644 --- a/homeassistant/components/upnp/__init__.py +++ b/homeassistant/components/upnp/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio from collections.abc import Mapping +from dataclasses import dataclass from datetime import timedelta from ipaddress import ip_address from typing import Any @@ -11,8 +12,10 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import ssdp +from homeassistant.components.binary_sensor import BinarySensorEntityDescription from homeassistant.components.network import async_get_source_ip from homeassistant.components.network.const import PUBLIC_TARGET_IP +from homeassistant.components.sensor import SensorEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady @@ -191,6 +194,20 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) +@dataclass +class UpnpBinarySensorEntityDescription(BinarySensorEntityDescription): + """A class that describes UPnP entities.""" + + format: str = "s" + + +@dataclass +class UpnpSensorEntityDescription(SensorEntityDescription): + """A class that describes a sensor UPnP entities.""" + + format: str = "s" + + class UpnpDataUpdateCoordinator(DataUpdateCoordinator): """Define an object to update data from UPNP device.""" @@ -221,14 +238,30 @@ class UpnpEntity(CoordinatorEntity): """Base class for UPnP/IGD entities.""" coordinator: UpnpDataUpdateCoordinator + entity_description: UpnpSensorEntityDescription | UpnpBinarySensorEntityDescription - def __init__(self, coordinator: UpnpDataUpdateCoordinator) -> None: + def __init__( + self, + coordinator: UpnpDataUpdateCoordinator, + entity_description: UpnpSensorEntityDescription + | UpnpBinarySensorEntityDescription, + ) -> None: """Initialize the base entities.""" super().__init__(coordinator) self._device = coordinator.device + self.entity_description = entity_description + self._attr_name = f"{coordinator.device.name} {entity_description.name}" + self._attr_unique_id = f"{coordinator.device.udn}_{entity_description.key}" self._attr_device_info = { "connections": {(dr.CONNECTION_UPNP, coordinator.device.udn)}, "name": coordinator.device.name, "manufacturer": coordinator.device.manufacturer, "model": coordinator.device.model_name, } + + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and ( + self.coordinator.data.get(self.entity_description.key) or False + ) diff --git a/homeassistant/components/upnp/binary_sensor.py b/homeassistant/components/upnp/binary_sensor.py index 2f2f0af0e96..3bf9635c78b 100644 --- a/homeassistant/components/upnp/binary_sensor.py +++ b/homeassistant/components/upnp/binary_sensor.py @@ -9,8 +9,15 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import UpnpDataUpdateCoordinator, UpnpEntity -from .const import DOMAIN, LOGGER, WANSTATUS +from . import UpnpBinarySensorEntityDescription, UpnpDataUpdateCoordinator, UpnpEntity +from .const import DOMAIN, LOGGER, WAN_STATUS + +BINARYSENSOR_ENTITY_DESCRIPTIONS: tuple[UpnpBinarySensorEntityDescription, ...] = ( + UpnpBinarySensorEntityDescription( + key=WAN_STATUS, + name="wan status", + ), +) async def async_setup_entry( @@ -23,10 +30,14 @@ async def async_setup_entry( LOGGER.debug("Adding binary sensor") - sensors = [ - UpnpStatusBinarySensor(coordinator), - ] - async_add_entities(sensors) + async_add_entities( + UpnpStatusBinarySensor( + coordinator=coordinator, + entity_description=entity_description, + ) + for entity_description in BINARYSENSOR_ENTITY_DESCRIPTIONS + if coordinator.data.get(entity_description.key) is not None + ) class UpnpStatusBinarySensor(UpnpEntity, BinarySensorEntity): @@ -37,18 +48,12 @@ class UpnpStatusBinarySensor(UpnpEntity, BinarySensorEntity): def __init__( self, coordinator: UpnpDataUpdateCoordinator, + entity_description: UpnpBinarySensorEntityDescription, ) -> None: """Initialize the base sensor.""" - super().__init__(coordinator) - self._attr_name = f"{coordinator.device.name} wan status" - self._attr_unique_id = f"{coordinator.device.udn}_wanstatus" - - @property - def available(self) -> bool: - """Return if entity is available.""" - return super().available and self.coordinator.data.get(WANSTATUS) + super().__init__(coordinator=coordinator, entity_description=entity_description) @property def is_on(self) -> bool: """Return true if the binary sensor is on.""" - return self.coordinator.data[WANSTATUS] == "Connected" + return self.coordinator.data[self.entity_description.key] == "Connected" diff --git a/homeassistant/components/upnp/const.py b/homeassistant/components/upnp/const.py index 769e398c5a4..142c00ad27f 100644 --- a/homeassistant/components/upnp/const.py +++ b/homeassistant/components/upnp/const.py @@ -18,9 +18,9 @@ PACKETS_SENT = "packets_sent" TIMESTAMP = "timestamp" DATA_PACKETS = "packets" DATA_RATE_PACKETS_PER_SECOND = f"{DATA_PACKETS}/{TIME_SECONDS}" -WANSTATUS = "wan_status" -WANIP = "wan_ip" -UPTIME = "uptime" +WAN_STATUS = "wan_status" +ROUTER_IP = "ip" +ROUTER_UPTIME = "uptime" KIBIBYTE = 1024 UPDATE_INTERVAL = timedelta(seconds=30) CONFIG_ENTRY_SCAN_INTERVAL = "scan_interval" @@ -31,3 +31,6 @@ DEFAULT_SCAN_INTERVAL = timedelta(seconds=30).total_seconds() ST_IGD_V1 = "urn:schemas-upnp-org:device:InternetGatewayDevice:1" ST_IGD_V2 = "urn:schemas-upnp-org:device:InternetGatewayDevice:2" SSDP_SEARCH_TIMEOUT = 4 + +RAW_SENSOR = "raw_sensor" +DERIVED_SENSOR = "derived_sensor" diff --git a/homeassistant/components/upnp/device.py b/homeassistant/components/upnp/device.py index ca06f501405..a1040816629 100644 --- a/homeassistant/components/upnp/device.py +++ b/homeassistant/components/upnp/device.py @@ -14,7 +14,6 @@ from async_upnp_client.profiles.igd import IgdDevice from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator import homeassistant.util.dt as dt_util from .const import ( @@ -26,10 +25,10 @@ from .const import ( LOGGER as _LOGGER, PACKETS_RECEIVED, PACKETS_SENT, + ROUTER_IP, + ROUTER_UPTIME, TIMESTAMP, - UPTIME, - WANIP, - WANSTATUS, + WAN_STATUS, ) @@ -49,7 +48,6 @@ class Device: """Initialize UPnP/IGD device.""" self._igd_device = igd_device self._device_updater = device_updater - self.coordinator: DataUpdateCoordinator = None @classmethod async def async_create_device( @@ -168,7 +166,7 @@ class Device: ) return { - WANSTATUS: values[0][0] if values[0] is not None else None, - UPTIME: values[0][2] if values[0] is not None else None, - WANIP: values[1], + WAN_STATUS: values[0][0] if values[0] is not None else None, + ROUTER_UPTIME: values[0][2] if values[0] is not None else None, + ROUTER_IP: values[1], } diff --git a/homeassistant/components/upnp/sensor.py b/homeassistant/components/upnp/sensor.py index 185d3ecac6d..bebb8e3e957 100644 --- a/homeassistant/components/upnp/sensor.py +++ b/homeassistant/components/upnp/sensor.py @@ -3,73 +3,111 @@ from __future__ import annotations from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import DATA_BYTES, DATA_RATE_KIBIBYTES_PER_SECOND +from homeassistant.const import DATA_BYTES, DATA_RATE_KIBIBYTES_PER_SECOND, TIME_SECONDS from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import UpnpDataUpdateCoordinator, UpnpEntity +from . import UpnpDataUpdateCoordinator, UpnpEntity, UpnpSensorEntityDescription from .const import ( BYTES_RECEIVED, BYTES_SENT, DATA_PACKETS, DATA_RATE_PACKETS_PER_SECOND, + DERIVED_SENSOR, DOMAIN, KIBIBYTE, LOGGER, PACKETS_RECEIVED, PACKETS_SENT, + RAW_SENSOR, + ROUTER_IP, + ROUTER_UPTIME, TIMESTAMP, + WAN_STATUS, ) -SENSOR_TYPES = { - BYTES_RECEIVED: { - "device_value_key": BYTES_RECEIVED, - "name": f"{DATA_BYTES} received", - "unit": DATA_BYTES, - "unique_id": BYTES_RECEIVED, - "derived_name": f"{DATA_RATE_KIBIBYTES_PER_SECOND} received", - "derived_unit": DATA_RATE_KIBIBYTES_PER_SECOND, - "derived_unique_id": "KiB/sec_received", - }, - BYTES_SENT: { - "device_value_key": BYTES_SENT, - "name": f"{DATA_BYTES} sent", - "unit": DATA_BYTES, - "unique_id": BYTES_SENT, - "derived_name": f"{DATA_RATE_KIBIBYTES_PER_SECOND} sent", - "derived_unit": DATA_RATE_KIBIBYTES_PER_SECOND, - "derived_unique_id": "KiB/sec_sent", - }, - PACKETS_RECEIVED: { - "device_value_key": PACKETS_RECEIVED, - "name": f"{DATA_PACKETS} received", - "unit": DATA_PACKETS, - "unique_id": PACKETS_RECEIVED, - "derived_name": f"{DATA_RATE_PACKETS_PER_SECOND} received", - "derived_unit": DATA_RATE_PACKETS_PER_SECOND, - "derived_unique_id": "packets/sec_received", - }, - PACKETS_SENT: { - "device_value_key": PACKETS_SENT, - "name": f"{DATA_PACKETS} sent", - "unit": DATA_PACKETS, - "unique_id": PACKETS_SENT, - "derived_name": f"{DATA_RATE_PACKETS_PER_SECOND} sent", - "derived_unit": DATA_RATE_PACKETS_PER_SECOND, - "derived_unique_id": "packets/sec_sent", - }, +SENSOR_ENTITY_DESCRIPTIONS: dict[str, tuple[UpnpSensorEntityDescription, ...]] = { + RAW_SENSOR: ( + UpnpSensorEntityDescription( + key=BYTES_RECEIVED, + name=f"{DATA_BYTES} received", + icon="mdi:server-network", + native_unit_of_measurement=DATA_BYTES, + format="d", + ), + UpnpSensorEntityDescription( + key=BYTES_SENT, + name=f"{DATA_BYTES} sent", + icon="mdi:server-network", + native_unit_of_measurement=DATA_BYTES, + format="d", + ), + UpnpSensorEntityDescription( + key=PACKETS_RECEIVED, + name=f"{DATA_PACKETS} received", + icon="mdi:server-network", + native_unit_of_measurement=DATA_PACKETS, + format="d", + ), + UpnpSensorEntityDescription( + key=PACKETS_SENT, + name=f"{DATA_PACKETS} sent", + icon="mdi:server-network", + native_unit_of_measurement=DATA_PACKETS, + format="d", + ), + UpnpSensorEntityDescription( + key=ROUTER_IP, + name="External IP", + icon="mdi:server-network", + ), + UpnpSensorEntityDescription( + key=ROUTER_UPTIME, + name="Uptime", + icon="mdi:server-network", + native_unit_of_measurement=TIME_SECONDS, + entity_registry_enabled_default=False, + format="d", + ), + UpnpSensorEntityDescription( + key=WAN_STATUS, + name="wan status", + icon="mdi:server-network", + ), + ), + DERIVED_SENSOR: ( + UpnpSensorEntityDescription( + key="KiB/sec_received", + name=f"{DATA_RATE_KIBIBYTES_PER_SECOND} received", + icon="mdi:server-network", + native_unit_of_measurement=DATA_RATE_KIBIBYTES_PER_SECOND, + format=".1f", + ), + UpnpSensorEntityDescription( + key="KiB/sent", + name=f"{DATA_RATE_KIBIBYTES_PER_SECOND} sent", + icon="mdi:server-network", + native_unit_of_measurement=DATA_RATE_KIBIBYTES_PER_SECOND, + format=".1f", + ), + UpnpSensorEntityDescription( + key="packets/sec_received", + name=f"{DATA_RATE_PACKETS_PER_SECOND} received", + icon="mdi:server-network", + native_unit_of_measurement=DATA_RATE_PACKETS_PER_SECOND, + format=".1f", + ), + UpnpSensorEntityDescription( + key="packets/sent", + name=f"{DATA_RATE_PACKETS_PER_SECOND} sent", + icon="mdi:server-network", + native_unit_of_measurement=DATA_RATE_PACKETS_PER_SECOND, + format=".1f", + ), + ), } -async def async_setup_platform( - hass: HomeAssistant, config, async_add_entities, discovery_info=None -) -> None: - """Old way of setting up UPnP/IGD sensors.""" - LOGGER.debug( - "async_setup_platform: config: %s, discovery: %s", config, discovery_info - ) - - async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -80,50 +118,31 @@ async def async_setup_entry( LOGGER.debug("Adding sensors") - sensors = [ - RawUpnpSensor(coordinator, SENSOR_TYPES[BYTES_RECEIVED]), - RawUpnpSensor(coordinator, SENSOR_TYPES[BYTES_SENT]), - RawUpnpSensor(coordinator, SENSOR_TYPES[PACKETS_RECEIVED]), - RawUpnpSensor(coordinator, SENSOR_TYPES[PACKETS_SENT]), - DerivedUpnpSensor(coordinator, SENSOR_TYPES[BYTES_RECEIVED]), - DerivedUpnpSensor(coordinator, SENSOR_TYPES[BYTES_SENT]), - DerivedUpnpSensor(coordinator, SENSOR_TYPES[PACKETS_RECEIVED]), - DerivedUpnpSensor(coordinator, SENSOR_TYPES[PACKETS_SENT]), - ] - async_add_entities(sensors) + entities = [] + entities.append( + RawUpnpSensor( + coordinator=coordinator, + entity_description=entity_description, + ) + for entity_description in SENSOR_ENTITY_DESCRIPTIONS[RAW_SENSOR] + if coordinator.data.get(entity_description.key) is not None + ) + + entities.append( + DerivedUpnpSensor( + coordinator=coordinator, + entity_description=entity_description, + ) + for entity_description in SENSOR_ENTITY_DESCRIPTIONS[DERIVED_SENSOR] + if coordinator.data.get(entity_description.key) is not None + ) + + async_add_entities(entities) class UpnpSensor(UpnpEntity, SensorEntity): """Base class for UPnP/IGD sensors.""" - def __init__( - self, - coordinator: UpnpDataUpdateCoordinator, - sensor_type: dict[str, str], - ) -> None: - """Initialize the base sensor.""" - super().__init__(coordinator) - self._sensor_type = sensor_type - self._attr_name = f"{coordinator.device.name} {sensor_type['name']}" - self._attr_unique_id = f"{coordinator.device.udn}_{sensor_type['unique_id']}" - - @property - def icon(self) -> str: - """Icon to use in the frontend, if any.""" - return "mdi:server-network" - - @property - def available(self) -> bool: - """Return if entity is available.""" - return super().available and self.coordinator.data.get( - self._sensor_type["device_value_key"] - ) - - @property - def native_unit_of_measurement(self) -> str: - """Return the unit of measurement of this entity, if any.""" - return self._sensor_type["unit"] - class RawUpnpSensor(UpnpSensor): """Representation of a UPnP/IGD sensor.""" @@ -131,30 +150,26 @@ class RawUpnpSensor(UpnpSensor): @property def native_value(self) -> str | None: """Return the state of the device.""" - device_value_key = self._sensor_type["device_value_key"] - value = self.coordinator.data[device_value_key] + value = self.coordinator.data[self.entity_description.key] if value is None: return None - return format(value, "d") + return format(value, self.entity_description.format) class DerivedUpnpSensor(UpnpSensor): """Representation of a UNIT Sent/Received per second sensor.""" - def __init__(self, coordinator: UpnpDataUpdateCoordinator, sensor_type) -> None: + entity_description: UpnpSensorEntityDescription + + def __init__( + self, + coordinator: UpnpDataUpdateCoordinator, + entity_description: UpnpSensorEntityDescription, + ) -> None: """Initialize sensor.""" - super().__init__(coordinator, sensor_type) + super().__init__(coordinator=coordinator, entity_description=entity_description) self._last_value = None self._last_timestamp = None - self._attr_name = f"{coordinator.device.name} {sensor_type['derived_name']}" - self._attr_unique_id = ( - f"{coordinator.device.udn}_{sensor_type['derived_unique_id']}" - ) - - @property - def native_unit_of_measurement(self) -> str: - """Return the unit of measurement of this entity, if any.""" - return self._sensor_type["derived_unit"] def _has_overflowed(self, current_value) -> bool: """Check if value has overflowed.""" @@ -164,8 +179,7 @@ class DerivedUpnpSensor(UpnpSensor): def native_value(self) -> str | None: """Return the state of the device.""" # Can't calculate any derivative if we have only one value. - device_value_key = self._sensor_type["device_value_key"] - current_value = self.coordinator.data[device_value_key] + current_value = self.coordinator.data[self.entity_description.key] if current_value is None: return None current_timestamp = self.coordinator.data[TIMESTAMP] @@ -176,7 +190,7 @@ class DerivedUpnpSensor(UpnpSensor): # Calculate derivative. delta_value = current_value - self._last_value - if self._sensor_type["unit"] == DATA_BYTES: + if self.entity_description.native_unit_of_measurement == DATA_BYTES: delta_value /= KIBIBYTE delta_time = current_timestamp - self._last_timestamp if delta_time.total_seconds() == 0: @@ -188,4 +202,4 @@ class DerivedUpnpSensor(UpnpSensor): self._last_value = current_value self._last_timestamp = current_timestamp - return format(derived, ".1f") + return format(derived, self.entity_description.format) diff --git a/tests/components/upnp/mock_upnp_device.py b/tests/components/upnp/mock_upnp_device.py index 42c9291f30f..230fd480cb1 100644 --- a/tests/components/upnp/mock_upnp_device.py +++ b/tests/components/upnp/mock_upnp_device.py @@ -10,10 +10,10 @@ from homeassistant.components.upnp.const import ( BYTES_SENT, PACKETS_RECEIVED, PACKETS_SENT, + ROUTER_IP, + ROUTER_UPTIME, TIMESTAMP, - UPTIME, - WANIP, - WANSTATUS, + WAN_STATUS, ) from homeassistant.components.upnp.device import Device from homeassistant.util import dt @@ -83,9 +83,9 @@ class MockDevice(Device): """Get connection status, uptime, and external IP.""" self.status_times_polled += 1 return { - WANSTATUS: "Connected", - UPTIME: 0, - WANIP: "192.168.0.1", + WAN_STATUS: "Connected", + ROUTER_UPTIME: 0, + ROUTER_IP: "192.168.0.1", } async def async_start(self) -> None: From 217192226548a39f745ab255040ed659f2bae17b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Fri, 3 Sep 2021 17:40:07 +0200 Subject: [PATCH 181/843] Always show state for the updater binary_sensor (#55584) --- homeassistant/components/updater/binary_sensor.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/updater/binary_sensor.py b/homeassistant/components/updater/binary_sensor.py index 25339f6308a..10090946f6e 100644 --- a/homeassistant/components/updater/binary_sensor.py +++ b/homeassistant/components/updater/binary_sensor.py @@ -26,11 +26,14 @@ class UpdaterBinary(CoordinatorEntity, BinarySensorEntity): _attr_unique_id = "updater" @property - def is_on(self) -> bool | None: - """Return true if the binary sensor is on.""" - if not self.coordinator.data: - return None - return self.coordinator.data.update_available + def available(self) -> bool: + """Return if entity is available.""" + return True + + @property + def is_on(self) -> bool: + """Return true if there is an update available.""" + return self.coordinator.data and self.coordinator.data.update_available @property def extra_state_attributes(self) -> dict | None: From 7461af68b983e5f376ae5e2cc7026d338bd62e58 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 3 Sep 2021 17:41:32 +0200 Subject: [PATCH 182/843] Use NamedTuple for color temperature range (#55626) --- homeassistant/components/tplink/light.py | 38 ++++++++++++++---------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/tplink/light.py b/homeassistant/components/tplink/light.py index 6d497812261..17e2b03b790 100644 --- a/homeassistant/components/tplink/light.py +++ b/homeassistant/components/tplink/light.py @@ -66,16 +66,24 @@ LIGHT_SYSINFO_IS_COLOR = "is_color" MAX_ATTEMPTS = 300 SLEEP_TIME = 2 -TPLINK_KELVIN = { - "LB130": (2500, 9000), - "LB120": (2700, 6500), - "LB230": (2500, 9000), - "KB130": (2500, 9000), - "KL130": (2500, 9000), - "KL125": (2500, 6500), - r"KL120\(EU\)": (2700, 6500), - r"KL120\(US\)": (2700, 5000), - r"KL430\(US\)": (2500, 9000), + +class ColorTempRange(NamedTuple): + """Color temperature range (in Kelvin).""" + + min: int + max: int + + +TPLINK_KELVIN: dict[str, ColorTempRange] = { + "LB130": ColorTempRange(2500, 9000), + "LB120": ColorTempRange(2700, 6500), + "LB230": ColorTempRange(2500, 9000), + "KB130": ColorTempRange(2500, 9000), + "KL130": ColorTempRange(2500, 9000), + "KL125": ColorTempRange(2500, 6500), + r"KL120\(EU\)": ColorTempRange(2700, 6500), + r"KL120\(US\)": ColorTempRange(2700, 5000), + r"KL430\(US\)": ColorTempRange(2500, 9000), } FALLBACK_MIN_COLOR = 2700 @@ -294,7 +302,7 @@ class TPLinkSmartBulb(LightEntity): """Flag supported features.""" return self._light_features.supported_features - def _get_valid_temperature_range(self) -> tuple[int, int]: + def _get_valid_temperature_range(self) -> ColorTempRange: """Return the device-specific white temperature range (in Kelvin). :return: White temperature range in Kelvin (minimum, maximum) @@ -305,7 +313,7 @@ class TPLinkSmartBulb(LightEntity): return temp_range # pyHS100 is abandoned, but some bulb definitions aren't present # use "safe" values for something that advertises color temperature - return FALLBACK_MIN_COLOR, FALLBACK_MAX_COLOR + return ColorTempRange(FALLBACK_MIN_COLOR, FALLBACK_MAX_COLOR) def _get_light_features(self) -> LightFeatures: """Determine all supported features in one go.""" @@ -323,9 +331,9 @@ class TPLinkSmartBulb(LightEntity): supported_features += SUPPORT_BRIGHTNESS if sysinfo.get(LIGHT_SYSINFO_IS_VARIABLE_COLOR_TEMP): supported_features += SUPPORT_COLOR_TEMP - max_range, min_range = self._get_valid_temperature_range() - min_mireds = kelvin_to_mired(min_range) - max_mireds = kelvin_to_mired(max_range) + color_temp_range = self._get_valid_temperature_range() + min_mireds = kelvin_to_mired(color_temp_range.max) + max_mireds = kelvin_to_mired(color_temp_range.min) if sysinfo.get(LIGHT_SYSINFO_IS_COLOR): supported_features += SUPPORT_COLOR From a234f2ab31dfabe42c03d02ff7cc84ca46a130ae Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 3 Sep 2021 17:48:48 +0200 Subject: [PATCH 183/843] Remove dead fritzbox code (#55617) * EntityInfo has been replaced by EntityDescription (#55104) * Extra switch attributes have been replaced by dedicated sensors (#52562) --- homeassistant/components/fritzbox/const.py | 2 -- homeassistant/components/fritzbox/model.py | 21 --------------------- 2 files changed, 23 deletions(-) diff --git a/homeassistant/components/fritzbox/const.py b/homeassistant/components/fritzbox/const.py index 6af75449a29..67e7c9dc564 100644 --- a/homeassistant/components/fritzbox/const.py +++ b/homeassistant/components/fritzbox/const.py @@ -11,8 +11,6 @@ ATTR_STATE_LOCKED: Final = "locked" ATTR_STATE_SUMMER_MODE: Final = "summer_mode" ATTR_STATE_WINDOW_OPEN: Final = "window_open" -ATTR_TEMPERATURE_UNIT: Final = "temperature_unit" - CONF_CONNECTIONS: Final = "connections" CONF_COORDINATOR: Final = "coordinator" diff --git a/homeassistant/components/fritzbox/model.py b/homeassistant/components/fritzbox/model.py index 69aefb8071c..baa8f656c02 100644 --- a/homeassistant/components/fritzbox/model.py +++ b/homeassistant/components/fritzbox/model.py @@ -7,16 +7,6 @@ from typing import Callable, TypedDict from pyfritzhome import FritzhomeDevice -class EntityInfo(TypedDict): - """TypedDict for EntityInfo.""" - - name: str - entity_id: str - unit_of_measurement: str | None - device_class: str | None - state_class: str | None - - class FritzExtraAttributes(TypedDict): """TypedDict for sensors extra attributes.""" @@ -34,17 +24,6 @@ class ClimateExtraAttributes(FritzExtraAttributes, total=False): window_open: bool -class SwitchExtraAttributes(TypedDict, total=False): - """TypedDict for sensors extra attributes.""" - - device_locked: bool - locked: bool - total_consumption: str - total_consumption_unit: str - temperature: str - temperature_unit: str - - @dataclass class FritzEntityDescriptionMixinBase: """Bases description mixin for Fritz!Smarthome entities.""" From 418d6a6a416891ec62492fa3f3c48a646d464321 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 3 Sep 2021 09:04:50 -0700 Subject: [PATCH 184/843] Guard for unexpected exceptions in device automation (#55639) * Guard for unexpected exceptions in device automation * merge Co-authored-by: J. Nick Koston --- .../components/device_automation/__init__.py | 9 ++++++- .../components/device_automation/test_init.py | 24 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/device_automation/__init__.py b/homeassistant/components/device_automation/__init__.py index 945774da0b4..89a3f8f6408 100644 --- a/homeassistant/components/device_automation/__init__.py +++ b/homeassistant/components/device_automation/__init__.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio from collections.abc import Iterable, Mapping from functools import wraps +import logging from types import ModuleType from typing import Any @@ -27,7 +28,6 @@ from .exceptions import DeviceNotFound, InvalidDeviceAutomationConfig DOMAIN = "device_automation" - DEVICE_TRIGGER_BASE_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend( { vol.Required(CONF_PLATFORM): "device", @@ -174,6 +174,13 @@ async def _async_get_device_automations( device_results, InvalidDeviceAutomationConfig ): continue + if isinstance(device_results, Exception): + logging.getLogger(__name__).error( + "Unexpected error fetching device %ss", + automation_type, + exc_info=device_results, + ) + continue for automation in device_results: combined_results[automation["device_id"]].append(automation) diff --git a/tests/components/device_automation/test_init.py b/tests/components/device_automation/test_init.py index 13190ed4b32..93d64e97959 100644 --- a/tests/components/device_automation/test_init.py +++ b/tests/components/device_automation/test_init.py @@ -1,4 +1,6 @@ """The test for light device automation.""" +from unittest.mock import patch + import pytest from homeassistant.components import device_automation @@ -443,6 +445,28 @@ async def test_async_get_device_automations_all_devices_action( assert len(result[device_entry.id]) == 3 +async def test_async_get_device_automations_all_devices_action_exception_throw( + hass, device_reg, entity_reg, caplog +): + """Test we get can fetch all the actions when no device id is passed and can handle one throwing an exception.""" + await async_setup_component(hass, "device_automation", {}) + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_reg.async_get_or_create("light", "test", "5678", device_id=device_entry.id) + with patch( + "homeassistant.components.light.device_trigger.async_get_triggers", + side_effect=KeyError, + ): + result = await device_automation.async_get_device_automations(hass, "trigger") + assert device_entry.id in result + assert len(result[device_entry.id]) == 0 + assert "KeyError" in caplog.text + + async def test_websocket_get_trigger_capabilities( hass, hass_ws_client, device_reg, entity_reg ): From 25b39b36e7b5e65de481e304767376bb8dc461e3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 3 Sep 2021 06:06:07 -1000 Subject: [PATCH 185/843] Ignore missing devices when in ssdp unsee (#55553) --- homeassistant/components/ssdp/__init__.py | 2 +- tests/components/ssdp/test_init.py | 113 +++++++++++++++++++++- 2 files changed, 111 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index 6e9441534ab..63ad6acb181 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -289,7 +289,7 @@ class Scanner: def _async_unsee(self, header_st: str | None, header_location: str | None) -> None: """If we see a device in a new location, unsee the original location.""" if header_st is not None: - self.seen.remove((header_st, header_location)) + self.seen.discard((header_st, header_location)) async def _async_process_entry(self, headers: Mapping[str, str]) -> None: """Process SSDP entries.""" diff --git a/tests/components/ssdp/test_init.py b/tests/components/ssdp/test_init.py index c50d71ed4c7..f176ccff0f2 100644 --- a/tests/components/ssdp/test_init.py +++ b/tests/components/ssdp/test_init.py @@ -1003,9 +1003,6 @@ async def test_location_change_evicts_prior_location_from_cache( @callback def _callback(*_): - import pprint - - pprint.pprint(mock_ssdp_response) hass.async_create_task(listener.async_callback(mock_ssdp_response)) listener.async_start = _async_callback @@ -1062,3 +1059,113 @@ async def test_location_change_evicts_prior_location_from_cache( mock_init.mock_calls[0][2]["data"][ssdp.ATTR_SSDP_LOCATION] == mock_good_ip_ssdp_response["location"] ) + + +async def test_location_change_with_overlapping_udn_st_combinations( + hass, aioclient_mock +): + """Test handling when a UDN and ST broadcast multiple locations.""" + mock_get_ssdp = { + "test_integration": [ + {"manufacturer": "test_manufacturer", "modelName": "test_model"} + ] + } + + hue_response = """ + + +test_manufacturer +test_model + + + """ + + aioclient_mock.get( + "http://192.168.72.1:49154/wps_device.xml", + text=hue_response.format(ip_address="192.168.72.1"), + ) + aioclient_mock.get( + "http://192.168.72.1:49152/wps_device.xml", + text=hue_response.format(ip_address="192.168.72.1"), + ) + ssdp_response_without_location = { + "ST": "upnp:rootdevice", + "_udn": "uuid:a793d3cc-e802-44fb-84f4-5a30f33115b6", + "USN": "uuid:a793d3cc-e802-44fb-84f4-5a30f33115b6::upnp:rootdevice", + "EXT": "", + } + + port_49154_response = CaseInsensitiveDict( + **ssdp_response_without_location, + **{"LOCATION": "http://192.168.72.1:49154/wps_device.xml"}, + ) + port_49152_response = CaseInsensitiveDict( + **ssdp_response_without_location, + **{"LOCATION": "http://192.168.72.1:49152/wps_device.xml"}, + ) + mock_ssdp_response = port_49154_response + + def _generate_fake_ssdp_listener(*args, **kwargs): + listener = SSDPListener(*args, **kwargs) + + async def _async_callback(*_): + pass + + @callback + def _callback(*_): + hass.async_create_task(listener.async_callback(mock_ssdp_response)) + + listener.async_start = _async_callback + listener.async_search = _callback + return listener + + with patch( + "homeassistant.components.ssdp.async_get_ssdp", + return_value=mock_get_ssdp, + ), patch( + "homeassistant.components.ssdp.SSDPListener", + new=_generate_fake_ssdp_listener, + ), patch.object( + hass.config_entries.flow, "async_init" + ) as mock_init: + assert await async_setup_component(hass, ssdp.DOMAIN, {ssdp.DOMAIN: {}}) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) + await hass.async_block_till_done() + assert len(mock_init.mock_calls) == 1 + assert mock_init.mock_calls[0][1][0] == "test_integration" + assert mock_init.mock_calls[0][2]["context"] == { + "source": config_entries.SOURCE_SSDP + } + assert ( + mock_init.mock_calls[0][2]["data"][ssdp.ATTR_SSDP_LOCATION] + == port_49154_response["location"] + ) + + mock_init.reset_mock() + mock_ssdp_response = port_49152_response + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=400)) + await hass.async_block_till_done() + assert mock_init.mock_calls[0][1][0] == "test_integration" + assert mock_init.mock_calls[0][2]["context"] == { + "source": config_entries.SOURCE_SSDP + } + assert ( + mock_init.mock_calls[0][2]["data"][ssdp.ATTR_SSDP_LOCATION] + == port_49152_response["location"] + ) + + mock_init.reset_mock() + mock_ssdp_response = port_49154_response + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=600)) + await hass.async_block_till_done() + assert mock_init.mock_calls[0][1][0] == "test_integration" + assert mock_init.mock_calls[0][2]["context"] == { + "source": config_entries.SOURCE_SSDP + } + assert ( + mock_init.mock_calls[0][2]["data"][ssdp.ATTR_SSDP_LOCATION] + == port_49154_response["location"] + ) From 7caa985a59999609024ded14d3d2d140eca94536 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Fri, 3 Sep 2021 18:17:41 +0200 Subject: [PATCH 186/843] Fix hdmi_cec switches (#55666) --- homeassistant/components/hdmi_cec/switch.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hdmi_cec/switch.py b/homeassistant/components/hdmi_cec/switch.py index 3764766275e..a268d7cfe79 100644 --- a/homeassistant/components/hdmi_cec/switch.py +++ b/homeassistant/components/hdmi_cec/switch.py @@ -4,6 +4,7 @@ from __future__ import annotations import logging from homeassistant.components.switch import DOMAIN, SwitchEntity +from homeassistant.const import STATE_OFF, STATE_ON from . import ATTR_NEW, CecEntity @@ -34,17 +35,25 @@ class CecSwitchEntity(CecEntity, SwitchEntity): def turn_on(self, **kwargs) -> None: """Turn device on.""" self._device.turn_on() - self._attr_is_on = True + self._state = STATE_ON self.schedule_update_ha_state(force_refresh=False) def turn_off(self, **kwargs) -> None: """Turn device off.""" self._device.turn_off() - self._attr_is_on = False + self._state = STATE_OFF self.schedule_update_ha_state(force_refresh=False) def toggle(self, **kwargs): """Toggle the entity.""" self._device.toggle() - self._attr_is_on = not self._attr_is_on + if self._state == STATE_ON: + self._state = STATE_OFF + else: + self._state = STATE_ON self.schedule_update_ha_state(force_refresh=False) + + @property + def is_on(self) -> bool: + """Return True if entity is on.""" + return self._state == STATE_ON From e0f640c0f840e91a48acba0c94a7ad6881d33bed Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 3 Sep 2021 09:53:47 -0700 Subject: [PATCH 187/843] Guard for doRollover failing (#55669) --- homeassistant/bootstrap.py | 6 +++++- tests/test_bootstrap.py | 9 +++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index f1136123999..66312f7283a 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -342,7 +342,11 @@ def async_enable_logging( err_log_path, backupCount=1 ) - err_handler.doRollover() + try: + err_handler.doRollover() + except OSError as err: + _LOGGER.error("Error rolling over log file: %s", err) + err_handler.setLevel(logging.INFO if verbose else logging.WARNING) err_handler.setFormatter(logging.Formatter(fmt, datefmt=datefmt)) diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 929cbbf6e81..3eeb06d056c 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -56,11 +56,14 @@ async def test_home_assistant_core_config_validation(hass): assert result is None -async def test_async_enable_logging(hass): +async def test_async_enable_logging(hass, caplog): """Test to ensure logging is migrated to the queue handlers.""" with patch("logging.getLogger"), patch( "homeassistant.bootstrap.async_activate_log_queue_handler" - ) as mock_async_activate_log_queue_handler: + ) as mock_async_activate_log_queue_handler, patch( + "homeassistant.bootstrap.logging.handlers.RotatingFileHandler.doRollover", + side_effect=OSError, + ): bootstrap.async_enable_logging(hass) mock_async_activate_log_queue_handler.assert_called_once() mock_async_activate_log_queue_handler.reset_mock() @@ -75,6 +78,8 @@ async def test_async_enable_logging(hass): for f in glob.glob("testing_config/home-assistant.log*"): os.remove(f) + assert "Error rolling over log file" in caplog.text + async def test_load_hassio(hass): """Test that we load Hass.io component.""" From 7111fc47c4dcb44bc360989ab5708542faf736d7 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 3 Sep 2021 10:15:57 -0700 Subject: [PATCH 188/843] Better handle invalid trigger config (#55637) --- .../components/device_automation/trigger.py | 11 +++++--- .../components/hue/device_trigger.py | 16 +++++++----- homeassistant/scripts/check_config.py | 4 +++ tests/scripts/test_check_config.py | 25 +++++++++++-------- 4 files changed, 37 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/device_automation/trigger.py b/homeassistant/components/device_automation/trigger.py index a1b6e53c5c3..1a63dcb9e9b 100644 --- a/homeassistant/components/device_automation/trigger.py +++ b/homeassistant/components/device_automation/trigger.py @@ -7,6 +7,8 @@ from homeassistant.components.device_automation import ( ) from homeassistant.const import CONF_DOMAIN +from .exceptions import InvalidDeviceAutomationConfig + # mypy: allow-untyped-defs, no-check-untyped-defs TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA) @@ -17,10 +19,13 @@ async def async_validate_trigger_config(hass, config): platform = await async_get_device_automation_platform( hass, config[CONF_DOMAIN], "trigger" ) - if hasattr(platform, "async_validate_trigger_config"): - return await getattr(platform, "async_validate_trigger_config")(hass, config) + if not hasattr(platform, "async_validate_trigger_config"): + return platform.TRIGGER_SCHEMA(config) - return platform.TRIGGER_SCHEMA(config) + try: + return await getattr(platform, "async_validate_trigger_config")(hass, config) + except InvalidDeviceAutomationConfig as err: + raise vol.Invalid(str(err) or "Invalid trigger configuration") from err async def async_attach_trigger(hass, config, action, automation_info): diff --git a/homeassistant/components/hue/device_trigger.py b/homeassistant/components/hue/device_trigger.py index ea91cd07d8c..77561e47dc5 100644 --- a/homeassistant/components/hue/device_trigger.py +++ b/homeassistant/components/hue/device_trigger.py @@ -118,12 +118,16 @@ async def async_validate_trigger_config(hass, config): trigger = (config[CONF_TYPE], config[CONF_SUBTYPE]) - if ( - not device - or device.model not in REMOTES - or trigger not in REMOTES[device.model] - ): - raise InvalidDeviceAutomationConfig + if not device: + raise InvalidDeviceAutomationConfig("Device {config[CONF_DEVICE_ID]} not found") + + if device.model not in REMOTES: + raise InvalidDeviceAutomationConfig( + f"Device model {device.model} is not a remote" + ) + + if trigger not in REMOTES[device.model]: + raise InvalidDeviceAutomationConfig("Device does not support trigger {trigger}") return config diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index 551f91b2b54..0ff339169a7 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -14,6 +14,7 @@ from unittest.mock import patch from homeassistant import core from homeassistant.config import get_default_config_dir from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import area_registry, device_registry, entity_registry from homeassistant.helpers.check_config import async_check_ha_config_file from homeassistant.util.yaml import Secrets import homeassistant.util.yaml.loader as yaml_loader @@ -229,6 +230,9 @@ async def async_check_config(config_dir): """Check the HA config.""" hass = core.HomeAssistant() hass.config.config_dir = config_dir + await area_registry.async_load(hass) + await device_registry.async_load(hass) + await entity_registry.async_load(hass) components = await async_check_ha_config_file(hass) await hass.async_stop(force=True) return components diff --git a/tests/scripts/test_check_config.py b/tests/scripts/test_check_config.py index ea6048dfc9e..1a96568f8ef 100644 --- a/tests/scripts/test_check_config.py +++ b/tests/scripts/test_check_config.py @@ -27,14 +27,23 @@ async def apply_stop_hass(stop_hass): """Make sure all hass are stopped.""" +@pytest.fixture +def mock_is_file(): + """Mock is_file.""" + # All files exist except for the old entity registry file + with patch( + "os.path.isfile", lambda path: not path.endswith("entity_registry.yaml") + ): + yield + + def normalize_yaml_files(check_dict): """Remove configuration path from ['yaml_files'].""" root = get_test_config_dir() return [key.replace(root, "...") for key in sorted(check_dict["yaml_files"].keys())] -@patch("os.path.isfile", return_value=True) -def test_bad_core_config(isfile_patch, loop): +def test_bad_core_config(mock_is_file, loop): """Test a bad core config setup.""" files = {YAML_CONFIG_FILE: BAD_CORE_CONFIG} with patch_yaml_files(files): @@ -43,8 +52,7 @@ def test_bad_core_config(isfile_patch, loop): assert res["except"]["homeassistant"][1] == {"unit_system": "bad"} -@patch("os.path.isfile", return_value=True) -def test_config_platform_valid(isfile_patch, loop): +def test_config_platform_valid(mock_is_file, loop): """Test a valid platform setup.""" files = {YAML_CONFIG_FILE: BASE_CONFIG + "light:\n platform: demo"} with patch_yaml_files(files): @@ -57,8 +65,7 @@ def test_config_platform_valid(isfile_patch, loop): assert len(res["yaml_files"]) == 1 -@patch("os.path.isfile", return_value=True) -def test_component_platform_not_found(isfile_patch, loop): +def test_component_platform_not_found(mock_is_file, loop): """Test errors if component or platform not found.""" # Make sure they don't exist files = {YAML_CONFIG_FILE: BASE_CONFIG + "beer:"} @@ -89,8 +96,7 @@ def test_component_platform_not_found(isfile_patch, loop): assert len(res["yaml_files"]) == 1 -@patch("os.path.isfile", return_value=True) -def test_secrets(isfile_patch, loop): +def test_secrets(mock_is_file, loop): """Test secrets config checking method.""" secrets_path = get_test_config_dir("secrets.yaml") @@ -121,8 +127,7 @@ def test_secrets(isfile_patch, loop): ] -@patch("os.path.isfile", return_value=True) -def test_package_invalid(isfile_patch, loop): +def test_package_invalid(mock_is_file, loop): """Test an invalid package.""" files = { YAML_CONFIG_FILE: BASE_CONFIG + (" packages:\n p1:\n" ' group: ["a"]') From fbf812a845c3074a8d5b409a854912c3322749fc Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 3 Sep 2021 22:33:26 +0200 Subject: [PATCH 189/843] Use EntityDescription - freebox (#55675) * Use EntityDescription - freebox * Remove default values --- homeassistant/components/freebox/const.py | 82 +++++------- homeassistant/components/freebox/router.py | 4 +- homeassistant/components/freebox/sensor.py | 142 +++++++-------------- 3 files changed, 84 insertions(+), 144 deletions(-) diff --git a/homeassistant/components/freebox/const.py b/homeassistant/components/freebox/const.py index df251dcf954..7183bd029ff 100644 --- a/homeassistant/components/freebox/const.py +++ b/homeassistant/components/freebox/const.py @@ -1,12 +1,10 @@ """Freebox component constants.""" +from __future__ import annotations + import socket -from homeassistant.const import ( - DATA_RATE_KILOBYTES_PER_SECOND, - DEVICE_CLASS_TEMPERATURE, - PERCENTAGE, - TEMP_CELSIUS, -) +from homeassistant.components.sensor import SensorEntityDescription +from homeassistant.const import DATA_RATE_KILOBYTES_PER_SECOND, PERCENTAGE DOMAIN = "freebox" SERVICE_REBOOT = "reboot" @@ -27,51 +25,39 @@ DEFAULT_DEVICE_NAME = "Unknown device" STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 -# Sensor -SENSOR_NAME = "name" -SENSOR_UNIT = "unit" -SENSOR_ICON = "icon" -SENSOR_DEVICE_CLASS = "device_class" -CONNECTION_SENSORS = { - "rate_down": { - SENSOR_NAME: "Freebox download speed", - SENSOR_UNIT: DATA_RATE_KILOBYTES_PER_SECOND, - SENSOR_ICON: "mdi:download-network", - SENSOR_DEVICE_CLASS: None, - }, - "rate_up": { - SENSOR_NAME: "Freebox upload speed", - SENSOR_UNIT: DATA_RATE_KILOBYTES_PER_SECOND, - SENSOR_ICON: "mdi:upload-network", - SENSOR_DEVICE_CLASS: None, - }, -} +CONNECTION_SENSORS: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="rate_down", + name="Freebox download speed", + native_unit_of_measurement=DATA_RATE_KILOBYTES_PER_SECOND, + icon="mdi:download-network", + ), + SensorEntityDescription( + key="rate_up", + name="Freebox upload speed", + native_unit_of_measurement=DATA_RATE_KILOBYTES_PER_SECOND, + icon="mdi:upload-network", + ), +) +CONNECTION_SENSORS_KEYS: list[str] = [desc.key for desc in CONNECTION_SENSORS] -CALL_SENSORS = { - "missed": { - SENSOR_NAME: "Freebox missed calls", - SENSOR_UNIT: None, - SENSOR_ICON: "mdi:phone-missed", - SENSOR_DEVICE_CLASS: None, - }, -} +CALL_SENSORS: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="missed", + name="Freebox missed calls", + icon="mdi:phone-missed", + ), +) -DISK_PARTITION_SENSORS = { - "partition_free_space": { - SENSOR_NAME: "free space", - SENSOR_UNIT: PERCENTAGE, - SENSOR_ICON: "mdi:harddisk", - SENSOR_DEVICE_CLASS: None, - }, -} - -TEMPERATURE_SENSOR_TEMPLATE = { - SENSOR_NAME: None, - SENSOR_UNIT: TEMP_CELSIUS, - SENSOR_ICON: "mdi:thermometer", - SENSOR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, -} +DISK_PARTITION_SENSORS: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="partition_free_space", + name="free space", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:harddisk", + ), +) # Icons DEVICE_ICONS = { diff --git a/homeassistant/components/freebox/router.py b/homeassistant/components/freebox/router.py index 9438b3eadc6..0673d550d76 100644 --- a/homeassistant/components/freebox/router.py +++ b/homeassistant/components/freebox/router.py @@ -24,7 +24,7 @@ from homeassistant.util import slugify from .const import ( API_VERSION, APP_DESC, - CONNECTION_SENSORS, + CONNECTION_SENSORS_KEYS, DOMAIN, STORAGE_KEY, STORAGE_VERSION, @@ -141,7 +141,7 @@ class FreeboxRouter: # Connection sensors connection_datas: dict[str, Any] = await self._api.connection.get_status() - for sensor_key in CONNECTION_SENSORS: + for sensor_key in CONNECTION_SENSORS_KEYS: self.sensors_connection[sensor_key] = connection_datas[sensor_key] self._attrs = { diff --git a/homeassistant/components/freebox/sensor.py b/homeassistant/components/freebox/sensor.py index 939c53b47db..654a73b786c 100644 --- a/homeassistant/components/freebox/sensor.py +++ b/homeassistant/components/freebox/sensor.py @@ -4,25 +4,19 @@ from __future__ import annotations import logging from typing import Any -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry -from homeassistant.const import DATA_RATE_KILOBYTES_PER_SECOND +from homeassistant.const import ( + DATA_RATE_KILOBYTES_PER_SECOND, + DEVICE_CLASS_TEMPERATURE, + TEMP_CELSIUS, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo import homeassistant.util.dt as dt_util -from .const import ( - CALL_SENSORS, - CONNECTION_SENSORS, - DISK_PARTITION_SENSORS, - DOMAIN, - SENSOR_DEVICE_CLASS, - SENSOR_ICON, - SENSOR_NAME, - SENSOR_UNIT, - TEMPERATURE_SENSOR_TEMPLATE, -) +from .const import CALL_SENSORS, CONNECTION_SENSORS, DISK_PARTITION_SENSORS, DOMAIN from .router import FreeboxRouter _LOGGER = logging.getLogger(__name__) @@ -41,34 +35,33 @@ async def async_setup_entry( router.mac, len(router.sensors_temperature), ) - for sensor_name in router.sensors_temperature: - entities.append( - FreeboxSensor( - router, - sensor_name, - {**TEMPERATURE_SENSOR_TEMPLATE, SENSOR_NAME: f"Freebox {sensor_name}"}, - ) + entities = [ + FreeboxSensor( + router, + SensorEntityDescription( + key=sensor_name, + name=f"Freebox {sensor_name}", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), ) + for sensor_name in router.sensors_temperature + ] - for sensor_key, sensor in CONNECTION_SENSORS.items(): - entities.append(FreeboxSensor(router, sensor_key, sensor)) - - for sensor_key, sensor in CALL_SENSORS.items(): - entities.append(FreeboxCallSensor(router, sensor_key, sensor)) + entities.extend( + [FreeboxSensor(router, description) for description in CONNECTION_SENSORS] + ) + entities.extend( + [FreeboxCallSensor(router, description) for description in CALL_SENSORS] + ) _LOGGER.debug("%s - %s - %s disk(s)", router.name, router.mac, len(router.disks)) - for disk in router.disks.values(): - for partition in disk["partitions"]: - for sensor_key, sensor in DISK_PARTITION_SENSORS.items(): - entities.append( - FreeboxDiskSensor( - router, - disk, - partition, - sensor_key, - sensor, - ) - ) + entities.extend( + FreeboxDiskSensor(router, disk, partition, description) + for disk in router.disks.values() + for partition in disk["partitions"] + for description in DISK_PARTITION_SENSORS + ) async_add_entities(entities, True) @@ -76,68 +69,30 @@ async def async_setup_entry( class FreeboxSensor(SensorEntity): """Representation of a Freebox sensor.""" + _attr_should_poll = False + def __init__( - self, router: FreeboxRouter, sensor_type: str, sensor: dict[str, Any] + self, router: FreeboxRouter, description: SensorEntityDescription ) -> None: """Initialize a Freebox sensor.""" - self._state = None + self.entity_description = description self._router = router - self._sensor_type = sensor_type - self._name = sensor[SENSOR_NAME] - self._unit = sensor[SENSOR_UNIT] - self._icon = sensor[SENSOR_ICON] - self._device_class = sensor[SENSOR_DEVICE_CLASS] - self._unique_id = f"{self._router.mac} {self._name}" + self._attr_unique_id = f"{router.mac} {description.name}" @callback def async_update_state(self) -> None: """Update the Freebox sensor.""" - state = self._router.sensors[self._sensor_type] - if self._unit == DATA_RATE_KILOBYTES_PER_SECOND: - self._state = round(state / 1000, 2) + state = self._router.sensors[self.entity_description.key] + if self.native_unit_of_measurement == DATA_RATE_KILOBYTES_PER_SECOND: + self._attr_native_value = round(state / 1000, 2) else: - self._state = state - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return self._unique_id - - @property - def name(self) -> str: - """Return the name.""" - return self._name - - @property - def native_value(self) -> str: - """Return the state.""" - return self._state - - @property - def native_unit_of_measurement(self) -> str: - """Return the unit.""" - return self._unit - - @property - def icon(self) -> str: - """Return the icon.""" - return self._icon - - @property - def device_class(self) -> str: - """Return the device_class.""" - return self._device_class + self._attr_native_value = state @property def device_info(self) -> DeviceInfo: """Return the device information.""" return self._router.device_info - @property - def should_poll(self) -> bool: - """No polling needed.""" - return False - @callback def async_on_demand_update(self): """Update state.""" @@ -160,10 +115,10 @@ class FreeboxCallSensor(FreeboxSensor): """Representation of a Freebox call sensor.""" def __init__( - self, router: FreeboxRouter, sensor_type: str, sensor: dict[str, Any] + self, router: FreeboxRouter, description: SensorEntityDescription ) -> None: """Initialize a Freebox call sensor.""" - super().__init__(router, sensor_type, sensor) + super().__init__(router, description) self._call_list_for_type = [] @callback @@ -174,10 +129,10 @@ class FreeboxCallSensor(FreeboxSensor): for call in self._router.call_list: if not call["new"]: continue - if call["type"] == self._sensor_type: + if self.entity_description.key == call["type"]: self._call_list_for_type.append(call) - self._state = len(self._call_list_for_type) + self._attr_native_value = len(self._call_list_for_type) @property def extra_state_attributes(self) -> dict[str, Any]: @@ -196,15 +151,14 @@ class FreeboxDiskSensor(FreeboxSensor): router: FreeboxRouter, disk: dict[str, Any], partition: dict[str, Any], - sensor_type: str, - sensor: dict[str, Any], + description: SensorEntityDescription, ) -> None: """Initialize a Freebox disk sensor.""" - super().__init__(router, sensor_type, sensor) + super().__init__(router, description) self._disk = disk self._partition = partition - self._name = f"{partition['label']} {sensor[SENSOR_NAME]}" - self._unique_id = f"{self._router.mac} {sensor_type} {self._disk['id']} {self._partition['id']}" + self._attr_name = f"{partition['label']} {description.name}" + self._unique_id = f"{self._router.mac} {description.key} {self._disk['id']} {self._partition['id']}" @property def device_info(self) -> DeviceInfo: @@ -223,6 +177,6 @@ class FreeboxDiskSensor(FreeboxSensor): @callback def async_update_state(self) -> None: """Update the Freebox disk sensor.""" - self._state = round( + self._attr_native_value = round( self._partition["free_bytes"] * 100 / self._partition["total_bytes"], 2 ) From 3c0a34dd01e2aac4cc83b65599179a70bf03faf9 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 3 Sep 2021 22:34:01 +0200 Subject: [PATCH 190/843] Use EntityDescription - luftdaten (#55676) * Use EntityDescription - luftdaten * Fix name attribute * Remove default values * Move SensorTypes back to __init__ --- .../components/luftdaten/__init__.py | 89 ++++++++++--------- homeassistant/components/luftdaten/sensor.py | 63 +++---------- 2 files changed, 62 insertions(+), 90 deletions(-) diff --git a/homeassistant/components/luftdaten/__init__.py b/homeassistant/components/luftdaten/__init__.py index 5dffab65d75..ea09c9208ee 100644 --- a/homeassistant/components/luftdaten/__init__.py +++ b/homeassistant/components/luftdaten/__init__.py @@ -1,10 +1,13 @@ """Support for Luftdaten stations.""" +from __future__ import annotations + import logging from luftdaten import Luftdaten from luftdaten.exceptions import LuftdatenError import voluptuous as vol +from homeassistant.components.sensor import SensorEntityDescription from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, @@ -47,49 +50,53 @@ SENSOR_TEMPERATURE = "temperature" TOPIC_UPDATE = f"{DOMAIN}_data_update" -SENSORS = { - SENSOR_TEMPERATURE: [ - "Temperature", - "mdi:thermometer", - TEMP_CELSIUS, - DEVICE_CLASS_TEMPERATURE, - ], - SENSOR_HUMIDITY: [ - "Humidity", - "mdi:water-percent", - PERCENTAGE, - DEVICE_CLASS_HUMIDITY, - ], - SENSOR_PRESSURE: [ - "Pressure", - "mdi:arrow-down-bold", - PRESSURE_HPA, - DEVICE_CLASS_PRESSURE, - ], - SENSOR_PRESSURE_AT_SEALEVEL: [ - "Pressure at sealevel", - "mdi:download", - PRESSURE_HPA, - DEVICE_CLASS_PRESSURE, - ], - SENSOR_PM10: [ - "PM10", - "mdi:thought-bubble", - CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - None, - ], - SENSOR_PM2_5: [ - "PM2.5", - "mdi:thought-bubble-outline", - CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - None, - ], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=SENSOR_TEMPERATURE, + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key=SENSOR_HUMIDITY, + name="Humidity", + icon="mdi:water-percent", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key=SENSOR_PRESSURE, + name="Pressure", + icon="mdi:arrow-down-bold", + native_unit_of_measurement=PRESSURE_HPA, + device_class=DEVICE_CLASS_PRESSURE, + ), + SensorEntityDescription( + key=SENSOR_PRESSURE_AT_SEALEVEL, + name="Pressure at sealevel", + icon="mdi:download", + native_unit_of_measurement=PRESSURE_HPA, + device_class=DEVICE_CLASS_PRESSURE, + ), + SensorEntityDescription( + key=SENSOR_PM10, + name="PM10", + icon="mdi:thought-bubble", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ), + SensorEntityDescription( + key=SENSOR_PM2_5, + name="PM2.5", + icon="mdi:thought-bubble-outline", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ), +) +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] SENSOR_SCHEMA = vol.Schema( { - vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSORS)): vol.All( - cv.ensure_list, [vol.In(SENSORS)] + vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSOR_KEYS): vol.All( + cv.ensure_list, [vol.In(SENSOR_KEYS)] ) } ) @@ -174,7 +181,7 @@ async def async_setup_entry(hass, config_entry): luftdaten = LuftDatenData( Luftdaten(config_entry.data[CONF_SENSOR_ID], hass.loop, session), config_entry.data.get(CONF_SENSORS, {}).get( - CONF_MONITORED_CONDITIONS, list(SENSORS) + CONF_MONITORED_CONDITIONS, SENSOR_KEYS ), ) await luftdaten.async_update() diff --git a/homeassistant/components/luftdaten/sensor.py b/homeassistant/components/luftdaten/sensor.py index b4bdd7f30b3..aa4995490ca 100644 --- a/homeassistant/components/luftdaten/sensor.py +++ b/homeassistant/components/luftdaten/sensor.py @@ -1,7 +1,5 @@ """Support for Luftdaten sensors.""" -import logging - -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_LATITUDE, @@ -16,87 +14,54 @@ from . import ( DATA_LUFTDATEN_CLIENT, DEFAULT_ATTRIBUTION, DOMAIN, - SENSORS, + SENSOR_TYPES, TOPIC_UPDATE, ) from .const import ATTR_SENSOR_ID -_LOGGER = logging.getLogger(__name__) - async def async_setup_entry(hass, entry, async_add_entities): """Set up a Luftdaten sensor based on a config entry.""" luftdaten = hass.data[DOMAIN][DATA_LUFTDATEN_CLIENT][entry.entry_id] - sensors = [] - for sensor_type in luftdaten.sensor_conditions: - try: - name, icon, unit, device_class = SENSORS[sensor_type] - except KeyError: - _LOGGER.debug("Unknown sensor value type: %s", sensor_type) - continue + entities = [ + LuftdatenSensor(luftdaten, description, entry.data[CONF_SHOW_ON_MAP]) + for description in SENSOR_TYPES + if description.key in luftdaten.sensor_conditions + ] - sensors.append( - LuftdatenSensor( - luftdaten, - sensor_type, - name, - icon, - unit, - device_class, - entry.data[CONF_SHOW_ON_MAP], - ) - ) - - async_add_entities(sensors, True) + async_add_entities(entities, True) class LuftdatenSensor(SensorEntity): """Implementation of a Luftdaten sensor.""" - def __init__(self, luftdaten, sensor_type, name, icon, unit, device_class, show): + _attr_should_poll = False + + def __init__(self, luftdaten, description: SensorEntityDescription, show): """Initialize the Luftdaten sensor.""" + self.entity_description = description self._async_unsub_dispatcher_connect = None self.luftdaten = luftdaten - self._icon = icon - self._name = name self._data = None - self.sensor_type = sensor_type - self._unit_of_measurement = unit self._show_on_map = show self._attrs = {} - self._attr_device_class = device_class - - @property - def icon(self): - """Return the icon.""" - return self._icon @property def native_value(self): """Return the state of the device.""" if self._data is not None: try: - return self._data[self.sensor_type] + return self._data[self.entity_description.key] except KeyError: return None - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement - - @property - def should_poll(self): - """Disable polling.""" - return False - @property def unique_id(self) -> str: """Return a unique, friendly identifier for this entity.""" if self._data is not None: try: - return f"{self._data['sensor_id']}_{self.sensor_type}" + return f"{self._data['sensor_id']}_{self.entity_description.key}" except KeyError: return None From 798f487ea4455d7d58ddafc518cbaeb071472ee8 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 3 Sep 2021 22:34:29 +0200 Subject: [PATCH 191/843] Use EntityDescription - faa_delays (#55678) * Use EntityDescription - faa_delays * Update binary_sensor.py --- .../components/faa_delays/binary_sensor.py | 75 ++++++++----------- homeassistant/components/faa_delays/const.py | 52 +++++++------ 2 files changed, 62 insertions(+), 65 deletions(-) diff --git a/homeassistant/components/faa_delays/binary_sensor.py b/homeassistant/components/faa_delays/binary_sensor.py index b96ee24a5bc..9ef55a1f2cc 100644 --- a/homeassistant/components/faa_delays/binary_sensor.py +++ b/homeassistant/components/faa_delays/binary_sensor.py @@ -1,6 +1,12 @@ """Platform for FAA Delays sensor component.""" -from homeassistant.components.binary_sensor import BinarySensorEntity -from homeassistant.const import ATTR_ICON, ATTR_NAME +from __future__ import annotations + +from typing import Any + +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, FAA_BINARY_SENSORS @@ -10,83 +16,68 @@ async def async_setup_entry(hass, entry, async_add_entities): """Set up a FAA sensor based on a config entry.""" coordinator = hass.data[DOMAIN][entry.entry_id] - binary_sensors = [] - for kind, attrs in FAA_BINARY_SENSORS.items(): - name = attrs[ATTR_NAME] - icon = attrs[ATTR_ICON] + entities = [ + FAABinarySensor(coordinator, entry.entry_id, description) + for description in FAA_BINARY_SENSORS + ] - binary_sensors.append( - FAABinarySensor(coordinator, kind, name, icon, entry.entry_id) - ) - - async_add_entities(binary_sensors) + async_add_entities(entities) class FAABinarySensor(CoordinatorEntity, BinarySensorEntity): """Define a binary sensor for FAA Delays.""" - def __init__(self, coordinator, sensor_type, name, icon, entry_id): + def __init__( + self, coordinator, entry_id, description: BinarySensorEntityDescription + ): """Initialize the sensor.""" super().__init__(coordinator) + self.entity_description = description self.coordinator = coordinator self._entry_id = entry_id - self._icon = icon - self._name = name - self._sensor_type = sensor_type - self._id = self.coordinator.data.iata - self._attrs = {} - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._id} {self._name}" - - @property - def icon(self): - """Return the icon.""" - return self._icon + self._attrs: dict[str, Any] = {} + _id = coordinator.data.iata + self._attr_name = f"{_id} {description.name}" + self._attr_unique_id = f"{_id}_{description.key}" @property def is_on(self): """Return the status of the sensor.""" - if self._sensor_type == "GROUND_DELAY": + sensor_type = self.entity_description.key + if sensor_type == "GROUND_DELAY": return self.coordinator.data.ground_delay.status - if self._sensor_type == "GROUND_STOP": + if sensor_type == "GROUND_STOP": return self.coordinator.data.ground_stop.status - if self._sensor_type == "DEPART_DELAY": + if sensor_type == "DEPART_DELAY": return self.coordinator.data.depart_delay.status - if self._sensor_type == "ARRIVE_DELAY": + if sensor_type == "ARRIVE_DELAY": return self.coordinator.data.arrive_delay.status - if self._sensor_type == "CLOSURE": + if sensor_type == "CLOSURE": return self.coordinator.data.closure.status return None - @property - def unique_id(self): - """Return a unique, Home Assistant friendly identifier for this entity.""" - return f"{self._id}_{self._sensor_type}" - @property def extra_state_attributes(self): """Return attributes for sensor.""" - if self._sensor_type == "GROUND_DELAY": + sensor_type = self.entity_description.key + if sensor_type == "GROUND_DELAY": self._attrs["average"] = self.coordinator.data.ground_delay.average self._attrs["reason"] = self.coordinator.data.ground_delay.reason - elif self._sensor_type == "GROUND_STOP": + elif sensor_type == "GROUND_STOP": self._attrs["endtime"] = self.coordinator.data.ground_stop.endtime self._attrs["reason"] = self.coordinator.data.ground_stop.reason - elif self._sensor_type == "DEPART_DELAY": + elif sensor_type == "DEPART_DELAY": self._attrs["minimum"] = self.coordinator.data.depart_delay.minimum self._attrs["maximum"] = self.coordinator.data.depart_delay.maximum self._attrs["trend"] = self.coordinator.data.depart_delay.trend self._attrs["reason"] = self.coordinator.data.depart_delay.reason - elif self._sensor_type == "ARRIVE_DELAY": + elif sensor_type == "ARRIVE_DELAY": self._attrs["minimum"] = self.coordinator.data.arrive_delay.minimum self._attrs["maximum"] = self.coordinator.data.arrive_delay.maximum self._attrs["trend"] = self.coordinator.data.arrive_delay.trend self._attrs["reason"] = self.coordinator.data.arrive_delay.reason - elif self._sensor_type == "CLOSURE": + elif sensor_type == "CLOSURE": self._attrs["begin"] = self.coordinator.data.closure.begin self._attrs["end"] = self.coordinator.data.closure.end self._attrs["reason"] = self.coordinator.data.closure.reason diff --git a/homeassistant/components/faa_delays/const.py b/homeassistant/components/faa_delays/const.py index c725be88106..f7ee8e7bad8 100644 --- a/homeassistant/components/faa_delays/const.py +++ b/homeassistant/components/faa_delays/const.py @@ -1,28 +1,34 @@ """Constants for the FAA Delays integration.""" +from __future__ import annotations -from homeassistant.const import ATTR_ICON, ATTR_NAME +from homeassistant.components.binary_sensor import BinarySensorEntityDescription DOMAIN = "faa_delays" -FAA_BINARY_SENSORS = { - "GROUND_DELAY": { - ATTR_NAME: "Ground Delay", - ATTR_ICON: "mdi:airport", - }, - "GROUND_STOP": { - ATTR_NAME: "Ground Stop", - ATTR_ICON: "mdi:airport", - }, - "DEPART_DELAY": { - ATTR_NAME: "Departure Delay", - ATTR_ICON: "mdi:airplane-takeoff", - }, - "ARRIVE_DELAY": { - ATTR_NAME: "Arrival Delay", - ATTR_ICON: "mdi:airplane-landing", - }, - "CLOSURE": { - ATTR_NAME: "Closure", - ATTR_ICON: "mdi:airplane:off", - }, -} +FAA_BINARY_SENSORS: tuple[BinarySensorEntityDescription, ...] = ( + BinarySensorEntityDescription( + key="GROUND_DELAY", + name="Ground Delay", + icon="mdi:airport", + ), + BinarySensorEntityDescription( + key="GROUND_STOP", + name="Ground Stop", + icon="mdi:airport", + ), + BinarySensorEntityDescription( + key="DEPART_DELAY", + name="Departure Delay", + icon="mdi:airplane-takeoff", + ), + BinarySensorEntityDescription( + key="ARRIVE_DELAY", + name="Arrival Delay", + icon="mdi:airplane-landing", + ), + BinarySensorEntityDescription( + key="CLOSURE", + name="Closure", + icon="mdi:airplane:off", + ), +) From 76ce0f6ea7cf04cd8e5a3d0accdc0da407129fa4 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 3 Sep 2021 22:34:51 +0200 Subject: [PATCH 192/843] Use EntityDescription - econet (#55680) * Use EntityDescription - econet * Resolve name constants --- .../components/econet/binary_sensor.py | 79 +++++++++---------- 1 file changed, 38 insertions(+), 41 deletions(-) diff --git a/homeassistant/components/econet/binary_sensor.py b/homeassistant/components/econet/binary_sensor.py index 116b1243ee0..cb7945e0815 100644 --- a/homeassistant/components/econet/binary_sensor.py +++ b/homeassistant/components/econet/binary_sensor.py @@ -1,4 +1,6 @@ """Support for Rheem EcoNet water heaters.""" +from __future__ import annotations + from pyeconet.equipment import EquipmentType from homeassistant.components.binary_sensor import ( @@ -7,77 +9,72 @@ from homeassistant.components.binary_sensor import ( DEVICE_CLASS_POWER, DEVICE_CLASS_SOUND, BinarySensorEntity, + BinarySensorEntityDescription, ) from . import EcoNetEntity from .const import DOMAIN, EQUIPMENT -SENSOR_NAME_RUNNING = "running" -SENSOR_NAME_SHUTOFF_VALVE = "shutoff_valve" -SENSOR_NAME_RUNNING = "running" -SENSOR_NAME_SCREEN_LOCKED = "screen_locked" -SENSOR_NAME_BEEP_ENABLED = "beep_enabled" - -ATTR = "attr" -DEVICE_CLASS = "device_class" -SENSORS = { - SENSOR_NAME_SHUTOFF_VALVE: { - ATTR: "shutoff_valve_open", - DEVICE_CLASS: DEVICE_CLASS_OPENING, - }, - SENSOR_NAME_RUNNING: {ATTR: "running", DEVICE_CLASS: DEVICE_CLASS_POWER}, - SENSOR_NAME_SCREEN_LOCKED: { - ATTR: "screen_locked", - DEVICE_CLASS: DEVICE_CLASS_LOCK, - }, - SENSOR_NAME_BEEP_ENABLED: { - ATTR: "beep_enabled", - DEVICE_CLASS: DEVICE_CLASS_SOUND, - }, -} +BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( + BinarySensorEntityDescription( + key="shutoff_valve_open", + name="shutoff_valve", + device_class=DEVICE_CLASS_OPENING, + ), + BinarySensorEntityDescription( + key="running", + name="running", + device_class=DEVICE_CLASS_POWER, + ), + BinarySensorEntityDescription( + key="screen_locked", + name="screen_locked", + device_class=DEVICE_CLASS_LOCK, + ), + BinarySensorEntityDescription( + key="beep_enabled", + name="beep_enabled", + device_class=DEVICE_CLASS_SOUND, + ), +) async def async_setup_entry(hass, entry, async_add_entities): """Set up EcoNet binary sensor based on a config entry.""" equipment = hass.data[DOMAIN][EQUIPMENT][entry.entry_id] - binary_sensors = [] all_equipment = equipment[EquipmentType.WATER_HEATER].copy() all_equipment.extend(equipment[EquipmentType.THERMOSTAT].copy()) - for _equip in all_equipment: - for sensor_name, sensor in SENSORS.items(): - if getattr(_equip, sensor[ATTR], None) is not None: - binary_sensors.append(EcoNetBinarySensor(_equip, sensor_name)) - async_add_entities(binary_sensors) + entities = [ + EcoNetBinarySensor(_equip, description) + for _equip in all_equipment + for description in BINARY_SENSOR_TYPES + if getattr(_equip, description.key, None) is not None + ] + + async_add_entities(entities) class EcoNetBinarySensor(EcoNetEntity, BinarySensorEntity): """Define a Econet binary sensor.""" - def __init__(self, econet_device, device_name): + def __init__(self, econet_device, description: BinarySensorEntityDescription): """Initialize.""" super().__init__(econet_device) + self.entity_description = description self._econet = econet_device - self._device_name = device_name @property def is_on(self): """Return true if the binary sensor is on.""" - return getattr(self._econet, SENSORS[self._device_name][ATTR]) - - @property - def device_class(self): - """Return the class of this sensor, from DEVICE_CLASSES.""" - return SENSORS[self._device_name][DEVICE_CLASS] + return getattr(self._econet, self.entity_description.key) @property def name(self): """Return the name of the entity.""" - return f"{self._econet.device_name}_{self._device_name}" + return f"{self._econet.device_name}_{self.entity_description.name}" @property def unique_id(self): """Return the unique ID of the entity.""" - return ( - f"{self._econet.device_id}_{self._econet.device_name}_{self._device_name}" - ) + return f"{self._econet.device_id}_{self._econet.device_name}_{self.entity_description.name}" From 1e4233fe20eece3b6b7087d3ddeb7e291216b4d8 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 3 Sep 2021 22:35:12 +0200 Subject: [PATCH 193/843] Use EntityDescription - iperf3 (#55681) --- homeassistant/components/iperf3/__init__.py | 30 ++++++++--- homeassistant/components/iperf3/sensor.py | 55 +++++++-------------- 2 files changed, 39 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/iperf3/__init__.py b/homeassistant/components/iperf3/__init__.py index bd5aeac099a..04dabc013e7 100644 --- a/homeassistant/components/iperf3/__init__.py +++ b/homeassistant/components/iperf3/__init__.py @@ -1,11 +1,16 @@ """Support for Iperf3 network measurement tool.""" +from __future__ import annotations + from datetime import timedelta import logging import iperf3 import voluptuous as vol -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.sensor import ( + DOMAIN as SENSOR_DOMAIN, + SensorEntityDescription, +) from homeassistant.const import ( CONF_HOST, CONF_HOSTS, @@ -40,10 +45,19 @@ ATTR_UPLOAD = "upload" ATTR_VERSION = "Version" ATTR_HOST = "host" -SENSOR_TYPES = { - ATTR_DOWNLOAD: [ATTR_DOWNLOAD.capitalize(), DATA_RATE_MEGABITS_PER_SECOND], - ATTR_UPLOAD: [ATTR_UPLOAD.capitalize(), DATA_RATE_MEGABITS_PER_SECOND], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=ATTR_DOWNLOAD, + name=ATTR_DOWNLOAD.capitalize(), + native_unit_of_measurement=DATA_RATE_MEGABITS_PER_SECOND, + ), + SensorEntityDescription( + key=ATTR_UPLOAD, + name=ATTR_UPLOAD.capitalize(), + native_unit_of_measurement=DATA_RATE_MEGABITS_PER_SECOND, + ), +) +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] PROTOCOLS = ["tcp", "udp"] @@ -62,9 +76,9 @@ CONFIG_SCHEMA = vol.Schema( DOMAIN: vol.Schema( { vol.Required(CONF_HOSTS): vol.All(cv.ensure_list, [HOST_CONFIG_SCHEMA]), - vol.Optional( - CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES) - ): vol.All(cv.ensure_list, [vol.In(list(SENSOR_TYPES))]), + vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSOR_KEYS): vol.All( + cv.ensure_list, [vol.In(SENSOR_KEYS)] + ), vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_INTERVAL): vol.All( cv.time_period, cv.positive_timedelta ), diff --git a/homeassistant/components/iperf3/sensor.py b/homeassistant/components/iperf3/sensor.py index 07b9cc069e4..dfc4abba707 100644 --- a/homeassistant/components/iperf3/sensor.py +++ b/homeassistant/components/iperf3/sensor.py @@ -1,5 +1,5 @@ """Support for Iperf3 sensors.""" -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -18,42 +18,26 @@ ATTR_REMOTE_PORT = "Remote Port" async def async_setup_platform(hass, config, async_add_entities, discovery_info): """Set up the Iperf3 sensor.""" - sensors = [] - for iperf3_host in hass.data[IPERF3_DOMAIN].values(): - sensors.extend([Iperf3Sensor(iperf3_host, sensor) for sensor in discovery_info]) - async_add_entities(sensors, True) + entities = [ + Iperf3Sensor(iperf3_host, description) + for iperf3_host in hass.data[IPERF3_DOMAIN].values() + for description in SENSOR_TYPES + if description.key in discovery_info + ] + async_add_entities(entities, True) class Iperf3Sensor(RestoreEntity, SensorEntity): """A Iperf3 sensor implementation.""" - def __init__(self, iperf3_data, sensor_type): + _attr_icon = ICON + _attr_should_poll = False + + def __init__(self, iperf3_data, description: SensorEntityDescription): """Initialize the sensor.""" - self._name = f"{SENSOR_TYPES[sensor_type][0]} {iperf3_data.host}" - self._state = None - self._sensor_type = sensor_type - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + self.entity_description = description self._iperf3_data = iperf3_data - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def native_value(self): - """Return the state of the device.""" - return self._state - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement - - @property - def icon(self): - """Return icon.""" - return ICON + self._attr_name = f"{description.name} {iperf3_data.host}" @property def extra_state_attributes(self): @@ -66,11 +50,6 @@ class Iperf3Sensor(RestoreEntity, SensorEntity): ATTR_VERSION: self._iperf3_data.data[ATTR_VERSION], } - @property - def should_poll(self): - """Return the polling requirement for this sensor.""" - return False - async def async_added_to_hass(self): """Handle entity which will be added.""" await super().async_added_to_hass() @@ -84,13 +63,13 @@ class Iperf3Sensor(RestoreEntity, SensorEntity): state = await self.async_get_last_state() if not state: return - self._state = state.state + self._attr_native_value = state.state def update(self): """Get the latest data and update the states.""" - data = self._iperf3_data.data.get(self._sensor_type) + data = self._iperf3_data.data.get(self.entity_description.key) if data is not None: - self._state = round(data, 2) + self._attr_native_value = round(data, 2) @callback def _schedule_immediate_update(self, host): From 9db13a3e7410f38a62a02e7109ec565f22944d13 Mon Sep 17 00:00:00 2001 From: Brian Rogers Date: Fri, 3 Sep 2021 16:35:33 -0400 Subject: [PATCH 194/843] Fix Rachio service missing with 1st generation controllers (#55679) --- homeassistant/components/rachio/device.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/rachio/device.py b/homeassistant/components/rachio/device.py index 6669a353094..8ac4c92582f 100644 --- a/homeassistant/components/rachio/device.py +++ b/homeassistant/components/rachio/device.py @@ -70,9 +70,6 @@ class RachioPerson: can_pause = True break - if not can_pause: - return - all_devices = [rachio_iro.name for rachio_iro in self._controllers] def pause_water(service): @@ -97,6 +94,16 @@ class RachioPerson: if iro.name in devices: iro.stop_watering() + hass.services.async_register( + DOMAIN, + SERVICE_STOP_WATERING, + stop_water, + schema=STOP_SERVICE_SCHEMA, + ) + + if not can_pause: + return + hass.services.async_register( DOMAIN, SERVICE_PAUSE_WATERING, @@ -111,13 +118,6 @@ class RachioPerson: schema=RESUME_SERVICE_SCHEMA, ) - hass.services.async_register( - DOMAIN, - SERVICE_STOP_WATERING, - stop_water, - schema=STOP_SERVICE_SCHEMA, - ) - def _setup(self, hass): """Rachio device setup.""" rachio = self.rachio @@ -134,7 +134,7 @@ class RachioPerson: for controller in devices: webhooks = rachio.notification.get_device_webhook(controller[KEY_ID])[1] # The API does not provide a way to tell if a controller is shared - # or if they are the owner. To work around this problem we fetch the webooks + # or if they are the owner. To work around this problem we fetch the webhooks # before we setup the device so we can skip it instead of failing. # webhooks are normally a list, however if there is an error # rachio hands us back a dict From ce6921d73ccba927e409e63674eb9bc8620538a6 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 3 Sep 2021 22:35:59 +0200 Subject: [PATCH 195/843] Use EntityDescription - picnic (#55682) * Use EntityDescription - picnic * Change _attr_extra_state_attributes to be static * Fix tests --- homeassistant/components/picnic/const.py | 214 +++++++++++++--------- homeassistant/components/picnic/sensor.py | 66 +++---- tests/components/picnic/test_sensor.py | 4 +- 3 files changed, 150 insertions(+), 134 deletions(-) diff --git a/homeassistant/components/picnic/const.py b/homeassistant/components/picnic/const.py index 18a62589732..e37f85cb28b 100644 --- a/homeassistant/components/picnic/const.py +++ b/homeassistant/components/picnic/const.py @@ -1,5 +1,12 @@ """Constants for the Picnic integration.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable, Literal + +from homeassistant.components.sensor import SensorEntityDescription from homeassistant.const import CURRENCY_EURO, DEVICE_CLASS_TIMESTAMP +from homeassistant.helpers.typing import StateType DOMAIN = "picnic" @@ -28,91 +35,122 @@ SENSOR_LAST_ORDER_ETA_END = "last_order_eta_end" SENSOR_LAST_ORDER_DELIVERY_TIME = "last_order_delivery_time" SENSOR_LAST_ORDER_TOTAL_PRICE = "last_order_total_price" -SENSOR_TYPES = { - SENSOR_CART_ITEMS_COUNT: { - "icon": "mdi:format-list-numbered", - "data_type": CART_DATA, - "state": lambda cart: cart.get("total_count", 0), - }, - SENSOR_CART_TOTAL_PRICE: { - "unit": CURRENCY_EURO, - "icon": "mdi:currency-eur", - "default_enabled": True, - "data_type": CART_DATA, - "state": lambda cart: cart.get("total_price", 0) / 100, - }, - SENSOR_SELECTED_SLOT_START: { - "class": DEVICE_CLASS_TIMESTAMP, - "icon": "mdi:calendar-start", - "default_enabled": True, - "data_type": SLOT_DATA, - "state": lambda slot: slot.get("window_start"), - }, - SENSOR_SELECTED_SLOT_END: { - "class": DEVICE_CLASS_TIMESTAMP, - "icon": "mdi:calendar-end", - "default_enabled": True, - "data_type": SLOT_DATA, - "state": lambda slot: slot.get("window_end"), - }, - SENSOR_SELECTED_SLOT_MAX_ORDER_TIME: { - "class": DEVICE_CLASS_TIMESTAMP, - "icon": "mdi:clock-alert-outline", - "default_enabled": True, - "data_type": SLOT_DATA, - "state": lambda slot: slot.get("cut_off_time"), - }, - SENSOR_SELECTED_SLOT_MIN_ORDER_VALUE: { - "unit": CURRENCY_EURO, - "icon": "mdi:currency-eur", - "default_enabled": True, - "data_type": SLOT_DATA, - "state": lambda slot: slot["minimum_order_value"] / 100 - if slot.get("minimum_order_value") - else None, - }, - SENSOR_LAST_ORDER_SLOT_START: { - "class": DEVICE_CLASS_TIMESTAMP, - "icon": "mdi:calendar-start", - "data_type": LAST_ORDER_DATA, - "state": lambda last_order: last_order.get("slot", {}).get("window_start"), - }, - SENSOR_LAST_ORDER_SLOT_END: { - "class": DEVICE_CLASS_TIMESTAMP, - "icon": "mdi:calendar-end", - "data_type": LAST_ORDER_DATA, - "state": lambda last_order: last_order.get("slot", {}).get("window_end"), - }, - SENSOR_LAST_ORDER_STATUS: { - "icon": "mdi:list-status", - "data_type": LAST_ORDER_DATA, - "state": lambda last_order: last_order.get("status"), - }, - SENSOR_LAST_ORDER_ETA_START: { - "class": DEVICE_CLASS_TIMESTAMP, - "icon": "mdi:clock-start", - "default_enabled": True, - "data_type": LAST_ORDER_DATA, - "state": lambda last_order: last_order.get("eta", {}).get("start"), - }, - SENSOR_LAST_ORDER_ETA_END: { - "class": DEVICE_CLASS_TIMESTAMP, - "icon": "mdi:clock-end", - "default_enabled": True, - "data_type": LAST_ORDER_DATA, - "state": lambda last_order: last_order.get("eta", {}).get("end"), - }, - SENSOR_LAST_ORDER_DELIVERY_TIME: { - "class": DEVICE_CLASS_TIMESTAMP, - "icon": "mdi:timeline-clock", - "default_enabled": True, - "data_type": LAST_ORDER_DATA, - "state": lambda last_order: last_order.get("delivery_time", {}).get("start"), - }, - SENSOR_LAST_ORDER_TOTAL_PRICE: { - "unit": CURRENCY_EURO, - "icon": "mdi:cash-marker", - "data_type": LAST_ORDER_DATA, - "state": lambda last_order: last_order.get("total_price", 0) / 100, - }, -} + +@dataclass +class PicnicRequiredKeysMixin: + """Mixin for required keys.""" + + data_type: Literal["cart_data", "slot_data", "last_order_data"] + state: Callable[[Any], StateType] + + +@dataclass +class PicnicSensorEntityDescription(SensorEntityDescription, PicnicRequiredKeysMixin): + """Describes Picnic sensor entity.""" + + entity_registry_enabled_default: bool = False + + +SENSOR_TYPES: tuple[PicnicSensorEntityDescription, ...] = ( + PicnicSensorEntityDescription( + key=SENSOR_CART_ITEMS_COUNT, + icon="mdi:format-list-numbered", + data_type="cart_data", + state=lambda cart: cart.get("total_count", 0), + ), + PicnicSensorEntityDescription( + key=SENSOR_CART_TOTAL_PRICE, + native_unit_of_measurement=CURRENCY_EURO, + icon="mdi:currency-eur", + entity_registry_enabled_default=True, + data_type="cart_data", + state=lambda cart: cart.get("total_price", 0) / 100, + ), + PicnicSensorEntityDescription( + key=SENSOR_SELECTED_SLOT_START, + device_class=DEVICE_CLASS_TIMESTAMP, + icon="mdi:calendar-start", + entity_registry_enabled_default=True, + data_type="slot_data", + state=lambda slot: slot.get("window_start"), + ), + PicnicSensorEntityDescription( + key=SENSOR_SELECTED_SLOT_END, + device_class=DEVICE_CLASS_TIMESTAMP, + icon="mdi:calendar-end", + entity_registry_enabled_default=True, + data_type="slot_data", + state=lambda slot: slot.get("window_end"), + ), + PicnicSensorEntityDescription( + key=SENSOR_SELECTED_SLOT_MAX_ORDER_TIME, + device_class=DEVICE_CLASS_TIMESTAMP, + icon="mdi:clock-alert-outline", + entity_registry_enabled_default=True, + data_type="slot_data", + state=lambda slot: slot.get("cut_off_time"), + ), + PicnicSensorEntityDescription( + key=SENSOR_SELECTED_SLOT_MIN_ORDER_VALUE, + native_unit_of_measurement=CURRENCY_EURO, + icon="mdi:currency-eur", + entity_registry_enabled_default=True, + data_type="slot_data", + state=lambda slot: ( + slot["minimum_order_value"] / 100 + if slot.get("minimum_order_value") + else None + ), + ), + PicnicSensorEntityDescription( + key=SENSOR_LAST_ORDER_SLOT_START, + device_class=DEVICE_CLASS_TIMESTAMP, + icon="mdi:calendar-start", + data_type="last_order_data", + state=lambda last_order: last_order.get("slot", {}).get("window_start"), + ), + PicnicSensorEntityDescription( + key=SENSOR_LAST_ORDER_SLOT_END, + device_class=DEVICE_CLASS_TIMESTAMP, + icon="mdi:calendar-end", + data_type="last_order_data", + state=lambda last_order: last_order.get("slot", {}).get("window_end"), + ), + PicnicSensorEntityDescription( + key=SENSOR_LAST_ORDER_STATUS, + icon="mdi:list-status", + data_type="last_order_data", + state=lambda last_order: last_order.get("status"), + ), + PicnicSensorEntityDescription( + key=SENSOR_LAST_ORDER_ETA_START, + device_class=DEVICE_CLASS_TIMESTAMP, + icon="mdi:clock-start", + entity_registry_enabled_default=True, + data_type="last_order_data", + state=lambda last_order: last_order.get("eta", {}).get("start"), + ), + PicnicSensorEntityDescription( + key=SENSOR_LAST_ORDER_ETA_END, + device_class=DEVICE_CLASS_TIMESTAMP, + icon="mdi:clock-end", + entity_registry_enabled_default=True, + data_type="last_order_data", + state=lambda last_order: last_order.get("eta", {}).get("end"), + ), + PicnicSensorEntityDescription( + key=SENSOR_LAST_ORDER_DELIVERY_TIME, + device_class=DEVICE_CLASS_TIMESTAMP, + icon="mdi:timeline-clock", + entity_registry_enabled_default=True, + data_type="last_order_data", + state=lambda last_order: last_order.get("delivery_time", {}).get("start"), + ), + PicnicSensorEntityDescription( + key=SENSOR_LAST_ORDER_TOTAL_PRICE, + native_unit_of_measurement=CURRENCY_EURO, + icon="mdi:cash-marker", + data_type="last_order_data", + state=lambda last_order: last_order.get("total_price", 0) / 100, + ), +) diff --git a/homeassistant/components/picnic/sensor.py b/homeassistant/components/picnic/sensor.py index 57f24180c03..34ad2943d8e 100644 --- a/homeassistant/components/picnic/sensor.py +++ b/homeassistant/components/picnic/sensor.py @@ -13,7 +13,14 @@ from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, ) -from .const import ADDRESS, ATTRIBUTION, CONF_COORDINATOR, DOMAIN, SENSOR_TYPES +from .const import ( + ADDRESS, + ATTRIBUTION, + CONF_COORDINATOR, + DOMAIN, + SENSOR_TYPES, + PicnicSensorEntityDescription, +) async def async_setup_entry( @@ -24,8 +31,8 @@ async def async_setup_entry( # Add an entity for each sensor type async_add_entities( - PicnicSensor(picnic_coordinator, config_entry, sensor_type, props) - for sensor_type, props in SENSOR_TYPES.items() + PicnicSensor(picnic_coordinator, config_entry, description) + for description in SENSOR_TYPES ) return True @@ -34,71 +41,40 @@ async def async_setup_entry( class PicnicSensor(SensorEntity, CoordinatorEntity): """The CoordinatorEntity subclass representing Picnic sensors.""" + entity_description: PicnicSensorEntityDescription + _attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} + def __init__( self, coordinator: DataUpdateCoordinator[Any], config_entry: ConfigEntry, - sensor_type, - properties, - ): + description: PicnicSensorEntityDescription, + ) -> None: """Init a Picnic sensor.""" super().__init__(coordinator) + self.entity_description = description - self.sensor_type = sensor_type - self.properties = properties - self.entity_id = f"sensor.picnic_{sensor_type}" + self.entity_id = f"sensor.picnic_{description.key}" self._service_unique_id = config_entry.unique_id - @property - def native_unit_of_measurement(self) -> str | None: - """Return the unit this state is expressed in.""" - return self.properties.get("unit") - - @property - def unique_id(self) -> str | None: - """Return a unique ID.""" - return f"{self._service_unique_id}.{self.sensor_type}" - - @property - def name(self) -> str | None: - """Return the name of the entity.""" - return self._to_capitalized_name(self.sensor_type) + self._attr_name = self._to_capitalized_name(description.key) + self._attr_unique_id = f"{config_entry.unique_id}.{description.key}" @property def native_value(self) -> StateType: """Return the state of the entity.""" data_set = ( - self.coordinator.data.get(self.properties["data_type"], {}) + self.coordinator.data.get(self.entity_description.data_type, {}) if self.coordinator.data is not None else {} ) - return self.properties["state"](data_set) - - @property - def device_class(self) -> str | None: - """Return the class of this device, from component DEVICE_CLASSES.""" - return self.properties.get("class") - - @property - def icon(self) -> str | None: - """Return the icon to use in the frontend, if any.""" - return self.properties["icon"] + return self.entity_description.state(data_set) @property def available(self) -> bool: """Return True if entity is available.""" return self.coordinator.last_update_success and self.state is not None - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return self.properties.get("default_enabled", False) - - @property - def extra_state_attributes(self): - """Return the sensor specific state attributes.""" - return {ATTR_ATTRIBUTION: ATTRIBUTION} - @property def device_info(self): """Return device info.""" diff --git a/tests/components/picnic/test_sensor.py b/tests/components/picnic/test_sensor.py index 098d86785ab..36aa06443df 100644 --- a/tests/components/picnic/test_sensor.py +++ b/tests/components/picnic/test_sensor.py @@ -85,6 +85,8 @@ DEFAULT_DELIVERY_RESPONSE = { ], } +SENSOR_KEYS = [desc.key for desc in SENSOR_TYPES] + @pytest.mark.usefixtures("hass_storage") class TestPicnicSensor(unittest.IsolatedAsyncioTestCase): @@ -161,7 +163,7 @@ class TestPicnicSensor(unittest.IsolatedAsyncioTestCase): async def _enable_all_sensors(self): """Enable all sensors of the Picnic integration.""" # Enable the sensors - for sensor_type in SENSOR_TYPES.keys(): + for sensor_type in SENSOR_KEYS: updated_entry = self.entity_registry.async_update_entity( f"sensor.picnic_{sensor_type}", disabled_by=None ) From bbd9c6eb5b84708a279df5a0ae0cbd498ac699c9 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 3 Sep 2021 22:36:17 +0200 Subject: [PATCH 196/843] Use EntityDescription - discogs (#55683) --- homeassistant/components/discogs/sensor.py | 105 ++++++++++----------- 1 file changed, 50 insertions(+), 55 deletions(-) diff --git a/homeassistant/components/discogs/sensor.py b/homeassistant/components/discogs/sensor.py index 3d90956a2b5..a751f437cc1 100644 --- a/homeassistant/components/discogs/sensor.py +++ b/homeassistant/components/discogs/sensor.py @@ -1,4 +1,6 @@ """Show the amount of records in a user's Discogs collection.""" +from __future__ import annotations + from datetime import timedelta import logging import random @@ -6,7 +8,11 @@ import random import discogs_client import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( ATTR_ATTRIBUTION, CONF_MONITORED_CONDITIONS, @@ -34,30 +40,33 @@ SENSOR_COLLECTION_TYPE = "collection" SENSOR_WANTLIST_TYPE = "wantlist" SENSOR_RANDOM_RECORD_TYPE = "random_record" -SENSORS = { - SENSOR_COLLECTION_TYPE: { - "name": "Collection", - "icon": ICON_RECORD, - "unit_of_measurement": UNIT_RECORDS, - }, - SENSOR_WANTLIST_TYPE: { - "name": "Wantlist", - "icon": ICON_RECORD, - "unit_of_measurement": UNIT_RECORDS, - }, - SENSOR_RANDOM_RECORD_TYPE: { - "name": "Random Record", - "icon": ICON_PLAYER, - "unit_of_measurement": None, - }, -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=SENSOR_COLLECTION_TYPE, + name="Collection", + icon=ICON_RECORD, + native_unit_of_measurement=UNIT_RECORDS, + ), + SensorEntityDescription( + key=SENSOR_WANTLIST_TYPE, + name="Wantlist", + icon=ICON_RECORD, + native_unit_of_measurement=UNIT_RECORDS, + ), + SensorEntityDescription( + key=SENSOR_RANDOM_RECORD_TYPE, + name="Random Record", + icon=ICON_PLAYER, + ), +) +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_TOKEN): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSORS)): vol.All( - cv.ensure_list, [vol.In(SENSORS)] + vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSOR_KEYS): vol.All( + cv.ensure_list, [vol.In(SENSOR_KEYS)] ), } ) @@ -81,51 +90,37 @@ def setup_platform(hass, config, add_entities, discovery_info=None): _LOGGER.error("API token is not valid") return - sensors = [] - for sensor_type in config[CONF_MONITORED_CONDITIONS]: - sensors.append(DiscogsSensor(discogs_data, name, sensor_type)) + monitored_conditions = config[CONF_MONITORED_CONDITIONS] + entities = [ + DiscogsSensor(discogs_data, name, description) + for description in SENSOR_TYPES + if description.key in monitored_conditions + ] - add_entities(sensors, True) + add_entities(entities, True) class DiscogsSensor(SensorEntity): """Create a new Discogs sensor for a specific type.""" - def __init__(self, discogs_data, name, sensor_type): + def __init__(self, discogs_data, name, description: SensorEntityDescription): """Initialize the Discogs sensor.""" + self.entity_description = description self._discogs_data = discogs_data - self._name = name - self._type = sensor_type - self._state = None - self._attrs = {} + self._attrs: dict = {} - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._name} {SENSORS[self._type]['name']}" - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return SENSORS[self._type]["icon"] - - @property - def native_unit_of_measurement(self): - """Return the unit this state is expressed in.""" - return SENSORS[self._type]["unit_of_measurement"] + self._attr_name = f"{name} {description.name}" @property def extra_state_attributes(self): """Return the device state attributes of the sensor.""" - if self._state is None or self._attrs is None: + if self._attr_native_value is None or self._attrs is None: return None - if self._type == SENSOR_RANDOM_RECORD_TYPE and self._state is not None: + if ( + self.entity_description.key == SENSOR_RANDOM_RECORD_TYPE + and self._attr_native_value is not None + ): return { "cat_no": self._attrs["labels"][0]["catno"], "cover_image": self._attrs["cover_image"], @@ -156,9 +151,9 @@ class DiscogsSensor(SensorEntity): def update(self): """Set state to the amount of records in user's collection.""" - if self._type == SENSOR_COLLECTION_TYPE: - self._state = self._discogs_data["collection_count"] - elif self._type == SENSOR_WANTLIST_TYPE: - self._state = self._discogs_data["wantlist_count"] + if self.entity_description.key == SENSOR_COLLECTION_TYPE: + self._attr_native_value = self._discogs_data["collection_count"] + elif self.entity_description.key == SENSOR_WANTLIST_TYPE: + self._attr_native_value = self._discogs_data["wantlist_count"] else: - self._state = self.get_random_record() + self._attr_native_value = self.get_random_record() From f5cd3211859ad473ab4ee571475169fc2f6969ad Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 3 Sep 2021 22:36:24 +0200 Subject: [PATCH 197/843] Use EntityDescription - airnow (#55684) --- homeassistant/components/airnow/sensor.py | 79 ++++++++++++----------- 1 file changed, 40 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/airnow/sensor.py b/homeassistant/components/airnow/sensor.py index a0f8d7e701b..ed879d32d4a 100644 --- a/homeassistant/components/airnow/sensor.py +++ b/homeassistant/components/airnow/sensor.py @@ -1,14 +1,15 @@ """Support for the AirNow sensor service.""" -from homeassistant.components.sensor import SensorEntity +from __future__ import annotations + +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import ( ATTR_ATTRIBUTION, - ATTR_DEVICE_CLASS, - ATTR_ICON, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, ) from homeassistant.helpers.update_coordinator import CoordinatorEntity +from . import AirNowDataUpdateCoordinator from .const import ( ATTR_API_AQI, ATTR_API_AQI_DESCRIPTION, @@ -22,69 +23,69 @@ from .const import ( ATTRIBUTION = "Data provided by AirNow" -ATTR_LABEL = "label" -ATTR_UNIT = "unit" - PARALLEL_UPDATES = 1 -SENSOR_TYPES = { - ATTR_API_AQI: { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:blur", - ATTR_LABEL: ATTR_API_AQI, - ATTR_UNIT: "aqi", - }, - ATTR_API_PM25: { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:blur", - ATTR_LABEL: ATTR_API_PM25, - ATTR_UNIT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - }, - ATTR_API_O3: { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:blur", - ATTR_LABEL: ATTR_API_O3, - ATTR_UNIT: CONCENTRATION_PARTS_PER_MILLION, - }, -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=ATTR_API_AQI, + icon="mdi:blur", + name=ATTR_API_AQI, + native_unit_of_measurement="aqi", + ), + SensorEntityDescription( + key=ATTR_API_PM25, + icon="mdi:blur", + name=ATTR_API_PM25, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ), + SensorEntityDescription( + key=ATTR_API_O3, + icon="mdi:blur", + name=ATTR_API_O3, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + ), +) async def async_setup_entry(hass, config_entry, async_add_entities): """Set up AirNow sensor entities based on a config entry.""" coordinator = hass.data[DOMAIN][config_entry.entry_id] - sensors = [] - for sensor in SENSOR_TYPES: - sensors.append(AirNowSensor(coordinator, sensor)) + entities = [AirNowSensor(coordinator, description) for description in SENSOR_TYPES] - async_add_entities(sensors, False) + async_add_entities(entities, False) class AirNowSensor(CoordinatorEntity, SensorEntity): """Define an AirNow sensor.""" - def __init__(self, coordinator, kind): + coordinator: AirNowDataUpdateCoordinator + + def __init__( + self, + coordinator: AirNowDataUpdateCoordinator, + description: SensorEntityDescription, + ) -> None: """Initialize.""" super().__init__(coordinator) - self.kind = kind + self.entity_description = description self._state = None self._attrs = {ATTR_ATTRIBUTION: ATTRIBUTION} - self._attr_name = f"AirNow {SENSOR_TYPES[self.kind][ATTR_LABEL]}" - self._attr_icon = SENSOR_TYPES[self.kind][ATTR_ICON] - self._attr_device_class = SENSOR_TYPES[self.kind][ATTR_DEVICE_CLASS] - self._attr_native_unit_of_measurement = SENSOR_TYPES[self.kind][ATTR_UNIT] - self._attr_unique_id = f"{self.coordinator.latitude}-{self.coordinator.longitude}-{self.kind.lower()}" + self._attr_name = f"AirNow {description.name}" + self._attr_unique_id = ( + f"{coordinator.latitude}-{coordinator.longitude}-{description.key.lower()}" + ) @property def native_value(self): """Return the state.""" - self._state = self.coordinator.data[self.kind] + self._state = self.coordinator.data[self.entity_description.key] return self._state @property def extra_state_attributes(self): """Return the state attributes.""" - if self.kind == ATTR_API_AQI: + if self.entity_description.key == ATTR_API_AQI: self._attrs[SENSOR_AQI_ATTR_DESCR] = self.coordinator.data[ ATTR_API_AQI_DESCRIPTION ] From 4eba2ccebcb5caa235f2bd5bbac287e5f2acdd3d Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 3 Sep 2021 23:35:28 +0200 Subject: [PATCH 198/843] Use EntityDescription - synology_dsm (#55407) --- .../components/synology_dsm/__init__.py | 104 +-- .../components/synology_dsm/binary_sensor.py | 75 ++- .../components/synology_dsm/camera.py | 57 +- .../components/synology_dsm/const.py | 590 +++++++++--------- .../components/synology_dsm/sensor.py | 103 +-- .../components/synology_dsm/switch.py | 29 +- 6 files changed, 486 insertions(+), 472 deletions(-) diff --git a/homeassistant/components/synology_dsm/__init__.py b/homeassistant/components/synology_dsm/__init__.py index 0bc88b683b7..685d2c27d60 100644 --- a/homeassistant/components/synology_dsm/__init__.py +++ b/homeassistant/components/synology_dsm/__init__.py @@ -26,14 +26,9 @@ from synology_dsm.exceptions import ( SynologyDSMRequestException, ) -from homeassistant.components.sensor import ATTR_STATE_CLASS from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry from homeassistant.const import ( ATTR_ATTRIBUTION, - ATTR_DEVICE_CLASS, - ATTR_ICON, - ATTR_NAME, - ATTR_UNIT_OF_MEASUREMENT, CONF_HOST, CONF_MAC, CONF_PASSWORD, @@ -68,7 +63,6 @@ from .const import ( DEFAULT_SCAN_INTERVAL, DEFAULT_VERIFY_SSL, DOMAIN, - ENTITY_ENABLE, EXCEPTION_DETAILS, EXCEPTION_UNKNOWN, PLATFORMS, @@ -82,7 +76,7 @@ from .const import ( SYSTEM_LOADED, UNDO_UPDATE_LISTENER, UTILISATION_SENSORS, - EntityInfo, + SynologyDSMEntityDescription, ) CONFIG_SCHEMA = cv.deprecated(DOMAIN) @@ -109,12 +103,12 @@ async def async_setup_entry( # noqa: C901 if "SYNO." in entity_entry.unique_id: return None - entries = { - **STORAGE_DISK_BINARY_SENSORS, - **STORAGE_DISK_SENSORS, - **STORAGE_VOL_SENSORS, - **UTILISATION_SENSORS, - } + entries = ( + *STORAGE_DISK_BINARY_SENSORS, + *STORAGE_DISK_SENSORS, + *STORAGE_VOL_SENSORS, + *UTILISATION_SENSORS, + ) infos = entity_entry.unique_id.split("_") serial = infos.pop(0) label = infos.pop(0) @@ -129,22 +123,22 @@ async def async_setup_entry( # noqa: C901 return None entity_type: str | None = None - for entity_key, entity_attrs in entries.items(): + for description in entries: if ( device_id - and entity_attrs[ATTR_NAME] == "Status" + and description.name == "Status" and "Status" in entity_entry.unique_id and "(Smart)" not in entity_entry.unique_id ): - if "sd" in device_id and "disk" in entity_key: - entity_type = entity_key + if "sd" in device_id and "disk" in description.key: + entity_type = description.key continue - if "volume" in device_id and "volume" in entity_key: - entity_type = entity_key + if "volume" in device_id and "volume" in description.key: + entity_type = description.key continue - if entity_attrs[ATTR_NAME] == label: - entity_type = entity_key + if description.name == label: + entity_type = description.key if entity_type is None: return None @@ -604,51 +598,25 @@ class SynoApi: class SynologyDSMBaseEntity(CoordinatorEntity): """Representation of a Synology NAS entry.""" + entity_description: SynologyDSMEntityDescription + unique_id: str + _attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} + def __init__( self, api: SynoApi, - entity_type: str, - entity_info: EntityInfo, coordinator: DataUpdateCoordinator[dict[str, dict[str, Any]]], + description: SynologyDSMEntityDescription, ) -> None: """Initialize the Synology DSM entity.""" super().__init__(coordinator) + self.entity_description = description self._api = api - self._api_key = entity_type.split(":")[0] - self.entity_type = entity_type.split(":")[-1] - self._name = f"{api.network.hostname} {entity_info[ATTR_NAME]}" - self._class = entity_info[ATTR_DEVICE_CLASS] - self._enable_default = entity_info[ENTITY_ENABLE] - self._icon = entity_info[ATTR_ICON] - self._unit = entity_info[ATTR_UNIT_OF_MEASUREMENT] - self._unique_id = f"{self._api.information.serial}_{entity_type}" - self._attr_state_class = entity_info[ATTR_STATE_CLASS] - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return self._unique_id - - @property - def name(self) -> str: - """Return the name.""" - return self._name - - @property - def icon(self) -> str | None: - """Return the icon.""" - return self._icon - - @property - def device_class(self) -> str | None: - """Return the class of this device.""" - return self._class - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return the state attributes.""" - return {ATTR_ATTRIBUTION: ATTRIBUTION} + self._attr_name = f"{api.network.hostname} {description.name}" + self._attr_unique_id: str = ( + f"{api.information.serial}_{description.api_key}:{description.key}" + ) @property def device_info(self) -> DeviceInfo: @@ -661,14 +629,11 @@ class SynologyDSMBaseEntity(CoordinatorEntity): "sw_version": self._api.information.version_string, } - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return self._enable_default - async def async_added_to_hass(self) -> None: """Register entity for updates from API.""" - self.async_on_remove(self._api.subscribe(self._api_key, self.unique_id)) + self.async_on_remove( + self._api.subscribe(self.entity_description.api_key, self.unique_id) + ) await super().async_added_to_hass() @@ -678,13 +643,12 @@ class SynologyDSMDeviceEntity(SynologyDSMBaseEntity): def __init__( self, api: SynoApi, - entity_type: str, - entity_info: EntityInfo, coordinator: DataUpdateCoordinator[dict[str, dict[str, Any]]], + description: SynologyDSMEntityDescription, device_id: str | None = None, ) -> None: """Initialize the Synology DSM disk or volume entity.""" - super().__init__(api, entity_type, entity_info, coordinator) + super().__init__(api, coordinator, description) self._device_id = device_id self._device_name: str | None = None self._device_manufacturer: str | None = None @@ -692,7 +656,7 @@ class SynologyDSMDeviceEntity(SynologyDSMBaseEntity): self._device_firmware: str | None = None self._device_type = None - if "volume" in entity_type: + if "volume" in description.key: volume = self._api.storage.get_volume(self._device_id) # Volume does not have a name self._device_name = volume["id"].replace("_", " ").capitalize() @@ -705,7 +669,7 @@ class SynologyDSMDeviceEntity(SynologyDSMBaseEntity): .replace("raid", "RAID") .replace("shr", "SHR") ) - elif "disk" in entity_type: + elif "disk" in description.key: disk = self._api.storage.get_disk(self._device_id) self._device_name = disk["name"] self._device_manufacturer = disk["vendor"] @@ -713,9 +677,9 @@ class SynologyDSMDeviceEntity(SynologyDSMBaseEntity): self._device_firmware = disk["firm"] self._device_type = disk["diskType"] self._name = ( - f"{self._api.network.hostname} {self._device_name} {entity_info[ATTR_NAME]}" + f"{self._api.network.hostname} {self._device_name} {description.name}" ) - self._unique_id += f"_{self._device_id}" + self._attr_unique_id += f"_{self._device_id}" @property def available(self) -> bool: diff --git a/homeassistant/components/synology_dsm/binary_sensor.py b/homeassistant/components/synology_dsm/binary_sensor.py index 5f27aa3b038..fc518d6c662 100644 --- a/homeassistant/components/synology_dsm/binary_sensor.py +++ b/homeassistant/components/synology_dsm/binary_sensor.py @@ -1,11 +1,14 @@ """Support for Synology DSM binary sensors.""" from __future__ import annotations +from typing import Any + from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DISKS from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import SynoApi, SynologyDSMBaseEntity, SynologyDSMDeviceEntity from .const import ( @@ -15,6 +18,7 @@ from .const import ( STORAGE_DISK_BINARY_SENSORS, SYNO_API, UPGRADE_BINARY_SENSORS, + SynologyDSMBinarySensorEntityDescription, ) @@ -32,39 +36,52 @@ async def async_setup_entry( | SynoDSMUpgradeBinarySensor | SynoDSMStorageBinarySensor ] = [ - SynoDSMSecurityBinarySensor(api, sensor_type, sensor, coordinator) - for sensor_type, sensor in SECURITY_BINARY_SENSORS.items() + SynoDSMSecurityBinarySensor(api, coordinator, description) + for description in SECURITY_BINARY_SENSORS ] - entities += [ - SynoDSMUpgradeBinarySensor(api, sensor_type, sensor, coordinator) - for sensor_type, sensor in UPGRADE_BINARY_SENSORS.items() - ] + entities.extend( + [ + SynoDSMUpgradeBinarySensor(api, coordinator, description) + for description in UPGRADE_BINARY_SENSORS + ] + ) # Handle all disks if api.storage.disks_ids: - for disk in entry.data.get(CONF_DISKS, api.storage.disks_ids): - entities += [ - SynoDSMStorageBinarySensor( - api, - sensor_type, - sensor, - coordinator, - disk, - ) - for sensor_type, sensor in STORAGE_DISK_BINARY_SENSORS.items() + entities.extend( + [ + SynoDSMStorageBinarySensor(api, coordinator, description, disk) + for disk in entry.data.get(CONF_DISKS, api.storage.disks_ids) + for description in STORAGE_DISK_BINARY_SENSORS ] + ) async_add_entities(entities) -class SynoDSMSecurityBinarySensor(SynologyDSMBaseEntity, BinarySensorEntity): +class SynoDSMBinarySensor(SynologyDSMBaseEntity, BinarySensorEntity): + """Mixin for binary sensor specific attributes.""" + + entity_description: SynologyDSMBinarySensorEntityDescription + + def __init__( + self, + api: SynoApi, + coordinator: DataUpdateCoordinator[dict[str, dict[str, Any]]], + description: SynologyDSMBinarySensorEntityDescription, + ) -> None: + """Initialize the Synology DSM binary_sensor entity.""" + super().__init__(api, coordinator, description) + + +class SynoDSMSecurityBinarySensor(SynoDSMBinarySensor): """Representation a Synology Security binary sensor.""" @property def is_on(self) -> bool: """Return the state.""" - return getattr(self._api.security, self.entity_type) != "safe" # type: ignore[no-any-return] + return getattr(self._api.security, self.entity_description.key) != "safe" # type: ignore[no-any-return] @property def available(self) -> bool: @@ -77,22 +94,36 @@ class SynoDSMSecurityBinarySensor(SynologyDSMBaseEntity, BinarySensorEntity): return self._api.security.status_by_check # type: ignore[no-any-return] -class SynoDSMStorageBinarySensor(SynologyDSMDeviceEntity, BinarySensorEntity): +class SynoDSMStorageBinarySensor(SynologyDSMDeviceEntity, SynoDSMBinarySensor): """Representation a Synology Storage binary sensor.""" + entity_description: SynologyDSMBinarySensorEntityDescription + + def __init__( + self, + api: SynoApi, + coordinator: DataUpdateCoordinator[dict[str, dict[str, Any]]], + description: SynologyDSMBinarySensorEntityDescription, + device_id: str | None = None, + ) -> None: + """Initialize the Synology DSM storage binary_sensor entity.""" + super().__init__(api, coordinator, description, device_id) + @property def is_on(self) -> bool: """Return the state.""" - return bool(getattr(self._api.storage, self.entity_type)(self._device_id)) + return bool( + getattr(self._api.storage, self.entity_description.key)(self._device_id) + ) -class SynoDSMUpgradeBinarySensor(SynologyDSMBaseEntity, BinarySensorEntity): +class SynoDSMUpgradeBinarySensor(SynoDSMBinarySensor): """Representation a Synology Upgrade binary sensor.""" @property def is_on(self) -> bool: """Return the state.""" - return bool(getattr(self._api.upgrade, self.entity_type)) + return bool(getattr(self._api.upgrade, self.entity_description.key)) @property def available(self) -> bool: diff --git a/homeassistant/components/synology_dsm/camera.py b/homeassistant/components/synology_dsm/camera.py index d609a434ae2..c305d11f4e0 100644 --- a/homeassistant/components/synology_dsm/camera.py +++ b/homeassistant/components/synology_dsm/camera.py @@ -1,6 +1,7 @@ """Support for Synology DSM cameras.""" from __future__ import annotations +from dataclasses import dataclass import logging from synology_dsm.api.surveillance_station import SynoCamera, SynoSurveillanceStation @@ -9,26 +10,30 @@ from synology_dsm.exceptions import ( SynologyDSMRequestException, ) -from homeassistant.components.camera import SUPPORT_STREAM, Camera -from homeassistant.components.sensor import ATTR_STATE_CLASS -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ICON, - ATTR_NAME, - ATTR_UNIT_OF_MEASUREMENT, +from homeassistant.components.camera import ( + SUPPORT_STREAM, + Camera, + CameraEntityDescription, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import SynoApi, SynologyDSMBaseEntity -from .const import COORDINATOR_CAMERAS, DOMAIN, ENTITY_ENABLE, SYNO_API +from .const import COORDINATOR_CAMERAS, DOMAIN, SYNO_API, SynologyDSMEntityDescription _LOGGER = logging.getLogger(__name__) +@dataclass +class SynologyDSMCameraEntityDescription( + CameraEntityDescription, SynologyDSMEntityDescription +): + """Describes Synology DSM camera entity.""" + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: @@ -56,6 +61,7 @@ class SynoDSMCamera(SynologyDSMBaseEntity, Camera): """Representation a Synology camera.""" coordinator: DataUpdateCoordinator[dict[str, dict[str, SynoCamera]]] + entity_description: SynologyDSMCameraEntityDescription def __init__( self, @@ -64,26 +70,21 @@ class SynoDSMCamera(SynologyDSMBaseEntity, Camera): camera_id: str, ) -> None: """Initialize a Synology camera.""" - super().__init__( - api, - f"{SynoSurveillanceStation.CAMERA_API_KEY}:{camera_id}", - { - ATTR_NAME: coordinator.data["cameras"][camera_id].name, - ENTITY_ENABLE: coordinator.data["cameras"][camera_id].is_enabled, - ATTR_DEVICE_CLASS: None, - ATTR_ICON: None, - ATTR_UNIT_OF_MEASUREMENT: None, - ATTR_STATE_CLASS: None, - }, - coordinator, + description = SynologyDSMCameraEntityDescription( + api_key=SynoSurveillanceStation.CAMERA_API_KEY, + key=camera_id, + name=coordinator.data["cameras"][camera_id].name, + entity_registry_enabled_default=coordinator.data["cameras"][ + camera_id + ].is_enabled, ) + super().__init__(api, coordinator, description) Camera.__init__(self) - self._camera_id = camera_id @property def camera_data(self) -> SynoCamera: """Camera data.""" - return self.coordinator.data["cameras"][self._camera_id] + return self.coordinator.data["cameras"][self.entity_description.key] @property def device_info(self) -> DeviceInfo: @@ -134,7 +135,7 @@ class SynoDSMCamera(SynologyDSMBaseEntity, Camera): if not self.available: return None try: - return self._api.surveillance_station.get_camera_image(self._camera_id) # type: ignore[no-any-return] + return self._api.surveillance_station.get_camera_image(self.entity_description.key) # type: ignore[no-any-return] except ( SynologyDSMAPIErrorException, SynologyDSMRequestException, @@ -163,7 +164,9 @@ class SynoDSMCamera(SynologyDSMBaseEntity, Camera): "SynoDSMCamera.enable_motion_detection(%s)", self.camera_data.name, ) - self._api.surveillance_station.enable_motion_detection(self._camera_id) + self._api.surveillance_station.enable_motion_detection( + self.entity_description.key + ) def disable_motion_detection(self) -> None: """Disable motion detection in camera.""" @@ -171,4 +174,6 @@ class SynoDSMCamera(SynologyDSMBaseEntity, Camera): "SynoDSMCamera.disable_motion_detection(%s)", self.camera_data.name, ) - self._api.surveillance_station.disable_motion_detection(self._camera_id) + self._api.surveillance_station.disable_motion_detection( + self.entity_description.key + ) diff --git a/homeassistant/components/synology_dsm/const.py b/homeassistant/components/synology_dsm/const.py index 1fc6ba6e09b..e054a9594a0 100644 --- a/homeassistant/components/synology_dsm/const.py +++ b/homeassistant/components/synology_dsm/const.py @@ -1,7 +1,7 @@ """Constants for Synology DSM.""" from __future__ import annotations -from typing import Final, TypedDict +from dataclasses import dataclass from synology_dsm.api.core.security import SynoCoreSecurity from synology_dsm.api.core.upgrade import SynoCoreUpgrade @@ -13,13 +13,14 @@ from synology_dsm.api.surveillance_station import SynoSurveillanceStation from homeassistant.components.binary_sensor import ( DEVICE_CLASS_SAFETY, DEVICE_CLASS_UPDATE, + BinarySensorEntityDescription, ) -from homeassistant.components.sensor import ATTR_STATE_CLASS, STATE_CLASS_MEASUREMENT +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + SensorEntityDescription, +) +from homeassistant.components.switch import SwitchEntityDescription from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ICON, - ATTR_NAME, - ATTR_UNIT_OF_MEASUREMENT, DATA_MEGABYTES, DATA_RATE_KILOBYTES_PER_SECOND, DATA_TERABYTES, @@ -28,18 +29,7 @@ from homeassistant.const import ( PERCENTAGE, TEMP_CELSIUS, ) - - -class EntityInfo(TypedDict): - """TypedDict for EntityInfo.""" - - name: str - unit_of_measurement: str | None - icon: str | None - device_class: str | None - state_class: str | None - enable: bool - +from homeassistant.helpers.entity import EntityDescription DOMAIN = "synology_dsm" PLATFORMS = ["binary_sensor", "camera", "sensor", "switch"] @@ -68,7 +58,6 @@ DEFAULT_SCAN_INTERVAL = 15 # min DEFAULT_TIMEOUT = 10 # sec ENTITY_UNIT_LOAD = "load" -ENTITY_ENABLE: Final = "enable" # Services SERVICE_REBOOT = "reboot" @@ -78,285 +67,302 @@ SERVICES = [ SERVICE_SHUTDOWN, ] -# Entity keys should start with the API_KEY to fetch + +@dataclass +class SynologyDSMRequiredKeysMixin: + """Mixin for required keys.""" + + api_key: str + + +@dataclass +class SynologyDSMEntityDescription(EntityDescription, SynologyDSMRequiredKeysMixin): + """Generic Synology DSM entity description.""" + + +@dataclass +class SynologyDSMBinarySensorEntityDescription( + BinarySensorEntityDescription, SynologyDSMEntityDescription +): + """Describes Synology DSM binary sensor entity.""" + + +@dataclass +class SynologyDSMSensorEntityDescription( + SensorEntityDescription, SynologyDSMEntityDescription +): + """Describes Synology DSM sensor entity.""" + + +@dataclass +class SynologyDSMSwitchEntityDescription( + SwitchEntityDescription, SynologyDSMEntityDescription +): + """Describes Synology DSM switch entity.""" + # Binary sensors -UPGRADE_BINARY_SENSORS: dict[str, EntityInfo] = { - f"{SynoCoreUpgrade.API_KEY}:update_available": { - ATTR_NAME: "Update available", - ATTR_UNIT_OF_MEASUREMENT: None, - ATTR_ICON: None, - ATTR_DEVICE_CLASS: DEVICE_CLASS_UPDATE, - ENTITY_ENABLE: True, - ATTR_STATE_CLASS: None, - }, -} +UPGRADE_BINARY_SENSORS: tuple[SynologyDSMBinarySensorEntityDescription, ...] = ( + SynologyDSMBinarySensorEntityDescription( + api_key=SynoCoreUpgrade.API_KEY, + key="update_available", + name="Update available", + device_class=DEVICE_CLASS_UPDATE, + ), +) -SECURITY_BINARY_SENSORS: dict[str, EntityInfo] = { - f"{SynoCoreSecurity.API_KEY}:status": { - ATTR_NAME: "Security status", - ATTR_UNIT_OF_MEASUREMENT: None, - ATTR_ICON: None, - ATTR_DEVICE_CLASS: DEVICE_CLASS_SAFETY, - ENTITY_ENABLE: True, - ATTR_STATE_CLASS: None, - }, -} +SECURITY_BINARY_SENSORS: tuple[SynologyDSMBinarySensorEntityDescription, ...] = ( + SynologyDSMBinarySensorEntityDescription( + api_key=SynoCoreSecurity.API_KEY, + key="status", + name="Security status", + device_class=DEVICE_CLASS_SAFETY, + ), +) -STORAGE_DISK_BINARY_SENSORS: dict[str, EntityInfo] = { - f"{SynoStorage.API_KEY}:disk_exceed_bad_sector_thr": { - ATTR_NAME: "Exceeded Max Bad Sectors", - ATTR_UNIT_OF_MEASUREMENT: None, - ATTR_ICON: None, - ATTR_DEVICE_CLASS: DEVICE_CLASS_SAFETY, - ENTITY_ENABLE: True, - ATTR_STATE_CLASS: None, - }, - f"{SynoStorage.API_KEY}:disk_below_remain_life_thr": { - ATTR_NAME: "Below Min Remaining Life", - ATTR_UNIT_OF_MEASUREMENT: None, - ATTR_ICON: None, - ATTR_DEVICE_CLASS: DEVICE_CLASS_SAFETY, - ENTITY_ENABLE: True, - ATTR_STATE_CLASS: None, - }, -} +STORAGE_DISK_BINARY_SENSORS: tuple[SynologyDSMBinarySensorEntityDescription, ...] = ( + SynologyDSMBinarySensorEntityDescription( + api_key=SynoStorage.API_KEY, + key="disk_exceed_bad_sector_thr", + name="Exceeded Max Bad Sectors", + device_class=DEVICE_CLASS_SAFETY, + ), + SynologyDSMBinarySensorEntityDescription( + api_key=SynoStorage.API_KEY, + key="disk_below_remain_life_thr", + name="Below Min Remaining Life", + device_class=DEVICE_CLASS_SAFETY, + ), +) # Sensors -UTILISATION_SENSORS: dict[str, EntityInfo] = { - f"{SynoCoreUtilization.API_KEY}:cpu_other_load": { - ATTR_NAME: "CPU Utilization (Other)", - ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, - ATTR_ICON: "mdi:chip", - ATTR_DEVICE_CLASS: None, - ENTITY_ENABLE: False, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - f"{SynoCoreUtilization.API_KEY}:cpu_user_load": { - ATTR_NAME: "CPU Utilization (User)", - ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, - ATTR_ICON: "mdi:chip", - ATTR_DEVICE_CLASS: None, - ENTITY_ENABLE: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - f"{SynoCoreUtilization.API_KEY}:cpu_system_load": { - ATTR_NAME: "CPU Utilization (System)", - ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, - ATTR_ICON: "mdi:chip", - ATTR_DEVICE_CLASS: None, - ENTITY_ENABLE: False, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - f"{SynoCoreUtilization.API_KEY}:cpu_total_load": { - ATTR_NAME: "CPU Utilization (Total)", - ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, - ATTR_ICON: "mdi:chip", - ATTR_DEVICE_CLASS: None, - ENTITY_ENABLE: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - f"{SynoCoreUtilization.API_KEY}:cpu_1min_load": { - ATTR_NAME: "CPU Load Average (1 min)", - ATTR_UNIT_OF_MEASUREMENT: ENTITY_UNIT_LOAD, - ATTR_ICON: "mdi:chip", - ATTR_DEVICE_CLASS: None, - ENTITY_ENABLE: False, - ATTR_STATE_CLASS: None, - }, - f"{SynoCoreUtilization.API_KEY}:cpu_5min_load": { - ATTR_NAME: "CPU Load Average (5 min)", - ATTR_UNIT_OF_MEASUREMENT: ENTITY_UNIT_LOAD, - ATTR_ICON: "mdi:chip", - ATTR_DEVICE_CLASS: None, - ENTITY_ENABLE: True, - ATTR_STATE_CLASS: None, - }, - f"{SynoCoreUtilization.API_KEY}:cpu_15min_load": { - ATTR_NAME: "CPU Load Average (15 min)", - ATTR_UNIT_OF_MEASUREMENT: ENTITY_UNIT_LOAD, - ATTR_ICON: "mdi:chip", - ATTR_DEVICE_CLASS: None, - ENTITY_ENABLE: True, - ATTR_STATE_CLASS: None, - }, - f"{SynoCoreUtilization.API_KEY}:memory_real_usage": { - ATTR_NAME: "Memory Usage (Real)", - ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, - ATTR_ICON: "mdi:memory", - ATTR_DEVICE_CLASS: None, - ENTITY_ENABLE: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - f"{SynoCoreUtilization.API_KEY}:memory_size": { - ATTR_NAME: "Memory Size", - ATTR_UNIT_OF_MEASUREMENT: DATA_MEGABYTES, - ATTR_ICON: "mdi:memory", - ATTR_DEVICE_CLASS: None, - ENTITY_ENABLE: False, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - f"{SynoCoreUtilization.API_KEY}:memory_cached": { - ATTR_NAME: "Memory Cached", - ATTR_UNIT_OF_MEASUREMENT: DATA_MEGABYTES, - ATTR_ICON: "mdi:memory", - ATTR_DEVICE_CLASS: None, - ENTITY_ENABLE: False, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - f"{SynoCoreUtilization.API_KEY}:memory_available_swap": { - ATTR_NAME: "Memory Available (Swap)", - ATTR_UNIT_OF_MEASUREMENT: DATA_MEGABYTES, - ATTR_ICON: "mdi:memory", - ATTR_DEVICE_CLASS: None, - ENTITY_ENABLE: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - f"{SynoCoreUtilization.API_KEY}:memory_available_real": { - ATTR_NAME: "Memory Available (Real)", - ATTR_UNIT_OF_MEASUREMENT: DATA_MEGABYTES, - ATTR_ICON: "mdi:memory", - ATTR_DEVICE_CLASS: None, - ENTITY_ENABLE: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - f"{SynoCoreUtilization.API_KEY}:memory_total_swap": { - ATTR_NAME: "Memory Total (Swap)", - ATTR_UNIT_OF_MEASUREMENT: DATA_MEGABYTES, - ATTR_ICON: "mdi:memory", - ATTR_DEVICE_CLASS: None, - ENTITY_ENABLE: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - f"{SynoCoreUtilization.API_KEY}:memory_total_real": { - ATTR_NAME: "Memory Total (Real)", - ATTR_UNIT_OF_MEASUREMENT: DATA_MEGABYTES, - ATTR_ICON: "mdi:memory", - ATTR_DEVICE_CLASS: None, - ENTITY_ENABLE: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - f"{SynoCoreUtilization.API_KEY}:network_up": { - ATTR_NAME: "Upload Throughput", - ATTR_UNIT_OF_MEASUREMENT: DATA_RATE_KILOBYTES_PER_SECOND, - ATTR_ICON: "mdi:upload", - ATTR_DEVICE_CLASS: None, - ENTITY_ENABLE: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - f"{SynoCoreUtilization.API_KEY}:network_down": { - ATTR_NAME: "Download Throughput", - ATTR_UNIT_OF_MEASUREMENT: DATA_RATE_KILOBYTES_PER_SECOND, - ATTR_ICON: "mdi:download", - ATTR_DEVICE_CLASS: None, - ENTITY_ENABLE: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, -} -STORAGE_VOL_SENSORS: dict[str, EntityInfo] = { - f"{SynoStorage.API_KEY}:volume_status": { - ATTR_NAME: "Status", - ATTR_UNIT_OF_MEASUREMENT: None, - ATTR_ICON: "mdi:checkbox-marked-circle-outline", - ATTR_DEVICE_CLASS: None, - ENTITY_ENABLE: True, - ATTR_STATE_CLASS: None, - }, - f"{SynoStorage.API_KEY}:volume_size_total": { - ATTR_NAME: "Total Size", - ATTR_UNIT_OF_MEASUREMENT: DATA_TERABYTES, - ATTR_ICON: "mdi:chart-pie", - ATTR_DEVICE_CLASS: None, - ENTITY_ENABLE: False, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - f"{SynoStorage.API_KEY}:volume_size_used": { - ATTR_NAME: "Used Space", - ATTR_UNIT_OF_MEASUREMENT: DATA_TERABYTES, - ATTR_ICON: "mdi:chart-pie", - ATTR_DEVICE_CLASS: None, - ENTITY_ENABLE: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - f"{SynoStorage.API_KEY}:volume_percentage_used": { - ATTR_NAME: "Volume Used", - ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, - ATTR_ICON: "mdi:chart-pie", - ATTR_DEVICE_CLASS: None, - ENTITY_ENABLE: True, - ATTR_STATE_CLASS: None, - }, - f"{SynoStorage.API_KEY}:volume_disk_temp_avg": { - ATTR_NAME: "Average Disk Temp", - ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, - ATTR_ICON: None, - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ENTITY_ENABLE: True, - ATTR_STATE_CLASS: None, - }, - f"{SynoStorage.API_KEY}:volume_disk_temp_max": { - ATTR_NAME: "Maximum Disk Temp", - ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, - ATTR_ICON: None, - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ENTITY_ENABLE: False, - ATTR_STATE_CLASS: None, - }, -} -STORAGE_DISK_SENSORS: dict[str, EntityInfo] = { - f"{SynoStorage.API_KEY}:disk_smart_status": { - ATTR_NAME: "Status (Smart)", - ATTR_UNIT_OF_MEASUREMENT: None, - ATTR_ICON: "mdi:checkbox-marked-circle-outline", - ATTR_DEVICE_CLASS: None, - ENTITY_ENABLE: False, - ATTR_STATE_CLASS: None, - }, - f"{SynoStorage.API_KEY}:disk_status": { - ATTR_NAME: "Status", - ATTR_UNIT_OF_MEASUREMENT: None, - ATTR_ICON: "mdi:checkbox-marked-circle-outline", - ATTR_DEVICE_CLASS: None, - ENTITY_ENABLE: True, - ATTR_STATE_CLASS: None, - }, - f"{SynoStorage.API_KEY}:disk_temp": { - ATTR_NAME: "Temperature", - ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, - ATTR_ICON: None, - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ENTITY_ENABLE: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, -} +UTILISATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( + SynologyDSMSensorEntityDescription( + api_key=SynoCoreUtilization.API_KEY, + key="cpu_other_load", + name="CPU Utilization (Other)", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:chip", + entity_registry_enabled_default=False, + state_class=STATE_CLASS_MEASUREMENT, + ), + SynologyDSMSensorEntityDescription( + api_key=SynoCoreUtilization.API_KEY, + key="cpu_user_load", + name="CPU Utilization (User)", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:chip", + state_class=STATE_CLASS_MEASUREMENT, + ), + SynologyDSMSensorEntityDescription( + api_key=SynoCoreUtilization.API_KEY, + key="cpu_system_load", + name="CPU Utilization (System)", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:chip", + entity_registry_enabled_default=False, + state_class=STATE_CLASS_MEASUREMENT, + ), + SynologyDSMSensorEntityDescription( + api_key=SynoCoreUtilization.API_KEY, + key="cpu_total_load", + name="CPU Utilization (Total)", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:chip", + state_class=STATE_CLASS_MEASUREMENT, + ), + SynologyDSMSensorEntityDescription( + api_key=SynoCoreUtilization.API_KEY, + key="cpu_1min_load", + name="CPU Load Average (1 min)", + native_unit_of_measurement=ENTITY_UNIT_LOAD, + icon="mdi:chip", + entity_registry_enabled_default=False, + ), + SynologyDSMSensorEntityDescription( + api_key=SynoCoreUtilization.API_KEY, + key="cpu_5min_load", + name="CPU Load Average (5 min)", + native_unit_of_measurement=ENTITY_UNIT_LOAD, + icon="mdi:chip", + ), + SynologyDSMSensorEntityDescription( + api_key=SynoCoreUtilization.API_KEY, + key="cpu_15min_load", + name="CPU Load Average (15 min)", + native_unit_of_measurement=ENTITY_UNIT_LOAD, + icon="mdi:chip", + ), + SynologyDSMSensorEntityDescription( + api_key=SynoCoreUtilization.API_KEY, + key="memory_real_usage", + name="Memory Usage (Real)", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:memory", + state_class=STATE_CLASS_MEASUREMENT, + ), + SynologyDSMSensorEntityDescription( + api_key=SynoCoreUtilization.API_KEY, + key="memory_size", + name="Memory Size", + native_unit_of_measurement=DATA_MEGABYTES, + icon="mdi:memory", + entity_registry_enabled_default=False, + state_class=STATE_CLASS_MEASUREMENT, + ), + SynologyDSMSensorEntityDescription( + api_key=SynoCoreUtilization.API_KEY, + key="memory_cached", + name="Memory Cached", + native_unit_of_measurement=DATA_MEGABYTES, + icon="mdi:memory", + entity_registry_enabled_default=False, + state_class=STATE_CLASS_MEASUREMENT, + ), + SynologyDSMSensorEntityDescription( + api_key=SynoCoreUtilization.API_KEY, + key="memory_available_swap", + name="Memory Available (Swap)", + native_unit_of_measurement=DATA_MEGABYTES, + icon="mdi:memory", + state_class=STATE_CLASS_MEASUREMENT, + ), + SynologyDSMSensorEntityDescription( + api_key=SynoCoreUtilization.API_KEY, + key="memory_available_real", + name="Memory Available (Real)", + native_unit_of_measurement=DATA_MEGABYTES, + icon="mdi:memory", + state_class=STATE_CLASS_MEASUREMENT, + ), + SynologyDSMSensorEntityDescription( + api_key=SynoCoreUtilization.API_KEY, + key="memory_total_swap", + name="Memory Total (Swap)", + native_unit_of_measurement=DATA_MEGABYTES, + icon="mdi:memory", + state_class=STATE_CLASS_MEASUREMENT, + ), + SynologyDSMSensorEntityDescription( + api_key=SynoCoreUtilization.API_KEY, + key="memory_total_real", + name="Memory Total (Real)", + native_unit_of_measurement=DATA_MEGABYTES, + icon="mdi:memory", + state_class=STATE_CLASS_MEASUREMENT, + ), + SynologyDSMSensorEntityDescription( + api_key=SynoCoreUtilization.API_KEY, + key="network_up", + name="Upload Throughput", + native_unit_of_measurement=DATA_RATE_KILOBYTES_PER_SECOND, + icon="mdi:upload", + state_class=STATE_CLASS_MEASUREMENT, + ), + SynologyDSMSensorEntityDescription( + api_key=SynoCoreUtilization.API_KEY, + key="network_down", + name="Download Throughput", + native_unit_of_measurement=DATA_RATE_KILOBYTES_PER_SECOND, + icon="mdi:download", + state_class=STATE_CLASS_MEASUREMENT, + ), +) +STORAGE_VOL_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( + SynologyDSMSensorEntityDescription( + api_key=SynoStorage.API_KEY, + key="volume_status", + name="Status", + icon="mdi:checkbox-marked-circle-outline", + ), + SynologyDSMSensorEntityDescription( + api_key=SynoStorage.API_KEY, + key="volume_size_total", + name="Total Size", + native_unit_of_measurement=DATA_TERABYTES, + icon="mdi:chart-pie", + entity_registry_enabled_default=False, + state_class=STATE_CLASS_MEASUREMENT, + ), + SynologyDSMSensorEntityDescription( + api_key=SynoStorage.API_KEY, + key="volume_size_used", + name="Used Space", + native_unit_of_measurement=DATA_TERABYTES, + icon="mdi:chart-pie", + state_class=STATE_CLASS_MEASUREMENT, + ), + SynologyDSMSensorEntityDescription( + api_key=SynoStorage.API_KEY, + key="volume_percentage_used", + name="Volume Used", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:chart-pie", + ), + SynologyDSMSensorEntityDescription( + api_key=SynoStorage.API_KEY, + key="volume_disk_temp_avg", + name="Average Disk Temp", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SynologyDSMSensorEntityDescription( + api_key=SynoStorage.API_KEY, + key="volume_disk_temp_max", + name="Maximum Disk Temp", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + entity_registry_enabled_default=False, + ), +) +STORAGE_DISK_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( + SynologyDSMSensorEntityDescription( + api_key=SynoStorage.API_KEY, + key="disk_smart_status", + name="Status (Smart)", + icon="mdi:checkbox-marked-circle-outline", + entity_registry_enabled_default=False, + ), + SynologyDSMSensorEntityDescription( + api_key=SynoStorage.API_KEY, + key="disk_status", + name="Status", + icon="mdi:checkbox-marked-circle-outline", + ), + SynologyDSMSensorEntityDescription( + api_key=SynoStorage.API_KEY, + key="disk_temp", + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), +) -INFORMATION_SENSORS: dict[str, EntityInfo] = { - f"{SynoDSMInformation.API_KEY}:temperature": { - ATTR_NAME: "temperature", - ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, - ATTR_ICON: None, - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ENTITY_ENABLE: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - f"{SynoDSMInformation.API_KEY}:uptime": { - ATTR_NAME: "last boot", - ATTR_UNIT_OF_MEASUREMENT: None, - ATTR_ICON: None, - ATTR_DEVICE_CLASS: DEVICE_CLASS_TIMESTAMP, - ENTITY_ENABLE: False, - ATTR_STATE_CLASS: None, - }, -} +INFORMATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( + SynologyDSMSensorEntityDescription( + api_key=SynoDSMInformation.API_KEY, + key="temperature", + name="temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SynologyDSMSensorEntityDescription( + api_key=SynoDSMInformation.API_KEY, + key="uptime", + name="last boot", + device_class=DEVICE_CLASS_TIMESTAMP, + entity_registry_enabled_default=False, + ), +) # Switch -SURVEILLANCE_SWITCH: dict[str, EntityInfo] = { - f"{SynoSurveillanceStation.HOME_MODE_API_KEY}:home_mode": { - ATTR_NAME: "home mode", - ATTR_UNIT_OF_MEASUREMENT: None, - ATTR_ICON: "mdi:home-account", - ATTR_DEVICE_CLASS: None, - ENTITY_ENABLE: True, - ATTR_STATE_CLASS: None, - }, -} +SURVEILLANCE_SWITCH: tuple[SynologyDSMSwitchEntityDescription, ...] = ( + SynologyDSMSwitchEntityDescription( + api_key=SynoSurveillanceStation.HOME_MODE_API_KEY, + key="home_mode", + name="home mode", + icon="mdi:home-account", + ), +) diff --git a/homeassistant/components/synology_dsm/sensor.py b/homeassistant/components/synology_dsm/sensor.py index 72ddb944b11..1aa7e35d992 100644 --- a/homeassistant/components/synology_dsm/sensor.py +++ b/homeassistant/components/synology_dsm/sensor.py @@ -28,7 +28,7 @@ from .const import ( STORAGE_VOL_SENSORS, SYNO_API, UTILISATION_SENSORS, - EntityInfo, + SynologyDSMSensorEntityDescription, ) @@ -42,77 +42,77 @@ async def async_setup_entry( coordinator = data[COORDINATOR_CENTRAL] entities: list[SynoDSMUtilSensor | SynoDSMStorageSensor | SynoDSMInfoSensor] = [ - SynoDSMUtilSensor(api, sensor_type, sensor, coordinator) - for sensor_type, sensor in UTILISATION_SENSORS.items() + SynoDSMUtilSensor(api, coordinator, description) + for description in UTILISATION_SENSORS ] # Handle all volumes if api.storage.volumes_ids: - for volume in entry.data.get(CONF_VOLUMES, api.storage.volumes_ids): - entities += [ - SynoDSMStorageSensor( - api, - sensor_type, - sensor, - coordinator, - volume, - ) - for sensor_type, sensor in STORAGE_VOL_SENSORS.items() + entities.extend( + [ + SynoDSMStorageSensor(api, coordinator, description, volume) + for volume in entry.data.get(CONF_VOLUMES, api.storage.volumes_ids) + for description in STORAGE_VOL_SENSORS ] + ) # Handle all disks if api.storage.disks_ids: - for disk in entry.data.get(CONF_DISKS, api.storage.disks_ids): - entities += [ - SynoDSMStorageSensor( - api, - sensor_type, - sensor, - coordinator, - disk, - ) - for sensor_type, sensor in STORAGE_DISK_SENSORS.items() + entities.extend( + [ + SynoDSMStorageSensor(api, coordinator, description, disk) + for disk in entry.data.get(CONF_DISKS, api.storage.disks_ids) + for description in STORAGE_DISK_SENSORS ] + ) - entities += [ - SynoDSMInfoSensor(api, sensor_type, sensor, coordinator) - for sensor_type, sensor in INFORMATION_SENSORS.items() - ] + entities.extend( + [ + SynoDSMInfoSensor(api, coordinator, description) + for description in INFORMATION_SENSORS + ] + ) async_add_entities(entities) -class SynoDSMSensor(SynologyDSMBaseEntity): +class SynoDSMSensor(SynologyDSMBaseEntity, SensorEntity): """Mixin for sensor specific attributes.""" - @property - def native_unit_of_measurement(self) -> str | None: - """Return the unit the value is expressed in.""" - return self._unit + entity_description: SynologyDSMSensorEntityDescription + + def __init__( + self, + api: SynoApi, + coordinator: DataUpdateCoordinator[dict[str, dict[str, Any]]], + description: SynologyDSMSensorEntityDescription, + ) -> None: + """Initialize the Synology DSM sensor entity.""" + super().__init__(api, coordinator, description) -class SynoDSMUtilSensor(SynoDSMSensor, SensorEntity): +class SynoDSMUtilSensor(SynoDSMSensor): """Representation a Synology Utilisation sensor.""" @property def native_value(self) -> Any | None: """Return the state.""" - attr = getattr(self._api.utilisation, self.entity_type) + attr = getattr(self._api.utilisation, self.entity_description.key) if callable(attr): attr = attr() if attr is None: return None # Data (RAM) - if self._unit == DATA_MEGABYTES: + if self.native_unit_of_measurement == DATA_MEGABYTES: return round(attr / 1024.0 ** 2, 1) # Network - if self._unit == DATA_RATE_KILOBYTES_PER_SECOND: + if self.native_unit_of_measurement == DATA_RATE_KILOBYTES_PER_SECOND: return round(attr / 1024.0, 1) # CPU load average - if self._unit == ENTITY_UNIT_LOAD: + if self.native_unit_of_measurement == ENTITY_UNIT_LOAD: return round(attr / 100, 2) return attr @@ -123,46 +123,57 @@ class SynoDSMUtilSensor(SynoDSMSensor, SensorEntity): return bool(self._api.utilisation) -class SynoDSMStorageSensor(SynologyDSMDeviceEntity, SynoDSMSensor, SensorEntity): +class SynoDSMStorageSensor(SynologyDSMDeviceEntity, SynoDSMSensor): """Representation a Synology Storage sensor.""" + entity_description: SynologyDSMSensorEntityDescription + + def __init__( + self, + api: SynoApi, + coordinator: DataUpdateCoordinator[dict[str, dict[str, Any]]], + description: SynologyDSMSensorEntityDescription, + device_id: str | None = None, + ) -> None: + """Initialize the Synology DSM storage sensor entity.""" + super().__init__(api, coordinator, description, device_id) + @property def native_value(self) -> Any | None: """Return the state.""" - attr = getattr(self._api.storage, self.entity_type)(self._device_id) + attr = getattr(self._api.storage, self.entity_description.key)(self._device_id) if attr is None: return None # Data (disk space) - if self._unit == DATA_TERABYTES: + if self.native_unit_of_measurement == DATA_TERABYTES: return round(attr / 1024.0 ** 4, 2) return attr -class SynoDSMInfoSensor(SynoDSMSensor, SensorEntity): +class SynoDSMInfoSensor(SynoDSMSensor): """Representation a Synology information sensor.""" def __init__( self, api: SynoApi, - entity_type: str, - entity_info: EntityInfo, coordinator: DataUpdateCoordinator[dict[str, dict[str, Any]]], + description: SynologyDSMSensorEntityDescription, ) -> None: """Initialize the Synology SynoDSMInfoSensor entity.""" - super().__init__(api, entity_type, entity_info, coordinator) + super().__init__(api, coordinator, description) self._previous_uptime: str | None = None self._last_boot: str | None = None @property def native_value(self) -> Any | None: """Return the state.""" - attr = getattr(self._api.information, self.entity_type) + attr = getattr(self._api.information, self.entity_description.key) if attr is None: return None - if self.entity_type == "uptime": + if self.entity_description.key == "uptime": # reboot happened or entity creation if self._previous_uptime is None or self._previous_uptime > attr: last_boot = utcnow() - timedelta(seconds=attr) diff --git a/homeassistant/components/synology_dsm/switch.py b/homeassistant/components/synology_dsm/switch.py index e08516ec03a..c5144d64a48 100644 --- a/homeassistant/components/synology_dsm/switch.py +++ b/homeassistant/components/synology_dsm/switch.py @@ -19,7 +19,7 @@ from .const import ( DOMAIN, SURVEILLANCE_SWITCH, SYNO_API, - EntityInfo, + SynologyDSMSwitchEntityDescription, ) _LOGGER = logging.getLogger(__name__) @@ -42,12 +42,14 @@ async def async_setup_entry( # initial data fetch coordinator: DataUpdateCoordinator = data[COORDINATOR_SWITCHES] await coordinator.async_refresh() - entities += [ - SynoDSMSurveillanceHomeModeToggle( - api, sensor_type, switch, version, coordinator - ) - for sensor_type, switch in SURVEILLANCE_SWITCH.items() - ] + entities.extend( + [ + SynoDSMSurveillanceHomeModeToggle( + api, version, coordinator, description + ) + for description in SURVEILLANCE_SWITCH + ] + ) async_add_entities(entities, True) @@ -56,28 +58,23 @@ class SynoDSMSurveillanceHomeModeToggle(SynologyDSMBaseEntity, ToggleEntity): """Representation a Synology Surveillance Station Home Mode toggle.""" coordinator: DataUpdateCoordinator[dict[str, dict[str, bool]]] + entity_description: SynologyDSMSwitchEntityDescription def __init__( self, api: SynoApi, - entity_type: str, - entity_info: EntityInfo, version: str, coordinator: DataUpdateCoordinator[dict[str, dict[str, bool]]], + description: SynologyDSMSwitchEntityDescription, ) -> None: """Initialize a Synology Surveillance Station Home Mode.""" - super().__init__( - api, - entity_type, - entity_info, - coordinator, - ) + super().__init__(api, coordinator, description) self._version = version @property def is_on(self) -> bool: """Return the state.""" - return self.coordinator.data["switches"][self.entity_type] + return self.coordinator.data["switches"][self.entity_description.key] async def async_turn_on(self, **kwargs: Any) -> None: """Turn on Home mode.""" From cd51d994b19762e6be251f519f4712aa7de4acea Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Fri, 3 Sep 2021 22:48:11 +0100 Subject: [PATCH 199/843] System Bridge - Set device class for binary sensor (#55688) --- homeassistant/components/system_bridge/binary_sensor.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/system_bridge/binary_sensor.py b/homeassistant/components/system_bridge/binary_sensor.py index 4280293a434..0587ec3629c 100644 --- a/homeassistant/components/system_bridge/binary_sensor.py +++ b/homeassistant/components/system_bridge/binary_sensor.py @@ -8,6 +8,7 @@ from systembridge import Bridge from homeassistant.components.binary_sensor import ( DEVICE_CLASS_BATTERY_CHARGING, + DEVICE_CLASS_UPDATE, BinarySensorEntity, BinarySensorEntityDescription, ) @@ -30,7 +31,7 @@ BASE_BINARY_SENSOR_TYPES: tuple[SystemBridgeBinarySensorEntityDescription, ...] SystemBridgeBinarySensorEntityDescription( key="version_available", name="New Version Available", - icon="mdi:counter", + device_class=DEVICE_CLASS_UPDATE, value=lambda bridge: bridge.information.updates.available, ), ) From a756308e79f82d42c17daf5850573e73f313f51b Mon Sep 17 00:00:00 2001 From: jan iversen Date: Sat, 4 Sep 2021 00:43:07 +0200 Subject: [PATCH 200/843] Update template/test_binary_sensor.py to use pytest (#55220) --- .../components/template/test_binary_sensor.py | 1019 ++++++----------- 1 file changed, 359 insertions(+), 660 deletions(-) diff --git a/tests/components/template/test_binary_sensor.py b/tests/components/template/test_binary_sensor.py index ccadef5aa96..92b8d6f773f 100644 --- a/tests/components/template/test_binary_sensor.py +++ b/tests/components/template/test_binary_sensor.py @@ -3,6 +3,8 @@ from datetime import timedelta import logging from unittest.mock import patch +import pytest + from homeassistant import setup from homeassistant.components import binary_sensor from homeassistant.const import ( @@ -18,55 +20,43 @@ import homeassistant.util.dt as dt_util from tests.common import async_fire_time_changed +ON = "on" +OFF = "off" -async def test_setup_legacy(hass): - """Test the setup.""" - config = { - "binary_sensor": { - "platform": "template", - "sensors": { - "test": { - "friendly_name": "virtual thingy", - "value_template": "{{ True }}", - "device_class": "motion", - } + +@pytest.mark.parametrize("count,domain", [(1, binary_sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "binary_sensor": { + "platform": "template", + "sensors": { + "test": { + "friendly_name": "virtual thingy", + "value_template": "{{ True }}", + "device_class": "motion", + } + }, }, - } - } - assert await setup.async_setup_component(hass, binary_sensor.DOMAIN, config) - await hass.async_block_till_done() + }, + ], +) +async def test_setup_legacy(hass, start_ha): + """Test the setup.""" state = hass.states.get("binary_sensor.test") assert state is not None assert state.name == "virtual thingy" - assert state.state == "on" + assert state.state == ON assert state.attributes["device_class"] == "motion" -async def test_setup_no_sensors(hass): - """Test setup with no sensors.""" - assert await setup.async_setup_component( - hass, binary_sensor.DOMAIN, {"binary_sensor": {"platform": "template"}} - ) - await hass.async_block_till_done() - assert len(hass.states.async_entity_ids()) == 0 - - -async def test_setup_invalid_device(hass): - """Test the setup with invalid devices.""" - assert await setup.async_setup_component( - hass, - binary_sensor.DOMAIN, +@pytest.mark.parametrize("count,domain", [(0, binary_sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + {"binary_sensor": {"platform": "template"}}, {"binary_sensor": {"platform": "template", "sensors": {"foo bar": {}}}}, - ) - await hass.async_block_till_done() - assert len(hass.states.async_entity_ids()) == 0 - - -async def test_setup_invalid_device_class(hass): - """Test setup with invalid sensor class.""" - assert await setup.async_setup_component( - hass, - binary_sensor.DOMAIN, { "binary_sensor": { "platform": "template", @@ -78,32 +68,23 @@ async def test_setup_invalid_device_class(hass): }, } }, - ) - await hass.async_block_till_done() - assert len(hass.states.async_entity_ids()) == 0 - - -async def test_setup_invalid_missing_template(hass): - """Test setup with invalid and missing template.""" - assert await setup.async_setup_component( - hass, - binary_sensor.DOMAIN, { "binary_sensor": { "platform": "template", "sensors": {"test": {"device_class": "motion"}}, } }, - ) - await hass.async_block_till_done() - assert len(hass.states.async_entity_ids()) == 0 + ], +) +async def test_setup_invalid_sensors(hass, count, start_ha): + """Test setup with no sensors.""" + assert len(hass.states.async_entity_ids()) == count -async def test_icon_template(hass): - """Test icon template.""" - assert await setup.async_setup_component( - hass, - binary_sensor.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, binary_sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "binary_sensor": { "platform": "template", @@ -115,16 +96,14 @@ async def test_icon_template(hass): "'Works' %}" "mdi:check" "{% endif %}", - } + }, }, - } + }, }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_icon_template(hass, start_ha): + """Test icon template.""" state = hass.states.get("binary_sensor.test_template_sensor") assert state.attributes.get("icon") == "" @@ -134,11 +113,10 @@ async def test_icon_template(hass): assert state.attributes["icon"] == "mdi:check" -async def test_entity_picture_template(hass): - """Test entity_picture template.""" - assert await setup.async_setup_component( - hass, - binary_sensor.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, binary_sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "binary_sensor": { "platform": "template", @@ -150,16 +128,14 @@ async def test_entity_picture_template(hass): "'Works' %}" "/local/sensor.png" "{% endif %}", - } + }, }, - } + }, }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_entity_picture_template(hass, start_ha): + """Test entity_picture template.""" state = hass.states.get("binary_sensor.test_template_sensor") assert state.attributes.get("entity_picture") == "" @@ -169,11 +145,10 @@ async def test_entity_picture_template(hass): assert state.attributes["entity_picture"] == "/local/sensor.png" -async def test_attribute_templates(hass): - """Test attribute_templates template.""" - assert await setup.async_setup_component( - hass, - binary_sensor.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, binary_sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "binary_sensor": { "platform": "template", @@ -183,16 +158,14 @@ async def test_attribute_templates(hass): "attribute_templates": { "test_attribute": "It {{ states.sensor.test_state.state }}." }, - } + }, }, - } + }, }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_attribute_templates(hass, start_ha): + """Test attribute_templates template.""" state = hass.states.get("binary_sensor.test_template_sensor") assert state.attributes.get("test_attribute") == "It ." hass.states.async_set("sensor.test_state", "Works2") @@ -203,501 +176,228 @@ async def test_attribute_templates(hass): assert state.attributes["test_attribute"] == "It Works." -async def test_match_all(hass): - """Test template that is rerendered on any state lifecycle.""" +@pytest.fixture +async def setup_mock(): + """Do setup of sensor mock.""" with patch( "homeassistant.components.template.binary_sensor." "BinarySensorTemplate._update_state" ) as _update_state: - assert await setup.async_setup_component( - hass, - binary_sensor.DOMAIN, - { - "binary_sensor": { - "platform": "template", - "sensors": { - "match_all_template_sensor": { - "value_template": ( - "{% for state in states %}" - "{% if state.entity_id == 'sensor.humidity' %}" - "{{ state.entity_id }}={{ state.state }}" - "{% endif %}" - "{% endfor %}" - ), - }, + yield _update_state + + +@pytest.mark.parametrize("count,domain", [(1, binary_sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "binary_sensor": { + "platform": "template", + "sensors": { + "match_all_template_sensor": { + "value_template": ( + "{% for state in states %}" + "{% if state.entity_id == 'sensor.humidity' %}" + "{{ state.entity_id }}={{ state.state }}" + "{% endif %}" + "{% endfor %}" + ), }, - } + }, + } + }, + ], +) +async def test_match_all(hass, setup_mock, start_ha): + """Test template that is rerendered on any state lifecycle.""" + init_calls = len(setup_mock.mock_calls) + + hass.states.async_set("sensor.any_state", "update") + await hass.async_block_till_done() + assert len(setup_mock.mock_calls) == init_calls + + +@pytest.mark.parametrize("count,domain", [(1, binary_sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "binary_sensor": { + "platform": "template", + "sensors": { + "test": { + "friendly_name": "virtual thingy", + "value_template": "{{ states.sensor.test_state.state == 'on' }}", + "device_class": "motion", + }, + }, }, - ) - - await hass.async_start() - await hass.async_block_till_done() - init_calls = len(_update_state.mock_calls) - - hass.states.async_set("sensor.any_state", "update") - await hass.async_block_till_done() - assert len(_update_state.mock_calls) == init_calls - - -async def test_event(hass): + }, + ], +) +async def test_event(hass, start_ha): """Test the event.""" - config = { - "binary_sensor": { - "platform": "template", - "sensors": { - "test": { - "friendly_name": "virtual thingy", - "value_template": "{{ states.sensor.test_state.state == 'on' }}", - "device_class": "motion", - } + state = hass.states.get("binary_sensor.test") + assert state.state == OFF + + hass.states.async_set("sensor.test_state", ON) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.test") + assert state.state == ON + + +@pytest.mark.parametrize("count,domain", [(1, binary_sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "binary_sensor": { + "platform": "template", + "sensors": { + "test_on": { + "friendly_name": "virtual thingy", + "value_template": "{{ states.sensor.test_state.state == 'on' }}", + "device_class": "motion", + "delay_on": 5, + }, + "test_off": { + "friendly_name": "virtual thingy", + "value_template": "{{ states.sensor.test_state.state == 'on' }}", + "device_class": "motion", + "delay_off": 5, + }, + }, }, - } - } - assert await setup.async_setup_component(hass, binary_sensor.DOMAIN, config) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "off" - - hass.states.async_set("sensor.test_state", "on") - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "on" - - -async def test_template_delay_on(hass): + }, + { + "binary_sensor": { + "platform": "template", + "sensors": { + "test_on": { + "friendly_name": "virtual thingy", + "value_template": "{{ states.sensor.test_state.state == 'on' }}", + "device_class": "motion", + "delay_on": '{{ ({ "seconds": 10 / 2 }) }}', + }, + "test_off": { + "friendly_name": "virtual thingy", + "value_template": "{{ states.sensor.test_state.state == 'on' }}", + "device_class": "motion", + "delay_off": '{{ ({ "seconds": 10 / 2 }) }}', + }, + }, + }, + }, + { + "binary_sensor": { + "platform": "template", + "sensors": { + "test_on": { + "friendly_name": "virtual thingy", + "value_template": "{{ states.sensor.test_state.state == 'on' }}", + "device_class": "motion", + "delay_on": '{{ ({ "seconds": states("input_number.delay")|int }) }}', + }, + "test_off": { + "friendly_name": "virtual thingy", + "value_template": "{{ states.sensor.test_state.state == 'on' }}", + "device_class": "motion", + "delay_off": '{{ ({ "seconds": states("input_number.delay")|int }) }}', + }, + }, + }, + }, + ], +) +async def test_template_delay_on_off(hass, start_ha): """Test binary sensor template delay on.""" - config = { - "binary_sensor": { - "platform": "template", - "sensors": { - "test": { - "friendly_name": "virtual thingy", - "value_template": "{{ states.sensor.test_state.state == 'on' }}", - "device_class": "motion", - "delay_on": 5, - } - }, - } - } - await setup.async_setup_component(hass, binary_sensor.DOMAIN, config) + assert hass.states.get("binary_sensor.test_on").state == OFF + assert hass.states.get("binary_sensor.test_off").state == OFF + hass.states.async_set("input_number.delay", 5) + hass.states.async_set("sensor.test_state", ON) await hass.async_block_till_done() - await hass.async_start() - - hass.states.async_set("sensor.test_state", "on") - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "off" + assert hass.states.get("binary_sensor.test_on").state == OFF + assert hass.states.get("binary_sensor.test_off").state == ON future = dt_util.utcnow() + timedelta(seconds=5) async_fire_time_changed(hass, future) await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "on" + assert hass.states.get("binary_sensor.test_on").state == ON + assert hass.states.get("binary_sensor.test_off").state == ON # check with time changes - hass.states.async_set("sensor.test_state", "off") + hass.states.async_set("sensor.test_state", OFF) await hass.async_block_till_done() + assert hass.states.get("binary_sensor.test_on").state == OFF + assert hass.states.get("binary_sensor.test_off").state == ON - state = hass.states.get("binary_sensor.test") - assert state.state == "off" - - hass.states.async_set("sensor.test_state", "on") + hass.states.async_set("sensor.test_state", ON) await hass.async_block_till_done() + assert hass.states.get("binary_sensor.test_on").state == OFF + assert hass.states.get("binary_sensor.test_off").state == ON - state = hass.states.get("binary_sensor.test") - assert state.state == "off" - - hass.states.async_set("sensor.test_state", "off") + hass.states.async_set("sensor.test_state", OFF) await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "off" + assert hass.states.get("binary_sensor.test_on").state == OFF + assert hass.states.get("binary_sensor.test_off").state == ON future = dt_util.utcnow() + timedelta(seconds=5) async_fire_time_changed(hass, future) await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "off" + assert hass.states.get("binary_sensor.test_on").state == OFF + assert hass.states.get("binary_sensor.test_off").state == OFF -async def test_template_delay_off(hass): - """Test binary sensor template delay off.""" - config = { - "binary_sensor": { - "platform": "template", - "sensors": { - "test": { - "friendly_name": "virtual thingy", - "value_template": "{{ states.sensor.test_state.state == 'on' }}", - "device_class": "motion", - "delay_off": 5, - } +@pytest.mark.parametrize("count,domain", [(1, binary_sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "binary_sensor": { + "platform": "template", + "sensors": { + "test": { + "friendly_name": "virtual thingy", + "value_template": "true", + "device_class": "motion", + "delay_off": 5, + }, + }, }, - } - } - hass.states.async_set("sensor.test_state", "on") - await setup.async_setup_component(hass, binary_sensor.DOMAIN, config) - await hass.async_block_till_done() - await hass.async_start() - - hass.states.async_set("sensor.test_state", "off") - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "on" - - future = dt_util.utcnow() + timedelta(seconds=5) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "off" - - # check with time changes - hass.states.async_set("sensor.test_state", "on") - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "on" - - hass.states.async_set("sensor.test_state", "off") - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "on" - - hass.states.async_set("sensor.test_state", "on") - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "on" - - future = dt_util.utcnow() + timedelta(seconds=5) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "on" - - -async def test_template_with_templated_delay_on(hass): - """Test binary sensor template with template delay on.""" - config = { - "binary_sensor": { - "platform": "template", - "sensors": { - "test": { - "friendly_name": "virtual thingy", - "value_template": "{{ states.sensor.test_state.state == 'on' }}", - "device_class": "motion", - "delay_on": '{{ ({ "seconds": 6 / 2 }) }}', - } - }, - } - } - await setup.async_setup_component(hass, binary_sensor.DOMAIN, config) - await hass.async_block_till_done() - await hass.async_start() - - hass.states.async_set("sensor.test_state", "on") - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "off" - - future = dt_util.utcnow() + timedelta(seconds=3) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "on" - - # check with time changes - hass.states.async_set("sensor.test_state", "off") - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "off" - - hass.states.async_set("sensor.test_state", "on") - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "off" - - hass.states.async_set("sensor.test_state", "off") - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "off" - - future = dt_util.utcnow() + timedelta(seconds=3) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "off" - - -async def test_template_with_templated_delay_off(hass): - """Test binary sensor template with template delay off.""" - config = { - "binary_sensor": { - "platform": "template", - "sensors": { - "test": { - "friendly_name": "virtual thingy", - "value_template": "{{ states.sensor.test_state.state == 'on' }}", - "device_class": "motion", - "delay_off": '{{ ({ "seconds": 6 / 2 }) }}', - } - }, - } - } - hass.states.async_set("sensor.test_state", "on") - await setup.async_setup_component(hass, binary_sensor.DOMAIN, config) - await hass.async_block_till_done() - await hass.async_start() - - hass.states.async_set("sensor.test_state", "off") - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "on" - - future = dt_util.utcnow() + timedelta(seconds=3) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "off" - - # check with time changes - hass.states.async_set("sensor.test_state", "on") - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "on" - - hass.states.async_set("sensor.test_state", "off") - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "on" - - hass.states.async_set("sensor.test_state", "on") - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "on" - - future = dt_util.utcnow() + timedelta(seconds=3) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "on" - - -async def test_template_with_delay_on_based_on_input(hass): - """Test binary sensor template with template delay on based on input number.""" - config = { - "binary_sensor": { - "platform": "template", - "sensors": { - "test": { - "friendly_name": "virtual thingy", - "value_template": "{{ states.sensor.test_state.state == 'on' }}", - "device_class": "motion", - "delay_on": '{{ ({ "seconds": states("input_number.delay")|int }) }}', - } - }, - } - } - await setup.async_setup_component(hass, binary_sensor.DOMAIN, config) - await hass.async_block_till_done() - await hass.async_start() - - hass.states.async_set("sensor.test_state", "off") - await hass.async_block_till_done() - - hass.states.async_set("input_number.delay", 3) - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "off" - - hass.states.async_set("sensor.test_state", "on") - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "off" - - future = dt_util.utcnow() + timedelta(seconds=3) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "on" - - # set input to 4 seconds - hass.states.async_set("sensor.test_state", "off") - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "off" - - hass.states.async_set("input_number.delay", 4) - await hass.async_block_till_done() - - hass.states.async_set("sensor.test_state", "on") - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "off" - - future = dt_util.utcnow() + timedelta(seconds=2) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "off" - - future = dt_util.utcnow() + timedelta(seconds=4) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "on" - - -async def test_template_with_delay_off_based_on_input(hass): - """Test binary sensor template with template delay off based on input number.""" - config = { - "binary_sensor": { - "platform": "template", - "sensors": { - "test": { - "friendly_name": "virtual thingy", - "value_template": "{{ states.sensor.test_state.state == 'on' }}", - "device_class": "motion", - "delay_off": '{{ ({ "seconds": states("input_number.delay")|int }) }}', - } - }, - } - } - await setup.async_setup_component(hass, binary_sensor.DOMAIN, config) - await hass.async_block_till_done() - await hass.async_start() - - hass.states.async_set("sensor.test_state", "on") - await hass.async_block_till_done() - - hass.states.async_set("input_number.delay", 3) - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "on" - - hass.states.async_set("sensor.test_state", "off") - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "on" - - future = dt_util.utcnow() + timedelta(seconds=3) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "off" - - # set input to 4 seconds - hass.states.async_set("sensor.test_state", "on") - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "on" - - hass.states.async_set("input_number.delay", 4) - await hass.async_block_till_done() - - hass.states.async_set("sensor.test_state", "off") - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "on" - - future = dt_util.utcnow() + timedelta(seconds=2) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "on" - - future = dt_util.utcnow() + timedelta(seconds=4) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "off" - - -async def test_available_without_availability_template(hass): + }, + ], +) +async def test_available_without_availability_template(hass, start_ha): """Ensure availability is true without an availability_template.""" - config = { - "binary_sensor": { - "platform": "template", - "sensors": { - "test": { - "friendly_name": "virtual thingy", - "value_template": "true", - "device_class": "motion", - "delay_off": 5, - } - }, - } - } - await setup.async_setup_component(hass, binary_sensor.DOMAIN, config) - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.test") assert state.state != STATE_UNAVAILABLE assert state.attributes[ATTR_DEVICE_CLASS] == "motion" -async def test_availability_template(hass): - """Test availability template.""" - config = { - "binary_sensor": { - "platform": "template", - "sensors": { - "test": { - "friendly_name": "virtual thingy", - "value_template": "true", - "device_class": "motion", - "delay_off": 5, - "availability_template": "{{ is_state('sensor.test_state','on') }}", - } +@pytest.mark.parametrize("count,domain", [(1, binary_sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "binary_sensor": { + "platform": "template", + "sensors": { + "test": { + "friendly_name": "virtual thingy", + "value_template": "true", + "device_class": "motion", + "delay_off": 5, + "availability_template": "{{ is_state('sensor.test_state','on') }}", + }, + }, }, - } - } - await setup.async_setup_component(hass, binary_sensor.DOMAIN, config) - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + }, + ], +) +async def test_availability_template(hass, start_ha): + """Test availability template.""" hass.states.async_set("sensor.test_state", STATE_OFF) await hass.async_block_till_done() @@ -712,13 +412,10 @@ async def test_availability_template(hass): assert state.attributes[ATTR_DEVICE_CLASS] == "motion" -async def test_invalid_attribute_template(hass, caplog): - """Test that errors are logged if rendering template fails.""" - hass.states.async_set("binary_sensor.test_sensor", "true") - - await setup.async_setup_component( - hass, - binary_sensor.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, binary_sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "binary_sensor": { "platform": "template", @@ -730,24 +427,23 @@ async def test_invalid_attribute_template(hass, caplog): }, } }, - } + }, }, - ) - await hass.async_block_till_done() + ], +) +async def test_invalid_attribute_template(hass, caplog, start_ha): + """Test that errors are logged if rendering template fails.""" + hass.states.async_set("binary_sensor.test_sensor", "true") assert len(hass.states.async_all()) == 2 - await hass.async_start() - await hass.async_block_till_done() - - assert "test_attribute" in caplog.text - assert "TemplateError" in caplog.text + text = str([x.getMessage() for x in caplog.get_records("setup")]) + assert ("test_attribute") in text + assert ("TemplateError") in text -async def test_invalid_availability_template_keeps_component_available(hass, caplog): - """Test that an invalid availability keeps the device available.""" - - await setup.async_setup_component( - hass, - binary_sensor.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, binary_sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "binary_sensor": { "platform": "template", @@ -755,22 +451,24 @@ async def test_invalid_availability_template_keeps_component_available(hass, cap "my_sensor": { "value_template": "{{ states.binary_sensor.test_sensor }}", "availability_template": "{{ x - 12 }}", - } + }, }, - } + }, }, - ) - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() + ], +) +async def test_invalid_availability_template_keeps_component_available( + hass, caplog, start_ha +): + """Test that an invalid availability keeps the device available.""" assert hass.states.get("binary_sensor.my_sensor").state != STATE_UNAVAILABLE - assert ("UndefinedError: 'x' is undefined") in caplog.text + text = str([x.getMessage() for x in caplog.get_records("setup")]) + assert ("UndefinedError: \\'x\\' is undefined") in text async def test_no_update_template_match_all(hass, caplog): """Test that we do not update sensors that match on all.""" - hass.states.async_set("binary_sensor.test_sensor", "true") hass.state = CoreState.not_running @@ -799,29 +497,30 @@ async def test_no_update_template_match_all(hass, caplog): }, ) await hass.async_block_till_done() + hass.states.async_set("binary_sensor.test_sensor", "true") assert len(hass.states.async_all()) == 5 - assert hass.states.get("binary_sensor.all_state").state == "off" - assert hass.states.get("binary_sensor.all_icon").state == "off" - assert hass.states.get("binary_sensor.all_entity_picture").state == "off" - assert hass.states.get("binary_sensor.all_attribute").state == "off" + assert hass.states.get("binary_sensor.all_state").state == OFF + assert hass.states.get("binary_sensor.all_icon").state == OFF + assert hass.states.get("binary_sensor.all_entity_picture").state == OFF + assert hass.states.get("binary_sensor.all_attribute").state == OFF hass.bus.async_fire(EVENT_HOMEASSISTANT_START) await hass.async_block_till_done() - assert hass.states.get("binary_sensor.all_state").state == "on" - assert hass.states.get("binary_sensor.all_icon").state == "on" - assert hass.states.get("binary_sensor.all_entity_picture").state == "on" - assert hass.states.get("binary_sensor.all_attribute").state == "on" + assert hass.states.get("binary_sensor.all_state").state == ON + assert hass.states.get("binary_sensor.all_icon").state == ON + assert hass.states.get("binary_sensor.all_entity_picture").state == ON + assert hass.states.get("binary_sensor.all_attribute").state == ON hass.states.async_set("binary_sensor.test_sensor", "false") await hass.async_block_till_done() - assert hass.states.get("binary_sensor.all_state").state == "on" + assert hass.states.get("binary_sensor.all_state").state == ON # Will now process because we have one valid template - assert hass.states.get("binary_sensor.all_icon").state == "off" - assert hass.states.get("binary_sensor.all_entity_picture").state == "off" - assert hass.states.get("binary_sensor.all_attribute").state == "off" + assert hass.states.get("binary_sensor.all_icon").state == OFF + assert hass.states.get("binary_sensor.all_entity_picture").state == OFF + assert hass.states.get("binary_sensor.all_attribute").state == OFF await hass.helpers.entity_component.async_update_entity("binary_sensor.all_state") await hass.helpers.entity_component.async_update_entity("binary_sensor.all_icon") @@ -832,24 +531,23 @@ async def test_no_update_template_match_all(hass, caplog): "binary_sensor.all_attribute" ) - assert hass.states.get("binary_sensor.all_state").state == "on" - assert hass.states.get("binary_sensor.all_icon").state == "off" - assert hass.states.get("binary_sensor.all_entity_picture").state == "off" - assert hass.states.get("binary_sensor.all_attribute").state == "off" + assert hass.states.get("binary_sensor.all_state").state == ON + assert hass.states.get("binary_sensor.all_icon").state == OFF + assert hass.states.get("binary_sensor.all_entity_picture").state == OFF + assert hass.states.get("binary_sensor.all_attribute").state == OFF -async def test_unique_id(hass): - """Test unique_id option only creates one binary sensor per id.""" - await setup.async_setup_component( - hass, - "template", +@pytest.mark.parametrize("count,domain", [(1, "template")]) +@pytest.mark.parametrize( + "config", + [ { "template": { "unique_id": "group-id", "binary_sensor": { "name": "top-level", "unique_id": "sensor-id", - "state": "on", + "state": ON, }, }, "binary_sensor": { @@ -866,12 +564,10 @@ async def test_unique_id(hass): }, }, }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_unique_id(hass, start_ha): + """Test unique_id option only creates one binary sensor per id.""" assert len(hass.states.async_all()) == 2 ent_reg = entity_registry.async_get(hass) @@ -889,28 +585,29 @@ async def test_unique_id(hass): ) -async def test_template_validation_error(hass, caplog): - """Test binary sensor template delay on.""" - caplog.set_level(logging.ERROR) - config = { - "binary_sensor": { - "platform": "template", - "sensors": { - "test": { - "friendly_name": "virtual thingy", - "value_template": "True", - "icon_template": "{{ states.sensor.test_state.state }}", - "device_class": "motion", - "delay_on": 5, +@pytest.mark.parametrize("count,domain", [(1, binary_sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "binary_sensor": { + "platform": "template", + "sensors": { + "test": { + "friendly_name": "virtual thingy", + "value_template": "True", + "icon_template": "{{ states.sensor.test_state.state }}", + "device_class": "motion", + "delay_on": 5, + }, }, }, }, - } - await setup.async_setup_component(hass, binary_sensor.DOMAIN, config) - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_template_validation_error(hass, caplog, start_ha): + """Test binary sensor template delay on.""" + caplog.set_level(logging.ERROR) state = hass.states.get("binary_sensor.test") assert state.attributes.get("icon") == "" @@ -931,11 +628,10 @@ async def test_template_validation_error(hass, caplog): assert state.attributes.get("icon") is None -async def test_trigger_entity(hass): - """Test trigger entity works.""" - assert await setup.async_setup_component( - hass, - "template", +@pytest.mark.parametrize("count,domain", [(2, "template")]) +@pytest.mark.parametrize( + "config", + [ { "template": [ {"invalid": "config"}, @@ -981,24 +677,25 @@ async def test_trigger_entity(hass): }, ], }, - ) - + ], +) +async def test_trigger_entity(hass, start_ha): + """Test trigger entity works.""" await hass.async_block_till_done() - state = hass.states.get("binary_sensor.hello_name") assert state is not None - assert state.state == "off" + assert state.state == OFF state = hass.states.get("binary_sensor.bare_minimum") assert state is not None - assert state.state == "off" + assert state.state == OFF context = Context() hass.bus.async_fire("test_event", {"beer": 2}, context=context) await hass.async_block_till_done() state = hass.states.get("binary_sensor.hello_name") - assert state.state == "on" + assert state.state == ON assert state.attributes.get("device_class") == "battery" assert state.attributes.get("icon") == "mdi:pirate" assert state.attributes.get("entity_picture") == "/local/dogs.png" @@ -1017,7 +714,7 @@ async def test_trigger_entity(hass): ) state = hass.states.get("binary_sensor.via_list") - assert state.state == "on" + assert state.state == ON assert state.attributes.get("device_class") == "battery" assert state.attributes.get("icon") == "mdi:pirate" assert state.attributes.get("entity_picture") == "/local/dogs.png" @@ -1029,30 +726,32 @@ async def test_trigger_entity(hass): hass.bus.async_fire("test_event", {"beer": 2, "uno_mas": "si"}) await hass.async_block_till_done() state = hass.states.get("binary_sensor.via_list") - assert state.state == "on" + assert state.state == ON assert state.attributes.get("another") == "si" -async def test_template_with_trigger_templated_delay_on(hass): - """Test binary sensor template with template delay on.""" - config = { - "template": { - "trigger": {"platform": "event", "event_type": "test_event"}, - "binary_sensor": { - "name": "test", - "state": "{{ trigger.event.data.beer == 2 }}", - "device_class": "motion", - "delay_on": '{{ ({ "seconds": 6 / 2 }) }}', - "auto_off": '{{ ({ "seconds": 1 + 1 }) }}', +@pytest.mark.parametrize("count,domain", [(1, "template")]) +@pytest.mark.parametrize( + "config", + [ + { + "template": { + "trigger": {"platform": "event", "event_type": "test_event"}, + "binary_sensor": { + "name": "test", + "state": "{{ trigger.event.data.beer == 2 }}", + "device_class": "motion", + "delay_on": '{{ ({ "seconds": 6 / 2 }) }}', + "auto_off": '{{ ({ "seconds": 1 + 1 }) }}', + }, }, - } - } - await setup.async_setup_component(hass, "template", config) - await hass.async_block_till_done() - await hass.async_start() - + }, + ], +) +async def test_template_with_trigger_templated_delay_on(hass, start_ha): + """Test binary sensor template with template delay on.""" state = hass.states.get("binary_sensor.test") - assert state.state == "off" + assert state.state == OFF context = Context() hass.bus.async_fire("test_event", {"beer": 2}, context=context) @@ -1063,7 +762,7 @@ async def test_template_with_trigger_templated_delay_on(hass): await hass.async_block_till_done() state = hass.states.get("binary_sensor.test") - assert state.state == "on" + assert state.state == ON # Now wait for the auto-off future = dt_util.utcnow() + timedelta(seconds=2) @@ -1071,4 +770,4 @@ async def test_template_with_trigger_templated_delay_on(hass): await hass.async_block_till_done() state = hass.states.get("binary_sensor.test") - assert state.state == "off" + assert state.state == OFF From edddeaf5ab5b14da85812a1736d892115cd5b0e5 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 4 Sep 2021 00:44:01 +0200 Subject: [PATCH 201/843] Use NamedTuple for api endpoint settings (#55694) --- homeassistant/components/hassio/__init__.py | 60 ++++++++++++++------- 1 file changed, 41 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index d55de8e275b..06dfb69b5f3 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import timedelta import logging import os -from typing import Any +from typing import Any, NamedTuple import voluptuous as vol @@ -132,35 +132,56 @@ SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend( ) +class APIEndpointSettings(NamedTuple): + """Settings for API endpoint.""" + + command: str + schema: vol.Schema + timeout: int = 60 + pass_data: bool = False + + MAP_SERVICE_API = { - SERVICE_ADDON_START: ("/addons/{addon}/start", SCHEMA_ADDON, 60, False), - SERVICE_ADDON_STOP: ("/addons/{addon}/stop", SCHEMA_ADDON, 60, False), - SERVICE_ADDON_RESTART: ("/addons/{addon}/restart", SCHEMA_ADDON, 60, False), - SERVICE_ADDON_UPDATE: ("/addons/{addon}/update", SCHEMA_ADDON, 60, False), - SERVICE_ADDON_STDIN: ("/addons/{addon}/stdin", SCHEMA_ADDON_STDIN, 60, False), - SERVICE_HOST_SHUTDOWN: ("/host/shutdown", SCHEMA_NO_DATA, 60, False), - SERVICE_HOST_REBOOT: ("/host/reboot", SCHEMA_NO_DATA, 60, False), - SERVICE_BACKUP_FULL: ("/backups/new/full", SCHEMA_BACKUP_FULL, 300, True), - SERVICE_BACKUP_PARTIAL: ( + SERVICE_ADDON_START: APIEndpointSettings("/addons/{addon}/start", SCHEMA_ADDON), + SERVICE_ADDON_STOP: APIEndpointSettings("/addons/{addon}/stop", SCHEMA_ADDON), + SERVICE_ADDON_RESTART: APIEndpointSettings("/addons/{addon}/restart", SCHEMA_ADDON), + SERVICE_ADDON_UPDATE: APIEndpointSettings("/addons/{addon}/update", SCHEMA_ADDON), + SERVICE_ADDON_STDIN: APIEndpointSettings( + "/addons/{addon}/stdin", SCHEMA_ADDON_STDIN + ), + SERVICE_HOST_SHUTDOWN: APIEndpointSettings("/host/shutdown", SCHEMA_NO_DATA), + SERVICE_HOST_REBOOT: APIEndpointSettings("/host/reboot", SCHEMA_NO_DATA), + SERVICE_BACKUP_FULL: APIEndpointSettings( + "/backups/new/full", + SCHEMA_BACKUP_FULL, + 300, + True, + ), + SERVICE_BACKUP_PARTIAL: APIEndpointSettings( "/backups/new/partial", SCHEMA_BACKUP_PARTIAL, 300, True, ), - SERVICE_RESTORE_FULL: ( + SERVICE_RESTORE_FULL: APIEndpointSettings( "/backups/{slug}/restore/full", SCHEMA_RESTORE_FULL, 300, True, ), - SERVICE_RESTORE_PARTIAL: ( + SERVICE_RESTORE_PARTIAL: APIEndpointSettings( "/backups/{slug}/restore/partial", SCHEMA_RESTORE_PARTIAL, 300, True, ), - SERVICE_SNAPSHOT_FULL: ("/backups/new/full", SCHEMA_BACKUP_FULL, 300, True), - SERVICE_SNAPSHOT_PARTIAL: ( + SERVICE_SNAPSHOT_FULL: APIEndpointSettings( + "/backups/new/full", + SCHEMA_BACKUP_FULL, + 300, + True, + ), + SERVICE_SNAPSHOT_PARTIAL: APIEndpointSettings( "/backups/new/partial", SCHEMA_BACKUP_PARTIAL, 300, @@ -466,7 +487,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: async def async_service_handler(service): """Handle service calls for Hass.io.""" - api_command = MAP_SERVICE_API[service.service][0] + api_endpoint = MAP_SERVICE_API[service.service] + if "snapshot" in service.service: _LOGGER.warning( "The service '%s' is deprecated and will be removed in Home Assistant 2021.11, use '%s' instead", @@ -488,22 +510,22 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: # Pass data to Hass.io API if service.service == SERVICE_ADDON_STDIN: payload = data[ATTR_INPUT] - elif MAP_SERVICE_API[service.service][3]: + elif api_endpoint.pass_data: payload = data # Call API try: await hassio.send_command( - api_command.format(addon=addon, slug=slug), + api_endpoint.command.format(addon=addon, slug=slug), payload=payload, - timeout=MAP_SERVICE_API[service.service][2], + timeout=api_endpoint.timeout, ) except HassioAPIError as err: _LOGGER.error("Error on Supervisor API: %s", err) for service, settings in MAP_SERVICE_API.items(): hass.services.async_register( - DOMAIN, service, async_service_handler, schema=settings[1] + DOMAIN, service, async_service_handler, schema=settings.schema ) async def update_info_data(now): From 617e8544c02fe72a4296d0fda6eb64799e7c4290 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 4 Sep 2021 00:44:16 +0200 Subject: [PATCH 202/843] Use NamedTuple for touchline preset mode settings (#55695) --- homeassistant/components/touchline/climate.py | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/touchline/climate.py b/homeassistant/components/touchline/climate.py index 3193b6f847c..9cd80428080 100644 --- a/homeassistant/components/touchline/climate.py +++ b/homeassistant/components/touchline/climate.py @@ -1,4 +1,6 @@ """Platform for Roth Touchline floor heating controller.""" +from typing import NamedTuple + from pytouchline import PyTouchline import voluptuous as vol @@ -11,17 +13,25 @@ from homeassistant.components.climate.const import ( from homeassistant.const import ATTR_TEMPERATURE, CONF_HOST, TEMP_CELSIUS import homeassistant.helpers.config_validation as cv + +class PresetMode(NamedTuple): + """Settings for preset mode.""" + + mode: int + program: int + + PRESET_MODES = { - "Normal": {"mode": 0, "program": 0}, - "Night": {"mode": 1, "program": 0}, - "Holiday": {"mode": 2, "program": 0}, - "Pro 1": {"mode": 0, "program": 1}, - "Pro 2": {"mode": 0, "program": 2}, - "Pro 3": {"mode": 0, "program": 3}, + "Normal": PresetMode(mode=0, program=0), + "Night": PresetMode(mode=1, program=0), + "Holiday": PresetMode(mode=2, program=0), + "Pro 1": PresetMode(mode=0, program=1), + "Pro 2": PresetMode(mode=0, program=2), + "Pro 3": PresetMode(mode=0, program=3), } TOUCHLINE_HA_PRESETS = { - (settings["mode"], settings["program"]): preset + (settings.mode, settings.program): preset for preset, settings in PRESET_MODES.items() } @@ -119,8 +129,9 @@ class Touchline(ClimateEntity): def set_preset_mode(self, preset_mode): """Set new target preset mode.""" - self.unit.set_operation_mode(PRESET_MODES[preset_mode]["mode"]) - self.unit.set_week_program(PRESET_MODES[preset_mode]["program"]) + preset_mode = PRESET_MODES[preset_mode] + self.unit.set_operation_mode(preset_mode.mode) + self.unit.set_week_program(preset_mode.program) def set_hvac_mode(self, hvac_mode): """Set new target hvac mode.""" From a54b9502ef5df8f99020bb9e6e2bf67557f1d7a2 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 4 Sep 2021 00:48:14 +0200 Subject: [PATCH 203/843] Use NamedTuple for light color mode mapping (#55696) --- .../components/light/reproduce_state.py | 35 ++++++++++++------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/light/reproduce_state.py b/homeassistant/components/light/reproduce_state.py index 77e5742bbab..7cc6b9c572c 100644 --- a/homeassistant/components/light/reproduce_state.py +++ b/homeassistant/components/light/reproduce_state.py @@ -5,7 +5,7 @@ import asyncio from collections.abc import Iterable import logging from types import MappingProxyType -from typing import Any, cast +from typing import Any, NamedTuple, cast from homeassistant.const import ( ATTR_ENTITY_ID, @@ -71,14 +71,22 @@ COLOR_GROUP = [ ATTR_KELVIN, ] + +class ColorModeAttr(NamedTuple): + """Map service data parameter to state attribute for a color mode.""" + + parameter: str + state_attr: str + + COLOR_MODE_TO_ATTRIBUTE = { - COLOR_MODE_COLOR_TEMP: (ATTR_COLOR_TEMP, ATTR_COLOR_TEMP), - COLOR_MODE_HS: (ATTR_HS_COLOR, ATTR_HS_COLOR), - COLOR_MODE_RGB: (ATTR_RGB_COLOR, ATTR_RGB_COLOR), - COLOR_MODE_RGBW: (ATTR_RGBW_COLOR, ATTR_RGBW_COLOR), - COLOR_MODE_RGBWW: (ATTR_RGBWW_COLOR, ATTR_RGBWW_COLOR), - COLOR_MODE_WHITE: (ATTR_WHITE, ATTR_BRIGHTNESS), - COLOR_MODE_XY: (ATTR_XY_COLOR, ATTR_XY_COLOR), + COLOR_MODE_COLOR_TEMP: ColorModeAttr(ATTR_COLOR_TEMP, ATTR_COLOR_TEMP), + COLOR_MODE_HS: ColorModeAttr(ATTR_HS_COLOR, ATTR_HS_COLOR), + COLOR_MODE_RGB: ColorModeAttr(ATTR_RGB_COLOR, ATTR_RGB_COLOR), + COLOR_MODE_RGBW: ColorModeAttr(ATTR_RGBW_COLOR, ATTR_RGBW_COLOR), + COLOR_MODE_RGBWW: ColorModeAttr(ATTR_RGBWW_COLOR, ATTR_RGBWW_COLOR), + COLOR_MODE_WHITE: ColorModeAttr(ATTR_WHITE, ATTR_BRIGHTNESS), + COLOR_MODE_XY: ColorModeAttr(ATTR_XY_COLOR, ATTR_XY_COLOR), } DEPRECATED_GROUP = [ @@ -162,17 +170,18 @@ async def _async_reproduce_state( # Remove deprecated white value if we got a valid color mode service_data.pop(ATTR_WHITE_VALUE, None) color_mode = state.attributes[ATTR_COLOR_MODE] - if parameter_state := COLOR_MODE_TO_ATTRIBUTE.get(color_mode): - parameter, state_attr = parameter_state - if state_attr not in state.attributes: + if color_mode_attr := COLOR_MODE_TO_ATTRIBUTE.get(color_mode): + if color_mode_attr.state_attr not in state.attributes: _LOGGER.warning( "Color mode %s specified but attribute %s missing for: %s", color_mode, - state_attr, + color_mode_attr.state_attr, state.entity_id, ) return - service_data[parameter] = state.attributes[state_attr] + service_data[color_mode_attr.parameter] = state.attributes[ + color_mode_attr.state_attr + ] else: # Fall back to Choosing the first color that is specified for color_attr in COLOR_GROUP: From 501e7c84be21e700ae60424ba648609bcb3eeca2 Mon Sep 17 00:00:00 2001 From: Tomasz Wieczorek Date: Sat, 4 Sep 2021 01:21:18 +0200 Subject: [PATCH 204/843] Type scaffold PLATFORMS (#55699) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Added template base type Proposition to add typing, as pre-commit test on newly created integrations fails on it automatically: ``` homeassistant/components//__init__.py:11: error: Need type annotation for "PLATFORMS" (hint: "PLATFORMS: List[] = ...") [var-annotated] Found 1 error in 1 file (checked 4 source files) ``` I believe there shouldn't be other type than text, hence the proposition. * Apply suggestions from code review Co-authored-by: Joakim Sørensen Co-authored-by: Joakim Sørensen --- script/scaffold/templates/config_flow/integration/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/scaffold/templates/config_flow/integration/__init__.py b/script/scaffold/templates/config_flow/integration/__init__.py index 2f146dfe6e3..ab5c93364e1 100644 --- a/script/scaffold/templates/config_flow/integration/__init__.py +++ b/script/scaffold/templates/config_flow/integration/__init__.py @@ -8,7 +8,7 @@ from .const import DOMAIN # TODO List the platforms that you want to support. # For your initial PR, limit it to 1 platform. -PLATFORMS = ["light"] +PLATFORMS: list[str] = ["light"] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: From 19dcb19d073d1bcd68c103376fff3b1152d8dc8a Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sat, 4 Sep 2021 00:13:17 +0000 Subject: [PATCH 205/843] [ci skip] Translation update --- .../components/adax/translations/es.json | 4 ++- .../components/airtouch4/translations/el.json | 9 ++++++ .../components/airtouch4/translations/es.json | 15 ++++++++++ .../airvisual/translations/sensor.es.json | 6 ++-- .../binary_sensor/translations/el.json | 14 ++++++++++ .../binary_sensor/translations/es.json | 4 +++ .../components/bosch_shc/translations/es.json | 6 +++- .../components/cloud/translations/el.json | 7 +++++ .../components/cloud/translations/es.json | 1 + .../components/cloud/translations/et.json | 1 + .../components/cloud/translations/hu.json | 1 + .../components/cloud/translations/pl.json | 1 + .../components/cloud/translations/ru.json | 1 + .../cloud/translations/zh-Hans.json | 1 + .../components/co2signal/translations/es.json | 12 +++++++- .../fjaraskupan/translations/el.json | 12 ++++++++ .../fjaraskupan/translations/hu.json | 13 +++++++++ .../components/flipr/translations/es.json | 2 +- .../freedompro/translations/es.json | 2 +- .../components/fritz/translations/es.json | 5 ++++ .../garages_amsterdam/translations/es.json | 3 ++ .../components/goalzero/translations/es.json | 3 +- .../components/homekit/translations/hu.json | 3 +- .../homekit/translations/zh-Hans.json | 3 +- .../components/honeywell/translations/es.json | 3 ++ .../components/iotawatt/translations/el.json | 16 +++++++++++ .../components/iotawatt/translations/es.json | 22 +++++++++++++++ .../components/iotawatt/translations/hu.json | 23 +++++++++++++++ .../iotawatt/translations/zh-Hans.json | 23 +++++++++++++++ .../meteoclimatic/translations/es.json | 3 ++ .../modern_forms/translations/es.json | 3 ++ .../components/mqtt/translations/el.json | 7 +++++ .../components/mqtt/translations/hu.json | 1 + .../components/mqtt/translations/zh-Hans.json | 1 + .../components/nanoleaf/translations/el.json | 21 ++++++++++++++ .../components/nanoleaf/translations/es.json | 18 ++++++++++++ .../components/nanoleaf/translations/hu.json | 28 +++++++++++++++++++ .../nanoleaf/translations/zh-Hans.json | 7 +++++ .../nfandroidtv/translations/es.json | 8 +++++- .../nmap_tracker/translations/el.json | 11 ++++++++ .../nmap_tracker/translations/hu.json | 1 + .../components/openuv/translations/el.json | 13 +++++++++ .../components/openuv/translations/es.json | 7 +++++ .../components/openuv/translations/hu.json | 11 ++++++++ .../openuv/translations/zh-Hans.json | 7 +++++ .../p1_monitor/translations/el.json | 13 +++++++++ .../p1_monitor/translations/es.json | 15 ++++++++++ .../p1_monitor/translations/hu.json | 17 +++++++++++ .../components/prosegur/translations/es.json | 4 +-- .../pvpc_hourly_pricing/translations/es.json | 2 +- .../rainforest_eagle/translations/el.json | 18 ++++++++++++ .../rainforest_eagle/translations/es.json | 14 ++++++++++ .../rainforest_eagle/translations/hu.json | 21 ++++++++++++++ .../components/renault/translations/es.json | 4 +-- .../components/sensor/translations/el.json | 18 ++++++++++++ .../components/sensor/translations/es.json | 1 + .../components/sensor/translations/hu.json | 2 ++ .../components/sia/translations/es.json | 1 + .../synology_dsm/translations/el.json | 5 ++++ .../synology_dsm/translations/es.json | 3 ++ .../synology_dsm/translations/hu.json | 3 +- .../components/tractive/translations/el.json | 14 ++++++++++ .../uptimerobot/translations/el.json | 12 ++++++++ .../uptimerobot/translations/es.json | 6 ++-- .../components/wallbox/translations/es.json | 4 +++ .../xiaomi_miio/translations/es.json | 10 +++---- .../yale_smart_alarm/translations/es.json | 2 +- .../yamaha_musiccast/translations/es.json | 3 ++ .../components/zha/translations/el.json | 13 +++++++++ .../components/zha/translations/hu.json | 7 ++++- .../components/zone/translations/ru.json | 2 +- .../components/zwave_js/translations/el.json | 19 +++++++++++++ .../components/zwave_js/translations/es.json | 9 ++++-- .../components/zwave_js/translations/hu.json | 12 ++++++-- 74 files changed, 583 insertions(+), 34 deletions(-) create mode 100644 homeassistant/components/airtouch4/translations/el.json create mode 100644 homeassistant/components/airtouch4/translations/es.json create mode 100644 homeassistant/components/cloud/translations/el.json create mode 100644 homeassistant/components/fjaraskupan/translations/el.json create mode 100644 homeassistant/components/fjaraskupan/translations/hu.json create mode 100644 homeassistant/components/iotawatt/translations/el.json create mode 100644 homeassistant/components/iotawatt/translations/es.json create mode 100644 homeassistant/components/iotawatt/translations/hu.json create mode 100644 homeassistant/components/iotawatt/translations/zh-Hans.json create mode 100644 homeassistant/components/mqtt/translations/el.json create mode 100644 homeassistant/components/nanoleaf/translations/el.json create mode 100644 homeassistant/components/nanoleaf/translations/es.json create mode 100644 homeassistant/components/nanoleaf/translations/hu.json create mode 100644 homeassistant/components/nanoleaf/translations/zh-Hans.json create mode 100644 homeassistant/components/nmap_tracker/translations/el.json create mode 100644 homeassistant/components/openuv/translations/el.json create mode 100644 homeassistant/components/p1_monitor/translations/el.json create mode 100644 homeassistant/components/p1_monitor/translations/es.json create mode 100644 homeassistant/components/p1_monitor/translations/hu.json create mode 100644 homeassistant/components/rainforest_eagle/translations/el.json create mode 100644 homeassistant/components/rainforest_eagle/translations/es.json create mode 100644 homeassistant/components/rainforest_eagle/translations/hu.json create mode 100644 homeassistant/components/tractive/translations/el.json create mode 100644 homeassistant/components/uptimerobot/translations/el.json create mode 100644 homeassistant/components/zha/translations/el.json create mode 100644 homeassistant/components/zwave_js/translations/el.json diff --git a/homeassistant/components/adax/translations/es.json b/homeassistant/components/adax/translations/es.json index 4a65e469bcd..20ecaaa0dd2 100644 --- a/homeassistant/components/adax/translations/es.json +++ b/homeassistant/components/adax/translations/es.json @@ -3,7 +3,9 @@ "step": { "user": { "data": { - "account_id": "ID de la cuenta" + "account_id": "ID de la cuenta", + "host": "Anfitri\u00f3n", + "password": "Contrase\u00f1a" } } } diff --git a/homeassistant/components/airtouch4/translations/el.json b/homeassistant/components/airtouch4/translations/el.json new file mode 100644 index 00000000000..004cb1a268f --- /dev/null +++ b/homeassistant/components/airtouch4/translations/el.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "title": "\u03a1\u03c5\u03b8\u03bc\u03af\u03c3\u03c4\u03b5 \u03c4\u03b1 \u03c3\u03c4\u03bf\u03b9\u03c7\u03b5\u03af\u03b1 \u03c3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7\u03c2 \u03c4\u03bf\u03c5 {intergration}." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airtouch4/translations/es.json b/homeassistant/components/airtouch4/translations/es.json new file mode 100644 index 00000000000..eeae1153555 --- /dev/null +++ b/homeassistant/components/airtouch4/translations/es.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "no_units": "No se pudo encontrar ning\u00fan grupo AirTouch 4." + }, + "step": { + "user": { + "data": { + "host": "Anfitri\u00f3n" + }, + "title": "Configura los detalles de conexi\u00f3n de tu AirTouch 4." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/sensor.es.json b/homeassistant/components/airvisual/translations/sensor.es.json index 4a8a7cea1e3..113c17246ed 100644 --- a/homeassistant/components/airvisual/translations/sensor.es.json +++ b/homeassistant/components/airvisual/translations/sensor.es.json @@ -9,11 +9,11 @@ "s2": "Di\u00f3xido de azufre" }, "airvisual__pollutant_level": { - "good": "Bien", - "hazardous": "Peligroso", + "good": "Bueno", + "hazardous": "Da\u00f1ino", "moderate": "Moderado", "unhealthy": "Insalubre", - "unhealthy_sensitive": "Incorrecto para grupos sensibles", + "unhealthy_sensitive": "Insalubre para grupos sensibles", "very_unhealthy": "Muy poco saludable" } } diff --git a/homeassistant/components/binary_sensor/translations/el.json b/homeassistant/components/binary_sensor/translations/el.json index f4ed1d55bc2..a3887149bee 100644 --- a/homeassistant/components/binary_sensor/translations/el.json +++ b/homeassistant/components/binary_sensor/translations/el.json @@ -1,4 +1,14 @@ { + "device_automation": { + "condition_type": { + "is_no_update": "{entity_name} \u03b5\u03af\u03bd\u03b1\u03b9 \u03b5\u03bd\u03b7\u03bc\u03b5\u03c1\u03c9\u03bc\u03ad\u03bd\u03bf", + "is_update": "{entity_name} \u03ad\u03c7\u03b5\u03b9 \u03bc\u03b9\u03b1 \u03b4\u03b9\u03b1\u03b8\u03ad\u03c3\u03b9\u03bc\u03b7 \u03b5\u03bd\u03b7\u03bc\u03ad\u03c1\u03c9\u03c3\u03b7" + }, + "trigger_type": { + "no_update": "{entity_name} \u03b5\u03bd\u03b7\u03bc\u03b5\u03c1\u03ce\u03b8\u03b7\u03ba\u03b5", + "update": "{entity_name} \u03ad\u03bb\u03b1\u03b2\u03b5 \u03bc\u03b9\u03b1 \u03b4\u03b9\u03b1\u03b8\u03ad\u03c3\u03b9\u03bc\u03b7 \u03b5\u03bd\u03b7\u03bc\u03ad\u03c1\u03c9\u03c3\u03b7" + } + }, "state": { "_": { "off": "\u0391\u03bd\u03b5\u03bd\u03b5\u03c1\u03b3\u03cc\u03c2", @@ -72,6 +82,10 @@ "off": "\u0394\u03b5\u03bd \u0395\u03bd\u03c4\u03bf\u03c0\u03af\u03c3\u03c4\u03b7\u03ba\u03b5", "on": "\u0395\u03bd\u03c4\u03bf\u03c0\u03af\u03c3\u03c4\u03b7\u03ba\u03b5" }, + "update": { + "off": "\u03a0\u03bb\u03ae\u03c1\u03c9\u03c2 \u03b5\u03bd\u03b7\u03bc\u03b5\u03c1\u03c9\u03bc\u03ad\u03bd\u03bf", + "on": "\u0394\u03b9\u03b1\u03b8\u03ad\u03c3\u03b9\u03bc\u03b7 \u03b5\u03bd\u03b7\u03bc\u03ad\u03c1\u03c9\u03c3\u03b7" + }, "vibration": { "off": "\u0394\u03b5\u03bd \u0395\u03bd\u03c4\u03bf\u03c0\u03af\u03c3\u03c4\u03b7\u03ba\u03b5", "on": "\u0395\u03bd\u03c4\u03bf\u03c0\u03af\u03c3\u03c4\u03b7\u03ba\u03b5" diff --git a/homeassistant/components/binary_sensor/translations/es.json b/homeassistant/components/binary_sensor/translations/es.json index 05fc002ecb0..f72e08d5937 100644 --- a/homeassistant/components/binary_sensor/translations/es.json +++ b/homeassistant/components/binary_sensor/translations/es.json @@ -178,6 +178,10 @@ "off": "No detectado", "on": "Detectado" }, + "update": { + "off": "Actualizado", + "on": "Actualizaci\u00f3n disponible" + }, "vibration": { "off": "No detectado", "on": "Detectado" diff --git a/homeassistant/components/bosch_shc/translations/es.json b/homeassistant/components/bosch_shc/translations/es.json index 2b3d4ca7479..6de8f923f5a 100644 --- a/homeassistant/components/bosch_shc/translations/es.json +++ b/homeassistant/components/bosch_shc/translations/es.json @@ -2,7 +2,8 @@ "config": { "error": { "pairing_failed": "El emparejamiento ha fallado; compruebe que el Bosch Smart Home Controller est\u00e1 en modo de emparejamiento (el LED parpadea) y que su contrase\u00f1a es correcta.", - "session_error": "Error de sesi\u00f3n: La API devuelve un resultado no correcto." + "session_error": "Error de sesi\u00f3n: La API devuelve un resultado no correcto.", + "unknown": "Error inesperado" }, "flow_title": "Bosch SHC: {name}", "step": { @@ -18,6 +19,9 @@ "description": "La integraci\u00f3n bosch_shc necesita volver a autentificar su cuenta" }, "user": { + "data": { + "host": "Anfitri\u00f3n" + }, "description": "Configura tu Bosch Smart Home Controller para permitir la supervisi\u00f3n y el control con Home Assistant.", "title": "Par\u00e1metros de autenticaci\u00f3n SHC" } diff --git a/homeassistant/components/cloud/translations/el.json b/homeassistant/components/cloud/translations/el.json new file mode 100644 index 00000000000..923f852f036 --- /dev/null +++ b/homeassistant/components/cloud/translations/el.json @@ -0,0 +1,7 @@ +{ + "system_health": { + "info": { + "remote_server": "\u0391\u03c0\u03bf\u03bc\u03b1\u03ba\u03c1\u03c5\u03c3\u03bc\u03ad\u03bd\u03bf\u03c2 \u0394\u03b9\u03b1\u03ba\u03bf\u03bc\u03b9\u03c3\u03c4\u03ae\u03c2" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cloud/translations/es.json b/homeassistant/components/cloud/translations/es.json index de05ccf527a..f81c71e8292 100644 --- a/homeassistant/components/cloud/translations/es.json +++ b/homeassistant/components/cloud/translations/es.json @@ -10,6 +10,7 @@ "relayer_connected": "Relayer conectado", "remote_connected": "Remoto conectado", "remote_enabled": "Remoto habilitado", + "remote_server": "Servidor remoto", "subscription_expiration": "Caducidad de la suscripci\u00f3n" } } diff --git a/homeassistant/components/cloud/translations/et.json b/homeassistant/components/cloud/translations/et.json index 19f8f40b9d5..59c2b8c6e82 100644 --- a/homeassistant/components/cloud/translations/et.json +++ b/homeassistant/components/cloud/translations/et.json @@ -10,6 +10,7 @@ "relayer_connected": "Edastaja on \u00fchendatud", "remote_connected": "Kaug\u00fchendus on loodud", "remote_enabled": "Kaug\u00fchendus on lubatud", + "remote_server": "Kaugserver", "subscription_expiration": "Tellimuse aegumine" } } diff --git a/homeassistant/components/cloud/translations/hu.json b/homeassistant/components/cloud/translations/hu.json index 8301806831b..3ecfa262ed5 100644 --- a/homeassistant/components/cloud/translations/hu.json +++ b/homeassistant/components/cloud/translations/hu.json @@ -10,6 +10,7 @@ "relayer_connected": "K\u00f6zvet\u00edt\u0151 csatlakoztatva", "remote_connected": "T\u00e1voli csatlakoz\u00e1s", "remote_enabled": "T\u00e1voli hozz\u00e1f\u00e9r\u00e9s enged\u00e9lyezve", + "remote_server": "T\u00e1voli szerver", "subscription_expiration": "El\u0151fizet\u00e9s lej\u00e1rata" } } diff --git a/homeassistant/components/cloud/translations/pl.json b/homeassistant/components/cloud/translations/pl.json index 30aaeeb77d1..d8fafb78b90 100644 --- a/homeassistant/components/cloud/translations/pl.json +++ b/homeassistant/components/cloud/translations/pl.json @@ -10,6 +10,7 @@ "relayer_connected": "Relayer pod\u0142\u0105czony", "remote_connected": "Zdalny dost\u0119p pod\u0142\u0105czony", "remote_enabled": "Zdalny dost\u0119p w\u0142\u0105czony", + "remote_server": "Zdalny serwer", "subscription_expiration": "Wyga\u015bni\u0119cie subskrypcji" } } diff --git a/homeassistant/components/cloud/translations/ru.json b/homeassistant/components/cloud/translations/ru.json index b2d8c55369b..aa3c34ad700 100644 --- a/homeassistant/components/cloud/translations/ru.json +++ b/homeassistant/components/cloud/translations/ru.json @@ -10,6 +10,7 @@ "relayer_connected": "Relayer \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d", "remote_connected": "\u0423\u0434\u0430\u043b\u0451\u043d\u043d\u044b\u0439 \u0434\u043e\u0441\u0442\u0443\u043f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d", "remote_enabled": "\u0423\u0434\u0430\u043b\u0451\u043d\u043d\u044b\u0439 \u0434\u043e\u0441\u0442\u0443\u043f \u0430\u043a\u0442\u0438\u0432\u0438\u0440\u043e\u0432\u0430\u043d", + "remote_server": "\u0423\u0434\u0430\u043b\u0451\u043d\u043d\u044b\u0439 \u0441\u0435\u0440\u0432\u0435\u0440", "subscription_expiration": "\u0421\u0440\u043e\u043a \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u044f \u043f\u043e\u0434\u043f\u0438\u0441\u043a\u0438" } } diff --git a/homeassistant/components/cloud/translations/zh-Hans.json b/homeassistant/components/cloud/translations/zh-Hans.json index eb1daf5e4f3..f4011e3981e 100644 --- a/homeassistant/components/cloud/translations/zh-Hans.json +++ b/homeassistant/components/cloud/translations/zh-Hans.json @@ -10,6 +10,7 @@ "relayer_connected": "\u901a\u8fc7\u4ee3\u7406\u8fde\u63a5", "remote_connected": "\u8fdc\u7a0b\u8fde\u63a5", "remote_enabled": "\u5df2\u542f\u7528\u8fdc\u7a0b\u63a7\u5236", + "remote_server": "\u8fdc\u7a0b\u670d\u52a1\u5668", "subscription_expiration": "\u8ba2\u9605\u5230\u671f\u65f6\u95f4" } } diff --git a/homeassistant/components/co2signal/translations/es.json b/homeassistant/components/co2signal/translations/es.json index 071ae642c74..61f21c21ca3 100644 --- a/homeassistant/components/co2signal/translations/es.json +++ b/homeassistant/components/co2signal/translations/es.json @@ -1,9 +1,19 @@ { "config": { "abort": { - "api_ratelimit": "Se ha superado el l\u00edmite de velocidad de la API" + "api_ratelimit": "Se ha superado el l\u00edmite de velocidad de la API", + "unknown": "Error inesperado" + }, + "error": { + "unknown": "Error inesperado" }, "step": { + "coordinates": { + "data": { + "latitude": "Latitud", + "longitude": "Longitud" + } + }, "country": { "data": { "country_code": "C\u00f3digo del pa\u00eds" diff --git a/homeassistant/components/fjaraskupan/translations/el.json b/homeassistant/components/fjaraskupan/translations/el.json new file mode 100644 index 00000000000..cb6a9ccddb2 --- /dev/null +++ b/homeassistant/components/fjaraskupan/translations/el.json @@ -0,0 +1,12 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u0394\u03b5\u03bd \u03b2\u03c1\u03ad\u03b8\u03b7\u03ba\u03b5 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae \u03c3\u03c4\u03bf \u03b4\u03af\u03ba\u03c4\u03c5\u03bf" + }, + "step": { + "confirm": { + "description": "\u0398\u03ad\u03bb\u03b5\u03c4\u03b5 \u03bd\u03b1 \u03c1\u03c5\u03b8\u03bc\u03af\u03c3\u03b5\u03c4\u03b5 \u03c4\u03bf {integration};" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fjaraskupan/translations/hu.json b/homeassistant/components/fjaraskupan/translations/hu.json new file mode 100644 index 00000000000..cb7e29986ac --- /dev/null +++ b/homeassistant/components/fjaraskupan/translations/hu.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nem tal\u00e1lhat\u00f3 eszk\u00f6z a h\u00e1l\u00f3zaton", + "single_instance_allowed": "M\u00e1r konfigur\u00e1lva. Csak egyetlen konfigur\u00e1ci\u00f3 lehets\u00e9ges." + }, + "step": { + "confirm": { + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani a Fj\u00e4r\u00e5skupan szolg\u00e1ltat\u00e1st?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flipr/translations/es.json b/homeassistant/components/flipr/translations/es.json index 766f83856ec..69ff84c2e69 100644 --- a/homeassistant/components/flipr/translations/es.json +++ b/homeassistant/components/flipr/translations/es.json @@ -9,7 +9,7 @@ "data": { "flipr_id": "ID de Flipr" }, - "description": "Elija su ID de Flipr en la lista", + "description": "Elige tu ID de Flipr en la lista", "title": "Elige tu Flipr" }, "user": { diff --git a/homeassistant/components/freedompro/translations/es.json b/homeassistant/components/freedompro/translations/es.json index b6f8afeaf6d..c08c30d64dc 100644 --- a/homeassistant/components/freedompro/translations/es.json +++ b/homeassistant/components/freedompro/translations/es.json @@ -12,7 +12,7 @@ "data": { "api_key": "Clave API" }, - "description": "Ingrese la clave API obtenida de https://home.freedompro.eu", + "description": "Ingresa la clave API obtenida de https://home.freedompro.eu", "title": "Clave API de Freedompro" } } diff --git a/homeassistant/components/fritz/translations/es.json b/homeassistant/components/fritz/translations/es.json index ed39b227ec8..f9f586bfa71 100644 --- a/homeassistant/components/fritz/translations/es.json +++ b/homeassistant/components/fritz/translations/es.json @@ -40,6 +40,11 @@ "title": "Configurar FRITZ!Box Tools - obligatorio" }, "user": { + "data": { + "host": "Anfitri\u00f3n", + "password": "Contrase\u00f1a", + "port": "Puerto" + }, "description": "Configure las herramientas de FRITZ! Box para controlar su FRITZ! Box.\n M\u00ednimo necesario: nombre de usuario, contrase\u00f1a.", "title": "Configurar las herramientas de FRITZ! Box" } diff --git a/homeassistant/components/garages_amsterdam/translations/es.json b/homeassistant/components/garages_amsterdam/translations/es.json index 3bf5c176b56..bfea8be63c1 100644 --- a/homeassistant/components/garages_amsterdam/translations/es.json +++ b/homeassistant/components/garages_amsterdam/translations/es.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "unknown": "Error inesperado" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/goalzero/translations/es.json b/homeassistant/components/goalzero/translations/es.json index 06ee47fd1ca..8c3aeb80965 100644 --- a/homeassistant/components/goalzero/translations/es.json +++ b/homeassistant/components/goalzero/translations/es.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "La cuenta ya ha sido configurada" + "already_configured": "La cuenta ya ha sido configurada", + "unknown": "Error inesperado" }, "error": { "cannot_connect": "No se pudo conectar", diff --git a/homeassistant/components/homekit/translations/hu.json b/homeassistant/components/homekit/translations/hu.json index c6fdf0afd74..f60db036247 100644 --- a/homeassistant/components/homekit/translations/hu.json +++ b/homeassistant/components/homekit/translations/hu.json @@ -21,7 +21,8 @@ "step": { "advanced": { "data": { - "auto_start": "Automatikus ind\u00edt\u00e1s (tiltsa le, ha manu\u00e1lisan h\u00edvja a homekit.start szolg\u00e1ltat\u00e1st)" + "auto_start": "Automatikus ind\u00edt\u00e1s (tiltsa le, ha manu\u00e1lisan h\u00edvja a homekit.start szolg\u00e1ltat\u00e1st)", + "devices": "Eszk\u00f6z\u00f6k (triggerek)" }, "description": "Ezeket a be\u00e1ll\u00edt\u00e1sokat csak akkor kell m\u00f3dos\u00edtani, ha a HomeKit nem m\u0171k\u00f6dik.", "title": "Halad\u00f3 be\u00e1ll\u00edt\u00e1sok" diff --git a/homeassistant/components/homekit/translations/zh-Hans.json b/homeassistant/components/homekit/translations/zh-Hans.json index e85c492d3cd..4a1486735ff 100644 --- a/homeassistant/components/homekit/translations/zh-Hans.json +++ b/homeassistant/components/homekit/translations/zh-Hans.json @@ -21,7 +21,8 @@ "step": { "advanced": { "data": { - "auto_start": "[%key_id:43661779%]" + "auto_start": "[%key_id:43661779%]", + "devices": "\u8bbe\u5907 (\u89e6\u53d1\u5668)" }, "description": "\u8fd9\u4e9b\u8bbe\u7f6e\u53ea\u6709\u5f53 HomeKit \u529f\u80fd\u4e0d\u6b63\u5e38\u65f6\u624d\u9700\u8981\u8c03\u6574\u3002", "title": "\u9ad8\u7ea7\u914d\u7f6e" diff --git a/homeassistant/components/honeywell/translations/es.json b/homeassistant/components/honeywell/translations/es.json index 41534be9d8d..70549039a04 100644 --- a/homeassistant/components/honeywell/translations/es.json +++ b/homeassistant/components/honeywell/translations/es.json @@ -2,6 +2,9 @@ "config": { "step": { "user": { + "data": { + "password": "Contrase\u00f1a" + }, "description": "Por favor, introduzca las credenciales utilizadas para iniciar sesi\u00f3n en mytotalconnectcomfort.com.", "title": "Honeywell Total Connect Comfort (US)" } diff --git a/homeassistant/components/iotawatt/translations/el.json b/homeassistant/components/iotawatt/translations/el.json new file mode 100644 index 00000000000..0030674e3ca --- /dev/null +++ b/homeassistant/components/iotawatt/translations/el.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "cannot_connect": "\u0391\u03c0\u03bf\u03c4\u03c5\u03c7\u03af\u03b1 \u03c3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7\u03c2", + "unknown": "\u0391\u03bd\u03b5\u03c0\u03ac\u03bd\u03c4\u03b5\u03c7\u03bf \u03c3\u03c6\u03ac\u03bb\u03bc\u03b1" + }, + "step": { + "auth": { + "data": { + "password": "\u039a\u03c9\u03b4\u03b9\u03ba\u03cc\u03c2", + "username": "\u038c\u03bd\u03bf\u03bc\u03b1 \u03c7\u03c1\u03ae\u03c3\u03c4\u03b7" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/iotawatt/translations/es.json b/homeassistant/components/iotawatt/translations/es.json new file mode 100644 index 00000000000..07540d160bb --- /dev/null +++ b/homeassistant/components/iotawatt/translations/es.json @@ -0,0 +1,22 @@ +{ + "config": { + "error": { + "cannot_connect": "La conexi\u00f3n ha fallado", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "unknown": "Error inesperado" + }, + "step": { + "auth": { + "data": { + "password": "Contrase\u00f1a", + "username": "Nombre de usuario" + } + }, + "user": { + "data": { + "host": "Anfitri\u00f3n" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/iotawatt/translations/hu.json b/homeassistant/components/iotawatt/translations/hu.json new file mode 100644 index 00000000000..1c545b3d3ce --- /dev/null +++ b/homeassistant/components/iotawatt/translations/hu.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "cannot_connect": "Nem siker\u00fclt csatlakozni", + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "unknown": "V\u00e1ratlan hiba" + }, + "step": { + "auth": { + "data": { + "password": "Jelsz\u00f3", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + }, + "description": "Az IoTawatt eszk\u00f6z hiteles\u00edt\u00e9st ig\u00e9nyel. K\u00e9rj\u00fck, adja meg felhaszn\u00e1l\u00f3nev\u00e9t \u00e9s jelszav\u00e1t, majd kattintson a K\u00fcld\u00e9s gombra." + }, + "user": { + "data": { + "host": "Gazdag\u00e9p" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/iotawatt/translations/zh-Hans.json b/homeassistant/components/iotawatt/translations/zh-Hans.json new file mode 100644 index 00000000000..7f36e76dc0a --- /dev/null +++ b/homeassistant/components/iotawatt/translations/zh-Hans.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "cannot_connect": "\u8fde\u63a5\u5931\u8d25", + "invalid_auth": "\u65e0\u6548\u51ed\u8bc1", + "unknown": "\u672a\u77e5\u9519\u8bef" + }, + "step": { + "auth": { + "data": { + "password": "\u5bc6\u7801", + "username": "\u7528\u6237\u540d" + }, + "description": "IoTawatt \u8bbe\u5907\u9700\u8981\u8eab\u4efd\u9a8c\u8bc1\u3002\u8bf7\u8f93\u5165\u7528\u6237\u540d\u548c\u5bc6\u7801\uff0c\u7136\u540e\u5355\u51fb\u63d0\u4ea4\u6309\u94ae\u3002" + }, + "user": { + "data": { + "host": "\u4e3b\u673a\u5730\u5740" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/meteoclimatic/translations/es.json b/homeassistant/components/meteoclimatic/translations/es.json index fd9a38db804..2cb627d4ae0 100644 --- a/homeassistant/components/meteoclimatic/translations/es.json +++ b/homeassistant/components/meteoclimatic/translations/es.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "unknown": "Error inesperado" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/modern_forms/translations/es.json b/homeassistant/components/modern_forms/translations/es.json index ac911baf4a4..25a432214fc 100644 --- a/homeassistant/components/modern_forms/translations/es.json +++ b/homeassistant/components/modern_forms/translations/es.json @@ -3,6 +3,9 @@ "flow_title": "{name}", "step": { "user": { + "data": { + "host": "Anfitri\u00f3n" + }, "description": "Configura tu ventilador de Modern Forms para que se integre con Home Assistant." }, "zeroconf_confirm": { diff --git a/homeassistant/components/mqtt/translations/el.json b/homeassistant/components/mqtt/translations/el.json new file mode 100644 index 00000000000..db531604073 --- /dev/null +++ b/homeassistant/components/mqtt/translations/el.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "already_configured": "\u0397 \u03c5\u03c0\u03b7\u03c1\u03b5\u03c3\u03af\u03b1 \u03b5\u03af\u03bd\u03b1\u03b9 \u03ae\u03b4\u03b7 \u03b4\u03b9\u03b1\u03bc\u03bf\u03c1\u03c6\u03c9\u03bc\u03ad\u03bd\u03b7" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mqtt/translations/hu.json b/homeassistant/components/mqtt/translations/hu.json index a519cab55d3..ad371afabc8 100644 --- a/homeassistant/components/mqtt/translations/hu.json +++ b/homeassistant/components/mqtt/translations/hu.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "A szolg\u00e1ltat\u00e1s m\u00e1r konfigur\u00e1lva van", "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges." }, "error": { diff --git a/homeassistant/components/mqtt/translations/zh-Hans.json b/homeassistant/components/mqtt/translations/zh-Hans.json index 97356ed44d4..31fc4e36825 100644 --- a/homeassistant/components/mqtt/translations/zh-Hans.json +++ b/homeassistant/components/mqtt/translations/zh-Hans.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "\u670d\u52a1\u5df2\u88ab\u914d\u7f6e", "single_instance_allowed": "\u53ea\u5141\u8bb8\u4e00\u4e2a MQTT \u914d\u7f6e\u3002" }, "error": { diff --git a/homeassistant/components/nanoleaf/translations/el.json b/homeassistant/components/nanoleaf/translations/el.json new file mode 100644 index 00000000000..be6719d8c2a --- /dev/null +++ b/homeassistant/components/nanoleaf/translations/el.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u0397 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae \u03b5\u03af\u03bd\u03b1\u03b9 \u03ae\u03b4\u03b7 \u03b4\u03b9\u03b1\u03bc\u03bf\u03c1\u03c6\u03c9\u03bc\u03ad\u03bd\u03b7", + "cannot_connect": "\u0391\u03c0\u03bf\u03c4\u03c5\u03c7\u03af\u03b1 \u03c3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7\u03c2", + "invalid_token": "\u0386\u03ba\u03c5\u03c1\u03bf \u03b4\u03b9\u03b1\u03c0\u03b9\u03c3\u03c4\u03b5\u03c5\u03c4\u03ae\u03c1\u03b9\u03bf \u03c3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7\u03c2", + "unknown": "\u0391\u03bd\u03b5\u03c0\u03ac\u03bd\u03c4\u03b5\u03c7\u03bf \u03bb\u03ac\u03b8\u03bf\u03c2" + }, + "error": { + "cannot_connect": "\u0391\u03c0\u03bf\u03c4\u03c5\u03c7\u03af\u03b1 \u03c3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7\u03c2", + "not_allowing_new_tokens": "\u03a4\u03bf Nanoleaf \u03b4\u03b5\u03bd \u03b5\u03c0\u03b9\u03c4\u03c1\u03ad\u03c0\u03b5\u03b9 \u03bd\u03ad\u03b1 tokens, \u03b1\u03ba\u03bf\u03bb\u03bf\u03c5\u03b8\u03ae\u03c3\u03c4\u03b5 \u03c4\u03b9\u03c2 \u03c0\u03b1\u03c1\u03b1\u03c0\u03ac\u03bd\u03c9 \u03bf\u03b4\u03b7\u03b3\u03af\u03b5\u03c2.", + "unknown": "\u0391\u03bd\u03b5\u03c0\u03ac\u03bd\u03c4\u03b5\u03c7\u03bf \u03c3\u03c6\u03ac\u03bb\u03bc\u03b1" + }, + "step": { + "link": { + "description": "\u03a0\u03b1\u03c4\u03ae\u03c3\u03c4\u03b5 \u03c0\u03b1\u03c1\u03b1\u03c4\u03b5\u03c4\u03b1\u03bc\u03ad\u03bd\u03b1 \u03c4\u03bf \u03ba\u03bf\u03c5\u03bc\u03c0\u03af \u03bb\u03b5\u03b9\u03c4\u03bf\u03c5\u03c1\u03b3\u03af\u03b1\u03c2 \u03c3\u03c4\u03bf Nanoleaf \u03b3\u03b9\u03b1 5 \u03b4\u03b5\u03c5\u03c4\u03b5\u03c1\u03cc\u03bb\u03b5\u03c0\u03c4\u03b1 \u03bc\u03ad\u03c7\u03c1\u03b9 \u03bd\u03b1 \u03b1\u03c1\u03c7\u03af\u03c3\u03bf\u03c5\u03bd \u03bd\u03b1 \u03b1\u03bd\u03b1\u03b2\u03bf\u03c3\u03b2\u03ae\u03bd\u03bf\u03c5\u03bd \u03bf\u03b9 \u03bb\u03c5\u03c7\u03bd\u03af\u03b5\u03c2 LED \u03c4\u03bf\u03c5 \u03ba\u03bf\u03c5\u03bc\u03c0\u03b9\u03bf\u03cd \u03ba\u03b1\u03b9, \u03c3\u03c4\u03b7 \u03c3\u03c5\u03bd\u03ad\u03c7\u03b5\u03b9\u03b1, \u03ba\u03ac\u03bd\u03c4\u03b5 \u03ba\u03bb\u03b9\u03ba \u03c3\u03c4\u03bf \u03ba\u03bf\u03c5\u03bc\u03c0\u03af **SUBMIT** \u03bc\u03ad\u03c3\u03b1 \u03c3\u03b5 30 \u03b4\u03b5\u03c5\u03c4\u03b5\u03c1\u03cc\u03bb\u03b5\u03c0\u03c4\u03b1.", + "title": "\u0394\u03b9\u03ac\u03b6\u03b5\u03c5\u03be\u03b7 Nanoleaf" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nanoleaf/translations/es.json b/homeassistant/components/nanoleaf/translations/es.json new file mode 100644 index 00000000000..16b28203215 --- /dev/null +++ b/homeassistant/components/nanoleaf/translations/es.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "unknown": "Error inesperado" + }, + "error": { + "unknown": "Error inesperado" + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "host": "Anfitri\u00f3n" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nanoleaf/translations/hu.json b/homeassistant/components/nanoleaf/translations/hu.json new file mode 100644 index 00000000000..7c5854055a4 --- /dev/null +++ b/homeassistant/components/nanoleaf/translations/hu.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "cannot_connect": "Nem siker\u00fclt csatlakozni", + "invalid_token": "\u00c9rv\u00e9nytelen hozz\u00e1f\u00e9r\u00e9si token", + "reauth_successful": "Az ism\u00e9telt hiteles\u00edt\u00e9s sikeres volt", + "unknown": "V\u00e1ratlan hiba" + }, + "error": { + "cannot_connect": "Nem siker\u00fclt csatlakozni", + "not_allowing_new_tokens": "A Nanoleaf nem enged\u00e9lyezi az \u00faj tokeneket, k\u00f6vesse a fenti utas\u00edt\u00e1sokat.", + "unknown": "V\u00e1ratlan hiba" + }, + "flow_title": "{name}", + "step": { + "link": { + "description": "Nyomja meg \u00e9s tartsa lenyomva a Nanoleaf bekapcsol\u00f3gombj\u00e1t 5 m\u00e1sodpercig, am\u00edg a gomb LED-je villogni nem kezd, majd kattintson a **SUBMIT** gombra 30 m\u00e1sodpercen bel\u00fcl.", + "title": "Nanoleaf link" + }, + "user": { + "data": { + "host": "Gazdag\u00e9p" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nanoleaf/translations/zh-Hans.json b/homeassistant/components/nanoleaf/translations/zh-Hans.json new file mode 100644 index 00000000000..0360008af43 --- /dev/null +++ b/homeassistant/components/nanoleaf/translations/zh-Hans.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "reauth_successful": "\u91cd\u9a8c\u8bc1\u6210\u529f" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nfandroidtv/translations/es.json b/homeassistant/components/nfandroidtv/translations/es.json index e99ce545b74..efb7c6a5c8a 100644 --- a/homeassistant/components/nfandroidtv/translations/es.json +++ b/homeassistant/components/nfandroidtv/translations/es.json @@ -1,8 +1,14 @@ { "config": { + "error": { + "unknown": "Error inesperado" + }, "step": { "user": { - "description": "Esta integraci\u00f3n requiere la aplicaci\u00f3n de Notificaciones para Android TV.\n\nPara Android TV: https://play.google.com/store/apps/details?id=de.cyberdream.androidtv.notifications.google\nPara Fire TV: https://www.amazon.com/Christian-Fees-Notifications-for-Fire/dp/B00OESCXEK\n\nDebe configurar una reserva DHCP en su router (consulte el manual de usuario de su router) o una direcci\u00f3n IP est\u00e1tica en el dispositivo. Si no, el dispositivo acabar\u00e1 por no estar disponible.", + "data": { + "host": "Anfitri\u00f3n" + }, + "description": "Esta integraci\u00f3n requiere la aplicaci\u00f3n de Notificaciones para Android TV.\n\nPara Android TV: https://play.google.com/store/apps/details?id=de.cyberdream.androidtv.notifications.google\nPara Fire TV: https://www.amazon.com/Christian-Fees-Notifications-for-Fire/dp/B00OESCXEK\n\nDebes configurar una reserva DHCP en su router (consulta el manual de usuario de tu router) o una direcci\u00f3n IP est\u00e1tica en el dispositivo. Si no, el dispositivo acabar\u00e1 por no estar disponible.", "title": "Notificaciones para Android TV / Fire TV" } } diff --git a/homeassistant/components/nmap_tracker/translations/el.json b/homeassistant/components/nmap_tracker/translations/el.json new file mode 100644 index 00000000000..74a0f8b1c9c --- /dev/null +++ b/homeassistant/components/nmap_tracker/translations/el.json @@ -0,0 +1,11 @@ +{ + "options": { + "step": { + "init": { + "data": { + "consider_home": "\u0394\u03b5\u03c5\u03c4\u03b5\u03c1\u03cc\u03bb\u03b5\u03c0\u03c4\u03b1 \u03b1\u03bd\u03b1\u03bc\u03bf\u03bd\u03ae\u03c2 \u03bc\u03ad\u03c7\u03c1\u03b9 \u03bd\u03b1 \u03b5\u03c0\u03b9\u03c3\u03b7\u03bc\u03b1\u03bd\u03b8\u03b5\u03af \u03bc\u03b9\u03b1 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae \u03b5\u03bd\u03c4\u03bf\u03c0\u03b9\u03c3\u03bc\u03bf\u03cd \u03c9\u03c2 \u03cc\u03c7\u03b9 \u03c3\u03c0\u03af\u03c4\u03b9, \u03b1\u03c6\u03bf\u03cd \u03b4\u03b5\u03bd \u03ad\u03c7\u03b5\u03b9 \u03b5\u03bc\u03c6\u03b1\u03bd\u03b9\u03c3\u03c4\u03b5\u03af." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nmap_tracker/translations/hu.json b/homeassistant/components/nmap_tracker/translations/hu.json index 1b5dc9d029b..e7443f41a0e 100644 --- a/homeassistant/components/nmap_tracker/translations/hu.json +++ b/homeassistant/components/nmap_tracker/translations/hu.json @@ -25,6 +25,7 @@ "step": { "init": { "data": { + "consider_home": "K\u00e9sleltet\u00e9s (m\u00e1sodpercben), am\u00edg az eszk\u00f6zk\u00f6vet\u0151 elveszt\u00e9se ut\u00e1n aktiv\u00e1l\u00f3djon a funcki\u00f3.", "exclude": "A szkennel\u00e9sb\u0151l kiz\u00e1rand\u00f3 h\u00e1l\u00f3zati c\u00edmek (vessz\u0151vel elv\u00e1lasztva)", "home_interval": "Minim\u00e1lis percsz\u00e1m az akt\u00edv eszk\u00f6z\u00f6k vizsg\u00e1lata k\u00f6z\u00f6tt (akkumul\u00e1tor k\u00edm\u00e9l\u00e9se)", "hosts": "H\u00e1l\u00f3zati c\u00edmek (vessz\u0151vel elv\u00e1lasztva) a beolvas\u00e1shoz", diff --git a/homeassistant/components/openuv/translations/el.json b/homeassistant/components/openuv/translations/el.json new file mode 100644 index 00000000000..a22e4c27ec9 --- /dev/null +++ b/homeassistant/components/openuv/translations/el.json @@ -0,0 +1,13 @@ +{ + "options": { + "step": { + "init": { + "data": { + "from_window": "\u0391\u03c1\u03c7\u03b9\u03ba\u03cc\u03c2 \u03b4\u03b5\u03af\u03ba\u03c4\u03b7\u03c2 UV \u03b3\u03b9\u03b1 \u03c4\u03bf \u03c0\u03b1\u03c1\u03ac\u03b8\u03c5\u03c1\u03bf \u03c0\u03c1\u03bf\u03c3\u03c4\u03b1\u03c3\u03af\u03b1\u03c2", + "to_window": "\u03a4\u03b5\u03bb\u03b9\u03ba\u03cc\u03c2 \u03b4\u03b5\u03af\u03ba\u03c4\u03b7\u03c2 UV \u03b3\u03b9\u03b1 \u03c4\u03bf \u03c0\u03b1\u03c1\u03ac\u03b8\u03c5\u03c1\u03bf \u03c0\u03c1\u03bf\u03c3\u03c4\u03b1\u03c3\u03af\u03b1\u03c2" + }, + "title": "\u0394\u03b9\u03b1\u03bc\u03cc\u03c1\u03c6\u03c9\u03c3\u03b7 {intergration}" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/openuv/translations/es.json b/homeassistant/components/openuv/translations/es.json index 7736adaa674..c912cef6c54 100644 --- a/homeassistant/components/openuv/translations/es.json +++ b/homeassistant/components/openuv/translations/es.json @@ -17,5 +17,12 @@ "title": "Completa tu informaci\u00f3n" } } + }, + "options": { + "step": { + "init": { + "title": "Configurar OpenUV" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/openuv/translations/hu.json b/homeassistant/components/openuv/translations/hu.json index b5c0e5ec608..1c3c6387dd3 100644 --- a/homeassistant/components/openuv/translations/hu.json +++ b/homeassistant/components/openuv/translations/hu.json @@ -17,5 +17,16 @@ "title": "T\u00f6ltsd ki az adataid" } } + }, + "options": { + "step": { + "init": { + "data": { + "from_window": "Kezd\u0151 UV index a v\u00e9d\u0151ablakhoz", + "to_window": "A v\u00e9d\u0151ablak UV-index\u00e9nek v\u00e9ge" + }, + "title": "Konfigur\u00e1lja az OpenUV-t" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/openuv/translations/zh-Hans.json b/homeassistant/components/openuv/translations/zh-Hans.json index d23c6f3d52c..f9604321f10 100644 --- a/homeassistant/components/openuv/translations/zh-Hans.json +++ b/homeassistant/components/openuv/translations/zh-Hans.json @@ -14,5 +14,12 @@ "title": "\u586b\u5199\u60a8\u7684\u4fe1\u606f" } } + }, + "options": { + "step": { + "init": { + "title": "\u914d\u7f6e OpenUV" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/p1_monitor/translations/el.json b/homeassistant/components/p1_monitor/translations/el.json new file mode 100644 index 00000000000..00e89f9735d --- /dev/null +++ b/homeassistant/components/p1_monitor/translations/el.json @@ -0,0 +1,13 @@ +{ + "config": { + "error": { + "cannot_connect": "\u0391\u03c0\u03bf\u03c4\u03c5\u03c7\u03af\u03b1 \u03c3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7\u03c2", + "unknown": "\u0391\u03bd\u03b5\u03c0\u03ac\u03bd\u03c4\u03b5\u03c7\u03bf \u03c3\u03c6\u03ac\u03bb\u03bc\u03b1" + }, + "step": { + "user": { + "description": "\u03a1\u03c5\u03b8\u03bc\u03af\u03c3\u03c4\u03b5 \u03c4\u03bf {intergration} \u03b3\u03b9\u03b1 \u03b5\u03bd\u03c3\u03c9\u03bc\u03ac\u03c4\u03c9\u03c3\u03b7 \u03bc\u03b5 \u03c4\u03bf Home Assistant." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/p1_monitor/translations/es.json b/homeassistant/components/p1_monitor/translations/es.json new file mode 100644 index 00000000000..023a2a9f17c --- /dev/null +++ b/homeassistant/components/p1_monitor/translations/es.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "unknown": "Error inesperado" + }, + "step": { + "user": { + "data": { + "host": "Anfitri\u00f3n", + "name": "Nombre" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/p1_monitor/translations/hu.json b/homeassistant/components/p1_monitor/translations/hu.json new file mode 100644 index 00000000000..80d00e51571 --- /dev/null +++ b/homeassistant/components/p1_monitor/translations/hu.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "Nem siker\u00fclt csatlakozni", + "unknown": "V\u00e1ratlan hiba" + }, + "step": { + "user": { + "data": { + "host": "Gazdag\u00e9p", + "name": "N\u00e9v" + }, + "description": "\u00c1ll\u00edtsa be a P1 monitort az Otthoni asszisztenssel val\u00f3 integr\u00e1ci\u00f3hoz." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/prosegur/translations/es.json b/homeassistant/components/prosegur/translations/es.json index fbccb2f6391..272b501da30 100644 --- a/homeassistant/components/prosegur/translations/es.json +++ b/homeassistant/components/prosegur/translations/es.json @@ -13,14 +13,14 @@ "reauth_confirm": { "data": { "description": "Vuelva a autenticarse con su cuenta Prosegur.", - "password": "Clave", + "password": "Contrase\u00f1a", "username": "Nombre de Usuario" } }, "user": { "data": { "country": "Pa\u00eds", - "password": "Clave", + "password": "Contrase\u00f1a", "username": "Nombre de Usuario" } } diff --git a/homeassistant/components/pvpc_hourly_pricing/translations/es.json b/homeassistant/components/pvpc_hourly_pricing/translations/es.json index 59c6f6de174..4af0de8f594 100644 --- a/homeassistant/components/pvpc_hourly_pricing/translations/es.json +++ b/homeassistant/components/pvpc_hourly_pricing/translations/es.json @@ -24,7 +24,7 @@ "power_p3": "Potencia contratada para el per\u00edodo valle P3 (kW)", "tariff": "Tarifa aplicable por zona geogr\u00e1fica" }, - "description": "Este sensor utiliza la API oficial para obtener el [precio horario de la electricidad (PVPC)](https://www.esios.ree.es/es/pvpc) en Espa\u00f1a.\nPara una explicaci\u00f3n m\u00e1s precisa visite los [documentos de integraci\u00f3n](https://www.home-assistant.io/integrations/pvpc_hourly_pricing/).", + "description": "Este sensor utiliza la API oficial para obtener el [precio horario de la electricidad (PVPC)](https://www.esios.ree.es/es/pvpc) en Espa\u00f1a.\nPara una explicaci\u00f3n m\u00e1s precisa visita los [documentos de integraci\u00f3n](https://www.home-assistant.io/integrations/pvpc_hourly_pricing/).", "title": "Configuraci\u00f3n del sensor" } } diff --git a/homeassistant/components/rainforest_eagle/translations/el.json b/homeassistant/components/rainforest_eagle/translations/el.json new file mode 100644 index 00000000000..686a0d72c44 --- /dev/null +++ b/homeassistant/components/rainforest_eagle/translations/el.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "\u0397 \u03c3\u03c5\u03c3\u03c3\u03ba\u03b5\u03c5\u03ae \u03b5\u03af\u03bd\u03b1\u03b9 \u03ae\u03b4\u03b7 \u03c1\u03c5\u03b8\u03bc\u03b9\u03c3\u03bc\u03ad\u03bd\u03b7" + }, + "error": { + "unknown": "\u0391\u03bd\u03b5\u03c0\u03ac\u03bd\u03c4\u03b5\u03c7\u03bf \u03c3\u03c6\u03ac\u03bb\u03bc\u03b1" + }, + "step": { + "user": { + "data": { + "cloud_id": "\u0391\u03bd\u03b1\u03b3\u03bd\u03c9\u03c1\u03b9\u03c3\u03c4\u03b9\u03ba\u03cc \u03bd\u03ad\u03c6\u03bf\u03c5\u03c2", + "install_code": "\u039a\u03c9\u03b4\u03b9\u03ba\u03cc\u03c2 \u03b5\u03b3\u03ba\u03b1\u03c4\u03ac\u03c3\u03c4\u03b1\u03c3\u03b7\u03c2" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rainforest_eagle/translations/es.json b/homeassistant/components/rainforest_eagle/translations/es.json new file mode 100644 index 00000000000..53d9cb6f7c8 --- /dev/null +++ b/homeassistant/components/rainforest_eagle/translations/es.json @@ -0,0 +1,14 @@ +{ + "config": { + "error": { + "unknown": "Error inesperado" + }, + "step": { + "user": { + "data": { + "host": "Anfitri\u00f3n" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rainforest_eagle/translations/hu.json b/homeassistant/components/rainforest_eagle/translations/hu.json new file mode 100644 index 00000000000..10f5a16cd23 --- /dev/null +++ b/homeassistant/components/rainforest_eagle/translations/hu.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + }, + "error": { + "cannot_connect": "Nem siker\u00fclt csatlakozni", + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "unknown": "V\u00e1ratlan hiba" + }, + "step": { + "user": { + "data": { + "cloud_id": "Cloud ID", + "host": "Gazdag\u00e9p", + "install_code": "Telep\u00edt\u00e9si k\u00f3d" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/renault/translations/es.json b/homeassistant/components/renault/translations/es.json index 0eabcacccd3..987377770dd 100644 --- a/homeassistant/components/renault/translations/es.json +++ b/homeassistant/components/renault/translations/es.json @@ -12,12 +12,12 @@ "data": { "kamereon_account_id": "ID de cuenta de Kamereon" }, - "title": "Seleccione el id de la cuenta de Kamereon" + "title": "Selecciona el id de la cuenta de Kamereon" }, "user": { "data": { "locale": "Configuraci\u00f3n regional", - "password": "Clave", + "password": "Contrase\u00f1a", "username": "Correo-e" }, "title": "Establecer las credenciales de Renault" diff --git a/homeassistant/components/sensor/translations/el.json b/homeassistant/components/sensor/translations/el.json index 21bcb9e378c..ed9e92cd4b1 100644 --- a/homeassistant/components/sensor/translations/el.json +++ b/homeassistant/components/sensor/translations/el.json @@ -1,4 +1,22 @@ { + "device_automation": { + "condition_type": { + "is_gas": "\u03a4\u03c1\u03ad\u03c7\u03bf\u03bd \u03b1\u03ad\u03c1\u03b9\u03bf {entity_name}", + "is_pm25": "\u03a4\u03c1\u03ad\u03c7\u03bf\u03bd \u03b5\u03c0\u03af\u03c0\u03b5\u03b4\u03bf \u03c3\u03c5\u03b3\u03ba\u03ad\u03bd\u03c4\u03c1\u03c9\u03c3\u03b7\u03c2 {entity_name} PM2.5", + "is_sulphur_dioxide": "\u03a4\u03c1\u03ad\u03c7\u03bf\u03bd \u03b5\u03c0\u03af\u03c0\u03b5\u03b4\u03bf \u03c3\u03c5\u03b3\u03ba\u03ad\u03bd\u03c4\u03c1\u03c9\u03c3\u03b7\u03c2 \u03b4\u03b9\u03bf\u03be\u03b5\u03b9\u03b4\u03af\u03bf\u03c5 \u03c4\u03bf\u03c5 \u03b8\u03b5\u03af\u03bf\u03c5 {entity_name}" + }, + "trigger_type": { + "gas": "{entity_name} \u03bc\u03b5\u03c4\u03b1\u03b2\u03bf\u03bb\u03ad\u03c2 \u03b1\u03b5\u03c1\u03af\u03bf\u03c5", + "nitrogen_dioxide": "{entity_name} \u03bc\u03b5\u03c4\u03b1\u03b2\u03bf\u03bb\u03ad\u03c2 \u03c4\u03b7\u03c2 \u03c3\u03c5\u03b3\u03ba\u03ad\u03bd\u03c4\u03c1\u03c9\u03c3\u03b7\u03c2 \u03b4\u03b9\u03bf\u03be\u03b5\u03b9\u03b4\u03af\u03bf\u03c5 \u03c4\u03bf\u03c5 \u03b1\u03b6\u03ce\u03c4\u03bf\u03c5", + "nitrogen_monoxide": "{entity_name} \u03bc\u03b5\u03c4\u03b1\u03b2\u03bf\u03bb\u03ad\u03c2 \u03c4\u03b7\u03c2 \u03c3\u03c5\u03b3\u03ba\u03ad\u03bd\u03c4\u03c1\u03c9\u03c3\u03b7\u03c2 \u03bc\u03bf\u03bd\u03bf\u03be\u03b5\u03b9\u03b4\u03af\u03bf\u03c5 \u03c4\u03bf\u03c5 \u03b1\u03b6\u03ce\u03c4\u03bf\u03c5", + "nitrous_oxide": "{entity_name} \u03bc\u03b5\u03c4\u03b1\u03b2\u03bf\u03bb\u03ad\u03c2 \u03c4\u03b7\u03c2 \u03c3\u03c5\u03b3\u03ba\u03ad\u03bd\u03c4\u03c1\u03c9\u03c3\u03b7\u03c2 \u03bf\u03be\u03b5\u03b9\u03b4\u03af\u03bf\u03c5 \u03c4\u03bf\u03c5 \u03b1\u03b6\u03ce\u03c4\u03bf\u03c5", + "ozone": "{entity_name} \u03b1\u03bb\u03bb\u03b1\u03b3\u03ad\u03c2 \u03c3\u03c4\u03b7 \u03c3\u03c5\u03b3\u03ba\u03ad\u03bd\u03c4\u03c1\u03c9\u03c3\u03b7 \u03cc\u03b6\u03bf\u03bd\u03c4\u03bf\u03c2", + "pm1": "{entity_name} \u03bc\u03b5\u03c4\u03b1\u03b2\u03bf\u03bb\u03ad\u03c2 \u03c3\u03c5\u03b3\u03ba\u03ad\u03bd\u03c4\u03c1\u03c9\u03c3\u03b7\u03c2 PM1", + "pm10": "{entity_name} \u03bc\u03b5\u03c4\u03b1\u03b2\u03bf\u03bb\u03ad\u03c2 \u03c3\u03c5\u03b3\u03ba\u03ad\u03bd\u03c4\u03c1\u03c9\u03c3\u03b7\u03c2 PM10", + "pm25": "{entity_name} \u03bc\u03b5\u03c4\u03b1\u03b2\u03bf\u03bb\u03ad\u03c2 \u03c3\u03c5\u03b3\u03ba\u03ad\u03bd\u03c4\u03c1\u03c9\u03c3\u03b7\u03c2 PM2.5", + "sulphur_dioxide": "{entity_name} \u03bc\u03b5\u03c4\u03b1\u03b2\u03bf\u03bb\u03ad\u03c2 \u03c3\u03c4\u03b7 \u03c3\u03c5\u03b3\u03ba\u03ad\u03bd\u03c4\u03c1\u03c9\u03c3\u03b7\u03c2 \u03b4\u03b9\u03bf\u03be\u03b5\u03b9\u03b4\u03af\u03bf\u03c5 \u03c4\u03bf\u03c5 \u03b8\u03b5\u03af\u03bf\u03c5" + } + }, "state": { "_": { "off": "\u039a\u03bb\u03b5\u03b9\u03c3\u03c4\u03cc", diff --git a/homeassistant/components/sensor/translations/es.json b/homeassistant/components/sensor/translations/es.json index 48c61f321a1..135aada7f44 100644 --- a/homeassistant/components/sensor/translations/es.json +++ b/homeassistant/components/sensor/translations/es.json @@ -26,6 +26,7 @@ "gas": "Cambio de gas de {entity_name}", "humidity": "Cambios de humedad de {entity_name}", "illuminance": "Cambios de luminosidad de {entity_name}", + "nitrogen_monoxide": "Cambios en la concentraci\u00f3n de mon\u00f3xido de nitr\u00f3geno de {entity_name}", "power": "Cambios de potencia de {entity_name}", "power_factor": "Cambio de factor de potencia en {entity_name}", "pressure": "Cambios de presi\u00f3n de {entity_name}", diff --git a/homeassistant/components/sensor/translations/hu.json b/homeassistant/components/sensor/translations/hu.json index 58ecdea0f24..1e33cc18355 100644 --- a/homeassistant/components/sensor/translations/hu.json +++ b/homeassistant/components/sensor/translations/hu.json @@ -23,6 +23,7 @@ "is_sulphur_dioxide": "A {entity_name} k\u00e9n-dioxid koncentr\u00e1ci\u00f3 jelenlegi szintje", "is_temperature": "{entity_name} aktu\u00e1lis h\u0151m\u00e9rs\u00e9klete", "is_value": "{entity_name} aktu\u00e1lis \u00e9rt\u00e9ke", + "is_volatile_organic_compounds": "Jelenlegi {entity_name} ill\u00e9kony szerves vegy\u00fcletek koncentr\u00e1ci\u00f3s szintje", "is_voltage": "A jelenlegi {entity_name} fesz\u00fclts\u00e9g" }, "trigger_type": { @@ -48,6 +49,7 @@ "sulphur_dioxide": "{entity_name} k\u00e9n-dioxid koncentr\u00e1ci\u00f3v\u00e1ltoz\u00e1s", "temperature": "{entity_name} h\u0151m\u00e9rs\u00e9klete v\u00e1ltozik", "value": "{entity_name} \u00e9rt\u00e9ke v\u00e1ltozik", + "volatile_organic_compounds": "{entity_name} ill\u00e9kony szerves vegy\u00fcletek koncentr\u00e1ci\u00f3j\u00e1nak v\u00e1ltoz\u00e1sai", "voltage": "{entity_name} fesz\u00fclts\u00e9ge v\u00e1ltozik" } }, diff --git a/homeassistant/components/sia/translations/es.json b/homeassistant/components/sia/translations/es.json index f32b6a86626..34ff6847421 100644 --- a/homeassistant/components/sia/translations/es.json +++ b/homeassistant/components/sia/translations/es.json @@ -18,6 +18,7 @@ "additional_account": "Cuentas adicionales", "encryption_key": "Clave de encriptaci\u00f3n", "ping_interval": "Intervalo de ping (min)", + "port": "Puerto", "protocol": "Protocolo", "zones": "N\u00famero de zonas de la cuenta" }, diff --git a/homeassistant/components/synology_dsm/translations/el.json b/homeassistant/components/synology_dsm/translations/el.json index e23b1fe0cf6..460ad657b6c 100644 --- a/homeassistant/components/synology_dsm/translations/el.json +++ b/homeassistant/components/synology_dsm/translations/el.json @@ -1,4 +1,9 @@ { + "config": { + "abort": { + "reconfigure_successful": "\u0397 \u03b5\u03c0\u03b1\u03bd\u03b1\u03b4\u03b9\u03b1\u03bc\u03cc\u03c1\u03c6\u03c9\u03c3\u03b7 \u03ae\u03c4\u03b1\u03bd \u03b5\u03c0\u03b9\u03c4\u03c5\u03c7\u03ae\u03c2" + } + }, "options": { "step": { "init": { diff --git a/homeassistant/components/synology_dsm/translations/es.json b/homeassistant/components/synology_dsm/translations/es.json index 7b86c248110..571dced9bc7 100644 --- a/homeassistant/components/synology_dsm/translations/es.json +++ b/homeassistant/components/synology_dsm/translations/es.json @@ -30,6 +30,9 @@ "title": "Synology DSM" }, "reauth": { + "data": { + "password": "Contrase\u00f1a" + }, "description": "Raz\u00f3n: {details}", "title": "Synology DSM Volver a autenticar la integraci\u00f3n" }, diff --git a/homeassistant/components/synology_dsm/translations/hu.json b/homeassistant/components/synology_dsm/translations/hu.json index 01f02e6156d..56a5ebb994f 100644 --- a/homeassistant/components/synology_dsm/translations/hu.json +++ b/homeassistant/components/synology_dsm/translations/hu.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "reauth_successful": "Az ism\u00e9telt hiteles\u00edt\u00e9s sikeres volt" + "reauth_successful": "Az ism\u00e9telt hiteles\u00edt\u00e9s sikeres volt", + "reconfigure_successful": "Az \u00fajrakonfigur\u00e1l\u00e1s sikeres" }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", diff --git a/homeassistant/components/tractive/translations/el.json b/homeassistant/components/tractive/translations/el.json new file mode 100644 index 00000000000..15ba157f55c --- /dev/null +++ b/homeassistant/components/tractive/translations/el.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "reauth_failed_existing": "\u0394\u03b5\u03bd \u03ae\u03c4\u03b1\u03bd \u03b4\u03c5\u03bd\u03b1\u03c4\u03ae \u03b7 \u03b5\u03bd\u03b7\u03bc\u03ad\u03c1\u03c9\u03c3\u03b7 \u03c4\u03b7\u03c2 \u03ba\u03b1\u03c4\u03b1\u03c7\u03ce\u03c1\u03b7\u03c3\u03b7\u03c2 \u03b4\u03b9\u03b1\u03bc\u03cc\u03c1\u03c6\u03c9\u03c3\u03b7\u03c2, \u03b1\u03c6\u03b1\u03b9\u03c1\u03ad\u03c3\u03c4\u03b5 \u03c4\u03b7\u03bd \u03b5\u03bd\u03c3\u03c9\u03bc\u03ac\u03c4\u03c9\u03c3\u03b7 \u03ba\u03b1\u03b9 \u03c1\u03c5\u03b8\u03bc\u03af\u03c3\u03c4\u03b5 \u03c4\u03b7\u03bd \u03be\u03b1\u03bd\u03ac." + }, + "step": { + "user": { + "data": { + "email": "\u03b7\u03bb\u03b5\u03ba\u03c4\u03c1\u03bf\u03bd\u03b9\u03ba\u03cc \u03c4\u03b1\u03c7\u03c5\u03b4\u03c1\u03bf\u03bc\u03b5\u03af\u03bf" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/uptimerobot/translations/el.json b/homeassistant/components/uptimerobot/translations/el.json new file mode 100644 index 00000000000..b9f2b180b4b --- /dev/null +++ b/homeassistant/components/uptimerobot/translations/el.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "reauth_confirm": { + "description": "\u03a0\u03c1\u03ad\u03c0\u03b5\u03b9 \u03bd\u03b1 \u03c0\u03b1\u03c1\u03ad\u03c7\u03b5\u03c4\u03b5 \u03ad\u03bd\u03b1 \u03ba\u03bb\u03b5\u03b9\u03b4\u03af API \u03bc\u03cc\u03bd\u03bf \u03b3\u03b9\u03b1 \u03b1\u03bd\u03ac\u03b3\u03bd\u03c9\u03c3\u03b7 \u03b1\u03c0\u03cc \u03c4\u03bf {intergration}." + }, + "user": { + "description": "\u03a0\u03c1\u03ad\u03c0\u03b5\u03b9 \u03bd\u03b1 \u03c0\u03b1\u03c1\u03ad\u03c7\u03b5\u03c4\u03b5 \u03ad\u03bd\u03b1 \u03ba\u03bb\u03b5\u03b9\u03b4\u03af API \u03bc\u03cc\u03bd\u03bf \u03b3\u03b9\u03b1 \u03b1\u03bd\u03ac\u03b3\u03bd\u03c9\u03c3\u03b7 \u03b1\u03c0\u03cc \u03c4\u03bf {intergration}." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/uptimerobot/translations/es.json b/homeassistant/components/uptimerobot/translations/es.json index d3c7f2b036d..1f04109611f 100644 --- a/homeassistant/components/uptimerobot/translations/es.json +++ b/homeassistant/components/uptimerobot/translations/es.json @@ -9,7 +9,7 @@ "error": { "cannot_connect": "No se pudo conectar", "invalid_api_key": "Clave de la API err\u00f3nea", - "reauth_failed_matching_account": "La clave de API que proporcion\u00f3 no coincide con el ID de cuenta para la configuraci\u00f3n existente.", + "reauth_failed_matching_account": "La clave de API que has proporcionado no coincide con el ID de cuenta para la configuraci\u00f3n existente.", "unknown": "Error desconocido" }, "step": { @@ -17,14 +17,14 @@ "data": { "api_key": "API Key" }, - "description": "Debe proporcionar una nueva clave API de solo lectura de Uptime Robot", + "description": "Debes proporcionar una nueva clave API de solo lectura de Uptime Robot", "title": "Volver a autenticar la integraci\u00f3n" }, "user": { "data": { "api_key": "Clave de la API" }, - "description": "Debe proporcionar una clave API de solo lectura de robot de tiempo de actividad/funcionamiento" + "description": "Debes proporcionar una clave API de solo lectura de robot de tiempo de actividad/funcionamiento" } } } diff --git a/homeassistant/components/wallbox/translations/es.json b/homeassistant/components/wallbox/translations/es.json index 71e7a748955..2b22ece4860 100644 --- a/homeassistant/components/wallbox/translations/es.json +++ b/homeassistant/components/wallbox/translations/es.json @@ -1,8 +1,12 @@ { "config": { + "error": { + "unknown": "Error inesperado" + }, "step": { "user": { "data": { + "password": "Contrase\u00f1a", "station": "N\u00famero de serie de la estaci\u00f3n" } } diff --git a/homeassistant/components/xiaomi_miio/translations/es.json b/homeassistant/components/xiaomi_miio/translations/es.json index 26f5b3937b6..5403e98f25f 100644 --- a/homeassistant/components/xiaomi_miio/translations/es.json +++ b/homeassistant/components/xiaomi_miio/translations/es.json @@ -53,8 +53,8 @@ "title": "Conectar con un Xiaomi Gateway" }, "manual": { - "description": "Necesitar\u00e1 la clave de 32 caracteres Token API, consulte https://www.home-assistant.io/integrations/xiaomi_miio#retrieving-the-access-token para obtener instrucciones. Tenga en cuenta que esta Token API es diferente de la clave utilizada por la integraci\u00f3n de Xiaomi Aqara.", - "title": "Con\u00e9ctese a un dispositivo Xiaomi Miio o una puerta de enlace Xiaomi" + "description": "Necesitar\u00e1s la clave de 32 caracteres Token API, consulta https://www.home-assistant.io/integrations/xiaomi_miio#retrieving-the-access-token para obtener instrucciones. Ten en cuenta que esta Token API es diferente de la clave utilizada por la integraci\u00f3n de Xiaomi Aqara.", + "title": "Con\u00e9ctate a un dispositivo Xiaomi Miio o una puerta de enlace Xiaomi" }, "reauth_confirm": { "description": "La integraci\u00f3n de Xiaomi Miio necesita volver a autenticar tu cuenta para actualizar los tokens o a\u00f1adir las credenciales de la nube que faltan.", @@ -78,14 +78,14 @@ }, "options": { "error": { - "cloud_credentials_incomplete": "Las credenciales de la nube est\u00e1n incompletas, por favor, rellene el nombre de usuario, la contrase\u00f1a y el pa\u00eds" + "cloud_credentials_incomplete": "Las credenciales de la nube est\u00e1n incompletas, por favor, rellena el nombre de usuario, la contrase\u00f1a y el pa\u00eds" }, "step": { "init": { "data": { - "cloud_subdevices": "Utilice la nube para conectar subdispositivos" + "cloud_subdevices": "Utiliza la nube para conectar subdispositivos" }, - "description": "Especifique los ajustes opcionales", + "description": "Especifica los ajustes opcionales", "title": "Xiaomi Miio" } } diff --git a/homeassistant/components/yale_smart_alarm/translations/es.json b/homeassistant/components/yale_smart_alarm/translations/es.json index b970badb079..367454a7d21 100644 --- a/homeassistant/components/yale_smart_alarm/translations/es.json +++ b/homeassistant/components/yale_smart_alarm/translations/es.json @@ -19,7 +19,7 @@ "data": { "area_id": "ID de \u00e1rea", "name": "Nombre", - "password": "Clave", + "password": "Contrase\u00f1a", "username": "Nombre de usuario" } } diff --git a/homeassistant/components/yamaha_musiccast/translations/es.json b/homeassistant/components/yamaha_musiccast/translations/es.json index c63baa7b576..185176e7b39 100644 --- a/homeassistant/components/yamaha_musiccast/translations/es.json +++ b/homeassistant/components/yamaha_musiccast/translations/es.json @@ -9,6 +9,9 @@ "flow_title": "MusicCast: {name}", "step": { "user": { + "data": { + "host": "Anfitri\u00f3n" + }, "description": "Configura MusicCast para integrarse con Home Assistant." } } diff --git a/homeassistant/components/zha/translations/el.json b/homeassistant/components/zha/translations/el.json new file mode 100644 index 00000000000..0df3306e84a --- /dev/null +++ b/homeassistant/components/zha/translations/el.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "not_zha_device": "\u0391\u03c5\u03c4\u03ae \u03b7 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae \u03b4\u03b5\u03bd \u03b5\u03af\u03bd\u03b1\u03b9 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae zha", + "usb_probe_failed": "\u0391\u03c0\u03bf\u03c4\u03c5\u03c7\u03af\u03b1 \u03b1\u03bd\u03af\u03c7\u03bd\u03b5\u03c5\u03c3\u03b7\u03c2 \u03c4\u03b7\u03c2 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae\u03c2 usb" + }, + "step": { + "confirm": { + "description": "\u0398\u03ad\u03bb\u03b5\u03c4\u03b5 \u03bd\u03b1 \u03c1\u03c5\u03b8\u03bc\u03af\u03c3\u03b5\u03c4\u03b5 \u03c4\u03bf {name} ;" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/zha/translations/hu.json b/homeassistant/components/zha/translations/hu.json index 9722095b548..c65eaea4325 100644 --- a/homeassistant/components/zha/translations/hu.json +++ b/homeassistant/components/zha/translations/hu.json @@ -1,13 +1,18 @@ { "config": { "abort": { - "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges." + "not_zha_device": "Ez az eszk\u00f6z nem zha eszk\u00f6z", + "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges.", + "usb_probe_failed": "Nem siker\u00fclt megvizsg\u00e1lni az USB eszk\u00f6zt" }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s" }, "flow_title": "{name}", "step": { + "confirm": { + "description": "Be szeretn\u00e9 \u00e1ll\u00edtani a {name}-t?" + }, "pick_radio": { "data": { "radio_type": "R\u00e1di\u00f3 t\u00edpusa" diff --git a/homeassistant/components/zone/translations/ru.json b/homeassistant/components/zone/translations/ru.json index 6a017e9e1c3..42de5482edb 100644 --- a/homeassistant/components/zone/translations/ru.json +++ b/homeassistant/components/zone/translations/ru.json @@ -6,7 +6,7 @@ "step": { "init": { "data": { - "icon": "\u0417\u043d\u0430\u0447\u043e\u043a", + "icon": "\u0418\u043a\u043e\u043d\u043a\u0430", "latitude": "\u0428\u0438\u0440\u043e\u0442\u0430", "longitude": "\u0414\u043e\u043b\u0433\u043e\u0442\u0430", "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435", diff --git a/homeassistant/components/zwave_js/translations/el.json b/homeassistant/components/zwave_js/translations/el.json new file mode 100644 index 00000000000..00149f5a0d0 --- /dev/null +++ b/homeassistant/components/zwave_js/translations/el.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "discovery_requires_supervisor": "\u0397 \u03b1\u03bd\u03b1\u03ba\u03ac\u03bb\u03c5\u03c8\u03b7 \u03b1\u03c0\u03b1\u03b9\u03c4\u03b5\u03af \u03c4\u03bf\u03bd \u03b5\u03c0\u03cc\u03c0\u03c4\u03b7.", + "not_zwave_device": "\u0397 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae \u03c0\u03bf\u03c5 \u03b1\u03bd\u03b1\u03ba\u03b1\u03bb\u03cd\u03c6\u03b8\u03b7\u03ba\u03b5 \u03b4\u03b5\u03bd \u03b5\u03af\u03bd\u03b1\u03b9 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae Z-Wave." + }, + "step": { + "usb_confirm": { + "description": "\u0398\u03ad\u03bb\u03b5\u03c4\u03b5 \u03bd\u03b1 \u03c1\u03c5\u03b8\u03bc\u03af\u03c3\u03b5\u03c4\u03b5 \u03c4\u03bf {name} \u03bc\u03b5 \u03c4\u03bf \u03c0\u03c1\u03cc\u03c3\u03b8\u03b5\u03c4\u03bf Z-Wave JS;" + } + } + }, + "device_automation": { + "trigger_type": { + "zwave_js.value_updated.config_parameter": "\u0391\u03bb\u03bb\u03b1\u03b3\u03ae \u03c4\u03b9\u03bc\u03ae\u03c2 \u03c3\u03c4\u03b7\u03bd \u03c0\u03b1\u03c1\u03ac\u03bc\u03b5\u03c4\u03c1\u03bf config {subtype}", + "zwave_js.value_updated.value": "\u0391\u03bb\u03bb\u03b1\u03b3\u03ae \u03c4\u03b9\u03bc\u03ae\u03c2 \u03c3\u03b5 \u03c4\u03b9\u03bc\u03ae Z-Wave JS" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/zwave_js/translations/es.json b/homeassistant/components/zwave_js/translations/es.json index 99ffee8270d..caebf4f4ecb 100644 --- a/homeassistant/components/zwave_js/translations/es.json +++ b/homeassistant/components/zwave_js/translations/es.json @@ -75,7 +75,7 @@ "addon_start_failed": "No se ha podido iniciar el complemento Z-Wave JS.", "already_configured": "El dispositivo ya est\u00e1 configurado", "cannot_connect": "No se pudo conectar", - "different_device": "El dispositivo USB conectado no es el mismo que el configurado anteriormente para esta entrada de configuraci\u00f3n. Por favor, cree una nueva entrada de configuraci\u00f3n para el nuevo dispositivo." + "different_device": "El dispositivo USB conectado no es el mismo que el configurado anteriormente para esta entrada de configuraci\u00f3n. Por favor, crea una nueva entrada de configuraci\u00f3n para el nuevo dispositivo." }, "error": { "cannot_connect": "No se pudo conectar", @@ -83,8 +83,8 @@ "unknown": "Error inesperado" }, "progress": { - "install_addon": "Por favor, espere mientras termina la instalaci\u00f3n del complemento Z-Wave JS. Esto puede tardar varios minutos.", - "start_addon": "Por favor, espere mientras se completa el inicio del complemento Z-Wave JS. Esto puede tardar algunos segundos." + "install_addon": "Por favor, espera mientras termina la instalaci\u00f3n del complemento Z-Wave JS. Esto puede tardar varios minutos.", + "start_addon": "Por favor, espera mientras se completa el inicio del complemento Z-Wave JS. Esto puede tardar algunos segundos." }, "step": { "configure_addon": { @@ -98,6 +98,9 @@ }, "install_addon": { "title": "La instalaci\u00f3n del complemento Z-Wave JS ha comenzado" + }, + "on_supervisor": { + "title": "Selecciona el m\u00e9todo de conexi\u00f3n" } } }, diff --git a/homeassistant/components/zwave_js/translations/hu.json b/homeassistant/components/zwave_js/translations/hu.json index 74a8b9db316..cf5521f3e04 100644 --- a/homeassistant/components/zwave_js/translations/hu.json +++ b/homeassistant/components/zwave_js/translations/hu.json @@ -8,7 +8,9 @@ "addon_start_failed": "Nem siker\u00fclt elind\u00edtani a Z-Wave JS b\u0151v\u00edtm\u00e9nyt.", "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", - "cannot_connect": "Sikertelen csatlakoz\u00e1s" + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "discovery_requires_supervisor": "A felfedez\u00e9shez a fel\u00fcgyel\u0151re van sz\u00fcks\u00e9g.", + "not_zwave_device": "A felfedezett eszk\u00f6z nem Z-Wave eszk\u00f6z." }, "error": { "addon_start_failed": "Nem siker\u00fclt elind\u00edtani a Z-Wave JS b\u0151v\u00edtm\u00e9nyt. Ellen\u0151rizze a konfigur\u00e1ci\u00f3t.", @@ -16,6 +18,7 @@ "invalid_ws_url": "\u00c9rv\u00e9nytelen websocket URL", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, + "flow_title": "{name}", "progress": { "install_addon": "V\u00e1rjon, am\u00edg a Z-Wave JS kieg\u00e9sz\u00edt\u0151 telep\u00edt\u00e9se befejez\u0151dik. Ez t\u00f6bb percig is eltarthat.", "start_addon": "V\u00e1rj am\u00edg a Z-Wave JS b\u0151v\u00edtm\u00e9ny elindul. Ez eltarthat n\u00e9h\u00e1ny m\u00e1sodpercig." @@ -48,6 +51,9 @@ }, "start_addon": { "title": "Indul a Z-Wave JS b\u0151v\u00edtm\u00e9ny." + }, + "usb_confirm": { + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani a(z) {name} alkalmaz\u00e1st a Z-Wave JS b\u0151v\u00edtm\u00e9nnyel?" } } }, @@ -63,7 +69,9 @@ "event.value_notification.basic": "Alapvet\u0151 CC esem\u00e9ny a(z) {subtype}", "event.value_notification.central_scene": "K\u00f6zponti jelenet m\u0171velet {subtype}", "event.value_notification.scene_activation": "Jelenetaktiv\u00e1l\u00e1s {subtype}", - "state.node_status": "A csom\u00f3pont \u00e1llapota megv\u00e1ltozott" + "state.node_status": "A csom\u00f3pont \u00e1llapota megv\u00e1ltozott", + "zwave_js.value_updated.config_parameter": "\u00c9rt\u00e9kv\u00e1ltoz\u00e1s a {subtype}", + "zwave_js.value_updated.value": "\u00c9rt\u00e9kv\u00e1ltoz\u00e1s egy Z-Wave JS \u00e9rt\u00e9ken" } }, "options": { From 0749e045bbb23e78876c76f392d8fd1c44ae5e50 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sat, 4 Sep 2021 02:17:24 +0200 Subject: [PATCH 206/843] Add reauth to Renault config flow (#55547) * Add reauth flow to async_setup_entry * Add reauth flow to config_flow * Add reauth tests * Split reauth/reauth_confirm * unindent code * Add entry_id and unique_id to reauth flow testing * Use description_placeholders for username * fix typo --- homeassistant/components/renault/__init__.py | 4 +- .../components/renault/config_flow.py | 51 +++++++++++++++++- homeassistant/components/renault/strings.json | 10 +++- tests/components/renault/test_config_flow.py | 52 +++++++++++++++++++ 4 files changed, 113 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/renault/__init__.py b/homeassistant/components/renault/__init__.py index 781f81ab745..17e4d3dd82b 100644 --- a/homeassistant/components/renault/__init__.py +++ b/homeassistant/components/renault/__init__.py @@ -4,7 +4,7 @@ import aiohttp from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from .const import CONF_LOCALE, DOMAIN, PLATFORMS from .renault_hub import RenaultHub @@ -22,7 +22,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b raise ConfigEntryNotReady() from exc if not login_success: - return False + raise ConfigEntryAuthFailed() hass.data.setdefault(DOMAIN, {}) await renault_hub.async_initialise(config_entry) diff --git a/homeassistant/components/renault/config_flow.py b/homeassistant/components/renault/config_flow.py index 09a69f1f95f..47832cdbe93 100644 --- a/homeassistant/components/renault/config_flow.py +++ b/homeassistant/components/renault/config_flow.py @@ -1,7 +1,7 @@ """Config flow to configure Renault component.""" from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any from renault_api.const import AVAILABLE_LOCALES import voluptuous as vol @@ -21,6 +21,7 @@ class RenaultFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize the Renault config flow.""" + self._original_data: dict[str, Any] | None = None self.renault_config: dict[str, Any] = {} self.renault_hub: RenaultHub | None = None @@ -90,3 +91,51 @@ class RenaultFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): {vol.Required(CONF_KAMEREON_ACCOUNT_ID): vol.In(accounts)} ), ) + + async def async_step_reauth(self, user_input: dict[str, Any]) -> FlowResult: + """Perform reauth upon an API authentication error.""" + self._original_data = user_input.copy() + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Dialog that informs the user that reauth is required.""" + if not user_input: + return self._show_reauth_confirm_form() + + if TYPE_CHECKING: + assert self._original_data + + # Check credentials + self.renault_hub = RenaultHub(self.hass, self._original_data[CONF_LOCALE]) + if not await self.renault_hub.attempt_login( + self._original_data[CONF_USERNAME], user_input[CONF_PASSWORD] + ): + return self._show_reauth_confirm_form({"base": "invalid_credentials"}) + + # Update existing entry + data = {**self._original_data, CONF_PASSWORD: user_input[CONF_PASSWORD]} + existing_entry = await self.async_set_unique_id( + self._original_data[CONF_KAMEREON_ACCOUNT_ID] + ) + if TYPE_CHECKING: + assert existing_entry + self.hass.config_entries.async_update_entry(existing_entry, data=data) + await self.hass.config_entries.async_reload(existing_entry.entry_id) + return self.async_abort(reason="reauth_successful") + + def _show_reauth_confirm_form( + self, errors: dict[str, Any] | None = None + ) -> FlowResult: + """Show the API keys form.""" + if TYPE_CHECKING: + assert self._original_data + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema({vol.Required(CONF_PASSWORD): str}), + errors=errors or {}, + description_placeholders={ + CONF_USERNAME: self._original_data[CONF_USERNAME] + }, + ) diff --git a/homeassistant/components/renault/strings.json b/homeassistant/components/renault/strings.json index 942c8b4a06c..30a356b7c42 100644 --- a/homeassistant/components/renault/strings.json +++ b/homeassistant/components/renault/strings.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "kamereon_no_account": "Unable to find Kamereon account." + "kamereon_no_account": "Unable to find Kamereon account", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "error": { "invalid_credentials": "[%key:common::config_flow::error::invalid_auth%]" @@ -14,6 +15,13 @@ }, "title": "Select Kamereon account id" }, + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "description": "Please update your password for {username}", + "title": "[%key:common::config_flow::title::reauth%]" + }, "user": { "data": { "locale": "Locale", diff --git a/tests/components/renault/test_config_flow.py b/tests/components/renault/test_config_flow.py index 684e17a0101..ebf458541f0 100644 --- a/tests/components/renault/test_config_flow.py +++ b/tests/components/renault/test_config_flow.py @@ -16,6 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client from . import get_mock_config_entry +from .const import MOCK_CONFIG from tests.common import load_fixture @@ -207,3 +208,54 @@ async def test_config_flow_duplicate(hass: HomeAssistant): await hass.async_block_till_done() assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_reauth(hass): + """Test the start of the config flow.""" + with patch( + "homeassistant.components.renault.async_setup_entry", + return_value=True, + ): + original_entry = get_mock_config_entry() + original_entry.add_to_hass(hass) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": original_entry.entry_id, + "unique_id": original_entry.unique_id, + }, + data=MOCK_CONFIG, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["description_placeholders"] == {CONF_USERNAME: "email@test.com"} + assert result["errors"] == {} + + # Failed credentials + with patch( + "renault_api.renault_session.RenaultSession.login", + side_effect=InvalidCredentialsException( + 403042, "invalid loginID or password" + ), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: "any"}, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["description_placeholders"] == {CONF_USERNAME: "email@test.com"} + assert result2["errors"] == {"base": "invalid_credentials"} + + # Valid credentials + with patch("renault_api.renault_session.RenaultSession.login"): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: "any"}, + ) + + assert result3["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result3["reason"] == "reauth_successful" From b10fc89a6b212474f7c3fac807f436b55fe27d10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Sat, 4 Sep 2021 03:25:51 +0300 Subject: [PATCH 207/843] Automation trigger info type hint improvements (#55402) * Make automation trigger info a TypedDict * zwave_js trigger type hint fixes * Remove redundant automation trigger info field presence checks * Use async_initialize_triggers in mqtt and tasmota device_trigger tests --- .../alarm_control_panel/device_trigger.py | 7 +- .../components/arcam_fmj/device_trigger.py | 9 +- .../components/automation/__init__.py | 19 ++- .../components/climate/device_trigger.py | 7 +- .../components/cover/device_trigger.py | 7 +- .../device_automation/toggle_entity.py | 7 +- .../device_tracker/device_trigger.py | 7 +- .../components/fan/device_trigger.py | 7 +- .../components/geo_location/trigger.py | 2 +- .../homeassistant/triggers/event.py | 13 +- .../homeassistant/triggers/homeassistant.py | 2 +- .../homeassistant/triggers/numeric_state.py | 6 +- .../homeassistant/triggers/state.py | 6 +- .../components/homeassistant/triggers/time.py | 2 +- .../homeassistant/triggers/time_pattern.py | 2 +- .../homekit_controller/device_trigger.py | 13 +- .../components/humidifier/device_trigger.py | 7 +- .../components/kodi/device_trigger.py | 11 +- .../components/light/device_trigger.py | 7 +- homeassistant/components/litejet/trigger.py | 2 +- .../components/lock/device_trigger.py | 7 +- .../lutron_caseta/device_trigger.py | 7 +- .../components/media_player/device_trigger.py | 7 +- .../components/mqtt/device_trigger.py | 9 +- homeassistant/components/mqtt/trigger.py | 2 +- .../components/nest/device_trigger.py | 7 +- .../components/netatmo/device_trigger.py | 7 +- .../components/philips_js/device_trigger.py | 9 +- .../components/remote/device_trigger.py | 7 +- .../components/select/device_trigger.py | 7 +- .../components/shelly/device_trigger.py | 7 +- homeassistant/components/sun/trigger.py | 2 +- .../components/switch/device_trigger.py | 7 +- homeassistant/components/tag/trigger.py | 9 +- .../components/tasmota/device_trigger.py | 11 +- homeassistant/components/template/trigger.py | 2 +- .../components/vacuum/device_trigger.py | 7 +- homeassistant/components/webhook/trigger.py | 2 +- homeassistant/components/zone/trigger.py | 2 +- .../components/zwave_js/device_trigger.py | 7 +- homeassistant/components/zwave_js/trigger.py | 16 +- .../zwave_js/triggers/value_updated.py | 13 +- homeassistant/helpers/trigger.py | 19 +-- .../integration/device_trigger.py | 7 +- tests/components/mqtt/test_device_trigger.py | 76 +++++---- .../components/tasmota/test_device_trigger.py | 155 ++++++++++-------- 46 files changed, 344 insertions(+), 210 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/device_trigger.py b/homeassistant/components/alarm_control_panel/device_trigger.py index 695ec0ebb4a..92c73b07bbd 100644 --- a/homeassistant/components/alarm_control_panel/device_trigger.py +++ b/homeassistant/components/alarm_control_panel/device_trigger.py @@ -11,7 +11,10 @@ from homeassistant.components.alarm_control_panel.const import ( SUPPORT_ALARM_ARM_NIGHT, SUPPORT_ALARM_ARM_VACATION, ) -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.components.homeassistant.triggers import state as state_trigger from homeassistant.const import ( @@ -129,7 +132,7 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" if config[CONF_TYPE] == "triggered": diff --git a/homeassistant/components/arcam_fmj/device_trigger.py b/homeassistant/components/arcam_fmj/device_trigger.py index 7bf7a06d851..ed9308a89c6 100644 --- a/homeassistant/components/arcam_fmj/device_trigger.py +++ b/homeassistant/components/arcam_fmj/device_trigger.py @@ -5,7 +5,10 @@ from typing import Any import voluptuous as vol -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.const import ( ATTR_ENTITY_ID, @@ -57,10 +60,10 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" - trigger_data = automation_info.get("trigger_data", {}) if automation_info else {} + trigger_data = automation_info["trigger_data"] job = HassJob(action) if config[CONF_TYPE] == "turn_on": diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 5e1b53c535e..24090b79fa8 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging -from typing import Any, Awaitable, Callable, Dict, cast +from typing import Any, Awaitable, Callable, Dict, TypedDict, cast import voluptuous as vol from voluptuous.humanize import humanize_error @@ -106,6 +106,23 @@ _LOGGER = logging.getLogger(__name__) AutomationActionType = Callable[[HomeAssistant, TemplateVarsType], Awaitable[None]] +class AutomationTriggerData(TypedDict): + """Automation trigger data.""" + + id: str + idx: str + + +class AutomationTriggerInfo(TypedDict): + """Information about automation trigger.""" + + domain: str + name: str + home_assistant_start: bool + variables: TemplateVarsType + trigger_data: AutomationTriggerData + + @bind_hass def is_on(hass, entity_id): """ diff --git a/homeassistant/components/climate/device_trigger.py b/homeassistant/components/climate/device_trigger.py index 4ff2e8fe477..ce4e08f9fd2 100644 --- a/homeassistant/components/climate/device_trigger.py +++ b/homeassistant/components/climate/device_trigger.py @@ -5,7 +5,10 @@ from typing import Any import voluptuous as vol -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.components.homeassistant.triggers import ( numeric_state as numeric_state_trigger, @@ -112,7 +115,7 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" trigger_type = config[CONF_TYPE] diff --git a/homeassistant/components/cover/device_trigger.py b/homeassistant/components/cover/device_trigger.py index e7048032cba..f4a2f4443d1 100644 --- a/homeassistant/components/cover/device_trigger.py +++ b/homeassistant/components/cover/device_trigger.py @@ -5,7 +5,10 @@ from typing import Any import voluptuous as vol -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.components.homeassistant.triggers import ( numeric_state as numeric_state_trigger, @@ -147,7 +150,7 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" if config[CONF_TYPE] in STATE_TRIGGER_TYPES: diff --git a/homeassistant/components/device_automation/toggle_entity.py b/homeassistant/components/device_automation/toggle_entity.py index 2e9576ee74a..5d08f8d9d31 100644 --- a/homeassistant/components/device_automation/toggle_entity.py +++ b/homeassistant/components/device_automation/toggle_entity.py @@ -5,7 +5,10 @@ from typing import Any import voluptuous as vol -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.components.device_automation.const import ( CONF_IS_OFF, CONF_IS_ON, @@ -146,7 +149,7 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Listen for state changes based on configuration.""" trigger_type = config[CONF_TYPE] diff --git a/homeassistant/components/device_tracker/device_trigger.py b/homeassistant/components/device_tracker/device_trigger.py index 0b8fd6da7f4..49a52fa887e 100644 --- a/homeassistant/components/device_tracker/device_trigger.py +++ b/homeassistant/components/device_tracker/device_trigger.py @@ -5,7 +5,10 @@ from typing import Any, Final import voluptuous as vol -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.components.zone import DOMAIN as DOMAIN_ZONE, trigger as zone from homeassistant.const import ( @@ -72,7 +75,7 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" if config[CONF_TYPE] == "enters": diff --git a/homeassistant/components/fan/device_trigger.py b/homeassistant/components/fan/device_trigger.py index 38cfb33b42d..503aaaac52a 100644 --- a/homeassistant/components/fan/device_trigger.py +++ b/homeassistant/components/fan/device_trigger.py @@ -5,7 +5,10 @@ from typing import Any import voluptuous as vol -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.components.device_automation import toggle_entity from homeassistant.const import CONF_DOMAIN from homeassistant.core import CALLBACK_TYPE, HomeAssistant @@ -36,7 +39,7 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Listen for state changes based on configuration.""" return await toggle_entity.async_attach_trigger( diff --git a/homeassistant/components/geo_location/trigger.py b/homeassistant/components/geo_location/trigger.py index c5e35ece593..b77aecee14c 100644 --- a/homeassistant/components/geo_location/trigger.py +++ b/homeassistant/components/geo_location/trigger.py @@ -37,7 +37,7 @@ def source_match(state, source): async def async_attach_trigger(hass, config, action, automation_info): """Listen for state changes based on configuration.""" - trigger_data = automation_info.get("trigger_data", {}) if automation_info else {} + trigger_data = automation_info["trigger_data"] source = config.get(CONF_SOURCE).lower() zone_entity_id = config.get(CONF_ZONE) trigger_event = config.get(CONF_EVENT) diff --git a/homeassistant/components/homeassistant/triggers/event.py b/homeassistant/components/homeassistant/triggers/event.py index 47dc5317bbd..b0d817478dc 100644 --- a/homeassistant/components/homeassistant/triggers/event.py +++ b/homeassistant/components/homeassistant/triggers/event.py @@ -5,7 +5,10 @@ from typing import Any import voluptuous as vol -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.const import CONF_EVENT_DATA, CONF_PLATFORM from homeassistant.core import CALLBACK_TYPE, Event, HassJob, HomeAssistant, callback from homeassistant.helpers import config_validation as cv, template @@ -35,15 +38,13 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict[str, Any], + automation_info: AutomationTriggerInfo, *, platform_type: str = "event", ) -> CALLBACK_TYPE: """Listen for events based on configuration.""" - trigger_data = automation_info.get("trigger_data", {}) if automation_info else {} - variables = None - if automation_info: - variables = automation_info.get("variables") + trigger_data = automation_info["trigger_data"] + variables = automation_info["variables"] template.attach(hass, config[CONF_EVENT_TYPE]) event_types = template.render_complex( diff --git a/homeassistant/components/homeassistant/triggers/homeassistant.py b/homeassistant/components/homeassistant/triggers/homeassistant.py index ea1a985139f..6f2ec75e313 100644 --- a/homeassistant/components/homeassistant/triggers/homeassistant.py +++ b/homeassistant/components/homeassistant/triggers/homeassistant.py @@ -20,7 +20,7 @@ TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend( async def async_attach_trigger(hass, config, action, automation_info): """Listen for events based on configuration.""" - trigger_data = automation_info.get("trigger_data", {}) if automation_info else {} + trigger_data = automation_info["trigger_data"] event = config.get(CONF_EVENT) job = HassJob(action) diff --git a/homeassistant/components/homeassistant/triggers/numeric_state.py b/homeassistant/components/homeassistant/triggers/numeric_state.py index f315addb272..3f280f581b3 100644 --- a/homeassistant/components/homeassistant/triggers/numeric_state.py +++ b/homeassistant/components/homeassistant/triggers/numeric_state.py @@ -78,10 +78,8 @@ async def async_attach_trigger( attribute = config.get(CONF_ATTRIBUTE) job = HassJob(action) - trigger_data = automation_info.get("trigger_data", {}) if automation_info else {} - _variables: dict = {} - if automation_info: - _variables = automation_info.get("variables") or {} + trigger_data = automation_info["trigger_data"] + _variables = automation_info["variables"] or {} if value_template is not None: value_template.hass = hass diff --git a/homeassistant/components/homeassistant/triggers/state.py b/homeassistant/components/homeassistant/triggers/state.py index 12c42a95978..f60071d633c 100644 --- a/homeassistant/components/homeassistant/triggers/state.py +++ b/homeassistant/components/homeassistant/triggers/state.py @@ -87,10 +87,8 @@ async def async_attach_trigger( attribute = config.get(CONF_ATTRIBUTE) job = HassJob(action) - trigger_data = automation_info.get("trigger_data", {}) if automation_info else {} - _variables: dict = {} - if automation_info: - _variables = automation_info.get("variables") or {} + trigger_data = automation_info["trigger_data"] + _variables = automation_info["variables"] or {} @callback def state_automation_listener(event: Event): diff --git a/homeassistant/components/homeassistant/triggers/time.py b/homeassistant/components/homeassistant/triggers/time.py index f661ae21a5b..6ca1998a5c3 100644 --- a/homeassistant/components/homeassistant/triggers/time.py +++ b/homeassistant/components/homeassistant/triggers/time.py @@ -39,7 +39,7 @@ TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend( async def async_attach_trigger(hass, config, action, automation_info): """Listen for state changes based on configuration.""" - trigger_data = automation_info.get("trigger_data", {}) if automation_info else {} + trigger_data = automation_info["trigger_data"] entities = {} removes = [] job = HassJob(action) diff --git a/homeassistant/components/homeassistant/triggers/time_pattern.py b/homeassistant/components/homeassistant/triggers/time_pattern.py index 0380e01c239..000d73b6cd1 100644 --- a/homeassistant/components/homeassistant/triggers/time_pattern.py +++ b/homeassistant/components/homeassistant/triggers/time_pattern.py @@ -57,7 +57,7 @@ TRIGGER_SCHEMA = vol.All( async def async_attach_trigger(hass, config, action, automation_info): """Listen for state changes based on configuration.""" - trigger_data = automation_info.get("trigger_data", {}) if automation_info else {} + trigger_data = automation_info["trigger_data"] hours = config.get(CONF_HOURS) minutes = config.get(CONF_MINUTES) seconds = config.get(CONF_SECONDS) diff --git a/homeassistant/components/homekit_controller/device_trigger.py b/homeassistant/components/homekit_controller/device_trigger.py index 1972aadfeca..5bb7d634626 100644 --- a/homeassistant/components/homekit_controller/device_trigger.py +++ b/homeassistant/components/homekit_controller/device_trigger.py @@ -9,7 +9,10 @@ from aiohomekit.model.services import ServicesTypes from aiohomekit.utils import clamp_enum_to_char import voluptuous as vol -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback @@ -75,12 +78,10 @@ class TriggerSource: self, config: TRIGGER_SCHEMA, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" - trigger_data = ( - automation_info.get("trigger_data", {}) if automation_info else {} - ) + trigger_data = automation_info["trigger_data"] def event_handler(char): if config[CONF_SUBTYPE] != HK_TO_HA_INPUT_EVENT_VALUES[char["value"]]: @@ -260,7 +261,7 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" device_id = config[CONF_DEVICE_ID] diff --git a/homeassistant/components/humidifier/device_trigger.py b/homeassistant/components/humidifier/device_trigger.py index 5c761e798ea..a049af9afec 100644 --- a/homeassistant/components/humidifier/device_trigger.py +++ b/homeassistant/components/humidifier/device_trigger.py @@ -5,7 +5,10 @@ from typing import Any import voluptuous as vol -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.components.device_automation import ( DEVICE_TRIGGER_BASE_SCHEMA, toggle_entity, @@ -80,7 +83,7 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" trigger_type = config[CONF_TYPE] diff --git a/homeassistant/components/kodi/device_trigger.py b/homeassistant/components/kodi/device_trigger.py index ac474413b54..68735bfa386 100644 --- a/homeassistant/components/kodi/device_trigger.py +++ b/homeassistant/components/kodi/device_trigger.py @@ -5,7 +5,10 @@ from typing import Any import voluptuous as vol -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.const import ( ATTR_ENTITY_ID, @@ -69,9 +72,9 @@ def _attach_trigger( config: ConfigType, action: AutomationActionType, event_type, - automation_info: dict, + automation_info: AutomationTriggerInfo, ): - trigger_data = automation_info.get("trigger_data", {}) if automation_info else {} + trigger_data = automation_info["trigger_data"] job = HassJob(action) @callback @@ -90,7 +93,7 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" if config[CONF_TYPE] == "turn_on": diff --git a/homeassistant/components/light/device_trigger.py b/homeassistant/components/light/device_trigger.py index 6cb6e8a34c1..6714ee4cf9c 100644 --- a/homeassistant/components/light/device_trigger.py +++ b/homeassistant/components/light/device_trigger.py @@ -5,7 +5,10 @@ from typing import Any import voluptuous as vol -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.components.device_automation import toggle_entity from homeassistant.const import CONF_DOMAIN from homeassistant.core import CALLBACK_TYPE, HomeAssistant @@ -22,7 +25,7 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Listen for state changes based on configuration.""" return await toggle_entity.async_attach_trigger( diff --git a/homeassistant/components/litejet/trigger.py b/homeassistant/components/litejet/trigger.py index 3a9930c5e70..5ff841a55c3 100644 --- a/homeassistant/components/litejet/trigger.py +++ b/homeassistant/components/litejet/trigger.py @@ -31,7 +31,7 @@ TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend( async def async_attach_trigger(hass, config, action, automation_info): """Listen for events based on configuration.""" - trigger_data = automation_info.get("trigger_data", {}) if automation_info else {} + trigger_data = automation_info["trigger_data"] number = config.get(CONF_NUMBER) held_more_than = config.get(CONF_HELD_MORE_THAN) held_less_than = config.get(CONF_HELD_LESS_THAN) diff --git a/homeassistant/components/lock/device_trigger.py b/homeassistant/components/lock/device_trigger.py index 393fd968437..cbdab7abb3d 100644 --- a/homeassistant/components/lock/device_trigger.py +++ b/homeassistant/components/lock/device_trigger.py @@ -5,7 +5,10 @@ from typing import Any import voluptuous as vol -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.components.homeassistant.triggers import state as state_trigger from homeassistant.const import ( @@ -80,7 +83,7 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" if config[CONF_TYPE] == "jammed": diff --git a/homeassistant/components/lutron_caseta/device_trigger.py b/homeassistant/components/lutron_caseta/device_trigger.py index 8a7f321e158..4e378942bd8 100644 --- a/homeassistant/components/lutron_caseta/device_trigger.py +++ b/homeassistant/components/lutron_caseta/device_trigger.py @@ -5,7 +5,10 @@ from typing import Any import voluptuous as vol -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.components.device_automation.exceptions import ( InvalidDeviceAutomationConfig, @@ -258,7 +261,7 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" device = get_button_device_by_dr_id(hass, config[CONF_DEVICE_ID]) diff --git a/homeassistant/components/media_player/device_trigger.py b/homeassistant/components/media_player/device_trigger.py index 532519616d2..9aa75ab935c 100644 --- a/homeassistant/components/media_player/device_trigger.py +++ b/homeassistant/components/media_player/device_trigger.py @@ -5,7 +5,10 @@ from typing import Any import voluptuous as vol -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.components.homeassistant.triggers import state as state_trigger from homeassistant.const import ( @@ -80,7 +83,7 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" if config[CONF_TYPE] == "turned_on": diff --git a/homeassistant/components/mqtt/device_trigger.py b/homeassistant/components/mqtt/device_trigger.py index b4b586e14d2..6348156ef50 100644 --- a/homeassistant/components/mqtt/device_trigger.py +++ b/homeassistant/components/mqtt/device_trigger.py @@ -7,7 +7,10 @@ from typing import Any, Callable import attr import voluptuous as vol -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.const import ( CONF_DEVICE, @@ -86,7 +89,7 @@ class TriggerInstance: """Attached trigger settings.""" action: AutomationActionType = attr.ib() - automation_info: dict = attr.ib() + automation_info: AutomationTriggerInfo = attr.ib() trigger: Trigger = attr.ib() remove: CALLBACK_TYPE | None = attr.ib(default=None) @@ -316,7 +319,7 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" if DEVICE_TRIGGERS not in hass.data: diff --git a/homeassistant/components/mqtt/trigger.py b/homeassistant/components/mqtt/trigger.py index 3ee23356c3f..6be19a1b43a 100644 --- a/homeassistant/components/mqtt/trigger.py +++ b/homeassistant/components/mqtt/trigger.py @@ -37,7 +37,7 @@ _LOGGER = logging.getLogger(__name__) async def async_attach_trigger(hass, config, action, automation_info): """Listen for state changes based on configuration.""" - trigger_data = automation_info.get("trigger_data", {}) if automation_info else {} + trigger_data = automation_info["trigger_data"] topic = config[CONF_TOPIC] wanted_payload = config.get(CONF_PAYLOAD) value_template = config.get(CONF_VALUE_TEMPLATE) diff --git a/homeassistant/components/nest/device_trigger.py b/homeassistant/components/nest/device_trigger.py index 619f6a3fe56..bcd5b6b96b3 100644 --- a/homeassistant/components/nest/device_trigger.py +++ b/homeassistant/components/nest/device_trigger.py @@ -5,7 +5,10 @@ from typing import Any import voluptuous as vol -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.components.device_automation.exceptions import ( InvalidDeviceAutomationConfig, @@ -87,7 +90,7 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" event_config = event_trigger.TRIGGER_SCHEMA( diff --git a/homeassistant/components/netatmo/device_trigger.py b/homeassistant/components/netatmo/device_trigger.py index 777b905f5d7..a228e7632a5 100644 --- a/homeassistant/components/netatmo/device_trigger.py +++ b/homeassistant/components/netatmo/device_trigger.py @@ -5,7 +5,10 @@ from typing import Any import voluptuous as vol -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.components.device_automation.exceptions import ( InvalidDeviceAutomationConfig, @@ -128,7 +131,7 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" device_registry = await hass.helpers.device_registry.async_get_registry() diff --git a/homeassistant/components/philips_js/device_trigger.py b/homeassistant/components/philips_js/device_trigger.py index 85b1a012860..09784dae63f 100644 --- a/homeassistant/components/philips_js/device_trigger.py +++ b/homeassistant/components/philips_js/device_trigger.py @@ -5,7 +5,10 @@ from typing import Any import voluptuous as vol -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE from homeassistant.core import CALLBACK_TYPE, HomeAssistant @@ -46,10 +49,10 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE | None: """Attach a trigger.""" - trigger_data = automation_info.get("trigger_data", {}) if automation_info else {} + trigger_data = automation_info["trigger_data"] registry: DeviceRegistry = await async_get_registry(hass) if config[CONF_TYPE] == TRIGGER_TYPE_TURN_ON: variables = { diff --git a/homeassistant/components/remote/device_trigger.py b/homeassistant/components/remote/device_trigger.py index 40182cc0114..cf3a7427745 100644 --- a/homeassistant/components/remote/device_trigger.py +++ b/homeassistant/components/remote/device_trigger.py @@ -5,7 +5,10 @@ from typing import Any import voluptuous as vol -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.components.device_automation import toggle_entity from homeassistant.const import CONF_DOMAIN from homeassistant.core import CALLBACK_TYPE, HomeAssistant @@ -22,7 +25,7 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Listen for state changes based on configuration.""" return await toggle_entity.async_attach_trigger( diff --git a/homeassistant/components/select/device_trigger.py b/homeassistant/components/select/device_trigger.py index ded3ff4bc24..6dabacf34e5 100644 --- a/homeassistant/components/select/device_trigger.py +++ b/homeassistant/components/select/device_trigger.py @@ -5,7 +5,10 @@ from typing import Any import voluptuous as vol -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.components.homeassistant.triggers.state import ( CONF_FOR, @@ -64,7 +67,7 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" state_config = { diff --git a/homeassistant/components/shelly/device_trigger.py b/homeassistant/components/shelly/device_trigger.py index c44dd279230..eae2953e5b8 100644 --- a/homeassistant/components/shelly/device_trigger.py +++ b/homeassistant/components/shelly/device_trigger.py @@ -5,7 +5,10 @@ from typing import Any, Final import voluptuous as vol -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.components.device_automation.exceptions import ( InvalidDeviceAutomationConfig, @@ -111,7 +114,7 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" event_config = { diff --git a/homeassistant/components/sun/trigger.py b/homeassistant/components/sun/trigger.py index b612934bfad..266df1f6a3b 100644 --- a/homeassistant/components/sun/trigger.py +++ b/homeassistant/components/sun/trigger.py @@ -26,7 +26,7 @@ TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend( async def async_attach_trigger(hass, config, action, automation_info): """Listen for events based on configuration.""" - trigger_data = automation_info.get("trigger_data", {}) if automation_info else {} + trigger_data = automation_info["trigger_data"] event = config.get(CONF_EVENT) offset = config.get(CONF_OFFSET) description = event diff --git a/homeassistant/components/switch/device_trigger.py b/homeassistant/components/switch/device_trigger.py index b796a31134f..6e4cf2f810e 100644 --- a/homeassistant/components/switch/device_trigger.py +++ b/homeassistant/components/switch/device_trigger.py @@ -5,7 +5,10 @@ from typing import Any import voluptuous as vol -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.components.device_automation import toggle_entity from homeassistant.const import CONF_DOMAIN from homeassistant.core import CALLBACK_TYPE, HomeAssistant @@ -22,7 +25,7 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Listen for state changes based on configuration.""" return await toggle_entity.async_attach_trigger( diff --git a/homeassistant/components/tag/trigger.py b/homeassistant/components/tag/trigger.py index ba90f0a9396..b844ee260a2 100644 --- a/homeassistant/components/tag/trigger.py +++ b/homeassistant/components/tag/trigger.py @@ -1,7 +1,10 @@ """Support for tag triggers.""" import voluptuous as vol -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.const import CONF_PLATFORM from homeassistant.core import CALLBACK_TYPE, Event, HassJob, HomeAssistant from homeassistant.helpers import config_validation as cv @@ -22,10 +25,10 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Listen for tag_scanned events based on configuration.""" - trigger_data = automation_info.get("trigger_data", {}) if automation_info else {} + trigger_data = automation_info["trigger_data"] tag_ids = set(config[TAG_ID]) device_ids = set(config[DEVICE_ID]) if DEVICE_ID in config else None diff --git a/homeassistant/components/tasmota/device_trigger.py b/homeassistant/components/tasmota/device_trigger.py index b3be1fbd2cc..27bcc4228ea 100644 --- a/homeassistant/components/tasmota/device_trigger.py +++ b/homeassistant/components/tasmota/device_trigger.py @@ -9,7 +9,10 @@ from hatasmota.models import DiscoveryHashType from hatasmota.trigger import TasmotaTrigger, TasmotaTriggerConfig import voluptuous as vol -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.components.homeassistant.triggers import event as event_trigger from homeassistant.config_entries import ConfigEntry @@ -49,7 +52,7 @@ class TriggerInstance: """Attached trigger settings.""" action: AutomationActionType = attr.ib() - automation_info: dict = attr.ib() + automation_info: AutomationTriggerInfo = attr.ib() trigger: Trigger = attr.ib() remove: CALLBACK_TYPE | None = attr.ib(default=None) @@ -93,7 +96,7 @@ class Trigger: trigger_instances: list[TriggerInstance] = attr.ib(factory=list) async def add_trigger( - self, action: AutomationActionType, automation_info: dict + self, action: AutomationActionType, automation_info: AutomationTriggerInfo ) -> Callable[[], None]: """Add Tasmota trigger.""" instance = TriggerInstance(action, automation_info, self) @@ -289,7 +292,7 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: Callable, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Attach a device trigger.""" if DEVICE_TRIGGERS not in hass.data: diff --git a/homeassistant/components/template/trigger.py b/homeassistant/components/template/trigger.py index 6db25da76ab..d2e50de53fd 100644 --- a/homeassistant/components/template/trigger.py +++ b/homeassistant/components/template/trigger.py @@ -31,7 +31,7 @@ async def async_attach_trigger( hass, config, action, automation_info, *, platform_type="template" ): """Listen for state changes based on configuration.""" - trigger_data = automation_info.get("trigger_data", {}) if automation_info else {} + trigger_data = automation_info["trigger_data"] value_template = config.get(CONF_VALUE_TEMPLATE) value_template.hass = hass time_delta = config.get(CONF_FOR) diff --git a/homeassistant/components/vacuum/device_trigger.py b/homeassistant/components/vacuum/device_trigger.py index 9189568d2f4..f4fdbcf972e 100644 --- a/homeassistant/components/vacuum/device_trigger.py +++ b/homeassistant/components/vacuum/device_trigger.py @@ -5,7 +5,10 @@ from typing import Any import voluptuous as vol -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.components.homeassistant.triggers import state as state_trigger from homeassistant.const import ( @@ -74,7 +77,7 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" if config[CONF_TYPE] == "cleaning": diff --git a/homeassistant/components/webhook/trigger.py b/homeassistant/components/webhook/trigger.py index 6bb8a61eeec..4e17c5e9e34 100644 --- a/homeassistant/components/webhook/trigger.py +++ b/homeassistant/components/webhook/trigger.py @@ -37,7 +37,7 @@ async def _handle_webhook(job, trigger_data, hass, webhook_id, request): async def async_attach_trigger(hass, config, action, automation_info): """Trigger based on incoming webhooks.""" - trigger_data = automation_info.get("trigger_data", {}) if automation_info else {} + trigger_data = automation_info["trigger_data"] webhook_id = config.get(CONF_WEBHOOK_ID) job = HassJob(action) hass.components.webhook.async_register( diff --git a/homeassistant/components/zone/trigger.py b/homeassistant/components/zone/trigger.py index eb084fe1874..ef054b39714 100644 --- a/homeassistant/components/zone/trigger.py +++ b/homeassistant/components/zone/trigger.py @@ -37,7 +37,7 @@ async def async_attach_trigger( hass, config, action, automation_info, *, platform_type: str = "zone" ) -> CALLBACK_TYPE: """Listen for state changes based on configuration.""" - trigger_data = automation_info.get("trigger_data", {}) if automation_info else {} + trigger_data = automation_info["trigger_data"] entity_id = config.get(CONF_ENTITY_ID) zone_entity_id = config.get(CONF_ZONE) event = config.get(CONF_EVENT) diff --git a/homeassistant/components/zwave_js/device_trigger.py b/homeassistant/components/zwave_js/device_trigger.py index 7ed13ce2b98..d3deba0979a 100644 --- a/homeassistant/components/zwave_js/device_trigger.py +++ b/homeassistant/components/zwave_js/device_trigger.py @@ -6,7 +6,10 @@ from typing import Any import voluptuous as vol from zwave_js_server.const import CommandClass -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.components.device_automation.exceptions import ( InvalidDeviceAutomationConfig, @@ -358,7 +361,7 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" trigger_type = config[CONF_TYPE] diff --git a/homeassistant/components/zwave_js/trigger.py b/homeassistant/components/zwave_js/trigger.py index 69e770e3817..ca9bd7d24a2 100644 --- a/homeassistant/components/zwave_js/trigger.py +++ b/homeassistant/components/zwave_js/trigger.py @@ -2,10 +2,14 @@ from __future__ import annotations from types import ModuleType -from typing import Any, Callable, cast +from typing import cast +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.const import CONF_PLATFORM -from homeassistant.core import HomeAssistant +from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.helpers.typing import ConfigType from .triggers import value_updated @@ -40,14 +44,14 @@ async def async_validate_trigger_config( async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, - action: Callable, - automation_info: dict[str, Any], -) -> Callable: + action: AutomationActionType, + automation_info: AutomationTriggerInfo, +) -> CALLBACK_TYPE: """Attach trigger of specified platform.""" platform = _get_trigger_platform(config) assert hasattr(platform, "async_attach_trigger") return cast( - Callable, + CALLBACK_TYPE, await getattr(platform, "async_attach_trigger")( hass, config, action, automation_info ), diff --git a/homeassistant/components/zwave_js/triggers/value_updated.py b/homeassistant/components/zwave_js/triggers/value_updated.py index a2dbb84cf3b..fdf2589073e 100644 --- a/homeassistant/components/zwave_js/triggers/value_updated.py +++ b/homeassistant/components/zwave_js/triggers/value_updated.py @@ -3,7 +3,6 @@ from __future__ import annotations import functools import logging -from typing import Any, Callable import voluptuous as vol from zwave_js_server.const import CommandClass @@ -11,6 +10,10 @@ from zwave_js_server.event import Event from zwave_js_server.model.node import Node from zwave_js_server.model.value import Value, get_value_id +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.components.zwave_js.const import ( ATTR_COMMAND_CLASS, ATTR_COMMAND_CLASS_NAME, @@ -79,8 +82,8 @@ TRIGGER_SCHEMA = vol.All( async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, - action: Callable, - automation_info: dict[str, Any], + action: AutomationActionType, + automation_info: AutomationTriggerInfo, *, platform_type: str = PLATFORM_TYPE, ) -> CALLBACK_TYPE: @@ -110,9 +113,7 @@ async def async_attach_trigger( unsubs = [] job = HassJob(action) - trigger_data: dict = {} - if automation_info: - trigger_data = automation_info.get("trigger_data", {}) + trigger_data = automation_info["trigger_data"] @callback def async_on_value_updated( diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index 29f344a6fa0..9d431cdb7b8 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio import logging -from types import MappingProxyType from typing import Any, Callable import voluptuous as vol @@ -11,7 +10,7 @@ import voluptuous as vol from homeassistant.const import CONF_ID, CONF_PLATFORM from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.typing import ConfigType, TemplateVarsType from homeassistant.loader import IntegrationNotFound, async_get_integration _PLATFORM_ALIASES = { @@ -62,15 +61,9 @@ async def async_initialize_triggers( name: str, log_cb: Callable, home_assistant_start: bool = False, - variables: dict[str, Any] | MappingProxyType | None = None, + variables: TemplateVarsType = None, ) -> CALLBACK_TYPE | None: """Initialize triggers.""" - info = { - "domain": domain, - "name": name, - "home_assistant_start": home_assistant_start, - "variables": variables, - } triggers = [] for idx, conf in enumerate(trigger_config): @@ -78,7 +71,13 @@ async def async_initialize_triggers( trigger_id = conf.get(CONF_ID, f"{idx}") trigger_idx = f"{idx}" trigger_data = {"id": trigger_id, "idx": trigger_idx} - info = {**info, "trigger_data": trigger_data} + info = { + "domain": domain, + "name": name, + "home_assistant_start": home_assistant_start, + "variables": variables, + "trigger_data": trigger_data, + } triggers.append(platform.async_attach_trigger(hass, conf, action, info)) attach_results = await asyncio.gather(*triggers, return_exceptions=True) diff --git a/script/scaffold/templates/device_trigger/integration/device_trigger.py b/script/scaffold/templates/device_trigger/integration/device_trigger.py index 16dc43f8d59..45c6adb4dcf 100644 --- a/script/scaffold/templates/device_trigger/integration/device_trigger.py +++ b/script/scaffold/templates/device_trigger/integration/device_trigger.py @@ -5,7 +5,10 @@ from typing import Any import voluptuous as vol -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.components.homeassistant.triggers import state from homeassistant.const import ( @@ -71,7 +74,7 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" # TODO Implement your own logic to attach triggers. diff --git a/tests/components/mqtt/test_device_trigger.py b/tests/components/mqtt/test_device_trigger.py index 61c7f73b5fb..b0db6169373 100644 --- a/tests/components/mqtt/test_device_trigger.py +++ b/tests/components/mqtt/test_device_trigger.py @@ -4,9 +4,9 @@ import json import pytest import homeassistant.components.automation as automation -from homeassistant.components.mqtt import DOMAIN, debug_info -from homeassistant.components.mqtt.device_trigger import async_attach_trigger +from homeassistant.components.mqtt import _LOGGER, DOMAIN, debug_info from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.trigger import async_initialize_triggers from homeassistant.setup import async_setup_component from tests.common import ( @@ -697,18 +697,22 @@ async def test_attach_remove(hass, device_reg, mqtt_mock): def callback(trigger): calls.append(trigger["trigger"]["payload"]) - remove = await async_attach_trigger( + remove = await async_initialize_triggers( hass, - { - "platform": "device", - "domain": DOMAIN, - "device_id": device_entry.id, - "discovery_id": "bla1", - "type": "button_short_press", - "subtype": "button_1", - }, + [ + { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "discovery_id": "bla1", + "type": "button_short_press", + "subtype": "button_1", + }, + ], callback, - None, + DOMAIN, + "mock-name", + _LOGGER.log, ) # Fake short press. @@ -751,18 +755,22 @@ async def test_attach_remove_late(hass, device_reg, mqtt_mock): def callback(trigger): calls.append(trigger["trigger"]["payload"]) - remove = await async_attach_trigger( + remove = await async_initialize_triggers( hass, - { - "platform": "device", - "domain": DOMAIN, - "device_id": device_entry.id, - "discovery_id": "bla1", - "type": "button_short_press", - "subtype": "button_1", - }, + [ + { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "discovery_id": "bla1", + "type": "button_short_press", + "subtype": "button_1", + }, + ], callback, - None, + DOMAIN, + "mock-name", + _LOGGER.log, ) async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", data1) @@ -808,18 +816,22 @@ async def test_attach_remove_late2(hass, device_reg, mqtt_mock): def callback(trigger): calls.append(trigger["trigger"]["payload"]) - remove = await async_attach_trigger( + remove = await async_initialize_triggers( hass, - { - "platform": "device", - "domain": DOMAIN, - "device_id": device_entry.id, - "discovery_id": "bla1", - "type": "button_short_press", - "subtype": "button_1", - }, + [ + { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "discovery_id": "bla1", + "type": "button_short_press", + "subtype": "button_1", + }, + ], callback, - None, + DOMAIN, + "mock-name", + _LOGGER.log, ) # Remove the trigger diff --git a/tests/components/tasmota/test_device_trigger.py b/tests/components/tasmota/test_device_trigger.py index 8ef4f7df919..aba448bcbe5 100644 --- a/tests/components/tasmota/test_device_trigger.py +++ b/tests/components/tasmota/test_device_trigger.py @@ -1,15 +1,16 @@ -"""The tests for MQTT device triggers.""" +"""The tests for Tasmota device triggers.""" import copy import json -from unittest.mock import patch +from unittest.mock import Mock, patch from hatasmota.switch import TasmotaSwitchTriggerConfig import pytest import homeassistant.components.automation as automation +from homeassistant.components.tasmota import _LOGGER from homeassistant.components.tasmota.const import DEFAULT_PREFIX, DOMAIN -from homeassistant.components.tasmota.device_trigger import async_attach_trigger from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.trigger import async_initialize_triggers from homeassistant.setup import async_setup_component from .test_common import DEFAULT_CONFIG @@ -812,18 +813,22 @@ async def test_attach_remove(hass, device_reg, mqtt_mock, setup_tasmota): def callback(trigger, context): calls.append(trigger["trigger"]["description"]) - remove = await async_attach_trigger( + remove = await async_initialize_triggers( hass, - { - "platform": "device", - "domain": DOMAIN, - "device_id": device_entry.id, - "discovery_id": "00000049A3BC_switch_1_TOGGLE", - "type": "button_short_press", - "subtype": "switch_1", - }, + [ + { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "discovery_id": "00000049A3BC_switch_1_TOGGLE", + "type": "button_short_press", + "subtype": "switch_1", + }, + ], callback, - None, + DOMAIN, + "mock-name", + _LOGGER.log, ) # Fake short press. @@ -869,18 +874,22 @@ async def test_attach_remove_late(hass, device_reg, mqtt_mock, setup_tasmota): def callback(trigger, context): calls.append(trigger["trigger"]["description"]) - remove = await async_attach_trigger( + remove = await async_initialize_triggers( hass, - { - "platform": "device", - "domain": DOMAIN, - "device_id": device_entry.id, - "discovery_id": "00000049A3BC_switch_1_TOGGLE", - "type": "button_short_press", - "subtype": "switch_1", - }, + [ + { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "discovery_id": "00000049A3BC_switch_1_TOGGLE", + "type": "button_short_press", + "subtype": "switch_1", + }, + ], callback, - None, + DOMAIN, + "mock-name", + _LOGGER.log, ) # Fake short press. @@ -936,18 +945,22 @@ async def test_attach_remove_late2(hass, device_reg, mqtt_mock, setup_tasmota): def callback(trigger, context): calls.append(trigger["trigger"]["description"]) - remove = await async_attach_trigger( + remove = await async_initialize_triggers( hass, - { - "platform": "device", - "domain": DOMAIN, - "device_id": device_entry.id, - "discovery_id": "00000049A3BC_switch_1_TOGGLE", - "type": "button_short_press", - "subtype": "switch_1", - }, + [ + { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "discovery_id": "00000049A3BC_switch_1_TOGGLE", + "type": "button_short_press", + "subtype": "switch_1", + }, + ], callback, - None, + DOMAIN, + "mock-name", + _LOGGER.log, ) # Remove the trigger @@ -979,18 +992,22 @@ async def test_attach_remove_unknown1(hass, device_reg, mqtt_mock, setup_tasmota set(), {(dr.CONNECTION_NETWORK_MAC, mac)} ) - remove = await async_attach_trigger( + remove = await async_initialize_triggers( hass, - { - "platform": "device", - "domain": DOMAIN, - "device_id": device_entry.id, - "discovery_id": "00000049A3BC_switch_1_TOGGLE", - "type": "button_short_press", - "subtype": "switch_1", - }, - None, - None, + [ + { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "discovery_id": "00000049A3BC_switch_1_TOGGLE", + "type": "button_short_press", + "subtype": "switch_1", + }, + ], + Mock(), + DOMAIN, + "mock-name", + _LOGGER.log, ) # Remove the trigger @@ -1023,18 +1040,22 @@ async def test_attach_unknown_remove_device_from_registry( set(), {(dr.CONNECTION_NETWORK_MAC, mac)} ) - await async_attach_trigger( + await async_initialize_triggers( hass, - { - "platform": "device", - "domain": DOMAIN, - "device_id": device_entry.id, - "discovery_id": "00000049A3BC_switch_1_TOGGLE", - "type": "button_short_press", - "subtype": "switch_1", - }, - None, - None, + [ + { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "discovery_id": "00000049A3BC_switch_1_TOGGLE", + "type": "button_short_press", + "subtype": "switch_1", + }, + ], + Mock(), + DOMAIN, + "mock-name", + _LOGGER.log, ) # Remove the device @@ -1063,18 +1084,22 @@ async def test_attach_remove_config_entry(hass, device_reg, mqtt_mock, setup_tas def callback(trigger, context): calls.append(trigger["trigger"]["description"]) - await async_attach_trigger( + await async_initialize_triggers( hass, - { - "platform": "device", - "domain": DOMAIN, - "device_id": device_entry.id, - "discovery_id": "00000049A3BC_switch_1_TOGGLE", - "type": "button_short_press", - "subtype": "switch_1", - }, + [ + { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "discovery_id": "00000049A3BC_switch_1_TOGGLE", + "type": "button_short_press", + "subtype": "switch_1", + }, + ], callback, - None, + DOMAIN, + "mock-name", + _LOGGER.log, ) # Fake short press. From 0dc8fb497b1162c08286dfd03f54da3d237f38dd Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 4 Sep 2021 03:01:48 +0200 Subject: [PATCH 208/843] Delay state update after switch is toggled for TP-Link HS210 device (#55671) * delay state update for HS210 * Update workaround comment --- homeassistant/components/tplink/switch.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/homeassistant/components/tplink/switch.py b/homeassistant/components/tplink/switch.py index 10cf5c64d75..2d5a379198d 100644 --- a/homeassistant/components/tplink/switch.py +++ b/homeassistant/components/tplink/switch.py @@ -1,6 +1,7 @@ """Support for TPLink HS100/HS110/HS200 smart switch.""" from __future__ import annotations +from asyncio import sleep from typing import Any from pyHS100 import SmartPlug @@ -89,9 +90,15 @@ class SmartPlugSwitch(CoordinatorEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" await self.hass.async_add_executor_job(self.smartplug.turn_on) + # Workaround for delayed device state update on HS210: #55190 + if "HS210" in self.device_info["model"]: + await sleep(0.5) await self.coordinator.async_refresh() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" await self.hass.async_add_executor_job(self.smartplug.turn_off) + # Workaround for delayed device state update on HS210: #55190 + if "HS210" in self.device_info["model"]: + await sleep(0.5) await self.coordinator.async_refresh() From 195ee2a188308a87d6185e641dbf85363c9e087a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 3 Sep 2021 17:15:28 -1000 Subject: [PATCH 209/843] Avoid creating sockets in homekit port available tests (#55668) * Avoid creating sockets in homekit port available tests * prevent new bridge from being setup -- its too fast now that the executor job is gone and it revealed an unpatched setup --- homeassistant/components/homekit/__init__.py | 4 +- .../components/homekit/config_flow.py | 6 +- homeassistant/components/homekit/util.py | 22 +++-- tests/components/homekit/test_config_flow.py | 14 ++- tests/components/homekit/test_homekit.py | 6 +- tests/components/homekit/test_util.py | 87 +++++++++++++++++-- 6 files changed, 105 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 19298a9f814..b293f1f542d 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -103,9 +103,9 @@ from .const import ( from .type_triggers import DeviceTriggerAccessory from .util import ( accessory_friendly_name, + async_port_is_available, dismiss_setup_message, get_persist_fullpath_for_entry_id, - port_is_available, remove_state_files_for_entry_id, show_setup_message, state_needs_accessory_mode, @@ -330,7 +330,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: logged_shutdown_wait = False for _ in range(0, SHUTDOWN_TIMEOUT): - if await hass.async_add_executor_job(port_is_available, entry.data[CONF_PORT]): + if async_port_is_available(entry.data[CONF_PORT]): break if not logged_shutdown_wait: diff --git a/homeassistant/components/homekit/config_flow.py b/homeassistant/components/homekit/config_flow.py index 6f0e9d9ba5f..81f439c8954 100644 --- a/homeassistant/components/homekit/config_flow.py +++ b/homeassistant/components/homekit/config_flow.py @@ -172,9 +172,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_pairing(self, user_input=None): """Pairing instructions.""" if user_input is not None: - port = await async_find_next_available_port( - self.hass, DEFAULT_CONFIG_FLOW_PORT - ) + port = async_find_next_available_port(self.hass, DEFAULT_CONFIG_FLOW_PORT) await self._async_add_entries_for_accessory_mode_entities(port) self.hk_data[CONF_PORT] = port include_domains_filter = self.hk_data[CONF_FILTER][CONF_INCLUDE_DOMAINS] @@ -205,7 +203,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): for entity_id in accessory_mode_entity_ids: if entity_id in exiting_entity_ids_accessory_mode: continue - port = await async_find_next_available_port(self.hass, next_port_to_check) + port = async_find_next_available_port(self.hass, next_port_to_check) next_port_to_check = port + 1 self.hass.async_create_task( self.hass.config_entries.flow.async_init( diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index 6585e9e9c4e..a5c9f3937ea 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -27,7 +27,7 @@ from homeassistant.const import ( CONF_TYPE, TEMP_CELSIUS, ) -from homeassistant.core import HomeAssistant, split_entity_id +from homeassistant.core import HomeAssistant, callback, split_entity_id import homeassistant.helpers.config_validation as cv from homeassistant.helpers.storage import STORAGE_DIR import homeassistant.util.temperature as temp_util @@ -433,34 +433,32 @@ def _get_test_socket(): return test_socket -def port_is_available(port: int) -> bool: +@callback +def async_port_is_available(port: int) -> bool: """Check to see if a port is available.""" - test_socket = _get_test_socket() try: - test_socket.bind(("", port)) + _get_test_socket().bind(("", port)) except OSError: return False - return True -async def async_find_next_available_port(hass: HomeAssistant, start_port: int) -> int: +@callback +def async_find_next_available_port(hass: HomeAssistant, start_port: int) -> int: """Find the next available port not assigned to a config entry.""" exclude_ports = { entry.data[CONF_PORT] for entry in hass.config_entries.async_entries(DOMAIN) if CONF_PORT in entry.data } - - return await hass.async_add_executor_job( - _find_next_available_port, start_port, exclude_ports - ) + return _async_find_next_available_port(start_port, exclude_ports) -def _find_next_available_port(start_port: int, exclude_ports: set) -> int: +@callback +def _async_find_next_available_port(start_port: int, exclude_ports: set) -> int: """Find the next available port starting with the given port.""" test_socket = _get_test_socket() - for port in range(start_port, MAX_PORT): + for port in range(start_port, MAX_PORT + 1): if port in exclude_ports: continue try: diff --git a/tests/components/homekit/test_config_flow.py b/tests/components/homekit/test_config_flow.py index b2e9af3816a..fadb4572df3 100644 --- a/tests/components/homekit/test_config_flow.py +++ b/tests/components/homekit/test_config_flow.py @@ -951,10 +951,15 @@ async def test_converting_bridge_to_accessory_mode(hass, hk_driver, mock_get_sou assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM assert result2["step_id"] == "cameras" - result3 = await hass.config_entries.options.async_configure( - result2["flow_id"], - user_input={"camera_copy": ["camera.tv"]}, - ) + with patch( + "homeassistant.components.homekit.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result3 = await hass.config_entries.options.async_configure( + result2["flow_id"], + user_input={"camera_copy": ["camera.tv"]}, + ) + await hass.async_block_till_done() assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert config_entry.options == { @@ -968,6 +973,7 @@ async def test_converting_bridge_to_accessory_mode(hass, hk_driver, mock_get_sou "include_entities": ["camera.tv"], }, } + assert len(mock_setup_entry.mock_calls) == 1 def _get_schema_default(schema, key_name): diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index b1ea2ab2a1d..e9c9ad6662b 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -1308,7 +1308,7 @@ async def test_homekit_uses_system_zeroconf(hass, hk_driver, mock_zeroconf): with patch("pyhap.accessory_driver.AccessoryDriver.async_start"), patch( f"{PATH_HOMEKIT}.HomeKit.async_stop" - ), patch(f"{PATH_HOMEKIT}.port_is_available"): + ), patch(f"{PATH_HOMEKIT}.async_port_is_available"): entry.add_to_hass(hass) assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -1701,7 +1701,7 @@ async def test_wait_for_port_to_free(hass, hk_driver, mock_zeroconf, caplog): with patch("pyhap.accessory_driver.AccessoryDriver.async_start"), patch( f"{PATH_HOMEKIT}.HomeKit.async_stop" - ), patch(f"{PATH_HOMEKIT}.port_is_available", return_value=True) as port_mock: + ), patch(f"{PATH_HOMEKIT}.async_port_is_available", return_value=True) as port_mock: assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert await hass.config_entries.async_unload(entry.entry_id) @@ -1712,7 +1712,7 @@ async def test_wait_for_port_to_free(hass, hk_driver, mock_zeroconf, caplog): with patch("pyhap.accessory_driver.AccessoryDriver.async_start"), patch( f"{PATH_HOMEKIT}.HomeKit.async_stop" ), patch.object(homekit_base, "PORT_CLEANUP_CHECK_INTERVAL_SECS", 0), patch( - f"{PATH_HOMEKIT}.port_is_available", return_value=False + f"{PATH_HOMEKIT}.async_port_is_available", return_value=False ) as port_mock: assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/homekit/test_util.py b/tests/components/homekit/test_util.py index 33c5c8623d1..2d4ac2171da 100644 --- a/tests/components/homekit/test_util.py +++ b/tests/components/homekit/test_util.py @@ -1,5 +1,5 @@ """Test HomeKit util module.""" -from unittest.mock import Mock +from unittest.mock import MagicMock, Mock, patch import pytest import voluptuous as vol @@ -26,12 +26,12 @@ from homeassistant.components.homekit.const import ( from homeassistant.components.homekit.util import ( accessory_friendly_name, async_find_next_available_port, + async_port_is_available, cleanup_name_for_homekit, convert_to_float, density_to_air_quality, dismiss_setup_message, format_sw_version, - port_is_available, show_setup_message, state_needs_accessory_mode, temperature_to_homekit, @@ -61,6 +61,25 @@ from .util import async_init_integration from tests.common import MockConfigEntry, async_mock_service +def _mock_socket(failure_attempts: int = 0) -> MagicMock: + """Mock a socket that fails to bind failure_attempts amount of times.""" + mock_socket = MagicMock() + attempts = 0 + + def _simulate_bind(*_): + import pprint + + pprint.pprint("Calling bind") + nonlocal attempts + attempts += 1 + if attempts <= failure_attempts: + raise OSError + return + + mock_socket.bind = Mock(side_effect=_simulate_bind) + return mock_socket + + def test_validate_entity_config(): """Test validate entities.""" configs = [ @@ -257,11 +276,35 @@ async def test_dismiss_setup_msg(hass): async def test_port_is_available(hass): """Test we can get an available port and it is actually available.""" - next_port = await async_find_next_available_port(hass, DEFAULT_CONFIG_FLOW_PORT) - + with patch( + "homeassistant.components.homekit.util.socket.socket", + return_value=_mock_socket(0), + ): + next_port = async_find_next_available_port(hass, DEFAULT_CONFIG_FLOW_PORT) assert next_port + with patch( + "homeassistant.components.homekit.util.socket.socket", + return_value=_mock_socket(0), + ): + assert async_port_is_available(next_port) - assert await hass.async_add_executor_job(port_is_available, next_port) + with patch( + "homeassistant.components.homekit.util.socket.socket", + return_value=_mock_socket(5), + ): + next_port = async_find_next_available_port(hass, DEFAULT_CONFIG_FLOW_PORT) + assert next_port == DEFAULT_CONFIG_FLOW_PORT + 5 + with patch( + "homeassistant.components.homekit.util.socket.socket", + return_value=_mock_socket(0), + ): + assert async_port_is_available(next_port) + + with patch( + "homeassistant.components.homekit.util.socket.socket", + return_value=_mock_socket(1), + ): + assert not async_port_is_available(next_port) async def test_port_is_available_skips_existing_entries(hass): @@ -273,12 +316,38 @@ async def test_port_is_available_skips_existing_entries(hass): ) entry.add_to_hass(hass) - next_port = await async_find_next_available_port(hass, DEFAULT_CONFIG_FLOW_PORT) + with patch( + "homeassistant.components.homekit.util.socket.socket", + return_value=_mock_socket(), + ): + next_port = async_find_next_available_port(hass, DEFAULT_CONFIG_FLOW_PORT) - assert next_port - assert next_port != DEFAULT_CONFIG_FLOW_PORT + assert next_port == DEFAULT_CONFIG_FLOW_PORT + 1 - assert await hass.async_add_executor_job(port_is_available, next_port) + with patch( + "homeassistant.components.homekit.util.socket.socket", + return_value=_mock_socket(), + ): + assert async_port_is_available(next_port) + + with patch( + "homeassistant.components.homekit.util.socket.socket", + return_value=_mock_socket(4), + ): + next_port = async_find_next_available_port(hass, DEFAULT_CONFIG_FLOW_PORT) + + assert next_port == DEFAULT_CONFIG_FLOW_PORT + 5 + with patch( + "homeassistant.components.homekit.util.socket.socket", + return_value=_mock_socket(), + ): + assert async_port_is_available(next_port) + + with pytest.raises(OSError), patch( + "homeassistant.components.homekit.util.socket.socket", + return_value=_mock_socket(10), + ): + async_find_next_available_port(hass, 65530) async def test_format_sw_version(): From 19c54b8cbfd713ba4a3e1286ef4bcfcd4fe60cbc Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 3 Sep 2021 22:17:10 -0700 Subject: [PATCH 210/843] Drop unused ruamel (#55672) --- docs/source/api/util.rst | 8 -- homeassistant/package_constraints.txt | 1 - homeassistant/util/ruamel_yaml.py | 152 ------------------------ requirements.txt | 1 - setup.py | 1 - tests/util/test_ruamel_yaml.py | 162 -------------------------- 6 files changed, 325 deletions(-) delete mode 100644 homeassistant/util/ruamel_yaml.py delete mode 100644 tests/util/test_ruamel_yaml.py diff --git a/docs/source/api/util.rst b/docs/source/api/util.rst index 52ae8eacdd3..071f4d81cdf 100644 --- a/docs/source/api/util.rst +++ b/docs/source/api/util.rst @@ -118,14 +118,6 @@ homeassistant.util.pressure :undoc-members: :show-inheritance: -homeassistant.util.ruamel\_yaml -------------------------------- - -.. automodule:: homeassistant.util.ruamel_yaml - :members: - :undoc-members: - :show-inheritance: - homeassistant.util.ssl ---------------------- diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c59fdafdc7a..d32947244c5 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -28,7 +28,6 @@ python-slugify==4.0.1 pyudev==0.22.0 pyyaml==5.4.1 requests==2.25.1 -ruamel.yaml==0.15.100 scapy==2.4.5 sqlalchemy==1.4.23 voluptuous-serialize==2.4.0 diff --git a/homeassistant/util/ruamel_yaml.py b/homeassistant/util/ruamel_yaml.py deleted file mode 100644 index 8d813eaa5a4..00000000000 --- a/homeassistant/util/ruamel_yaml.py +++ /dev/null @@ -1,152 +0,0 @@ -"""ruamel.yaml utility functions.""" -from __future__ import annotations - -from collections import OrderedDict -from contextlib import suppress -import logging -import os -from os import O_CREAT, O_TRUNC, O_WRONLY, stat_result -from typing import Union - -import ruamel.yaml -from ruamel.yaml import YAML -from ruamel.yaml.compat import StringIO -from ruamel.yaml.constructor import SafeConstructor -from ruamel.yaml.error import YAMLError - -from homeassistant.exceptions import HomeAssistantError -from homeassistant.util.yaml import secret_yaml - -_LOGGER = logging.getLogger(__name__) - -JSON_TYPE = Union[list, dict, str] # pylint: disable=invalid-name - - -class ExtSafeConstructor(SafeConstructor): - """Extended SafeConstructor.""" - - name: str | None = None - - -class UnsupportedYamlError(HomeAssistantError): - """Unsupported YAML.""" - - -class WriteError(HomeAssistantError): - """Error writing the data.""" - - -def _include_yaml( - constructor: ExtSafeConstructor, node: ruamel.yaml.nodes.Node -) -> JSON_TYPE: - """Load another YAML file and embeds it using the !include tag. - - Example: - device_tracker: !include device_tracker.yaml - - """ - if constructor.name is None: - raise HomeAssistantError( - f"YAML include error: filename not set for {node.value}" - ) - fname = os.path.join(os.path.dirname(constructor.name), node.value) - return load_yaml(fname, False) - - -def _yaml_unsupported( - constructor: ExtSafeConstructor, node: ruamel.yaml.nodes.Node -) -> None: - raise UnsupportedYamlError( - f"Unsupported YAML, you can not use {node.tag} in " - f"{os.path.basename(constructor.name or '(None)')}" - ) - - -def object_to_yaml(data: JSON_TYPE) -> str: - """Create yaml string from object.""" - yaml = YAML(typ="rt") - yaml.indent(sequence=4, offset=2) - stream = StringIO() - try: - yaml.dump(data, stream) - result: str = stream.getvalue() - return result - except YAMLError as exc: - _LOGGER.error("YAML error: %s", exc) - raise HomeAssistantError(exc) from exc - - -def yaml_to_object(data: str) -> JSON_TYPE: - """Create object from yaml string.""" - yaml = YAML(typ="rt") - try: - result: list | dict | str = yaml.load(data) - return result - except YAMLError as exc: - _LOGGER.error("YAML error: %s", exc) - raise HomeAssistantError(exc) from exc - - -def load_yaml(fname: str, round_trip: bool = False) -> JSON_TYPE: - """Load a YAML file.""" - if round_trip: - yaml = YAML(typ="rt") - yaml.preserve_quotes = True # type: ignore[assignment] - else: - if ExtSafeConstructor.name is None: - ExtSafeConstructor.name = fname - yaml = YAML(typ="safe") - yaml.Constructor = ExtSafeConstructor - - try: - with open(fname, encoding="utf-8") as conf_file: - # If configuration file is empty YAML returns None - # We convert that to an empty dict - return yaml.load(conf_file) or OrderedDict() - except YAMLError as exc: - _LOGGER.error("YAML error in %s: %s", fname, exc) - raise HomeAssistantError(exc) from exc - except UnicodeDecodeError as exc: - _LOGGER.error("Unable to read file %s: %s", fname, exc) - raise HomeAssistantError(exc) from exc - - -def save_yaml(fname: str, data: JSON_TYPE) -> None: - """Save a YAML file.""" - yaml = YAML(typ="rt") - yaml.indent(sequence=4, offset=2) - tmp_fname = f"{fname}__TEMP__" - try: - try: - file_stat = os.stat(fname) - except OSError: - file_stat = stat_result((0o644, -1, -1, -1, -1, -1, -1, -1, -1, -1)) - with open( - os.open(tmp_fname, O_WRONLY | O_CREAT | O_TRUNC, file_stat.st_mode), - "w", - encoding="utf-8", - ) as temp_file: - yaml.dump(data, temp_file) - os.replace(tmp_fname, fname) - if hasattr(os, "chown") and file_stat.st_ctime > -1: - with suppress(OSError): - os.chown(fname, file_stat.st_uid, file_stat.st_gid) - except YAMLError as exc: - _LOGGER.error(str(exc)) - raise HomeAssistantError(exc) from exc - except OSError as exc: - _LOGGER.exception("Saving YAML file %s failed: %s", fname, exc) - raise WriteError(exc) from exc - finally: - if os.path.exists(tmp_fname): - try: - os.remove(tmp_fname) - except OSError as exc: - # If we are cleaning up then something else went wrong, so - # we should suppress likely follow-on errors in the cleanup - _LOGGER.error("YAML replacement cleanup failed: %s", exc) - - -ExtSafeConstructor.add_constructor("!secret", secret_yaml) -ExtSafeConstructor.add_constructor("!include", _include_yaml) -ExtSafeConstructor.add_constructor(None, _yaml_unsupported) diff --git a/requirements.txt b/requirements.txt index 70eeccdae1b..e6b4b5845c4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,7 +18,6 @@ pip>=8.0.3,<20.3 python-slugify==4.0.1 pyyaml==5.4.1 requests==2.25.1 -ruamel.yaml==0.15.100 voluptuous==0.12.1 voluptuous-serialize==2.4.0 yarl==1.6.3 diff --git a/setup.py b/setup.py index 302eadbfcf6..e9ab189406b 100755 --- a/setup.py +++ b/setup.py @@ -50,7 +50,6 @@ REQUIRES = [ "python-slugify==4.0.1", "pyyaml==5.4.1", "requests==2.25.1", - "ruamel.yaml==0.15.100", "voluptuous==0.12.1", "voluptuous-serialize==2.4.0", "yarl==1.6.3", diff --git a/tests/util/test_ruamel_yaml.py b/tests/util/test_ruamel_yaml.py deleted file mode 100644 index b4e78a883af..00000000000 --- a/tests/util/test_ruamel_yaml.py +++ /dev/null @@ -1,162 +0,0 @@ -"""Test Home Assistant ruamel.yaml loader.""" -import os -from tempfile import mkdtemp - -import pytest -from ruamel.yaml import YAML - -from homeassistant.exceptions import HomeAssistantError -import homeassistant.util.ruamel_yaml as util_yaml - -TEST_YAML_A = """\ -title: My Awesome Home -# Include external resources -resources: - - url: /local/my-custom-card.js - type: js - - url: /local/my-webfont.css - type: css - -# Exclude entities from "Unused entities" view -excluded_entities: - - weblink.router -views: - # View tab title. - - title: Example - # Optional unique id for direct access /lovelace/${id} - id: example - # Optional background (overwrites the global background). - background: radial-gradient(crimson, skyblue) - # Each view can have a different theme applied. - theme: dark-mode - # The cards to show on this view. - cards: - # The filter card will filter entities for their state - - type: entity-filter - entities: - - device_tracker.paulus - - device_tracker.anne_there - state_filter: - - 'home' - card: - type: glance - title: People that are home - - # The picture entity card will represent an entity with a picture - - type: picture-entity - image: https://www.home-assistant.io/images/default-social.png - entity: light.bed_light - - # Specify a tab icon if you want the view tab to be an icon. - - icon: mdi:home-assistant - # Title of the view. Will be used as the tooltip for tab icon - title: Second view - cards: - - id: test - type: entities - title: Test card - # Entities card will take a list of entities and show their state. - - type: entities - # Title of the entities card - title: Example - # The entities here will be shown in the same order as specified. - # Each entry is an entity ID or a map with extra options. - entities: - - light.kitchen - - switch.ac - - entity: light.living_room - # Override the name to use - name: LR Lights - - # The markdown card will render markdown text. - - type: markdown - title: Lovelace - content: > - Welcome to your **Lovelace UI**. -""" - -TEST_YAML_B = """\ -title: Home -views: - - title: Dashboard - id: dashboard - icon: mdi:home - cards: - - id: testid - type: vertical-stack - cards: - - type: picture-entity - entity: group.sample - name: Sample - image: /local/images/sample.jpg - tap_action: toggle -""" - -# Test data that can not be loaded as YAML -TEST_BAD_YAML = """\ -title: Home -views: - - title: Dashboard - icon: mdi:home - cards: - - id: testid - type: vertical-stack -""" - -# Test unsupported YAML -TEST_UNSUP_YAML = """\ -title: Home -views: - - title: Dashboard - icon: mdi:home - cards: !include cards.yaml -""" - -TMP_DIR = None - - -def setup(): - """Set up for tests.""" - global TMP_DIR - TMP_DIR = mkdtemp() - - -def teardown(): - """Clean up after tests.""" - for fname in os.listdir(TMP_DIR): - os.remove(os.path.join(TMP_DIR, fname)) - os.rmdir(TMP_DIR) - - -def _path_for(leaf_name): - return os.path.join(TMP_DIR, f"{leaf_name}.yaml") - - -def test_save_and_load(): - """Test saving and loading back.""" - yaml = YAML(typ="rt") - fname = _path_for("test1") - open(fname, "w+").close() - util_yaml.save_yaml(fname, yaml.load(TEST_YAML_A)) - data = util_yaml.load_yaml(fname, True) - assert data == yaml.load(TEST_YAML_A) - - -def test_overwrite_and_reload(): - """Test that we can overwrite an existing file and read back.""" - yaml = YAML(typ="rt") - fname = _path_for("test2") - open(fname, "w+").close() - util_yaml.save_yaml(fname, yaml.load(TEST_YAML_A)) - util_yaml.save_yaml(fname, yaml.load(TEST_YAML_B)) - data = util_yaml.load_yaml(fname, True) - assert data == yaml.load(TEST_YAML_B) - - -def test_load_bad_data(): - """Test error from trying to load unserialisable data.""" - fname = _path_for("test3") - with open(fname, "w") as fh: - fh.write(TEST_BAD_YAML) - with pytest.raises(HomeAssistantError): - util_yaml.load_yaml(fname, True) From 7aa454231fbe850f97335dde176950a597567b25 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Sat, 4 Sep 2021 07:56:12 +0200 Subject: [PATCH 211/843] Update template/test_sensor.py to use pytest (#55288) --- tests/components/template/test_sensor.py | 866 ++++++++++------------- 1 file changed, 355 insertions(+), 511 deletions(-) diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py index a606c2ec62b..4c45ff1b150 100644 --- a/tests/components/template/test_sensor.py +++ b/tests/components/template/test_sensor.py @@ -23,116 +23,102 @@ from homeassistant.helpers.template import Template from homeassistant.setup import ATTR_COMPONENT, async_setup_component import homeassistant.util.dt as dt_util -from tests.common import assert_setup_component, async_fire_time_changed +from tests.common import async_fire_time_changed + +TEST_NAME = "sensor.test_template_sensor" -async def test_template_legacy(hass): +@pytest.mark.parametrize("count,domain", [(1, sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "sensor": { + "platform": "template", + "sensors": { + "test_template_sensor": { + "value_template": "It {{ states.sensor.test_state.state }}." + } + }, + }, + }, + ], +) +async def test_template_legacy(hass, start_ha): """Test template.""" - with assert_setup_component(1, sensor.DOMAIN): - assert await async_setup_component( - hass, - sensor.DOMAIN, - { - "sensor": { - "platform": "template", - "sensors": { - "test_template_sensor": { - "value_template": "It {{ states.sensor.test_state.state }}." - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - state = hass.states.get("sensor.test_template_sensor") - assert state.state == "It ." + assert hass.states.get(TEST_NAME).state == "It ." hass.states.async_set("sensor.test_state", "Works") await hass.async_block_till_done() - state = hass.states.get("sensor.test_template_sensor") - assert state.state == "It Works." + assert hass.states.get(TEST_NAME).state == "It Works." -async def test_icon_template(hass): +@pytest.mark.parametrize("count,domain", [(1, sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "sensor": { + "platform": "template", + "sensors": { + "test_template_sensor": { + "value_template": "{{ states.sensor.test_state.state }}", + "icon_template": "{% if states.sensor.test_state.state == " + "'Works' %}" + "mdi:check" + "{% endif %}", + } + }, + }, + }, + ], +) +async def test_icon_template(hass, start_ha): """Test icon template.""" - with assert_setup_component(1, sensor.DOMAIN): - assert await async_setup_component( - hass, - sensor.DOMAIN, - { - "sensor": { - "platform": "template", - "sensors": { - "test_template_sensor": { - "value_template": "{{ states.sensor.test_state.state }}", - "icon_template": "{% if states.sensor.test_state.state == " - "'Works' %}" - "mdi:check" - "{% endif %}", - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - state = hass.states.get("sensor.test_template_sensor") - assert state.attributes.get("icon") == "" + assert hass.states.get(TEST_NAME).attributes.get("icon") == "" hass.states.async_set("sensor.test_state", "Works") await hass.async_block_till_done() - state = hass.states.get("sensor.test_template_sensor") - assert state.attributes["icon"] == "mdi:check" + assert hass.states.get(TEST_NAME).attributes["icon"] == "mdi:check" -async def test_entity_picture_template(hass): +@pytest.mark.parametrize("count,domain", [(1, sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "sensor": { + "platform": "template", + "sensors": { + "test_template_sensor": { + "value_template": "{{ states.sensor.test_state.state }}", + "entity_picture_template": "{% if states.sensor.test_state.state == " + "'Works' %}" + "/local/sensor.png" + "{% endif %}", + } + }, + }, + }, + ], +) +async def test_entity_picture_template(hass, start_ha): """Test entity_picture template.""" - with assert_setup_component(1, sensor.DOMAIN): - assert await async_setup_component( - hass, - sensor.DOMAIN, - { - "sensor": { - "platform": "template", - "sensors": { - "test_template_sensor": { - "value_template": "{{ states.sensor.test_state.state }}", - "entity_picture_template": "{% if states.sensor.test_state.state == " - "'Works' %}" - "/local/sensor.png" - "{% endif %}", - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - state = hass.states.get("sensor.test_template_sensor") - assert state.attributes.get("entity_picture") == "" + assert hass.states.get(TEST_NAME).attributes.get("entity_picture") == "" hass.states.async_set("sensor.test_state", "Works") await hass.async_block_till_done() - state = hass.states.get("sensor.test_template_sensor") - assert state.attributes["entity_picture"] == "/local/sensor.png" + assert ( + hass.states.get(TEST_NAME).attributes["entity_picture"] == "/local/sensor.png" + ) -async def test_friendly_name_template(hass): - """Test friendly_name template.""" - with assert_setup_component(1, sensor.DOMAIN): - assert await async_setup_component( - hass, - sensor.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, sensor.DOMAIN)]) +@pytest.mark.parametrize( + "attribute,config", + [ + ( + "friendly_name", { "sensor": { "platform": "template", @@ -142,29 +128,11 @@ async def test_friendly_name_template(hass): "friendly_name_template": "It {{ states.sensor.test_state.state }}.", } }, - } + }, }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - state = hass.states.get("sensor.test_template_sensor") - assert state.attributes.get("friendly_name") == "It ." - - hass.states.async_set("sensor.test_state", "Works") - await hass.async_block_till_done() - state = hass.states.get("sensor.test_template_sensor") - assert state.attributes["friendly_name"] == "It Works." - - -async def test_friendly_name_template_with_unknown_state(hass): - """Test friendly_name template with an unknown value_template.""" - with assert_setup_component(1, sensor.DOMAIN): - assert await async_setup_component( - hass, - sensor.DOMAIN, + ), + ( + "friendly_name", { "sensor": { "platform": "template", @@ -174,29 +142,11 @@ async def test_friendly_name_template_with_unknown_state(hass): "friendly_name_template": "It {{ states.sensor.test_state.state }}.", } }, - } + }, }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - state = hass.states.get("sensor.test_template_sensor") - assert state.attributes["friendly_name"] == "It ." - - hass.states.async_set("sensor.test_state", "Works") - await hass.async_block_till_done() - state = hass.states.get("sensor.test_template_sensor") - assert state.attributes["friendly_name"] == "It Works." - - -async def test_attribute_templates(hass): - """Test attribute_templates template.""" - with assert_setup_component(1, sensor.DOMAIN): - assert await async_setup_component( - hass, - sensor.DOMAIN, + ), + ( + "test_attribute", { "sensor": { "platform": "template", @@ -208,203 +158,131 @@ async def test_attribute_templates(hass): }, } }, - } + }, }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - state = hass.states.get("sensor.test_template_sensor") - assert state.attributes.get("test_attribute") == "It ." + ), + ], +) +async def test_friendly_name_template(hass, attribute, start_ha): + """Test friendly_name template with an unknown value_template.""" + assert hass.states.get(TEST_NAME).attributes.get(attribute) == "It ." hass.states.async_set("sensor.test_state", "Works") await hass.async_block_till_done() - state = hass.states.get("sensor.test_template_sensor") - assert state.attributes["test_attribute"] == "It Works." + assert hass.states.get(TEST_NAME).attributes[attribute] == "It Works." -async def test_template_syntax_error(hass): - """Test templating syntax error.""" - with assert_setup_component(0, sensor.DOMAIN): - assert await async_setup_component( - hass, - sensor.DOMAIN, - { - "sensor": { - "platform": "template", - "sensors": { - "test_template_sensor": {"value_template": "{% if rubbish %}"} - }, - } +@pytest.mark.parametrize("count,domain", [(0, sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "sensor": { + "platform": "template", + "sensors": { + "test_template_sensor": {"value_template": "{% if rubbish %}"} + }, }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - assert hass.states.async_all() == [] - - -async def test_template_attribute_missing(hass): - """Test missing attribute template.""" - with assert_setup_component(1, sensor.DOMAIN): - assert await async_setup_component( - hass, - sensor.DOMAIN, - { - "sensor": { - "platform": "template", - "sensors": { - "test_template_sensor": { - "value_template": "It {{ states.sensor.test_state" - ".attributes.missing }}." - } - }, - } + }, + { + "sensor": { + "platform": "template", + "sensors": { + "test INVALID sensor": { + "value_template": "{{ states.sensor.test_state.state }}" + } + }, }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - state = hass.states.get("sensor.test_template_sensor") - assert state.state == STATE_UNAVAILABLE - - -async def test_invalid_name_does_not_create(hass): - """Test invalid name.""" - with assert_setup_component(0, sensor.DOMAIN): - assert await async_setup_component( - hass, - sensor.DOMAIN, - { - "sensor": { - "platform": "template", - "sensors": { - "test INVALID sensor": { - "value_template": "{{ states.sensor.test_state.state }}" - } - }, - } + }, + { + "sensor": { + "platform": "template", + "sensors": { + "test_template_sensor": {"invalid"}, + }, }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert hass.states.async_all() == [] - - -async def test_invalid_sensor_does_not_create(hass): - """Test invalid sensor.""" - with assert_setup_component(0, sensor.DOMAIN): - assert await async_setup_component( - hass, - sensor.DOMAIN, - { - "sensor": { - "platform": "template", - "sensors": {"test_template_sensor": "invalid"}, - } + }, + { + "sensor": { + "platform": "template", }, - ) - - await hass.async_block_till_done() - await hass.async_start() - - assert hass.states.async_all() == [] - - -async def test_no_sensors_does_not_create(hass): - """Test no sensors.""" - with assert_setup_component(0, sensor.DOMAIN): - assert await async_setup_component( - hass, sensor.DOMAIN, {"sensor": {"platform": "template"}} - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert hass.states.async_all() == [] - - -async def test_missing_template_does_not_create(hass): - """Test missing template.""" - with assert_setup_component(0, sensor.DOMAIN): - assert await async_setup_component( - hass, - sensor.DOMAIN, - { - "sensor": { - "platform": "template", - "sensors": { - "test_template_sensor": { - "not_value_template": "{{ states.sensor.test_state.state }}" - } - }, - } + }, + { + "sensor": { + "platform": "template", + "sensors": { + "test_template_sensor": { + "not_value_template": "{{ states.sensor.test_state.state }}" + } + }, }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert hass.states.async_all() == [] - - -async def test_setup_invalid_device_class(hass): - """Test setup with invalid device_class.""" - with assert_setup_component(0, sensor.DOMAIN): - assert await async_setup_component( - hass, - sensor.DOMAIN, - { - "sensor": { - "platform": "template", - "sensors": { + }, + { + "sensor": { + "platform": "template", + "sensors": { + "test_template_sensor": { "test": { "value_template": "{{ states.sensor.test_sensor.state }}", "device_class": "foobarnotreal", } - }, - } + } + }, }, - ) + }, + ], +) +async def test_template_syntax_error(hass, start_ha): + """Test setup with invalid device_class.""" + assert hass.states.async_all() == [] -async def test_setup_valid_device_class(hass): +@pytest.mark.parametrize("count,domain", [(1, sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "sensor": { + "platform": "template", + "sensors": { + "test_template_sensor": { + "value_template": "It {{ states.sensor.test_state" + ".attributes.missing }}." + } + }, + }, + }, + ], +) +async def test_template_attribute_missing(hass, start_ha): + """Test missing attribute template.""" + assert hass.states.get(TEST_NAME).state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize("count,domain", [(1, sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "sensor": { + "platform": "template", + "sensors": { + "test1": { + "value_template": "{{ states.sensor.test_sensor.state }}", + "device_class": "temperature", + }, + "test2": { + "value_template": "{{ states.sensor.test_sensor.state }}" + }, + }, + }, + }, + ], +) +async def test_setup_valid_device_class(hass, start_ha): """Test setup with valid device_class.""" - with assert_setup_component(1, sensor.DOMAIN): - assert await async_setup_component( - hass, - sensor.DOMAIN, - { - "sensor": { - "platform": "template", - "sensors": { - "test1": { - "value_template": "{{ states.sensor.test_sensor.state }}", - "device_class": "temperature", - }, - "test2": { - "value_template": "{{ states.sensor.test_sensor.state }}" - }, - }, - } - }, - ) - await hass.async_block_till_done() - - state = hass.states.get("sensor.test1") - assert state.attributes["device_class"] == "temperature" - state = hass.states.get("sensor.test2") - assert "device_class" not in state.attributes + assert hass.states.get("sensor.test1").attributes["device_class"] == "temperature" + assert "device_class" not in hass.states.get("sensor.test2").attributes @pytest.mark.parametrize("load_registries", [False]) @@ -448,52 +326,46 @@ async def test_creating_sensor_loads_group(hass): assert order == ["group", "sensor.template"] -async def test_available_template_with_entities(hass): +@pytest.mark.parametrize("count,domain", [(1, sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "sensor": { + "platform": "template", + "sensors": { + "test_template_sensor": { + "value_template": "{{ states.sensor.test_sensor.state }}", + "availability_template": "{{ is_state('sensor.availability_sensor', 'on') }}", + } + }, + }, + }, + ], +) +async def test_available_template_with_entities(hass, start_ha): """Test availability tempalates with values from other entities.""" hass.states.async_set("sensor.availability_sensor", STATE_OFF) - with assert_setup_component(1, sensor.DOMAIN): - assert await async_setup_component( - hass, - sensor.DOMAIN, - { - "sensor": { - "platform": "template", - "sensors": { - "test_template_sensor": { - "value_template": "{{ states.sensor.test_sensor.state }}", - "availability_template": "{{ is_state('sensor.availability_sensor', 'on') }}", - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() # When template returns true.. hass.states.async_set("sensor.availability_sensor", STATE_ON) await hass.async_block_till_done() # Device State should not be unavailable - assert hass.states.get("sensor.test_template_sensor").state != STATE_UNAVAILABLE + assert hass.states.get(TEST_NAME).state != STATE_UNAVAILABLE # When Availability template returns false hass.states.async_set("sensor.availability_sensor", STATE_OFF) await hass.async_block_till_done() # device state should be unavailable - assert hass.states.get("sensor.test_template_sensor").state == STATE_UNAVAILABLE + assert hass.states.get(TEST_NAME).state == STATE_UNAVAILABLE -async def test_invalid_attribute_template(hass, caplog): - """Test that errors are logged if rendering template fails.""" - hass.states.async_set("sensor.test_sensor", "startup") - - await async_setup_component( - hass, - sensor.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "sensor": { "platform": "template", @@ -505,9 +377,13 @@ async def test_invalid_attribute_template(hass, caplog): }, } }, - } + }, }, - ) + ], +) +async def test_invalid_attribute_template(hass, caplog, start_ha): + """Test that errors are logged if rendering template fails.""" + hass.states.async_set("sensor.test_sensor", "startup") await hass.async_block_till_done() assert len(hass.states.async_all()) == 2 @@ -515,16 +391,15 @@ async def test_invalid_attribute_template(hass, caplog): await hass.async_block_till_done() await hass.helpers.entity_component.async_update_entity("sensor.invalid_template") - assert "TemplateError" in caplog.text + messages = str([x.message for x in caplog.get_records("setup")]) + assert "TemplateError" in messages assert "test_attribute" in caplog.text -async def test_invalid_availability_template_keeps_component_available(hass, caplog): - """Test that an invalid availability keeps the device available.""" - - await async_setup_component( - hass, - sensor.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "sensor": { "platform": "template", @@ -534,16 +409,17 @@ async def test_invalid_availability_template_keeps_component_available(hass, cap "availability_template": "{{ x - 12 }}", } }, - } + }, }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_invalid_availability_template_keeps_component_available( + hass, caplog, start_ha +): + """Test that an invalid availability keeps the device available.""" assert hass.states.get("sensor.my_sensor").state != STATE_UNAVAILABLE - assert ("UndefinedError: 'x' is undefined") in caplog.text + messages = str([x.message for x in caplog.get_records("setup")]) + assert "UndefinedError: \\'x\\' is undefined" in messages async def test_no_template_match_all(hass, caplog): @@ -632,11 +508,10 @@ async def test_no_template_match_all(hass, caplog): assert hass.states.get("sensor.invalid_attribute").state == "hello" -async def test_unique_id(hass): - """Test unique_id option only creates one sensor per id.""" - await async_setup_component( - hass, - "template", +@pytest.mark.parametrize("count,domain", [(1, "template")]) +@pytest.mark.parametrize( + "config", + [ { "template": { "unique_id": "group-id", @@ -656,16 +531,13 @@ async def test_unique_id(hass): }, }, }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_unique_id(hass, start_ha): + """Test unique_id option only creates one sensor per id.""" assert len(hass.states.async_all()) == 2 ent_reg = entity_registry.async_get(hass) - assert len(ent_reg.entities) == 2 assert ( ent_reg.async_get_entity_id("sensor", "template", "group-id-sensor-id") @@ -677,17 +549,10 @@ async def test_unique_id(hass): ) -async def test_sun_renders_once_per_sensor(hass): - """Test sun change renders the template only once per sensor.""" - - now = dt_util.utcnow() - hass.states.async_set( - "sun.sun", "above_horizon", {"elevation": 45.3, "next_rising": now} - ) - - await async_setup_component( - hass, - sensor.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "sensor": { "platform": "template", @@ -703,10 +568,15 @@ async def test_sun_renders_once_per_sensor(hass): }, } }, - ) + ], +) +async def test_sun_renders_once_per_sensor(hass, start_ha): + """Test sun change renders the template only once per sensor.""" - await hass.async_block_till_done() - await hass.async_start() + now = dt_util.utcnow() + hass.states.async_set( + "sun.sun", "above_horizon", {"elevation": 45.3, "next_rising": now} + ) await hass.async_block_till_done() assert len(hass.states.async_all()) == 3 @@ -738,12 +608,10 @@ async def test_sun_renders_once_per_sensor(hass): } -async def test_self_referencing_sensor_loop(hass, caplog): - """Test a self referencing sensor does not loop forever.""" - - await async_setup_component( - hass, - sensor.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "sensor": { "platform": "template", @@ -754,31 +622,25 @@ async def test_self_referencing_sensor_loop(hass, caplog): }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_self_referencing_sensor_loop(hass, caplog, start_ha): + """Test a self referencing sensor does not loop forever.""" assert len(hass.states.async_all()) == 1 - await hass.async_block_till_done() await hass.async_block_till_done() + messages = str([x.message for x in caplog.get_records("setup")]) + assert "Template loop detected" in messages - assert "Template loop detected" in caplog.text - - state = hass.states.get("sensor.test") - assert int(state.state) == 2 + assert int(hass.states.get("sensor.test").state) == 2 await hass.async_block_till_done() - assert int(state.state) == 2 + assert int(hass.states.get("sensor.test").state) == 2 -async def test_self_referencing_sensor_with_icon_loop(hass, caplog): - """Test a self referencing sensor loops forever with a valid self referencing icon.""" - - await async_setup_component( - hass, - sensor.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "sensor": { "platform": "template", @@ -790,33 +652,28 @@ async def test_self_referencing_sensor_with_icon_loop(hass, caplog): }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_self_referencing_sensor_with_icon_loop(hass, caplog, start_ha): + """Test a self referencing sensor loops forever with a valid self referencing icon.""" assert len(hass.states.async_all()) == 1 - await hass.async_block_till_done() await hass.async_block_till_done() - - assert "Template loop detected" in caplog.text + messages = str([x.message for x in caplog.get_records("setup")]) + assert "Template loop detected" in messages state = hass.states.get("sensor.test") assert int(state.state) == 3 assert state.attributes[ATTR_ICON] == "mdi:greater" - await hass.async_block_till_done() + state = hass.states.get("sensor.test") assert int(state.state) == 3 -async def test_self_referencing_sensor_with_icon_and_picture_entity_loop(hass, caplog): - """Test a self referencing sensor loop forevers with a valid self referencing icon.""" - - await async_setup_component( - hass, - sensor.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "sensor": { "platform": "template", @@ -829,18 +686,17 @@ async def test_self_referencing_sensor_with_icon_and_picture_entity_loop(hass, c }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_self_referencing_sensor_with_icon_and_picture_entity_loop( + hass, caplog, start_ha +): + """Test a self referencing sensor loop forevers with a valid self referencing icon.""" assert len(hass.states.async_all()) == 1 - await hass.async_block_till_done() await hass.async_block_till_done() - - assert "Template loop detected" in caplog.text + messages = str([x.message for x in caplog.get_records("setup")]) + assert "Template loop detected" in messages state = hass.states.get("sensor.test") assert int(state.state) == 4 @@ -851,12 +707,10 @@ async def test_self_referencing_sensor_with_icon_and_picture_entity_loop(hass, c assert int(state.state) == 4 -async def test_self_referencing_entity_picture_loop(hass, caplog): - """Test a self referencing sensor does not loop forever with a looping self referencing entity picture.""" - - await async_setup_component( - hass, - sensor.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "sensor": { "platform": "template", @@ -868,14 +722,11 @@ async def test_self_referencing_entity_picture_loop(hass, caplog): }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_self_referencing_entity_picture_loop(hass, caplog, start_ha): + """Test a self referencing sensor does not loop forever with a looping self referencing entity picture.""" assert len(hass.states.async_all()) == 1 - next_time = dt_util.utcnow() + timedelta(seconds=1.2) with patch( "homeassistant.helpers.ratelimit.dt_util.utcnow", return_value=next_time @@ -884,7 +735,8 @@ async def test_self_referencing_entity_picture_loop(hass, caplog): await hass.async_block_till_done() await hass.async_block_till_done() - assert "Template loop detected" in caplog.text + messages = str([x.message for x in caplog.get_records("setup")]) + assert "Template loop detected" in messages state = hass.states.get("sensor.test") assert int(state.state) == 1 @@ -969,48 +821,42 @@ async def test_self_referencing_icon_with_no_loop(hass, caplog): assert "Template loop detected" not in caplog.text -async def test_duplicate_templates(hass): +@pytest.mark.parametrize("count,domain", [(1, sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "sensor": { + "platform": "template", + "sensors": { + "test_template_sensor": { + "value_template": "{{ states.sensor.test_state.state }}", + "friendly_name_template": "{{ states.sensor.test_state.state }}", + } + }, + } + }, + ], +) +async def test_duplicate_templates(hass, start_ha): """Test template entity where the value and friendly name as the same template.""" hass.states.async_set("sensor.test_state", "Abc") - - with assert_setup_component(1, sensor.DOMAIN): - assert await async_setup_component( - hass, - sensor.DOMAIN, - { - "sensor": { - "platform": "template", - "sensors": { - "test_template_sensor": { - "value_template": "{{ states.sensor.test_state.state }}", - "friendly_name_template": "{{ states.sensor.test_state.state }}", - } - }, - } - }, - ) - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - state = hass.states.get("sensor.test_template_sensor") + state = hass.states.get(TEST_NAME) assert state.attributes["friendly_name"] == "Abc" assert state.state == "Abc" hass.states.async_set("sensor.test_state", "Def") await hass.async_block_till_done() - - state = hass.states.get("sensor.test_template_sensor") + state = hass.states.get(TEST_NAME) assert state.attributes["friendly_name"] == "Def" assert state.state == "Def" -async def test_trigger_entity(hass): - """Test trigger entity works.""" - assert await async_setup_component( - hass, - "template", +@pytest.mark.parametrize("count,domain", [(2, "template")]) +@pytest.mark.parametrize( + "config", + [ { "template": [ {"invalid": "config"}, @@ -1059,10 +905,10 @@ async def test_trigger_entity(hass): }, ], }, - ) - - await hass.async_block_till_done() - + ], +) +async def test_trigger_entity(hass, start_ha): + """Test trigger entity works.""" state = hass.states.get("sensor.hello_name") assert state is not None assert state.state == STATE_UNKNOWN @@ -1106,11 +952,10 @@ async def test_trigger_entity(hass): assert state.context is context -async def test_trigger_entity_render_error(hass): - """Test trigger entity handles render error.""" - assert await async_setup_component( - hass, - "template", +@pytest.mark.parametrize("count,domain", [(1, "template")]) +@pytest.mark.parametrize( + "config", + [ { "template": { "trigger": {"platform": "event", "event_type": "test_event"}, @@ -1123,10 +968,10 @@ async def test_trigger_entity_render_error(hass): }, }, }, - ) - - await hass.async_block_till_done() - + ], +) +async def test_trigger_entity_render_error(hass, start_ha): + """Test trigger entity handles render error.""" state = hass.states.get("sensor.hello") assert state is not None assert state.state == STATE_UNKNOWN @@ -1143,11 +988,10 @@ async def test_trigger_entity_render_error(hass): assert ent_reg.entities["sensor.hello"].unique_id == "no-base-id" -async def test_trigger_not_allowed_platform_config(hass, caplog): - """Test we throw a helpful warning if a trigger is configured in platform config.""" - assert await async_setup_component( - hass, - sensor.DOMAIN, +@pytest.mark.parametrize("count,domain", [(0, sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "sensor": { "platform": "template", @@ -1160,23 +1004,23 @@ async def test_trigger_not_allowed_platform_config(hass, caplog): }, } }, - ) - - await hass.async_block_till_done() - - state = hass.states.get("sensor.test_template_sensor") + ], +) +async def test_trigger_not_allowed_platform_config(hass, caplog, start_ha): + """Test we throw a helpful warning if a trigger is configured in platform config.""" + state = hass.states.get(TEST_NAME) assert state is None + messages = str([x.message for x in caplog.get_records("setup")]) assert ( "You can only add triggers to template entities if they are defined under `template:`." - in caplog.text + in messages ) -async def test_config_top_level(hass): - """Test unique_id option only creates one sensor per id.""" - await async_setup_component( - hass, - "template", +@pytest.mark.parametrize("count,domain", [(1, "template")]) +@pytest.mark.parametrize( + "config", + [ { "template": { "sensor": { @@ -1188,10 +1032,10 @@ async def test_config_top_level(hass): }, }, }, - ) - - await hass.async_block_till_done() - + ], +) +async def test_config_top_level(hass, start_ha): + """Test unique_id option only creates one sensor per id.""" assert len(hass.states.async_all()) == 1 state = hass.states.get("sensor.top_level") assert state is not None From 10317fba17f4718c204481226f1e478286512abe Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 4 Sep 2021 00:23:35 -0700 Subject: [PATCH 212/843] better detect legacy eagly devices (#55706) --- homeassistant/components/rainforest_eagle/data.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/rainforest_eagle/data.py b/homeassistant/components/rainforest_eagle/data.py index 76ddb2d25d7..70c2bddb4b3 100644 --- a/homeassistant/components/rainforest_eagle/data.py +++ b/homeassistant/components/rainforest_eagle/data.py @@ -54,7 +54,7 @@ async def async_get_type(hass, cloud_id, install_code, host): meters = await hub.get_device_list() except aioeagle.BadAuth as err: raise InvalidAuth from err - except aiohttp.ClientError: + except (KeyError, aiohttp.ClientError): # This can happen if it's an eagle-100 meters = None From b3181a0ab2d269b6d1cb4e63aa78746c8d89e1e5 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 4 Sep 2021 09:25:25 +0200 Subject: [PATCH 213/843] Use NamedTuple for RGBColor (#55698) --- homeassistant/util/color.py | 310 +++++++++++++++++++----------------- 1 file changed, 160 insertions(+), 150 deletions(-) diff --git a/homeassistant/util/color.py b/homeassistant/util/color.py index c81beddb07a..ebd3b175905 100644 --- a/homeassistant/util/color.py +++ b/homeassistant/util/color.py @@ -3,11 +3,21 @@ from __future__ import annotations import colorsys import math +from typing import NamedTuple import attr # mypy: disallow-any-generics + +class RGBColor(NamedTuple): + """RGB hex values.""" + + r: int + g: int + b: int + + # Official CSS3 colors from w3.org: # https://www.w3.org/TR/2010/PR-css3-color-20101028/#html4 # names do not have spaces in them so that we can compare against @@ -15,156 +25,156 @@ import attr # This lets "dark seagreen" and "dark sea green" both match the same # color "darkseagreen". COLORS = { - "aliceblue": (240, 248, 255), - "antiquewhite": (250, 235, 215), - "aqua": (0, 255, 255), - "aquamarine": (127, 255, 212), - "azure": (240, 255, 255), - "beige": (245, 245, 220), - "bisque": (255, 228, 196), - "black": (0, 0, 0), - "blanchedalmond": (255, 235, 205), - "blue": (0, 0, 255), - "blueviolet": (138, 43, 226), - "brown": (165, 42, 42), - "burlywood": (222, 184, 135), - "cadetblue": (95, 158, 160), - "chartreuse": (127, 255, 0), - "chocolate": (210, 105, 30), - "coral": (255, 127, 80), - "cornflowerblue": (100, 149, 237), - "cornsilk": (255, 248, 220), - "crimson": (220, 20, 60), - "cyan": (0, 255, 255), - "darkblue": (0, 0, 139), - "darkcyan": (0, 139, 139), - "darkgoldenrod": (184, 134, 11), - "darkgray": (169, 169, 169), - "darkgreen": (0, 100, 0), - "darkgrey": (169, 169, 169), - "darkkhaki": (189, 183, 107), - "darkmagenta": (139, 0, 139), - "darkolivegreen": (85, 107, 47), - "darkorange": (255, 140, 0), - "darkorchid": (153, 50, 204), - "darkred": (139, 0, 0), - "darksalmon": (233, 150, 122), - "darkseagreen": (143, 188, 143), - "darkslateblue": (72, 61, 139), - "darkslategray": (47, 79, 79), - "darkslategrey": (47, 79, 79), - "darkturquoise": (0, 206, 209), - "darkviolet": (148, 0, 211), - "deeppink": (255, 20, 147), - "deepskyblue": (0, 191, 255), - "dimgray": (105, 105, 105), - "dimgrey": (105, 105, 105), - "dodgerblue": (30, 144, 255), - "firebrick": (178, 34, 34), - "floralwhite": (255, 250, 240), - "forestgreen": (34, 139, 34), - "fuchsia": (255, 0, 255), - "gainsboro": (220, 220, 220), - "ghostwhite": (248, 248, 255), - "gold": (255, 215, 0), - "goldenrod": (218, 165, 32), - "gray": (128, 128, 128), - "green": (0, 128, 0), - "greenyellow": (173, 255, 47), - "grey": (128, 128, 128), - "honeydew": (240, 255, 240), - "hotpink": (255, 105, 180), - "indianred": (205, 92, 92), - "indigo": (75, 0, 130), - "ivory": (255, 255, 240), - "khaki": (240, 230, 140), - "lavender": (230, 230, 250), - "lavenderblush": (255, 240, 245), - "lawngreen": (124, 252, 0), - "lemonchiffon": (255, 250, 205), - "lightblue": (173, 216, 230), - "lightcoral": (240, 128, 128), - "lightcyan": (224, 255, 255), - "lightgoldenrodyellow": (250, 250, 210), - "lightgray": (211, 211, 211), - "lightgreen": (144, 238, 144), - "lightgrey": (211, 211, 211), - "lightpink": (255, 182, 193), - "lightsalmon": (255, 160, 122), - "lightseagreen": (32, 178, 170), - "lightskyblue": (135, 206, 250), - "lightslategray": (119, 136, 153), - "lightslategrey": (119, 136, 153), - "lightsteelblue": (176, 196, 222), - "lightyellow": (255, 255, 224), - "lime": (0, 255, 0), - "limegreen": (50, 205, 50), - "linen": (250, 240, 230), - "magenta": (255, 0, 255), - "maroon": (128, 0, 0), - "mediumaquamarine": (102, 205, 170), - "mediumblue": (0, 0, 205), - "mediumorchid": (186, 85, 211), - "mediumpurple": (147, 112, 219), - "mediumseagreen": (60, 179, 113), - "mediumslateblue": (123, 104, 238), - "mediumspringgreen": (0, 250, 154), - "mediumturquoise": (72, 209, 204), - "mediumvioletred": (199, 21, 133), - "midnightblue": (25, 25, 112), - "mintcream": (245, 255, 250), - "mistyrose": (255, 228, 225), - "moccasin": (255, 228, 181), - "navajowhite": (255, 222, 173), - "navy": (0, 0, 128), - "navyblue": (0, 0, 128), - "oldlace": (253, 245, 230), - "olive": (128, 128, 0), - "olivedrab": (107, 142, 35), - "orange": (255, 165, 0), - "orangered": (255, 69, 0), - "orchid": (218, 112, 214), - "palegoldenrod": (238, 232, 170), - "palegreen": (152, 251, 152), - "paleturquoise": (175, 238, 238), - "palevioletred": (219, 112, 147), - "papayawhip": (255, 239, 213), - "peachpuff": (255, 218, 185), - "peru": (205, 133, 63), - "pink": (255, 192, 203), - "plum": (221, 160, 221), - "powderblue": (176, 224, 230), - "purple": (128, 0, 128), - "red": (255, 0, 0), - "rosybrown": (188, 143, 143), - "royalblue": (65, 105, 225), - "saddlebrown": (139, 69, 19), - "salmon": (250, 128, 114), - "sandybrown": (244, 164, 96), - "seagreen": (46, 139, 87), - "seashell": (255, 245, 238), - "sienna": (160, 82, 45), - "silver": (192, 192, 192), - "skyblue": (135, 206, 235), - "slateblue": (106, 90, 205), - "slategray": (112, 128, 144), - "slategrey": (112, 128, 144), - "snow": (255, 250, 250), - "springgreen": (0, 255, 127), - "steelblue": (70, 130, 180), - "tan": (210, 180, 140), - "teal": (0, 128, 128), - "thistle": (216, 191, 216), - "tomato": (255, 99, 71), - "turquoise": (64, 224, 208), - "violet": (238, 130, 238), - "wheat": (245, 222, 179), - "white": (255, 255, 255), - "whitesmoke": (245, 245, 245), - "yellow": (255, 255, 0), - "yellowgreen": (154, 205, 50), + "aliceblue": RGBColor(240, 248, 255), + "antiquewhite": RGBColor(250, 235, 215), + "aqua": RGBColor(0, 255, 255), + "aquamarine": RGBColor(127, 255, 212), + "azure": RGBColor(240, 255, 255), + "beige": RGBColor(245, 245, 220), + "bisque": RGBColor(255, 228, 196), + "black": RGBColor(0, 0, 0), + "blanchedalmond": RGBColor(255, 235, 205), + "blue": RGBColor(0, 0, 255), + "blueviolet": RGBColor(138, 43, 226), + "brown": RGBColor(165, 42, 42), + "burlywood": RGBColor(222, 184, 135), + "cadetblue": RGBColor(95, 158, 160), + "chartreuse": RGBColor(127, 255, 0), + "chocolate": RGBColor(210, 105, 30), + "coral": RGBColor(255, 127, 80), + "cornflowerblue": RGBColor(100, 149, 237), + "cornsilk": RGBColor(255, 248, 220), + "crimson": RGBColor(220, 20, 60), + "cyan": RGBColor(0, 255, 255), + "darkblue": RGBColor(0, 0, 139), + "darkcyan": RGBColor(0, 139, 139), + "darkgoldenrod": RGBColor(184, 134, 11), + "darkgray": RGBColor(169, 169, 169), + "darkgreen": RGBColor(0, 100, 0), + "darkgrey": RGBColor(169, 169, 169), + "darkkhaki": RGBColor(189, 183, 107), + "darkmagenta": RGBColor(139, 0, 139), + "darkolivegreen": RGBColor(85, 107, 47), + "darkorange": RGBColor(255, 140, 0), + "darkorchid": RGBColor(153, 50, 204), + "darkred": RGBColor(139, 0, 0), + "darksalmon": RGBColor(233, 150, 122), + "darkseagreen": RGBColor(143, 188, 143), + "darkslateblue": RGBColor(72, 61, 139), + "darkslategray": RGBColor(47, 79, 79), + "darkslategrey": RGBColor(47, 79, 79), + "darkturquoise": RGBColor(0, 206, 209), + "darkviolet": RGBColor(148, 0, 211), + "deeppink": RGBColor(255, 20, 147), + "deepskyblue": RGBColor(0, 191, 255), + "dimgray": RGBColor(105, 105, 105), + "dimgrey": RGBColor(105, 105, 105), + "dodgerblue": RGBColor(30, 144, 255), + "firebrick": RGBColor(178, 34, 34), + "floralwhite": RGBColor(255, 250, 240), + "forestgreen": RGBColor(34, 139, 34), + "fuchsia": RGBColor(255, 0, 255), + "gainsboro": RGBColor(220, 220, 220), + "ghostwhite": RGBColor(248, 248, 255), + "gold": RGBColor(255, 215, 0), + "goldenrod": RGBColor(218, 165, 32), + "gray": RGBColor(128, 128, 128), + "green": RGBColor(0, 128, 0), + "greenyellow": RGBColor(173, 255, 47), + "grey": RGBColor(128, 128, 128), + "honeydew": RGBColor(240, 255, 240), + "hotpink": RGBColor(255, 105, 180), + "indianred": RGBColor(205, 92, 92), + "indigo": RGBColor(75, 0, 130), + "ivory": RGBColor(255, 255, 240), + "khaki": RGBColor(240, 230, 140), + "lavender": RGBColor(230, 230, 250), + "lavenderblush": RGBColor(255, 240, 245), + "lawngreen": RGBColor(124, 252, 0), + "lemonchiffon": RGBColor(255, 250, 205), + "lightblue": RGBColor(173, 216, 230), + "lightcoral": RGBColor(240, 128, 128), + "lightcyan": RGBColor(224, 255, 255), + "lightgoldenrodyellow": RGBColor(250, 250, 210), + "lightgray": RGBColor(211, 211, 211), + "lightgreen": RGBColor(144, 238, 144), + "lightgrey": RGBColor(211, 211, 211), + "lightpink": RGBColor(255, 182, 193), + "lightsalmon": RGBColor(255, 160, 122), + "lightseagreen": RGBColor(32, 178, 170), + "lightskyblue": RGBColor(135, 206, 250), + "lightslategray": RGBColor(119, 136, 153), + "lightslategrey": RGBColor(119, 136, 153), + "lightsteelblue": RGBColor(176, 196, 222), + "lightyellow": RGBColor(255, 255, 224), + "lime": RGBColor(0, 255, 0), + "limegreen": RGBColor(50, 205, 50), + "linen": RGBColor(250, 240, 230), + "magenta": RGBColor(255, 0, 255), + "maroon": RGBColor(128, 0, 0), + "mediumaquamarine": RGBColor(102, 205, 170), + "mediumblue": RGBColor(0, 0, 205), + "mediumorchid": RGBColor(186, 85, 211), + "mediumpurple": RGBColor(147, 112, 219), + "mediumseagreen": RGBColor(60, 179, 113), + "mediumslateblue": RGBColor(123, 104, 238), + "mediumspringgreen": RGBColor(0, 250, 154), + "mediumturquoise": RGBColor(72, 209, 204), + "mediumvioletred": RGBColor(199, 21, 133), + "midnightblue": RGBColor(25, 25, 112), + "mintcream": RGBColor(245, 255, 250), + "mistyrose": RGBColor(255, 228, 225), + "moccasin": RGBColor(255, 228, 181), + "navajowhite": RGBColor(255, 222, 173), + "navy": RGBColor(0, 0, 128), + "navyblue": RGBColor(0, 0, 128), + "oldlace": RGBColor(253, 245, 230), + "olive": RGBColor(128, 128, 0), + "olivedrab": RGBColor(107, 142, 35), + "orange": RGBColor(255, 165, 0), + "orangered": RGBColor(255, 69, 0), + "orchid": RGBColor(218, 112, 214), + "palegoldenrod": RGBColor(238, 232, 170), + "palegreen": RGBColor(152, 251, 152), + "paleturquoise": RGBColor(175, 238, 238), + "palevioletred": RGBColor(219, 112, 147), + "papayawhip": RGBColor(255, 239, 213), + "peachpuff": RGBColor(255, 218, 185), + "peru": RGBColor(205, 133, 63), + "pink": RGBColor(255, 192, 203), + "plum": RGBColor(221, 160, 221), + "powderblue": RGBColor(176, 224, 230), + "purple": RGBColor(128, 0, 128), + "red": RGBColor(255, 0, 0), + "rosybrown": RGBColor(188, 143, 143), + "royalblue": RGBColor(65, 105, 225), + "saddlebrown": RGBColor(139, 69, 19), + "salmon": RGBColor(250, 128, 114), + "sandybrown": RGBColor(244, 164, 96), + "seagreen": RGBColor(46, 139, 87), + "seashell": RGBColor(255, 245, 238), + "sienna": RGBColor(160, 82, 45), + "silver": RGBColor(192, 192, 192), + "skyblue": RGBColor(135, 206, 235), + "slateblue": RGBColor(106, 90, 205), + "slategray": RGBColor(112, 128, 144), + "slategrey": RGBColor(112, 128, 144), + "snow": RGBColor(255, 250, 250), + "springgreen": RGBColor(0, 255, 127), + "steelblue": RGBColor(70, 130, 180), + "tan": RGBColor(210, 180, 140), + "teal": RGBColor(0, 128, 128), + "thistle": RGBColor(216, 191, 216), + "tomato": RGBColor(255, 99, 71), + "turquoise": RGBColor(64, 224, 208), + "violet": RGBColor(238, 130, 238), + "wheat": RGBColor(245, 222, 179), + "white": RGBColor(255, 255, 255), + "whitesmoke": RGBColor(245, 245, 245), + "yellow": RGBColor(255, 255, 0), + "yellowgreen": RGBColor(154, 205, 50), # And... - "homeassistant": (3, 169, 244), + "homeassistant": RGBColor(3, 169, 244), } @@ -186,7 +196,7 @@ class GamutType: blue: XYPoint = attr.ib() -def color_name_to_rgb(color_name: str) -> tuple[int, int, int]: +def color_name_to_rgb(color_name: str) -> RGBColor: """Convert color name to RGB hex value.""" # COLORS map has no spaces in it, so make the color_name have no # spaces in it as well for matching purposes From 38d42de2c089376ed0babd612f221a9e877920d5 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sat, 4 Sep 2021 10:47:42 +0200 Subject: [PATCH 214/843] Handle negative numbers in sensor long term statistics (#55708) * Handle negative numbers in sensor long term statistics * Use negative states in tests --- homeassistant/components/sensor/recorder.py | 44 +++++++------- tests/components/sensor/test_recorder.py | 64 ++++++++++----------- 2 files changed, 52 insertions(+), 56 deletions(-) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 0054b01abd2..8bf251ffb18 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -129,13 +129,6 @@ def _get_entities(hass: HomeAssistant) -> list[tuple[str, str, str | None]]: return entity_ids -# Faster than try/except -# From https://stackoverflow.com/a/23639915 -def _is_number(s: str) -> bool: # pylint: disable=invalid-name - """Return True if string is a number.""" - return s.replace(".", "", 1).isdigit() - - def _time_weighted_average( fstates: list[tuple[float, State]], start: datetime.datetime, end: datetime.datetime ) -> float: @@ -190,9 +183,13 @@ def _normalize_states( if device_class not in UNIT_CONVERSIONS: # We're not normalizing this device class, return the state as they are - fstates = [ - (float(el.state), el) for el in entity_history if _is_number(el.state) - ] + fstates = [] + for state in entity_history: + try: + fstates.append((float(state.state), state)) + except ValueError: + continue + if fstates: all_units = _get_units(fstates) if len(all_units) > 1: @@ -220,23 +217,22 @@ def _normalize_states( fstates = [] for state in entity_history: - # Exclude non numerical states from statistics - if not _is_number(state.state): - continue + try: + fstate = float(state.state) + unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + # Exclude unsupported units from statistics + if unit not in UNIT_CONVERSIONS[device_class]: + if WARN_UNSUPPORTED_UNIT not in hass.data: + hass.data[WARN_UNSUPPORTED_UNIT] = set() + if entity_id not in hass.data[WARN_UNSUPPORTED_UNIT]: + hass.data[WARN_UNSUPPORTED_UNIT].add(entity_id) + _LOGGER.warning("%s has unknown unit %s", entity_id, unit) + continue - fstate = float(state.state) - unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - # Exclude unsupported units from statistics - if unit not in UNIT_CONVERSIONS[device_class]: - if WARN_UNSUPPORTED_UNIT not in hass.data: - hass.data[WARN_UNSUPPORTED_UNIT] = set() - if entity_id not in hass.data[WARN_UNSUPPORTED_UNIT]: - hass.data[WARN_UNSUPPORTED_UNIT].add(entity_id) - _LOGGER.warning("%s has unknown unit %s", entity_id, unit) + fstates.append((UNIT_CONVERSIONS[device_class][unit](fstate), state)) + except ValueError: continue - fstates.append((UNIT_CONVERSIONS[device_class][unit](fstate), state)) - return DEVICE_CLASS_UNITS[device_class], fstates diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 115473c23de..aeeab317eb1 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -50,18 +50,18 @@ GAS_SENSOR_ATTRIBUTES = { @pytest.mark.parametrize( "device_class,unit,native_unit,mean,min,max", [ - (None, "%", "%", 16.440677, 10, 30), - ("battery", "%", "%", 16.440677, 10, 30), - ("battery", None, None, 16.440677, 10, 30), - ("humidity", "%", "%", 16.440677, 10, 30), - ("humidity", None, None, 16.440677, 10, 30), - ("pressure", "Pa", "Pa", 16.440677, 10, 30), - ("pressure", "hPa", "Pa", 1644.0677, 1000, 3000), - ("pressure", "mbar", "Pa", 1644.0677, 1000, 3000), - ("pressure", "inHg", "Pa", 55674.53, 33863.89, 101591.67), - ("pressure", "psi", "Pa", 113354.48, 68947.57, 206842.71), - ("temperature", "°C", "°C", 16.440677, 10, 30), - ("temperature", "°F", "°C", -8.644068, -12.22222, -1.111111), + (None, "%", "%", 13.050847, -10, 30), + ("battery", "%", "%", 13.050847, -10, 30), + ("battery", None, None, 13.050847, -10, 30), + ("humidity", "%", "%", 13.050847, -10, 30), + ("humidity", None, None, 13.050847, -10, 30), + ("pressure", "Pa", "Pa", 13.050847, -10, 30), + ("pressure", "hPa", "Pa", 1305.0847, -1000, 3000), + ("pressure", "mbar", "Pa", 1305.0847, -1000, 3000), + ("pressure", "inHg", "Pa", 44195.25, -33863.89, 101591.67), + ("pressure", "psi", "Pa", 89982.42, -68947.57, 206842.71), + ("temperature", "°C", "°C", 13.050847, -10, 30), + ("temperature", "°F", "°C", -10.52731, -23.33333, -1.111111), ], ) def test_compile_hourly_statistics( @@ -155,8 +155,8 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero), - "mean": approx(16.440677966101696), - "min": approx(10.0), + "mean": approx(13.050847), + "min": approx(-10.0), "max": approx(30.0), "last_reset": None, "state": None, @@ -167,8 +167,8 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes { "statistic_id": "sensor.test6", "start": process_timestamp_to_utc_isoformat(zero), - "mean": approx(16.440677966101696), - "min": approx(10.0), + "mean": approx(13.050847), + "min": approx(-10.0), "max": approx(30.0), "last_reset": None, "state": None, @@ -179,8 +179,8 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes { "statistic_id": "sensor.test7", "start": process_timestamp_to_utc_isoformat(zero), - "mean": approx(16.440677966101696), - "min": approx(10.0), + "mean": approx(13.050847), + "min": approx(-10.0), "max": approx(30.0), "last_reset": None, "state": None, @@ -988,10 +988,10 @@ def test_list_statistic_ids_unsupported(hass_recorder, caplog, _attributes): @pytest.mark.parametrize( "device_class,unit,native_unit,mean,min,max", [ - (None, None, None, 16.440677, 10, 30), - (None, "%", "%", 16.440677, 10, 30), - ("battery", "%", "%", 16.440677, 10, 30), - ("battery", None, None, 16.440677, 10, 30), + (None, None, None, 13.050847, -10, 30), + (None, "%", "%", 13.050847, -10, 30), + ("battery", "%", "%", 13.050847, -10, 30), + ("battery", None, None, 13.050847, -10, 30), ], ) def test_compile_hourly_statistics_changing_units_1( @@ -1074,10 +1074,10 @@ def test_compile_hourly_statistics_changing_units_1( @pytest.mark.parametrize( "device_class,unit,native_unit,mean,min,max", [ - (None, None, None, 16.440677, 10, 30), - (None, "%", "%", 16.440677, 10, 30), - ("battery", "%", "%", 16.440677, 10, 30), - ("battery", None, None, 16.440677, 10, 30), + (None, None, None, 13.050847, -10, 30), + (None, "%", "%", 13.050847, -10, 30), + ("battery", "%", "%", 13.050847, -10, 30), + ("battery", None, None, 13.050847, -10, 30), ], ) def test_compile_hourly_statistics_changing_units_2( @@ -1119,10 +1119,10 @@ def test_compile_hourly_statistics_changing_units_2( @pytest.mark.parametrize( "device_class,unit,native_unit,mean,min,max", [ - (None, None, None, 16.440677, 10, 30), - (None, "%", "%", 16.440677, 10, 30), - ("battery", "%", "%", 16.440677, 10, 30), - ("battery", None, None, 16.440677, 10, 30), + (None, None, None, 13.050847, -10, 30), + (None, "%", "%", 13.050847, -10, 30), + ("battery", "%", "%", 13.050847, -10, 30), + ("battery", None, None, 13.050847, -10, 30), ], ) def test_compile_hourly_statistics_changing_units_3( @@ -1203,7 +1203,7 @@ def test_compile_hourly_statistics_changing_units_3( @pytest.mark.parametrize( "device_class,unit,native_unit,mean,min,max", [ - (None, None, None, 16.440677, 10, 30), + (None, None, None, 13.050847, -10, 30), ], ) def test_compile_hourly_statistics_changing_statistics( @@ -1309,7 +1309,7 @@ def record_states(hass, zero, entity_id, attributes): states = {entity_id: []} with patch("homeassistant.components.recorder.dt_util.utcnow", return_value=one): - states[entity_id].append(set_state(entity_id, "10", attributes=attributes)) + states[entity_id].append(set_state(entity_id, "-10", attributes=attributes)) with patch("homeassistant.components.recorder.dt_util.utcnow", return_value=two): states[entity_id].append(set_state(entity_id, "15", attributes=attributes)) From b7e8348c306fb6af9582a04eed6058a28409dc26 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sat, 4 Sep 2021 12:16:06 +0200 Subject: [PATCH 215/843] Add bluez to the devcontainer (#55469) * Fix fjaraskupan dependency for tests * update package list * Typo * hadolint fixes * hadolint fixes #2 * Cleanup * Rewording --- Dockerfile.dev | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Dockerfile.dev b/Dockerfile.dev index 6dd789761e6..5ebaa644ce5 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -6,6 +6,8 @@ RUN \ curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \ && apt-get update \ && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + # Additional library needed by some tests and accordingly by VScode Tests Discovery + bluez \ libudev-dev \ libavformat-dev \ libavcodec-dev \ From d8b85b20672eab6fa2e7f679f2629038443b69e2 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Sat, 4 Sep 2021 13:18:23 +0200 Subject: [PATCH 216/843] Fix LIFX firmware version information (#55713) --- homeassistant/components/lifx/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json index 847c75b4fa5..2dc46615f3a 100644 --- a/homeassistant/components/lifx/manifest.json +++ b/homeassistant/components/lifx/manifest.json @@ -3,7 +3,7 @@ "name": "LIFX", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/lifx", - "requirements": ["aiolifx==0.6.10", "aiolifx_effects==0.2.2"], + "requirements": ["aiolifx==0.7.0", "aiolifx_effects==0.2.2"], "homekit": { "models": ["LIFX"] }, diff --git a/requirements_all.txt b/requirements_all.txt index e8bdb1db15e..112dea917c7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -201,7 +201,7 @@ aiokafka==0.6.0 aiokef==0.2.16 # homeassistant.components.lifx -aiolifx==0.6.10 +aiolifx==0.7.0 # homeassistant.components.lifx aiolifx_effects==0.2.2 From 070010827889cf7e4bf055a2a99b8b089e5da854 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 4 Sep 2021 13:42:36 +0200 Subject: [PATCH 217/843] Use NamedTuple for device_automation details (#55697) --- .../components/device_automation/__init__.py | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/device_automation/__init__.py b/homeassistant/components/device_automation/__init__.py index 89a3f8f6408..567e579d8b8 100644 --- a/homeassistant/components/device_automation/__init__.py +++ b/homeassistant/components/device_automation/__init__.py @@ -6,7 +6,7 @@ from collections.abc import Iterable, Mapping from functools import wraps import logging from types import ModuleType -from typing import Any +from typing import Any, NamedTuple import voluptuous as vol import voluptuous_serialize @@ -36,19 +36,31 @@ DEVICE_TRIGGER_BASE_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend( } ) + +class DeviceAutomationDetails(NamedTuple): + """Details for device automation.""" + + section: str + get_automations_func: str + get_capabilities_func: str + + TYPES = { - # platform name, get automations function, get capabilities function - "trigger": ( + "trigger": DeviceAutomationDetails( "device_trigger", "async_get_triggers", "async_get_trigger_capabilities", ), - "condition": ( + "condition": DeviceAutomationDetails( "device_condition", "async_get_conditions", "async_get_condition_capabilities", ), - "action": ("device_action", "async_get_actions", "async_get_action_capabilities"), + "action": DeviceAutomationDetails( + "device_action", + "async_get_actions", + "async_get_action_capabilities", + ), } @@ -92,7 +104,7 @@ async def async_get_device_automation_platform( Throws InvalidDeviceAutomationConfig if the integration is not found or does not support device automation. """ - platform_name = TYPES[automation_type][0] + platform_name = TYPES[automation_type].section try: integration = await async_get_integration_with_requirements(hass, domain) platform = integration.get_platform(platform_name) @@ -119,7 +131,7 @@ async def _async_get_device_automations_from_domain( except InvalidDeviceAutomationConfig: return {} - function_name = TYPES[automation_type][1] + function_name = TYPES[automation_type].get_automations_func return await asyncio.gather( *( @@ -196,7 +208,7 @@ async def _async_get_device_automation_capabilities(hass, automation_type, autom except InvalidDeviceAutomationConfig: return {} - function_name = TYPES[automation_type][2] + function_name = TYPES[automation_type].get_capabilities_func if not hasattr(platform, function_name): # The device automation has no capabilities From 6348bf70accc3ccebd6938ee93407ed4811927c7 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Sat, 4 Sep 2021 16:09:55 +0200 Subject: [PATCH 218/843] Add caplog setup fixture. (#55714) --- tests/components/template/conftest.py | 6 +++ .../components/template/test_binary_sensor.py | 12 +++--- tests/components/template/test_sensor.py | 39 ++++++++----------- tests/components/template/test_vacuum.py | 13 +++---- 4 files changed, 32 insertions(+), 38 deletions(-) diff --git a/tests/components/template/conftest.py b/tests/components/template/conftest.py index e2168d0925e..5ccc9e6479a 100644 --- a/tests/components/template/conftest.py +++ b/tests/components/template/conftest.py @@ -25,3 +25,9 @@ async def start_ha(hass, count, domain, config, caplog): await hass.async_block_till_done() await hass.async_start() await hass.async_block_till_done() + + +@pytest.fixture +async def caplog_setup_text(caplog): + """Return setup log of integration.""" + yield caplog.text diff --git a/tests/components/template/test_binary_sensor.py b/tests/components/template/test_binary_sensor.py index 92b8d6f773f..98d76776242 100644 --- a/tests/components/template/test_binary_sensor.py +++ b/tests/components/template/test_binary_sensor.py @@ -431,13 +431,12 @@ async def test_availability_template(hass, start_ha): }, ], ) -async def test_invalid_attribute_template(hass, caplog, start_ha): +async def test_invalid_attribute_template(hass, start_ha, caplog_setup_text): """Test that errors are logged if rendering template fails.""" hass.states.async_set("binary_sensor.test_sensor", "true") assert len(hass.states.async_all()) == 2 - text = str([x.getMessage() for x in caplog.get_records("setup")]) - assert ("test_attribute") in text - assert ("TemplateError") in text + assert ("test_attribute") in caplog_setup_text + assert ("TemplateError") in caplog_setup_text @pytest.mark.parametrize("count,domain", [(1, binary_sensor.DOMAIN)]) @@ -458,13 +457,12 @@ async def test_invalid_attribute_template(hass, caplog, start_ha): ], ) async def test_invalid_availability_template_keeps_component_available( - hass, caplog, start_ha + hass, start_ha, caplog_setup_text ): """Test that an invalid availability keeps the device available.""" assert hass.states.get("binary_sensor.my_sensor").state != STATE_UNAVAILABLE - text = str([x.getMessage() for x in caplog.get_records("setup")]) - assert ("UndefinedError: \\'x\\' is undefined") in text + assert "UndefinedError: 'x' is undefined" in caplog_setup_text async def test_no_update_template_match_all(hass, caplog): diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py index 4c45ff1b150..242ac09d3d0 100644 --- a/tests/components/template/test_sensor.py +++ b/tests/components/template/test_sensor.py @@ -381,7 +381,7 @@ async def test_available_template_with_entities(hass, start_ha): }, ], ) -async def test_invalid_attribute_template(hass, caplog, start_ha): +async def test_invalid_attribute_template(hass, caplog, start_ha, caplog_setup_text): """Test that errors are logged if rendering template fails.""" hass.states.async_set("sensor.test_sensor", "startup") await hass.async_block_till_done() @@ -390,9 +390,7 @@ async def test_invalid_attribute_template(hass, caplog, start_ha): hass.bus.async_fire(EVENT_HOMEASSISTANT_START) await hass.async_block_till_done() await hass.helpers.entity_component.async_update_entity("sensor.invalid_template") - - messages = str([x.message for x in caplog.get_records("setup")]) - assert "TemplateError" in messages + assert "TemplateError" in caplog_setup_text assert "test_attribute" in caplog.text @@ -414,12 +412,11 @@ async def test_invalid_attribute_template(hass, caplog, start_ha): ], ) async def test_invalid_availability_template_keeps_component_available( - hass, caplog, start_ha + hass, start_ha, caplog_setup_text ): """Test that an invalid availability keeps the device available.""" assert hass.states.get("sensor.my_sensor").state != STATE_UNAVAILABLE - messages = str([x.message for x in caplog.get_records("setup")]) - assert "UndefinedError: \\'x\\' is undefined" in messages + assert "UndefinedError: 'x' is undefined" in caplog_setup_text async def test_no_template_match_all(hass, caplog): @@ -624,14 +621,12 @@ async def test_sun_renders_once_per_sensor(hass, start_ha): }, ], ) -async def test_self_referencing_sensor_loop(hass, caplog, start_ha): +async def test_self_referencing_sensor_loop(hass, start_ha, caplog_setup_text): """Test a self referencing sensor does not loop forever.""" assert len(hass.states.async_all()) == 1 await hass.async_block_till_done() await hass.async_block_till_done() - messages = str([x.message for x in caplog.get_records("setup")]) - assert "Template loop detected" in messages - + assert "Template loop detected" in caplog_setup_text assert int(hass.states.get("sensor.test").state) == 2 await hass.async_block_till_done() assert int(hass.states.get("sensor.test").state) == 2 @@ -654,13 +649,14 @@ async def test_self_referencing_sensor_loop(hass, caplog, start_ha): }, ], ) -async def test_self_referencing_sensor_with_icon_loop(hass, caplog, start_ha): +async def test_self_referencing_sensor_with_icon_loop( + hass, start_ha, caplog_setup_text +): """Test a self referencing sensor loops forever with a valid self referencing icon.""" assert len(hass.states.async_all()) == 1 await hass.async_block_till_done() await hass.async_block_till_done() - messages = str([x.message for x in caplog.get_records("setup")]) - assert "Template loop detected" in messages + assert "Template loop detected" in caplog_setup_text state = hass.states.get("sensor.test") assert int(state.state) == 3 @@ -689,14 +685,13 @@ async def test_self_referencing_sensor_with_icon_loop(hass, caplog, start_ha): ], ) async def test_self_referencing_sensor_with_icon_and_picture_entity_loop( - hass, caplog, start_ha + hass, start_ha, caplog_setup_text ): """Test a self referencing sensor loop forevers with a valid self referencing icon.""" assert len(hass.states.async_all()) == 1 await hass.async_block_till_done() await hass.async_block_till_done() - messages = str([x.message for x in caplog.get_records("setup")]) - assert "Template loop detected" in messages + assert "Template loop detected" in caplog_setup_text state = hass.states.get("sensor.test") assert int(state.state) == 4 @@ -724,7 +719,7 @@ async def test_self_referencing_sensor_with_icon_and_picture_entity_loop( }, ], ) -async def test_self_referencing_entity_picture_loop(hass, caplog, start_ha): +async def test_self_referencing_entity_picture_loop(hass, start_ha, caplog_setup_text): """Test a self referencing sensor does not loop forever with a looping self referencing entity picture.""" assert len(hass.states.async_all()) == 1 next_time = dt_util.utcnow() + timedelta(seconds=1.2) @@ -735,8 +730,7 @@ async def test_self_referencing_entity_picture_loop(hass, caplog, start_ha): await hass.async_block_till_done() await hass.async_block_till_done() - messages = str([x.message for x in caplog.get_records("setup")]) - assert "Template loop detected" in messages + assert "Template loop detected" in caplog_setup_text state = hass.states.get("sensor.test") assert int(state.state) == 1 @@ -1006,14 +1000,13 @@ async def test_trigger_entity_render_error(hass, start_ha): }, ], ) -async def test_trigger_not_allowed_platform_config(hass, caplog, start_ha): +async def test_trigger_not_allowed_platform_config(hass, start_ha, caplog_setup_text): """Test we throw a helpful warning if a trigger is configured in platform config.""" state = hass.states.get(TEST_NAME) assert state is None - messages = str([x.message for x in caplog.get_records("setup")]) assert ( "You can only add triggers to template entities if they are defined under `template:`." - in messages + in caplog_setup_text ) diff --git a/tests/components/template/test_vacuum.py b/tests/components/template/test_vacuum.py index 6e0252845d1..2bd6063b6ef 100644 --- a/tests/components/template/test_vacuum.py +++ b/tests/components/template/test_vacuum.py @@ -207,12 +207,11 @@ async def test_available_template_with_entities(hass, start_ha): ], ) async def test_invalid_availability_template_keeps_component_available( - hass, caplog, start_ha + hass, start_ha, caplog_setup_text ): """Test that an invalid availability keeps the device available.""" assert hass.states.get("vacuum.test_template_vacuum") != STATE_UNAVAILABLE - text = str([x.getMessage() for x in caplog.get_records("setup")]) - assert ("UndefinedError: \\'x\\' is undefined") in text + assert "UndefinedError: 'x' is undefined" in caplog_setup_text @pytest.mark.parametrize( @@ -275,13 +274,11 @@ async def test_attribute_templates(hass, start_ha): ) ], ) -async def test_invalid_attribute_template(hass, caplog, start_ha): +async def test_invalid_attribute_template(hass, start_ha, caplog_setup_text): """Test that errors are logged if rendering template fails.""" assert len(hass.states.async_all()) == 1 - - text = str([x.getMessage() for x in caplog.get_records("setup")]) - assert "test_attribute" in text - assert "TemplateError" in text + assert "test_attribute" in caplog_setup_text + assert "TemplateError" in caplog_setup_text @pytest.mark.parametrize( From 58da58c0087e789e4d38906e42b5630b00a5c8dc Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sat, 4 Sep 2021 22:06:50 +0200 Subject: [PATCH 219/843] Bump motion_blinds to 0.5.5 (#55710) --- homeassistant/components/motion_blinds/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/motion_blinds/manifest.json b/homeassistant/components/motion_blinds/manifest.json index 83007cf562c..b8e8add912d 100644 --- a/homeassistant/components/motion_blinds/manifest.json +++ b/homeassistant/components/motion_blinds/manifest.json @@ -3,7 +3,7 @@ "name": "Motion Blinds", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/motion_blinds", - "requirements": ["motionblinds==0.4.10"], + "requirements": ["motionblinds==0.5.5"], "codeowners": ["@starkillerOG"], "iot_class": "local_push" } diff --git a/requirements_all.txt b/requirements_all.txt index 112dea917c7..09cbb2d7fb8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -997,7 +997,7 @@ minio==4.0.9 mitemp_bt==0.0.3 # homeassistant.components.motion_blinds -motionblinds==0.4.10 +motionblinds==0.5.5 # homeassistant.components.motioneye motioneye-client==0.3.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 652eb373c52..59c06ce47f8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -568,7 +568,7 @@ millheater==0.5.2 minio==4.0.9 # homeassistant.components.motion_blinds -motionblinds==0.4.10 +motionblinds==0.5.5 # homeassistant.components.motioneye motioneye-client==0.3.11 From f5a543b220d79d8ed331b1888a22b8886e15365d Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 4 Sep 2021 22:16:01 +0200 Subject: [PATCH 220/843] Remove deprecated device_state_attributes (#55734) --- homeassistant/components/flipr/sensor.py | 7 ++----- homeassistant/components/gogogate2/sensor.py | 2 +- homeassistant/components/group/media_player.py | 2 +- homeassistant/components/nws/sensor.py | 6 +----- homeassistant/components/plaato/const.py | 2 +- homeassistant/components/plaato/entity.py | 4 ++-- 6 files changed, 8 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/flipr/sensor.py b/homeassistant/components/flipr/sensor.py index f9fd4e9633e..0d986114659 100644 --- a/homeassistant/components/flipr/sensor.py +++ b/homeassistant/components/flipr/sensor.py @@ -57,6 +57,8 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class FliprSensor(FliprEntity, SensorEntity): """Sensor representing FliprSensor data.""" + _attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} + @property def name(self): """Return the name of the particular component.""" @@ -84,8 +86,3 @@ class FliprSensor(FliprEntity, SensorEntity): def native_unit_of_measurement(self): """Return unit of measurement.""" return SENSORS[self.info_type]["unit"] - - @property - def device_state_attributes(self): - """Return device attributes.""" - return {ATTR_ATTRIBUTION: ATTRIBUTION} diff --git a/homeassistant/components/gogogate2/sensor.py b/homeassistant/components/gogogate2/sensor.py index a9be18d06a6..7ad248b88d6 100644 --- a/homeassistant/components/gogogate2/sensor.py +++ b/homeassistant/components/gogogate2/sensor.py @@ -121,7 +121,7 @@ class DoorSensorTemperature(GoGoGate2Entity, SensorEntity): return TEMP_CELSIUS @property - def device_state_attributes(self): + def extra_state_attributes(self): """Return the state attributes.""" door = self._get_door() if door.sensorid is not None: diff --git a/homeassistant/components/group/media_player.py b/homeassistant/components/group/media_player.py index 810959609b5..b7db9fa9631 100644 --- a/homeassistant/components/group/media_player.py +++ b/homeassistant/components/group/media_player.py @@ -205,7 +205,7 @@ class MediaGroup(MediaPlayerEntity): return False @property - def device_state_attributes(self) -> dict: + def extra_state_attributes(self) -> dict: """Return the state attributes for the media group.""" return {ATTR_ENTITY_ID: self._entities} diff --git a/homeassistant/components/nws/sensor.py b/homeassistant/components/nws/sensor.py index 85b60ffd475..4be99f95c19 100644 --- a/homeassistant/components/nws/sensor.py +++ b/homeassistant/components/nws/sensor.py @@ -55,6 +55,7 @@ class NWSSensor(CoordinatorEntity, SensorEntity): """An NWS Sensor Entity.""" entity_description: NWSSensorEntityDescription + _attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} def __init__( self, @@ -95,11 +96,6 @@ class NWSSensor(CoordinatorEntity, SensorEntity): return round(value) return value - @property - def device_state_attributes(self): - """Return the attribution.""" - return {ATTR_ATTRIBUTION: ATTRIBUTION} - @property def unique_id(self): """Return a unique_id for this entity.""" diff --git a/homeassistant/components/plaato/const.py b/homeassistant/components/plaato/const.py index 1700b803775..2d8cf40c91e 100644 --- a/homeassistant/components/plaato/const.py +++ b/homeassistant/components/plaato/const.py @@ -26,7 +26,7 @@ UNDO_UPDATE_LISTENER = "undo_update_listener" DEFAULT_SCAN_INTERVAL = 5 MIN_UPDATE_INTERVAL = timedelta(minutes=1) -DEVICE_STATE_ATTRIBUTES = { +EXTRA_STATE_ATTRIBUTES = { "beer_name": "beer_name", "keg_date": "keg_date", "mode": "mode", diff --git a/homeassistant/components/plaato/entity.py b/homeassistant/components/plaato/entity.py index a28dfefb567..3c04c5d597d 100644 --- a/homeassistant/components/plaato/entity.py +++ b/homeassistant/components/plaato/entity.py @@ -7,9 +7,9 @@ from .const import ( DEVICE, DEVICE_ID, DEVICE_NAME, - DEVICE_STATE_ATTRIBUTES, DEVICE_TYPE, DOMAIN, + EXTRA_STATE_ATTRIBUTES, SENSOR_DATA, SENSOR_SIGNAL, ) @@ -73,7 +73,7 @@ class PlaatoEntity(entity.Entity): if self._attributes: return { attr_key: self._attributes[plaato_key] - for attr_key, plaato_key in DEVICE_STATE_ATTRIBUTES.items() + for attr_key, plaato_key in EXTRA_STATE_ATTRIBUTES.items() if plaato_key in self._attributes and self._attributes[plaato_key] is not None } From c81a319346c4dae678ec77de7243e4eac3d96d0a Mon Sep 17 00:00:00 2001 From: Brian Egge Date: Sat, 4 Sep 2021 16:17:57 -0400 Subject: [PATCH 221/843] Handle unknown preset mode in generic thermostat (#55588) Co-authored-by: Paulus Schoutsen --- .../components/generic_thermostat/climate.py | 34 +++++++++---------- .../generic_thermostat/test_climate.py | 15 ++++++++ 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/generic_thermostat/climate.py b/homeassistant/components/generic_thermostat/climate.py index e83852d122f..a659d13cb7e 100644 --- a/homeassistant/components/generic_thermostat/climate.py +++ b/homeassistant/components/generic_thermostat/climate.py @@ -182,14 +182,17 @@ class GenericThermostat(ClimateEntity, RestoreEntity): self._temp_lock = asyncio.Lock() self._min_temp = min_temp self._max_temp = max_temp + self._attr_preset_mode = PRESET_NONE self._target_temp = target_temp self._unit = unit self._unique_id = unique_id self._support_flags = SUPPORT_FLAGS if away_temp: self._support_flags = SUPPORT_FLAGS | SUPPORT_PRESET_MODE + self._attr_preset_modes = [PRESET_NONE, PRESET_AWAY] + else: + self._attr_preset_modes = [PRESET_NONE] self._away_temp = away_temp - self._is_away = False async def async_added_to_hass(self): """Run when entity about to be added.""" @@ -247,8 +250,8 @@ class GenericThermostat(ClimateEntity, RestoreEntity): ) else: self._target_temp = float(old_state.attributes[ATTR_TEMPERATURE]) - if old_state.attributes.get(ATTR_PRESET_MODE) == PRESET_AWAY: - self._is_away = True + if old_state.attributes.get(ATTR_PRESET_MODE) in self._attr_preset_modes: + self._attr_preset_mode = old_state.attributes.get(ATTR_PRESET_MODE) if not self._hvac_mode and old_state.state: self._hvac_mode = old_state.state @@ -343,16 +346,6 @@ class GenericThermostat(ClimateEntity, RestoreEntity): """List of available operation modes.""" return self._hvac_list - @property - def preset_mode(self): - """Return the current preset mode, e.g., home, away, temp.""" - return PRESET_AWAY if self._is_away else PRESET_NONE - - @property - def preset_modes(self): - """Return a list of available preset modes or PRESET_NONE if _away_temp is undefined.""" - return [PRESET_NONE, PRESET_AWAY] if self._away_temp else PRESET_NONE - async def async_set_hvac_mode(self, hvac_mode): """Set hvac mode.""" if hvac_mode == HVAC_MODE_HEAT: @@ -521,13 +514,20 @@ class GenericThermostat(ClimateEntity, RestoreEntity): async def async_set_preset_mode(self, preset_mode: str): """Set new preset mode.""" - if preset_mode == PRESET_AWAY and not self._is_away: - self._is_away = True + if preset_mode not in (self._attr_preset_modes or []): + raise ValueError( + f"Got unsupported preset_mode {preset_mode}. Must be one of {self._attr_preset_modes}" + ) + if preset_mode == self._attr_preset_mode: + # I don't think we need to call async_write_ha_state if we didn't change the state + return + if preset_mode == PRESET_AWAY: + self._attr_preset_mode = PRESET_AWAY self._saved_target_temp = self._target_temp self._target_temp = self._away_temp await self._async_control_heating(force=True) - elif preset_mode == PRESET_NONE and self._is_away: - self._is_away = False + elif preset_mode == PRESET_NONE: + self._attr_preset_mode = PRESET_NONE self._target_temp = self._saved_target_temp await self._async_control_heating(force=True) diff --git a/tests/components/generic_thermostat/test_climate.py b/tests/components/generic_thermostat/test_climate.py index 4b9fbca41e2..7363ee8a32a 100644 --- a/tests/components/generic_thermostat/test_climate.py +++ b/tests/components/generic_thermostat/test_climate.py @@ -324,6 +324,21 @@ async def test_set_away_mode_twice_and_restore_prev_temp(hass, setup_comp_2): assert state.attributes.get("temperature") == 23 +async def test_set_preset_mode_invalid(hass, setup_comp_2): + """Test an invalid mode raises an error and ignore case when checking modes.""" + await common.async_set_temperature(hass, 23) + await common.async_set_preset_mode(hass, "away") + state = hass.states.get(ENTITY) + assert state.attributes.get("preset_mode") == "away" + await common.async_set_preset_mode(hass, "none") + state = hass.states.get(ENTITY) + assert state.attributes.get("preset_mode") == "none" + with pytest.raises(ValueError): + await common.async_set_preset_mode(hass, "Sleep") + state = hass.states.get(ENTITY) + assert state.attributes.get("preset_mode") == "none" + + async def test_sensor_bad_value(hass, setup_comp_2): """Test sensor that have None as state.""" state = hass.states.get(ENTITY) From 715ce3185bb3a36a3629ddda8f7836c09620f70e Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sat, 4 Sep 2021 22:56:59 +0200 Subject: [PATCH 222/843] Handle Fritz InternalError (#55711) --- homeassistant/components/fritz/sensor.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py index bc579b1125e..53efc7a83f3 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -9,6 +9,7 @@ from fritzconnection.core.exceptions import ( FritzActionError, FritzActionFailedError, FritzConnectionException, + FritzInternalError, FritzServiceError, ) from fritzconnection.lib.fritzstatus import FritzStatus @@ -273,7 +274,12 @@ async def async_setup_entry( "GetInfo", ) dsl = dslinterface["NewEnable"] - except (FritzActionError, FritzActionFailedError, FritzServiceError): + except ( + FritzInternalError, + FritzActionError, + FritzActionFailedError, + FritzServiceError, + ): pass for sensor_type, sensor_data in SENSOR_DATA.items(): From d39b86111071d18c095d742f5294e95642bc488c Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sat, 4 Sep 2021 22:58:34 +0200 Subject: [PATCH 223/843] Fix SamsungTV sendkey when not connected (#55723) --- homeassistant/components/samsungtv/bridge.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/samsungtv/bridge.py b/homeassistant/components/samsungtv/bridge.py index 095d3339428..0d00a0cb94f 100644 --- a/homeassistant/components/samsungtv/bridge.py +++ b/homeassistant/components/samsungtv/bridge.py @@ -240,7 +240,8 @@ class SamsungTVLegacyBridge(SamsungTVBridge): def _send_key(self, key): """Send the key using legacy protocol.""" - self._get_remote().control(key) + if remote := self._get_remote(): + remote.control(key) def stop(self): """Stop Bridge.""" @@ -315,7 +316,8 @@ class SamsungTVWSBridge(SamsungTVBridge): """Send the key using websocket protocol.""" if key == "KEY_POWEROFF": key = "KEY_POWER" - self._get_remote().send_key(key) + if remote := self._get_remote(): + remote.send_key(key) def _get_remote(self, avoid_open: bool = False): """Create or return a remote control instance.""" From cce0ca5688ad5085776253dfcf85a57da2f156f4 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 4 Sep 2021 15:38:14 -0700 Subject: [PATCH 224/843] Tag Hue errors as format strings (#55751) --- homeassistant/components/hue/device_trigger.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hue/device_trigger.py b/homeassistant/components/hue/device_trigger.py index 77561e47dc5..5af68b9d769 100644 --- a/homeassistant/components/hue/device_trigger.py +++ b/homeassistant/components/hue/device_trigger.py @@ -119,7 +119,9 @@ async def async_validate_trigger_config(hass, config): trigger = (config[CONF_TYPE], config[CONF_SUBTYPE]) if not device: - raise InvalidDeviceAutomationConfig("Device {config[CONF_DEVICE_ID]} not found") + raise InvalidDeviceAutomationConfig( + f"Device {config[CONF_DEVICE_ID]} not found" + ) if device.model not in REMOTES: raise InvalidDeviceAutomationConfig( @@ -127,7 +129,9 @@ async def async_validate_trigger_config(hass, config): ) if trigger not in REMOTES[device.model]: - raise InvalidDeviceAutomationConfig("Device does not support trigger {trigger}") + raise InvalidDeviceAutomationConfig( + f"Device does not support trigger {trigger}" + ) return config From f8ebc315763a677da1671d787634e99ce1e2221b Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sun, 5 Sep 2021 00:11:36 +0000 Subject: [PATCH 225/843] [ci skip] Translation update --- .../components/cloud/translations/he.json | 3 ++- .../components/homekit/translations/te.json | 11 +++++++++++ .../components/hyperion/translations/he.json | 2 +- .../components/iotawatt/translations/te.json | 17 +++++++++++++++++ .../components/nanoleaf/translations/te.json | 10 ++++++++++ .../components/openuv/translations/te.json | 13 +++++++++++++ .../components/renault/translations/ca.json | 10 +++++++++- .../components/renault/translations/en.json | 10 +++++++++- .../components/renault/translations/et.json | 10 +++++++++- .../components/renault/translations/he.json | 10 +++++++++- .../components/renault/translations/ru.json | 10 +++++++++- .../components/renault/translations/te.json | 12 ++++++++++++ .../renault/translations/zh-Hans.json | 10 +++++++++- .../renault/translations/zh-Hant.json | 10 +++++++++- .../components/shelly/translations/he.json | 2 +- .../components/tplink/translations/he.json | 2 +- 16 files changed, 131 insertions(+), 11 deletions(-) create mode 100644 homeassistant/components/homekit/translations/te.json create mode 100644 homeassistant/components/iotawatt/translations/te.json create mode 100644 homeassistant/components/nanoleaf/translations/te.json create mode 100644 homeassistant/components/openuv/translations/te.json create mode 100644 homeassistant/components/renault/translations/te.json diff --git a/homeassistant/components/cloud/translations/he.json b/homeassistant/components/cloud/translations/he.json index 9ea65e73c4e..79b550b23e2 100644 --- a/homeassistant/components/cloud/translations/he.json +++ b/homeassistant/components/cloud/translations/he.json @@ -3,7 +3,8 @@ "info": { "alexa_enabled": "Alexa \u05de\u05d5\u05e4\u05e2\u05dc\u05ea", "google_enabled": "Google \u05de\u05d5\u05e4\u05e2\u05dc", - "logged_in": "\u05de\u05d7\u05d5\u05d1\u05e8" + "logged_in": "\u05de\u05d7\u05d5\u05d1\u05e8", + "remote_server": "\u05e9\u05e8\u05ea \u05de\u05e8\u05d5\u05d7\u05e7" } } } \ No newline at end of file diff --git a/homeassistant/components/homekit/translations/te.json b/homeassistant/components/homekit/translations/te.json new file mode 100644 index 00000000000..3ad5c6451b1 --- /dev/null +++ b/homeassistant/components/homekit/translations/te.json @@ -0,0 +1,11 @@ +{ + "options": { + "step": { + "advanced": { + "data": { + "devices": "\u0c2a\u0c30\u0c3f\u0c15\u0c30\u0c3e\u0c32\u0c41 (\u0c1f\u0c4d\u0c30\u0c3f\u0c17\u0c4d\u0c17\u0c30\u0c4d\u0c32\u0c41)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hyperion/translations/he.json b/homeassistant/components/hyperion/translations/he.json index dd22953025f..a48e41ec0d2 100644 --- a/homeassistant/components/hyperion/translations/he.json +++ b/homeassistant/components/hyperion/translations/he.json @@ -12,7 +12,7 @@ }, "step": { "confirm": { - "description": "\u05d4\u05d0\u05dd \u05d0\u05ea\u05d4 \u05e8\u05d5\u05e6\u05d4 \u05dc\u05d4\u05d5\u05e1\u05d9\u05e3 \u05d0\u05ea Hyperion Ambilight \u05d4\u05d6\u05d4 \u05dc-Home Assistant?\n\n**\u05de\u05d0\u05e8\u05d7:** {host}\n**\u05e4\u05ea\u05d7\u05d4:** {port}\n**\u05de\u05d6\u05d4\u05d4**: {id}" + "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05d5\u05e1\u05d9\u05e3 \u05d0\u05ea Hyperion Ambilight \u05d4\u05d6\u05d4 \u05dc-Home Assistant?\n\n**\u05de\u05d0\u05e8\u05d7:** {host}\n**\u05e4\u05ea\u05d7\u05d4:** {port}\n**\u05de\u05d6\u05d4\u05d4**: {id}" }, "user": { "data": { diff --git a/homeassistant/components/iotawatt/translations/te.json b/homeassistant/components/iotawatt/translations/te.json new file mode 100644 index 00000000000..1f494ec8005 --- /dev/null +++ b/homeassistant/components/iotawatt/translations/te.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "\u0c15\u0c28\u0c46\u0c15\u0c4d\u0c1f\u0c4d \u0c05\u0c35\u0c4d\u0c35\u0c21\u0c02 \u0c15\u0c41\u0c26\u0c30\u0c32\u0c47\u0c26\u0c41", + "invalid_auth": "\u0c38\u0c30\u0c3f\u0c15\u0c3e\u0c28\u0c3f \u0c2a\u0c4d\u0c30\u0c3e\u0c2e\u0c3e\u0c23\u0c3f\u0c15\u0c02", + "unknown": "\u0c05\u0c28\u0c41\u0c15\u0c4b\u0c28\u0c3f \u0c32\u0c4b\u0c2a\u0c02 " + }, + "step": { + "auth": { + "data": { + "username": "\u0c35\u0c3f\u0c28\u0c3f\u0c2f\u0c4b\u0c17\u0c26\u0c3e\u0c30\u0c41\u0c28\u0c3f \u0c2a\u0c47\u0c30\u0c41 " + }, + "description": "IoTawatt \u0c2a\u0c30\u0c3f\u0c15\u0c30\u0c3e\u0c28\u0c3f\u0c15\u0c3f \u0c2a\u0c4d\u0c30\u0c3e\u0c2e\u0c3e\u0c23\u0c40\u0c15\u0c30\u0c23 \u0c05\u0c35\u0c38\u0c30\u0c02. \u0c26\u0c2f\u0c1a\u0c47\u0c38\u0c3f \u0c35\u0c3f\u0c28\u0c3f\u0c2f\u0c4b\u0c17\u0c26\u0c3e\u0c30\u0c41 \u0c2a\u0c47\u0c30\u0c41 \u0c2e\u0c30\u0c3f\u0c2f\u0c41 \u0c2a\u0c3e\u0c38\u0c4d\u200c\u0c35\u0c30\u0c4d\u0c21\u0c4d\u200c\u0c28\u0c41 \u0c28\u0c2e\u0c4b\u0c26\u0c41 \u0c1a\u0c47\u0c38\u0c3f, \u0c38\u0c2e\u0c30\u0c4d\u0c2a\u0c3f\u0c02\u0c1a\u0c41 \u0c2c\u0c1f\u0c28\u0c4d\u200c\u0c28\u0c3f \u0c15\u0c4d\u0c32\u0c3f\u0c15\u0c4d \u0c1a\u0c47\u0c2f\u0c02\u0c21\u0c3f." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nanoleaf/translations/te.json b/homeassistant/components/nanoleaf/translations/te.json new file mode 100644 index 00000000000..1ed2d9b78fe --- /dev/null +++ b/homeassistant/components/nanoleaf/translations/te.json @@ -0,0 +1,10 @@ +{ + "config": { + "step": { + "link": { + "description": "\u0c2e\u0c40 \u0c28\u0c3e\u0c28\u0c4b \u0c32\u0c40\u0c2b\u0c4d \u0c32\u0c4b\u0c28\u0c3f LED \u0c2c\u0c1f\u0c28\u0c4d\u0c32\u0c41 \u0c2b\u0c4d\u0c32\u0c3e\u0c37\u0c3f\u0c02\u0c17\u0c4d \u0c2a\u0c4d\u0c30\u0c3e\u0c30\u0c02\u0c2d\u0c2e\u0c2f\u0c4d\u0c2f\u0c47 \u0c35\u0c30\u0c15\u0c41 \u0c2a\u0c35\u0c30\u0c4d \u0c2c\u0c1f\u0c28\u0c4d\u200c\u0c28\u0c3f 5 \u0c38\u0c46\u0c15\u0c28\u0c4d\u0c32 \u0c2a\u0c3e\u0c1f\u0c41 \u0c28\u0c4a\u0c15\u0c4d\u0c15\u0c3f \u0c09\u0c02\u0c1a\u0c02\u0c21\u0c3f, \u0c06\u0c2a\u0c48 30 \u0c38\u0c46\u0c15\u0c28\u0c4d\u0c32\u0c32\u0c4b ** \u0c38\u0c2c\u0c4d\u0c2e\u0c3f\u0c1f\u0c4d ** \u0c15\u0c4d\u0c32\u0c3f\u0c15\u0c4d \u0c1a\u0c47\u0c2f\u0c02\u0c21\u0c3f.", + "title": "\u0c28\u0c3e\u0c28\u0c4b\u0c32\u0c40\u0c2b\u0c4d\u200c\u0c28\u0c3f \u0c32\u0c3f\u0c02\u0c15\u0c4d \u0c1a\u0c47\u0c2f\u0c02\u0c21\u0c3f" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/openuv/translations/te.json b/homeassistant/components/openuv/translations/te.json new file mode 100644 index 00000000000..56a9eae6ee6 --- /dev/null +++ b/homeassistant/components/openuv/translations/te.json @@ -0,0 +1,13 @@ +{ + "options": { + "step": { + "init": { + "data": { + "from_window": "\u0c30\u0c15\u0c4d\u0c37\u0c23 \u0c35\u0c3f\u0c02\u0c21\u0c4b \u0c15\u0c4b\u0c38\u0c02 UV \u0c38\u0c42\u0c1a\u0c3f\u0c15\u0c28\u0c41 \u0c2a\u0c4d\u0c30\u0c3e\u0c30\u0c02\u0c2d\u0c3f\u0c38\u0c4d\u0c24\u0c4b\u0c02\u0c26\u0c3f", + "to_window": "\u0c30\u0c15\u0c4d\u0c37\u0c23 \u0c35\u0c3f\u0c02\u0c21\u0c4b \u0c15\u0c4a\u0c30\u0c15\u0c41 \u0c2f\u0c41\u0c35\u0c3f \u0c07\u0c02\u0c21\u0c46\u0c15\u0c4d\u0c38\u0c4d \u0c28\u0c3f \u0c2e\u0c41\u0c17\u0c3f\u0c38\u0c4d\u0c24\u0c41\u0c02\u0c26\u0c3f" + }, + "title": "OpenUV \u0c28\u0c3f \u0c15\u0c3e\u0c28\u0c4d\u0c2b\u0c3f\u0c17\u0c30\u0c4d \u0c1a\u0c47\u0c2f\u0c02\u0c21\u0c3f" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/renault/translations/ca.json b/homeassistant/components/renault/translations/ca.json index 8315d35b87b..4aacab5cfc8 100644 --- a/homeassistant/components/renault/translations/ca.json +++ b/homeassistant/components/renault/translations/ca.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "El compte ja ha estat configurat", - "kamereon_no_account": "No s'ha pogut trobar cap compte Kamereon." + "kamereon_no_account": "No s'ha pogut trobar cap compte Kamereon", + "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" }, "error": { "invalid_credentials": "Autenticaci\u00f3 inv\u00e0lida" @@ -14,6 +15,13 @@ }, "title": "Seleccioneu l'ID del compte Kamereon" }, + "reauth_confirm": { + "data": { + "password": "Contrasenya" + }, + "description": "Actualitza la contrasenya de l'usuari {username}", + "title": "Reautenticaci\u00f3 de la integraci\u00f3" + }, "user": { "data": { "locale": "Llengua/regi\u00f3", diff --git a/homeassistant/components/renault/translations/en.json b/homeassistant/components/renault/translations/en.json index 87186e6f59c..104a3f0ba64 100644 --- a/homeassistant/components/renault/translations/en.json +++ b/homeassistant/components/renault/translations/en.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "Account is already configured", - "kamereon_no_account": "Unable to find Kamereon account." + "kamereon_no_account": "Unable to find Kamereon account", + "reauth_successful": "Re-authentication was successful" }, "error": { "invalid_credentials": "Invalid authentication" @@ -14,6 +15,13 @@ }, "title": "Select Kamereon account id" }, + "reauth_confirm": { + "data": { + "password": "Password" + }, + "description": "Please update your password for {username}", + "title": "Reauthenticate Integration" + }, "user": { "data": { "locale": "Locale", diff --git a/homeassistant/components/renault/translations/et.json b/homeassistant/components/renault/translations/et.json index bae0db1aed7..464c27d2ecc 100644 --- a/homeassistant/components/renault/translations/et.json +++ b/homeassistant/components/renault/translations/et.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "Konto on juba h\u00e4\u00e4lestatud", - "kamereon_no_account": "Kamereoni kontot ei leitud." + "kamereon_no_account": "Kamereoni kontot ei leitud.", + "reauth_successful": "Taastuvastamine \u00f5nnestus" }, "error": { "invalid_credentials": "Tuvastamine nurjus" @@ -14,6 +15,13 @@ }, "title": "Vali Kamereoni konto ID" }, + "reauth_confirm": { + "data": { + "password": "Salas\u00f5na" + }, + "description": "Uuenda kasutaja {username} salas\u00f5na.", + "title": "Sidumise taastuvastamine" + }, "user": { "data": { "locale": "Riigi kood (n\u00e4iteks EE)", diff --git a/homeassistant/components/renault/translations/he.json b/homeassistant/components/renault/translations/he.json index 25cec1032e9..1518df4599e 100644 --- a/homeassistant/components/renault/translations/he.json +++ b/homeassistant/components/renault/translations/he.json @@ -1,12 +1,20 @@ { "config": { "abort": { - "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "reauth_successful": "\u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7" }, "error": { "invalid_credentials": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9" }, "step": { + "reauth_confirm": { + "data": { + "password": "\u05e1\u05d9\u05e1\u05de\u05d4" + }, + "description": "\u05e0\u05d0 \u05dc\u05e2\u05d3\u05db\u05df \u05d0\u05ea \u05d4\u05e1\u05d9\u05e1\u05de\u05d4 \u05e9\u05dc\u05da \u05e2\u05d1\u05d5\u05e8 {username}", + "title": "\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05e9\u05dc \u05e9\u05d9\u05dc\u05d5\u05d1" + }, "user": { "data": { "password": "\u05e1\u05d9\u05e1\u05de\u05d4", diff --git a/homeassistant/components/renault/translations/ru.json b/homeassistant/components/renault/translations/ru.json index 822d42b6117..65f3a4ffbae 100644 --- a/homeassistant/components/renault/translations/ru.json +++ b/homeassistant/components/renault/translations/ru.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "\u042d\u0442\u0430 \u0443\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430 \u0432 Home Assistant.", - "kamereon_no_account": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043d\u0430\u0439\u0442\u0438 \u0443\u0447\u0435\u0442\u043d\u0443\u044e \u0437\u0430\u043f\u0438\u0441\u044c Kamereon." + "kamereon_no_account": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043d\u0430\u0439\u0442\u0438 \u0443\u0447\u0435\u0442\u043d\u0443\u044e \u0437\u0430\u043f\u0438\u0441\u044c Kamereon.", + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430 \u0443\u0441\u043f\u0435\u0448\u043d\u043e." }, "error": { "invalid_credentials": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438." @@ -14,6 +15,13 @@ }, "title": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 ID \u0443\u0447\u0435\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438 Kamereon" }, + "reauth_confirm": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u044c" + }, + "description": "\u041e\u0431\u043d\u043e\u0432\u0438\u0442\u0435 \u043f\u0430\u0440\u043e\u043b\u044c \u0434\u043b\u044f \u0443\u0447\u0451\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438 {username}", + "title": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f" + }, "user": { "data": { "locale": "\u0420\u0435\u0433\u0438\u043e\u043d", diff --git a/homeassistant/components/renault/translations/te.json b/homeassistant/components/renault/translations/te.json new file mode 100644 index 00000000000..56becef9dee --- /dev/null +++ b/homeassistant/components/renault/translations/te.json @@ -0,0 +1,12 @@ +{ + "config": { + "abort": { + "reauth_successful": "\u0c2a\u0c41\u0c28\u0c03-\u0c2a\u0c4d\u0c30\u0c3e\u0c2e\u0c3e\u0c23\u0c3f\u0c15\u0c02 \u0c35\u0c3f\u0c1c\u0c2f\u0c35\u0c02\u0c24\u0c2e\u0c2f\u0c3f\u0c02\u0c26\u0c3f" + }, + "step": { + "reauth_confirm": { + "description": "{\u0c2f\u0c42\u0c1c\u0c30\u0c4d \u0c28\u0c47\u0c2e\u0c4d} \u0c15\u0c4a\u0c30\u0c15\u0c41 \u0c26\u0c2f\u0c1a\u0c47\u0c38\u0c3f \u0c2e\u0c40 \u0c2a\u0c3e\u0c38\u0c4d \u0c35\u0c30\u0c4d\u0c21\u0c4d \u0c28\u0c3f \u0c05\u0c2a\u0c4d \u0c21\u0c47\u0c1f\u0c4d \u0c1a\u0c47\u0c2f\u0c02\u0c21\u0c3f" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/renault/translations/zh-Hans.json b/homeassistant/components/renault/translations/zh-Hans.json index ab8c60ed030..41538c06523 100644 --- a/homeassistant/components/renault/translations/zh-Hans.json +++ b/homeassistant/components/renault/translations/zh-Hans.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "\u6b64\u8d26\u53f7\u5df2\u88ab\u914d\u7f6e", - "kamereon_no_account": "\u65e0\u6cd5\u627e\u5230 Kamereon \u5e10\u6237" + "kamereon_no_account": "\u65e0\u6cd5\u627e\u5230 Kamereon \u5e10\u6237", + "reauth_successful": "\u91cd\u65b0\u9a8c\u8bc1\u6210\u529f" }, "error": { "invalid_credentials": "\u65e0\u6548\u8ba4\u8bc1" @@ -14,6 +15,13 @@ }, "title": "\u9009\u62e9 Kamereon \u8d26\u53f7 ID" }, + "reauth_confirm": { + "data": { + "password": "\u5bc6\u7801" + }, + "description": "\u8bf7\u66f4\u65b0 {username}\u7684\u5bc6\u7801", + "title": "\u91cd\u65b0\u9a8c\u8bc1" + }, "user": { "data": { "locale": "\u5730\u533a", diff --git a/homeassistant/components/renault/translations/zh-Hant.json b/homeassistant/components/renault/translations/zh-Hant.json index 4ae5413499d..a423d9d2359 100644 --- a/homeassistant/components/renault/translations/zh-Hant.json +++ b/homeassistant/components/renault/translations/zh-Hant.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", - "kamereon_no_account": "\u627e\u4e0d\u5230 Kamereon \u5e33\u865f\u3002" + "kamereon_no_account": "\u627e\u4e0d\u5230 Kamereon \u5e33\u865f", + "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f" }, "error": { "invalid_credentials": "\u9a57\u8b49\u78bc\u7121\u6548" @@ -14,6 +15,13 @@ }, "title": "\u9078\u64c7 Kamereon \u5e33\u865f ID" }, + "reauth_confirm": { + "data": { + "password": "\u5bc6\u78bc" + }, + "description": "\u8acb\u66f4\u65b0 {username} \u5bc6\u78bc", + "title": "\u91cd\u65b0\u8a8d\u8b49\u6574\u5408" + }, "user": { "data": { "locale": "\u4f4d\u7f6e", diff --git a/homeassistant/components/shelly/translations/he.json b/homeassistant/components/shelly/translations/he.json index 44d5897f85d..a27b19c08e7 100644 --- a/homeassistant/components/shelly/translations/he.json +++ b/homeassistant/components/shelly/translations/he.json @@ -11,7 +11,7 @@ "flow_title": "{name}", "step": { "confirm_discovery": { - "description": "\u05d4\u05d0\u05dd \u05d0\u05ea\u05d4 \u05e8\u05d5\u05e6\u05d4 \u05dc\u05d4\u05d2\u05d3\u05d9\u05e8 \u05d0\u05ea {model} \u05d1-{host}? \n\n\u05d9\u05e9 \u05dc\u05d4\u05e2\u05d9\u05e8 \u05d4\u05ea\u05e7\u05e0\u05d9\u05dd \u05d4\u05de\u05d5\u05e4\u05e2\u05dc\u05d9\u05dd \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea \u05e1\u05d5\u05dc\u05dc\u05d4 \u05d4\u05de\u05d5\u05d2\u05e0\u05d9\u05dd \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea \u05e1\u05d9\u05e1\u05de\u05d4 \u05dc\u05e4\u05e0\u05d9 \u05e9\u05de\u05de\u05e9\u05d9\u05db\u05d9\u05dd \u05d1\u05d4\u05d2\u05d3\u05e8\u05d4.\n\u05d4\u05ea\u05e7\u05e0\u05d9\u05dd \u05d4\u05de\u05d5\u05e4\u05e2\u05dc\u05d9\u05dd \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea \u05e1\u05d5\u05dc\u05dc\u05d4 \u05e9\u05d0\u05d9\u05e0\u05dd \u05de\u05d5\u05d2\u05e0\u05d9\u05dd \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea \u05e1\u05d9\u05e1\u05de\u05d4 \u05d9\u05ea\u05d5\u05d5\u05e1\u05e4\u05d5 \u05db\u05d0\u05e9\u05e8 \u05d4\u05d4\u05ea\u05e7\u05df \u05d9\u05ea\u05e2\u05d5\u05e8\u05e8, \u05db\u05e2\u05ea \u05e0\u05d9\u05ea\u05df \u05dc\u05d4\u05e2\u05d9\u05e8 \u05d0\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05d1\u05d0\u05d5\u05e4\u05df \u05d9\u05d3\u05e0\u05d9 \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea \u05dc\u05d7\u05d9\u05e6\u05d4 \u05e2\u05dc\u05d9\u05d5 \u05d0\u05d5 \u05dc\u05d7\u05db\u05d5\u05ea \u05dc\u05e2\u05d3\u05db\u05d5\u05df \u05d4\u05e0\u05ea\u05d5\u05e0\u05d9\u05dd \u05d4\u05d1\u05d0 \u05de\u05d4\u05d4\u05ea\u05e7\u05df." + "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05d2\u05d3\u05d9\u05e8 \u05d0\u05ea {model} \u05d1-{host}? \n\n\u05d9\u05e9 \u05dc\u05d4\u05e2\u05d9\u05e8 \u05d4\u05ea\u05e7\u05e0\u05d9\u05dd \u05d4\u05de\u05d5\u05e4\u05e2\u05dc\u05d9\u05dd \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea \u05e1\u05d5\u05dc\u05dc\u05d4 \u05d4\u05de\u05d5\u05d2\u05e0\u05d9\u05dd \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea \u05e1\u05d9\u05e1\u05de\u05d4 \u05dc\u05e4\u05e0\u05d9 \u05e9\u05de\u05de\u05e9\u05d9\u05db\u05d9\u05dd \u05d1\u05d4\u05d2\u05d3\u05e8\u05d4.\n\u05d4\u05ea\u05e7\u05e0\u05d9\u05dd \u05d4\u05de\u05d5\u05e4\u05e2\u05dc\u05d9\u05dd \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea \u05e1\u05d5\u05dc\u05dc\u05d4 \u05e9\u05d0\u05d9\u05e0\u05dd \u05de\u05d5\u05d2\u05e0\u05d9\u05dd \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea \u05e1\u05d9\u05e1\u05de\u05d4 \u05d9\u05ea\u05d5\u05d5\u05e1\u05e4\u05d5 \u05db\u05d0\u05e9\u05e8 \u05d4\u05d4\u05ea\u05e7\u05df \u05d9\u05ea\u05e2\u05d5\u05e8\u05e8, \u05db\u05e2\u05ea \u05e0\u05d9\u05ea\u05df \u05dc\u05d4\u05e2\u05d9\u05e8 \u05d0\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05d1\u05d0\u05d5\u05e4\u05df \u05d9\u05d3\u05e0\u05d9 \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea \u05dc\u05d7\u05d9\u05e6\u05d4 \u05e2\u05dc\u05d9\u05d5 \u05d0\u05d5 \u05dc\u05d7\u05db\u05d5\u05ea \u05dc\u05e2\u05d3\u05db\u05d5\u05df \u05d4\u05e0\u05ea\u05d5\u05e0\u05d9\u05dd \u05d4\u05d1\u05d0 \u05de\u05d4\u05d4\u05ea\u05e7\u05df." }, "credentials": { "data": { diff --git a/homeassistant/components/tplink/translations/he.json b/homeassistant/components/tplink/translations/he.json index 888c65226dc..053fc43039a 100644 --- a/homeassistant/components/tplink/translations/he.json +++ b/homeassistant/components/tplink/translations/he.json @@ -6,7 +6,7 @@ }, "step": { "confirm": { - "description": "\u05d4\u05d0\u05dd \u05d0\u05ea\u05d4 \u05e8\u05d5\u05e6\u05d4 \u05dc\u05d4\u05d2\u05d3\u05d9\u05e8 \u05d4\u05ea\u05e7\u05e0\u05d9\u05dd \u05d7\u05db\u05de\u05d9\u05dd \u05e9\u05dc TP-Link ?" + "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05d2\u05d3\u05d9\u05e8 \u05d4\u05ea\u05e7\u05e0\u05d9\u05dd \u05d7\u05db\u05de\u05d9\u05dd \u05e9\u05dc TP-Link ?" } } } From 5a2bcd27631c483c2636f918fe312d5090530985 Mon Sep 17 00:00:00 2001 From: Chris Browet Date: Sun, 5 Sep 2021 12:41:39 +0200 Subject: [PATCH 226/843] ADD: generalize regex_findall (#54584) --- homeassistant/helpers/template.py | 8 +++++++- tests/helpers/test_template.py | 11 +++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 4e9f9c432e0..3580af3e2bd 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -1397,10 +1397,15 @@ def regex_search(value, find="", ignorecase=False): def regex_findall_index(value, find="", index=0, ignorecase=False): """Find all matches using regex and then pick specific match index.""" + return regex_findall(value, find, ignorecase)[index] + + +def regex_findall(value, find="", ignorecase=False): + """Find all matches using regex.""" if not isinstance(value, str): value = str(value) flags = re.I if ignorecase else 0 - return re.findall(find, value, flags)[index] + return re.findall(find, value, flags) def bitwise_and(first_value, second_value): @@ -1565,6 +1570,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.filters["regex_match"] = regex_match self.filters["regex_replace"] = regex_replace self.filters["regex_search"] = regex_search + self.filters["regex_findall"] = regex_findall self.filters["regex_findall_index"] = regex_findall_index self.filters["bitwise_and"] = bitwise_and self.filters["bitwise_or"] = bitwise_or diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index d10ef114992..efdcebf70e1 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -1110,6 +1110,17 @@ def test_regex_replace(hass): assert tpl.async_render() == ["Home Assistant test"] +def test_regex_findall(hass): + """Test regex_findall method.""" + tpl = template.Template( + """ +{{ 'Flight from JFK to LHR' | regex_findall('([A-Z]{3})') }} + """, + hass, + ) + assert tpl.async_render() == ["JFK", "LHR"] + + def test_regex_findall_index(hass): """Test regex_findall_index method.""" tpl = template.Template( From 4e1e7a4a718396ccbdaa37b17c6f6410d12528c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Sun, 5 Sep 2021 21:42:22 +0300 Subject: [PATCH 227/843] Protect Huawei LTE against None ltedl/ulfreq (#54411) Refs https://github.com/home-assistant/core/issues/54400 --- homeassistant/components/huawei_lte/sensor.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py index 47987e5607e..746e44687ca 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -193,11 +193,17 @@ SENSOR_META: dict[str | tuple[str, str], SensorMeta] = { ), (KEY_DEVICE_SIGNAL, "ltedlfreq"): SensorMeta( name="Downlink frequency", - formatter=lambda x: (round(int(x) / 10), FREQUENCY_MEGAHERTZ), + formatter=lambda x: ( + round(int(x) / 10) if x is not None else None, + FREQUENCY_MEGAHERTZ, + ), ), (KEY_DEVICE_SIGNAL, "lteulfreq"): SensorMeta( name="Uplink frequency", - formatter=lambda x: (round(int(x) / 10), FREQUENCY_MEGAHERTZ), + formatter=lambda x: ( + round(int(x) / 10) if x is not None else None, + FREQUENCY_MEGAHERTZ, + ), ), KEY_MONITORING_CHECK_NOTIFICATIONS: SensorMeta( exclude=re.compile( From aa6cb84b27f3ca811b67f11ceb85c345c98e2f7e Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Sun, 5 Sep 2021 17:45:08 -0400 Subject: [PATCH 228/843] Optimize ZHA ZCL attribute reporting configuration (#55796) * Refactor ZCL attribute reporting configuration Configure up to 3 attributes in a single request. * Use constant for attribute reporting configuration * Update tests * Cleanup * Remove irrelevant for this PR section --- .../components/zha/core/channels/base.py | 99 +++++++++++++------ .../components/zha/core/channels/hvac.py | 93 +++-------------- homeassistant/components/zha/core/const.py | 1 + tests/components/zha/common.py | 22 ++++- tests/components/zha/test_channels.py | 28 +++++- 5 files changed, 131 insertions(+), 112 deletions(-) diff --git a/homeassistant/components/zha/core/channels/base.py b/homeassistant/components/zha/core/channels/base.py index e38e9e992da..64496b0b3bd 100644 --- a/homeassistant/components/zha/core/channels/base.py +++ b/homeassistant/components/zha/core/channels/base.py @@ -8,6 +8,7 @@ import logging from typing import Any import zigpy.exceptions +from zigpy.zcl.foundation import Status from homeassistant.const import ATTR_COMMAND from homeassistant.core import callback @@ -23,6 +24,7 @@ from ..const import ( ATTR_UNIQUE_ID, ATTR_VALUE, CHANNEL_ZDO, + REPORT_CONFIG_ATTR_PER_REQ, SIGNAL_ATTR_UPDATED, ZHA_CHANNEL_MSG, ZHA_CHANNEL_MSG_BIND, @@ -87,7 +89,7 @@ class ChannelStatus(Enum): class ZigbeeChannel(LogMixin): """Base channel for a Zigbee cluster.""" - REPORT_CONFIG = () + REPORT_CONFIG: tuple[dict[int | str, tuple[int, int, int | float]]] = () BIND: bool = True def __init__( @@ -101,9 +103,8 @@ class ZigbeeChannel(LogMixin): self._id = f"{ch_pool.id}:0x{cluster.cluster_id:04x}" unique_id = ch_pool.unique_id.replace("-", ":") self._unique_id = f"{unique_id}:0x{cluster.cluster_id:04x}" - self._report_config = self.REPORT_CONFIG - if not hasattr(self, "_value_attribute") and len(self._report_config) > 0: - attr = self._report_config[0].get("attr") + if not hasattr(self, "_value_attribute") and self.REPORT_CONFIG: + attr = self.REPORT_CONFIG[0].get("attr") if isinstance(attr, str): self.value_attribute = self.cluster.attridx.get(attr) else: @@ -195,42 +196,42 @@ class ZigbeeChannel(LogMixin): if self.cluster.cluster_id >= 0xFC00 and self._ch_pool.manufacturer_code: kwargs["manufacturer"] = self._ch_pool.manufacturer_code - for report in self._report_config: - attr = report["attr"] + for attr_report in self.REPORT_CONFIG: + attr, config = attr_report["attr"], attr_report["config"] attr_name = self.cluster.attributes.get(attr, [attr])[0] - min_report_int, max_report_int, reportable_change = report["config"] event_data[attr_name] = { - "min": min_report_int, - "max": max_report_int, + "min": config[0], + "max": config[1], "id": attr, "name": attr_name, - "change": reportable_change, + "change": config[2], + "success": False, } + to_configure = [*self.REPORT_CONFIG] + chunk, rest = ( + to_configure[:REPORT_CONFIG_ATTR_PER_REQ], + to_configure[REPORT_CONFIG_ATTR_PER_REQ:], + ) + while chunk: + reports = {rec["attr"]: rec["config"] for rec in chunk} try: - res = await self.cluster.configure_reporting( - attr, min_report_int, max_report_int, reportable_change, **kwargs - ) - self.debug( - "reporting '%s' attr on '%s' cluster: %d/%d/%d: Result: '%s'", - attr_name, - self.cluster.ep_attribute, - min_report_int, - max_report_int, - reportable_change, - res, - ) - event_data[attr_name]["success"] = ( - res[0][0].status == 0 or res[0][0].status == 134 - ) + res = await self.cluster.configure_reporting_multiple(reports, **kwargs) + self._configure_reporting_status(reports, res[0]) + # if we get a response, then it's a success + for attr_stat in event_data.values(): + attr_stat["success"] = True except (zigpy.exceptions.ZigbeeException, asyncio.TimeoutError) as ex: self.debug( - "failed to set reporting for '%s' attr on '%s' cluster: %s", - attr_name, + "failed to set reporting on '%s' cluster for: %s", self.cluster.ep_attribute, str(ex), ) - event_data[attr_name]["success"] = False + break + chunk, rest = ( + rest[:REPORT_CONFIG_ATTR_PER_REQ], + rest[REPORT_CONFIG_ATTR_PER_REQ:], + ) async_dispatcher_send( self._ch_pool.hass, @@ -245,6 +246,46 @@ class ZigbeeChannel(LogMixin): }, ) + def _configure_reporting_status( + self, attrs: dict[int | str, tuple], res: list | tuple + ) -> None: + """Parse configure reporting result.""" + if not isinstance(res, list): + # assume default response + self.debug( + "attr reporting for '%s' on '%s': %s", + attrs, + self.name, + res, + ) + return + if res[0].status == Status.SUCCESS and len(res) == 1: + self.debug( + "Successfully configured reporting for '%s' on '%s' cluster: %s", + attrs, + self.name, + res, + ) + return + + failed = [ + self.cluster.attributes.get(r.attrid, [r.attrid])[0] + for r in res + if r.status != Status.SUCCESS + ] + attrs = {self.cluster.attributes.get(r, [r])[0] for r in attrs} + self.debug( + "Successfully configured reporting for '%s' on '%s' cluster", + attrs - set(failed), + self.name, + ) + self.debug( + "Failed to configure reporting for '%s' on '%s' cluster: %s", + failed, + self.name, + res, + ) + async def async_configure(self) -> None: """Set cluster binding and attribute reporting.""" if not self._ch_pool.skip_configuration: @@ -267,7 +308,7 @@ class ZigbeeChannel(LogMixin): return self.debug("initializing channel: from_cache: %s", from_cache) - attributes = [cfg["attr"] for cfg in self._report_config] + attributes = [cfg["attr"] for cfg in self.REPORT_CONFIG] if attributes: await self.get_attributes(attributes, from_cache=from_cache) diff --git a/homeassistant/components/zha/core/channels/hvac.py b/homeassistant/components/zha/core/channels/hvac.py index 6b0cd9e5e28..f4a3245bef8 100644 --- a/homeassistant/components/zha/core/channels/hvac.py +++ b/homeassistant/components/zha/core/channels/hvac.py @@ -6,7 +6,6 @@ https://home-assistant.io/integrations/zha/ """ from __future__ import annotations -import asyncio from collections import namedtuple from typing import Any @@ -85,6 +84,20 @@ class Pump(ZigbeeChannel): class ThermostatChannel(ZigbeeChannel): """Thermostat channel.""" + REPORT_CONFIG = ( + {"attr": "local_temp", "config": REPORT_CONFIG_CLIMATE}, + {"attr": "occupied_cooling_setpoint", "config": REPORT_CONFIG_CLIMATE}, + {"attr": "occupied_heating_setpoint", "config": REPORT_CONFIG_CLIMATE}, + {"attr": "unoccupied_cooling_setpoint", "config": REPORT_CONFIG_CLIMATE}, + {"attr": "unoccupied_heating_setpoint", "config": REPORT_CONFIG_CLIMATE}, + {"attr": "running_mode", "config": REPORT_CONFIG_CLIMATE}, + {"attr": "running_state", "config": REPORT_CONFIG_CLIMATE_DEMAND}, + {"attr": "system_mode", "config": REPORT_CONFIG_CLIMATE}, + {"attr": "occupancy", "config": REPORT_CONFIG_CLIMATE_DISCRETE}, + {"attr": "pi_cooling_demand", "config": REPORT_CONFIG_CLIMATE_DEMAND}, + {"attr": "pi_heating_demand", "config": REPORT_CONFIG_CLIMATE_DEMAND}, + ) + def __init__( self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType ) -> None: @@ -132,19 +145,6 @@ class ThermostatChannel(ZigbeeChannel): self._system_mode = None self._unoccupied_cooling_setpoint = None self._unoccupied_heating_setpoint = None - self._report_config = [ - {"attr": "local_temp", "config": REPORT_CONFIG_CLIMATE}, - {"attr": "occupied_cooling_setpoint", "config": REPORT_CONFIG_CLIMATE}, - {"attr": "occupied_heating_setpoint", "config": REPORT_CONFIG_CLIMATE}, - {"attr": "unoccupied_cooling_setpoint", "config": REPORT_CONFIG_CLIMATE}, - {"attr": "unoccupied_heating_setpoint", "config": REPORT_CONFIG_CLIMATE}, - {"attr": "running_mode", "config": REPORT_CONFIG_CLIMATE}, - {"attr": "running_state", "config": REPORT_CONFIG_CLIMATE_DEMAND}, - {"attr": "system_mode", "config": REPORT_CONFIG_CLIMATE}, - {"attr": "occupancy", "config": REPORT_CONFIG_CLIMATE_DISCRETE}, - {"attr": "pi_cooling_demand", "config": REPORT_CONFIG_CLIMATE_DEMAND}, - {"attr": "pi_heating_demand", "config": REPORT_CONFIG_CLIMATE_DEMAND}, - ] @property def abs_max_cool_setpoint_limit(self) -> int: @@ -285,71 +285,6 @@ class ThermostatChannel(ZigbeeChannel): chunk, attrs = attrs[:4], attrs[4:] - async def configure_reporting(self): - """Configure attribute reporting for a cluster. - - This also swallows DeliveryError exceptions that are thrown when - devices are unreachable. - """ - kwargs = {} - if self.cluster.cluster_id >= 0xFC00 and self._ch_pool.manufacturer_code: - kwargs["manufacturer"] = self._ch_pool.manufacturer_code - - chunk, rest = self._report_config[:4], self._report_config[4:] - while chunk: - attrs = {record["attr"]: record["config"] for record in chunk} - try: - res = await self.cluster.configure_reporting_multiple(attrs, **kwargs) - self._configure_reporting_status(attrs, res[0]) - except (ZigbeeException, asyncio.TimeoutError) as ex: - self.debug( - "failed to set reporting on '%s' cluster for: %s", - self.cluster.ep_attribute, - str(ex), - ) - break - chunk, rest = rest[:4], rest[4:] - - def _configure_reporting_status( - self, attrs: dict[int | str, tuple], res: list | tuple - ) -> None: - """Parse configure reporting result.""" - if not isinstance(res, list): - # assume default response - self.debug( - "attr reporting for '%s' on '%s': %s", - attrs, - self.name, - res, - ) - return - if res[0].status == Status.SUCCESS and len(res) == 1: - self.debug( - "Successfully configured reporting for '%s' on '%s' cluster: %s", - attrs, - self.name, - res, - ) - return - - failed = [ - self.cluster.attributes.get(r.attrid, [r.attrid])[0] - for r in res - if r.status != Status.SUCCESS - ] - attrs = {self.cluster.attributes.get(r, [r])[0] for r in attrs} - self.debug( - "Successfully configured reporting for '%s' on '%s' cluster", - attrs - set(failed), - self.name, - ) - self.debug( - "Failed to configure reporting for '%s' on '%s' cluster: %s", - failed, - self.name, - res, - ) - @retryable_req(delays=(1, 1, 3)) async def async_initialize_channel_specific(self, from_cache: bool) -> None: """Initialize channel.""" diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index ecb65981637..76f025dd79a 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -287,6 +287,7 @@ class RadioType(enum.Enum): return self._desc +REPORT_CONFIG_ATTR_PER_REQ = 3 REPORT_CONFIG_MAX_INT = 900 REPORT_CONFIG_MAX_INT_BATTERY_SAVE = 10800 REPORT_CONFIG_MIN_INT = 30 diff --git a/tests/components/zha/common.py b/tests/components/zha/common.py index 5180e9dbc07..97890b287e8 100644 --- a/tests/components/zha/common.py +++ b/tests/components/zha/common.py @@ -1,5 +1,6 @@ """Common test objects.""" import asyncio +import math import time from unittest.mock import AsyncMock, Mock @@ -99,6 +100,9 @@ def patch_cluster(cluster): [zcl_f.ConfigureReportingResponseRecord(zcl_f.Status.SUCCESS, 0x00, 0xAABB)] ] ) + cluster.configure_reporting_multiple = AsyncMock( + return_value=zcl_f.ConfigureReportingResponse.deserialize(b"\x00")[0] + ) cluster.deserialize = Mock() cluster.handle_cluster_request = Mock() cluster.read_attributes = AsyncMock(wraps=cluster.read_attributes) @@ -227,6 +231,7 @@ def reset_clusters(clusters): for cluster in clusters: cluster.bind.reset_mock() cluster.configure_reporting.reset_mock() + cluster.configure_reporting_multiple.reset_mock() cluster.write_attributes.reset_mock() @@ -240,8 +245,21 @@ async def async_test_rejoin(hass, zigpy_device, clusters, report_counts, ep_id=1 for cluster, reports in zip(clusters, report_counts): assert cluster.bind.call_count == 1 assert cluster.bind.await_count == 1 - assert cluster.configure_reporting.call_count == reports - assert cluster.configure_reporting.await_count == reports + if reports: + assert cluster.configure_reporting.call_count == 0 + assert cluster.configure_reporting.await_count == 0 + assert cluster.configure_reporting_multiple.call_count == math.ceil( + reports / zha_const.REPORT_CONFIG_ATTR_PER_REQ + ) + assert cluster.configure_reporting_multiple.await_count == math.ceil( + reports / zha_const.REPORT_CONFIG_ATTR_PER_REQ + ) + else: + # no reports at all + assert cluster.configure_reporting.call_count == reports + assert cluster.configure_reporting.await_count == reports + assert cluster.configure_reporting_multiple.call_count == reports + assert cluster.configure_reporting_multiple.await_count == reports async def async_wait_for_updates(hass): diff --git a/tests/components/zha/test_channels.py b/tests/components/zha/test_channels.py index bd7fd3f9207..45fbf648806 100644 --- a/tests/components/zha/test_channels.py +++ b/tests/components/zha/test_channels.py @@ -1,5 +1,6 @@ """Test ZHA Core channels.""" import asyncio +import math from unittest import mock from unittest.mock import AsyncMock, patch @@ -123,6 +124,23 @@ async def poll_control_device(zha_device_restored, zigpy_device_mock): (0x0020, 1, {}), (0x0021, 0, {}), (0x0101, 1, {"lock_state"}), + ( + 0x0201, + 1, + { + "local_temp", + "occupied_cooling_setpoint", + "occupied_heating_setpoint", + "unoccupied_cooling_setpoint", + "unoccupied_heating_setpoint", + "running_mode", + "running_state", + "system_mode", + "occupancy", + "pi_cooling_demand", + "pi_heating_demand", + }, + ), (0x0202, 1, {"fan_mode"}), (0x0300, 1, {"current_x", "current_y", "color_temperature"}), (0x0400, 1, {"measured_value"}), @@ -156,8 +174,14 @@ async def test_in_channel_config( await channel.async_configure() assert cluster.bind.call_count == bind_count - assert cluster.configure_reporting.call_count == len(attrs) - reported_attrs = {attr[0][0] for attr in cluster.configure_reporting.call_args_list} + assert cluster.configure_reporting.call_count == 0 + assert cluster.configure_reporting_multiple.call_count == math.ceil(len(attrs) / 3) + reported_attrs = { + a + for a in attrs + for attr in cluster.configure_reporting_multiple.call_args_list + for attrs in attr[0][0] + } assert set(attrs) == reported_attrs From 22961b30d2a317a1a0c40558de850d6ae2f10626 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 6 Sep 2021 01:28:48 +0200 Subject: [PATCH 229/843] Update to denonavr version 0.10.9 (#55805) --- homeassistant/components/denonavr/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/denonavr/manifest.json b/homeassistant/components/denonavr/manifest.json index c684c8b0dc5..ce79d937264 100644 --- a/homeassistant/components/denonavr/manifest.json +++ b/homeassistant/components/denonavr/manifest.json @@ -3,7 +3,7 @@ "name": "Denon AVR Network Receivers", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/denonavr", - "requirements": ["denonavr==0.10.8"], + "requirements": ["denonavr==0.10.9"], "codeowners": ["@scarface-4711", "@starkillerOG"], "ssdp": [ { diff --git a/requirements_all.txt b/requirements_all.txt index 09cbb2d7fb8..6e78586102c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -514,7 +514,7 @@ defusedxml==0.7.1 deluge-client==1.7.1 # homeassistant.components.denonavr -denonavr==0.10.8 +denonavr==0.10.9 # homeassistant.components.devolo_home_control devolo-home-control-api==0.17.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 59c06ce47f8..40a08d2a28d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -301,7 +301,7 @@ debugpy==1.4.1 defusedxml==0.7.1 # homeassistant.components.denonavr -denonavr==0.10.8 +denonavr==0.10.9 # homeassistant.components.devolo_home_control devolo-home-control-api==0.17.4 From c2b89725be5df4f8a0b8a6a1e0ac9ff1a484c0e7 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Mon, 6 Sep 2021 00:12:56 +0000 Subject: [PATCH 230/843] [ci skip] Translation update --- homeassistant/components/renault/translations/it.json | 10 +++++++++- homeassistant/components/renault/translations/no.json | 10 +++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/renault/translations/it.json b/homeassistant/components/renault/translations/it.json index 37ba94b3cdf..f315a8b5826 100644 --- a/homeassistant/components/renault/translations/it.json +++ b/homeassistant/components/renault/translations/it.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "L'account \u00e8 gi\u00e0 configurato", - "kamereon_no_account": "Impossibile trovare l'account Kamereon." + "kamereon_no_account": "Impossibile trovare l'account Kamereon.", + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente" }, "error": { "invalid_credentials": "Autenticazione non valida" @@ -14,6 +15,13 @@ }, "title": "Seleziona l'id dell'account Kamereon" }, + "reauth_confirm": { + "data": { + "password": "Password" + }, + "description": "Aggiorna la tua password per {username}", + "title": "Autenticare nuovamente l'integrazione" + }, "user": { "data": { "locale": "Locale", diff --git a/homeassistant/components/renault/translations/no.json b/homeassistant/components/renault/translations/no.json index 4675f939fdd..9ae887830f2 100644 --- a/homeassistant/components/renault/translations/no.json +++ b/homeassistant/components/renault/translations/no.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "Kontoen er allerede konfigurert", - "kamereon_no_account": "Kan ikke finne Kamereon -kontoen." + "kamereon_no_account": "Finner ikke Kamereon-konto", + "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" }, "error": { "invalid_credentials": "Ugyldig godkjenning" @@ -14,6 +15,13 @@ }, "title": "Velg Kamereon -konto -ID" }, + "reauth_confirm": { + "data": { + "password": "Passord" + }, + "description": "Oppdater passordet ditt for {username}", + "title": "Godkjenne integrering p\u00e5 nytt" + }, "user": { "data": { "locale": "Lokal", From 523998f8a11353fa360a698179eb773efd59f93b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 5 Sep 2021 20:53:12 -0700 Subject: [PATCH 231/843] Drop logger service fields because keys are dynamic (#55750) --- homeassistant/components/logger/services.yaml | 58 ------------------- 1 file changed, 58 deletions(-) diff --git a/homeassistant/components/logger/services.yaml b/homeassistant/components/logger/services.yaml index 1995a027b0b..5930a4e5d9e 100644 --- a/homeassistant/components/logger/services.yaml +++ b/homeassistant/components/logger/services.yaml @@ -18,61 +18,3 @@ set_default_level: set_level: name: Set level description: Set log level for integrations. - fields: - homeassistant.core: - name: Home Assistant Core - description: - "Example on how to change the logging level for a Home Assistant Core - integrations." - selector: - select: - options: - - 'debug' - - 'critical' - - 'error' - - 'fatal' - - 'info' - - 'warn' - - 'warning' - homeassistant.components.mqtt: - name: Home Assistant components mqtt - description: - "Example on how to change the logging level for an Integration." - selector: - select: - options: - - 'debug' - - 'critical' - - 'error' - - 'fatal' - - 'info' - - 'warn' - - 'warning' - custom_components.my_integration: - name: Custom components "my_integation" - description: - "Example on how to change the logging level for a Custom Integration." - selector: - select: - options: - - 'debug' - - 'critical' - - 'error' - - 'fatal' - - 'info' - - 'warn' - - 'warning' - aiohttp: - name: aioHttp - description: - "Example on how to change the logging level for a Python module." - selector: - select: - options: - - 'debug' - - 'critical' - - 'error' - - 'fatal' - - 'info' - - 'warn' - - 'warning' From 85658213940f74a8f8d8e9fc00a6858fc99539fa Mon Sep 17 00:00:00 2001 From: Witold Sowa Date: Mon, 6 Sep 2021 07:41:57 +0200 Subject: [PATCH 232/843] =?UTF-8?q?ZHA:=20Added=20support=20for=20ZigBee?= =?UTF-8?q?=20Simple=20Sensor=20device=20and=20Binary=20Input=20c=E2=80=A6?= =?UTF-8?q?=20(#55819)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ZHA: Added support for ZigBee Simple Sensor device and Binary Input cluster * Apply suggestions from code review * Apply suggestions from code review Co-authored-by: Alexei Chetroi --- homeassistant/components/zha/binary_sensor.py | 8 ++++++ homeassistant/components/zha/core/const.py | 1 + .../components/zha/core/registries.py | 1 + tests/components/zha/zha_devices_list.py | 26 ++++++++++++++++--- 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index a0d8abc1233..e6f03a8a848 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -20,6 +20,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from .core import discovery from .core.const import ( CHANNEL_ACCELEROMETER, + CHANNEL_BINARY_INPUT, CHANNEL_OCCUPANCY, CHANNEL_ON_OFF, CHANNEL_ZONE, @@ -136,6 +137,13 @@ class Opening(BinarySensor): DEVICE_CLASS = DEVICE_CLASS_OPENING +@STRICT_MATCH(channel_names=CHANNEL_BINARY_INPUT) +class BinaryInput(BinarySensor): + """ZHA BinarySensor.""" + + SENSOR_ATTR = "present_value" + + @STRICT_MATCH( channel_names=CHANNEL_ON_OFF, manufacturers="IKEA of Sweden", diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index 76f025dd79a..04ed3ba4281 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -73,6 +73,7 @@ BAUD_RATES = [2400, 4800, 9600, 14400, 19200, 38400, 57600, 115200, 128000, 2560 BINDINGS = "bindings" CHANNEL_ACCELEROMETER = "accelerometer" +CHANNEL_BINARY_INPUT = "binary_input" CHANNEL_ANALOG_INPUT = "analog_input" CHANNEL_ANALOG_OUTPUT = "analog_output" CHANNEL_ATTRIBUTE = "attribute" diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py index 04e97f8b7ed..53425e329c0 100644 --- a/homeassistant/components/zha/core/registries.py +++ b/homeassistant/components/zha/core/registries.py @@ -66,6 +66,7 @@ SINGLE_INPUT_CLUSTER_DEVICE_CLASS = { VOC_LEVEL_CLUSTER: SENSOR, zcl.clusters.closures.DoorLock.cluster_id: LOCK, zcl.clusters.closures.WindowCovering.cluster_id: COVER, + zcl.clusters.general.BinaryInput.cluster_id: BINARY_SENSOR, zcl.clusters.general.AnalogInput.cluster_id: SENSOR, zcl.clusters.general.AnalogOutput.cluster_id: NUMBER, zcl.clusters.general.MultistateInput.cluster_id: SENSOR, diff --git a/tests/components/zha/zha_devices_list.py b/tests/components/zha/zha_devices_list.py index 1ea52d4e604..004be25d21f 100644 --- a/tests/components/zha/zha_devices_list.py +++ b/tests/components/zha/zha_devices_list.py @@ -1398,6 +1398,11 @@ DEVICES = [ "entity_class": "ElectricalMeasurement", "entity_id": "sensor.lumi_lumi_plug_maus01_77665544_electrical_measurement", }, + ("binary_sensor", "00:11:22:33:44:55:66:77-100-15"): { + "channels": ["binary_input"], + "entity_class": "BinaryInput", + "entity_id": "binary_sensor.lumi_lumi_plug_maus01_77665544_binary_input", + }, }, "event_channels": ["1:0x0019"], "manufacturer": "LUMI", @@ -2659,7 +2664,12 @@ DEVICES = [ "channels": ["power"], "entity_class": "Battery", "entity_id": "sensor.philips_rwl020_77665544_power", - } + }, + ("binary_sensor", "00:11:22:33:44:55:66:77-2-15"): { + "channels": ["binary_input"], + "entity_class": "BinaryInput", + "entity_id": "binary_sensor.philips_rwl020_77665544_binary_input", + }, }, "event_channels": ["1:0x0005", "1:0x0006", "1:0x0008", "2:0x0019"], "manufacturer": "Philips", @@ -2741,7 +2751,7 @@ DEVICES = [ }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-64514"): { "channels": ["manufacturer_specific"], - "entity_class": "BinarySensor", + "entity_class": "BinaryInput", "entity_id": "binary_sensor.samjin_multi_77665544_manufacturer_specific", "default_match": True, }, @@ -3099,6 +3109,11 @@ DEVICES = [ "entity_class": "ElectricalMeasurement", "entity_id": "sensor.smartthings_outletv4_77665544_electrical_measurement", }, + ("binary_sensor", "00:11:22:33:44:55:66:77-1-15"): { + "channels": ["binary_input"], + "entity_class": "BinaryInput", + "entity_id": "binary_sensor.smartthings_outletv4_77665544_binary_input", + }, }, "event_channels": ["1:0x0019"], "manufacturer": "SmartThings", @@ -3122,7 +3137,12 @@ DEVICES = [ "channels": ["power"], "entity_class": "ZHADeviceScannerEntity", "entity_id": "device_tracker.smartthings_tagv4_77665544_power", - } + }, + ("binary_sensor", "00:11:22:33:44:55:66:77-1-15"): { + "channels": ["binary_input"], + "entity_class": "BinaryInput", + "entity_id": "binary_sensor.smartthings_tagv4_77665544_binary_input", + }, }, "event_channels": ["1:0x0019"], "manufacturer": "SmartThings", From 1b3530a3f8e73c01cb9f4212c65109dab56da309 Mon Sep 17 00:00:00 2001 From: Greg Date: Sun, 5 Sep 2021 23:32:50 -0700 Subject: [PATCH 233/843] Bump envoy_reader API to 0.20.0 (#55822) --- homeassistant/components/enphase_envoy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index a682f53bc44..9e948eaf842 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -3,7 +3,7 @@ "name": "Enphase Envoy", "documentation": "https://www.home-assistant.io/integrations/enphase_envoy", "requirements": [ - "envoy_reader==0.19.0" + "envoy_reader==0.20.0" ], "codeowners": [ "@gtdiehl" diff --git a/requirements_all.txt b/requirements_all.txt index 6e78586102c..60bfe276c37 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -583,7 +583,7 @@ env_canada==0.2.5 # envirophat==0.0.6 # homeassistant.components.enphase_envoy -envoy_reader==0.19.0 +envoy_reader==0.20.0 # homeassistant.components.season ephem==3.7.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 40a08d2a28d..57c001d6c1c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -334,7 +334,7 @@ emulated_roku==0.2.1 enocean==0.50 # homeassistant.components.enphase_envoy -envoy_reader==0.19.0 +envoy_reader==0.20.0 # homeassistant.components.season ephem==3.7.7.0 From 0dd128af7777c2cdf75264477e54366c6dc3d217 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Mon, 6 Sep 2021 08:49:00 +0200 Subject: [PATCH 234/843] Change fix property to _attr for tradfri (#55691) --- .../components/tradfri/base_class.py | 38 +++---------------- homeassistant/components/tradfri/cover.py | 2 +- homeassistant/components/tradfri/light.py | 37 ++++-------------- homeassistant/components/tradfri/sensor.py | 2 +- homeassistant/components/tradfri/switch.py | 2 +- 5 files changed, 17 insertions(+), 64 deletions(-) diff --git a/homeassistant/components/tradfri/base_class.py b/homeassistant/components/tradfri/base_class.py index 0c9f2f7312f..ed95e47abd5 100644 --- a/homeassistant/components/tradfri/base_class.py +++ b/homeassistant/components/tradfri/base_class.py @@ -32,6 +32,8 @@ class TradfriBaseClass(Entity): All devices and groups should ultimately inherit from this class. """ + _attr_should_poll = False + def __init__(self, device, api, gateway_id): """Initialize a device.""" self._api = handle_error(api) @@ -39,9 +41,6 @@ class TradfriBaseClass(Entity): self._device_control = None self._device_data = None self._gateway_id = gateway_id - self._name = None - self._unique_id = None - self._refresh(device) @callback @@ -49,7 +48,7 @@ class TradfriBaseClass(Entity): """Start observation of device.""" if exc: self.async_write_ha_state() - _LOGGER.warning("Observation failed for %s", self._name, exc_info=exc) + _LOGGER.warning("Observation failed for %s", self._attr_name, exc_info=exc) try: cmd = self._device.observe( @@ -66,21 +65,6 @@ class TradfriBaseClass(Entity): """Start thread when added to hass.""" self._async_start_observe() - @property - def name(self): - """Return the display name of this device.""" - return self._name - - @property - def should_poll(self): - """No polling needed for tradfri device.""" - return False - - @property - def unique_id(self): - """Return unique ID for device.""" - return self._unique_id - @callback def _observe_update(self, device): """Receive new state data for this device.""" @@ -90,7 +74,7 @@ class TradfriBaseClass(Entity): def _refresh(self, device): """Refresh the device data.""" self._device = device - self._name = device.name + self._attr_name = device.name class TradfriBaseDevice(TradfriBaseClass): @@ -99,16 +83,6 @@ class TradfriBaseDevice(TradfriBaseClass): All devices should inherit from this class. """ - def __init__(self, device, api, gateway_id): - """Initialize a device.""" - super().__init__(device, api, gateway_id) - self._available = True - - @property - def available(self): - """Return True if entity is available.""" - return self._available - @property def device_info(self): """Return the device info.""" @@ -118,7 +92,7 @@ class TradfriBaseDevice(TradfriBaseClass): "identifiers": {(DOMAIN, self._device.id)}, "manufacturer": info.manufacturer, "model": info.model_number, - "name": self._name, + "name": self._attr_name, "sw_version": info.firmware_version, "via_device": (DOMAIN, self._gateway_id), } @@ -126,4 +100,4 @@ class TradfriBaseDevice(TradfriBaseClass): def _refresh(self, device): """Refresh the device data.""" super()._refresh(device) - self._available = device.reachable + self._attr_available = device.reachable diff --git a/homeassistant/components/tradfri/cover.py b/homeassistant/components/tradfri/cover.py index 4c7cde1dfd1..ad077f1f040 100644 --- a/homeassistant/components/tradfri/cover.py +++ b/homeassistant/components/tradfri/cover.py @@ -24,7 +24,7 @@ class TradfriCover(TradfriBaseDevice, CoverEntity): def __init__(self, device, api, gateway_id): """Initialize a cover.""" super().__init__(device, api, gateway_id) - self._unique_id = f"{gateway_id}-{device.id}" + self._attr_unique_id = f"{gateway_id}-{device.id}" self._refresh(device) diff --git a/homeassistant/components/tradfri/light.py b/homeassistant/components/tradfri/light.py index a4c2ee67865..3dfdb7e6fe7 100644 --- a/homeassistant/components/tradfri/light.py +++ b/homeassistant/components/tradfri/light.py @@ -48,19 +48,16 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class TradfriGroup(TradfriBaseClass, LightEntity): """The platform class for light groups required by hass.""" + _attr_supported_features = SUPPORTED_GROUP_FEATURES + def __init__(self, device, api, gateway_id): """Initialize a Group.""" super().__init__(device, api, gateway_id) - self._unique_id = f"group-{gateway_id}-{device.id}" - + self._attr_unique_id = f"group-{gateway_id}-{device.id}" + self._attr_should_poll = True self._refresh(device) - @property - def should_poll(self): - """Poll needed for tradfri groups.""" - return True - async def async_update(self): """Fetch new state data for the group. @@ -68,11 +65,6 @@ class TradfriGroup(TradfriBaseClass, LightEntity): """ await self._api(self._device.update()) - @property - def supported_features(self): - """Flag supported features.""" - return SUPPORTED_GROUP_FEATURES - @property def is_on(self): """Return true if group lights are on.""" @@ -108,7 +100,7 @@ class TradfriLight(TradfriBaseDevice, LightEntity): def __init__(self, device, api, gateway_id): """Initialize a Light.""" super().__init__(device, api, gateway_id) - self._unique_id = f"light-{gateway_id}-{device.id}" + self._attr_unique_id = f"light-{gateway_id}-{device.id}" self._hs_color = None # Calculate supported features @@ -119,24 +111,11 @@ class TradfriLight(TradfriBaseDevice, LightEntity): _features |= SUPPORT_COLOR | SUPPORT_COLOR_TEMP if device.light_control.can_set_temp: _features |= SUPPORT_COLOR_TEMP - self._features = _features + self._attr_supported_features = _features self._refresh(device) - - @property - def min_mireds(self): - """Return the coldest color_temp that this light supports.""" - return self._device_control.min_mireds - - @property - def max_mireds(self): - """Return the warmest color_temp that this light supports.""" - return self._device_control.max_mireds - - @property - def supported_features(self): - """Flag supported features.""" - return self._features + self._attr_min_mireds = self._device_control.min_mireds + self._attr_max_mireds = self._device_control.max_mireds @property def is_on(self): diff --git a/homeassistant/components/tradfri/sensor.py b/homeassistant/components/tradfri/sensor.py index f7f68b666ba..7f0ed233d1b 100644 --- a/homeassistant/components/tradfri/sensor.py +++ b/homeassistant/components/tradfri/sensor.py @@ -35,7 +35,7 @@ class TradfriSensor(TradfriBaseDevice, SensorEntity): def __init__(self, device, api, gateway_id): """Initialize the device.""" super().__init__(device, api, gateway_id) - self._unique_id = f"{gateway_id}-{device.id}" + self._attr_unique_id = f"{gateway_id}-{device.id}" @property def native_value(self): diff --git a/homeassistant/components/tradfri/switch.py b/homeassistant/components/tradfri/switch.py index 6634090d00d..00e15f1b875 100644 --- a/homeassistant/components/tradfri/switch.py +++ b/homeassistant/components/tradfri/switch.py @@ -25,7 +25,7 @@ class TradfriSwitch(TradfriBaseDevice, SwitchEntity): def __init__(self, device, api, gateway_id): """Initialize a switch.""" super().__init__(device, api, gateway_id) - self._unique_id = f"{gateway_id}-{device.id}" + self._attr_unique_id = f"{gateway_id}-{device.id}" def _refresh(self, device): """Refresh the switch data.""" From 99ef2ae54d765b8458057850693a593ba983e9cd Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 6 Sep 2021 09:33:58 +0200 Subject: [PATCH 235/843] Use EntityDescription - vilfo (#55746) --- homeassistant/components/vilfo/const.py | 58 ++++++++++++++---------- homeassistant/components/vilfo/sensor.py | 54 ++++------------------ 2 files changed, 45 insertions(+), 67 deletions(-) diff --git a/homeassistant/components/vilfo/const.py b/homeassistant/components/vilfo/const.py index d47e738a858..36ecab0ca48 100644 --- a/homeassistant/components/vilfo/const.py +++ b/homeassistant/components/vilfo/const.py @@ -1,19 +1,16 @@ """Constants for the Vilfo Router integration.""" -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ICON, - DEVICE_CLASS_TIMESTAMP, - PERCENTAGE, -) +from __future__ import annotations + +from dataclasses import dataclass + +from homeassistant.components.sensor import SensorEntityDescription +from homeassistant.const import DEVICE_CLASS_TIMESTAMP, PERCENTAGE DOMAIN = "vilfo" -ATTR_API_DATA_FIELD = "api_data_field" ATTR_API_DATA_FIELD_LOAD = "load" ATTR_API_DATA_FIELD_BOOT_TIME = "boot_time" -ATTR_LABEL = "label" ATTR_LOAD = "load" -ATTR_UNIT = "unit" ATTR_BOOT_TIME = "boot_time" ROUTER_DEFAULT_HOST = "admin.vilfo.com" @@ -21,17 +18,32 @@ ROUTER_DEFAULT_MODEL = "Vilfo Router" ROUTER_DEFAULT_NAME = "Vilfo Router" ROUTER_MANUFACTURER = "Vilfo AB" -SENSOR_TYPES = { - ATTR_LOAD: { - ATTR_LABEL: "Load", - ATTR_UNIT: PERCENTAGE, - ATTR_ICON: "mdi:memory", - ATTR_API_DATA_FIELD: ATTR_API_DATA_FIELD_LOAD, - }, - ATTR_BOOT_TIME: { - ATTR_LABEL: "Boot time", - ATTR_ICON: "mdi:timer-outline", - ATTR_API_DATA_FIELD: ATTR_API_DATA_FIELD_BOOT_TIME, - ATTR_DEVICE_CLASS: DEVICE_CLASS_TIMESTAMP, - }, -} + +@dataclass +class VilfoRequiredKeysMixin: + """Mixin for required keys.""" + + api_key: str + + +@dataclass +class VilfoSensorEntityDescription(SensorEntityDescription, VilfoRequiredKeysMixin): + """Describes Vilfo sensor entity.""" + + +SENSOR_TYPES: tuple[VilfoSensorEntityDescription, ...] = ( + VilfoSensorEntityDescription( + key=ATTR_LOAD, + name="Load", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:memory", + api_key=ATTR_API_DATA_FIELD_LOAD, + ), + VilfoSensorEntityDescription( + key=ATTR_BOOT_TIME, + name="Boot time", + icon="mdi:timer-outline", + api_key=ATTR_API_DATA_FIELD_BOOT_TIME, + device_class=DEVICE_CLASS_TIMESTAMP, + ), +) diff --git a/homeassistant/components/vilfo/sensor.py b/homeassistant/components/vilfo/sensor.py index bb2df21f257..463ed31650c 100644 --- a/homeassistant/components/vilfo/sensor.py +++ b/homeassistant/components/vilfo/sensor.py @@ -1,17 +1,13 @@ """Support for Vilfo Router sensors.""" from homeassistant.components.sensor import SensorEntity -from homeassistant.const import ATTR_ICON from .const import ( - ATTR_API_DATA_FIELD, - ATTR_DEVICE_CLASS, - ATTR_LABEL, - ATTR_UNIT, DOMAIN, ROUTER_DEFAULT_MODEL, ROUTER_DEFAULT_NAME, ROUTER_MANUFACTURER, SENSOR_TYPES, + VilfoSensorEntityDescription, ) @@ -19,21 +15,20 @@ async def async_setup_entry(hass, config_entry, async_add_entities): """Add Vilfo Router entities from a config_entry.""" vilfo = hass.data[DOMAIN][config_entry.entry_id] - sensors = [] + entities = [VilfoRouterSensor(vilfo, description) for description in SENSOR_TYPES] - for sensor_type in SENSOR_TYPES: - sensors.append(VilfoRouterSensor(sensor_type, vilfo)) - - async_add_entities(sensors, True) + async_add_entities(entities, True) class VilfoRouterSensor(SensorEntity): """Define a Vilfo Router Sensor.""" - def __init__(self, sensor_type, api): + entity_description: VilfoSensorEntityDescription + + def __init__(self, api, description: VilfoSensorEntityDescription): """Initialize.""" + self.entity_description = description self.api = api - self.sensor_type = sensor_type self._device_info = { "identifiers": {(DOMAIN, api.host, api.mac_address)}, "name": ROUTER_DEFAULT_NAME, @@ -41,8 +36,7 @@ class VilfoRouterSensor(SensorEntity): "model": ROUTER_DEFAULT_MODEL, "sw_version": api.firmware_version, } - self._unique_id = f"{self.api.unique_id}_{self.sensor_type}" - self._state = None + self._attr_unique_id = f"{api.unique_id}_{description.key}" @property def available(self): @@ -54,41 +48,13 @@ class VilfoRouterSensor(SensorEntity): """Return the device info.""" return self._device_info - @property - def device_class(self): - """Return the device class.""" - return SENSOR_TYPES[self.sensor_type].get(ATTR_DEVICE_CLASS) - - @property - def icon(self): - """Return the icon for the sensor.""" - return SENSOR_TYPES[self.sensor_type][ATTR_ICON] - @property def name(self): """Return the name of the sensor.""" parent_device_name = self._device_info["name"] - sensor_name = SENSOR_TYPES[self.sensor_type][ATTR_LABEL] - return f"{parent_device_name} {sensor_name}" - - @property - def native_value(self): - """Return the state.""" - return self._state - - @property - def unique_id(self): - """Return a unique_id for this entity.""" - return self._unique_id - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of this entity.""" - return SENSOR_TYPES[self.sensor_type].get(ATTR_UNIT) + return f"{parent_device_name} {self.entity_description.name}" async def async_update(self): """Update the router data.""" await self.api.async_update() - self._state = self.api.data.get( - SENSOR_TYPES[self.sensor_type][ATTR_API_DATA_FIELD] - ) + self._attr_native_value = self.api.data.get(self.entity_description.api_key) From cc6a0d2f8d73194d7ba3da4b37e5bd615a9e5ff1 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 6 Sep 2021 09:40:41 +0200 Subject: [PATCH 236/843] Use EntityDescription - awair (#55747) --- homeassistant/components/awair/const.py | 156 ++++++++++++----------- homeassistant/components/awair/sensor.py | 93 +++++++------- tests/components/awair/test_sensor.py | 41 +++--- 3 files changed, 154 insertions(+), 136 deletions(-) diff --git a/homeassistant/components/awair/const.py b/homeassistant/components/awair/const.py index 1841a167a50..4968e86bcf5 100644 --- a/homeassistant/components/awair/const.py +++ b/homeassistant/components/awair/const.py @@ -1,4 +1,5 @@ """Constants for the Awair component.""" +from __future__ import annotations from dataclasses import dataclass from datetime import timedelta @@ -6,9 +7,8 @@ import logging from python_awair.devices import AwairDevice +from homeassistant.components.sensor import SensorEntityDescription from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ICON, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, @@ -36,10 +36,6 @@ API_VOC = "volatile_organic_compounds" ATTRIBUTION = "Awair air quality sensor" -ATTR_LABEL = "label" -ATTR_UNIT = "unit" -ATTR_UNIQUE_ID = "unique_id" - DOMAIN = "awair" DUST_ALIASES = [API_PM25, API_PM10] @@ -48,71 +44,89 @@ LOGGER = logging.getLogger(__package__) UPDATE_INTERVAL = timedelta(minutes=5) -SENSOR_TYPES = { - API_SCORE: { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:blur", - ATTR_UNIT: PERCENTAGE, - ATTR_LABEL: "Awair score", - ATTR_UNIQUE_ID: "score", # matches legacy format - }, - API_HUMID: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, - ATTR_ICON: None, - ATTR_UNIT: PERCENTAGE, - ATTR_LABEL: "Humidity", - ATTR_UNIQUE_ID: "HUMID", # matches legacy format - }, - API_LUX: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_ILLUMINANCE, - ATTR_ICON: None, - ATTR_UNIT: LIGHT_LUX, - ATTR_LABEL: "Illuminance", - ATTR_UNIQUE_ID: "illuminance", - }, - API_SPL_A: { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:ear-hearing", - ATTR_UNIT: SOUND_PRESSURE_WEIGHTED_DBA, - ATTR_LABEL: "Sound level", - ATTR_UNIQUE_ID: "sound_level", - }, - API_VOC: { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:cloud", - ATTR_UNIT: CONCENTRATION_PARTS_PER_BILLION, - ATTR_LABEL: "Volatile organic compounds", - ATTR_UNIQUE_ID: "VOC", # matches legacy format - }, - API_TEMP: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_ICON: None, - ATTR_UNIT: TEMP_CELSIUS, - ATTR_LABEL: "Temperature", - ATTR_UNIQUE_ID: "TEMP", # matches legacy format - }, - API_PM25: { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:blur", - ATTR_UNIT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - ATTR_LABEL: "PM2.5", - ATTR_UNIQUE_ID: "PM25", # matches legacy format - }, - API_PM10: { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:blur", - ATTR_UNIT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - ATTR_LABEL: "PM10", - ATTR_UNIQUE_ID: "PM10", # matches legacy format - }, - API_CO2: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_CO2, - ATTR_ICON: "mdi:cloud", - ATTR_UNIT: CONCENTRATION_PARTS_PER_MILLION, - ATTR_LABEL: "Carbon dioxide", - ATTR_UNIQUE_ID: "CO2", # matches legacy format - }, -} + +@dataclass +class AwairRequiredKeysMixin: + """Mixinf for required keys.""" + + unique_id_tag: str + + +@dataclass +class AwairSensorEntityDescription(SensorEntityDescription, AwairRequiredKeysMixin): + """Describes Awair sensor entity.""" + + +SENSOR_TYPE_SCORE = AwairSensorEntityDescription( + key=API_SCORE, + icon="mdi:blur", + native_unit_of_measurement=PERCENTAGE, + name="Awair score", + unique_id_tag="score", # matches legacy format +) + +SENSOR_TYPES: tuple[AwairSensorEntityDescription, ...] = ( + AwairSensorEntityDescription( + key=API_HUMID, + device_class=DEVICE_CLASS_HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + name="Humidity", + unique_id_tag="HUMID", # matches legacy format + ), + AwairSensorEntityDescription( + key=API_LUX, + device_class=DEVICE_CLASS_ILLUMINANCE, + native_unit_of_measurement=LIGHT_LUX, + name="Illuminance", + unique_id_tag="illuminance", + ), + AwairSensorEntityDescription( + key=API_SPL_A, + icon="mdi:ear-hearing", + native_unit_of_measurement=SOUND_PRESSURE_WEIGHTED_DBA, + name="Sound level", + unique_id_tag="sound_level", + ), + AwairSensorEntityDescription( + key=API_VOC, + icon="mdi:cloud", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, + name="Volatile organic compounds", + unique_id_tag="VOC", # matches legacy format + ), + AwairSensorEntityDescription( + key=API_TEMP, + device_class=DEVICE_CLASS_TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + name="Temperature", + unique_id_tag="TEMP", # matches legacy format + ), + AwairSensorEntityDescription( + key=API_CO2, + device_class=DEVICE_CLASS_CO2, + icon="mdi:cloud", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + name="Carbon dioxide", + unique_id_tag="CO2", # matches legacy format + ), +) + +SENSOR_TYPES_DUST: tuple[AwairSensorEntityDescription, ...] = ( + AwairSensorEntityDescription( + key=API_PM25, + icon="mdi:blur", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + name="PM2.5", + unique_id_tag="PM25", # matches legacy format + ), + AwairSensorEntityDescription( + key=API_PM10, + icon="mdi:blur", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + name="PM10", + unique_id_tag="PM10", # matches legacy format + ), +) @dataclass diff --git a/homeassistant/components/awair/sensor.py b/homeassistant/components/awair/sensor.py index 3b46d3b2317..80591e36f2d 100644 --- a/homeassistant/components/awair/sensor.py +++ b/homeassistant/components/awair/sensor.py @@ -7,7 +7,7 @@ import voluptuous as vol from homeassistant.components.awair import AwairDataUpdateCoordinator, AwairResult from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import ATTR_ATTRIBUTION, ATTR_DEVICE_CLASS, CONF_ACCESS_TOKEN +from homeassistant.const import ATTR_ATTRIBUTION, CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr import homeassistant.helpers.config_validation as cv @@ -22,15 +22,14 @@ from .const import ( API_SCORE, API_TEMP, API_VOC, - ATTR_ICON, - ATTR_LABEL, - ATTR_UNIQUE_ID, - ATTR_UNIT, ATTRIBUTION, DOMAIN, DUST_ALIASES, LOGGER, + SENSOR_TYPE_SCORE, SENSOR_TYPES, + SENSOR_TYPES_DUST, + AwairSensorEntityDescription, ) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -60,16 +59,20 @@ async def async_setup_entry( ): """Set up Awair sensor entity based on a config entry.""" coordinator = hass.data[DOMAIN][config_entry.entry_id] - sensors = [] + entities = [] data: list[AwairResult] = coordinator.data.values() for result in data: if result.air_data: - sensors.append(AwairSensor(API_SCORE, result.device, coordinator)) + entities.append(AwairSensor(result.device, coordinator, SENSOR_TYPE_SCORE)) device_sensors = result.air_data.sensors.keys() - for sensor in device_sensors: - if sensor in SENSOR_TYPES: - sensors.append(AwairSensor(sensor, result.device, coordinator)) + entities.extend( + [ + AwairSensor(result.device, coordinator, description) + for description in (*SENSOR_TYPES, *SENSOR_TYPES_DUST) + if description.key in device_sensors + ] + ) # The "DUST" sensor for Awair is a combo pm2.5/pm10 sensor only # present on first-gen devices in lieu of separate pm2.5/pm10 sensors. @@ -78,45 +81,53 @@ async def async_setup_entry( # that data - because we can't really tell what kind of particles the # "DUST" sensor actually detected. However, it's still useful data. if API_DUST in device_sensors: - for alias_kind in DUST_ALIASES: - sensors.append(AwairSensor(alias_kind, result.device, coordinator)) + entities.extend( + [ + AwairSensor(result.device, coordinator, description) + for description in SENSOR_TYPES_DUST + ] + ) - async_add_entities(sensors) + async_add_entities(entities) class AwairSensor(CoordinatorEntity, SensorEntity): """Defines an Awair sensor entity.""" + entity_description: AwairSensorEntityDescription + def __init__( self, - kind: str, device: AwairDevice, coordinator: AwairDataUpdateCoordinator, + description: AwairSensorEntityDescription, ) -> None: """Set up an individual AwairSensor.""" super().__init__(coordinator) - self._kind = kind + self.entity_description = description self._device = device @property - def name(self) -> str: + def name(self) -> str | None: """Return the name of the sensor.""" - name = SENSOR_TYPES[self._kind][ATTR_LABEL] if self._device.name: - name = f"{self._device.name} {name}" + return f"{self._device.name} {self.entity_description.name}" - return name + return self.entity_description.name @property def unique_id(self) -> str: """Return the uuid as the unique_id.""" - unique_id_tag = SENSOR_TYPES[self._kind][ATTR_UNIQUE_ID] + unique_id_tag = self.entity_description.unique_id_tag # This integration used to create a sensor that was labelled as a "PM2.5" # sensor for first-gen Awair devices, but its unique_id reflected the truth: # under the hood, it was a "DUST" sensor. So we preserve that specific unique_id # for users with first-gen devices that are upgrading. - if self._kind == API_PM25 and API_DUST in self._air_data.sensors: + if ( + self.entity_description.key == API_PM25 + and API_DUST in self._air_data.sensors + ): unique_id_tag = "DUST" return f"{self._device.uuid}_{unique_id_tag}" @@ -127,16 +138,17 @@ class AwairSensor(CoordinatorEntity, SensorEntity): # If the last update was successful... if self.coordinator.last_update_success and self._air_data: # and the results included our sensor type... - if self._kind in self._air_data.sensors: + sensor_type = self.entity_description.key + if sensor_type in self._air_data.sensors: # then we are available. return True # or, we're a dust alias - if self._kind in DUST_ALIASES and API_DUST in self._air_data.sensors: + if sensor_type in DUST_ALIASES and API_DUST in self._air_data.sensors: return True # or we are API_SCORE - if self._kind == API_SCORE: + if sensor_type == API_SCORE: # then we are available. return True @@ -147,38 +159,24 @@ class AwairSensor(CoordinatorEntity, SensorEntity): def native_value(self) -> float: """Return the state, rounding off to reasonable values.""" state: float + sensor_type = self.entity_description.key # Special-case for "SCORE", which we treat as the AQI - if self._kind == API_SCORE: + if sensor_type == API_SCORE: state = self._air_data.score - elif self._kind in DUST_ALIASES and API_DUST in self._air_data.sensors: + elif sensor_type in DUST_ALIASES and API_DUST in self._air_data.sensors: state = self._air_data.sensors.dust else: - state = self._air_data.sensors[self._kind] + state = self._air_data.sensors[sensor_type] - if self._kind == API_VOC or self._kind == API_SCORE: + if sensor_type in {API_VOC, API_SCORE}: return round(state) - if self._kind == API_TEMP: + if sensor_type == API_TEMP: return round(state, 1) return round(state, 2) - @property - def icon(self) -> str: - """Return the icon.""" - return SENSOR_TYPES[self._kind][ATTR_ICON] - - @property - def device_class(self) -> str: - """Return the device_class.""" - return SENSOR_TYPES[self._kind][ATTR_DEVICE_CLASS] - - @property - def native_unit_of_measurement(self) -> str: - """Return the unit the value is expressed in.""" - return SENSOR_TYPES[self._kind][ATTR_UNIT] - @property def extra_state_attributes(self) -> dict: """Return the Awair Index alongside state attributes. @@ -201,10 +199,11 @@ class AwairSensor(CoordinatorEntity, SensorEntity): https://docs.developer.getawair.com/?version=latest#awair-score-and-index """ + sensor_type = self.entity_description.key attrs = {ATTR_ATTRIBUTION: ATTRIBUTION} - if self._kind in self._air_data.indices: - attrs["awair_index"] = abs(self._air_data.indices[self._kind]) - elif self._kind in DUST_ALIASES and API_DUST in self._air_data.indices: + if sensor_type in self._air_data.indices: + attrs["awair_index"] = abs(self._air_data.indices[sensor_type]) + elif sensor_type in DUST_ALIASES and API_DUST in self._air_data.indices: attrs["awair_index"] = abs(self._air_data.indices.dust) return attrs diff --git a/tests/components/awair/test_sensor.py b/tests/components/awair/test_sensor.py index b37e8dbf5d2..658ba802e8e 100644 --- a/tests/components/awair/test_sensor.py +++ b/tests/components/awair/test_sensor.py @@ -11,9 +11,10 @@ from homeassistant.components.awair.const import ( API_SPL_A, API_TEMP, API_VOC, - ATTR_UNIQUE_ID, DOMAIN, + SENSOR_TYPE_SCORE, SENSOR_TYPES, + SENSOR_TYPES_DUST, ) from homeassistant.const import ( ATTR_ICON, @@ -44,6 +45,10 @@ from .const import ( from tests.common import MockConfigEntry +SENSOR_TYPES_MAP = { + desc.key: desc for desc in (SENSOR_TYPE_SCORE, *SENSOR_TYPES, *SENSOR_TYPES_DUST) +} + async def setup_awair(hass, fixtures): """Add Awair devices to hass, using specified fixtures for data.""" @@ -80,7 +85,7 @@ async def test_awair_gen1_sensors(hass): hass, registry, "sensor.living_room_awair_score", - f"{AWAIR_UUID}_{SENSOR_TYPES[API_SCORE][ATTR_UNIQUE_ID]}", + f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_SCORE].unique_id_tag}", "88", {ATTR_ICON: "mdi:blur"}, ) @@ -89,7 +94,7 @@ async def test_awair_gen1_sensors(hass): hass, registry, "sensor.living_room_temperature", - f"{AWAIR_UUID}_{SENSOR_TYPES[API_TEMP][ATTR_UNIQUE_ID]}", + f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_TEMP].unique_id_tag}", "21.8", {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, "awair_index": 1.0}, ) @@ -98,7 +103,7 @@ async def test_awair_gen1_sensors(hass): hass, registry, "sensor.living_room_humidity", - f"{AWAIR_UUID}_{SENSOR_TYPES[API_HUMID][ATTR_UNIQUE_ID]}", + f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_HUMID].unique_id_tag}", "41.59", {ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, "awair_index": 0.0}, ) @@ -107,7 +112,7 @@ async def test_awair_gen1_sensors(hass): hass, registry, "sensor.living_room_carbon_dioxide", - f"{AWAIR_UUID}_{SENSOR_TYPES[API_CO2][ATTR_UNIQUE_ID]}", + f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_CO2].unique_id_tag}", "654.0", { ATTR_ICON: "mdi:cloud", @@ -120,7 +125,7 @@ async def test_awair_gen1_sensors(hass): hass, registry, "sensor.living_room_volatile_organic_compounds", - f"{AWAIR_UUID}_{SENSOR_TYPES[API_VOC][ATTR_UNIQUE_ID]}", + f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_VOC].unique_id_tag}", "366", { ATTR_ICON: "mdi:cloud", @@ -147,7 +152,7 @@ async def test_awair_gen1_sensors(hass): hass, registry, "sensor.living_room_pm10", - f"{AWAIR_UUID}_{SENSOR_TYPES[API_PM10][ATTR_UNIQUE_ID]}", + f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_PM10].unique_id_tag}", "14.3", { ATTR_ICON: "mdi:blur", @@ -176,7 +181,7 @@ async def test_awair_gen2_sensors(hass): hass, registry, "sensor.living_room_awair_score", - f"{AWAIR_UUID}_{SENSOR_TYPES[API_SCORE][ATTR_UNIQUE_ID]}", + f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_SCORE].unique_id_tag}", "97", {ATTR_ICON: "mdi:blur"}, ) @@ -185,7 +190,7 @@ async def test_awair_gen2_sensors(hass): hass, registry, "sensor.living_room_pm2_5", - f"{AWAIR_UUID}_{SENSOR_TYPES[API_PM25][ATTR_UNIQUE_ID]}", + f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_PM25].unique_id_tag}", "2.0", { ATTR_ICON: "mdi:blur", @@ -210,7 +215,7 @@ async def test_awair_mint_sensors(hass): hass, registry, "sensor.living_room_awair_score", - f"{AWAIR_UUID}_{SENSOR_TYPES[API_SCORE][ATTR_UNIQUE_ID]}", + f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_SCORE].unique_id_tag}", "98", {ATTR_ICON: "mdi:blur"}, ) @@ -219,7 +224,7 @@ async def test_awair_mint_sensors(hass): hass, registry, "sensor.living_room_pm2_5", - f"{AWAIR_UUID}_{SENSOR_TYPES[API_PM25][ATTR_UNIQUE_ID]}", + f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_PM25].unique_id_tag}", "1.0", { ATTR_ICON: "mdi:blur", @@ -232,7 +237,7 @@ async def test_awair_mint_sensors(hass): hass, registry, "sensor.living_room_illuminance", - f"{AWAIR_UUID}_{SENSOR_TYPES[API_LUX][ATTR_UNIQUE_ID]}", + f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_LUX].unique_id_tag}", "441.7", {ATTR_UNIT_OF_MEASUREMENT: LIGHT_LUX}, ) @@ -252,7 +257,7 @@ async def test_awair_glow_sensors(hass): hass, registry, "sensor.living_room_awair_score", - f"{AWAIR_UUID}_{SENSOR_TYPES[API_SCORE][ATTR_UNIQUE_ID]}", + f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_SCORE].unique_id_tag}", "93", {ATTR_ICON: "mdi:blur"}, ) @@ -272,7 +277,7 @@ async def test_awair_omni_sensors(hass): hass, registry, "sensor.living_room_awair_score", - f"{AWAIR_UUID}_{SENSOR_TYPES[API_SCORE][ATTR_UNIQUE_ID]}", + f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_SCORE].unique_id_tag}", "99", {ATTR_ICON: "mdi:blur"}, ) @@ -281,7 +286,7 @@ async def test_awair_omni_sensors(hass): hass, registry, "sensor.living_room_sound_level", - f"{AWAIR_UUID}_{SENSOR_TYPES[API_SPL_A][ATTR_UNIQUE_ID]}", + f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_SPL_A].unique_id_tag}", "47.0", {ATTR_ICON: "mdi:ear-hearing", ATTR_UNIT_OF_MEASUREMENT: "dBa"}, ) @@ -290,7 +295,7 @@ async def test_awair_omni_sensors(hass): hass, registry, "sensor.living_room_illuminance", - f"{AWAIR_UUID}_{SENSOR_TYPES[API_LUX][ATTR_UNIQUE_ID]}", + f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_LUX].unique_id_tag}", "804.9", {ATTR_UNIT_OF_MEASUREMENT: LIGHT_LUX}, ) @@ -325,7 +330,7 @@ async def test_awair_unavailable(hass): hass, registry, "sensor.living_room_awair_score", - f"{AWAIR_UUID}_{SENSOR_TYPES[API_SCORE][ATTR_UNIQUE_ID]}", + f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_SCORE].unique_id_tag}", "88", {ATTR_ICON: "mdi:blur"}, ) @@ -338,7 +343,7 @@ async def test_awair_unavailable(hass): hass, registry, "sensor.living_room_awair_score", - f"{AWAIR_UUID}_{SENSOR_TYPES[API_SCORE][ATTR_UNIQUE_ID]}", + f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_SCORE].unique_id_tag}", STATE_UNAVAILABLE, {ATTR_ICON: "mdi:blur"}, ) From 96db04213b44bd772581321a6a1547151d484acb Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 6 Sep 2021 09:44:33 +0200 Subject: [PATCH 237/843] Use EntityDescription - vultr (#55789) --- homeassistant/components/vultr/sensor.py | 84 ++++++++++++------------ tests/components/vultr/test_sensor.py | 10 +-- 2 files changed, 48 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/vultr/sensor.py b/homeassistant/components/vultr/sensor.py index 01506d4f47e..f4a9055b54c 100644 --- a/homeassistant/components/vultr/sensor.py +++ b/homeassistant/components/vultr/sensor.py @@ -1,9 +1,15 @@ """Support for monitoring the state of Vultr Subscriptions.""" +from __future__ import annotations + import logging import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_NAME, DATA_GIGABYTES import homeassistant.helpers.config_validation as cv @@ -17,22 +23,29 @@ from . import ( _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = "Vultr {} {}" -MONITORED_CONDITIONS = { - ATTR_CURRENT_BANDWIDTH_USED: [ - "Current Bandwidth Used", - DATA_GIGABYTES, - "mdi:chart-histogram", - ], - ATTR_PENDING_CHARGES: ["Pending Charges", "US$", "mdi:currency-usd"], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=ATTR_CURRENT_BANDWIDTH_USED, + name="Current Bandwidth Used", + native_unit_of_measurement=DATA_GIGABYTES, + icon="mdi:chart-histogram", + ), + SensorEntityDescription( + key=ATTR_PENDING_CHARGES, + name="Pending Charges", + native_unit_of_measurement="US$", + icon="mdi:currency-usd", + ), +) +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_SUBSCRIPTION): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional( - CONF_MONITORED_CONDITIONS, default=list(MONITORED_CONDITIONS) - ): vol.All(cv.ensure_list, [vol.In(MONITORED_CONDITIONS)]), + vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSOR_KEYS): vol.All( + cv.ensure_list, [vol.In(SENSOR_KEYS)] + ), } ) @@ -41,68 +54,55 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Vultr subscription (server) sensor.""" vultr = hass.data[DATA_VULTR] - subscription = config.get(CONF_SUBSCRIPTION) - name = config.get(CONF_NAME) - monitored_conditions = config.get(CONF_MONITORED_CONDITIONS) + subscription = config[CONF_SUBSCRIPTION] + name = config[CONF_NAME] + monitored_conditions = config[CONF_MONITORED_CONDITIONS] if subscription not in vultr.data: _LOGGER.error("Subscription %s not found", subscription) return - sensors = [] + entities = [ + VultrSensor(vultr, subscription, name, description) + for description in SENSOR_TYPES + if description.key in monitored_conditions + ] - for condition in monitored_conditions: - sensors.append(VultrSensor(vultr, subscription, condition, name)) - - add_entities(sensors, True) + add_entities(entities, True) class VultrSensor(SensorEntity): """Representation of a Vultr subscription sensor.""" - def __init__(self, vultr, subscription, condition, name): + def __init__(self, vultr, subscription, name, description: SensorEntityDescription): """Initialize a new Vultr sensor.""" + self.entity_description = description self._vultr = vultr - self._condition = condition self._name = name self.subscription = subscription self.data = None - condition_info = MONITORED_CONDITIONS[condition] - - self._condition_name = condition_info[0] - self._units = condition_info[1] - self._icon = condition_info[2] - @property def name(self): """Return the name of the sensor.""" try: - return self._name.format(self._condition_name) + return self._name.format(self.entity_description.name) except IndexError: try: - return self._name.format(self.data["label"], self._condition_name) + return self._name.format( + self.data["label"], self.entity_description.name + ) except (KeyError, TypeError): return self._name - @property - def icon(self): - """Return the icon used in the frontend if any.""" - return self._icon - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement to present the value in.""" - return self._units - @property def native_value(self): """Return the value of this given sensor type.""" try: - return round(float(self.data.get(self._condition)), 2) + return round(float(self.data.get(self.entity_description.key)), 2) except (TypeError, ValueError): - return self.data.get(self._condition) + return self.data.get(self.entity_description.key) def update(self): """Update state of sensor.""" diff --git a/tests/components/vultr/test_sensor.py b/tests/components/vultr/test_sensor.py index e1dbda1dd04..bacffe8e6af 100644 --- a/tests/components/vultr/test_sensor.py +++ b/tests/components/vultr/test_sensor.py @@ -39,12 +39,12 @@ class TestVultrSensorSetup(unittest.TestCase): { CONF_NAME: vultr.DEFAULT_NAME, CONF_SUBSCRIPTION: "576965", - CONF_MONITORED_CONDITIONS: vultr.MONITORED_CONDITIONS, + CONF_MONITORED_CONDITIONS: vultr.SENSOR_KEYS, }, { CONF_NAME: "Server {}", CONF_SUBSCRIPTION: "123456", - CONF_MONITORED_CONDITIONS: vultr.MONITORED_CONDITIONS, + CONF_MONITORED_CONDITIONS: vultr.SENSOR_KEYS, }, { CONF_NAME: "VPS Charges", @@ -126,7 +126,7 @@ class TestVultrSensorSetup(unittest.TestCase): vultr.PLATFORM_SCHEMA( { CONF_PLATFORM: base_vultr.DOMAIN, - CONF_MONITORED_CONDITIONS: vultr.MONITORED_CONDITIONS, + CONF_MONITORED_CONDITIONS: vultr.SENSOR_KEYS, } ) with pytest.raises(vol.Invalid): # Bad monitored_conditions @@ -154,7 +154,9 @@ class TestVultrSensorSetup(unittest.TestCase): base_vultr.setup(self.hass, VALID_CONFIG) bad_conf = { - CONF_MONITORED_CONDITIONS: vultr.MONITORED_CONDITIONS + CONF_NAME: "Vultr {} {}", + CONF_SUBSCRIPTION: "", + CONF_MONITORED_CONDITIONS: vultr.SENSOR_KEYS, } # No subs at all no_sub_setup = vultr.setup_platform( From a4dae0c1e180398148f9755053ee0915f536d442 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 6 Sep 2021 09:48:12 +0200 Subject: [PATCH 238/843] Use EntityDescription - meteoclimatic (#55792) --- .../components/meteoclimatic/const.py | 153 ++++++++++-------- .../components/meteoclimatic/sensor.py | 42 ++--- 2 files changed, 94 insertions(+), 101 deletions(-) diff --git a/homeassistant/components/meteoclimatic/const.py b/homeassistant/components/meteoclimatic/const.py index cd4be5821ea..f4e51a6fb10 100644 --- a/homeassistant/components/meteoclimatic/const.py +++ b/homeassistant/components/meteoclimatic/const.py @@ -1,9 +1,11 @@ """Meteoclimatic component constants.""" +from __future__ import annotations from datetime import timedelta from meteoclimatic import Condition +from homeassistant.components.sensor import SensorEntityDescription from homeassistant.components.weather import ( ATTR_CONDITION_CLEAR_NIGHT, ATTR_CONDITION_CLOUDY, @@ -45,77 +47,86 @@ CONF_STATION_CODE = "station_code" DEFAULT_WEATHER_CARD = True -SENSOR_TYPE_NAME = "name" -SENSOR_TYPE_UNIT = "unit" -SENSOR_TYPE_ICON = "icon" -SENSOR_TYPE_CLASS = "device_class" -SENSOR_TYPES = { - "temp_current": { - SENSOR_TYPE_NAME: "Temperature", - SENSOR_TYPE_UNIT: TEMP_CELSIUS, - SENSOR_TYPE_CLASS: DEVICE_CLASS_TEMPERATURE, - }, - "temp_max": { - SENSOR_TYPE_NAME: "Daily Max Temperature", - SENSOR_TYPE_UNIT: TEMP_CELSIUS, - SENSOR_TYPE_CLASS: DEVICE_CLASS_TEMPERATURE, - }, - "temp_min": { - SENSOR_TYPE_NAME: "Daily Min Temperature", - SENSOR_TYPE_UNIT: TEMP_CELSIUS, - SENSOR_TYPE_CLASS: DEVICE_CLASS_TEMPERATURE, - }, - "humidity_current": { - SENSOR_TYPE_NAME: "Humidity", - SENSOR_TYPE_UNIT: PERCENTAGE, - SENSOR_TYPE_CLASS: DEVICE_CLASS_HUMIDITY, - }, - "humidity_max": { - SENSOR_TYPE_NAME: "Daily Max Humidity", - SENSOR_TYPE_UNIT: PERCENTAGE, - SENSOR_TYPE_CLASS: DEVICE_CLASS_HUMIDITY, - }, - "humidity_min": { - SENSOR_TYPE_NAME: "Daily Min Humidity", - SENSOR_TYPE_UNIT: PERCENTAGE, - SENSOR_TYPE_CLASS: DEVICE_CLASS_HUMIDITY, - }, - "pressure_current": { - SENSOR_TYPE_NAME: "Pressure", - SENSOR_TYPE_UNIT: PRESSURE_HPA, - SENSOR_TYPE_CLASS: DEVICE_CLASS_PRESSURE, - }, - "pressure_max": { - SENSOR_TYPE_NAME: "Daily Max Pressure", - SENSOR_TYPE_UNIT: PRESSURE_HPA, - SENSOR_TYPE_CLASS: DEVICE_CLASS_PRESSURE, - }, - "pressure_min": { - SENSOR_TYPE_NAME: "Daily Min Pressure", - SENSOR_TYPE_UNIT: PRESSURE_HPA, - SENSOR_TYPE_CLASS: DEVICE_CLASS_PRESSURE, - }, - "wind_current": { - SENSOR_TYPE_NAME: "Wind Speed", - SENSOR_TYPE_UNIT: SPEED_KILOMETERS_PER_HOUR, - SENSOR_TYPE_ICON: "mdi:weather-windy", - }, - "wind_max": { - SENSOR_TYPE_NAME: "Daily Max Wind Speed", - SENSOR_TYPE_UNIT: SPEED_KILOMETERS_PER_HOUR, - SENSOR_TYPE_ICON: "mdi:weather-windy", - }, - "wind_bearing": { - SENSOR_TYPE_NAME: "Wind Bearing", - SENSOR_TYPE_UNIT: DEGREE, - SENSOR_TYPE_ICON: "mdi:weather-windy", - }, - "rain": { - SENSOR_TYPE_NAME: "Daily Precipitation", - SENSOR_TYPE_UNIT: LENGTH_MILLIMETERS, - SENSOR_TYPE_ICON: "mdi:cup-water", - }, -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="temp_current", + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key="temp_max", + name="Daily Max Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key="temp_min", + name="Daily Min Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key="humidity_current", + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key="humidity_max", + name="Daily Max Humidity", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key="humidity_min", + name="Daily Min Humidity", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key="pressure_current", + name="Pressure", + native_unit_of_measurement=PRESSURE_HPA, + device_class=DEVICE_CLASS_PRESSURE, + ), + SensorEntityDescription( + key="pressure_max", + name="Daily Max Pressure", + native_unit_of_measurement=PRESSURE_HPA, + device_class=DEVICE_CLASS_PRESSURE, + ), + SensorEntityDescription( + key="pressure_min", + name="Daily Min Pressure", + native_unit_of_measurement=PRESSURE_HPA, + device_class=DEVICE_CLASS_PRESSURE, + ), + SensorEntityDescription( + key="wind_current", + name="Wind Speed", + native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, + device_class="mdi:weather-windy", + ), + SensorEntityDescription( + key="wind_max", + name="Daily Max Wind Speed", + native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, + device_class="mdi:weather-windy", + ), + SensorEntityDescription( + key="wind_bearing", + name="Wind Bearing", + native_unit_of_measurement=DEGREE, + device_class="mdi:weather-windy", + ), + SensorEntityDescription( + key="rain", + name="Daily Precipitation", + native_unit_of_measurement=LENGTH_MILLIMETERS, + device_class="mdi:cup-water", + ), +) CONDITION_CLASSES = { ATTR_CONDITION_CLEAR_NIGHT: [Condition.moon, Condition.hazemoon], diff --git a/homeassistant/components/meteoclimatic/sensor.py b/homeassistant/components/meteoclimatic/sensor.py index b5a07ad06e6..d3ecb44ce70 100644 --- a/homeassistant/components/meteoclimatic/sensor.py +++ b/homeassistant/components/meteoclimatic/sensor.py @@ -1,7 +1,7 @@ """Support for Meteoclimatic sensor.""" import logging -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.core import HomeAssistant @@ -10,17 +10,7 @@ from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, ) -from .const import ( - ATTRIBUTION, - DOMAIN, - MANUFACTURER, - MODEL, - SENSOR_TYPE_CLASS, - SENSOR_TYPE_ICON, - SENSOR_TYPE_NAME, - SENSOR_TYPE_UNIT, - SENSOR_TYPES, -) +from .const import ATTRIBUTION, DOMAIN, MANUFACTURER, MODEL, SENSOR_TYPES _LOGGER = logging.getLogger(__name__) @@ -32,7 +22,7 @@ async def async_setup_entry( coordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( - [MeteoclimaticSensor(sensor_type, coordinator) for sensor_type in SENSOR_TYPES], + [MeteoclimaticSensor(coordinator, description) for description in SENSOR_TYPES], False, ) @@ -40,20 +30,17 @@ async def async_setup_entry( class MeteoclimaticSensor(CoordinatorEntity, SensorEntity): """Representation of a Meteoclimatic sensor.""" - def __init__(self, sensor_type: str, coordinator: DataUpdateCoordinator) -> None: + _attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} + + def __init__( + self, coordinator: DataUpdateCoordinator, description: SensorEntityDescription + ) -> None: """Initialize the Meteoclimatic sensor.""" super().__init__(coordinator) - self._type = sensor_type + self.entity_description = description station = self.coordinator.data["station"] - self._attr_device_class = SENSOR_TYPES[sensor_type].get(SENSOR_TYPE_CLASS) - self._attr_icon = SENSOR_TYPES[sensor_type].get(SENSOR_TYPE_ICON) - self._attr_name = ( - f"{station.name} {SENSOR_TYPES[sensor_type][SENSOR_TYPE_NAME]}" - ) - self._attr_unique_id = f"{station.code}_{sensor_type}" - self._attr_native_unit_of_measurement = SENSOR_TYPES[sensor_type].get( - SENSOR_TYPE_UNIT - ) + self._attr_name = f"{station.name} {description.name}" + self._attr_unique_id = f"{station.code}_{description.key}" @property def device_info(self): @@ -70,12 +57,7 @@ class MeteoclimaticSensor(CoordinatorEntity, SensorEntity): def native_value(self): """Return the state of the sensor.""" return ( - getattr(self.coordinator.data["weather"], self._type) + getattr(self.coordinator.data["weather"], self.entity_description.key) if self.coordinator.data else None ) - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return {ATTR_ATTRIBUTION: ATTRIBUTION} From 77b60c712ee85186ee5d65eebaa1d1e3ce23e0b6 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 6 Sep 2021 09:54:07 +0200 Subject: [PATCH 239/843] Use EntityDescription - sabnzbd (#55788) --- homeassistant/components/sabnzbd/__init__.py | 98 +++++++++++++++++--- homeassistant/components/sabnzbd/sensor.py | 60 ++++++------ 2 files changed, 110 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/sabnzbd/__init__.py b/homeassistant/components/sabnzbd/__init__.py index a420ca53814..f6e15bd074c 100644 --- a/homeassistant/components/sabnzbd/__init__.py +++ b/homeassistant/components/sabnzbd/__init__.py @@ -1,6 +1,7 @@ """Support for monitoring an SABnzbd NZB client.""" from __future__ import annotations +from dataclasses import dataclass from datetime import timedelta import logging @@ -8,6 +9,7 @@ from pysabnzbd import SabnzbdApi, SabnzbdApiException import voluptuous as vol from homeassistant.components.discovery import SERVICE_SABNZBD +from homeassistant.components.sensor import SensorEntityDescription from homeassistant.const import ( CONF_API_KEY, CONF_HOST, @@ -52,19 +54,87 @@ SERVICE_SET_SPEED = "set_speed" SIGNAL_SABNZBD_UPDATED = "sabnzbd_updated" -SENSOR_TYPES = { - "current_status": ["Status", None, "status"], - "speed": ["Speed", DATA_RATE_MEGABYTES_PER_SECOND, "kbpersec"], - "queue_size": ["Queue", DATA_MEGABYTES, "mb"], - "queue_remaining": ["Left", DATA_MEGABYTES, "mbleft"], - "disk_size": ["Disk", DATA_GIGABYTES, "diskspacetotal1"], - "disk_free": ["Disk Free", DATA_GIGABYTES, "diskspace1"], - "queue_count": ["Queue Count", None, "noofslots_total"], - "day_size": ["Daily Total", DATA_GIGABYTES, "day_size"], - "week_size": ["Weekly Total", DATA_GIGABYTES, "week_size"], - "month_size": ["Monthly Total", DATA_GIGABYTES, "month_size"], - "total_size": ["Total", DATA_GIGABYTES, "total_size"], -} + +@dataclass +class SabnzbdRequiredKeysMixin: + """Mixin for required keys.""" + + field_name: str + + +@dataclass +class SabnzbdSensorEntityDescription(SensorEntityDescription, SabnzbdRequiredKeysMixin): + """Describes Sabnzbd sensor entity.""" + + +SENSOR_TYPES: tuple[SabnzbdSensorEntityDescription, ...] = ( + SabnzbdSensorEntityDescription( + key="current_status", + name="Status", + field_name="status", + ), + SabnzbdSensorEntityDescription( + key="speed", + name="Speed", + native_unit_of_measurement=DATA_RATE_MEGABYTES_PER_SECOND, + field_name="kbpersec", + ), + SabnzbdSensorEntityDescription( + key="queue_size", + name="Queue", + native_unit_of_measurement=DATA_MEGABYTES, + field_name="mb", + ), + SabnzbdSensorEntityDescription( + key="queue_remaining", + name="Left", + native_unit_of_measurement=DATA_MEGABYTES, + field_name="mbleft", + ), + SabnzbdSensorEntityDescription( + key="disk_size", + name="Disk", + native_unit_of_measurement=DATA_GIGABYTES, + field_name="diskspacetotal1", + ), + SabnzbdSensorEntityDescription( + key="disk_free", + name="Disk Free", + native_unit_of_measurement=DATA_GIGABYTES, + field_name="diskspace1", + ), + SabnzbdSensorEntityDescription( + key="queue_count", + name="Queue Count", + field_name="noofslots_total", + ), + SabnzbdSensorEntityDescription( + key="day_size", + name="Daily Total", + native_unit_of_measurement=DATA_GIGABYTES, + field_name="day_size", + ), + SabnzbdSensorEntityDescription( + key="week_size", + name="Weekly Total", + native_unit_of_measurement=DATA_GIGABYTES, + field_name="week_size", + ), + SabnzbdSensorEntityDescription( + key="month_size", + name="Monthly Total", + native_unit_of_measurement=DATA_GIGABYTES, + field_name="month_size", + ), + SabnzbdSensorEntityDescription( + key="total_size", + name="Total", + native_unit_of_measurement=DATA_GIGABYTES, + field_name="total_size", + ), +) + +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] SPEED_LIMIT_SCHEMA = vol.Schema( {vol.Optional(ATTR_SPEED, default=DEFAULT_SPEED_LIMIT): cv.string} @@ -80,7 +150,7 @@ CONFIG_SCHEMA = vol.Schema( vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_SENSORS): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] + cv.ensure_list, [vol.In(SENSOR_KEYS)] ), vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, } diff --git a/homeassistant/components/sabnzbd/sensor.py b/homeassistant/components/sabnzbd/sensor.py index ffe57e608bf..3079bd75601 100644 --- a/homeassistant/components/sabnzbd/sensor.py +++ b/homeassistant/components/sabnzbd/sensor.py @@ -2,7 +2,12 @@ from homeassistant.components.sensor import SensorEntity from homeassistant.helpers.dispatcher import async_dispatcher_connect -from . import DATA_SABNZBD, SENSOR_TYPES, SIGNAL_SABNZBD_UPDATED +from . import ( + DATA_SABNZBD, + SENSOR_TYPES, + SIGNAL_SABNZBD_UPDATED, + SabnzbdSensorEntityDescription, +) async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): @@ -14,22 +19,27 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= sensors = sab_api_data.sensors client_name = sab_api_data.name async_add_entities( - [SabnzbdSensor(sensor, sab_api_data, client_name) for sensor in sensors] + [ + SabnzbdSensor(sab_api_data, client_name, description) + for description in SENSOR_TYPES + if description.key in sensors + ] ) class SabnzbdSensor(SensorEntity): """Representation of an SABnzbd sensor.""" - def __init__(self, sensor_type, sabnzbd_api_data, client_name): + entity_description: SabnzbdSensorEntityDescription + _attr_should_poll = False + + def __init__( + self, sabnzbd_api_data, client_name, description: SabnzbdSensorEntityDescription + ): """Initialize the sensor.""" - self._client_name = client_name - self._field_name = SENSOR_TYPES[sensor_type][2] - self._name = SENSOR_TYPES[sensor_type][0] + self.entity_description = description self._sabnzbd_api = sabnzbd_api_data - self._state = None - self._type = sensor_type - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + self._attr_name = f"{client_name} {description.name}" async def async_added_to_hass(self): """Call when entity about to be added to hass.""" @@ -39,33 +49,15 @@ class SabnzbdSensor(SensorEntity): ) ) - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._client_name} {self._name}" - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def should_poll(self): - """Don't poll. Will be updated by dispatcher signal.""" - return False - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement - def update_state(self, args): """Get the latest data and updates the states.""" - self._state = self._sabnzbd_api.get_queue_field(self._field_name) + self._attr_native_value = self._sabnzbd_api.get_queue_field( + self.entity_description.field_name + ) - if self._type == "speed": - self._state = round(float(self._state) / 1024, 1) - elif "size" in self._type: - self._state = round(float(self._state), 2) + if self.entity_description.key == "speed": + self._attr_native_value = round(float(self._attr_native_value) / 1024, 1) + elif "size" in self.entity_description.key: + self._attr_native_value = round(float(self._attr_native_value), 2) self.schedule_update_ha_state() From 655399eb7bc6f8dc827c3f20e3e1d6fa41b29268 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 6 Sep 2021 10:12:09 +0200 Subject: [PATCH 240/843] Use EntityDescription - aemet (#55744) --- homeassistant/components/aemet/const.py | 256 +++++++++++++---------- homeassistant/components/aemet/sensor.py | 94 ++++----- 2 files changed, 182 insertions(+), 168 deletions(-) diff --git a/homeassistant/components/aemet/const.py b/homeassistant/components/aemet/const.py index 0927f64dd2a..e84060b444d 100644 --- a/homeassistant/components/aemet/const.py +++ b/homeassistant/components/aemet/const.py @@ -1,5 +1,7 @@ """Constant values for the AEMET OpenData component.""" +from __future__ import annotations +from homeassistant.components.sensor import SensorEntityDescription from homeassistant.components.weather import ( ATTR_CONDITION_CLEAR_NIGHT, ATTR_CONDITION_CLOUDY, @@ -40,9 +42,6 @@ DEFAULT_NAME = "AEMET" DOMAIN = "aemet" ENTRY_NAME = "name" ENTRY_WEATHER_COORDINATOR = "weather_coordinator" -SENSOR_NAME = "sensor_name" -SENSOR_UNIT = "sensor_unit" -SENSOR_DEVICE_CLASS = "sensor_device_class" ATTR_API_CONDITION = "condition" ATTR_API_FORECAST_DAILY = "forecast-daily" @@ -200,118 +199,145 @@ FORECAST_MODE_ATTR_API = { FORECAST_MODE_HOURLY: ATTR_API_FORECAST_HOURLY, } -FORECAST_SENSOR_TYPES = { - ATTR_FORECAST_CONDITION: { - SENSOR_NAME: "Condition", - }, - ATTR_FORECAST_PRECIPITATION: { - SENSOR_NAME: "Precipitation", - SENSOR_UNIT: PRECIPITATION_MILLIMETERS_PER_HOUR, - }, - ATTR_FORECAST_PRECIPITATION_PROBABILITY: { - SENSOR_NAME: "Precipitation probability", - SENSOR_UNIT: PERCENTAGE, - }, - ATTR_FORECAST_TEMP: { - SENSOR_NAME: "Temperature", - SENSOR_UNIT: TEMP_CELSIUS, - SENSOR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - }, - ATTR_FORECAST_TEMP_LOW: { - SENSOR_NAME: "Temperature Low", - SENSOR_UNIT: TEMP_CELSIUS, - SENSOR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - }, - ATTR_FORECAST_TIME: { - SENSOR_NAME: "Time", - SENSOR_DEVICE_CLASS: DEVICE_CLASS_TIMESTAMP, - }, - ATTR_FORECAST_WIND_BEARING: { - SENSOR_NAME: "Wind bearing", - SENSOR_UNIT: DEGREE, - }, - ATTR_FORECAST_WIND_SPEED: { - SENSOR_NAME: "Wind speed", - SENSOR_UNIT: SPEED_KILOMETERS_PER_HOUR, - }, -} -WEATHER_SENSOR_TYPES = { - ATTR_API_CONDITION: { - SENSOR_NAME: "Condition", - }, - ATTR_API_HUMIDITY: { - SENSOR_NAME: "Humidity", - SENSOR_UNIT: PERCENTAGE, - SENSOR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, - }, - ATTR_API_PRESSURE: { - SENSOR_NAME: "Pressure", - SENSOR_UNIT: PRESSURE_HPA, - SENSOR_DEVICE_CLASS: DEVICE_CLASS_PRESSURE, - }, - ATTR_API_RAIN: { - SENSOR_NAME: "Rain", - SENSOR_UNIT: PRECIPITATION_MILLIMETERS_PER_HOUR, - }, - ATTR_API_RAIN_PROB: { - SENSOR_NAME: "Rain probability", - SENSOR_UNIT: PERCENTAGE, - }, - ATTR_API_SNOW: { - SENSOR_NAME: "Snow", - SENSOR_UNIT: PRECIPITATION_MILLIMETERS_PER_HOUR, - }, - ATTR_API_SNOW_PROB: { - SENSOR_NAME: "Snow probability", - SENSOR_UNIT: PERCENTAGE, - }, - ATTR_API_STATION_ID: { - SENSOR_NAME: "Station ID", - }, - ATTR_API_STATION_NAME: { - SENSOR_NAME: "Station name", - }, - ATTR_API_STATION_TIMESTAMP: { - SENSOR_NAME: "Station timestamp", - SENSOR_DEVICE_CLASS: DEVICE_CLASS_TIMESTAMP, - }, - ATTR_API_STORM_PROB: { - SENSOR_NAME: "Storm probability", - SENSOR_UNIT: PERCENTAGE, - }, - ATTR_API_TEMPERATURE: { - SENSOR_NAME: "Temperature", - SENSOR_UNIT: TEMP_CELSIUS, - SENSOR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - }, - ATTR_API_TEMPERATURE_FEELING: { - SENSOR_NAME: "Temperature feeling", - SENSOR_UNIT: TEMP_CELSIUS, - SENSOR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - }, - ATTR_API_TOWN_ID: { - SENSOR_NAME: "Town ID", - }, - ATTR_API_TOWN_NAME: { - SENSOR_NAME: "Town name", - }, - ATTR_API_TOWN_TIMESTAMP: { - SENSOR_NAME: "Town timestamp", - SENSOR_DEVICE_CLASS: DEVICE_CLASS_TIMESTAMP, - }, - ATTR_API_WIND_BEARING: { - SENSOR_NAME: "Wind bearing", - SENSOR_UNIT: DEGREE, - }, - ATTR_API_WIND_MAX_SPEED: { - SENSOR_NAME: "Wind max speed", - SENSOR_UNIT: SPEED_KILOMETERS_PER_HOUR, - }, - ATTR_API_WIND_SPEED: { - SENSOR_NAME: "Wind speed", - SENSOR_UNIT: SPEED_KILOMETERS_PER_HOUR, - }, -} +FORECAST_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=ATTR_FORECAST_CONDITION, + name="Condition", + ), + SensorEntityDescription( + key=ATTR_FORECAST_PRECIPITATION, + name="Precipitation", + native_unit_of_measurement=PRECIPITATION_MILLIMETERS_PER_HOUR, + ), + SensorEntityDescription( + key=ATTR_FORECAST_PRECIPITATION_PROBABILITY, + name="Precipitation probability", + native_unit_of_measurement=PERCENTAGE, + ), + SensorEntityDescription( + key=ATTR_FORECAST_TEMP, + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key=ATTR_FORECAST_TEMP_LOW, + name="Temperature Low", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key=ATTR_FORECAST_TIME, + name="Time", + device_class=DEVICE_CLASS_TIMESTAMP, + ), + SensorEntityDescription( + key=ATTR_FORECAST_WIND_BEARING, + name="Wind bearing", + native_unit_of_measurement=DEGREE, + ), + SensorEntityDescription( + key=ATTR_FORECAST_WIND_SPEED, + name="Wind speed", + native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, + ), +) +WEATHER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=ATTR_API_CONDITION, + name="Condition", + ), + SensorEntityDescription( + key=ATTR_API_HUMIDITY, + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key=ATTR_API_PRESSURE, + name="Pressure", + native_unit_of_measurement=PRESSURE_HPA, + device_class=DEVICE_CLASS_PRESSURE, + ), + SensorEntityDescription( + key=ATTR_API_RAIN, + name="Rain", + native_unit_of_measurement=PRECIPITATION_MILLIMETERS_PER_HOUR, + ), + SensorEntityDescription( + key=ATTR_API_RAIN_PROB, + name="Rain probability", + native_unit_of_measurement=PERCENTAGE, + ), + SensorEntityDescription( + key=ATTR_API_SNOW, + name="Snow", + native_unit_of_measurement=PRECIPITATION_MILLIMETERS_PER_HOUR, + ), + SensorEntityDescription( + key=ATTR_API_SNOW_PROB, + name="Snow probability", + native_unit_of_measurement=PERCENTAGE, + ), + SensorEntityDescription( + key=ATTR_API_STATION_ID, + name="Station ID", + ), + SensorEntityDescription( + key=ATTR_API_STATION_NAME, + name="Station name", + ), + SensorEntityDescription( + key=ATTR_API_STATION_TIMESTAMP, + name="Station timestamp", + device_class=DEVICE_CLASS_TIMESTAMP, + ), + SensorEntityDescription( + key=ATTR_API_STORM_PROB, + name="Storm probability", + native_unit_of_measurement=PERCENTAGE, + ), + SensorEntityDescription( + key=ATTR_API_TEMPERATURE, + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key=ATTR_API_TEMPERATURE_FEELING, + name="Temperature feeling", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key=ATTR_API_TOWN_ID, + name="Town ID", + ), + SensorEntityDescription( + key=ATTR_API_TOWN_NAME, + name="Town name", + ), + SensorEntityDescription( + key=ATTR_API_TOWN_TIMESTAMP, + name="Town timestamp", + device_class=DEVICE_CLASS_TIMESTAMP, + ), + SensorEntityDescription( + key=ATTR_API_WIND_BEARING, + name="Wind bearing", + native_unit_of_measurement=DEGREE, + ), + SensorEntityDescription( + key=ATTR_API_WIND_MAX_SPEED, + name="Wind max speed", + native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, + ), + SensorEntityDescription( + key=ATTR_API_WIND_SPEED, + name="Wind speed", + native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, + ), +) WIND_BEARING_MAP = { "C": None, diff --git a/homeassistant/components/aemet/sensor.py b/homeassistant/components/aemet/sensor.py index 35336980e1a..685e9fb200b 100644 --- a/homeassistant/components/aemet/sensor.py +++ b/homeassistant/components/aemet/sensor.py @@ -1,5 +1,7 @@ """Support for the AEMET OpenData service.""" -from homeassistant.components.sensor import SensorEntity +from __future__ import annotations + +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -14,9 +16,6 @@ from .const import ( FORECAST_MONITORED_CONDITIONS, FORECAST_SENSOR_TYPES, MONITORED_CONDITIONS, - SENSOR_DEVICE_CLASS, - SENSOR_NAME, - SENSOR_UNIT, WEATHER_SENSOR_TYPES, ) from .weather_update_coordinator import WeatherUpdateCoordinator @@ -28,37 +27,30 @@ async def async_setup_entry(hass, config_entry, async_add_entities): name = domain_data[ENTRY_NAME] weather_coordinator = domain_data[ENTRY_WEATHER_COORDINATOR] - weather_sensor_types = WEATHER_SENSOR_TYPES - forecast_sensor_types = FORECAST_SENSOR_TYPES - - entities = [] - for sensor_type in MONITORED_CONDITIONS: - unique_id = f"{config_entry.unique_id}-{sensor_type}" - entities.append( - AemetSensor( - name, - unique_id, - sensor_type, - weather_sensor_types[sensor_type], + unique_id = config_entry.unique_id + entities: list[AbstractAemetSensor] = [ + AemetSensor(name, unique_id, weather_coordinator, description) + for description in WEATHER_SENSOR_TYPES + if description.key in MONITORED_CONDITIONS + ] + entities.extend( + [ + AemetForecastSensor( + name_prefix, + unique_id_prefix, weather_coordinator, + mode, + description, ) - ) - - for mode in FORECAST_MODES: - name = f"{domain_data[ENTRY_NAME]} {mode}" - - for sensor_type in FORECAST_MONITORED_CONDITIONS: - unique_id = f"{config_entry.unique_id}-forecast-{mode}-{sensor_type}" - entities.append( - AemetForecastSensor( - f"{name} Forecast", - unique_id, - sensor_type, - forecast_sensor_types[sensor_type], - weather_coordinator, - mode, - ) + for mode in FORECAST_MODES + if ( + (name_prefix := f"{domain_data[ENTRY_NAME]} {mode} Forecast") + and (unique_id_prefix := f"{unique_id}-forecast-{mode}") ) + for description in FORECAST_SENSOR_TYPES + if description.key in FORECAST_MONITORED_CONDITIONS + ] + ) async_add_entities(entities) @@ -72,20 +64,14 @@ class AbstractAemetSensor(CoordinatorEntity, SensorEntity): self, name, unique_id, - sensor_type, - sensor_configuration, coordinator: WeatherUpdateCoordinator, + description: SensorEntityDescription, ): """Initialize the sensor.""" super().__init__(coordinator) - self._name = name - self._unique_id = unique_id - self._sensor_type = sensor_type - self._sensor_name = sensor_configuration[SENSOR_NAME] - self._attr_name = f"{self._name} {self._sensor_name}" - self._attr_unique_id = self._unique_id - self._attr_device_class = sensor_configuration.get(SENSOR_DEVICE_CLASS) - self._attr_native_unit_of_measurement = sensor_configuration.get(SENSOR_UNIT) + self.entity_description = description + self._attr_name = f"{name} {description.name}" + self._attr_unique_id = unique_id class AemetSensor(AbstractAemetSensor): @@ -95,20 +81,21 @@ class AemetSensor(AbstractAemetSensor): self, name, unique_id, - sensor_type, - sensor_configuration, weather_coordinator: WeatherUpdateCoordinator, + description: SensorEntityDescription, ): """Initialize the sensor.""" super().__init__( - name, unique_id, sensor_type, sensor_configuration, weather_coordinator + name=name, + unique_id=f"{unique_id}-{description.key}", + coordinator=weather_coordinator, + description=description, ) - self._weather_coordinator = weather_coordinator @property def native_value(self): """Return the state of the device.""" - return self._weather_coordinator.data.get(self._sensor_type) + return self.coordinator.data.get(self.entity_description.key) class AemetForecastSensor(AbstractAemetSensor): @@ -118,16 +105,17 @@ class AemetForecastSensor(AbstractAemetSensor): self, name, unique_id, - sensor_type, - sensor_configuration, weather_coordinator: WeatherUpdateCoordinator, forecast_mode, + description: SensorEntityDescription, ): """Initialize the sensor.""" super().__init__( - name, unique_id, sensor_type, sensor_configuration, weather_coordinator + name=name, + unique_id=f"{unique_id}-{description.key}", + coordinator=weather_coordinator, + description=description, ) - self._weather_coordinator = weather_coordinator self._forecast_mode = forecast_mode self._attr_entity_registry_enabled_default = ( self._forecast_mode == FORECAST_MODE_DAILY @@ -137,9 +125,9 @@ class AemetForecastSensor(AbstractAemetSensor): def native_value(self): """Return the state of the device.""" forecast = None - forecasts = self._weather_coordinator.data.get( + forecasts = self.coordinator.data.get( FORECAST_MODE_ATTR_API[self._forecast_mode] ) if forecasts: - forecast = forecasts[0].get(self._sensor_type) + forecast = forecasts[0].get(self.entity_description.key) return forecast From a4e4ffef0a36ade7fedb51e708f92624a593000b Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 6 Sep 2021 10:19:31 +0200 Subject: [PATCH 241/843] Use EntityDescription - apcupsd (#55790) --- homeassistant/components/apcupsd/sensor.py | 473 ++++++++++++++++----- 1 file changed, 378 insertions(+), 95 deletions(-) diff --git a/homeassistant/components/apcupsd/sensor.py b/homeassistant/components/apcupsd/sensor.py index 5937ff6a852..f7a350925ec 100644 --- a/homeassistant/components/apcupsd/sensor.py +++ b/homeassistant/components/apcupsd/sensor.py @@ -1,10 +1,16 @@ """Support for APCUPSd sensors.""" +from __future__ import annotations + import logging from apcaccess.status import ALL_UNITS import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( CONF_RESOURCES, DEVICE_CLASS_TEMPERATURE, @@ -25,74 +31,360 @@ from . import DOMAIN _LOGGER = logging.getLogger(__name__) SENSOR_PREFIX = "UPS " -SENSOR_TYPES = { - "alarmdel": ["Alarm Delay", None, "mdi:alarm", None], - "ambtemp": ["Ambient Temperature", None, "mdi:thermometer", None], - "apc": ["Status Data", None, "mdi:information-outline", None], - "apcmodel": ["Model", None, "mdi:information-outline", None], - "badbatts": ["Bad Batteries", None, "mdi:information-outline", None], - "battdate": ["Battery Replaced", None, "mdi:calendar-clock", None], - "battstat": ["Battery Status", None, "mdi:information-outline", None], - "battv": ["Battery Voltage", ELECTRIC_POTENTIAL_VOLT, "mdi:flash", None], - "bcharge": ["Battery", PERCENTAGE, "mdi:battery", None], - "cable": ["Cable Type", None, "mdi:ethernet-cable", None], - "cumonbatt": ["Total Time on Battery", None, "mdi:timer-outline", None], - "date": ["Status Date", None, "mdi:calendar-clock", None], - "dipsw": ["Dip Switch Settings", None, "mdi:information-outline", None], - "dlowbatt": ["Low Battery Signal", None, "mdi:clock-alert", None], - "driver": ["Driver", None, "mdi:information-outline", None], - "dshutd": ["Shutdown Delay", None, "mdi:timer-outline", None], - "dwake": ["Wake Delay", None, "mdi:timer-outline", None], - "endapc": ["Date and Time", None, "mdi:calendar-clock", None], - "extbatts": ["External Batteries", None, "mdi:information-outline", None], - "firmware": ["Firmware Version", None, "mdi:information-outline", None], - "hitrans": ["Transfer High", ELECTRIC_POTENTIAL_VOLT, "mdi:flash", None], - "hostname": ["Hostname", None, "mdi:information-outline", None], - "humidity": ["Ambient Humidity", PERCENTAGE, "mdi:water-percent", None], - "itemp": ["Internal Temperature", TEMP_CELSIUS, None, DEVICE_CLASS_TEMPERATURE], - "lastxfer": ["Last Transfer", None, "mdi:transfer", None], - "linefail": ["Input Voltage Status", None, "mdi:information-outline", None], - "linefreq": ["Line Frequency", FREQUENCY_HERTZ, "mdi:information-outline", None], - "linev": ["Input Voltage", ELECTRIC_POTENTIAL_VOLT, "mdi:flash", None], - "loadpct": ["Load", PERCENTAGE, "mdi:gauge", None], - "loadapnt": ["Load Apparent Power", PERCENTAGE, "mdi:gauge", None], - "lotrans": ["Transfer Low", ELECTRIC_POTENTIAL_VOLT, "mdi:flash", None], - "mandate": ["Manufacture Date", None, "mdi:calendar", None], - "masterupd": ["Master Update", None, "mdi:information-outline", None], - "maxlinev": ["Input Voltage High", ELECTRIC_POTENTIAL_VOLT, "mdi:flash", None], - "maxtime": ["Battery Timeout", None, "mdi:timer-off-outline", None], - "mbattchg": ["Battery Shutdown", PERCENTAGE, "mdi:battery-alert", None], - "minlinev": ["Input Voltage Low", ELECTRIC_POTENTIAL_VOLT, "mdi:flash", None], - "mintimel": ["Shutdown Time", None, "mdi:timer-outline", None], - "model": ["Model", None, "mdi:information-outline", None], - "nombattv": ["Battery Nominal Voltage", ELECTRIC_POTENTIAL_VOLT, "mdi:flash", None], - "nominv": ["Nominal Input Voltage", ELECTRIC_POTENTIAL_VOLT, "mdi:flash", None], - "nomoutv": ["Nominal Output Voltage", ELECTRIC_POTENTIAL_VOLT, "mdi:flash", None], - "nompower": ["Nominal Output Power", POWER_WATT, "mdi:flash", None], - "nomapnt": ["Nominal Apparent Power", POWER_VOLT_AMPERE, "mdi:flash", None], - "numxfers": ["Transfer Count", None, "mdi:counter", None], - "outcurnt": ["Output Current", ELECTRIC_CURRENT_AMPERE, "mdi:flash", None], - "outputv": ["Output Voltage", ELECTRIC_POTENTIAL_VOLT, "mdi:flash", None], - "reg1": ["Register 1 Fault", None, "mdi:information-outline", None], - "reg2": ["Register 2 Fault", None, "mdi:information-outline", None], - "reg3": ["Register 3 Fault", None, "mdi:information-outline", None], - "retpct": ["Restore Requirement", PERCENTAGE, "mdi:battery-alert", None], - "selftest": ["Last Self Test", None, "mdi:calendar-clock", None], - "sense": ["Sensitivity", None, "mdi:information-outline", None], - "serialno": ["Serial Number", None, "mdi:information-outline", None], - "starttime": ["Startup Time", None, "mdi:calendar-clock", None], - "statflag": ["Status Flag", None, "mdi:information-outline", None], - "status": ["Status", None, "mdi:information-outline", None], - "stesti": ["Self Test Interval", None, "mdi:information-outline", None], - "timeleft": ["Time Left", None, "mdi:clock-alert", None], - "tonbatt": ["Time on Battery", None, "mdi:timer-outline", None], - "upsmode": ["Mode", None, "mdi:information-outline", None], - "upsname": ["Name", None, "mdi:information-outline", None], - "version": ["Daemon Info", None, "mdi:information-outline", None], - "xoffbat": ["Transfer from Battery", None, "mdi:transfer", None], - "xoffbatt": ["Transfer from Battery", None, "mdi:transfer", None], - "xonbatt": ["Transfer to Battery", None, "mdi:transfer", None], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="alarmdel", + name="Alarm Delay", + icon="mdi:alarm", + ), + SensorEntityDescription( + key="ambtemp", + name="Ambient Temperature", + icon="mdi:thermometer", + ), + SensorEntityDescription( + key="apc", + name="Status Data", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="apcmodel", + name="Model", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="badbatts", + name="Bad Batteries", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="battdate", + name="Battery Replaced", + icon="mdi:calendar-clock", + ), + SensorEntityDescription( + key="battstat", + name="Battery Status", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="battv", + name="Battery Voltage", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon="mdi:flash", + ), + SensorEntityDescription( + key="bcharge", + name="Battery", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:battery", + ), + SensorEntityDescription( + key="cable", + name="Cable Type", + icon="mdi:ethernet-cable", + ), + SensorEntityDescription( + key="cumonbatt", + name="Total Time on Battery", + icon="mdi:timer-outline", + ), + SensorEntityDescription( + key="date", + name="Status Date", + icon="mdi:calendar-clock", + ), + SensorEntityDescription( + key="dipsw", + name="Dip Switch Settings", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="dlowbatt", + name="Low Battery Signal", + icon="mdi:clock-alert", + ), + SensorEntityDescription( + key="driver", + name="Driver", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="dshutd", + name="Shutdown Delay", + icon="mdi:timer-outline", + ), + SensorEntityDescription( + key="dwake", + name="Wake Delay", + icon="mdi:timer-outline", + ), + SensorEntityDescription( + key="endapc", + name="Date and Time", + icon="mdi:calendar-clock", + ), + SensorEntityDescription( + key="extbatts", + name="External Batteries", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="firmware", + name="Firmware Version", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="hitrans", + name="Transfer High", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon="mdi:flash", + ), + SensorEntityDescription( + key="hostname", + name="Hostname", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="humidity", + name="Ambient Humidity", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:water-percent", + ), + SensorEntityDescription( + key="itemp", + name="Internal Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key="lastxfer", + name="Last Transfer", + icon="mdi:transfer", + ), + SensorEntityDescription( + key="linefail", + name="Input Voltage Status", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="linefreq", + name="Line Frequency", + native_unit_of_measurement=FREQUENCY_HERTZ, + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="linev", + name="Input Voltage", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon="mdi:flash", + ), + SensorEntityDescription( + key="loadpct", + name="Load", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:gauge", + ), + SensorEntityDescription( + key="loadapnt", + name="Load Apparent Power", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:gauge", + ), + SensorEntityDescription( + key="lotrans", + name="Transfer Low", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon="mdi:flash", + ), + SensorEntityDescription( + key="mandate", + name="Manufacture Date", + icon="mdi:calendar", + ), + SensorEntityDescription( + key="masterupd", + name="Master Update", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="maxlinev", + name="Input Voltage High", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon="mdi:flash", + ), + SensorEntityDescription( + key="maxtime", + name="Battery Timeout", + icon="mdi:timer-off-outline", + ), + SensorEntityDescription( + key="mbattchg", + name="Battery Shutdown", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:battery-alert", + ), + SensorEntityDescription( + key="minlinev", + name="Input Voltage Low", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon="mdi:flash", + ), + SensorEntityDescription( + key="mintimel", + name="Shutdown Time", + icon="mdi:timer-outline", + ), + SensorEntityDescription( + key="model", + name="Model", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="nombattv", + name="Battery Nominal Voltage", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon="mdi:flash", + ), + SensorEntityDescription( + key="nominv", + name="Nominal Input Voltage", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon="mdi:flash", + ), + SensorEntityDescription( + key="nomoutv", + name="Nominal Output Voltage", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon="mdi:flash", + ), + SensorEntityDescription( + key="nompower", + name="Nominal Output Power", + native_unit_of_measurement=POWER_WATT, + icon="mdi:flash", + ), + SensorEntityDescription( + key="nomapnt", + name="Nominal Apparent Power", + native_unit_of_measurement=POWER_VOLT_AMPERE, + icon="mdi:flash", + ), + SensorEntityDescription( + key="numxfers", + name="Transfer Count", + icon="mdi:counter", + ), + SensorEntityDescription( + key="outcurnt", + name="Output Current", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + icon="mdi:flash", + ), + SensorEntityDescription( + key="outputv", + name="Output Voltage", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon="mdi:flash", + ), + SensorEntityDescription( + key="reg1", + name="Register 1 Fault", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="reg2", + name="Register 2 Fault", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="reg3", + name="Register 3 Fault", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="retpct", + name="Restore Requirement", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:battery-alert", + ), + SensorEntityDescription( + key="selftest", + name="Last Self Test", + icon="mdi:calendar-clock", + ), + SensorEntityDescription( + key="sense", + name="Sensitivity", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="serialno", + name="Serial Number", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="starttime", + name="Startup Time", + icon="mdi:calendar-clock", + ), + SensorEntityDescription( + key="statflag", + name="Status Flag", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="status", + name="Status", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="stesti", + name="Self Test Interval", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="timeleft", + name="Time Left", + icon="mdi:clock-alert", + ), + SensorEntityDescription( + key="tonbatt", + name="Time on Battery", + icon="mdi:timer-outline", + ), + SensorEntityDescription( + key="upsmode", + name="Mode", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="upsname", + name="Name", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="version", + name="Daemon Info", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="xoffbat", + name="Transfer from Battery", + icon="mdi:transfer", + ), + SensorEntityDescription( + key="xoffbatt", + name="Transfer from Battery", + icon="mdi:transfer", + ), + SensorEntityDescription( + key="xonbatt", + name="Transfer to Battery", + icon="mdi:transfer", + ), +) +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] SPECIFIC_UNITS = {"ITEMP": TEMP_CELSIUS} INFERRED_UNITS = { @@ -111,7 +403,7 @@ INFERRED_UNITS = { PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_RESOURCES, default=[]): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] + cv.ensure_list, [vol.In(SENSOR_KEYS)] ) } ) @@ -120,25 +412,20 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the APCUPSd sensors.""" apcups_data = hass.data[DOMAIN] - entities = [] + resources = config[CONF_RESOURCES] - for resource in config[CONF_RESOURCES]: - sensor_type = resource.lower() - - if sensor_type not in SENSOR_TYPES: - SENSOR_TYPES[sensor_type] = [ - sensor_type.title(), - "", - "mdi:information-outline", - ] - - if sensor_type.upper() not in apcups_data.status: + for resource in resources: + if resource.upper() not in apcups_data.status: _LOGGER.warning( "Sensor type: %s does not appear in the APCUPSd status output", - sensor_type, + resource, ) - entities.append(APCUPSdSensor(apcups_data, sensor_type)) + entities = [ + APCUPSdSensor(apcups_data, description) + for description in SENSOR_TYPES + if description.key in resources + ] add_entities(entities, True) @@ -159,22 +446,18 @@ def infer_unit(value): class APCUPSdSensor(SensorEntity): """Representation of a sensor entity for APCUPSd status values.""" - def __init__(self, data, sensor_type): + def __init__(self, data, description: SensorEntityDescription): """Initialize the sensor.""" + self.entity_description = description self._data = data - self.type = sensor_type - self._attr_name = SENSOR_PREFIX + SENSOR_TYPES[sensor_type][0] - self._attr_icon = SENSOR_TYPES[self.type][2] - self._attr_native_unit_of_measurement = SENSOR_TYPES[sensor_type][1] - self._attr_device_class = SENSOR_TYPES[sensor_type][3] + self._attr_name = f"{SENSOR_PREFIX}{description.name}" def update(self): """Get the latest status and use it to update our sensor state.""" - if self.type.upper() not in self._data.status: + key = self.entity_description.key.upper() + if key not in self._data.status: self._attr_native_value = None else: - self._attr_native_value, inferred_unit = infer_unit( - self._data.status[self.type.upper()] - ) - if not self._attr_native_unit_of_measurement: + self._attr_native_value, inferred_unit = infer_unit(self._data.status[key]) + if not self.native_unit_of_measurement: self._attr_native_unit_of_measurement = inferred_unit From 755835ee2eff51cc874da2dc15aa17d6b420ee78 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 6 Sep 2021 10:19:57 +0200 Subject: [PATCH 242/843] Alexa - Remove legacy speed support for fan platform (#55174) * Remove legacy fan speed support * remove fan range controller tests * retrigger tests --- .../components/alexa/capabilities.py | 10 - homeassistant/components/alexa/entities.py | 4 - homeassistant/components/alexa/handlers.py | 41 +--- tests/components/alexa/test_capabilities.py | 14 +- tests/components/alexa/test_smart_home.py | 199 +----------------- tests/components/alexa/test_state_report.py | 11 +- 6 files changed, 10 insertions(+), 269 deletions(-) diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index fcd6ebf6ae2..22022250cc6 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -1483,16 +1483,6 @@ class AlexaRangeController(AlexaCapability): if self.entity.state in (STATE_UNAVAILABLE, STATE_UNKNOWN, None): return None - # Fan Speed - if self.instance == f"{fan.DOMAIN}.{fan.ATTR_SPEED}": - speed_list = self.entity.attributes.get(fan.ATTR_SPEED_LIST) - speed = self.entity.attributes.get(fan.ATTR_SPEED) - if speed_list is not None and speed is not None: - speed_index = next( - (i for i, v in enumerate(speed_list) if v == speed), None - ) - return speed_index - # Cover Position if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": return self.entity.attributes.get(cover.ATTR_CURRENT_POSITION) diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index cef18623bf5..9a8e56d3551 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -535,10 +535,6 @@ class FanCapabilities(AlexaEntity): if supported & fan.SUPPORT_SET_SPEED: yield AlexaPercentageController(self.entity) yield AlexaPowerLevelController(self.entity) - # The use of legacy speeds is deprecated in the schema, support will be removed after a quarter (2021.7) - yield AlexaRangeController( - self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_SPEED}" - ) if supported & fan.SUPPORT_OSCILLATE: yield AlexaToggleController( self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_OSCILLATING}" diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index 01d1369eb2f..fc587128e82 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -1091,24 +1091,8 @@ async def async_api_set_range(hass, config, directive, context): data = {ATTR_ENTITY_ID: entity.entity_id} range_value = directive.payload["rangeValue"] - # Fan Speed - if instance == f"{fan.DOMAIN}.{fan.ATTR_SPEED}": - range_value = int(range_value) - service = fan.SERVICE_SET_SPEED - speed_list = entity.attributes[fan.ATTR_SPEED_LIST] - speed = next((v for i, v in enumerate(speed_list) if i == range_value), None) - - if not speed: - msg = "Entity does not support value" - raise AlexaInvalidValueError(msg) - - if speed == fan.SPEED_OFF: - service = fan.SERVICE_TURN_OFF - - data[fan.ATTR_SPEED] = speed - # Cover Position - elif instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": + if instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": range_value = int(range_value) if range_value == 0: service = cover.SERVICE_CLOSE_COVER @@ -1184,29 +1168,8 @@ async def async_api_adjust_range(hass, config, directive, context): range_delta_default = bool(directive.payload["rangeValueDeltaDefault"]) response_value = 0 - # Fan Speed - if instance == f"{fan.DOMAIN}.{fan.ATTR_SPEED}": - range_delta = int(range_delta) - service = fan.SERVICE_SET_SPEED - speed_list = entity.attributes[fan.ATTR_SPEED_LIST] - current_speed = entity.attributes[fan.ATTR_SPEED] - current_speed_index = next( - (i for i, v in enumerate(speed_list) if v == current_speed), 0 - ) - new_speed_index = min( - len(speed_list) - 1, max(0, current_speed_index + range_delta) - ) - speed = next( - (v for i, v in enumerate(speed_list) if i == new_speed_index), None - ) - - if speed == fan.SPEED_OFF: - service = fan.SERVICE_TURN_OFF - - data[fan.ATTR_SPEED] = response_value = speed - # Cover Position - elif instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": + if instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": range_delta = int(range_delta * 20) if range_delta_default else int(range_delta) service = SERVICE_SET_COVER_POSITION current = entity.attributes.get(cover.ATTR_POSITION) diff --git a/tests/components/alexa/test_capabilities.py b/tests/components/alexa/test_capabilities.py index dc93ed6d805..bdc19bc792f 100644 --- a/tests/components/alexa/test_capabilities.py +++ b/tests/components/alexa/test_capabilities.py @@ -346,16 +346,14 @@ async def test_report_colored_temp_light_state(hass): async def test_report_fan_speed_state(hass): - """Test PercentageController, PowerLevelController, RangeController reports fan speed correctly.""" + """Test PercentageController, PowerLevelController reports fan speed correctly.""" hass.states.async_set( "fan.off", "off", { "friendly_name": "Off fan", - "speed": "off", "supported_features": 1, "percentage": 0, - "speed_list": ["off", "low", "medium", "high"], }, ) hass.states.async_set( @@ -363,10 +361,8 @@ async def test_report_fan_speed_state(hass): "on", { "friendly_name": "Low speed fan", - "speed": "low", "supported_features": 1, "percentage": 33, - "speed_list": ["off", "low", "medium", "high"], }, ) hass.states.async_set( @@ -374,10 +370,8 @@ async def test_report_fan_speed_state(hass): "on", { "friendly_name": "Medium speed fan", - "speed": "medium", "supported_features": 1, "percentage": 66, - "speed_list": ["off", "low", "medium", "high"], }, ) hass.states.async_set( @@ -385,32 +379,26 @@ async def test_report_fan_speed_state(hass): "on", { "friendly_name": "High speed fan", - "speed": "high", "supported_features": 1, "percentage": 100, - "speed_list": ["off", "low", "medium", "high"], }, ) properties = await reported_properties(hass, "fan.off") properties.assert_equal("Alexa.PercentageController", "percentage", 0) properties.assert_equal("Alexa.PowerLevelController", "powerLevel", 0) - properties.assert_equal("Alexa.RangeController", "rangeValue", 0) properties = await reported_properties(hass, "fan.low_speed") properties.assert_equal("Alexa.PercentageController", "percentage", 33) properties.assert_equal("Alexa.PowerLevelController", "powerLevel", 33) - properties.assert_equal("Alexa.RangeController", "rangeValue", 1) properties = await reported_properties(hass, "fan.medium_speed") properties.assert_equal("Alexa.PercentageController", "percentage", 66) properties.assert_equal("Alexa.PowerLevelController", "powerLevel", 66) - properties.assert_equal("Alexa.RangeController", "rangeValue", 2) properties = await reported_properties(hass, "fan.high_speed") properties.assert_equal("Alexa.PercentageController", "percentage", 100) properties.assert_equal("Alexa.PowerLevelController", "powerLevel", 100) - properties.assert_equal("Alexa.RangeController", "rangeValue", 3) async def test_report_fan_preset_mode(hass): diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 0da21042049..998be054186 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -385,8 +385,6 @@ async def test_variable_fan(hass): { "friendly_name": "Test fan 2", "supported_features": 1, - "speed_list": ["low", "medium", "high"], - "speed": "high", "percentage": 100, }, ) @@ -401,28 +399,18 @@ async def test_variable_fan(hass): "Alexa.PercentageController", "Alexa.PowerController", "Alexa.PowerLevelController", - "Alexa.RangeController", "Alexa.EndpointHealth", "Alexa", ) - range_capability = get_capability(capabilities, "Alexa.RangeController") - assert range_capability is not None - assert range_capability["instance"] == "fan.speed" + capability = get_capability(capabilities, "Alexa.PercentageController") + assert capability is not None - properties = range_capability["properties"] - assert properties["nonControllable"] is False - assert {"name": "rangeValue"} in properties["supported"] + capability = get_capability(capabilities, "Alexa.PowerController") + assert capability is not None - capability_resources = range_capability["capabilityResources"] - assert capability_resources is not None - assert { - "@type": "asset", - "value": {"assetId": "Alexa.Setting.FanSpeed"}, - } in capability_resources["friendlyNames"] - - configuration = range_capability["configuration"] - assert configuration is not None + capability = get_capability(capabilities, "Alexa.PowerLevelController") + assert capability is not None call, _ = await assert_request_calls_service( "Alexa.PercentageController", @@ -671,181 +659,6 @@ async def test_direction_fan(hass): assert call.data -async def test_fan_range(hass): - """Test fan speed with rangeController.""" - device = ( - "fan.test_5", - "off", - { - "friendly_name": "Test fan 5", - "supported_features": 1, - "speed_list": ["off", "low", "medium", "high", "turbo", 5, "warp_speed"], - "speed": "medium", - }, - ) - appliance = await discovery_test(device, hass) - - assert appliance["endpointId"] == "fan#test_5" - assert appliance["displayCategories"][0] == "FAN" - assert appliance["friendlyName"] == "Test fan 5" - - capabilities = assert_endpoint_capabilities( - appliance, - "Alexa.PercentageController", - "Alexa.PowerController", - "Alexa.PowerLevelController", - "Alexa.RangeController", - "Alexa.EndpointHealth", - "Alexa", - ) - - range_capability = get_capability(capabilities, "Alexa.RangeController") - assert range_capability is not None - assert range_capability["instance"] == "fan.speed" - - capability_resources = range_capability["capabilityResources"] - assert capability_resources is not None - assert { - "@type": "asset", - "value": {"assetId": "Alexa.Setting.FanSpeed"}, - } in capability_resources["friendlyNames"] - - configuration = range_capability["configuration"] - assert configuration is not None - - supported_range = configuration["supportedRange"] - assert supported_range["minimumValue"] == 0 - assert supported_range["maximumValue"] == 6 - assert supported_range["precision"] == 1 - - presets = configuration["presets"] - assert { - "rangeValue": 0, - "presetResources": { - "friendlyNames": [ - {"@type": "text", "value": {"text": "off", "locale": "en-US"}} - ] - }, - } in presets - - assert { - "rangeValue": 1, - "presetResources": { - "friendlyNames": [ - {"@type": "text", "value": {"text": "low", "locale": "en-US"}}, - {"@type": "asset", "value": {"assetId": "Alexa.Value.Minimum"}}, - ] - }, - } in presets - - assert { - "rangeValue": 2, - "presetResources": { - "friendlyNames": [ - {"@type": "text", "value": {"text": "medium", "locale": "en-US"}} - ] - }, - } in presets - - assert {"rangeValue": 5} not in presets - - assert { - "rangeValue": 6, - "presetResources": { - "friendlyNames": [ - {"@type": "text", "value": {"text": "warp speed", "locale": "en-US"}}, - {"@type": "asset", "value": {"assetId": "Alexa.Value.Maximum"}}, - ] - }, - } in presets - - call, _ = await assert_request_calls_service( - "Alexa.RangeController", - "SetRangeValue", - "fan#test_5", - "fan.set_speed", - hass, - payload={"rangeValue": 1}, - instance="fan.speed", - ) - assert call.data["speed"] == "low" - - call, _ = await assert_request_calls_service( - "Alexa.RangeController", - "SetRangeValue", - "fan#test_5", - "fan.set_speed", - hass, - payload={"rangeValue": 5}, - instance="fan.speed", - ) - assert call.data["speed"] == 5 - - call, _ = await assert_request_calls_service( - "Alexa.RangeController", - "SetRangeValue", - "fan#test_5", - "fan.set_speed", - hass, - payload={"rangeValue": 6}, - instance="fan.speed", - ) - assert call.data["speed"] == "warp_speed" - - await assert_range_changes( - hass, - [ - ("low", -1, False), - ("high", 1, False), - ("medium", 0, False), - ("warp_speed", 99, False), - ], - "Alexa.RangeController", - "AdjustRangeValue", - "fan#test_5", - "fan.set_speed", - "speed", - instance="fan.speed", - ) - - -async def test_fan_range_off(hass): - """Test fan range controller 0 turns_off fan.""" - device = ( - "fan.test_6", - "off", - { - "friendly_name": "Test fan 6", - "supported_features": 1, - "speed_list": ["off", "low", "medium", "high"], - "speed": "high", - }, - ) - await discovery_test(device, hass) - - call, _ = await assert_request_calls_service( - "Alexa.RangeController", - "SetRangeValue", - "fan#test_6", - "fan.turn_off", - hass, - payload={"rangeValue": 0}, - instance="fan.speed", - ) - assert call.data["speed"] == "off" - - await assert_range_changes( - hass, - [("off", -3, False), ("off", -99, False)], - "Alexa.RangeController", - "AdjustRangeValue", - "fan#test_6", - "fan.turn_off", - "speed", - instance="fan.speed", - ) - - async def test_preset_mode_fan(hass, caplog): """Test fan discovery. diff --git a/tests/components/alexa/test_state_report.py b/tests/components/alexa/test_state_report.py index bbe80f29eef..729d9d6e467 100644 --- a/tests/components/alexa/test_state_report.py +++ b/tests/components/alexa/test_state_report.py @@ -51,8 +51,6 @@ async def test_report_state_instance(hass, aioclient_mock): { "friendly_name": "Test fan", "supported_features": 15, - "speed": None, - "speed_list": ["off", "low", "high"], "oscillating": False, "preset_mode": None, "preset_modes": ["auto", "smart"], @@ -68,8 +66,6 @@ async def test_report_state_instance(hass, aioclient_mock): { "friendly_name": "Test fan", "supported_features": 15, - "speed": "high", - "speed_list": ["off", "low", "high"], "oscillating": True, "preset_mode": "smart", "preset_modes": ["auto", "smart"], @@ -109,12 +105,7 @@ async def test_report_state_instance(hass, aioclient_mock): assert report["value"] == 90 assert report["namespace"] == "Alexa.PowerLevelController" checks += 1 - if report["name"] == "rangeValue": - assert report["value"] == 2 - assert report["instance"] == "fan.speed" - assert report["namespace"] == "Alexa.RangeController" - checks += 1 - assert checks == 5 + assert checks == 4 assert call_json["event"]["endpoint"]["endpointId"] == "fan#test_fan" From 364edbfd8a80b4e24df4c9ec23df129e240a8a16 Mon Sep 17 00:00:00 2001 From: Philip Allgaier Date: Mon, 6 Sep 2021 10:27:11 +0200 Subject: [PATCH 243/843] Add service descriptions for supervisor backup restore services (#52766) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add service descriptions for supervisor backup restore * Add fields to restore services * Update homeassistant/components/hassio/services.yaml Co-authored-by: Joakim Sørensen Co-authored-by: Joakim Sørensen --- homeassistant/components/hassio/services.yaml | 54 ++++++++++++++++--- 1 file changed, 46 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/hassio/services.yaml b/homeassistant/components/hassio/services.yaml index 38d78984ddc..3e5736c3593 100644 --- a/homeassistant/components/hassio/services.yaml +++ b/homeassistant/components/hassio/services.yaml @@ -72,8 +72,8 @@ snapshot_full: fields: name: name: Name - description: Optional or it will be the current date and time. - example: "backup 1" + description: Optional (default = current date and time). + example: "Backup 1" selector: text: password: @@ -89,7 +89,7 @@ snapshot_partial: fields: addons: name: Add-ons - description: Optional list of addon slugs. + description: Optional list of add-on slugs. example: ["core_ssh", "core_samba", "core_mosquitto"] selector: object: @@ -101,7 +101,7 @@ snapshot_partial: object: name: name: Name - description: Optional or it will be the current date and time. + description: Optional (default = current date and time). example: "Partial backup 1" selector: text: @@ -118,8 +118,8 @@ backup_full: fields: name: name: Name - description: Optional or it will be the current date and time. - example: "backup 1" + description: Optional (default = current date and time). + example: "Backup 1" selector: text: password: @@ -135,7 +135,7 @@ backup_partial: fields: addons: name: Add-ons - description: Optional list of addon slugs. + description: Optional list of add-on slugs. example: ["core_ssh", "core_samba", "core_mosquitto"] selector: object: @@ -147,7 +147,7 @@ backup_partial: object: name: name: Name - description: Optional or it will be the current date and time. + description: Optional (default = current date and time). example: "Partial backup 1" selector: text: @@ -157,3 +157,41 @@ backup_partial: example: "password" selector: text: + +restore_full: + name: Restore from full backup. + description: Restore from full backup. + fields: + slug: + name: Slug + description: Slug of backup to restore from. + selector: + text: + password: + name: Password + description: Optional password. + example: "password" + selector: + text: + +restore_partial: + name: Restore from partial backup. + description: Restore from partial backup. + fields: + homeassistant: + name: Home Assistant settings + description: Restore Home Assistant + selector: + boolean: + folders: + name: Folders + description: Optional list of directories. + example: ["homeassistant", "share"] + selector: + object: + addons: + name: Add-ons + description: Optional list of add-on slugs. + example: ["core_ssh", "core_samba", "core_mosquitto"] + selector: + object: From 3001df99cb3e78d0ad45434e6b0861621eac4ea2 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 6 Sep 2021 11:58:47 +0200 Subject: [PATCH 244/843] Use EntityDescription - poolsense (#55743) --- .../components/poolsense/__init__.py | 19 ++- .../components/poolsense/binary_sensor.py | 59 +++---- homeassistant/components/poolsense/sensor.py | 150 ++++++++---------- 3 files changed, 94 insertions(+), 134 deletions(-) diff --git a/homeassistant/components/poolsense/__init__.py b/homeassistant/components/poolsense/__init__.py index 5ec1cb475b5..134be1cefba 100644 --- a/homeassistant/components/poolsense/__init__.py +++ b/homeassistant/components/poolsense/__init__.py @@ -7,16 +7,17 @@ from poolsense import PoolSense from poolsense.exceptions import PoolSenseError from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.const import ATTR_ATTRIBUTION, CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, UpdateFailed, ) -from .const import DOMAIN +from .const import ATTRIBUTION, DOMAIN PLATFORMS = ["sensor", "binary_sensor"] @@ -61,16 +62,14 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): class PoolSenseEntity(CoordinatorEntity): """Implements a common class elements representing the PoolSense component.""" - def __init__(self, coordinator, email, info_type): + _attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} + + def __init__(self, coordinator, email, description: EntityDescription): """Initialize poolsense sensor.""" super().__init__(coordinator) - self._unique_id = f"{email}-{info_type}" - self.info_type = info_type - - @property - def unique_id(self): - """Return a unique id.""" - return self._unique_id + self.entity_description = description + self._attr_name = f"PoolSense {description.name}" + self._attr_unique_id = f"{email}-{description.key}" class PoolSenseDataUpdateCoordinator(DataUpdateCoordinator): diff --git a/homeassistant/components/poolsense/binary_sensor.py b/homeassistant/components/poolsense/binary_sensor.py index ea07f1637a6..1b45ee15f0f 100644 --- a/homeassistant/components/poolsense/binary_sensor.py +++ b/homeassistant/components/poolsense/binary_sensor.py @@ -1,42 +1,40 @@ """Support for PoolSense binary sensors.""" +from __future__ import annotations + from homeassistant.components.binary_sensor import ( DEVICE_CLASS_PROBLEM, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.const import CONF_EMAIL from . import PoolSenseEntity from .const import DOMAIN -BINARY_SENSORS = { - "pH Status": { - "unit": None, - "icon": None, - "name": "pH Status", - "device_class": DEVICE_CLASS_PROBLEM, - }, - "Chlorine Status": { - "unit": None, - "icon": None, - "name": "Chlorine Status", - "device_class": DEVICE_CLASS_PROBLEM, - }, -} +BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( + BinarySensorEntityDescription( + key="pH Status", + name="pH Status", + device_class=DEVICE_CLASS_PROBLEM, + ), + BinarySensorEntityDescription( + key="Chlorine Status", + name="Chlorine Status", + device_class=DEVICE_CLASS_PROBLEM, + ), +) async def async_setup_entry(hass, config_entry, async_add_entities): """Defer sensor setup to the shared sensor module.""" coordinator = hass.data[DOMAIN][config_entry.entry_id] - binary_sensors_list = [] - for binary_sensor in BINARY_SENSORS: - binary_sensors_list.append( - PoolSenseBinarySensor( - coordinator, config_entry.data[CONF_EMAIL], binary_sensor - ) - ) + entities = [ + PoolSenseBinarySensor(coordinator, config_entry.data[CONF_EMAIL], description) + for description in BINARY_SENSOR_TYPES + ] - async_add_entities(binary_sensors_list, False) + async_add_entities(entities, False) class PoolSenseBinarySensor(PoolSenseEntity, BinarySensorEntity): @@ -45,19 +43,4 @@ class PoolSenseBinarySensor(PoolSenseEntity, BinarySensorEntity): @property def is_on(self): """Return true if the binary sensor is on.""" - return self.coordinator.data[self.info_type] == "red" - - @property - def icon(self): - """Return the icon.""" - return BINARY_SENSORS[self.info_type]["icon"] - - @property - def device_class(self): - """Return the class of this device.""" - return BINARY_SENSORS[self.info_type]["device_class"] - - @property - def name(self): - """Return the name of the binary sensor.""" - return f"PoolSense {BINARY_SENSORS[self.info_type]['name']}" + return self.coordinator.data[self.entity_description.key] == "red" diff --git a/homeassistant/components/poolsense/sensor.py b/homeassistant/components/poolsense/sensor.py index e9aeaca20f5..82df8b4d208 100644 --- a/homeassistant/components/poolsense/sensor.py +++ b/homeassistant/components/poolsense/sensor.py @@ -1,7 +1,8 @@ """Sensor platform for the PoolSense sensor.""" -from homeassistant.components.sensor import SensorEntity +from __future__ import annotations + +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import ( - ATTR_ATTRIBUTION, CONF_EMAIL, DEVICE_CLASS_BATTERY, DEVICE_CLASS_TEMPERATURE, @@ -12,103 +13,80 @@ from homeassistant.const import ( ) from . import PoolSenseEntity -from .const import ATTRIBUTION, DOMAIN +from .const import DOMAIN -SENSORS = { - "Chlorine": { - "unit": ELECTRIC_POTENTIAL_MILLIVOLT, - "icon": "mdi:pool", - "name": "Chlorine", - "device_class": None, - }, - "pH": {"unit": None, "icon": "mdi:pool", "name": "pH", "device_class": None}, - "Battery": { - "unit": PERCENTAGE, - "icon": None, - "name": "Battery", - "device_class": DEVICE_CLASS_BATTERY, - }, - "Water Temp": { - "unit": TEMP_CELSIUS, - "icon": "mdi:coolant-temperature", - "name": "Temperature", - "device_class": DEVICE_CLASS_TEMPERATURE, - }, - "Last Seen": { - "unit": None, - "icon": "mdi:clock", - "name": "Last Seen", - "device_class": DEVICE_CLASS_TIMESTAMP, - }, - "Chlorine High": { - "unit": ELECTRIC_POTENTIAL_MILLIVOLT, - "icon": "mdi:pool", - "name": "Chlorine High", - "device_class": None, - }, - "Chlorine Low": { - "unit": ELECTRIC_POTENTIAL_MILLIVOLT, - "icon": "mdi:pool", - "name": "Chlorine Low", - "device_class": None, - }, - "pH High": { - "unit": None, - "icon": "mdi:pool", - "name": "pH High", - "device_class": None, - }, - "pH Low": { - "unit": None, - "icon": "mdi:pool", - "name": "pH Low", - "device_class": None, - }, -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="Chlorine", + native_unit_of_measurement=ELECTRIC_POTENTIAL_MILLIVOLT, + icon="mdi:pool", + name="Chlorine", + ), + SensorEntityDescription( + key="pH", + icon="mdi:pool", + name="pH", + ), + SensorEntityDescription( + key="Battery", + native_unit_of_measurement=PERCENTAGE, + name="Battery", + device_class=DEVICE_CLASS_BATTERY, + ), + SensorEntityDescription( + key="Water Temp", + native_unit_of_measurement=TEMP_CELSIUS, + icon="mdi:coolant-temperature", + name="Temperature", + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key="Last Seen", + icon="mdi:clock", + name="Last Seen", + device_class=DEVICE_CLASS_TIMESTAMP, + ), + SensorEntityDescription( + key="Chlorine High", + native_unit_of_measurement=ELECTRIC_POTENTIAL_MILLIVOLT, + icon="mdi:pool", + name="Chlorine High", + ), + SensorEntityDescription( + key="Chlorine Low", + native_unit_of_measurement=ELECTRIC_POTENTIAL_MILLIVOLT, + icon="mdi:pool", + name="Chlorine Low", + ), + SensorEntityDescription( + key="pH High", + icon="mdi:pool", + name="pH High", + ), + SensorEntityDescription( + key="pH Low", + icon="mdi:pool", + name="pH Low", + ), +) async def async_setup_entry(hass, config_entry, async_add_entities): """Defer sensor setup to the shared sensor module.""" coordinator = hass.data[DOMAIN][config_entry.entry_id] - sensors_list = [] - for sensor in SENSORS: - sensors_list.append( - PoolSenseSensor(coordinator, config_entry.data[CONF_EMAIL], sensor) - ) + entities = [ + PoolSenseSensor(coordinator, config_entry.data[CONF_EMAIL], description) + for description in SENSOR_TYPES + ] - async_add_entities(sensors_list, False) + async_add_entities(entities, False) class PoolSenseSensor(PoolSenseEntity, SensorEntity): """Sensor representing poolsense data.""" - @property - def name(self): - """Return the name of the particular component.""" - return f"PoolSense {SENSORS[self.info_type]['name']}" - @property def native_value(self): """State of the sensor.""" - return self.coordinator.data[self.info_type] - - @property - def device_class(self): - """Return the device class.""" - return SENSORS[self.info_type]["device_class"] - - @property - def icon(self): - """Return the icon.""" - return SENSORS[self.info_type]["icon"] - - @property - def native_unit_of_measurement(self): - """Return unit of measurement.""" - return SENSORS[self.info_type]["unit"] - - @property - def extra_state_attributes(self): - """Return device attributes.""" - return {ATTR_ATTRIBUTION: ATTRIBUTION} + return self.coordinator.data[self.entity_description.key] From 4475cf24c8681455f23c8c8056e2df9dd4dc9650 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 6 Sep 2021 11:59:03 +0200 Subject: [PATCH 245/843] Use EntityDescription - aqualogic (#55791) --- homeassistant/components/aqualogic/sensor.py | 148 +++++++++++++------ 1 file changed, 105 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/aqualogic/sensor.py b/homeassistant/components/aqualogic/sensor.py index 394f8844adb..4c46a7aa5eb 100644 --- a/homeassistant/components/aqualogic/sensor.py +++ b/homeassistant/components/aqualogic/sensor.py @@ -1,8 +1,15 @@ """Support for AquaLogic sensors.""" +from __future__ import annotations + +from dataclasses import dataclass import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( CONF_MONITORED_CONDITIONS, DEVICE_CLASS_TEMPERATURE, @@ -16,40 +23,88 @@ import homeassistant.helpers.config_validation as cv from . import DOMAIN, UPDATE_TOPIC -TEMP_UNITS = [TEMP_CELSIUS, TEMP_FAHRENHEIT] -PERCENT_UNITS = [PERCENTAGE, PERCENTAGE] -SALT_UNITS = ["g/L", "PPM"] -WATT_UNITS = [POWER_WATT, POWER_WATT] -NO_UNITS = [None, None] -# sensor_type [ description, unit, icon, device_class ] -# sensor_type corresponds to property names in aqualogic.core.AquaLogic -SENSOR_TYPES = { - "air_temp": ["Air Temperature", TEMP_UNITS, None, DEVICE_CLASS_TEMPERATURE], - "pool_temp": [ - "Pool Temperature", - TEMP_UNITS, - "mdi:oil-temperature", - DEVICE_CLASS_TEMPERATURE, - ], - "spa_temp": [ - "Spa Temperature", - TEMP_UNITS, - "mdi:oil-temperature", - DEVICE_CLASS_TEMPERATURE, - ], - "pool_chlorinator": ["Pool Chlorinator", PERCENT_UNITS, "mdi:gauge", None], - "spa_chlorinator": ["Spa Chlorinator", PERCENT_UNITS, "mdi:gauge", None], - "salt_level": ["Salt Level", SALT_UNITS, "mdi:gauge", None], - "pump_speed": ["Pump Speed", PERCENT_UNITS, "mdi:speedometer", None], - "pump_power": ["Pump Power", WATT_UNITS, "mdi:gauge", None], - "status": ["Status", NO_UNITS, "mdi:alert", None], -} +@dataclass +class AquaLogicSensorEntityDescription(SensorEntityDescription): + """Describes AquaLogic sensor entity.""" + + unit_metric: str | None = None + unit_imperial: str | None = None + + +# keys correspond to property names in aqualogic.core.AquaLogic +SENSOR_TYPES: tuple[AquaLogicSensorEntityDescription, ...] = ( + AquaLogicSensorEntityDescription( + key="air_temp", + name="Air Temperature", + unit_metric=TEMP_CELSIUS, + unit_imperial=TEMP_FAHRENHEIT, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + AquaLogicSensorEntityDescription( + key="pool_temp", + name="Pool Temperature", + unit_metric=TEMP_CELSIUS, + unit_imperial=TEMP_FAHRENHEIT, + icon="mdi:oil-temperature", + device_class=DEVICE_CLASS_TEMPERATURE, + ), + AquaLogicSensorEntityDescription( + key="spa_temp", + name="Spa Temperature", + unit_metric=TEMP_CELSIUS, + unit_imperial=TEMP_FAHRENHEIT, + icon="mdi:oil-temperature", + device_class=DEVICE_CLASS_TEMPERATURE, + ), + AquaLogicSensorEntityDescription( + key="pool_chlorinator", + name="Pool Chlorinator", + unit_metric=PERCENTAGE, + unit_imperial=PERCENTAGE, + icon="mdi:gauge", + ), + AquaLogicSensorEntityDescription( + key="spa_chlorinator", + name="Spa Chlorinator", + unit_metric=PERCENTAGE, + unit_imperial=PERCENTAGE, + icon="mdi:gauge", + ), + AquaLogicSensorEntityDescription( + key="salt_level", + name="Salt Level", + unit_metric="g/L", + unit_imperial="PPM", + icon="mdi:gauge", + ), + AquaLogicSensorEntityDescription( + key="pump_speed", + name="Pump Speed", + unit_metric=PERCENTAGE, + unit_imperial=PERCENTAGE, + icon="mdi:speedometer", + ), + AquaLogicSensorEntityDescription( + key="pump_power", + name="Pump Power", + unit_metric=POWER_WATT, + unit_imperial=POWER_WATT, + icon="mdi:gauge", + ), + AquaLogicSensorEntityDescription( + key="status", + name="Status", + icon="mdi:alert", + ), +) + +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { - vol.Required(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] + vol.Required(CONF_MONITORED_CONDITIONS, default=SENSOR_KEYS): vol.All( + cv.ensure_list, [vol.In(SENSOR_KEYS)] ) } ) @@ -57,26 +112,29 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the sensor platform.""" - sensors = [] - processor = hass.data[DOMAIN] - for sensor_type in config[CONF_MONITORED_CONDITIONS]: - sensors.append(AquaLogicSensor(processor, sensor_type)) + monitored_conditions = config[CONF_MONITORED_CONDITIONS] - async_add_entities(sensors) + entities = [ + AquaLogicSensor(processor, description) + for description in SENSOR_TYPES + if description.key in monitored_conditions + ] + + async_add_entities(entities) class AquaLogicSensor(SensorEntity): """Sensor implementation for the AquaLogic component.""" + entity_description: AquaLogicSensorEntityDescription _attr_should_poll = False - def __init__(self, processor, sensor_type): + def __init__(self, processor, description: AquaLogicSensorEntityDescription): """Initialize sensor.""" + self.entity_description = description self._processor = processor - self._type = sensor_type - self._attr_name = f"AquaLogic {SENSOR_TYPES[sensor_type][0]}" - self._attr_icon = SENSOR_TYPES[sensor_type][2] + self._attr_name = f"AquaLogic {description.name}" async def async_added_to_hass(self): """Register callbacks.""" @@ -92,11 +150,15 @@ class AquaLogicSensor(SensorEntity): panel = self._processor.panel if panel is not None: if panel.is_metric: - self._attr_native_unit_of_measurement = SENSOR_TYPES[self._type][1][0] + self._attr_native_unit_of_measurement = ( + self.entity_description.unit_metric + ) else: - self._attr_native_unit_of_measurement = SENSOR_TYPES[self._type][1][1] + self._attr_native_unit_of_measurement = ( + self.entity_description.unit_imperial + ) - self._attr_native_value = getattr(panel, self._type) + self._attr_native_value = getattr(panel, self.entity_description.key) self.async_write_ha_state() else: self._attr_native_unit_of_measurement = None From d50b700dc7434309c6795b30bfbe8d70da0b8288 Mon Sep 17 00:00:00 2001 From: Andre Richter Date: Mon, 6 Sep 2021 12:03:45 +0200 Subject: [PATCH 246/843] Refactor exception handling in Vallox (#55461) --- homeassistant/components/vallox/__init__.py | 5 +- homeassistant/components/vallox/fan.py | 62 ++++++++++----------- homeassistant/components/vallox/sensor.py | 42 ++++++++------ 3 files changed, 59 insertions(+), 50 deletions(-) diff --git a/homeassistant/components/vallox/__init__.py b/homeassistant/components/vallox/__init__.py index 031c47a1233..b51caa1f7b4 100644 --- a/homeassistant/components/vallox/__init__.py +++ b/homeassistant/components/vallox/__init__.py @@ -167,12 +167,13 @@ class ValloxStateProxy: try: self._metric_cache = await self._client.fetch_metrics() self._profile = await self._client.get_profile() - self._valid = True except (OSError, ValloxApiException) as err: - _LOGGER.error("Error during state cache update: %s", err) self._valid = False + _LOGGER.error("Error during state cache update: %s", err) + return + self._valid = True async_dispatcher_send(self._hass, SIGNAL_VALLOX_STATE_UPDATE) diff --git a/homeassistant/components/vallox/fan.py b/homeassistant/components/vallox/fan.py index 0c887daaef5..a3488ffdfb2 100644 --- a/homeassistant/components/vallox/fan.py +++ b/homeassistant/components/vallox/fan.py @@ -110,11 +110,7 @@ class ValloxFan(FanEntity): """Fetch state from the device.""" try: # Fetch if the whole device is in regular operation state. - mode = self._state_proxy.fetch_metric(METRIC_KEY_MODE) - if mode == MODE_ON: - self._state = True - else: - self._state = False + self._state = self._state_proxy.fetch_metric(METRIC_KEY_MODE) == MODE_ON # Fetch the profile fan speeds. self._fan_speed_home = int( @@ -133,11 +129,12 @@ class ValloxFan(FanEntity): ) ) - self._available = True - except (OSError, KeyError) as err: self._available = False _LOGGER.error("Error updating fan: %s", err) + return + + self._available = True # # The fan entity model has changed to use percentages and preset_modes @@ -161,32 +158,35 @@ class ValloxFan(FanEntity): if speed is not None: return - if self._state is False: - try: - await self._client.set_values({METRIC_KEY_MODE: MODE_ON}) - - # This state change affects other entities like sensors. Force - # an immediate update that can be observed by all parties - # involved. - await self._state_proxy.async_update(None) - - except OSError as err: - self._available = False - _LOGGER.error("Error turning on: %s", err) - else: + if self._state is True: _LOGGER.error("Already on") + return + + try: + await self._client.set_values({METRIC_KEY_MODE: MODE_ON}) + + except OSError as err: + self._available = False + _LOGGER.error("Error turning on: %s", err) + return + + # This state change affects other entities like sensors. Force an immediate update that can + # be observed by all parties involved. + await self._state_proxy.async_update(None) async def async_turn_off(self, **kwargs) -> None: """Turn the device off.""" - if self._state is True: - try: - await self._client.set_values({METRIC_KEY_MODE: MODE_OFF}) - - # Same as for turn_on method. - await self._state_proxy.async_update(None) - - except OSError as err: - self._available = False - _LOGGER.error("Error turning off: %s", err) - else: + if self._state is False: _LOGGER.error("Already off") + return + + try: + await self._client.set_values({METRIC_KEY_MODE: MODE_OFF}) + + except OSError as err: + self._available = False + _LOGGER.error("Error turning off: %s", err) + return + + # Same as for turn_on method. + await self._state_proxy.async_update(None) diff --git a/homeassistant/components/vallox/sensor.py b/homeassistant/components/vallox/sensor.py index 99365762977..e2562663ac6 100644 --- a/homeassistant/components/vallox/sensor.py +++ b/homeassistant/components/vallox/sensor.py @@ -67,11 +67,13 @@ class ValloxSensor(SensorEntity): self._attr_native_value = self._state_proxy.fetch_metric( self.entity_description.metric_key ) - self._attr_available = True except (OSError, KeyError) as err: self._attr_available = False _LOGGER.error("Error updating sensor: %s", err) + return + + self._attr_available = True class ValloxProfileSensor(ValloxSensor): @@ -81,11 +83,13 @@ class ValloxProfileSensor(ValloxSensor): """Fetch state from the ventilation unit.""" try: self._attr_native_value = self._state_proxy.get_profile() - self._attr_available = True except OSError as err: self._attr_available = False _LOGGER.error("Error updating sensor: %s", err) + return + + self._attr_available = True # There seems to be a quirk with respect to the fan speed reporting. The device @@ -101,17 +105,19 @@ class ValloxFanSpeedSensor(ValloxSensor): async def async_update(self): """Fetch state from the ventilation unit.""" try: - # If device is in regular operation, continue. - if self._state_proxy.fetch_metric(METRIC_KEY_MODE) == MODE_ON: - await super().async_update() - else: - # Report zero percent otherwise. - self._attr_native_value = 0 - self._attr_available = True + fan_on = self._state_proxy.fetch_metric(METRIC_KEY_MODE) == MODE_ON except (OSError, KeyError) as err: self._attr_available = False _LOGGER.error("Error updating sensor: %s", err) + return + + if fan_on: + await super().async_update() + else: + # Report zero percent otherwise. + self._attr_native_value = 0 + self._attr_available = True class ValloxFilterRemainingSensor(ValloxSensor): @@ -123,18 +129,20 @@ class ValloxFilterRemainingSensor(ValloxSensor): days_remaining = int( self._state_proxy.fetch_metric(self.entity_description.metric_key) ) - days_remaining_delta = timedelta(days=days_remaining) - - # Since only a delta of days is received from the device, fix the - # time so the timestamp does not change with every update. - now = datetime.utcnow().replace(hour=13, minute=0, second=0, microsecond=0) - - self._attr_native_value = (now + days_remaining_delta).isoformat() - self._attr_available = True except (OSError, KeyError) as err: self._attr_available = False _LOGGER.error("Error updating sensor: %s", err) + return + + days_remaining_delta = timedelta(days=days_remaining) + + # Since only a delta of days is received from the device, fix the + # time so the timestamp does not change with every update. + now = datetime.utcnow().replace(hour=13, minute=0, second=0, microsecond=0) + + self._attr_native_value = (now + days_remaining_delta).isoformat() + self._attr_available = True @dataclass From 67b71447030de80989459fcc787644f59ee55639 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Mon, 6 Sep 2021 11:26:20 +0100 Subject: [PATCH 247/843] Fix incomfort min/max temperatures (#55806) --- homeassistant/components/incomfort/water_heater.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/incomfort/water_heater.py b/homeassistant/components/incomfort/water_heater.py index 84ed0212d3b..469e3571334 100644 --- a/homeassistant/components/incomfort/water_heater.py +++ b/homeassistant/components/incomfort/water_heater.py @@ -67,13 +67,13 @@ class IncomfortWaterHeater(IncomfortEntity, WaterHeaterEntity): @property def min_temp(self) -> float: - """Return max valid temperature that can be set.""" - return 80.0 + """Return min valid temperature that can be set.""" + return 30.0 @property def max_temp(self) -> float: """Return max valid temperature that can be set.""" - return 30.0 + return 80.0 @property def temperature_unit(self) -> str: From 753285eae7d3e3e27ece41f3797a2b6e34e5298c Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 6 Sep 2021 12:33:34 +0200 Subject: [PATCH 248/843] Fix a lazy preset mode update for Xiaomi Miio fans (#55837) --- homeassistant/components/xiaomi_miio/fan.py | 27 ++++++++++----------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index e62f7aa870c..75a7fda60d0 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -415,36 +415,42 @@ class XiaomiAirPurifier(XiaomiGenericDevice): self._preset_modes = PRESET_MODES_AIRPURIFIER_PRO self._supported_features = SUPPORT_PRESET_MODE self._speed_count = 1 + self._operation_mode_class = AirpurifierOperationMode elif self._model == MODEL_AIRPURIFIER_PRO_V7: self._device_features = FEATURE_FLAGS_AIRPURIFIER_PRO_V7 self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER_PRO_V7 self._preset_modes = PRESET_MODES_AIRPURIFIER_PRO_V7 self._supported_features = SUPPORT_PRESET_MODE self._speed_count = 1 + self._operation_mode_class = AirpurifierOperationMode elif self._model in [MODEL_AIRPURIFIER_2S, MODEL_AIRPURIFIER_2H]: self._device_features = FEATURE_FLAGS_AIRPURIFIER_2S self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER_COMMON self._preset_modes = PRESET_MODES_AIRPURIFIER_2S self._supported_features = SUPPORT_PRESET_MODE self._speed_count = 1 + self._operation_mode_class = AirpurifierOperationMode elif self._model in MODELS_PURIFIER_MIOT: self._device_features = FEATURE_FLAGS_AIRPURIFIER_MIOT self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER_MIOT self._preset_modes = PRESET_MODES_AIRPURIFIER_MIOT self._supported_features = SUPPORT_SET_SPEED | SUPPORT_PRESET_MODE self._speed_count = 3 + self._operation_mode_class = AirpurifierMiotOperationMode elif self._model == MODEL_AIRPURIFIER_V3: self._device_features = FEATURE_FLAGS_AIRPURIFIER_V3 self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER_V3 self._preset_modes = PRESET_MODES_AIRPURIFIER_V3 self._supported_features = SUPPORT_PRESET_MODE self._speed_count = 1 + self._operation_mode_class = AirpurifierOperationMode else: self._device_features = FEATURE_FLAGS_AIRPURIFIER_MIIO self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER self._preset_modes = PRESET_MODES_AIRPURIFIER self._supported_features = SUPPORT_PRESET_MODE self._speed_count = 1 + self._operation_mode_class = AirpurifierOperationMode self._state_attrs.update( {attribute: None for attribute in self._available_attributes} @@ -456,7 +462,7 @@ class XiaomiAirPurifier(XiaomiGenericDevice): def preset_mode(self): """Get the active preset mode.""" if self._state: - preset_mode = AirpurifierOperationMode(self._state_attrs[ATTR_MODE]).name + preset_mode = self._operation_mode_class(self._mode).name return preset_mode if preset_mode in self._preset_modes else None return None @@ -465,7 +471,7 @@ class XiaomiAirPurifier(XiaomiGenericDevice): def percentage(self): """Return the current percentage based speed.""" if self._state: - mode = AirpurifierOperationMode(self._state_attrs[ATTR_MODE]) + mode = self._operation_mode_class(self._state_attrs[ATTR_MODE]) if mode in self.REVERSE_SPEED_MODE_MAPPING: return ranged_value_to_percentage( (1, self._speed_count), self.REVERSE_SPEED_MODE_MAPPING[mode] @@ -489,7 +495,7 @@ class XiaomiAirPurifier(XiaomiGenericDevice): await self._try_command( "Setting operation mode of the miio device failed.", self._device.set_mode, - AirpurifierOperationMode(self.SPEED_MODE_MAPPING[speed_mode]), + self._operation_mode_class(self.SPEED_MODE_MAPPING[speed_mode]), ) async def async_set_preset_mode(self, preset_mode: str) -> None: @@ -500,11 +506,13 @@ class XiaomiAirPurifier(XiaomiGenericDevice): if preset_mode not in self.preset_modes: _LOGGER.warning("'%s'is not a valid preset mode", preset_mode) return - await self._try_command( + if await self._try_command( "Setting operation mode of the miio device failed.", self._device.set_mode, self.PRESET_MODE_MAPPING[preset_mode], - ) + ): + self._mode = self._operation_mode_class[preset_mode].value + self.async_write_ha_state() async def async_set_extra_features(self, features: int = 1): """Set the extra features.""" @@ -548,15 +556,6 @@ class XiaomiAirPurifierMiot(XiaomiAirPurifier): return None - @property - def preset_mode(self): - """Get the active preset mode.""" - if self._state: - preset_mode = AirpurifierMiotOperationMode(self._mode).name - return preset_mode if preset_mode in self._preset_modes else None - - return None - async def async_set_percentage(self, percentage: int) -> None: """Set the percentage of the fan. From 05abf1405d3be9b686dde979f825d2a3cbdd36ad Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 6 Sep 2021 13:24:00 +0200 Subject: [PATCH 249/843] Migrate emulated_hue tests from unittest to pytest (#55794) * Migrate emulated_hue tests from unittest to pytest * Remove unused variables --- tests/components/emulated_hue/test_upnp.py | 230 ++++++++++----------- 1 file changed, 114 insertions(+), 116 deletions(-) diff --git a/tests/components/emulated_hue/test_upnp.py b/tests/components/emulated_hue/test_upnp.py index e68688399e0..8ea65380359 100644 --- a/tests/components/emulated_hue/test_upnp.py +++ b/tests/components/emulated_hue/test_upnp.py @@ -2,23 +2,19 @@ import json import unittest -from aiohttp.hdrs import CONTENT_TYPE +from aiohttp import web import defusedxml.ElementTree as ET -import requests +import pytest -from homeassistant import const, setup +from homeassistant import setup from homeassistant.components import emulated_hue from homeassistant.components.emulated_hue import upnp from homeassistant.const import CONTENT_TYPE_JSON, HTTP_OK -from tests.common import get_test_home_assistant, get_test_instance_port +from tests.common import get_test_instance_port -HTTP_SERVER_PORT = get_test_instance_port() BRIDGE_SERVER_PORT = get_test_instance_port() -BRIDGE_URL_BASE = f"http://127.0.0.1:{BRIDGE_SERVER_PORT}" + "{}" -JSON_HEADERS = {CONTENT_TYPE: const.CONTENT_TYPE_JSON} - class MockTransport: """Mock asyncio transport.""" @@ -32,49 +28,48 @@ class MockTransport: self.sends.append((response, addr)) -class TestEmulatedHue(unittest.TestCase): - """Test the emulated Hue component.""" +@pytest.fixture +def hue_client(aiohttp_client): + """Return a hue API client.""" + app = web.Application() + with unittest.mock.patch( + "homeassistant.components.emulated_hue.web.Application", return_value=app + ): - hass = None + async def client(): + """Return an authenticated client.""" + return await aiohttp_client(app) - @classmethod - def setUpClass(cls): - """Set up the class.""" - cls.hass = hass = get_test_home_assistant() + yield client - setup.setup_component( - hass, - emulated_hue.DOMAIN, - {emulated_hue.DOMAIN: {emulated_hue.CONF_LISTEN_PORT: BRIDGE_SERVER_PORT}}, - ) - cls.hass.start() +async def setup_hue(hass): + """Set up the emulated_hue integration.""" + assert await setup.async_setup_component( + hass, + emulated_hue.DOMAIN, + {emulated_hue.DOMAIN: {emulated_hue.CONF_LISTEN_PORT: BRIDGE_SERVER_PORT}}, + ) - @classmethod - def tearDownClass(cls): - """Stop the class.""" - cls.hass.stop() - def test_upnp_discovery_basic(self): - """Tests the UPnP basic discovery response.""" - upnp_responder_protocol = upnp.UPNPResponderProtocol( - None, None, "192.0.2.42", 8080 - ) - mock_transport = MockTransport() - upnp_responder_protocol.transport = mock_transport +def test_upnp_discovery_basic(): + """Tests the UPnP basic discovery response.""" + upnp_responder_protocol = upnp.UPNPResponderProtocol(None, None, "192.0.2.42", 8080) + mock_transport = MockTransport() + upnp_responder_protocol.transport = mock_transport - """Original request emitted by the Hue Bridge v1 app.""" - request = """M-SEARCH * HTTP/1.1 + """Original request emitted by the Hue Bridge v1 app.""" + request = """M-SEARCH * HTTP/1.1 HOST:239.255.255.250:1900 ST:ssdp:all Man:"ssdp:discover" MX:3 """ - encoded_request = request.replace("\n", "\r\n").encode("utf-8") + encoded_request = request.replace("\n", "\r\n").encode("utf-8") - upnp_responder_protocol.datagram_received(encoded_request, 1234) - expected_response = """HTTP/1.1 200 OK + upnp_responder_protocol.datagram_received(encoded_request, 1234) + expected_response = """HTTP/1.1 200 OK CACHE-CONTROL: max-age=60 EXT: LOCATION: http://192.0.2.42:8080/description.xml @@ -84,30 +79,29 @@ ST: urn:schemas-upnp-org:device:basic:1 USN: uuid:2f402f80-da50-11e1-9b23-001788255acc """ - expected_send = expected_response.replace("\n", "\r\n").encode("utf-8") + expected_send = expected_response.replace("\n", "\r\n").encode("utf-8") - assert mock_transport.sends == [(expected_send, 1234)] + assert mock_transport.sends == [(expected_send, 1234)] - def test_upnp_discovery_rootdevice(self): - """Tests the UPnP rootdevice discovery response.""" - upnp_responder_protocol = upnp.UPNPResponderProtocol( - None, None, "192.0.2.42", 8080 - ) - mock_transport = MockTransport() - upnp_responder_protocol.transport = mock_transport - """Original request emitted by Busch-Jaeger free@home SysAP.""" - request = """M-SEARCH * HTTP/1.1 +def test_upnp_discovery_rootdevice(): + """Tests the UPnP rootdevice discovery response.""" + upnp_responder_protocol = upnp.UPNPResponderProtocol(None, None, "192.0.2.42", 8080) + mock_transport = MockTransport() + upnp_responder_protocol.transport = mock_transport + + """Original request emitted by Busch-Jaeger free@home SysAP.""" + request = """M-SEARCH * HTTP/1.1 HOST: 239.255.255.250:1900 MAN: "ssdp:discover" MX: 40 ST: upnp:rootdevice """ - encoded_request = request.replace("\n", "\r\n").encode("utf-8") + encoded_request = request.replace("\n", "\r\n").encode("utf-8") - upnp_responder_protocol.datagram_received(encoded_request, 1234) - expected_response = """HTTP/1.1 200 OK + upnp_responder_protocol.datagram_received(encoded_request, 1234) + expected_response = """HTTP/1.1 200 OK CACHE-CONTROL: max-age=60 EXT: LOCATION: http://192.0.2.42:8080/description.xml @@ -117,95 +111,99 @@ ST: upnp:rootdevice USN: uuid:2f402f80-da50-11e1-9b23-001788255acc::upnp:rootdevice """ - expected_send = expected_response.replace("\n", "\r\n").encode("utf-8") + expected_send = expected_response.replace("\n", "\r\n").encode("utf-8") - assert mock_transport.sends == [(expected_send, 1234)] + assert mock_transport.sends == [(expected_send, 1234)] - def test_upnp_no_response(self): - """Tests the UPnP does not response on an invalid request.""" - upnp_responder_protocol = upnp.UPNPResponderProtocol( - None, None, "192.0.2.42", 8080 - ) - mock_transport = MockTransport() - upnp_responder_protocol.transport = mock_transport - """Original request emitted by the Hue Bridge v1 app.""" - request = """INVALID * HTTP/1.1 +def test_upnp_no_response(): + """Tests the UPnP does not response on an invalid request.""" + upnp_responder_protocol = upnp.UPNPResponderProtocol(None, None, "192.0.2.42", 8080) + mock_transport = MockTransport() + upnp_responder_protocol.transport = mock_transport + + """Original request emitted by the Hue Bridge v1 app.""" + request = """INVALID * HTTP/1.1 HOST:239.255.255.250:1900 ST:ssdp:all Man:"ssdp:discover" MX:3 """ - encoded_request = request.replace("\n", "\r\n").encode("utf-8") + encoded_request = request.replace("\n", "\r\n").encode("utf-8") - upnp_responder_protocol.datagram_received(encoded_request, 1234) + upnp_responder_protocol.datagram_received(encoded_request, 1234) - assert mock_transport.sends == [] + assert mock_transport.sends == [] - def test_description_xml(self): - """Test the description.""" - result = requests.get(BRIDGE_URL_BASE.format("/description.xml"), timeout=5) - assert result.status_code == HTTP_OK - assert "text/xml" in result.headers["content-type"] +async def test_description_xml(hass, hue_client): + """Test the description.""" + await setup_hue(hass) + client = await hue_client() + result = await client.get("/description.xml", timeout=5) - # Make sure the XML is parsable - try: - root = ET.fromstring(result.text) - ns = {"s": "urn:schemas-upnp-org:device-1-0"} - assert root.find("./s:device/s:serialNumber", ns).text == "001788FFFE23BFC2" - except: # noqa: E722 pylint: disable=bare-except - self.fail("description.xml is not valid XML!") + assert result.status == HTTP_OK + assert "text/xml" in result.headers["content-type"] - def test_create_username(self): - """Test the creation of an username.""" - request_json = {"devicetype": "my_device"} + try: + root = ET.fromstring(await result.text()) + ns = {"s": "urn:schemas-upnp-org:device-1-0"} + assert root.find("./s:device/s:serialNumber", ns).text == "001788FFFE23BFC2" + except: # noqa: E722 pylint: disable=bare-except + pytest.fail("description.xml is not valid XML!") - result = requests.post( - BRIDGE_URL_BASE.format("/api"), data=json.dumps(request_json), timeout=5 - ) - assert result.status_code == HTTP_OK - assert CONTENT_TYPE_JSON in result.headers["content-type"] +async def test_create_username(hass, hue_client): + """Test the creation of an username.""" + await setup_hue(hass) + client = await hue_client() + request_json = {"devicetype": "my_device"} - resp_json = result.json() - success_json = resp_json[0] + result = await client.post("/api", data=json.dumps(request_json), timeout=5) - assert "success" in success_json - assert "username" in success_json["success"] + assert result.status == HTTP_OK + assert CONTENT_TYPE_JSON in result.headers["content-type"] - def test_unauthorized_view(self): - """Test unauthorized view.""" - request_json = {"devicetype": "my_device"} + resp_json = await result.json() + success_json = resp_json[0] - result = requests.get( - BRIDGE_URL_BASE.format("/api/unauthorized"), - data=json.dumps(request_json), - timeout=5, - ) + assert "success" in success_json + assert "username" in success_json["success"] - assert result.status_code == HTTP_OK - assert CONTENT_TYPE_JSON in result.headers["content-type"] - resp_json = result.json() - assert len(resp_json) == 1 - success_json = resp_json[0] - assert len(success_json) == 1 +async def test_unauthorized_view(hass, hue_client): + """Test unauthorized view.""" + await setup_hue(hass) + client = await hue_client() + request_json = {"devicetype": "my_device"} - assert "error" in success_json - error_json = success_json["error"] - assert len(error_json) == 3 - assert "/" in error_json["address"] - assert "unauthorized user" in error_json["description"] - assert "1" in error_json["type"] + result = await client.get( + "/api/unauthorized", data=json.dumps(request_json), timeout=5 + ) - def test_valid_username_request(self): - """Test request with a valid username.""" - request_json = {"invalid_key": "my_device"} + assert result.status == HTTP_OK + assert CONTENT_TYPE_JSON in result.headers["content-type"] - result = requests.post( - BRIDGE_URL_BASE.format("/api"), data=json.dumps(request_json), timeout=5 - ) + resp_json = await result.json() + assert len(resp_json) == 1 + success_json = resp_json[0] + assert len(success_json) == 1 - assert result.status_code == 400 + assert "error" in success_json + error_json = success_json["error"] + assert len(error_json) == 3 + assert "/" in error_json["address"] + assert "unauthorized user" in error_json["description"] + assert "1" in error_json["type"] + + +async def test_valid_username_request(hass, hue_client): + """Test request with a valid username.""" + await setup_hue(hass) + client = await hue_client() + request_json = {"invalid_key": "my_device"} + + result = await client.post("/api", data=json.dumps(request_json), timeout=5) + + assert result.status == 400 From df928c80b800dbbfe5d37d2c45c887c09b2beb4d Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Mon, 6 Sep 2021 14:37:33 +0200 Subject: [PATCH 250/843] Shutdown the container on abnormal signals (#55660) So far the finish script exits whenever the service terminated by a signal (indicated by 256 as first argument). This is the intended behavior when SIGTERM is being sent: SIGTERM is used on regular shutdown through the supervisor. We don't want the finish script to shutdown itself while being taken down by the supervisor already. However, every other signal which lead to a process exit likely means trouble: SIGSEGV, SIGILL, etc. In those cases we want the container to exit. The Supervisor (or restart policy of Docker in the container case) will take care of restarting if appropriate. --- rootfs/etc/services.d/home-assistant/finish | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/rootfs/etc/services.d/home-assistant/finish b/rootfs/etc/services.d/home-assistant/finish index d039fc04c86..119a90ea3c6 100644 --- a/rootfs/etc/services.d/home-assistant/finish +++ b/rootfs/etc/services.d/home-assistant/finish @@ -2,7 +2,19 @@ # ============================================================================== # Take down the S6 supervision tree when Home Assistant fails # ============================================================================== -if { s6-test ${1} -ne 100 } -if { s6-test ${1} -ne 256 } +define HA_RESTART_EXIT_CODE 100 +define SIGNAL_EXIT_CODE 256 +define SIGTERM 15 + +foreground { s6-echo "[finish] process exit code ${1}" } + +if { s6-test ${1} -ne ${HA_RESTART_EXIT_CODE} } +ifelse { s6-test ${1} -eq ${SIGNAL_EXIT_CODE} } { + # Process terminated by a signal + define signal ${2} + foreground { s6-echo "[finish] process received signal ${signal}" } + if { s6-test ${signal} -ne ${SIGTERM} } + s6-svscanctl -t /var/run/s6/services +} s6-svscanctl -t /var/run/s6/services From e6a29b6a2aea189cd9bfe765a741a8096723e0f6 Mon Sep 17 00:00:00 2001 From: mrwhite31 <46862347+mrwhite31@users.noreply.github.com> Date: Mon, 6 Sep 2021 15:11:12 +0200 Subject: [PATCH 251/843] Fix typo in in rfxtrx Barometer sensor (#55839) Fix typo in sensor.py to fix barometer unavailability --- homeassistant/components/rfxtrx/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/rfxtrx/sensor.py b/homeassistant/components/rfxtrx/sensor.py index 7ce986d7082..fd3be53bfda 100644 --- a/homeassistant/components/rfxtrx/sensor.py +++ b/homeassistant/components/rfxtrx/sensor.py @@ -75,7 +75,7 @@ class RfxtrxSensorEntityDescription(SensorEntityDescription): SENSOR_TYPES = ( RfxtrxSensorEntityDescription( - key="Barameter", + key="Barometer", device_class=DEVICE_CLASS_PRESSURE, state_class=STATE_CLASS_MEASUREMENT, native_unit_of_measurement=PRESSURE_HPA, From 9ee0d8fefe2173b7aa5d9e36704f559362681a76 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 6 Sep 2021 15:30:03 +0200 Subject: [PATCH 252/843] Fix xiaomi miio Air Quality Monitor initialization (#55773) --- homeassistant/components/xiaomi_miio/const.py | 7 +++ .../components/xiaomi_miio/sensor.py | 59 ++++++++++--------- 2 files changed, 39 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/const.py b/homeassistant/components/xiaomi_miio/const.py index b63143c0f41..29c740cf800 100644 --- a/homeassistant/components/xiaomi_miio/const.py +++ b/homeassistant/components/xiaomi_miio/const.py @@ -118,6 +118,13 @@ MODEL_AIRQUALITYMONITOR_B1 = "cgllc.airmonitor.b1" MODEL_AIRQUALITYMONITOR_S1 = "cgllc.airmonitor.s1" MODEL_AIRQUALITYMONITOR_CGDN1 = "cgllc.airm.cgdn1" +MODELS_AIR_QUALITY_MONITOR = [ + MODEL_AIRQUALITYMONITOR_V1, + MODEL_AIRQUALITYMONITOR_B1, + MODEL_AIRQUALITYMONITOR_S1, + MODEL_AIRQUALITYMONITOR_CGDN1, +] + # Light Models MODELS_LIGHT_EYECARE = ["philips.light.sread1"] MODELS_LIGHT_CEILING = ["philips.light.ceiling", "philips.light.zyceiling"] diff --git a/homeassistant/components/xiaomi_miio/sensor.py b/homeassistant/components/xiaomi_miio/sensor.py index 96bcdf9145d..7e2a5d230cd 100644 --- a/homeassistant/components/xiaomi_miio/sensor.py +++ b/homeassistant/components/xiaomi_miio/sensor.py @@ -68,6 +68,7 @@ from .const import ( MODEL_FAN_ZA1, MODEL_FAN_ZA3, MODEL_FAN_ZA4, + MODELS_AIR_QUALITY_MONITOR, MODELS_HUMIDIFIER_MIIO, MODELS_HUMIDIFIER_MIOT, MODELS_HUMIDIFIER_MJJSQ, @@ -380,23 +381,11 @@ async def async_setup_entry(hass, config_entry, async_add_entities): host = config_entry.data[CONF_HOST] token = config_entry.data[CONF_TOKEN] model = config_entry.data[CONF_MODEL] - device = hass.data[DOMAIN][config_entry.entry_id].get(KEY_DEVICE) - sensors = [] + if model in (MODEL_FAN_ZA1, MODEL_FAN_ZA3, MODEL_FAN_ZA4, MODEL_FAN_P5): return - if model in MODEL_TO_SENSORS_MAP: - sensors = MODEL_TO_SENSORS_MAP[model] - elif model in MODELS_HUMIDIFIER_MIOT: - sensors = HUMIDIFIER_MIOT_SENSORS - elif model in MODELS_HUMIDIFIER_MJJSQ: - sensors = HUMIDIFIER_MJJSQ_SENSORS - elif model in MODELS_HUMIDIFIER_MIIO: - sensors = HUMIDIFIER_MIIO_SENSORS - elif model in MODELS_PURIFIER_MIIO: - sensors = PURIFIER_MIIO_SENSORS - elif model in MODELS_PURIFIER_MIOT: - sensors = PURIFIER_MIOT_SENSORS - else: + + if model in MODELS_AIR_QUALITY_MONITOR: unique_id = config_entry.unique_id name = config_entry.title _LOGGER.debug("Initializing with host %s (token %s...)", host, token[:5]) @@ -408,19 +397,35 @@ async def async_setup_entry(hass, config_entry, async_add_entities): name, device, config_entry, unique_id, description ) ) - for sensor, description in SENSOR_TYPES.items(): - if sensor not in sensors: - continue - entities.append( - XiaomiGenericSensor( - f"{config_entry.title} {description.name}", - device, - config_entry, - f"{sensor}_{config_entry.unique_id}", - hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR], - description, + else: + device = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] + sensors = [] + if model in MODEL_TO_SENSORS_MAP: + sensors = MODEL_TO_SENSORS_MAP[model] + elif model in MODELS_HUMIDIFIER_MIOT: + sensors = HUMIDIFIER_MIOT_SENSORS + elif model in MODELS_HUMIDIFIER_MJJSQ: + sensors = HUMIDIFIER_MJJSQ_SENSORS + elif model in MODELS_HUMIDIFIER_MIIO: + sensors = HUMIDIFIER_MIIO_SENSORS + elif model in MODELS_PURIFIER_MIIO: + sensors = PURIFIER_MIIO_SENSORS + elif model in MODELS_PURIFIER_MIOT: + sensors = PURIFIER_MIOT_SENSORS + + for sensor, description in SENSOR_TYPES.items(): + if sensor not in sensors: + continue + entities.append( + XiaomiGenericSensor( + f"{config_entry.title} {description.name}", + device, + config_entry, + f"{sensor}_{config_entry.unique_id}", + hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR], + description, + ) ) - ) async_add_entities(entities) From e671ad41ec6da3db2a5f44b60a3eeae413696b36 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 6 Sep 2021 09:50:54 -0400 Subject: [PATCH 253/843] Replace zigpy-cc with zigpy-znp (#55828) * Replace zigpy-cc with zigpy-znp in a ZHA config migration * Fix failing unit tests --- homeassistant/components/zha/__init__.py | 11 ++++- homeassistant/components/zha/config_flow.py | 2 +- homeassistant/components/zha/core/const.py | 7 --- homeassistant/components/zha/core/gateway.py | 8 ++-- homeassistant/components/zha/manifest.json | 1 - requirements_all.txt | 3 -- requirements_test_all.txt | 3 -- tests/components/zha/test_config_flow.py | 48 +++++++++++++++++--- tests/components/zha/test_init.py | 6 +-- 9 files changed, 61 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index e5b8c0936fd..b50adf15020 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -184,7 +184,16 @@ async def async_migrate_entry( data[CONF_DEVICE][CONF_BAUDRATE] = baudrate config_entry.version = 2 - hass.config_entries.async_update_entry(config_entry, data=data) + config_entry.data = data + + if config_entry.version == 2: + data = {**config_entry.data} + + if data[CONF_RADIO_TYPE] == "ti_cc": + data[CONF_RADIO_TYPE] = "znp" + + config_entry.version = 3 + config_entry.data = data _LOGGER.info("Migration to version %s successful", config_entry.version) return True diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 481b79c5aa7..9173db4d510 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -31,7 +31,7 @@ DECONZ_DOMAIN = "deconz" class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow.""" - VERSION = 2 + VERSION = 3 def __init__(self): """Initialize flow instance.""" diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index 04ed3ba4281..fe0240472bd 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -7,7 +7,6 @@ import logging import bellows.zigbee.application import voluptuous as vol from zigpy.config import CONF_DEVICE_PATH # noqa: F401 # pylint: disable=unused-import -import zigpy_cc.zigbee.application import zigpy_deconz.zigbee.application import zigpy_xbee.zigbee.application import zigpy_zigate.zigbee.application @@ -181,7 +180,6 @@ DATA_ZHA_SHUTDOWN_TASK = "zha_shutdown_task" DEBUG_COMP_BELLOWS = "bellows" DEBUG_COMP_ZHA = "homeassistant.components.zha" DEBUG_COMP_ZIGPY = "zigpy" -DEBUG_COMP_ZIGPY_CC = "zigpy_cc" DEBUG_COMP_ZIGPY_ZNP = "zigpy_znp" DEBUG_COMP_ZIGPY_DECONZ = "zigpy_deconz" DEBUG_COMP_ZIGPY_XBEE = "zigpy_xbee" @@ -192,7 +190,6 @@ DEBUG_LEVELS = { DEBUG_COMP_BELLOWS: logging.DEBUG, DEBUG_COMP_ZHA: logging.DEBUG, DEBUG_COMP_ZIGPY: logging.DEBUG, - DEBUG_COMP_ZIGPY_CC: logging.DEBUG, DEBUG_COMP_ZIGPY_ZNP: logging.DEBUG, DEBUG_COMP_ZIGPY_DECONZ: logging.DEBUG, DEBUG_COMP_ZIGPY_XBEE: logging.DEBUG, @@ -246,10 +243,6 @@ class RadioType(enum.Enum): "deCONZ = dresden elektronik deCONZ protocol: ConBee I/II, RaspBee I/II", zigpy_deconz.zigbee.application.ControllerApplication, ) - ti_cc = ( - "Legacy TI_CC = Texas Instruments Z-Stack ZNP protocol: CC253x, CC26x2, CC13x2", - zigpy_cc.zigbee.application.ControllerApplication, - ) zigate = ( "ZiGate = ZiGate Zigbee radios: PiZiGate, ZiGate USB-TTL, ZiGate WiFi", zigpy_zigate.zigbee.application.ControllerApplication, diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 50da16802b3..4e793b39a8a 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -46,10 +46,10 @@ from .const import ( DEBUG_COMP_BELLOWS, DEBUG_COMP_ZHA, DEBUG_COMP_ZIGPY, - DEBUG_COMP_ZIGPY_CC, DEBUG_COMP_ZIGPY_DECONZ, DEBUG_COMP_ZIGPY_XBEE, DEBUG_COMP_ZIGPY_ZIGATE, + DEBUG_COMP_ZIGPY_ZNP, DEBUG_LEVEL_CURRENT, DEBUG_LEVEL_ORIGINAL, DEBUG_LEVELS, @@ -689,7 +689,9 @@ def async_capture_log_levels(): DEBUG_COMP_BELLOWS: logging.getLogger(DEBUG_COMP_BELLOWS).getEffectiveLevel(), DEBUG_COMP_ZHA: logging.getLogger(DEBUG_COMP_ZHA).getEffectiveLevel(), DEBUG_COMP_ZIGPY: logging.getLogger(DEBUG_COMP_ZIGPY).getEffectiveLevel(), - DEBUG_COMP_ZIGPY_CC: logging.getLogger(DEBUG_COMP_ZIGPY_CC).getEffectiveLevel(), + DEBUG_COMP_ZIGPY_ZNP: logging.getLogger( + DEBUG_COMP_ZIGPY_ZNP + ).getEffectiveLevel(), DEBUG_COMP_ZIGPY_DECONZ: logging.getLogger( DEBUG_COMP_ZIGPY_DECONZ ).getEffectiveLevel(), @@ -708,7 +710,7 @@ def async_set_logger_levels(levels): logging.getLogger(DEBUG_COMP_BELLOWS).setLevel(levels[DEBUG_COMP_BELLOWS]) logging.getLogger(DEBUG_COMP_ZHA).setLevel(levels[DEBUG_COMP_ZHA]) logging.getLogger(DEBUG_COMP_ZIGPY).setLevel(levels[DEBUG_COMP_ZIGPY]) - logging.getLogger(DEBUG_COMP_ZIGPY_CC).setLevel(levels[DEBUG_COMP_ZIGPY_CC]) + logging.getLogger(DEBUG_COMP_ZIGPY_ZNP).setLevel(levels[DEBUG_COMP_ZIGPY_ZNP]) logging.getLogger(DEBUG_COMP_ZIGPY_DECONZ).setLevel(levels[DEBUG_COMP_ZIGPY_DECONZ]) logging.getLogger(DEBUG_COMP_ZIGPY_XBEE).setLevel(levels[DEBUG_COMP_ZIGPY_XBEE]) logging.getLogger(DEBUG_COMP_ZIGPY_ZIGATE).setLevel(levels[DEBUG_COMP_ZIGPY_ZIGATE]) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 4b2b27e829c..19ffff2f12b 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -8,7 +8,6 @@ "pyserial==3.5", "pyserial-asyncio==0.5", "zha-quirks==0.0.60", - "zigpy-cc==0.5.2", "zigpy-deconz==0.13.0", "zigpy==0.37.1", "zigpy-xbee==0.14.0", diff --git a/requirements_all.txt b/requirements_all.txt index 60bfe276c37..cfe5c4ea8ec 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2467,9 +2467,6 @@ zhong_hong_hvac==1.0.9 # homeassistant.components.ziggo_mediabox_xl ziggo-mediabox-xl==1.1.0 -# homeassistant.components.zha -zigpy-cc==0.5.2 - # homeassistant.components.zha zigpy-deconz==0.13.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 57c001d6c1c..7b5185dac17 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1381,9 +1381,6 @@ zeroconf==0.36.2 # homeassistant.components.zha zha-quirks==0.0.60 -# homeassistant.components.zha -zigpy-cc==0.5.2 - # homeassistant.components.zha zigpy-deconz==0.13.0 diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index 732b7cf440d..c00e25fa636 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -13,7 +13,7 @@ from homeassistant.components.ssdp import ( ATTR_UPNP_MANUFACTURER_URL, ATTR_UPNP_SERIAL, ) -from homeassistant.components.zha import config_flow +from homeassistant.components.zha import async_migrate_entry, config_flow from homeassistant.components.zha.core.const import ( CONF_BAUDRATE, CONF_FLOWCONTROL, @@ -447,7 +447,7 @@ async def test_user_flow_existing_config_entry(hass): assert result["type"] == "abort" -@patch("zigpy_cc.zigbee.application.ControllerApplication.probe", return_value=False) +@patch("zigpy_znp.zigbee.application.ControllerApplication.probe", return_value=False) @patch( "zigpy_deconz.zigbee.application.ControllerApplication.probe", return_value=False ) @@ -455,7 +455,7 @@ async def test_user_flow_existing_config_entry(hass): "zigpy_zigate.zigbee.application.ControllerApplication.probe", return_value=False ) @patch("zigpy_xbee.zigbee.application.ControllerApplication.probe", return_value=False) -async def test_probe_radios(xbee_probe, zigate_probe, deconz_probe, cc_probe, hass): +async def test_probe_radios(xbee_probe, zigate_probe, deconz_probe, znp_probe, hass): """Test detect radios.""" app_ctrl_cls = MagicMock() app_ctrl_cls.SCHEMA_DEVICE = zigpy.config.SCHEMA_DEVICE @@ -468,6 +468,7 @@ async def test_probe_radios(xbee_probe, zigate_probe, deconz_probe, cc_probe, ha with p1 as probe_mock: res = await config_flow.detect_radios("/dev/null") assert probe_mock.await_count == 1 + assert znp_probe.await_count == 1 # ZNP appears earlier in the radio list assert res[CONF_RADIO_TYPE] == "ezsp" assert zigpy.config.CONF_DEVICE in res assert ( @@ -479,10 +480,10 @@ async def test_probe_radios(xbee_probe, zigate_probe, deconz_probe, cc_probe, ha assert xbee_probe.await_count == 1 assert zigate_probe.await_count == 1 assert deconz_probe.await_count == 1 - assert cc_probe.await_count == 1 + assert znp_probe.await_count == 2 -@patch("zigpy_cc.zigbee.application.ControllerApplication.probe", return_value=False) +@patch("zigpy_znp.zigbee.application.ControllerApplication.probe", return_value=False) @patch( "zigpy_deconz.zigbee.application.ControllerApplication.probe", return_value=False ) @@ -490,7 +491,7 @@ async def test_probe_radios(xbee_probe, zigate_probe, deconz_probe, cc_probe, ha "zigpy_zigate.zigbee.application.ControllerApplication.probe", return_value=False ) @patch("zigpy_xbee.zigbee.application.ControllerApplication.probe", return_value=False) -async def test_probe_new_ezsp(xbee_probe, zigate_probe, deconz_probe, cc_probe, hass): +async def test_probe_new_ezsp(xbee_probe, zigate_probe, deconz_probe, znp_probe, hass): """Test detect radios.""" app_ctrl_cls = MagicMock() app_ctrl_cls.SCHEMA_DEVICE = zigpy.config.SCHEMA_DEVICE @@ -560,3 +561,38 @@ async def test_user_port_config(probe_mock, hass): ) assert result["data"][CONF_RADIO_TYPE] == "ezsp" assert probe_mock.await_count == 1 + + +@pytest.mark.parametrize( + "old_type,new_type", + [ + ("ezsp", "ezsp"), + ("ti_cc", "znp"), # only one that should change + ("znp", "znp"), + ("deconz", "deconz"), + ], +) +async def test_migration_ti_cc_to_znp(old_type, new_type, hass, config_entry): + """Test zigpy-cc to zigpy-znp config migration.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=old_type + new_type, + data={ + CONF_RADIO_TYPE: old_type, + CONF_DEVICE: { + CONF_DEVICE_PATH: "/dev/ttyUSB1", + CONF_BAUDRATE: 115200, + CONF_FLOWCONTROL: None, + }, + }, + ) + + config_entry.version = 2 + config_entry.add_to_hass(hass) + + await async_migrate_entry(hass, config_entry) + + assert config_entry.version > 2 + assert config_entry.data[CONF_RADIO_TYPE] == new_type diff --git a/tests/components/zha/test_init.py b/tests/components/zha/test_init.py index f259febd817..bb8c502562e 100644 --- a/tests/components/zha/test_init.py +++ b/tests/components/zha/test_init.py @@ -42,7 +42,7 @@ async def test_migration_from_v1_no_baudrate(hass, config_entry_v1, config): assert config_entry_v1.data[CONF_DEVICE][CONF_DEVICE_PATH] == DATA_PORT_PATH assert CONF_BAUDRATE not in config_entry_v1.data[CONF_DEVICE] assert CONF_USB_PATH not in config_entry_v1.data - assert config_entry_v1.version == 2 + assert config_entry_v1.version == 3 @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) @@ -57,7 +57,7 @@ async def test_migration_from_v1_with_baudrate(hass, config_entry_v1): assert CONF_USB_PATH not in config_entry_v1.data assert CONF_BAUDRATE in config_entry_v1.data[CONF_DEVICE] assert config_entry_v1.data[CONF_DEVICE][CONF_BAUDRATE] == 115200 - assert config_entry_v1.version == 2 + assert config_entry_v1.version == 3 @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) @@ -71,7 +71,7 @@ async def test_migration_from_v1_wrong_baudrate(hass, config_entry_v1): assert config_entry_v1.data[CONF_DEVICE][CONF_DEVICE_PATH] == DATA_PORT_PATH assert CONF_USB_PATH not in config_entry_v1.data assert CONF_BAUDRATE not in config_entry_v1.data[CONF_DEVICE] - assert config_entry_v1.version == 2 + assert config_entry_v1.version == 3 @pytest.mark.skipif( From 263494999960658671220e6858e899fe64fceee6 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 6 Sep 2021 16:19:02 +0200 Subject: [PATCH 254/843] Add motion_blinds VerticalBlind and cleanup (#55774) --- homeassistant/components/motion_blinds/__init__.py | 12 ++++++++---- homeassistant/components/motion_blinds/cover.py | 1 + homeassistant/components/motion_blinds/sensor.py | 12 ++---------- 3 files changed, 11 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/motion_blinds/__init__.py b/homeassistant/components/motion_blinds/__init__.py index f7ae6573b1b..a4fb003b546 100644 --- a/homeassistant/components/motion_blinds/__init__.py +++ b/homeassistant/components/motion_blinds/__init__.py @@ -3,8 +3,7 @@ from datetime import timedelta import logging from socket import timeout -from motionblinds import MotionMulticast -from motionblinds.motion_blinds import ParseException +from motionblinds import MotionMulticast, ParseException from homeassistant import config_entries, core from homeassistant.const import CONF_API_KEY, CONF_HOST, EVENT_HOMEASSISTANT_STOP @@ -40,7 +39,7 @@ class DataUpdateCoordinatorMotionBlinds(DataUpdateCoordinator): name, update_interval=None, update_method=None, - ): + ) -> None: """Initialize global data updater.""" super().__init__( hass, @@ -137,6 +136,11 @@ async def async_setup_entry( KEY_COORDINATOR: coordinator, } + if motion_gateway.firmware is not None: + version = f"{motion_gateway.firmware}, protocol: {motion_gateway.protocol}" + else: + version = f"Protocol: {motion_gateway.protocol}" + device_registry = await dr.async_get_registry(hass) device_registry.async_get_or_create( config_entry_id=entry.entry_id, @@ -145,7 +149,7 @@ async def async_setup_entry( manufacturer=MANUFACTURER, name=entry.title, model="Wi-Fi bridge", - sw_version=motion_gateway.protocol, + sw_version=version, ) hass.config_entries.async_setup_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/motion_blinds/cover.py b/homeassistant/components/motion_blinds/cover.py index 60ec375a9c0..c96dff93e67 100644 --- a/homeassistant/components/motion_blinds/cover.py +++ b/homeassistant/components/motion_blinds/cover.py @@ -52,6 +52,7 @@ TILT_DEVICE_MAP = { BlindType.VenetianBlind: DEVICE_CLASS_BLIND, BlindType.ShangriLaBlind: DEVICE_CLASS_BLIND, BlindType.DoubleRoller: DEVICE_CLASS_SHADE, + BlindType.VerticalBlind: DEVICE_CLASS_BLIND, } TDBU_DEVICE_MAP = { diff --git a/homeassistant/components/motion_blinds/sensor.py b/homeassistant/components/motion_blinds/sensor.py index 9c6db5d88ec..194f0ae315c 100644 --- a/homeassistant/components/motion_blinds/sensor.py +++ b/homeassistant/components/motion_blinds/sensor.py @@ -40,11 +40,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class MotionBatterySensor(CoordinatorEntity, SensorEntity): - """ - Representation of a Motion Battery Sensor. - - Updates are done by the cover platform. - """ + """Representation of a Motion Battery Sensor.""" _attr_device_class = DEVICE_CLASS_BATTERY _attr_native_unit_of_measurement = PERCENTAGE @@ -91,11 +87,7 @@ class MotionBatterySensor(CoordinatorEntity, SensorEntity): class MotionTDBUBatterySensor(MotionBatterySensor): - """ - Representation of a Motion Battery Sensor for a Top Down Bottom Up blind. - - Updates are done by the cover platform. - """ + """Representation of a Motion Battery Sensor for a Top Down Bottom Up blind.""" def __init__(self, coordinator, blind, motor): """Initialize the Motion Battery Sensor.""" From b99a22cd4d6660b1f7dd6c1d5bf2d80311c448f5 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 6 Sep 2021 18:28:58 +0200 Subject: [PATCH 255/843] Re-add state_class total to sensor (#55103) * Re-add state_class total to sensor * Make energy cost sensor enforce state_class total_increasing * Bump deprecation of last_reset for state_class measurement * Correct rebase mistakes --- homeassistant/components/sensor/__init__.py | 10 ++- homeassistant/components/sensor/recorder.py | 5 +- tests/components/energy/test_sensor.py | 3 +- tests/components/sensor/test_init.py | 9 ++- tests/components/sensor/test_recorder.py | 84 ++++++++++++++++++++- 5 files changed, 101 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 0518495a0f0..370b36bf1fc 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -96,11 +96,14 @@ DEVICE_CLASSES_SCHEMA: Final = vol.All(vol.Lower, vol.In(DEVICE_CLASSES)) # The state represents a measurement in present time STATE_CLASS_MEASUREMENT: Final = "measurement" +# The state represents a total amount, e.g. net energy consumption +STATE_CLASS_TOTAL: Final = "total" # The state represents a monotonically increasing total, e.g. an amount of consumed gas STATE_CLASS_TOTAL_INCREASING: Final = "total_increasing" STATE_CLASSES: Final[list[str]] = [ STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL, STATE_CLASS_TOTAL_INCREASING, ] @@ -214,9 +217,10 @@ class SensorEntity(Entity): report_issue = self._suggest_report_issue() _LOGGER.warning( "Entity %s (%s) with state_class %s has set last_reset. Setting " - "last_reset is deprecated and will be unsupported from Home " - "Assistant Core 2021.11. Please update your configuration if " - "state_class is manually configured, otherwise %s", + "last_reset for entities with state_class other than 'total' is " + "deprecated and will be removed from Home Assistant Core 2021.11. " + "Please update your configuration if state_class is manually " + "configured, otherwise %s", self.entity_id, type(self), self.state_class, diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 8bf251ffb18..c6c8482669e 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -15,6 +15,7 @@ from homeassistant.components.sensor import ( DEVICE_CLASS_PRESSURE, DEVICE_CLASS_TEMPERATURE, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL, STATE_CLASS_TOTAL_INCREASING, STATE_CLASSES, ) @@ -56,10 +57,12 @@ DEVICE_CLASS_STATISTICS: dict[str, dict[str, set[str]]] = { DEVICE_CLASS_GAS: {"sum"}, DEVICE_CLASS_MONETARY: {"sum"}, }, + STATE_CLASS_TOTAL: {}, STATE_CLASS_TOTAL_INCREASING: {}, } DEFAULT_STATISTICS = { STATE_CLASS_MEASUREMENT: {"mean", "min", "max"}, + STATE_CLASS_TOTAL: {"sum"}, STATE_CLASS_TOTAL_INCREASING: {"sum"}, } @@ -389,7 +392,7 @@ def compile_statistics( # noqa: C901 for fstate, state in fstates: - # Deprecated, will be removed in Home Assistant 2021.10 + # Deprecated, will be removed in Home Assistant 2021.11 if ( "last_reset" not in state.attributes and state_class == STATE_CLASS_MEASUREMENT diff --git a/tests/components/energy/test_sensor.py b/tests/components/energy/test_sensor.py index 542ea3296ce..f91ddd92206 100644 --- a/tests/components/energy/test_sensor.py +++ b/tests/components/energy/test_sensor.py @@ -8,6 +8,7 @@ import pytest from homeassistant.components.energy import data from homeassistant.components.sensor import ( ATTR_STATE_CLASS, + STATE_CLASS_TOTAL, STATE_CLASS_TOTAL_INCREASING, ) from homeassistant.components.sensor.recorder import compile_statistics @@ -357,7 +358,7 @@ async def test_cost_sensor_handle_gas(hass, hass_storage) -> None: assert state.state == "50.0" -@pytest.mark.parametrize("state_class", [None]) +@pytest.mark.parametrize("state_class", [None, STATE_CLASS_TOTAL]) async def test_cost_sensor_wrong_state_class( hass, hass_storage, caplog, state_class ) -> None: diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 7463cc6755a..7859d133c29 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -45,10 +45,11 @@ async def test_deprecated_last_reset(hass, caplog, enable_custom_integrations): assert ( "Entity sensor.test () " - "with state_class measurement has set last_reset. Setting last_reset is " - "deprecated and will be unsupported from Home Assistant Core 2021.11. Please " - "update your configuration if state_class is manually configured, otherwise " - "report it to the custom component author." + "with state_class measurement has set last_reset. Setting last_reset for " + "entities with state_class other than 'total' is deprecated and will be " + "removed from Home Assistant Core 2021.11. Please update your configuration if " + "state_class is manually configured, otherwise report it to the custom " + "component author." ) in caplog.text diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index aeeab317eb1..41fa80d3f24 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -191,7 +191,7 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes assert "Error while processing event StatisticsTask" not in caplog.text -@pytest.mark.parametrize("state_class", ["measurement"]) +@pytest.mark.parametrize("state_class", ["measurement", "total"]) @pytest.mark.parametrize( "device_class,unit,native_unit,factor", [ @@ -349,6 +349,88 @@ def test_compile_hourly_sum_statistics_amount_reset_every_state_change( assert "Error while processing event StatisticsTask" not in caplog.text +@pytest.mark.parametrize( + "device_class,unit,native_unit,factor", + [ + ("energy", "kWh", "kWh", 1), + ("energy", "Wh", "kWh", 1 / 1000), + ("monetary", "EUR", "EUR", 1), + ("monetary", "SEK", "SEK", 1), + ("gas", "m³", "m³", 1), + ("gas", "ft³", "m³", 0.0283168466), + ], +) +def test_compile_hourly_sum_statistics_total_no_reset( + hass_recorder, caplog, device_class, unit, native_unit, factor +): + """Test compiling hourly statistics.""" + zero = dt_util.utcnow() + hass = hass_recorder() + recorder = hass.data[DATA_INSTANCE] + setup_component(hass, "sensor", {}) + attributes = { + "device_class": device_class, + "state_class": "total", + "unit_of_measurement": unit, + } + seq = [10, 15, 20, 10, 30, 40, 50, 60, 70] + + four, eight, states = record_meter_states( + hass, zero, "sensor.test1", attributes, seq + ) + hist = history.get_significant_states( + hass, zero - timedelta.resolution, eight + timedelta.resolution + ) + assert dict(states)["sensor.test1"] == dict(hist)["sensor.test1"] + + recorder.do_adhoc_statistics(period="hourly", start=zero) + wait_recording_done(hass) + recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=1)) + wait_recording_done(hass) + recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=2)) + wait_recording_done(hass) + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + {"statistic_id": "sensor.test1", "unit_of_measurement": native_unit} + ] + stats = statistics_during_period(hass, zero) + assert stats == { + "sensor.test1": [ + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero), + "max": None, + "mean": None, + "min": None, + "last_reset": None, + "state": approx(factor * seq[2]), + "sum": approx(factor * 10.0), + }, + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), + "max": None, + "mean": None, + "min": None, + "last_reset": None, + "state": approx(factor * seq[5]), + "sum": approx(factor * 30.0), + }, + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=2)), + "max": None, + "mean": None, + "min": None, + "last_reset": None, + "state": approx(factor * seq[8]), + "sum": approx(factor * 60.0), + }, + ] + } + assert "Error while processing event StatisticsTask" not in caplog.text + + @pytest.mark.parametrize( "device_class,unit,native_unit,factor", [ From dd7dea9a3fae934eaf710935e46edc68f7b42d4b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 6 Sep 2021 19:10:27 +0200 Subject: [PATCH 256/843] Make scapy imports in DHCP local (#55647) --- homeassistant/components/dhcp/__init__.py | 23 ++++++++++++++++++----- tests/components/dhcp/test_init.py | 12 +++++------- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/dhcp/__init__.py b/homeassistant/components/dhcp/__init__.py index 1a49667bad8..cc89c9b785d 100644 --- a/homeassistant/components/dhcp/__init__.py +++ b/homeassistant/components/dhcp/__init__.py @@ -14,13 +14,8 @@ from aiodiscover.discovery import ( IP_ADDRESS as DISCOVERY_IP_ADDRESS, MAC_ADDRESS as DISCOVERY_MAC_ADDRESS, ) -from scapy.arch.common import compile_filter from scapy.config import conf from scapy.error import Scapy_Exception -from scapy.layers.dhcp import DHCP -from scapy.layers.inet import IP -from scapy.layers.l2 import Ether -from scapy.sendrecv import AsyncSniffer from homeassistant.components.device_tracker.const import ( ATTR_HOST_NAME, @@ -282,6 +277,12 @@ class DHCPWatcher(WatcherBase): async def async_start(self): """Start watching for dhcp packets.""" + # Local import because importing from scapy has side effects such as opening + # sockets + from scapy.sendrecv import ( # pylint: disable=import-outside-toplevel + AsyncSniffer, + ) + # disable scapy promiscuous mode as we do not need it conf.sniff_promisc = 0 @@ -318,6 +319,12 @@ class DHCPWatcher(WatcherBase): def handle_dhcp_packet(self, packet): """Process a dhcp packet.""" + # Local import because importing from scapy has side effects such as opening + # sockets + from scapy.layers.dhcp import DHCP # pylint: disable=import-outside-toplevel + from scapy.layers.inet import IP # pylint: disable=import-outside-toplevel + from scapy.layers.l2 import Ether # pylint: disable=import-outside-toplevel + if DHCP not in packet: return @@ -382,4 +389,10 @@ def _verify_working_pcap(cap_filter): If we cannot create a filter we will be listening for all traffic which is too intensive. """ + # Local import because importing from scapy has side effects such as opening + # sockets + from scapy.arch.common import ( # pylint: disable=import-outside-toplevel + compile_filter, + ) + compile_filter(cap_filter) diff --git a/tests/components/dhcp/test_init.py b/tests/components/dhcp/test_init.py index 0da383c758a..90ce1ebbf20 100644 --- a/tests/components/dhcp/test_init.py +++ b/tests/components/dhcp/test_init.py @@ -390,9 +390,9 @@ async def test_setup_and_stop(hass): ) await hass.async_block_till_done() - with patch("homeassistant.components.dhcp.AsyncSniffer.start") as start_call, patch( + with patch("scapy.sendrecv.AsyncSniffer.start") as start_call, patch( "homeassistant.components.dhcp._verify_l2socket_setup", - ), patch("homeassistant.components.dhcp.compile_filter",), patch( + ), patch("scapy.arch.common.compile_filter"), patch( "homeassistant.components.dhcp.DiscoverHosts.async_discover" ): hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) @@ -461,12 +461,10 @@ async def test_setup_fails_with_broken_libpcap(hass, caplog): ) await hass.async_block_till_done() - with patch("homeassistant.components.dhcp._verify_l2socket_setup",), patch( - "homeassistant.components.dhcp.compile_filter", + with patch("homeassistant.components.dhcp._verify_l2socket_setup"), patch( + "scapy.arch.common.compile_filter", side_effect=ImportError, - ) as compile_filter, patch( - "homeassistant.components.dhcp.AsyncSniffer", - ) as async_sniffer, patch( + ) as compile_filter, patch("scapy.sendrecv.AsyncSniffer") as async_sniffer, patch( "homeassistant.components.dhcp.DiscoverHosts.async_discover" ): hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) From 8b6d0ca13f2499fd67af46431c187ed00d07782e Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 6 Sep 2021 20:44:38 +0200 Subject: [PATCH 257/843] Replace util.get_local_ip in favor of components.network.async_get_source_ip() - part 2 (#53368) Co-authored-by: J. Nick Koston --- .../components/emulated_hue/__init__.py | 16 +++++++--------- .../components/emulated_hue/manifest.json | 1 + tests/components/emulated_hue/test_hue_api.py | 3 ++- tests/components/emulated_hue/test_init.py | 8 ++++---- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/emulated_hue/__init__.py b/homeassistant/components/emulated_hue/__init__.py index 1ee5e19caa7..3cfa710703c 100644 --- a/homeassistant/components/emulated_hue/__init__.py +++ b/homeassistant/components/emulated_hue/__init__.py @@ -4,7 +4,8 @@ import logging from aiohttp import web import voluptuous as vol -from homeassistant import util +from homeassistant.components.network import async_get_source_ip +from homeassistant.components.network.const import PUBLIC_TARGET_IP from homeassistant.const import ( CONF_ENTITIES, CONF_TYPE, @@ -105,7 +106,9 @@ ATTR_EMULATED_HUE_NAME = "emulated_hue_name" async def async_setup(hass, yaml_config): """Activate the emulated_hue component.""" - config = Config(hass, yaml_config.get(DOMAIN, {})) + local_ip = await async_get_source_ip(hass, PUBLIC_TARGET_IP) + config = Config(hass, yaml_config.get(DOMAIN, {}), local_ip) + await config.async_setup() app = web.Application() app["hass"] = hass @@ -156,7 +159,6 @@ async def async_setup(hass, yaml_config): nonlocal protocol nonlocal site nonlocal runner - await config.async_setup() _, protocol = await listen @@ -186,7 +188,7 @@ async def async_setup(hass, yaml_config): class Config: """Hold configuration variables for the emulated hue bridge.""" - def __init__(self, hass, conf): + def __init__(self, hass, conf, local_ip): """Initialize the instance.""" self.hass = hass self.type = conf.get(CONF_TYPE) @@ -204,11 +206,7 @@ class Config: # Get the IP address that will be passed to the Echo during discovery self.host_ip_addr = conf.get(CONF_HOST_IP) if self.host_ip_addr is None: - self.host_ip_addr = util.get_local_ip() - _LOGGER.info( - "Listen IP address not specified, auto-detected address is %s", - self.host_ip_addr, - ) + self.host_ip_addr = local_ip # Get the port that the Hue bridge will listen on self.listen_port = conf.get(CONF_LISTEN_PORT) diff --git a/homeassistant/components/emulated_hue/manifest.json b/homeassistant/components/emulated_hue/manifest.json index 406451639f2..e5a9072e51d 100644 --- a/homeassistant/components/emulated_hue/manifest.json +++ b/homeassistant/components/emulated_hue/manifest.json @@ -3,6 +3,7 @@ "name": "Emulated Hue", "documentation": "https://www.home-assistant.io/integrations/emulated_hue", "requirements": ["aiohttp_cors==0.7.0"], + "dependencies": ["network"], "after_dependencies": ["http"], "codeowners": [], "quality_scale": "internal", diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index 8515d4e4b0c..e4d422f9802 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -244,6 +244,7 @@ def hue_client(loop, hass_hue, hass_client_no_auth): "scene.light_off": {emulated_hue.CONF_ENTITY_HIDDEN: False}, }, }, + "127.0.0.1", ) config.numbers = ENTITY_IDS_BY_NUMBER @@ -322,7 +323,7 @@ async def test_lights_all_dimmable(hass, hass_client_no_auth): {emulated_hue.DOMAIN: hue_config}, ) await hass.async_block_till_done() - config = Config(None, hue_config) + config = Config(None, hue_config, "127.0.0.1") config.numbers = ENTITY_IDS_BY_NUMBER web_app = hass.http.app HueOneLightStateView(config).register(web_app, web_app.router) diff --git a/tests/components/emulated_hue/test_init.py b/tests/components/emulated_hue/test_init.py index da15fbfba30..2b0d6fe06c6 100644 --- a/tests/components/emulated_hue/test_init.py +++ b/tests/components/emulated_hue/test_init.py @@ -14,7 +14,7 @@ from tests.common import async_fire_time_changed async def test_config_google_home_entity_id_to_number(hass, hass_storage): """Test config adheres to the type.""" - conf = Config(hass, {"type": "google_home"}) + conf = Config(hass, {"type": "google_home"}, "127.0.0.1") hass_storage[DATA_KEY] = { "version": DATA_VERSION, "key": DATA_KEY, @@ -45,7 +45,7 @@ async def test_config_google_home_entity_id_to_number(hass, hass_storage): async def test_config_google_home_entity_id_to_number_altered(hass, hass_storage): """Test config adheres to the type.""" - conf = Config(hass, {"type": "google_home"}) + conf = Config(hass, {"type": "google_home"}, "127.0.0.1") hass_storage[DATA_KEY] = { "version": DATA_VERSION, "key": DATA_KEY, @@ -76,7 +76,7 @@ async def test_config_google_home_entity_id_to_number_altered(hass, hass_storage async def test_config_google_home_entity_id_to_number_empty(hass, hass_storage): """Test config adheres to the type.""" - conf = Config(hass, {"type": "google_home"}) + conf = Config(hass, {"type": "google_home"}, "127.0.0.1") hass_storage[DATA_KEY] = {"version": DATA_VERSION, "key": DATA_KEY, "data": {}} await conf.async_setup() @@ -100,7 +100,7 @@ async def test_config_google_home_entity_id_to_number_empty(hass, hass_storage): def test_config_alexa_entity_id_to_number(): """Test config adheres to the type.""" - conf = Config(None, {"type": "alexa"}) + conf = Config(None, {"type": "alexa"}, "127.0.0.1") number = conf.entity_id_to_number("light.test") assert number == "light.test" From 12b1f87b35796f6d6d5d0bd9e773c94ee1f0a245 Mon Sep 17 00:00:00 2001 From: Brandon Rothweiler Date: Mon, 6 Sep 2021 14:53:03 -0400 Subject: [PATCH 258/843] Upgrade pymazda to 0.2.1 (#55820) --- homeassistant/components/mazda/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mazda/manifest.json b/homeassistant/components/mazda/manifest.json index cc12653f5cb..7eb85f722ae 100644 --- a/homeassistant/components/mazda/manifest.json +++ b/homeassistant/components/mazda/manifest.json @@ -3,7 +3,7 @@ "name": "Mazda Connected Services", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/mazda", - "requirements": ["pymazda==0.2.0"], + "requirements": ["pymazda==0.2.1"], "codeowners": ["@bdr99"], "quality_scale": "platinum", "iot_class": "cloud_polling" diff --git a/requirements_all.txt b/requirements_all.txt index cfe5c4ea8ec..b147c53736c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1599,7 +1599,7 @@ pymailgunner==1.4 pymata-express==1.19 # homeassistant.components.mazda -pymazda==0.2.0 +pymazda==0.2.1 # homeassistant.components.mediaroom pymediaroom==0.6.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7b5185dac17..838507e157c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -921,7 +921,7 @@ pymailgunner==1.4 pymata-express==1.19 # homeassistant.components.mazda -pymazda==0.2.0 +pymazda==0.2.1 # homeassistant.components.melcloud pymelcloud==2.5.3 From 0533a9c714e658d7f7ff6b41d4c67da6ddd282ba Mon Sep 17 00:00:00 2001 From: Joshi <42069141+Joshi425@users.noreply.github.com> Date: Mon, 6 Sep 2021 21:03:46 +0200 Subject: [PATCH 259/843] Fix switch name attribute for thinkingcleaner (#55730) --- homeassistant/components/thinkingcleaner/switch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/thinkingcleaner/switch.py b/homeassistant/components/thinkingcleaner/switch.py index 75cfc51a511..cad94b72023 100644 --- a/homeassistant/components/thinkingcleaner/switch.py +++ b/homeassistant/components/thinkingcleaner/switch.py @@ -80,7 +80,7 @@ class ThinkingCleanerSwitch(SwitchEntity): self.last_lock_time = None self.graceful_state = False - self._attr_name = f"{tc_object} {description.name}" + self._attr_name = f"{tc_object.name} {description.name}" def lock_update(self): """Lock the update since TC clean takes some time to update.""" From eba9b610112ddcfd82cf5394b0fa98209bc652b8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Sep 2021 10:35:24 -1000 Subject: [PATCH 260/843] Fix exception during rediscovery of ignored zha config entries (#55859) Fixes #55709 --- homeassistant/components/zha/config_flow.py | 4 +- tests/components/zha/test_config_flow.py | 59 +++++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 9173db4d510..2b867366453 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -108,7 +108,7 @@ class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured( updates={ CONF_DEVICE: { - **current_entry.data[CONF_DEVICE], + **current_entry.data.get(CONF_DEVICE, {}), CONF_DEVICE_PATH: dev_path, }, } @@ -172,7 +172,7 @@ class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured( updates={ CONF_DEVICE: { - **current_entry.data[CONF_DEVICE], + **current_entry.data.get(CONF_DEVICE, {}), CONF_DEVICE_PATH: device_path, }, } diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index c00e25fa636..b8a951a57f6 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -113,6 +113,34 @@ async def test_discovery_via_zeroconf_ip_change(detect_mock, hass): } +@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) +@patch("zigpy_znp.zigbee.application.ControllerApplication.probe", return_value=True) +async def test_discovery_via_zeroconf_ip_change_ignored(detect_mock, hass): + """Test zeroconf flow that was ignored gets updated.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="tube_zb_gw_cc2652p2_poe", + source=config_entries.SOURCE_IGNORE, + ) + entry.add_to_hass(hass) + + service_info = { + "host": "192.168.1.22", + "port": 6053, + "hostname": "tube_zb_gw_cc2652p2_poe.local.", + "properties": {"address": "tube_zb_gw_cc2652p2_poe.local"}, + } + result = await hass.config_entries.flow.async_init( + "zha", context={"source": SOURCE_ZEROCONF}, data=service_info + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + assert entry.data[CONF_DEVICE] == { + CONF_DEVICE_PATH: "socket://192.168.1.22:6638", + } + + @patch("zigpy_znp.zigbee.application.ControllerApplication.probe", return_value=True) async def test_discovery_via_usb(detect_mock, hass): """Test usb flow -- radio detected.""" @@ -317,6 +345,37 @@ async def test_discovery_via_usb_deconz_ignored(detect_mock, hass): assert result["step_id"] == "confirm" +@patch("zigpy_znp.zigbee.application.ControllerApplication.probe", return_value=True) +async def test_discovery_via_usb_zha_ignored_updates(detect_mock, hass): + """Test usb flow that was ignored gets updated.""" + entry = MockConfigEntry( + domain=DOMAIN, + source=config_entries.SOURCE_IGNORE, + data={}, + unique_id="AAAA:AAAA_1234_test_zigbee radio", + ) + entry.add_to_hass(hass) + await hass.async_block_till_done() + discovery_info = { + "device": "/dev/ttyZIGBEE", + "pid": "AAAA", + "vid": "AAAA", + "serial_number": "1234", + "description": "zigbee radio", + "manufacturer": "test", + } + result = await hass.config_entries.flow.async_init( + "zha", context={"source": SOURCE_USB}, data=discovery_info + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + assert entry.data[CONF_DEVICE] == { + CONF_DEVICE_PATH: "/dev/ttyZIGBEE", + } + + @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) @patch("zigpy_znp.zigbee.application.ControllerApplication.probe", return_value=True) async def test_discovery_already_setup(detect_mock, hass): From cac3e1acfa6f48820196ee207a715d6179968cef Mon Sep 17 00:00:00 2001 From: jan iversen Date: Mon, 6 Sep 2021 22:35:40 +0200 Subject: [PATCH 261/843] Allow same address different register types in modbus (#55767) --- homeassistant/components/modbus/validators.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py index a4177a7ff30..f41827f2c69 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -25,9 +25,11 @@ from homeassistant.const import ( from .const import ( CONF_DATA_TYPE, + CONF_INPUT_TYPE, CONF_SWAP, CONF_SWAP_BYTE, CONF_SWAP_NONE, + CONF_WRITE_TYPE, DATA_TYPE_CUSTOM, DATA_TYPE_FLOAT, DATA_TYPE_FLOAT16, @@ -212,6 +214,10 @@ def duplicate_entity_validator(config: dict) -> dict: for index, entry in enumerate(hub[conf_key]): name = entry[CONF_NAME] addr = str(entry[CONF_ADDRESS]) + if CONF_INPUT_TYPE in entry: + addr += "_" + str(entry[CONF_INPUT_TYPE]) + elif CONF_WRITE_TYPE in entry: + addr += "_" + str(entry[CONF_WRITE_TYPE]) if CONF_COMMAND_ON in entry: addr += "_" + str(entry[CONF_COMMAND_ON]) if CONF_COMMAND_OFF in entry: From 9093819671c969b0fbb5098edc3f098aa2362b4e Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 6 Sep 2021 22:36:18 +0200 Subject: [PATCH 262/843] Fix target humidity step for Xiaomi MJJSQ humidifiers (#55858) --- homeassistant/components/xiaomi_miio/humidifier.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/xiaomi_miio/humidifier.py b/homeassistant/components/xiaomi_miio/humidifier.py index aa26faae2b3..584d5caf6b5 100644 --- a/homeassistant/components/xiaomi_miio/humidifier.py +++ b/homeassistant/components/xiaomi_miio/humidifier.py @@ -210,7 +210,7 @@ class XiaomiAirHumidifier(XiaomiGenericHumidifier, HumidifierEntity): self._available_modes = AVAILABLE_MODES_MJJSQ self._min_humidity = 30 self._max_humidity = 80 - self._humidity_steps = 10 + self._humidity_steps = 100 else: self._available_modes = AVAILABLE_MODES_OTHER self._min_humidity = 30 From bcfedeb79717980eea6198d2ae19ca3ebae7aaca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Mon, 6 Sep 2021 22:36:45 +0200 Subject: [PATCH 263/843] Surepetcare, bug fix (#55842) --- homeassistant/components/surepetcare/__init__.py | 2 +- tests/components/surepetcare/conftest.py | 12 ++++++++---- tests/components/surepetcare/test_sensor.py | 2 +- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/surepetcare/__init__.py b/homeassistant/components/surepetcare/__init__.py index 58890090d57..00c45701423 100644 --- a/homeassistant/components/surepetcare/__init__.py +++ b/homeassistant/components/surepetcare/__init__.py @@ -144,7 +144,7 @@ class SurePetcareAPI: """Get the latest data from Sure Petcare.""" try: - self.states = await self.surepy.get_entities() + self.states = await self.surepy.get_entities(refresh=True) except SurePetcareError as error: _LOGGER.error("Unable to fetch data: %s", error) return diff --git a/tests/components/surepetcare/conftest.py b/tests/components/surepetcare/conftest.py index 43738f22587..cecdaababa9 100644 --- a/tests/components/surepetcare/conftest.py +++ b/tests/components/surepetcare/conftest.py @@ -7,12 +7,16 @@ from surepy import MESTART_RESOURCE from . import MOCK_API_DATA +async def _mock_call(method, resource): + if method == "GET" and resource == MESTART_RESOURCE: + return {"data": MOCK_API_DATA} + + @pytest.fixture async def surepetcare(): """Mock the SurePetcare for easier testing.""" - with patch("surepy.SureAPIClient", autospec=True) as mock_client_class, patch( - "surepy.find_token" - ): + with patch("surepy.SureAPIClient", autospec=True) as mock_client_class: client = mock_client_class.return_value - client.resources = {MESTART_RESOURCE: {"data": MOCK_API_DATA}} + client.resources = {} + client.call = _mock_call yield client diff --git a/tests/components/surepetcare/test_sensor.py b/tests/components/surepetcare/test_sensor.py index 8e7160364ea..cbf69bb97dc 100644 --- a/tests/components/surepetcare/test_sensor.py +++ b/tests/components/surepetcare/test_sensor.py @@ -12,7 +12,7 @@ EXPECTED_ENTITY_IDS = { } -async def test_binary_sensors(hass, surepetcare) -> None: +async def test_sensors(hass, surepetcare) -> None: """Test the generation of unique ids.""" assert await async_setup_component(hass, DOMAIN, MOCK_CONFIG) await hass.async_block_till_done() From b088ce601c052c031badc779448aafc648b64d25 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Mon, 6 Sep 2021 22:37:12 +0200 Subject: [PATCH 264/843] Bump zwave-js-server-python to 0.30.0 (#55831) --- homeassistant/components/zwave_js/cover.py | 2 +- .../components/zwave_js/discovery_data_template.py | 4 ++-- homeassistant/components/zwave_js/manifest.json | 2 +- homeassistant/components/zwave_js/sensor.py | 2 +- homeassistant/components/zwave_js/switch.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/zwave_js/test_api.py | 8 ++++---- 8 files changed, 12 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/zwave_js/cover.py b/homeassistant/components/zwave_js/cover.py index 7fceaf64c0e..9060e13a9a5 100644 --- a/homeassistant/components/zwave_js/cover.py +++ b/homeassistant/components/zwave_js/cover.py @@ -5,7 +5,7 @@ import logging from typing import Any from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const.command_class.barrior_operator import BarrierState +from zwave_js_server.const.command_class.barrier_operator import BarrierState from zwave_js_server.model.value import Value as ZwaveValue from homeassistant.components.cover import ( diff --git a/homeassistant/components/zwave_js/discovery_data_template.py b/homeassistant/components/zwave_js/discovery_data_template.py index 23482bd33fe..f294294625a 100644 --- a/homeassistant/components/zwave_js/discovery_data_template.py +++ b/homeassistant/components/zwave_js/discovery_data_template.py @@ -32,8 +32,8 @@ from zwave_js_server.const.command_class.multilevel_sensor import ( ) from zwave_js_server.model.node import Node as ZwaveNode from zwave_js_server.model.value import Value as ZwaveValue, get_value_id -from zwave_js_server.util.command_class import ( - get_meter_scale_type, +from zwave_js_server.util.command_class.meter import get_meter_scale_type +from zwave_js_server.util.command_class.multilevel_sensor import ( get_multilevel_sensor_type, ) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index ad8ec22befb..c7b2b35837b 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -3,7 +3,7 @@ "name": "Z-Wave JS", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zwave_js", - "requirements": ["zwave-js-server-python==0.29.1"], + "requirements": ["zwave-js-server-python==0.30.0"], "codeowners": ["@home-assistant/z-wave"], "dependencies": ["usb", "http", "websocket_api"], "iot_class": "local_push", diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 09d44f7f24a..6532da8a5e0 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -15,7 +15,7 @@ from zwave_js_server.const.command_class.meter import ( ) from zwave_js_server.model.node import Node as ZwaveNode from zwave_js_server.model.value import ConfigurationValue -from zwave_js_server.util.command_class import get_meter_type +from zwave_js_server.util.command_class.meter import get_meter_type from homeassistant.components.sensor import ( DEVICE_CLASS_ENERGY, diff --git a/homeassistant/components/zwave_js/switch.py b/homeassistant/components/zwave_js/switch.py index bd86a3b8377..44aa3a5566f 100644 --- a/homeassistant/components/zwave_js/switch.py +++ b/homeassistant/components/zwave_js/switch.py @@ -5,7 +5,7 @@ import logging from typing import Any from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const.command_class.barrior_operator import ( +from zwave_js_server.const.command_class.barrier_operator import ( BarrierEventSignalingSubsystemState, ) diff --git a/requirements_all.txt b/requirements_all.txt index b147c53736c..7fa52e65976 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2486,4 +2486,4 @@ zigpy==0.37.1 zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.29.1 +zwave-js-server-python==0.30.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 838507e157c..2266c9a0a6e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1397,4 +1397,4 @@ zigpy-znp==0.5.4 zigpy==0.37.1 # homeassistant.components.zwave_js -zwave-js-server-python==0.29.1 +zwave-js-server-python==0.30.0 diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index ee05724a9cb..b3bb924413d 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -338,7 +338,7 @@ async def test_add_node_secure( assert len(client.async_send_command.call_args_list) == 1 assert client.async_send_command.call_args[0][0] == { "command": "controller.begin_inclusion", - "options": {"inclusionStrategy": InclusionStrategy.SECURITY_S0}, + "options": {"strategy": InclusionStrategy.SECURITY_S0}, } client.async_send_command.reset_mock() @@ -363,7 +363,7 @@ async def test_add_node( assert len(client.async_send_command.call_args_list) == 1 assert client.async_send_command.call_args[0][0] == { "command": "controller.begin_inclusion", - "options": {"inclusionStrategy": InclusionStrategy.INSECURE}, + "options": {"strategy": InclusionStrategy.INSECURE}, } event = Event( @@ -671,7 +671,7 @@ async def test_replace_failed_node_secure( assert client.async_send_command.call_args[0][0] == { "command": "controller.replace_failed_node", "nodeId": nortek_thermostat.node_id, - "options": {"inclusionStrategy": InclusionStrategy.SECURITY_S0}, + "options": {"strategy": InclusionStrategy.SECURITY_S0}, } client.async_send_command.reset_mock() @@ -720,7 +720,7 @@ async def test_replace_failed_node( assert client.async_send_command.call_args[0][0] == { "command": "controller.replace_failed_node", "nodeId": nortek_thermostat.node_id, - "options": {"inclusionStrategy": InclusionStrategy.INSECURE}, + "options": {"strategy": InclusionStrategy.INSECURE}, } client.async_send_command.reset_mock() From 4fa9871080bee5dc6b3b2b92d598c834ecd7384c Mon Sep 17 00:00:00 2001 From: Tatham Oddie Date: Tue, 7 Sep 2021 06:39:39 +1000 Subject: [PATCH 265/843] Fix logbook entity_matches_only query mode (#55761) The string matching template needs to match the same compact JSON format as the data is now written in. --- homeassistant/components/logbook/__init__.py | 2 +- tests/components/logbook/test_init.py | 39 ++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/logbook/__init__.py b/homeassistant/components/logbook/__init__.py index 8992ca2d7fc..8bbfd08314d 100644 --- a/homeassistant/components/logbook/__init__.py +++ b/homeassistant/components/logbook/__init__.py @@ -48,7 +48,7 @@ from homeassistant.helpers.integration_platform import ( from homeassistant.loader import bind_hass import homeassistant.util.dt as dt_util -ENTITY_ID_JSON_TEMPLATE = '"entity_id": ?"{}"' +ENTITY_ID_JSON_TEMPLATE = '"entity_id":"{}"' ENTITY_ID_JSON_EXTRACT = re.compile('"entity_id": ?"([^"]+)"') DOMAIN_JSON_EXTRACT = re.compile('"domain": ?"([^"]+)"') ICON_JSON_EXTRACT = re.compile('"icon": ?"([^"]+)"') diff --git a/tests/components/logbook/test_init.py b/tests/components/logbook/test_init.py index 3dab7e6c2fb..b95ef2e148a 100644 --- a/tests/components/logbook/test_init.py +++ b/tests/components/logbook/test_init.py @@ -1289,6 +1289,45 @@ async def test_logbook_entity_matches_only(hass, hass_client): assert json_dict[1]["context_user_id"] == "9400facee45711eaa9308bfd3d19e474" +async def test_custom_log_entry_discoverable_via_entity_matches_only(hass, hass_client): + """Test if a custom log entry is later discoverable via entity_matches_only.""" + await hass.async_add_executor_job(init_recorder_component, hass) + await async_setup_component(hass, "logbook", {}) + await hass.async_add_executor_job(hass.data[recorder.DATA_INSTANCE].block_till_done) + + logbook.async_log_entry( + hass, + "Alarm", + "is triggered", + "switch", + "switch.test_switch", + ) + await hass.async_block_till_done() + await hass.async_add_executor_job(trigger_db_commit, hass) + await hass.async_block_till_done() + await hass.async_add_executor_job(hass.data[recorder.DATA_INSTANCE].block_till_done) + + client = await hass_client() + + # Today time 00:00:00 + start = dt_util.utcnow().date() + start_date = datetime(start.year, start.month, start.day) + + # Test today entries with filter by end_time + end_time = start + timedelta(hours=24) + response = await client.get( + f"/api/logbook/{start_date.isoformat()}?end_time={end_time.isoformat()}&entity=switch.test_switch&entity_matches_only" + ) + assert response.status == 200 + json_dict = await response.json() + + assert len(json_dict) == 1 + + assert json_dict[0]["name"] == "Alarm" + assert json_dict[0]["message"] == "is triggered" + assert json_dict[0]["entity_id"] == "switch.test_switch" + + async def test_logbook_entity_matches_only_multiple(hass, hass_client): """Test the logbook view with a multiple entities and entity_matches_only.""" await hass.async_add_executor_job(init_recorder_component, hass) From 8d4aac618d711236b9199512088f111487e8a1b1 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Mon, 6 Sep 2021 22:40:15 +0200 Subject: [PATCH 266/843] Allow same IP if ports are different on modbus (#55766) --- homeassistant/components/modbus/validators.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py index f41827f2c69..df4fe3c1e62 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -248,7 +248,10 @@ def duplicate_modbus_validator(config: list) -> list: errors = [] for index, hub in enumerate(config): name = hub.get(CONF_NAME, DEFAULT_HUB) - host = hub[CONF_PORT] if hub[CONF_TYPE] == SERIAL else hub[CONF_HOST] + if hub[CONF_TYPE] == SERIAL: + host = hub[CONF_PORT] + else: + host = f"{hub[CONF_HOST]}_{hub[CONF_PORT]}" if host in hosts: err = f"Modbus {name}  contains duplicate host/port {host}, not loaded!" _LOGGER.warning(err) From 34d54511e8fbabfd77a31023b2ec55fe96ec98d1 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Mon, 6 Sep 2021 21:41:01 +0100 Subject: [PATCH 267/843] Integration Sensor unit of measurement overwrite (#55869) --- .../components/integration/sensor.py | 22 +++++++------------ 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index b8e72c3be5c..cd3e376b792 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -110,16 +110,10 @@ class IntegrationSensor(RestoreEntity, SensorEntity): self._method = integration_method self._name = name if name is not None else f"{source_entity} integral" - - if unit_of_measurement is None: - self._unit_template = ( - f"{'' if unit_prefix is None else unit_prefix}{{}}{unit_time}" - ) - # we postpone the definition of unit_of_measurement to later - self._unit_of_measurement = None - else: - self._unit_of_measurement = unit_of_measurement - + self._unit_template = ( + f"{'' if unit_prefix is None else unit_prefix}{{}}{unit_time}" + ) + self._unit_of_measurement = unit_of_measurement self._unit_prefix = UNIT_PREFIXES[unit_prefix] self._unit_time = UNIT_TIME[unit_time] self._attr_state_class = STATE_CLASS_TOTAL_INCREASING @@ -135,10 +129,10 @@ class IntegrationSensor(RestoreEntity, SensorEntity): _LOGGER.warning("Could not restore last state: %s", err) else: self._attr_device_class = state.attributes.get(ATTR_DEVICE_CLASS) - - self._unit_of_measurement = state.attributes.get( - ATTR_UNIT_OF_MEASUREMENT - ) + if self._unit_of_measurement is None: + self._unit_of_measurement = state.attributes.get( + ATTR_UNIT_OF_MEASUREMENT + ) @callback def calc_integration(event): From 6895081595a19f4721ad7610e600fc61b2037706 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 6 Sep 2021 16:57:59 -0400 Subject: [PATCH 268/843] Use `async_update_entry` in config unit test instead of modifying `data` (#55855) --- homeassistant/components/zha/__init__.py | 4 ++-- tests/components/zha/test_config_flow.py | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index b50adf15020..d6578be775f 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -184,7 +184,7 @@ async def async_migrate_entry( data[CONF_DEVICE][CONF_BAUDRATE] = baudrate config_entry.version = 2 - config_entry.data = data + hass.config_entries.async_update_entry(config_entry, data=data) if config_entry.version == 2: data = {**config_entry.data} @@ -193,7 +193,7 @@ async def async_migrate_entry( data[CONF_RADIO_TYPE] = "znp" config_entry.version = 3 - config_entry.data = data + hass.config_entries.async_update_entry(config_entry, data=data) _LOGGER.info("Migration to version %s successful", config_entry.version) return True diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index b8a951a57f6..5aef30c854d 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -13,7 +13,7 @@ from homeassistant.components.ssdp import ( ATTR_UPNP_MANUFACTURER_URL, ATTR_UPNP_SERIAL, ) -from homeassistant.components.zha import async_migrate_entry, config_flow +from homeassistant.components.zha import config_flow from homeassistant.components.zha.core.const import ( CONF_BAUDRATE, CONF_FLOWCONTROL, @@ -633,8 +633,6 @@ async def test_user_port_config(probe_mock, hass): ) async def test_migration_ti_cc_to_znp(old_type, new_type, hass, config_entry): """Test zigpy-cc to zigpy-znp config migration.""" - await setup.async_setup_component(hass, "persistent_notification", {}) - config_entry = MockConfigEntry( domain=DOMAIN, unique_id=old_type + new_type, @@ -651,7 +649,9 @@ async def test_migration_ti_cc_to_znp(old_type, new_type, hass, config_entry): config_entry.version = 2 config_entry.add_to_hass(hass) - await async_migrate_entry(hass, config_entry) + with patch("homeassistant.components.zha.async_setup_entry", return_value=True): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() assert config_entry.version > 2 assert config_entry.data[CONF_RADIO_TYPE] == new_type From b1dbdec2ea37fc066a3c908253fdbd8e14235f3e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 7 Sep 2021 00:27:31 +0200 Subject: [PATCH 269/843] Set state class to total for Integration sensors (#55872) --- homeassistant/components/integration/sensor.py | 4 ++-- tests/components/integration/test_sensor.py | 12 +++++++----- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index cd3e376b792..d9a41cf714e 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.sensor import ( DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, PLATFORM_SCHEMA, - STATE_CLASS_TOTAL_INCREASING, + STATE_CLASS_TOTAL, SensorEntity, ) from homeassistant.const import ( @@ -116,7 +116,7 @@ class IntegrationSensor(RestoreEntity, SensorEntity): self._unit_of_measurement = unit_of_measurement self._unit_prefix = UNIT_PREFIXES[unit_prefix] self._unit_time = UNIT_TIME[unit_time] - self._attr_state_class = STATE_CLASS_TOTAL_INCREASING + self._attr_state_class = STATE_CLASS_TOTAL async def async_added_to_hass(self): """Handle entity which will be added.""" diff --git a/tests/components/integration/test_sensor.py b/tests/components/integration/test_sensor.py index e8aaf906936..5eff62835ba 100644 --- a/tests/components/integration/test_sensor.py +++ b/tests/components/integration/test_sensor.py @@ -2,7 +2,7 @@ from datetime import timedelta from unittest.mock import patch -from homeassistant.components.sensor import STATE_CLASS_TOTAL_INCREASING +from homeassistant.components.sensor import STATE_CLASS_TOTAL from homeassistant.const import ( DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, @@ -39,7 +39,7 @@ async def test_state(hass) -> None: state = hass.states.get("sensor.integration") assert state is not None - assert state.attributes.get("state_class") == STATE_CLASS_TOTAL_INCREASING + assert state.attributes.get("state_class") == STATE_CLASS_TOTAL assert "device_class" not in state.attributes future_now = dt_util.utcnow() + timedelta(seconds=3600) @@ -57,7 +57,7 @@ async def test_state(hass) -> None: assert state.attributes.get("unit_of_measurement") == ENERGY_KILO_WATT_HOUR assert state.attributes.get("device_class") == DEVICE_CLASS_ENERGY - assert state.attributes.get("state_class") == STATE_CLASS_TOTAL_INCREASING + assert state.attributes.get("state_class") == STATE_CLASS_TOTAL async def test_restore_state(hass: HomeAssistant) -> None: @@ -104,7 +104,9 @@ async def test_restore_state_failed(hass: HomeAssistant) -> None: State( "sensor.integration", "INVALID", - {}, + { + "last_reset": "2019-10-06T21:00:00.000000", + }, ), ), ) @@ -125,7 +127,7 @@ async def test_restore_state_failed(hass: HomeAssistant) -> None: assert state assert state.state == "0" assert state.attributes.get("unit_of_measurement") == ENERGY_KILO_WATT_HOUR - assert state.attributes.get("state_class") == STATE_CLASS_TOTAL_INCREASING + assert state.attributes.get("state_class") == STATE_CLASS_TOTAL assert "device_class" not in state.attributes From c6888e4faf6a6bb7a4c22b3f21e749043fba652a Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Mon, 6 Sep 2021 19:00:06 -0400 Subject: [PATCH 270/843] Refactor ZHA tests (#55844) * Replace ZHA tests FakeDevice * Refactor ZHA tests to use zigpy devices and endpoints * Use common consts for zigpy device mocks Use the same dict key names for device signature mocks as zha quirks. * Use const for test device list * Update tests/components/zha/common.py --- homeassistant/components/zha/core/device.py | 2 +- tests/components/zha/common.py | 90 - tests/components/zha/conftest.py | 42 +- .../zha/test_alarm_control_panel.py | 8 +- tests/components/zha/test_api.py | 23 +- tests/components/zha/test_binary_sensor.py | 15 +- tests/components/zha/test_channels.py | 39 +- tests/components/zha/test_climate.py | 38 +- tests/components/zha/test_cover.py | 29 +- tests/components/zha/test_device.py | 19 +- tests/components/zha/test_device_action.py | 8 +- tests/components/zha/test_device_tracker.py | 9 +- tests/components/zha/test_device_trigger.py | 8 +- tests/components/zha/test_discover.py | 61 +- tests/components/zha/test_fan.py | 29 +- tests/components/zha/test_gateway.py | 29 +- tests/components/zha/test_light.py | 60 +- tests/components/zha/test_lock.py | 7 +- tests/components/zha/test_number.py | 7 +- tests/components/zha/test_sensor.py | 19 +- tests/components/zha/test_switch.py | 25 +- tests/components/zha/zha_devices_list.py | 4639 +++++++++-------- 22 files changed, 2614 insertions(+), 2592 deletions(-) diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 9e8a8450ec1..82e2b85173e 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -503,7 +503,7 @@ class ZHADevice(LogMixin): names.append( { ATTR_NAME: f"unknown {endpoint.device_type} device_type " - f"of 0x{endpoint.profile_id:04x} profile id" + f"of 0x{(endpoint.profile_id or 0xFFFF):04x} profile id" } ) device_info[ATTR_ENDPOINT_NAMES] = names diff --git a/tests/components/zha/common.py b/tests/components/zha/common.py index 97890b287e8..0115838c70d 100644 --- a/tests/components/zha/common.py +++ b/tests/components/zha/common.py @@ -1,75 +1,14 @@ """Common test objects.""" import asyncio import math -import time from unittest.mock import AsyncMock, Mock -import zigpy.device as zigpy_dev -import zigpy.endpoint as zigpy_ep -import zigpy.profiles.zha -import zigpy.types -import zigpy.zcl -import zigpy.zcl.clusters.general import zigpy.zcl.foundation as zcl_f -import zigpy.zdo.types import homeassistant.components.zha.core.const as zha_const from homeassistant.util import slugify -class FakeEndpoint: - """Fake endpoint for moking zigpy.""" - - def __init__(self, manufacturer, model, epid=1): - """Init fake endpoint.""" - self.device = None - self.endpoint_id = epid - self.in_clusters = {} - self.out_clusters = {} - self._cluster_attr = {} - self.member_of = {} - self.status = zigpy_ep.Status.ZDO_INIT - self.manufacturer = manufacturer - self.model = model - self.profile_id = zigpy.profiles.zha.PROFILE_ID - self.device_type = None - self.request = AsyncMock(return_value=[0]) - - def add_input_cluster(self, cluster_id, _patch_cluster=True): - """Add an input cluster.""" - cluster = zigpy.zcl.Cluster.from_id(self, cluster_id, is_server=True) - if _patch_cluster: - patch_cluster(cluster) - self.in_clusters[cluster_id] = cluster - ep_attribute = cluster.ep_attribute - if ep_attribute: - setattr(self, ep_attribute, cluster) - - def add_output_cluster(self, cluster_id, _patch_cluster=True): - """Add an output cluster.""" - cluster = zigpy.zcl.Cluster.from_id(self, cluster_id, is_server=False) - if _patch_cluster: - patch_cluster(cluster) - self.out_clusters[cluster_id] = cluster - - reply = AsyncMock(return_value=[0]) - request = AsyncMock(return_value=[0]) - - @property - def __class__(self): - """Fake being Zigpy endpoint.""" - return zigpy_ep.Endpoint - - @property - def unique_id(self): - """Return the unique id for the endpoint.""" - return self.device.ieee, self.endpoint_id - - -FakeEndpoint.add_to_group = zigpy_ep.Endpoint.add_to_group -FakeEndpoint.remove_from_group = zigpy_ep.Endpoint.remove_from_group - - def patch_cluster(cluster): """Patch a cluster for testing.""" cluster.PLUGGED_ATTR_READS = {} @@ -115,35 +54,6 @@ def patch_cluster(cluster): cluster.add = AsyncMock(return_value=[0]) -class FakeDevice: - """Fake device for mocking zigpy.""" - - def __init__(self, app, ieee, manufacturer, model, node_desc=None, nwk=0xB79C): - """Init fake device.""" - self._application = app - self.application = app - self.ieee = zigpy.types.EUI64.convert(ieee) - self.nwk = nwk - self.zdo = Mock() - self.endpoints = {0: self.zdo} - self.lqi = 255 - self.rssi = 8 - self.last_seen = time.time() - self.status = zigpy_dev.Status.ENDPOINTS_INIT - self.initializing = False - self.skip_configuration = False - self.manufacturer = manufacturer - self.model = model - self.remove_from_group = AsyncMock() - if node_desc is None: - node_desc = b"\x02@\x807\x10\x7fd\x00\x00*d\x00\x00" - self.node_desc = zigpy.zdo.types.NodeDescriptor.deserialize(node_desc)[0] - self.neighbors = [] - - -FakeDevice.add_to_group = zigpy_dev.Device.add_to_group - - def get_zha_gateway(hass): """Return ZHA gateway from hass.data.""" try: diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py index df90256b3a8..da76b7015c9 100644 --- a/tests/components/zha/conftest.py +++ b/tests/components/zha/conftest.py @@ -1,22 +1,26 @@ """Test configuration for the ZHA component.""" +import time from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch import pytest import zigpy from zigpy.application import ControllerApplication import zigpy.config +from zigpy.const import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE +import zigpy.device import zigpy.group +import zigpy.profiles import zigpy.types +import zigpy.zdo.types as zdo_t from homeassistant.components.zha import DOMAIN import homeassistant.components.zha.core.const as zha_const import homeassistant.components.zha.core.device as zha_core_device from homeassistant.setup import async_setup_component -from .common import FakeDevice, FakeEndpoint, get_zha_gateway - from tests.common import MockConfigEntry from tests.components.light.conftest import mock_light_profiles # noqa: F401 +from tests.components.zha import common FIXTURE_GRP_ID = 0x1001 FIXTURE_GRP_NAME = "fixture group" @@ -114,23 +118,29 @@ def zigpy_device_mock(zigpy_app_controller): patch_cluster=True, ): """Make a fake device using the specified cluster classes.""" - device = FakeDevice( - zigpy_app_controller, ieee, manufacturer, model, node_descriptor, nwk=nwk + device = zigpy.device.Device( + zigpy_app_controller, zigpy.types.EUI64.convert(ieee), nwk ) + device.manufacturer = manufacturer + device.model = model + device.node_desc = zdo_t.NodeDescriptor.deserialize(node_descriptor)[0] + device.last_seen = time.time() + for epid, ep in endpoints.items(): - endpoint = FakeEndpoint(manufacturer, model, epid) - endpoint.device = device - device.endpoints[epid] = endpoint - endpoint.device_type = ep["device_type"] - profile_id = ep.get("profile_id") - if profile_id: - endpoint.profile_id = profile_id + endpoint = device.add_endpoint(epid) + endpoint.device_type = ep[SIG_EP_TYPE] + endpoint.profile_id = ep.get(SIG_EP_PROFILE) + endpoint.request = AsyncMock(return_value=[0]) - for cluster_id in ep.get("in_clusters", []): - endpoint.add_input_cluster(cluster_id, _patch_cluster=patch_cluster) + for cluster_id in ep.get(SIG_EP_INPUT, []): + cluster = endpoint.add_input_cluster(cluster_id) + if patch_cluster: + common.patch_cluster(cluster) - for cluster_id in ep.get("out_clusters", []): - endpoint.add_output_cluster(cluster_id, _patch_cluster=patch_cluster) + for cluster_id in ep.get(SIG_EP_OUTPUT, []): + cluster = endpoint.add_output_cluster(cluster_id) + if patch_cluster: + common.patch_cluster(cluster) return device @@ -143,7 +153,7 @@ def zha_device_joined(hass, setup_zha): async def _zha_device(zigpy_dev): await setup_zha() - zha_gateway = get_zha_gateway(hass) + zha_gateway = common.get_zha_gateway(hass) await zha_gateway.async_device_initialized(zigpy_dev) await hass.async_block_till_done() return zha_gateway.get_device(zigpy_dev.ieee) diff --git a/tests/components/zha/test_alarm_control_panel.py b/tests/components/zha/test_alarm_control_panel.py index c3428a044a4..39063225e50 100644 --- a/tests/components/zha/test_alarm_control_panel.py +++ b/tests/components/zha/test_alarm_control_panel.py @@ -18,6 +18,7 @@ from homeassistant.const import ( ) from .common import async_enable_traffic, find_entity_id +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE @pytest.fixture @@ -25,9 +26,10 @@ def zigpy_device(zigpy_device_mock): """Device tracker zigpy device.""" endpoints = { 1: { - "in_clusters": [security.IasAce.cluster_id], - "out_clusters": [], - "device_type": zha.DeviceType.IAS_ANCILLARY_CONTROL, + SIG_EP_INPUT: [security.IasAce.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.IAS_ANCILLARY_CONTROL, + SIG_EP_PROFILE: zha.PROFILE_ID, } } return zigpy_device_mock( diff --git a/tests/components/zha/test_api.py b/tests/components/zha/test_api.py index 288f886a865..4e97f35bf1d 100644 --- a/tests/components/zha/test_api.py +++ b/tests/components/zha/test_api.py @@ -40,7 +40,14 @@ from homeassistant.components.zha.core.const import ( from homeassistant.const import ATTR_NAME from homeassistant.core import Context -from .conftest import FIXTURE_GRP_ID, FIXTURE_GRP_NAME +from .conftest import ( + FIXTURE_GRP_ID, + FIXTURE_GRP_NAME, + SIG_EP_INPUT, + SIG_EP_OUTPUT, + SIG_EP_PROFILE, + SIG_EP_TYPE, +) IEEE_SWITCH_DEVICE = "01:2d:6f:00:0a:90:69:e7" IEEE_GROUPABLE_DEVICE = "01:2d:6f:00:0a:90:69:e8" @@ -53,9 +60,10 @@ async def device_switch(hass, zigpy_device_mock, zha_device_joined): zigpy_device = zigpy_device_mock( { 1: { - "in_clusters": [general.OnOff.cluster_id, general.Basic.cluster_id], - "out_clusters": [], - "device_type": zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_INPUT: [general.OnOff.cluster_id, general.Basic.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, } }, ieee=IEEE_SWITCH_DEVICE, @@ -72,13 +80,14 @@ async def device_groupable(hass, zigpy_device_mock, zha_device_joined): zigpy_device = zigpy_device_mock( { 1: { - "in_clusters": [ + SIG_EP_INPUT: [ general.OnOff.cluster_id, general.Basic.cluster_id, general.Groups.cluster_id, ], - "out_clusters": [], - "device_type": zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, } }, ieee=IEEE_GROUPABLE_DEVICE, diff --git a/tests/components/zha/test_binary_sensor.py b/tests/components/zha/test_binary_sensor.py index 7a2217521ad..1ab638d0b26 100644 --- a/tests/components/zha/test_binary_sensor.py +++ b/tests/components/zha/test_binary_sensor.py @@ -13,21 +13,24 @@ from .common import ( find_entity_id, send_attributes_report, ) +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE DEVICE_IAS = { 1: { - "device_type": zigpy.profiles.zha.DeviceType.IAS_ZONE, - "in_clusters": [security.IasZone.cluster_id], - "out_clusters": [], + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.IAS_ZONE, + SIG_EP_INPUT: [security.IasZone.cluster_id], + SIG_EP_OUTPUT: [], } } DEVICE_OCCUPANCY = { 1: { - "device_type": zigpy.profiles.zha.DeviceType.OCCUPANCY_SENSOR, - "in_clusters": [measurement.OccupancySensing.cluster_id], - "out_clusters": [], + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.OCCUPANCY_SENSOR, + SIG_EP_INPUT: [measurement.OccupancySensing.cluster_id], + SIG_EP_OUTPUT: [], } } diff --git a/tests/components/zha/test_channels.py b/tests/components/zha/test_channels.py index 45fbf648806..c1e60db31dd 100644 --- a/tests/components/zha/test_channels.py +++ b/tests/components/zha/test_channels.py @@ -15,6 +15,7 @@ import homeassistant.components.zha.core.const as zha_const import homeassistant.components.zha.core.registries as registries from .common import get_zha_gateway, make_zcl_header +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE from tests.common import async_capture_events @@ -43,7 +44,7 @@ def zigpy_coordinator_device(zigpy_device_mock): """Coordinator device fixture.""" coordinator = zigpy_device_mock( - {1: {"in_clusters": [0x1000], "out_clusters": [], "device_type": 0x1234}}, + {1: {SIG_EP_INPUT: [0x1000], SIG_EP_OUTPUT: [], SIG_EP_TYPE: 0x1234}}, "00:11:22:33:44:55:66:77", "test manufacturer", "test model", @@ -69,7 +70,7 @@ def poll_control_ch(channel_pool, zigpy_device_mock): """Poll control channel fixture.""" cluster_id = zigpy.zcl.clusters.general.PollControl.cluster_id zigpy_dev = zigpy_device_mock( - {1: {"in_clusters": [cluster_id], "out_clusters": [], "device_type": 0x1234}}, + {1: {SIG_EP_INPUT: [cluster_id], SIG_EP_OUTPUT: [], SIG_EP_TYPE: 0x1234}}, "00:11:22:33:44:55:66:77", "test manufacturer", "test model", @@ -85,7 +86,7 @@ async def poll_control_device(zha_device_restored, zigpy_device_mock): """Poll control device fixture.""" cluster_id = zigpy.zcl.clusters.general.PollControl.cluster_id zigpy_dev = zigpy_device_mock( - {1: {"in_clusters": [cluster_id], "out_clusters": [], "device_type": 0x1234}}, + {1: {SIG_EP_INPUT: [cluster_id], SIG_EP_OUTPUT: [], SIG_EP_TYPE: 0x1234}}, "00:11:22:33:44:55:66:77", "test manufacturer", "test model", @@ -159,7 +160,7 @@ async def test_in_channel_config( ): """Test ZHA core channel configuration for input clusters.""" zigpy_dev = zigpy_device_mock( - {1: {"in_clusters": [cluster_id], "out_clusters": [], "device_type": 0x1234}}, + {1: {SIG_EP_INPUT: [cluster_id], SIG_EP_OUTPUT: [], SIG_EP_TYPE: 0x1234}}, "00:11:22:33:44:55:66:77", "test manufacturer", "test model", @@ -221,7 +222,7 @@ async def test_out_channel_config( ): """Test ZHA core channel configuration for output clusters.""" zigpy_dev = zigpy_device_mock( - {1: {"out_clusters": [cluster_id], "in_clusters": [], "device_type": 0x1234}}, + {1: {SIG_EP_OUTPUT: [cluster_id], SIG_EP_INPUT: [], SIG_EP_TYPE: 0x1234}}, "00:11:22:33:44:55:66:77", "test manufacturer", "test model", @@ -328,14 +329,14 @@ def test_ep_channels_all_channels(m1, zha_device_mock): zha_device = zha_device_mock( { 1: { - "in_clusters": [0, 1, 6, 8], - "out_clusters": [], - "device_type": zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_INPUT: [0, 1, 6, 8], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, }, 2: { - "in_clusters": [0, 1, 6, 8, 768], - "out_clusters": [], - "device_type": 0x0000, + SIG_EP_INPUT: [0, 1, 6, 8, 768], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: 0x0000, }, } ) @@ -379,11 +380,11 @@ def test_channel_power_config(m1, zha_device_mock): in_clusters = [0, 1, 6, 8] zha_device = zha_device_mock( { - 1: {"in_clusters": in_clusters, "out_clusters": [], "device_type": 0x0000}, + 1: {SIG_EP_INPUT: in_clusters, SIG_EP_OUTPUT: [], SIG_EP_TYPE: 0x0000}, 2: { - "in_clusters": [*in_clusters, 768], - "out_clusters": [], - "device_type": 0x0000, + SIG_EP_INPUT: [*in_clusters, 768], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: 0x0000, }, } ) @@ -402,8 +403,8 @@ def test_channel_power_config(m1, zha_device_mock): zha_device = zha_device_mock( { - 1: {"in_clusters": [], "out_clusters": [], "device_type": 0x0000}, - 2: {"in_clusters": in_clusters, "out_clusters": [], "device_type": 0x0000}, + 1: {SIG_EP_INPUT: [], SIG_EP_OUTPUT: [], SIG_EP_TYPE: 0x0000}, + 2: {SIG_EP_INPUT: in_clusters, SIG_EP_OUTPUT: [], SIG_EP_TYPE: 0x0000}, } ) channels = zha_channels.Channels.new(zha_device) @@ -412,7 +413,7 @@ def test_channel_power_config(m1, zha_device_mock): assert "2:0x0001" in pools[2].all_channels zha_device = zha_device_mock( - {2: {"in_clusters": in_clusters, "out_clusters": [], "device_type": 0x0000}} + {2: {SIG_EP_INPUT: in_clusters, SIG_EP_OUTPUT: [], SIG_EP_TYPE: 0x0000}} ) channels = zha_channels.Channels.new(zha_device) pools = {pool.id: pool for pool in channels.pools} @@ -556,7 +557,7 @@ def zigpy_zll_device(zigpy_device_mock): """ZLL device fixture.""" return zigpy_device_mock( - {1: {"in_clusters": [0x1000], "out_clusters": [], "device_type": 0x1234}}, + {1: {SIG_EP_INPUT: [0x1000], SIG_EP_OUTPUT: [], SIG_EP_TYPE: 0x1234}}, "00:11:22:33:44:55:66:77", "test manufacturer", "test model", diff --git a/tests/components/zha/test_climate.py b/tests/components/zha/test_climate.py index ea2d6dfb7e3..f3808fee78d 100644 --- a/tests/components/zha/test_climate.py +++ b/tests/components/zha/test_climate.py @@ -3,6 +3,7 @@ from unittest.mock import patch import pytest +import zigpy.profiles import zigpy.zcl.clusters from zigpy.zcl.clusters.hvac import Thermostat import zigpy.zcl.foundation as zcl_f @@ -51,74 +52,79 @@ from homeassistant.components.zha.core.const import PRESET_COMPLEX, PRESET_SCHED from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, STATE_UNKNOWN from .common import async_enable_traffic, find_entity_id, send_attributes_report +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE CLIMATE = { 1: { - "device_type": zigpy.profiles.zha.DeviceType.THERMOSTAT, - "in_clusters": [ + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.THERMOSTAT, + SIG_EP_INPUT: [ zigpy.zcl.clusters.general.Basic.cluster_id, zigpy.zcl.clusters.general.Identify.cluster_id, zigpy.zcl.clusters.hvac.Thermostat.cluster_id, zigpy.zcl.clusters.hvac.UserInterface.cluster_id, ], - "out_clusters": [zigpy.zcl.clusters.general.Ota.cluster_id], + SIG_EP_OUTPUT: [zigpy.zcl.clusters.general.Ota.cluster_id], } } CLIMATE_FAN = { 1: { - "device_type": zigpy.profiles.zha.DeviceType.THERMOSTAT, - "in_clusters": [ + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.THERMOSTAT, + SIG_EP_INPUT: [ zigpy.zcl.clusters.general.Basic.cluster_id, zigpy.zcl.clusters.general.Identify.cluster_id, zigpy.zcl.clusters.hvac.Fan.cluster_id, zigpy.zcl.clusters.hvac.Thermostat.cluster_id, zigpy.zcl.clusters.hvac.UserInterface.cluster_id, ], - "out_clusters": [zigpy.zcl.clusters.general.Ota.cluster_id], + SIG_EP_OUTPUT: [zigpy.zcl.clusters.general.Ota.cluster_id], } } CLIMATE_SINOPE = { 1: { - "device_type": zigpy.profiles.zha.DeviceType.THERMOSTAT, - "in_clusters": [ + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.THERMOSTAT, + SIG_EP_INPUT: [ zigpy.zcl.clusters.general.Basic.cluster_id, zigpy.zcl.clusters.general.Identify.cluster_id, zigpy.zcl.clusters.hvac.Thermostat.cluster_id, zigpy.zcl.clusters.hvac.UserInterface.cluster_id, 65281, ], - "out_clusters": [zigpy.zcl.clusters.general.Ota.cluster_id, 65281], - "profile_id": 260, + SIG_EP_OUTPUT: [zigpy.zcl.clusters.general.Ota.cluster_id, 65281], }, } CLIMATE_ZEN = { 1: { - "device_type": zigpy.profiles.zha.DeviceType.THERMOSTAT, - "in_clusters": [ + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.THERMOSTAT, + SIG_EP_INPUT: [ zigpy.zcl.clusters.general.Basic.cluster_id, zigpy.zcl.clusters.general.Identify.cluster_id, zigpy.zcl.clusters.hvac.Fan.cluster_id, zigpy.zcl.clusters.hvac.Thermostat.cluster_id, zigpy.zcl.clusters.hvac.UserInterface.cluster_id, ], - "out_clusters": [zigpy.zcl.clusters.general.Ota.cluster_id], + SIG_EP_OUTPUT: [zigpy.zcl.clusters.general.Ota.cluster_id], } } CLIMATE_MOES = { 1: { - "device_type": zigpy.profiles.zha.DeviceType.THERMOSTAT, - "in_clusters": [ + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.THERMOSTAT, + SIG_EP_INPUT: [ zigpy.zcl.clusters.general.Basic.cluster_id, zigpy.zcl.clusters.general.Identify.cluster_id, zigpy.zcl.clusters.hvac.Thermostat.cluster_id, zigpy.zcl.clusters.hvac.UserInterface.cluster_id, 61148, ], - "out_clusters": [zigpy.zcl.clusters.general.Ota.cluster_id], + SIG_EP_OUTPUT: [zigpy.zcl.clusters.general.Ota.cluster_id], } } MANUF_SINOPE = "Sinope Technologies" diff --git a/tests/components/zha/test_cover.py b/tests/components/zha/test_cover.py index c926618813c..e002e2c26f0 100644 --- a/tests/components/zha/test_cover.py +++ b/tests/components/zha/test_cover.py @@ -32,6 +32,7 @@ from .common import ( make_zcl_header, send_attributes_report, ) +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE from tests.common import async_capture_events, mock_coro, mock_restore_cache @@ -42,9 +43,10 @@ def zigpy_cover_device(zigpy_device_mock): endpoints = { 1: { - "device_type": zigpy.profiles.zha.DeviceType.IAS_ZONE, - "in_clusters": [closures.WindowCovering.cluster_id], - "out_clusters": [], + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.IAS_ZONE, + SIG_EP_INPUT: [closures.WindowCovering.cluster_id], + SIG_EP_OUTPUT: [], } } return zigpy_device_mock(endpoints) @@ -56,9 +58,10 @@ def zigpy_cover_remote(zigpy_device_mock): endpoints = { 1: { - "device_type": zigpy.profiles.zha.DeviceType.WINDOW_COVERING_CONTROLLER, - "in_clusters": [], - "out_clusters": [closures.WindowCovering.cluster_id], + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.WINDOW_COVERING_CONTROLLER, + SIG_EP_INPUT: [], + SIG_EP_OUTPUT: [closures.WindowCovering.cluster_id], } } return zigpy_device_mock(endpoints) @@ -70,13 +73,14 @@ def zigpy_shade_device(zigpy_device_mock): endpoints = { 1: { - "device_type": zigpy.profiles.zha.DeviceType.SHADE, - "in_clusters": [ + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.SHADE, + SIG_EP_INPUT: [ closures.Shade.cluster_id, general.LevelControl.cluster_id, general.OnOff.cluster_id, ], - "out_clusters": [], + SIG_EP_OUTPUT: [], } } return zigpy_device_mock(endpoints) @@ -88,9 +92,10 @@ def zigpy_keen_vent(zigpy_device_mock): endpoints = { 1: { - "device_type": zigpy.profiles.zha.DeviceType.LEVEL_CONTROLLABLE_OUTPUT, - "in_clusters": [general.LevelControl.cluster_id, general.OnOff.cluster_id], - "out_clusters": [], + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.LEVEL_CONTROLLABLE_OUTPUT, + SIG_EP_INPUT: [general.LevelControl.cluster_id, general.OnOff.cluster_id], + SIG_EP_OUTPUT: [], } } return zigpy_device_mock( diff --git a/tests/components/zha/test_device.py b/tests/components/zha/test_device.py index 0f696f21572..76877e71ffc 100644 --- a/tests/components/zha/test_device.py +++ b/tests/components/zha/test_device.py @@ -17,6 +17,7 @@ import homeassistant.helpers.device_registry as dr import homeassistant.util.dt as dt_util from .common import async_enable_traffic, make_zcl_header +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE from tests.common import async_fire_time_changed @@ -32,9 +33,9 @@ def zigpy_device(zigpy_device_mock): endpoints = { 3: { - "in_clusters": in_clusters, - "out_clusters": [], - "device_type": zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_INPUT: in_clusters, + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, } } return zigpy_device_mock(endpoints) @@ -53,9 +54,9 @@ def zigpy_device_mains(zigpy_device_mock): endpoints = { 3: { - "in_clusters": in_clusters, - "out_clusters": [], - "device_type": zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_INPUT: in_clusters, + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, } } return zigpy_device_mock( @@ -83,9 +84,9 @@ async def ota_zha_device(zha_device_restored, zigpy_device_mock): zigpy_dev = zigpy_device_mock( { 1: { - "in_clusters": [general.Basic.cluster_id], - "out_clusters": [general.Ota.cluster_id], - "device_type": 0x1234, + SIG_EP_INPUT: [general.Basic.cluster_id], + SIG_EP_OUTPUT: [general.Ota.cluster_id], + SIG_EP_TYPE: 0x1234, } }, "00:11:22:33:44:55:66:77", diff --git a/tests/components/zha/test_device_action.py b/tests/components/zha/test_device_action.py index 49fa11de26c..b67f54a0a16 100644 --- a/tests/components/zha/test_device_action.py +++ b/tests/components/zha/test_device_action.py @@ -12,6 +12,8 @@ from homeassistant.components.zha import DOMAIN from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE + from tests.common import async_get_device_automations, async_mock_service, mock_coro from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 @@ -28,9 +30,9 @@ async def device_ias(hass, zigpy_device_mock, zha_device_joined_restored): zigpy_device = zigpy_device_mock( { 1: { - "in_clusters": [c.cluster_id for c in clusters], - "out_clusters": [general.OnOff.cluster_id], - "device_type": zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_INPUT: [c.cluster_id for c in clusters], + SIG_EP_OUTPUT: [general.OnOff.cluster_id], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, } }, ) diff --git a/tests/components/zha/test_device_tracker.py b/tests/components/zha/test_device_tracker.py index 0cc2b6f25c1..60dd136b9fd 100644 --- a/tests/components/zha/test_device_tracker.py +++ b/tests/components/zha/test_device_tracker.py @@ -3,6 +3,7 @@ from datetime import timedelta import time import pytest +import zigpy.profiles.zha import zigpy.zcl.clusters.general as general from homeassistant.components.device_tracker import DOMAIN, SOURCE_TYPE_ROUTER @@ -18,6 +19,7 @@ from .common import ( find_entity_id, send_attributes_report, ) +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE from tests.common import async_fire_time_changed @@ -27,15 +29,16 @@ def zigpy_device_dt(zigpy_device_mock): """Device tracker zigpy device.""" endpoints = { 1: { - "in_clusters": [ + SIG_EP_INPUT: [ general.Basic.cluster_id, general.PowerConfiguration.cluster_id, general.Identify.cluster_id, general.PollControl.cluster_id, general.BinaryInput.cluster_id, ], - "out_clusters": [general.Identify.cluster_id, general.Ota.cluster_id], - "device_type": SMARTTHINGS_ARRIVAL_SENSOR_DEVICE_TYPE, + SIG_EP_OUTPUT: [general.Identify.cluster_id, general.Ota.cluster_id], + SIG_EP_TYPE: SMARTTHINGS_ARRIVAL_SENSOR_DEVICE_TYPE, + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, } } return zigpy_device_mock(endpoints) diff --git a/tests/components/zha/test_device_trigger.py b/tests/components/zha/test_device_trigger.py index 841d6b43400..fbfc2144a6a 100644 --- a/tests/components/zha/test_device_trigger.py +++ b/tests/components/zha/test_device_trigger.py @@ -12,6 +12,7 @@ from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from .common import async_enable_traffic +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE from tests.common import ( async_fire_time_changed, @@ -57,9 +58,10 @@ async def mock_devices(hass, zigpy_device_mock, zha_device_joined_restored): zigpy_device = zigpy_device_mock( { 1: { - "in_clusters": [general.Basic.cluster_id], - "out_clusters": [general.OnOff.cluster_id], - "device_type": zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_INPUT: [general.Basic.cluster_id], + SIG_EP_OUTPUT: [general.OnOff.cluster_id], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, } } ) diff --git a/tests/components/zha/test_discover.py b/tests/components/zha/test_discover.py index 9dc71d4aa25..86cdcaa1c60 100644 --- a/tests/components/zha/test_discover.py +++ b/tests/components/zha/test_discover.py @@ -5,6 +5,7 @@ from unittest import mock from unittest.mock import AsyncMock, patch import pytest +from zigpy.const import SIG_ENDPOINTS, SIG_MANUFACTURER, SIG_MODEL, SIG_NODE_DESC import zigpy.profiles.zha import zigpy.quirks import zigpy.types @@ -29,7 +30,15 @@ import homeassistant.components.zha.switch import homeassistant.helpers.entity_registry from .common import get_zha_gateway -from .zha_devices_list import DEVICES +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE +from .zha_devices_list import ( + DEV_SIG_CHANNELS, + DEV_SIG_ENT_MAP, + DEV_SIG_ENT_MAP_CLASS, + DEV_SIG_ENT_MAP_ID, + DEV_SIG_EVT_CHANNELS, + DEVICES, +) NO_TAIL_ID = re.compile("_\\d$") @@ -72,11 +81,11 @@ async def test_devices( ) zigpy_device = zigpy_device_mock( - device["endpoints"], + device[SIG_ENDPOINTS], "00:11:22:33:44:55:66:77", - device["manufacturer"], - device["model"], - node_descriptor=device["node_descriptor"], + device[SIG_MANUFACTURER], + device[SIG_MODEL], + node_descriptor=device[SIG_NODE_DESC], patch_cluster=False, ) @@ -120,11 +129,13 @@ async def test_devices( ch.id for pool in zha_dev.channels.pools for ch in pool.client_channels.values() } - entity_map = device["entity_map"] + entity_map = device[DEV_SIG_ENT_MAP] assert zha_entity_ids == { - e["entity_id"] for e in entity_map.values() if not e.get("default_match", False) + e[DEV_SIG_ENT_MAP_ID] + for e in entity_map.values() + if not e.get("default_match", False) } - assert event_channels == set(device["event_channels"]) + assert event_channels == set(device[DEV_SIG_EVT_CHANNELS]) for call in _dispatch.call_args_list: _, component, entity_cls, unique_id, channels = call[0] @@ -133,10 +144,10 @@ async def test_devices( assert key in entity_map assert entity_id is not None - no_tail_id = NO_TAIL_ID.sub("", entity_map[key]["entity_id"]) + no_tail_id = NO_TAIL_ID.sub("", entity_map[key][DEV_SIG_ENT_MAP_ID]) assert entity_id.startswith(no_tail_id) - assert {ch.name for ch in channels} == set(entity_map[key]["channels"]) - assert entity_cls.__name__ == entity_map[key]["entity_class"] + assert {ch.name for ch in channels} == set(entity_map[key][DEV_SIG_CHANNELS]) + assert entity_cls.__name__ == entity_map[key][DEV_SIG_ENT_MAP_CLASS] def _get_first_identify_cluster(zigpy_device): @@ -258,20 +269,20 @@ async def test_discover_endpoint(device_info, channels_mock, hass): "homeassistant.components.zha.core.channels.Channels.async_new_entity" ) as new_ent: channels = channels_mock( - device_info["endpoints"], - manufacturer=device_info["manufacturer"], - model=device_info["model"], - node_desc=device_info["node_descriptor"], + device_info[SIG_ENDPOINTS], + manufacturer=device_info[SIG_MANUFACTURER], + model=device_info[SIG_MODEL], + node_desc=device_info[SIG_NODE_DESC], patch_cluster=False, ) - assert device_info["event_channels"] == sorted( + assert device_info[DEV_SIG_EVT_CHANNELS] == sorted( ch.id for pool in channels.pools for ch in pool.client_channels.values() ) assert new_ent.call_count == len( [ device_info - for device_info in device_info["entity_map"].values() + for device_info in device_info[DEV_SIG_ENT_MAP].values() if not device_info.get("default_match", False) ] ) @@ -279,10 +290,10 @@ async def test_discover_endpoint(device_info, channels_mock, hass): for call_args in new_ent.call_args_list: comp, ent_cls, unique_id, channels = call_args[0] map_id = (comp, unique_id) - assert map_id in device_info["entity_map"] - entity_info = device_info["entity_map"][map_id] - assert {ch.name for ch in channels} == set(entity_info["channels"]) - assert ent_cls.__name__ == entity_info["entity_class"] + assert map_id in device_info[DEV_SIG_ENT_MAP] + entity_info = device_info[DEV_SIG_ENT_MAP][map_id] + assert {ch.name for ch in channels} == set(entity_info[DEV_SIG_CHANNELS]) + assert ent_cls.__name__ == entity_info[DEV_SIG_ENT_MAP_CLASS] def _ch_mock(cluster): @@ -377,11 +388,11 @@ async def test_device_override( zigpy_device = zigpy_device_mock( { 1: { - "device_type": zigpy.profiles.zha.DeviceType.COLOR_DIMMABLE_LIGHT, + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.COLOR_DIMMABLE_LIGHT, "endpoint_id": 1, - "in_clusters": [0, 3, 4, 5, 6, 8, 768, 2821, 64513], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 768, 2821, 64513], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, } }, "00:11:22:33:44:55:66:77", diff --git a/tests/components/zha/test_fan.py b/tests/components/zha/test_fan.py index eed0e0b691e..65b2df725dc 100644 --- a/tests/components/zha/test_fan.py +++ b/tests/components/zha/test_fan.py @@ -49,6 +49,7 @@ from .common import ( get_zha_gateway, send_attributes_report, ) +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE from tests.components.zha.common import async_wait_for_updates @@ -61,9 +62,10 @@ def zigpy_device(zigpy_device_mock): """Device tracker zigpy device.""" endpoints = { 1: { - "in_clusters": [hvac.Fan.cluster_id], - "out_clusters": [], - "device_type": zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_INPUT: [hvac.Fan.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_PROFILE: zha.PROFILE_ID, } } return zigpy_device_mock( @@ -78,9 +80,10 @@ async def coordinator(hass, zigpy_device_mock, zha_device_joined): zigpy_device = zigpy_device_mock( { 1: { - "in_clusters": [general.Groups.cluster_id], - "out_clusters": [], - "device_type": zha.DeviceType.COLOR_DIMMABLE_LIGHT, + SIG_EP_INPUT: [general.Groups.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.COLOR_DIMMABLE_LIGHT, + SIG_EP_PROFILE: zha.PROFILE_ID, } }, ieee="00:15:8d:00:02:32:4f:32", @@ -99,13 +102,14 @@ async def device_fan_1(hass, zigpy_device_mock, zha_device_joined): zigpy_device = zigpy_device_mock( { 1: { - "in_clusters": [ + SIG_EP_INPUT: [ general.Groups.cluster_id, general.OnOff.cluster_id, hvac.Fan.cluster_id, ], - "out_clusters": [], - "device_type": zha.DeviceType.ON_OFF_LIGHT, + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.ON_OFF_LIGHT, + SIG_EP_PROFILE: zha.PROFILE_ID, }, }, ieee=IEEE_GROUPABLE_DEVICE, @@ -123,14 +127,15 @@ async def device_fan_2(hass, zigpy_device_mock, zha_device_joined): zigpy_device = zigpy_device_mock( { 1: { - "in_clusters": [ + SIG_EP_INPUT: [ general.Groups.cluster_id, general.OnOff.cluster_id, hvac.Fan.cluster_id, general.LevelControl.cluster_id, ], - "out_clusters": [], - "device_type": zha.DeviceType.ON_OFF_LIGHT, + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.ON_OFF_LIGHT, + SIG_EP_PROFILE: zha.PROFILE_ID, }, }, ieee=IEEE_GROUPABLE_DEVICE2, diff --git a/tests/components/zha/test_gateway.py b/tests/components/zha/test_gateway.py index 4b3a9bec50c..6662f0c2c9f 100644 --- a/tests/components/zha/test_gateway.py +++ b/tests/components/zha/test_gateway.py @@ -13,6 +13,7 @@ from homeassistant.components.zha.core.group import GroupMember from homeassistant.components.zha.core.store import TOMBSTONE_LIFETIME from .common import async_enable_traffic, async_find_group_entity_id, get_zha_gateway +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE IEEE_GROUPABLE_DEVICE = "01:2d:6f:00:0a:90:69:e8" IEEE_GROUPABLE_DEVICE2 = "02:2d:6f:00:0a:90:69:e8" @@ -24,9 +25,10 @@ def zigpy_dev_basic(zigpy_device_mock): return zigpy_device_mock( { 1: { - "in_clusters": [general.Basic.cluster_id], - "out_clusters": [], - "device_type": zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_INPUT: [general.Basic.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_PROFILE: zha.PROFILE_ID, } } ) @@ -47,9 +49,10 @@ async def coordinator(hass, zigpy_device_mock, zha_device_joined): zigpy_device = zigpy_device_mock( { 1: { - "in_clusters": [], - "out_clusters": [], - "device_type": zha.DeviceType.COLOR_DIMMABLE_LIGHT, + SIG_EP_INPUT: [], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.COLOR_DIMMABLE_LIGHT, + SIG_EP_PROFILE: zha.PROFILE_ID, } }, ieee="00:15:8d:00:02:32:4f:32", @@ -68,14 +71,15 @@ async def device_light_1(hass, zigpy_device_mock, zha_device_joined): zigpy_device = zigpy_device_mock( { 1: { - "in_clusters": [ + SIG_EP_INPUT: [ general.OnOff.cluster_id, general.LevelControl.cluster_id, lighting.Color.cluster_id, general.Groups.cluster_id, ], - "out_clusters": [], - "device_type": zha.DeviceType.COLOR_DIMMABLE_LIGHT, + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.COLOR_DIMMABLE_LIGHT, + SIG_EP_PROFILE: zha.PROFILE_ID, } }, ieee=IEEE_GROUPABLE_DEVICE, @@ -92,14 +96,15 @@ async def device_light_2(hass, zigpy_device_mock, zha_device_joined): zigpy_device = zigpy_device_mock( { 1: { - "in_clusters": [ + SIG_EP_INPUT: [ general.OnOff.cluster_id, general.LevelControl.cluster_id, lighting.Color.cluster_id, general.Groups.cluster_id, ], - "out_clusters": [], - "device_type": zha.DeviceType.COLOR_DIMMABLE_LIGHT, + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.COLOR_DIMMABLE_LIGHT, + SIG_EP_PROFILE: zha.PROFILE_ID, } }, ieee=IEEE_GROUPABLE_DEVICE2, diff --git a/tests/components/zha/test_light.py b/tests/components/zha/test_light.py index 915fc77462b..d1a3b5dabb0 100644 --- a/tests/components/zha/test_light.py +++ b/tests/components/zha/test_light.py @@ -1,6 +1,6 @@ """Test zha light.""" from datetime import timedelta -from unittest.mock import AsyncMock, MagicMock, call, patch, sentinel +from unittest.mock import AsyncMock, call, patch, sentinel import pytest import zigpy.profiles.zha as zha @@ -23,6 +23,7 @@ from .common import ( get_zha_gateway, send_attributes_report, ) +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE from tests.common import async_fire_time_changed from tests.components.zha.common import async_wait_for_updates @@ -35,39 +36,42 @@ IEEE_GROUPABLE_DEVICE3 = "03:2d:6f:00:0a:90:69:e7" LIGHT_ON_OFF = { 1: { - "device_type": zha.DeviceType.ON_OFF_LIGHT, - "in_clusters": [ + SIG_EP_PROFILE: zha.PROFILE_ID, + SIG_EP_TYPE: zha.DeviceType.ON_OFF_LIGHT, + SIG_EP_INPUT: [ general.Basic.cluster_id, general.Identify.cluster_id, general.OnOff.cluster_id, ], - "out_clusters": [general.Ota.cluster_id], + SIG_EP_OUTPUT: [general.Ota.cluster_id], } } LIGHT_LEVEL = { 1: { - "device_type": zha.DeviceType.DIMMABLE_LIGHT, - "in_clusters": [ + SIG_EP_PROFILE: zha.PROFILE_ID, + SIG_EP_TYPE: zha.DeviceType.DIMMABLE_LIGHT, + SIG_EP_INPUT: [ general.Basic.cluster_id, general.LevelControl.cluster_id, general.OnOff.cluster_id, ], - "out_clusters": [general.Ota.cluster_id], + SIG_EP_OUTPUT: [general.Ota.cluster_id], } } LIGHT_COLOR = { 1: { - "device_type": zha.DeviceType.COLOR_DIMMABLE_LIGHT, - "in_clusters": [ + SIG_EP_PROFILE: zha.PROFILE_ID, + SIG_EP_TYPE: zha.DeviceType.COLOR_DIMMABLE_LIGHT, + SIG_EP_INPUT: [ general.Basic.cluster_id, general.Identify.cluster_id, general.LevelControl.cluster_id, general.OnOff.cluster_id, lighting.Color.cluster_id, ], - "out_clusters": [general.Ota.cluster_id], + SIG_EP_OUTPUT: [general.Ota.cluster_id], } } @@ -79,9 +83,10 @@ async def coordinator(hass, zigpy_device_mock, zha_device_joined): zigpy_device = zigpy_device_mock( { 1: { - "in_clusters": [general.Groups.cluster_id], - "out_clusters": [], - "device_type": zha.DeviceType.COLOR_DIMMABLE_LIGHT, + SIG_EP_INPUT: [general.Groups.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.COLOR_DIMMABLE_LIGHT, + SIG_EP_PROFILE: zha.PROFILE_ID, } }, ieee="00:15:8d:00:02:32:4f:32", @@ -100,15 +105,16 @@ async def device_light_1(hass, zigpy_device_mock, zha_device_joined): zigpy_device = zigpy_device_mock( { 1: { - "in_clusters": [ + SIG_EP_INPUT: [ general.OnOff.cluster_id, general.LevelControl.cluster_id, lighting.Color.cluster_id, general.Groups.cluster_id, general.Identify.cluster_id, ], - "out_clusters": [], - "device_type": zha.DeviceType.COLOR_DIMMABLE_LIGHT, + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.COLOR_DIMMABLE_LIGHT, + SIG_EP_PROFILE: zha.PROFILE_ID, } }, ieee=IEEE_GROUPABLE_DEVICE, @@ -126,15 +132,16 @@ async def device_light_2(hass, zigpy_device_mock, zha_device_joined): zigpy_device = zigpy_device_mock( { 1: { - "in_clusters": [ + SIG_EP_INPUT: [ general.OnOff.cluster_id, general.LevelControl.cluster_id, lighting.Color.cluster_id, general.Groups.cluster_id, general.Identify.cluster_id, ], - "out_clusters": [], - "device_type": zha.DeviceType.COLOR_DIMMABLE_LIGHT, + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.COLOR_DIMMABLE_LIGHT, + SIG_EP_PROFILE: zha.PROFILE_ID, } }, ieee=IEEE_GROUPABLE_DEVICE2, @@ -152,15 +159,16 @@ async def device_light_3(hass, zigpy_device_mock, zha_device_joined): zigpy_device = zigpy_device_mock( { 1: { - "in_clusters": [ + SIG_EP_INPUT: [ general.OnOff.cluster_id, general.LevelControl.cluster_id, lighting.Color.cluster_id, general.Groups.cluster_id, general.Identify.cluster_id, ], - "out_clusters": [], - "device_type": zha.DeviceType.COLOR_DIMMABLE_LIGHT, + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.COLOR_DIMMABLE_LIGHT, + SIG_EP_PROFILE: zha.PROFILE_ID, } }, ieee=IEEE_GROUPABLE_DEVICE3, @@ -171,14 +179,14 @@ async def device_light_3(hass, zigpy_device_mock, zha_device_joined): return zha_device -@patch("zigpy.zcl.clusters.general.OnOff.read_attributes", new=MagicMock()) async def test_light_refresh(hass, zigpy_device_mock, zha_device_joined_restored): """Test zha light platform refresh.""" # create zigpy devices zigpy_device = zigpy_device_mock(LIGHT_ON_OFF) - zha_device = await zha_device_joined_restored(zigpy_device) on_off_cluster = zigpy_device.endpoints[1].on_off + on_off_cluster.PLUGGED_ATTR_READS = {"on_off": 0} + zha_device = await zha_device_joined_restored(zigpy_device) entity_id = await find_entity_id(DOMAIN, zha_device, hass) # allow traffic to flow through the gateway and device @@ -193,7 +201,7 @@ async def test_light_refresh(hass, zigpy_device_mock, zha_device_joined_restored assert hass.states.get(entity_id).state == STATE_OFF # 1 interval - 1 call - on_off_cluster.read_attributes.return_value = [{"on_off": 1}, {}] + on_off_cluster.PLUGGED_ATTR_READS = {"on_off": 1} async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=80)) await hass.async_block_till_done() assert on_off_cluster.read_attributes.call_count == 1 @@ -201,7 +209,7 @@ async def test_light_refresh(hass, zigpy_device_mock, zha_device_joined_restored assert hass.states.get(entity_id).state == STATE_ON # 2 intervals - 2 calls - on_off_cluster.read_attributes.return_value = [{"on_off": 0}, {}] + on_off_cluster.PLUGGED_ATTR_READS = {"on_off": 0} async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=80)) await hass.async_block_till_done() assert on_off_cluster.read_attributes.call_count == 2 diff --git a/tests/components/zha/test_lock.py b/tests/components/zha/test_lock.py index 72ba0aba9c5..ca9b7961d38 100644 --- a/tests/components/zha/test_lock.py +++ b/tests/components/zha/test_lock.py @@ -11,6 +11,7 @@ from homeassistant.components.lock import DOMAIN from homeassistant.const import STATE_LOCKED, STATE_UNAVAILABLE, STATE_UNLOCKED from .common import async_enable_traffic, find_entity_id, send_attributes_report +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE from tests.common import mock_coro @@ -28,9 +29,9 @@ async def lock(hass, zigpy_device_mock, zha_device_joined_restored): zigpy_device = zigpy_device_mock( { 1: { - "in_clusters": [closures.DoorLock.cluster_id, general.Basic.cluster_id], - "out_clusters": [], - "device_type": zigpy.profiles.zha.DeviceType.DOOR_LOCK, + SIG_EP_INPUT: [closures.DoorLock.cluster_id, general.Basic.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.DOOR_LOCK, } }, ) diff --git a/tests/components/zha/test_number.py b/tests/components/zha/test_number.py index 1bb9aa947ff..9623a89a8c2 100644 --- a/tests/components/zha/test_number.py +++ b/tests/components/zha/test_number.py @@ -17,6 +17,7 @@ from .common import ( find_entity_id, send_attributes_report, ) +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE from tests.common import mock_coro @@ -27,9 +28,9 @@ def zigpy_analog_output_device(zigpy_device_mock): endpoints = { 1: { - "device_type": zigpy.profiles.zha.DeviceType.LEVEL_CONTROL_SWITCH, - "in_clusters": [general.AnalogOutput.cluster_id, general.Basic.cluster_id], - "out_clusters": [], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.LEVEL_CONTROL_SWITCH, + SIG_EP_INPUT: [general.AnalogOutput.cluster_id, general.Basic.cluster_id], + SIG_EP_OUTPUT: [], } } return zigpy_device_mock(endpoints) diff --git a/tests/components/zha/test_sensor.py b/tests/components/zha/test_sensor.py index b6b4b343e3b..ddccb5117d5 100644 --- a/tests/components/zha/test_sensor.py +++ b/tests/components/zha/test_sensor.py @@ -34,6 +34,7 @@ from .common import ( send_attribute_report, send_attributes_report, ) +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE async def async_test_humidity(hass, cluster, entity_id): @@ -163,9 +164,9 @@ async def test_sensor( zigpy_device = zigpy_device_mock( { 1: { - "in_clusters": [cluster_id, general.Basic.cluster_id], - "out_cluster": [], - "device_type": zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_INPUT: [cluster_id, general.Basic.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, } } ) @@ -284,12 +285,12 @@ async def test_temp_uom( zigpy_device = zigpy_device_mock( { 1: { - "in_clusters": [ + SIG_EP_INPUT: [ measurement.TemperatureMeasurement.cluster_id, general.Basic.cluster_id, ], - "out_cluster": [], - "device_type": zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, } } ) @@ -327,9 +328,9 @@ async def test_electrical_measurement_init( zigpy_device = zigpy_device_mock( { 1: { - "in_clusters": [cluster_id, general.Basic.cluster_id], - "out_cluster": [], - "device_type": zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_INPUT: [cluster_id, general.Basic.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, } } ) diff --git a/tests/components/zha/test_switch.py b/tests/components/zha/test_switch.py index 4cec0753c68..04f43344b98 100644 --- a/tests/components/zha/test_switch.py +++ b/tests/components/zha/test_switch.py @@ -18,6 +18,7 @@ from .common import ( get_zha_gateway, send_attributes_report, ) +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE from tests.common import mock_coro from tests.components.zha.common import async_wait_for_updates @@ -33,9 +34,9 @@ def zigpy_device(zigpy_device_mock): """Device tracker zigpy device.""" endpoints = { 1: { - "in_clusters": [general.Basic.cluster_id, general.OnOff.cluster_id], - "out_clusters": [], - "device_type": zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_INPUT: [general.Basic.cluster_id, general.OnOff.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.ON_OFF_SWITCH, } } return zigpy_device_mock(endpoints) @@ -48,9 +49,9 @@ async def coordinator(hass, zigpy_device_mock, zha_device_joined): zigpy_device = zigpy_device_mock( { 1: { - "in_clusters": [], - "out_clusters": [], - "device_type": zha.DeviceType.COLOR_DIMMABLE_LIGHT, + SIG_EP_INPUT: [], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.COLOR_DIMMABLE_LIGHT, } }, ieee="00:15:8d:00:02:32:4f:32", @@ -69,9 +70,9 @@ async def device_switch_1(hass, zigpy_device_mock, zha_device_joined): zigpy_device = zigpy_device_mock( { 1: { - "in_clusters": [general.OnOff.cluster_id, general.Groups.cluster_id], - "out_clusters": [], - "device_type": zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_INPUT: [general.OnOff.cluster_id, general.Groups.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.ON_OFF_SWITCH, } }, ieee=IEEE_GROUPABLE_DEVICE, @@ -89,9 +90,9 @@ async def device_switch_2(hass, zigpy_device_mock, zha_device_joined): zigpy_device = zigpy_device_mock( { 1: { - "in_clusters": [general.OnOff.cluster_id, general.Groups.cluster_id], - "out_clusters": [], - "device_type": zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_INPUT: [general.OnOff.cluster_id, general.Groups.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.ON_OFF_SWITCH, } }, ieee=IEEE_GROUPABLE_DEVICE2, diff --git a/tests/components/zha/zha_devices_list.py b/tests/components/zha/zha_devices_list.py index 004be25d21f..30780bcaa86 100644 --- a/tests/components/zha/zha_devices_list.py +++ b/tests/components/zha/zha_devices_list.py @@ -1,1022 +1,1051 @@ """Example Zigbee Devices.""" +from zigpy.const import ( + SIG_ENDPOINTS, + SIG_EP_INPUT, + SIG_EP_OUTPUT, + SIG_EP_PROFILE, + SIG_EP_TYPE, + SIG_MANUFACTURER, + SIG_MODEL, + SIG_NODE_DESC, +) + +DEV_SIG_CHANNELS = "channels" +DEV_SIG_DEV_NO = "device_no" +DEV_SIG_ENTITIES = "entities" +DEV_SIG_ENT_MAP = "entity_map" +DEV_SIG_ENT_MAP_CLASS = "entity_class" +DEV_SIG_ENT_MAP_ID = "entity_id" +DEV_SIG_EP_ID = "endpoint_id" +DEV_SIG_EVT_CHANNELS = "event_channels" +DEV_SIG_ZHA_QUIRK = "zha_quirk" + DEVICES = [ { - "device_no": 0, - "endpoints": { + DEV_SIG_DEV_NO: 0, + SIG_ENDPOINTS: { 1: { - "device_type": 2080, - "endpoint_id": 1, - "in_clusters": [0, 3, 4096, 64716], - "out_clusters": [3, 4, 6, 8, 4096, 64716], - "profile_id": 260, + SIG_EP_TYPE: 2080, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4096, 64716], + SIG_EP_OUTPUT: [3, 4, 6, 8, 4096, 64716], + SIG_EP_PROFILE: 260, } }, - "entities": [], - "entity_map": {}, - "event_channels": ["1:0x0006", "1:0x0008"], - "manufacturer": "ADUROLIGHT", - "model": "Adurolight_NCC", - "node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00*d\x00\x00", - "zha_quirks": "AdurolightNCC", + DEV_SIG_ENTITIES: [], + DEV_SIG_ENT_MAP: {}, + DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008"], + SIG_MANUFACTURER: "ADUROLIGHT", + SIG_MODEL: "Adurolight_NCC", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00*d\x00\x00", + DEV_SIG_ZHA_QUIRK: "AdurolightNCC", }, { - "device_no": 1, - "endpoints": { + DEV_SIG_DEV_NO: 1, + SIG_ENDPOINTS: { 5: { - "device_type": 1026, - "endpoint_id": 5, - "in_clusters": [0, 1, 3, 32, 1026, 1280, 2821], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 5, + SIG_EP_INPUT: [0, 1, 3, 32, 1026, 1280, 2821], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "binary_sensor.bosch_isw_zpr1_wp13_77665544_ias_zone", "sensor.bosch_isw_zpr1_wp13_77665544_power", "sensor.bosch_isw_zpr1_wp13_77665544_temperature", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-5-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.bosch_isw_zpr1_wp13_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.bosch_isw_zpr1_wp13_77665544_power", }, ("sensor", "00:11:22:33:44:55:66:77-5-1026"): { - "channels": ["temperature"], - "entity_class": "Temperature", - "entity_id": "sensor.bosch_isw_zpr1_wp13_77665544_temperature", + DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.bosch_isw_zpr1_wp13_77665544_temperature", }, ("binary_sensor", "00:11:22:33:44:55:66:77-5-1280"): { - "channels": ["ias_zone"], - "entity_class": "IASZone", - "entity_id": "binary_sensor.bosch_isw_zpr1_wp13_77665544_ias_zone", + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.bosch_isw_zpr1_wp13_77665544_ias_zone", }, }, - "event_channels": ["5:0x0019"], - "manufacturer": "Bosch", - "model": "ISW-ZPR1-WP13", - "node_descriptor": b"\x02@\x08\x00\x00l\x00\x00\x00\x00\x00\x00\x00", + DEV_SIG_EVT_CHANNELS: ["5:0x0019"], + SIG_MANUFACTURER: "Bosch", + SIG_MODEL: "ISW-ZPR1-WP13", + SIG_NODE_DESC: b"\x02@\x08\x00\x00l\x00\x00\x00\x00\x00\x00\x00", }, { - "device_no": 2, - "endpoints": { + DEV_SIG_DEV_NO: 2, + SIG_ENDPOINTS: { 1: { - "device_type": 1, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 32, 2821], - "out_clusters": [3, 6, 8, 25], - "profile_id": 260, + SIG_EP_TYPE: 1, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 32, 2821], + SIG_EP_OUTPUT: [3, 6, 8, 25], + SIG_EP_PROFILE: 260, } }, - "entities": ["sensor.centralite_3130_77665544_power"], - "entity_map": { + DEV_SIG_ENTITIES: ["sensor.centralite_3130_77665544_power"], + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.centralite_3130_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3130_77665544_power", } }, - "event_channels": ["1:0x0006", "1:0x0008", "1:0x0019"], - "manufacturer": "CentraLite", - "model": "3130", - "node_descriptor": b"\x02@\x80N\x10RR\x00\x00\x00R\x00\x00", - "zha_quirks": "CentraLite3130", + DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0019"], + SIG_MANUFACTURER: "CentraLite", + SIG_MODEL: "3130", + SIG_NODE_DESC: b"\x02@\x80N\x10RR\x00\x00\x00R\x00\x00", + DEV_SIG_ZHA_QUIRK: "CentraLite3130", }, { - "device_no": 3, - "endpoints": { + DEV_SIG_DEV_NO: 3, + SIG_ENDPOINTS: { 1: { - "device_type": 81, - "endpoint_id": 1, - "in_clusters": [0, 3, 4, 5, 6, 1794, 2820, 2821, 64515], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_TYPE: 81, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 1794, 2820, 2821, 64515], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "sensor.centralite_3210_l_77665544_electrical_measurement", "sensor.centralite_3210_l_77665544_smartenergy_metering", "switch.centralite_3210_l_77665544_on_off", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("switch", "00:11:22:33:44:55:66:77-1"): { - "channels": ["on_off"], - "entity_class": "Switch", - "entity_id": "switch.centralite_3210_l_77665544_on_off", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.centralite_3210_l_77665544_on_off", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { - "channels": ["smartenergy_metering"], - "entity_class": "SmartEnergyMetering", - "entity_id": "sensor.centralite_3210_l_77665544_smartenergy_metering", + DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_77665544_smartenergy_metering", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { - "channels": ["electrical_measurement"], - "entity_class": "ElectricalMeasurement", - "entity_id": "sensor.centralite_3210_l_77665544_electrical_measurement", + DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_77665544_electrical_measurement", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "CentraLite", - "model": "3210-L", - "node_descriptor": b"\x01@\x8eN\x10RR\x00\x00\x00R\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "CentraLite", + SIG_MODEL: "3210-L", + SIG_NODE_DESC: b"\x01@\x8eN\x10RR\x00\x00\x00R\x00\x00", }, { - "device_no": 4, - "endpoints": { + DEV_SIG_DEV_NO: 4, + SIG_ENDPOINTS: { 1: { - "device_type": 770, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 32, 1026, 2821, 64581], - "out_clusters": [3, 25], - "profile_id": 260, + SIG_EP_TYPE: 770, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 32, 1026, 2821, 64581], + SIG_EP_OUTPUT: [3, 25], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "sensor.centralite_3310_s_77665544_manufacturer_specific", "sensor.centralite_3310_s_77665544_power", "sensor.centralite_3310_s_77665544_temperature", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.centralite_3310_s_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3310_s_77665544_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { - "channels": ["temperature"], - "entity_class": "Temperature", - "entity_id": "sensor.centralite_3310_s_77665544_temperature", + DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3310_s_77665544_temperature", }, ("sensor", "00:11:22:33:44:55:66:77-1-64581"): { - "channels": ["manufacturer_specific"], - "entity_class": "Humidity", - "entity_id": "sensor.centralite_3310_s_77665544_manufacturer_specific", + DEV_SIG_CHANNELS: ["manufacturer_specific"], + DEV_SIG_ENT_MAP_CLASS: "Humidity", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3310_s_77665544_manufacturer_specific", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "CentraLite", - "model": "3310-S", - "node_descriptor": b"\x02@\x80\xdf\xc2RR\x00\x00\x00R\x00\x00", - "zha_quirks": "CentraLite3310S", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "CentraLite", + SIG_MODEL: "3310-S", + SIG_NODE_DESC: b"\x02@\x80\xdf\xc2RR\x00\x00\x00R\x00\x00", + DEV_SIG_ZHA_QUIRK: "CentraLite3310S", }, { - "device_no": 5, - "endpoints": { + DEV_SIG_DEV_NO: 5, + SIG_ENDPOINTS: { 1: { - "device_type": 1026, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 32, 1026, 1280, 2821], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 32, 1026, 1280, 2821], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, }, 2: { - "device_type": 12, - "endpoint_id": 2, - "in_clusters": [0, 3, 2821, 64527], - "out_clusters": [3], - "profile_id": 49887, + SIG_EP_TYPE: 12, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [0, 3, 2821, 64527], + SIG_EP_OUTPUT: [3], + SIG_EP_PROFILE: 49887, }, }, - "entities": [ + DEV_SIG_ENTITIES: [ "binary_sensor.centralite_3315_s_77665544_ias_zone", "sensor.centralite_3315_s_77665544_power", "sensor.centralite_3315_s_77665544_temperature", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.centralite_3315_s_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3315_s_77665544_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { - "channels": ["temperature"], - "entity_class": "Temperature", - "entity_id": "sensor.centralite_3315_s_77665544_temperature", + DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3315_s_77665544_temperature", }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - "channels": ["ias_zone"], - "entity_class": "IASZone", - "entity_id": "binary_sensor.centralite_3315_s_77665544_ias_zone", + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.centralite_3315_s_77665544_ias_zone", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "CentraLite", - "model": "3315-S", - "node_descriptor": b"\x02@\x80\xdf\xc2RR\x00\x00\x00R\x00\x00", - "zha_quirks": "CentraLiteIASSensor", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "CentraLite", + SIG_MODEL: "3315-S", + SIG_NODE_DESC: b"\x02@\x80\xdf\xc2RR\x00\x00\x00R\x00\x00", + DEV_SIG_ZHA_QUIRK: "CentraLiteIASSensor", }, { - "device_no": 6, - "endpoints": { + DEV_SIG_DEV_NO: 6, + SIG_ENDPOINTS: { 1: { - "device_type": 1026, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 32, 1026, 1280, 2821], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 32, 1026, 1280, 2821], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, }, 2: { - "device_type": 12, - "endpoint_id": 2, - "in_clusters": [0, 3, 2821, 64527], - "out_clusters": [3], - "profile_id": 49887, + SIG_EP_TYPE: 12, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [0, 3, 2821, 64527], + SIG_EP_OUTPUT: [3], + SIG_EP_PROFILE: 49887, }, }, - "entities": [ + DEV_SIG_ENTITIES: [ "binary_sensor.centralite_3320_l_77665544_ias_zone", "sensor.centralite_3320_l_77665544_power", "sensor.centralite_3320_l_77665544_temperature", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.centralite_3320_l_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3320_l_77665544_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { - "channels": ["temperature"], - "entity_class": "Temperature", - "entity_id": "sensor.centralite_3320_l_77665544_temperature", + DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3320_l_77665544_temperature", }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - "channels": ["ias_zone"], - "entity_class": "IASZone", - "entity_id": "binary_sensor.centralite_3320_l_77665544_ias_zone", + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.centralite_3320_l_77665544_ias_zone", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "CentraLite", - "model": "3320-L", - "node_descriptor": b"\x02@\x80\xdf\xc2RR\x00\x00\x00R\x00\x00", - "zha_quirks": "CentraLiteIASSensor", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "CentraLite", + SIG_MODEL: "3320-L", + SIG_NODE_DESC: b"\x02@\x80\xdf\xc2RR\x00\x00\x00R\x00\x00", + DEV_SIG_ZHA_QUIRK: "CentraLiteIASSensor", }, { - "device_no": 7, - "endpoints": { + DEV_SIG_DEV_NO: 7, + SIG_ENDPOINTS: { 1: { - "device_type": 1026, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 32, 1026, 1280, 2821], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 32, 1026, 1280, 2821], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, }, 2: { - "device_type": 263, - "endpoint_id": 2, - "in_clusters": [0, 3, 2821, 64582], - "out_clusters": [3], - "profile_id": 49887, + SIG_EP_TYPE: 263, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [0, 3, 2821, 64582], + SIG_EP_OUTPUT: [3], + SIG_EP_PROFILE: 49887, }, }, - "entities": [ + DEV_SIG_ENTITIES: [ "binary_sensor.centralite_3326_l_77665544_ias_zone", "sensor.centralite_3326_l_77665544_power", "sensor.centralite_3326_l_77665544_temperature", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.centralite_3326_l_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3326_l_77665544_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { - "channels": ["temperature"], - "entity_class": "Temperature", - "entity_id": "sensor.centralite_3326_l_77665544_temperature", + DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3326_l_77665544_temperature", }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - "channels": ["ias_zone"], - "entity_class": "IASZone", - "entity_id": "binary_sensor.centralite_3326_l_77665544_ias_zone", + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.centralite_3326_l_77665544_ias_zone", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "CentraLite", - "model": "3326-L", - "node_descriptor": b"\x02@\x80\xdf\xc2RR\x00\x00\x00R\x00\x00", - "zha_quirks": "CentraLiteMotionSensor", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "CentraLite", + SIG_MODEL: "3326-L", + SIG_NODE_DESC: b"\x02@\x80\xdf\xc2RR\x00\x00\x00R\x00\x00", + DEV_SIG_ZHA_QUIRK: "CentraLiteMotionSensor", }, { - "device_no": 8, - "endpoints": { + DEV_SIG_DEV_NO: 8, + SIG_ENDPOINTS: { 1: { - "device_type": 1026, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 32, 1026, 1280, 2821], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 32, 1026, 1280, 2821], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, }, 2: { - "device_type": 263, - "endpoint_id": 2, - "in_clusters": [0, 3, 1030, 2821], - "out_clusters": [3], - "profile_id": 260, + SIG_EP_TYPE: 263, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [0, 3, 1030, 2821], + SIG_EP_OUTPUT: [3], + SIG_EP_PROFILE: 260, }, }, - "entities": [ + DEV_SIG_ENTITIES: [ "binary_sensor.centralite_motion_sensor_a_77665544_ias_zone", "binary_sensor.centralite_motion_sensor_a_77665544_occupancy", "sensor.centralite_motion_sensor_a_77665544_power", "sensor.centralite_motion_sensor_a_77665544_temperature", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.centralite_motion_sensor_a_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_motion_sensor_a_77665544_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { - "channels": ["temperature"], - "entity_class": "Temperature", - "entity_id": "sensor.centralite_motion_sensor_a_77665544_temperature", + DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_motion_sensor_a_77665544_temperature", }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - "channels": ["ias_zone"], - "entity_class": "IASZone", - "entity_id": "binary_sensor.centralite_motion_sensor_a_77665544_ias_zone", + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.centralite_motion_sensor_a_77665544_ias_zone", }, ("binary_sensor", "00:11:22:33:44:55:66:77-2-1030"): { - "channels": ["occupancy"], - "entity_class": "Occupancy", - "entity_id": "binary_sensor.centralite_motion_sensor_a_77665544_occupancy", + DEV_SIG_CHANNELS: ["occupancy"], + DEV_SIG_ENT_MAP_CLASS: "Occupancy", + DEV_SIG_ENT_MAP_ID: "binary_sensor.centralite_motion_sensor_a_77665544_occupancy", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "CentraLite", - "model": "Motion Sensor-A", - "node_descriptor": b"\x02@\x80N\x10RR\x00\x00\x00R\x00\x00", - "zha_quirks": "CentraLite3305S", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "CentraLite", + SIG_MODEL: "Motion Sensor-A", + SIG_NODE_DESC: b"\x02@\x80N\x10RR\x00\x00\x00R\x00\x00", + DEV_SIG_ZHA_QUIRK: "CentraLite3305S", }, { - "device_no": 9, - "endpoints": { + DEV_SIG_DEV_NO: 9, + SIG_ENDPOINTS: { 1: { - "device_type": 81, - "endpoint_id": 1, - "in_clusters": [0, 3, 4, 5, 6, 1794], - "out_clusters": [0], - "profile_id": 260, + SIG_EP_TYPE: 81, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 1794], + SIG_EP_OUTPUT: [0], + SIG_EP_PROFILE: 260, }, 4: { - "device_type": 9, - "endpoint_id": 4, - "in_clusters": [], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_TYPE: 9, + DEV_SIG_EP_ID: 4, + SIG_EP_INPUT: [], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, }, }, - "entities": [ + DEV_SIG_ENTITIES: [ "sensor.climaxtechnology_psmp5_00_00_02_02tc_77665544_smartenergy_metering", "switch.climaxtechnology_psmp5_00_00_02_02tc_77665544_on_off", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("switch", "00:11:22:33:44:55:66:77-1"): { - "channels": ["on_off"], - "entity_class": "Switch", - "entity_id": "switch.climaxtechnology_psmp5_00_00_02_02tc_77665544_on_off", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.climaxtechnology_psmp5_00_00_02_02tc_77665544_on_off", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { - "channels": ["smartenergy_metering"], - "entity_class": "SmartEnergyMetering", - "entity_id": "sensor.climaxtechnology_psmp5_00_00_02_02tc_77665544_smartenergy_metering", + DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", + DEV_SIG_ENT_MAP_ID: "sensor.climaxtechnology_psmp5_00_00_02_02tc_77665544_smartenergy_metering", }, }, - "event_channels": ["4:0x0019"], - "manufacturer": "ClimaxTechnology", - "model": "PSMP5_00.00.02.02TC", - "node_descriptor": b"\x01@\x8e\x00\x00P\xa0\x00\x00\x00\xa0\x00\x00", + DEV_SIG_EVT_CHANNELS: ["4:0x0019"], + SIG_MANUFACTURER: "ClimaxTechnology", + SIG_MODEL: "PSMP5_00.00.02.02TC", + SIG_NODE_DESC: b"\x01@\x8e\x00\x00P\xa0\x00\x00\x00\xa0\x00\x00", }, { - "device_no": 10, - "endpoints": { + DEV_SIG_DEV_NO: 10, + SIG_ENDPOINTS: { 1: { - "device_type": 1026, - "endpoint_id": 1, - "in_clusters": [0, 3, 1280, 1282], - "out_clusters": [0], - "profile_id": 260, + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 1280, 1282], + SIG_EP_OUTPUT: [0], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "binary_sensor.climaxtechnology_sd8sc_00_00_03_12tc_77665544_ias_zone" ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - "channels": ["ias_zone"], - "entity_class": "IASZone", - "entity_id": "binary_sensor.climaxtechnology_sd8sc_00_00_03_12tc_77665544_ias_zone", + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.climaxtechnology_sd8sc_00_00_03_12tc_77665544_ias_zone", } }, - "event_channels": [], - "manufacturer": "ClimaxTechnology", - "model": "SD8SC_00.00.03.12TC", - "node_descriptor": b"\x02@\x80\x00\x00P\xa0\x00\x00\x00\xa0\x00\x00", + DEV_SIG_EVT_CHANNELS: [], + SIG_MANUFACTURER: "ClimaxTechnology", + SIG_MODEL: "SD8SC_00.00.03.12TC", + SIG_NODE_DESC: b"\x02@\x80\x00\x00P\xa0\x00\x00\x00\xa0\x00\x00", }, { - "device_no": 11, - "endpoints": { + DEV_SIG_DEV_NO: 11, + SIG_ENDPOINTS: { 1: { - "device_type": 1026, - "endpoint_id": 1, - "in_clusters": [0, 3, 1280], - "out_clusters": [0], - "profile_id": 260, + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 1280], + SIG_EP_OUTPUT: [0], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "binary_sensor.climaxtechnology_ws15_00_00_03_03tc_77665544_ias_zone" ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - "channels": ["ias_zone"], - "entity_class": "IASZone", - "entity_id": "binary_sensor.climaxtechnology_ws15_00_00_03_03tc_77665544_ias_zone", + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.climaxtechnology_ws15_00_00_03_03tc_77665544_ias_zone", } }, - "event_channels": [], - "manufacturer": "ClimaxTechnology", - "model": "WS15_00.00.03.03TC", - "node_descriptor": b"\x02@\x80\x00\x00P\xa0\x00\x00\x00\xa0\x00\x00", + DEV_SIG_EVT_CHANNELS: [], + SIG_MANUFACTURER: "ClimaxTechnology", + SIG_MODEL: "WS15_00.00.03.03TC", + SIG_NODE_DESC: b"\x02@\x80\x00\x00P\xa0\x00\x00\x00\xa0\x00\x00", }, { - "device_no": 12, - "endpoints": { + DEV_SIG_DEV_NO: 12, + SIG_ENDPOINTS: { 11: { - "device_type": 528, - "endpoint_id": 11, - "in_clusters": [0, 3, 4, 5, 6, 8, 768], - "out_clusters": [], - "profile_id": 49246, + SIG_EP_TYPE: 528, + DEV_SIG_EP_ID: 11, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 768], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 49246, }, 13: { - "device_type": 57694, - "endpoint_id": 13, - "in_clusters": [4096], - "out_clusters": [4096], - "profile_id": 49246, + SIG_EP_TYPE: 57694, + DEV_SIG_EP_ID: 13, + SIG_EP_INPUT: [4096], + SIG_EP_OUTPUT: [4096], + SIG_EP_PROFILE: 49246, }, }, - "entities": [ + DEV_SIG_ENTITIES: [ "light.feibit_inc_co_fb56_zcw08ku1_1_77665544_level_light_color_on_off" ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-11"): { - "channels": ["level", "light_color", "on_off"], - "entity_class": "Light", - "entity_id": "light.feibit_inc_co_fb56_zcw08ku1_1_77665544_level_light_color_on_off", + DEV_SIG_CHANNELS: ["level", "light_color", "on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.feibit_inc_co_fb56_zcw08ku1_1_77665544_level_light_color_on_off", } }, - "event_channels": [], - "manufacturer": "Feibit Inc co.", - "model": "FB56-ZCW08KU1.1", - "node_descriptor": b"\x01@\x8e\x00\x00P\xa0\x00\x00\x00\xa0\x00\x00", + DEV_SIG_EVT_CHANNELS: [], + SIG_MANUFACTURER: "Feibit Inc co.", + SIG_MODEL: "FB56-ZCW08KU1.1", + SIG_NODE_DESC: b"\x01@\x8e\x00\x00P\xa0\x00\x00\x00\xa0\x00\x00", }, { - "device_no": 13, - "endpoints": { + DEV_SIG_DEV_NO: 13, + SIG_ENDPOINTS: { 1: { - "device_type": 1026, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 1280, 1282], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 1280, 1282], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "binary_sensor.heiman_smokesensor_em_77665544_ias_zone", "sensor.heiman_smokesensor_em_77665544_power", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.heiman_smokesensor_em_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.heiman_smokesensor_em_77665544_power", }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - "channels": ["ias_zone"], - "entity_class": "IASZone", - "entity_id": "binary_sensor.heiman_smokesensor_em_77665544_ias_zone", + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.heiman_smokesensor_em_77665544_ias_zone", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "HEIMAN", - "model": "SmokeSensor-EM", - "node_descriptor": b"\x02@\x80\x0b\x12RR\x00\x00\x00R\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "HEIMAN", + SIG_MODEL: "SmokeSensor-EM", + SIG_NODE_DESC: b"\x02@\x80\x0b\x12RR\x00\x00\x00R\x00\x00", }, { - "device_no": 14, - "endpoints": { + DEV_SIG_DEV_NO: 14, + SIG_ENDPOINTS: { 1: { - "device_type": 1026, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 9, 1280], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 9, 1280], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, } }, - "entities": ["binary_sensor.heiman_co_v16_77665544_ias_zone"], - "entity_map": { + DEV_SIG_ENTITIES: ["binary_sensor.heiman_co_v16_77665544_ias_zone"], + DEV_SIG_ENT_MAP: { ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - "channels": ["ias_zone"], - "entity_class": "IASZone", - "entity_id": "binary_sensor.heiman_co_v16_77665544_ias_zone", + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.heiman_co_v16_77665544_ias_zone", } }, - "event_channels": ["1:0x0019"], - "manufacturer": "Heiman", - "model": "CO_V16", - "node_descriptor": b"\x02@\x84\xaa\xbb@\x00\x00\x00\x00\x00\x00\x03", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "Heiman", + SIG_MODEL: "CO_V16", + SIG_NODE_DESC: b"\x02@\x84\xaa\xbb@\x00\x00\x00\x00\x00\x00\x03", }, { - "device_no": 15, - "endpoints": { + DEV_SIG_DEV_NO: 15, + SIG_ENDPOINTS: { 1: { - "device_type": 1027, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 4, 9, 1280, 1282], - "out_clusters": [3, 25], - "profile_id": 260, + SIG_EP_TYPE: 1027, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 4, 9, 1280, 1282], + SIG_EP_OUTPUT: [3, 25], + SIG_EP_PROFILE: 260, } }, - "entities": ["binary_sensor.heiman_warningdevice_77665544_ias_zone"], - "entity_map": { + DEV_SIG_ENTITIES: ["binary_sensor.heiman_warningdevice_77665544_ias_zone"], + DEV_SIG_ENT_MAP: { ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - "channels": ["ias_zone"], - "entity_class": "IASZone", - "entity_id": "binary_sensor.heiman_warningdevice_77665544_ias_zone", + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.heiman_warningdevice_77665544_ias_zone", } }, - "event_channels": ["1:0x0019"], - "manufacturer": "Heiman", - "model": "WarningDevice", - "node_descriptor": b"\x01@\x8e\x0b\x12RR\x00\x00\x00R\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "Heiman", + SIG_MODEL: "WarningDevice", + SIG_NODE_DESC: b"\x01@\x8e\x0b\x12RR\x00\x00\x00R\x00\x00", }, { - "device_no": 16, - "endpoints": { + DEV_SIG_DEV_NO: 16, + SIG_ENDPOINTS: { 6: { - "device_type": 1026, - "endpoint_id": 6, - "in_clusters": [0, 1, 3, 32, 1024, 1026, 1280], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 6, + SIG_EP_INPUT: [0, 1, 3, 32, 1024, 1026, 1280], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "binary_sensor.hivehome_com_mot003_77665544_ias_zone", "sensor.hivehome_com_mot003_77665544_illuminance", "sensor.hivehome_com_mot003_77665544_power", "sensor.hivehome_com_mot003_77665544_temperature", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-6-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.hivehome_com_mot003_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.hivehome_com_mot003_77665544_power", }, ("sensor", "00:11:22:33:44:55:66:77-6-1024"): { - "channels": ["illuminance"], - "entity_class": "Illuminance", - "entity_id": "sensor.hivehome_com_mot003_77665544_illuminance", + DEV_SIG_CHANNELS: ["illuminance"], + DEV_SIG_ENT_MAP_CLASS: "Illuminance", + DEV_SIG_ENT_MAP_ID: "sensor.hivehome_com_mot003_77665544_illuminance", }, ("sensor", "00:11:22:33:44:55:66:77-6-1026"): { - "channels": ["temperature"], - "entity_class": "Temperature", - "entity_id": "sensor.hivehome_com_mot003_77665544_temperature", + DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.hivehome_com_mot003_77665544_temperature", }, ("binary_sensor", "00:11:22:33:44:55:66:77-6-1280"): { - "channels": ["ias_zone"], - "entity_class": "IASZone", - "entity_id": "binary_sensor.hivehome_com_mot003_77665544_ias_zone", + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.hivehome_com_mot003_77665544_ias_zone", }, }, - "event_channels": ["6:0x0019"], - "manufacturer": "HiveHome.com", - "model": "MOT003", - "node_descriptor": b"\x02@\x809\x10PP\x00\x00\x00P\x00\x00", - "zha_quirks": "MOT003", + DEV_SIG_EVT_CHANNELS: ["6:0x0019"], + SIG_MANUFACTURER: "HiveHome.com", + SIG_MODEL: "MOT003", + SIG_NODE_DESC: b"\x02@\x809\x10PP\x00\x00\x00P\x00\x00", + DEV_SIG_ZHA_QUIRK: "MOT003", }, { - "device_no": 17, - "endpoints": { + DEV_SIG_DEV_NO: 17, + SIG_ENDPOINTS: { 1: { - "device_type": 268, - "endpoint_id": 1, - "in_clusters": [0, 3, 4, 5, 6, 8, 768, 4096, 64636], - "out_clusters": [5, 25, 32, 4096], - "profile_id": 260, + SIG_EP_TYPE: 268, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 768, 4096, 64636], + SIG_EP_OUTPUT: [5, 25, 32, 4096], + SIG_EP_PROFILE: 260, }, 242: { - "device_type": 97, - "endpoint_id": 242, - "in_clusters": [33], - "out_clusters": [33], - "profile_id": 41440, + SIG_EP_TYPE: 97, + DEV_SIG_EP_ID: 242, + SIG_EP_INPUT: [33], + SIG_EP_OUTPUT: [33], + SIG_EP_PROFILE: 41440, }, }, - "entities": [ + DEV_SIG_ENTITIES: [ "light.ikea_of_sweden_tradfri_bulb_e12_ws_opal_600lm_77665544_level_light_color_on_off" ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - "channels": ["level", "light_color", "on_off"], - "entity_class": "Light", - "entity_id": "light.ikea_of_sweden_tradfri_bulb_e12_ws_opal_600lm_77665544_level_light_color_on_off", + DEV_SIG_CHANNELS: ["level", "light_color", "on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.ikea_of_sweden_tradfri_bulb_e12_ws_opal_600lm_77665544_level_light_color_on_off", } }, - "event_channels": ["1:0x0005", "1:0x0019"], - "manufacturer": "IKEA of Sweden", - "model": "TRADFRI bulb E12 WS opal 600lm", - "node_descriptor": b"\x01@\x8e|\x11RR\x00\x00,R\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019"], + SIG_MANUFACTURER: "IKEA of Sweden", + SIG_MODEL: "TRADFRI bulb E12 WS opal 600lm", + SIG_NODE_DESC: b"\x01@\x8e|\x11RR\x00\x00,R\x00\x00", }, { - "device_no": 18, - "endpoints": { + DEV_SIG_DEV_NO: 18, + SIG_ENDPOINTS: { 1: { - "device_type": 512, - "endpoint_id": 1, - "in_clusters": [0, 3, 4, 5, 6, 8, 768, 2821, 4096], - "out_clusters": [5, 25, 32, 4096], - "profile_id": 49246, + SIG_EP_TYPE: 512, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 768, 2821, 4096], + SIG_EP_OUTPUT: [5, 25, 32, 4096], + SIG_EP_PROFILE: 49246, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "light.ikea_of_sweden_tradfri_bulb_e26_cws_opal_600lm_77665544_level_light_color_on_off" ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - "channels": ["level", "light_color", "on_off"], - "entity_class": "Light", - "entity_id": "light.ikea_of_sweden_tradfri_bulb_e26_cws_opal_600lm_77665544_level_light_color_on_off", + DEV_SIG_CHANNELS: ["level", "light_color", "on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.ikea_of_sweden_tradfri_bulb_e26_cws_opal_600lm_77665544_level_light_color_on_off", } }, - "event_channels": ["1:0x0005", "1:0x0019"], - "manufacturer": "IKEA of Sweden", - "model": "TRADFRI bulb E26 CWS opal 600lm", - "node_descriptor": b"\x01@\x8e|\x11RR\x00\x00\x00R\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019"], + SIG_MANUFACTURER: "IKEA of Sweden", + SIG_MODEL: "TRADFRI bulb E26 CWS opal 600lm", + SIG_NODE_DESC: b"\x01@\x8e|\x11RR\x00\x00\x00R\x00\x00", }, { - "device_no": 19, - "endpoints": { + DEV_SIG_DEV_NO: 19, + SIG_ENDPOINTS: { 1: { - "device_type": 256, - "endpoint_id": 1, - "in_clusters": [0, 3, 4, 5, 6, 8, 2821, 4096], - "out_clusters": [5, 25, 32, 4096], - "profile_id": 49246, + SIG_EP_TYPE: 256, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 2821, 4096], + SIG_EP_OUTPUT: [5, 25, 32, 4096], + SIG_EP_PROFILE: 49246, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "light.ikea_of_sweden_tradfri_bulb_e26_w_opal_1000lm_77665544_level_on_off" ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - "channels": ["level", "on_off"], - "entity_class": "Light", - "entity_id": "light.ikea_of_sweden_tradfri_bulb_e26_w_opal_1000lm_77665544_level_on_off", + DEV_SIG_CHANNELS: ["level", "on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.ikea_of_sweden_tradfri_bulb_e26_w_opal_1000lm_77665544_level_on_off", } }, - "event_channels": ["1:0x0005", "1:0x0019"], - "manufacturer": "IKEA of Sweden", - "model": "TRADFRI bulb E26 W opal 1000lm", - "node_descriptor": b"\x01@\x8e|\x11RR\x00\x00\x00R\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019"], + SIG_MANUFACTURER: "IKEA of Sweden", + SIG_MODEL: "TRADFRI bulb E26 W opal 1000lm", + SIG_NODE_DESC: b"\x01@\x8e|\x11RR\x00\x00\x00R\x00\x00", }, { - "device_no": 20, - "endpoints": { + DEV_SIG_DEV_NO: 20, + SIG_ENDPOINTS: { 1: { - "device_type": 544, - "endpoint_id": 1, - "in_clusters": [0, 3, 4, 5, 6, 8, 768, 2821, 4096], - "out_clusters": [5, 25, 32, 4096], - "profile_id": 49246, + SIG_EP_TYPE: 544, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 768, 2821, 4096], + SIG_EP_OUTPUT: [5, 25, 32, 4096], + SIG_EP_PROFILE: 49246, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "light.ikea_of_sweden_tradfri_bulb_e26_ws_opal_980lm_77665544_level_light_color_on_off" ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - "channels": ["level", "light_color", "on_off"], - "entity_class": "Light", - "entity_id": "light.ikea_of_sweden_tradfri_bulb_e26_ws_opal_980lm_77665544_level_light_color_on_off", + DEV_SIG_CHANNELS: ["level", "light_color", "on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.ikea_of_sweden_tradfri_bulb_e26_ws_opal_980lm_77665544_level_light_color_on_off", } }, - "event_channels": ["1:0x0005", "1:0x0019"], - "manufacturer": "IKEA of Sweden", - "model": "TRADFRI bulb E26 WS opal 980lm", - "node_descriptor": b"\x01@\x8e|\x11RR\x00\x00\x00R\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019"], + SIG_MANUFACTURER: "IKEA of Sweden", + SIG_MODEL: "TRADFRI bulb E26 WS opal 980lm", + SIG_NODE_DESC: b"\x01@\x8e|\x11RR\x00\x00\x00R\x00\x00", }, { - "device_no": 21, - "endpoints": { + DEV_SIG_DEV_NO: 21, + SIG_ENDPOINTS: { 1: { - "device_type": 256, - "endpoint_id": 1, - "in_clusters": [0, 3, 4, 5, 6, 8, 2821, 4096], - "out_clusters": [5, 25, 32, 4096], - "profile_id": 260, + SIG_EP_TYPE: 256, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 2821, 4096], + SIG_EP_OUTPUT: [5, 25, 32, 4096], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "light.ikea_of_sweden_tradfri_bulb_e26_opal_1000lm_77665544_level_on_off" ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - "channels": ["level", "on_off"], - "entity_class": "Light", - "entity_id": "light.ikea_of_sweden_tradfri_bulb_e26_opal_1000lm_77665544_level_on_off", + DEV_SIG_CHANNELS: ["level", "on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.ikea_of_sweden_tradfri_bulb_e26_opal_1000lm_77665544_level_on_off", } }, - "event_channels": ["1:0x0005", "1:0x0019"], - "manufacturer": "IKEA of Sweden", - "model": "TRADFRI bulb E26 opal 1000lm", - "node_descriptor": b"\x01@\x8e|\x11RR\x00\x00\x00R\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019"], + SIG_MANUFACTURER: "IKEA of Sweden", + SIG_MODEL: "TRADFRI bulb E26 opal 1000lm", + SIG_NODE_DESC: b"\x01@\x8e|\x11RR\x00\x00\x00R\x00\x00", }, { - "device_no": 22, - "endpoints": { + DEV_SIG_DEV_NO: 22, + SIG_ENDPOINTS: { 1: { - "device_type": 266, - "endpoint_id": 1, - "in_clusters": [0, 3, 4, 5, 6, 64636], - "out_clusters": [5, 25, 32], - "profile_id": 260, + SIG_EP_TYPE: 266, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 64636], + SIG_EP_OUTPUT: [5, 25, 32], + SIG_EP_PROFILE: 260, } }, - "entities": ["switch.ikea_of_sweden_tradfri_control_outlet_77665544_on_off"], - "entity_map": { + DEV_SIG_ENTITIES: [ + "switch.ikea_of_sweden_tradfri_control_outlet_77665544_on_off" + ], + DEV_SIG_ENT_MAP: { ("switch", "00:11:22:33:44:55:66:77-1"): { - "channels": ["on_off"], - "entity_class": "Switch", - "entity_id": "switch.ikea_of_sweden_tradfri_control_outlet_77665544_on_off", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.ikea_of_sweden_tradfri_control_outlet_77665544_on_off", } }, - "event_channels": ["1:0x0005", "1:0x0019"], - "manufacturer": "IKEA of Sweden", - "model": "TRADFRI control outlet", - "node_descriptor": b"\x01@\x8e|\x11RR\x00\x00,R\x00\x00", - "zha_quirks": "TradfriPlug", + DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019"], + SIG_MANUFACTURER: "IKEA of Sweden", + SIG_MODEL: "TRADFRI control outlet", + SIG_NODE_DESC: b"\x01@\x8e|\x11RR\x00\x00,R\x00\x00", + DEV_SIG_ZHA_QUIRK: "TradfriPlug", }, { - "device_no": 23, - "endpoints": { + DEV_SIG_DEV_NO: 23, + SIG_ENDPOINTS: { 1: { - "device_type": 2128, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 9, 2821, 4096], - "out_clusters": [3, 4, 6, 25, 4096], - "profile_id": 49246, + SIG_EP_TYPE: 2128, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 9, 2821, 4096], + SIG_EP_OUTPUT: [3, 4, 6, 25, 4096], + SIG_EP_PROFILE: 49246, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "binary_sensor.ikea_of_sweden_tradfri_motion_sensor_77665544_on_off", "sensor.ikea_of_sweden_tradfri_motion_sensor_77665544_power", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.ikea_of_sweden_tradfri_motion_sensor_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_motion_sensor_77665544_power", }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-6"): { - "channels": ["on_off"], - "entity_class": "Motion", - "entity_id": "binary_sensor.ikea_of_sweden_tradfri_motion_sensor_77665544_on_off", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Motion", + DEV_SIG_ENT_MAP_ID: "binary_sensor.ikea_of_sweden_tradfri_motion_sensor_77665544_on_off", }, }, - "event_channels": ["1:0x0006", "1:0x0019"], - "manufacturer": "IKEA of Sweden", - "model": "TRADFRI motion sensor", - "node_descriptor": b"\x02@\x80|\x11RR\x00\x00\x00R\x00\x00", - "zha_quirks": "IkeaTradfriMotion", + DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0019"], + SIG_MANUFACTURER: "IKEA of Sweden", + SIG_MODEL: "TRADFRI motion sensor", + SIG_NODE_DESC: b"\x02@\x80|\x11RR\x00\x00\x00R\x00\x00", + DEV_SIG_ZHA_QUIRK: "IkeaTradfriMotion", }, { - "device_no": 24, - "endpoints": { + DEV_SIG_DEV_NO: 24, + SIG_ENDPOINTS: { 1: { - "device_type": 2080, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 9, 32, 4096, 64636], - "out_clusters": [3, 4, 6, 8, 25, 258, 4096], - "profile_id": 260, + SIG_EP_TYPE: 2080, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 9, 32, 4096, 64636], + SIG_EP_OUTPUT: [3, 4, 6, 8, 25, 258, 4096], + SIG_EP_PROFILE: 260, } }, - "entities": ["sensor.ikea_of_sweden_tradfri_on_off_switch_77665544_power"], - "entity_map": { + DEV_SIG_ENTITIES: [ + "sensor.ikea_of_sweden_tradfri_on_off_switch_77665544_power" + ], + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.ikea_of_sweden_tradfri_on_off_switch_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_on_off_switch_77665544_power", } }, - "event_channels": ["1:0x0006", "1:0x0008", "1:0x0019", "1:0x0102"], - "manufacturer": "IKEA of Sweden", - "model": "TRADFRI on/off switch", - "node_descriptor": b"\x02@\x80|\x11RR\x00\x00,R\x00\x00", - "zha_quirks": "IkeaTradfriRemote2Btn", + DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0019", "1:0x0102"], + SIG_MANUFACTURER: "IKEA of Sweden", + SIG_MODEL: "TRADFRI on/off switch", + SIG_NODE_DESC: b"\x02@\x80|\x11RR\x00\x00,R\x00\x00", + DEV_SIG_ZHA_QUIRK: "IkeaTradfriRemote2Btn", }, { - "device_no": 25, - "endpoints": { + DEV_SIG_DEV_NO: 25, + SIG_ENDPOINTS: { 1: { - "device_type": 2096, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 9, 2821, 4096], - "out_clusters": [3, 4, 5, 6, 8, 25, 4096], - "profile_id": 49246, + SIG_EP_TYPE: 2096, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 9, 2821, 4096], + SIG_EP_OUTPUT: [3, 4, 5, 6, 8, 25, 4096], + SIG_EP_PROFILE: 49246, } }, - "entities": ["sensor.ikea_of_sweden_tradfri_remote_control_77665544_power"], - "entity_map": { + DEV_SIG_ENTITIES: [ + "sensor.ikea_of_sweden_tradfri_remote_control_77665544_power" + ], + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.ikea_of_sweden_tradfri_remote_control_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_remote_control_77665544_power", } }, - "event_channels": ["1:0x0005", "1:0x0006", "1:0x0008", "1:0x0019"], - "manufacturer": "IKEA of Sweden", - "model": "TRADFRI remote control", - "node_descriptor": b"\x02@\x80|\x11RR\x00\x00\x00R\x00\x00", - "zha_quirks": "IkeaTradfriRemote", + DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0006", "1:0x0008", "1:0x0019"], + SIG_MANUFACTURER: "IKEA of Sweden", + SIG_MODEL: "TRADFRI remote control", + SIG_NODE_DESC: b"\x02@\x80|\x11RR\x00\x00\x00R\x00\x00", + DEV_SIG_ZHA_QUIRK: "IkeaTradfriRemote", }, { - "device_no": 26, - "endpoints": { + DEV_SIG_DEV_NO: 26, + SIG_ENDPOINTS: { 1: { - "device_type": 8, - "endpoint_id": 1, - "in_clusters": [0, 3, 9, 2821, 4096, 64636], - "out_clusters": [25, 32, 4096], - "profile_id": 260, + SIG_EP_TYPE: 8, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 9, 2821, 4096, 64636], + SIG_EP_OUTPUT: [25, 32, 4096], + SIG_EP_PROFILE: 260, }, 242: { - "device_type": 97, - "endpoint_id": 242, - "in_clusters": [33], - "out_clusters": [33], - "profile_id": 41440, + SIG_EP_TYPE: 97, + DEV_SIG_EP_ID: 242, + SIG_EP_INPUT: [33], + SIG_EP_OUTPUT: [33], + SIG_EP_PROFILE: 41440, }, }, - "entities": [], - "entity_map": {}, - "event_channels": ["1:0x0019"], - "manufacturer": "IKEA of Sweden", - "model": "TRADFRI signal repeater", - "node_descriptor": b"\x01@\x8e|\x11RR\x00\x00,R\x00\x00", + DEV_SIG_ENTITIES: [], + DEV_SIG_ENT_MAP: {}, + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "IKEA of Sweden", + SIG_MODEL: "TRADFRI signal repeater", + SIG_NODE_DESC: b"\x01@\x8e|\x11RR\x00\x00,R\x00\x00", }, { - "device_no": 27, - "endpoints": { + DEV_SIG_DEV_NO: 27, + SIG_ENDPOINTS: { 1: { - "device_type": 2064, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 9, 2821, 4096], - "out_clusters": [3, 4, 6, 8, 25, 4096], - "profile_id": 260, + SIG_EP_TYPE: 2064, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 9, 2821, 4096], + SIG_EP_OUTPUT: [3, 4, 6, 8, 25, 4096], + SIG_EP_PROFILE: 260, } }, - "entities": ["sensor.ikea_of_sweden_tradfri_wireless_dimmer_77665544_power"], - "entity_map": { + DEV_SIG_ENTITIES: [ + "sensor.ikea_of_sweden_tradfri_wireless_dimmer_77665544_power" + ], + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.ikea_of_sweden_tradfri_wireless_dimmer_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_wireless_dimmer_77665544_power", } }, - "event_channels": ["1:0x0006", "1:0x0008", "1:0x0019"], - "manufacturer": "IKEA of Sweden", - "model": "TRADFRI wireless dimmer", - "node_descriptor": b"\x02@\x80|\x11RR\x00\x00\x00R\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0019"], + SIG_MANUFACTURER: "IKEA of Sweden", + SIG_MODEL: "TRADFRI wireless dimmer", + SIG_NODE_DESC: b"\x02@\x80|\x11RR\x00\x00\x00R\x00\x00", }, { - "device_no": 28, - "endpoints": { + DEV_SIG_DEV_NO: 28, + SIG_ENDPOINTS: { 1: { - "device_type": 257, - "endpoint_id": 1, - "in_clusters": [0, 3, 4, 5, 6, 8, 1794, 2821], - "out_clusters": [10, 25], - "profile_id": 260, + SIG_EP_TYPE: 257, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 1794, 2821], + SIG_EP_OUTPUT: [10, 25], + SIG_EP_PROFILE: 260, }, 2: { - "device_type": 260, - "endpoint_id": 2, - "in_clusters": [0, 3, 2821], - "out_clusters": [3, 6, 8], - "profile_id": 260, + SIG_EP_TYPE: 260, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [0, 3, 2821], + SIG_EP_OUTPUT: [3, 6, 8], + SIG_EP_PROFILE: 260, }, }, - "entities": [ + DEV_SIG_ENTITIES: [ "light.jasco_products_45852_77665544_level_on_off", "sensor.jasco_products_45852_77665544_smartenergy_metering", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - "channels": ["level", "on_off"], - "entity_class": "Light", - "entity_id": "light.jasco_products_45852_77665544_level_on_off", + DEV_SIG_CHANNELS: ["level", "on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.jasco_products_45852_77665544_level_on_off", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { - "channels": ["smartenergy_metering"], - "entity_class": "SmartEnergyMetering", - "entity_id": "sensor.jasco_products_45852_77665544_smartenergy_metering", + DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", + DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45852_77665544_smartenergy_metering", }, }, - "event_channels": ["1:0x0019", "2:0x0006", "2:0x0008"], - "manufacturer": "Jasco Products", - "model": "45852", - "node_descriptor": b"\x01@\x8e$\x11R\xff\x00\x00\x00\xff\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0019", "2:0x0006", "2:0x0008"], + SIG_MANUFACTURER: "Jasco Products", + SIG_MODEL: "45852", + SIG_NODE_DESC: b"\x01@\x8e$\x11R\xff\x00\x00\x00\xff\x00\x00", }, { - "device_no": 29, - "endpoints": { + DEV_SIG_DEV_NO: 29, + SIG_ENDPOINTS: { 1: { - "device_type": 256, - "endpoint_id": 1, - "in_clusters": [0, 3, 4, 5, 6, 1794, 2821], - "out_clusters": [10, 25], - "profile_id": 260, + SIG_EP_TYPE: 256, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 1794, 2821], + SIG_EP_OUTPUT: [10, 25], + SIG_EP_PROFILE: 260, }, 2: { - "device_type": 259, - "endpoint_id": 2, - "in_clusters": [0, 3, 2821], - "out_clusters": [3, 6], - "profile_id": 260, + SIG_EP_TYPE: 259, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [0, 3, 2821], + SIG_EP_OUTPUT: [3, 6], + SIG_EP_PROFILE: 260, }, }, - "entities": [ + DEV_SIG_ENTITIES: [ "light.jasco_products_45856_77665544_on_off", "sensor.jasco_products_45856_77665544_smartenergy_metering", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - "channels": ["on_off"], - "entity_class": "Light", - "entity_id": "light.jasco_products_45856_77665544_on_off", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.jasco_products_45856_77665544_on_off", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { - "channels": ["smartenergy_metering"], - "entity_class": "SmartEnergyMetering", - "entity_id": "sensor.jasco_products_45856_77665544_smartenergy_metering", + DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", + DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45856_77665544_smartenergy_metering", }, }, - "event_channels": ["1:0x0019", "2:0x0006"], - "manufacturer": "Jasco Products", - "model": "45856", - "node_descriptor": b"\x01@\x8e$\x11R\xff\x00\x00\x00\xff\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0019", "2:0x0006"], + SIG_MANUFACTURER: "Jasco Products", + SIG_MODEL: "45856", + SIG_NODE_DESC: b"\x01@\x8e$\x11R\xff\x00\x00\x00\xff\x00\x00", }, { - "device_no": 30, - "endpoints": { + DEV_SIG_DEV_NO: 30, + SIG_ENDPOINTS: { 1: { - "device_type": 257, - "endpoint_id": 1, - "in_clusters": [0, 3, 4, 5, 6, 8, 1794, 2821], - "out_clusters": [10, 25], - "profile_id": 260, + SIG_EP_TYPE: 257, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 1794, 2821], + SIG_EP_OUTPUT: [10, 25], + SIG_EP_PROFILE: 260, }, 2: { - "device_type": 260, - "endpoint_id": 2, - "in_clusters": [0, 3, 2821], - "out_clusters": [3, 6, 8], - "profile_id": 260, + SIG_EP_TYPE: 260, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [0, 3, 2821], + SIG_EP_OUTPUT: [3, 6, 8], + SIG_EP_PROFILE: 260, }, }, - "entities": [ + DEV_SIG_ENTITIES: [ "light.jasco_products_45857_77665544_level_on_off", "sensor.jasco_products_45857_77665544_smartenergy_metering", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - "channels": ["level", "on_off"], - "entity_class": "Light", - "entity_id": "light.jasco_products_45857_77665544_level_on_off", + DEV_SIG_CHANNELS: ["level", "on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.jasco_products_45857_77665544_level_on_off", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { - "channels": ["smartenergy_metering"], - "entity_class": "SmartEnergyMetering", - "entity_id": "sensor.jasco_products_45857_77665544_smartenergy_metering", + DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", + DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45857_77665544_smartenergy_metering", }, }, - "event_channels": ["1:0x0019", "2:0x0006", "2:0x0008"], - "manufacturer": "Jasco Products", - "model": "45857", - "node_descriptor": b"\x01@\x8e$\x11R\xff\x00\x00\x00\xff\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0019", "2:0x0006", "2:0x0008"], + SIG_MANUFACTURER: "Jasco Products", + SIG_MODEL: "45857", + SIG_NODE_DESC: b"\x01@\x8e$\x11R\xff\x00\x00\x00\xff\x00\x00", }, { - "device_no": 31, - "endpoints": { + DEV_SIG_DEV_NO: 31, + SIG_ENDPOINTS: { 1: { - "device_type": 3, - "endpoint_id": 1, - "in_clusters": [ + SIG_EP_TYPE: 3, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [ 0, 1, 3, @@ -1031,50 +1060,50 @@ DEVICES = [ 64513, 64514, ], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "cover.keen_home_inc_sv02_610_mp_1_3_77665544_level_on_off", "sensor.keen_home_inc_sv02_610_mp_1_3_77665544_power", "sensor.keen_home_inc_sv02_610_mp_1_3_77665544_pressure", "sensor.keen_home_inc_sv02_610_mp_1_3_77665544_temperature", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("cover", "00:11:22:33:44:55:66:77-1"): { - "channels": ["level", "on_off"], - "entity_class": "KeenVent", - "entity_id": "cover.keen_home_inc_sv02_610_mp_1_3_77665544_level_on_off", + DEV_SIG_CHANNELS: ["level", "on_off"], + DEV_SIG_ENT_MAP_CLASS: "KeenVent", + DEV_SIG_ENT_MAP_ID: "cover.keen_home_inc_sv02_610_mp_1_3_77665544_level_on_off", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.keen_home_inc_sv02_610_mp_1_3_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_610_mp_1_3_77665544_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { - "channels": ["temperature"], - "entity_class": "Temperature", - "entity_id": "sensor.keen_home_inc_sv02_610_mp_1_3_77665544_temperature", + DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_610_mp_1_3_77665544_temperature", }, ("sensor", "00:11:22:33:44:55:66:77-1-1027"): { - "channels": ["pressure"], - "entity_class": "Pressure", - "entity_id": "sensor.keen_home_inc_sv02_610_mp_1_3_77665544_pressure", + DEV_SIG_CHANNELS: ["pressure"], + DEV_SIG_ENT_MAP_CLASS: "Pressure", + DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_610_mp_1_3_77665544_pressure", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "Keen Home Inc", - "model": "SV02-610-MP-1.3", - "node_descriptor": b"\x02@\x80[\x11RR\x00\x00*R\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "Keen Home Inc", + SIG_MODEL: "SV02-610-MP-1.3", + SIG_NODE_DESC: b"\x02@\x80[\x11RR\x00\x00*R\x00\x00", }, { - "device_no": 32, - "endpoints": { + DEV_SIG_DEV_NO: 32, + SIG_ENDPOINTS: { 1: { - "device_type": 3, - "endpoint_id": 1, - "in_clusters": [ + SIG_EP_TYPE: 3, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [ 0, 1, 3, @@ -1089,50 +1118,50 @@ DEVICES = [ 64513, 64514, ], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "cover.keen_home_inc_sv02_612_mp_1_2_77665544_level_on_off", "sensor.keen_home_inc_sv02_612_mp_1_2_77665544_power", "sensor.keen_home_inc_sv02_612_mp_1_2_77665544_pressure", "sensor.keen_home_inc_sv02_612_mp_1_2_77665544_temperature", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("cover", "00:11:22:33:44:55:66:77-1"): { - "channels": ["level", "on_off"], - "entity_class": "KeenVent", - "entity_id": "cover.keen_home_inc_sv02_612_mp_1_2_77665544_level_on_off", + DEV_SIG_CHANNELS: ["level", "on_off"], + DEV_SIG_ENT_MAP_CLASS: "KeenVent", + DEV_SIG_ENT_MAP_ID: "cover.keen_home_inc_sv02_612_mp_1_2_77665544_level_on_off", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.keen_home_inc_sv02_612_mp_1_2_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_612_mp_1_2_77665544_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { - "channels": ["temperature"], - "entity_class": "Temperature", - "entity_id": "sensor.keen_home_inc_sv02_612_mp_1_2_77665544_temperature", + DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_612_mp_1_2_77665544_temperature", }, ("sensor", "00:11:22:33:44:55:66:77-1-1027"): { - "channels": ["pressure"], - "entity_class": "Pressure", - "entity_id": "sensor.keen_home_inc_sv02_612_mp_1_2_77665544_pressure", + DEV_SIG_CHANNELS: ["pressure"], + DEV_SIG_ENT_MAP_CLASS: "Pressure", + DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_612_mp_1_2_77665544_pressure", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "Keen Home Inc", - "model": "SV02-612-MP-1.2", - "node_descriptor": b"\x02@\x80[\x11RR\x00\x00*R\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "Keen Home Inc", + SIG_MODEL: "SV02-612-MP-1.2", + SIG_NODE_DESC: b"\x02@\x80[\x11RR\x00\x00*R\x00\x00", }, { - "device_no": 33, - "endpoints": { + DEV_SIG_DEV_NO: 33, + SIG_ENDPOINTS: { 1: { - "device_type": 3, - "endpoint_id": 1, - "in_clusters": [ + SIG_EP_TYPE: 3, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [ 0, 1, 3, @@ -1147,1468 +1176,1472 @@ DEVICES = [ 64513, 64514, ], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "cover.keen_home_inc_sv02_612_mp_1_3_77665544_level_on_off", "sensor.keen_home_inc_sv02_612_mp_1_3_77665544_power", "sensor.keen_home_inc_sv02_612_mp_1_3_77665544_pressure", "sensor.keen_home_inc_sv02_612_mp_1_3_77665544_temperature", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("cover", "00:11:22:33:44:55:66:77-1"): { - "channels": ["level", "on_off"], - "entity_class": "KeenVent", - "entity_id": "cover.keen_home_inc_sv02_612_mp_1_3_77665544_level_on_off", + DEV_SIG_CHANNELS: ["level", "on_off"], + DEV_SIG_ENT_MAP_CLASS: "KeenVent", + DEV_SIG_ENT_MAP_ID: "cover.keen_home_inc_sv02_612_mp_1_3_77665544_level_on_off", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.keen_home_inc_sv02_612_mp_1_3_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_612_mp_1_3_77665544_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { - "channels": ["temperature"], - "entity_class": "Temperature", - "entity_id": "sensor.keen_home_inc_sv02_612_mp_1_3_77665544_temperature", + DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_612_mp_1_3_77665544_temperature", }, ("sensor", "00:11:22:33:44:55:66:77-1-1027"): { - "channels": ["pressure"], - "entity_class": "Pressure", - "entity_id": "sensor.keen_home_inc_sv02_612_mp_1_3_77665544_pressure", + DEV_SIG_CHANNELS: ["pressure"], + DEV_SIG_ENT_MAP_CLASS: "Pressure", + DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_612_mp_1_3_77665544_pressure", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "Keen Home Inc", - "model": "SV02-612-MP-1.3", - "node_descriptor": b"\x02@\x80[\x11RR\x00\x00*R\x00\x00", - "zha_quirks": "KeenHomeSmartVent", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "Keen Home Inc", + SIG_MODEL: "SV02-612-MP-1.3", + SIG_NODE_DESC: b"\x02@\x80[\x11RR\x00\x00*R\x00\x00", + DEV_SIG_ZHA_QUIRK: "KeenHomeSmartVent", }, { - "device_no": 34, - "endpoints": { + DEV_SIG_DEV_NO: 34, + SIG_ENDPOINTS: { 1: { - "device_type": 257, - "endpoint_id": 1, - "in_clusters": [0, 3, 4, 5, 6, 8, 514], - "out_clusters": [3, 25], - "profile_id": 260, + SIG_EP_TYPE: 257, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 514], + SIG_EP_OUTPUT: [3, 25], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "fan.king_of_fans_inc_hbuniversalcfremote_77665544_fan", "light.king_of_fans_inc_hbuniversalcfremote_77665544_level_on_off", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - "channels": ["level", "on_off"], - "entity_class": "Light", - "entity_id": "light.king_of_fans_inc_hbuniversalcfremote_77665544_level_on_off", + DEV_SIG_CHANNELS: ["level", "on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.king_of_fans_inc_hbuniversalcfremote_77665544_level_on_off", }, ("fan", "00:11:22:33:44:55:66:77-1-514"): { - "channels": ["fan"], - "entity_class": "ZhaFan", - "entity_id": "fan.king_of_fans_inc_hbuniversalcfremote_77665544_fan", + DEV_SIG_CHANNELS: ["fan"], + DEV_SIG_ENT_MAP_CLASS: "ZhaFan", + DEV_SIG_ENT_MAP_ID: "fan.king_of_fans_inc_hbuniversalcfremote_77665544_fan", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "King Of Fans, Inc.", - "model": "HBUniversalCFRemote", - "node_descriptor": b"\x02@\x8c\x02\x10RR\x00\x00\x00R\x00\x00", - "zha_quirks": "CeilingFan", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "King Of Fans, Inc.", + SIG_MODEL: "HBUniversalCFRemote", + SIG_NODE_DESC: b"\x02@\x8c\x02\x10RR\x00\x00\x00R\x00\x00", + DEV_SIG_ZHA_QUIRK: "CeilingFan", }, { - "device_no": 35, - "endpoints": { + DEV_SIG_DEV_NO: 35, + SIG_ENDPOINTS: { 1: { - "device_type": 2048, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 4096, 64769], - "out_clusters": [3, 4, 6, 8, 25, 768, 4096], - "profile_id": 260, + SIG_EP_TYPE: 2048, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 4096, 64769], + SIG_EP_OUTPUT: [3, 4, 6, 8, 25, 768, 4096], + SIG_EP_PROFILE: 260, } }, - "entities": ["sensor.lds_zbt_cctswitch_d0001_77665544_power"], - "entity_map": { + DEV_SIG_ENTITIES: ["sensor.lds_zbt_cctswitch_d0001_77665544_power"], + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.lds_zbt_cctswitch_d0001_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lds_zbt_cctswitch_d0001_77665544_power", } }, - "event_channels": ["1:0x0006", "1:0x0008", "1:0x0019", "1:0x0300"], - "manufacturer": "LDS", - "model": "ZBT-CCTSwitch-D0001", - "node_descriptor": b"\x02@\x80h\x11RR\x00\x00,R\x00\x00", - "zha_quirks": "CCTSwitch", + DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0019", "1:0x0300"], + SIG_MANUFACTURER: "LDS", + SIG_MODEL: "ZBT-CCTSwitch-D0001", + SIG_NODE_DESC: b"\x02@\x80h\x11RR\x00\x00,R\x00\x00", + DEV_SIG_ZHA_QUIRK: "CCTSwitch", }, { - "device_no": 36, - "endpoints": { + DEV_SIG_DEV_NO: 36, + SIG_ENDPOINTS: { 1: { - "device_type": 258, - "endpoint_id": 1, - "in_clusters": [0, 3, 4, 5, 6, 8, 768, 2821, 64513], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_TYPE: 258, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 768, 2821, 64513], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, } }, - "entities": ["light.ledvance_a19_rgbw_77665544_level_light_color_on_off"], - "entity_map": { + DEV_SIG_ENTITIES: ["light.ledvance_a19_rgbw_77665544_level_light_color_on_off"], + DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - "channels": ["level", "light_color", "on_off"], - "entity_class": "Light", - "entity_id": "light.ledvance_a19_rgbw_77665544_level_light_color_on_off", + DEV_SIG_CHANNELS: ["level", "light_color", "on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.ledvance_a19_rgbw_77665544_level_light_color_on_off", } }, - "event_channels": ["1:0x0019"], - "manufacturer": "LEDVANCE", - "model": "A19 RGBW", - "node_descriptor": b"\x01@\x8e\x89\x11RR\x00\x00\x00R\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "LEDVANCE", + SIG_MODEL: "A19 RGBW", + SIG_NODE_DESC: b"\x01@\x8e\x89\x11RR\x00\x00\x00R\x00\x00", }, { - "device_no": 37, - "endpoints": { + DEV_SIG_DEV_NO: 37, + SIG_ENDPOINTS: { 1: { - "device_type": 258, - "endpoint_id": 1, - "in_clusters": [0, 3, 4, 5, 6, 8, 768, 2821, 64513], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_TYPE: 258, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 768, 2821, 64513], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, } }, - "entities": ["light.ledvance_flex_rgbw_77665544_level_light_color_on_off"], - "entity_map": { + DEV_SIG_ENTITIES: [ + "light.ledvance_flex_rgbw_77665544_level_light_color_on_off" + ], + DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - "channels": ["level", "light_color", "on_off"], - "entity_class": "Light", - "entity_id": "light.ledvance_flex_rgbw_77665544_level_light_color_on_off", + DEV_SIG_CHANNELS: ["level", "light_color", "on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.ledvance_flex_rgbw_77665544_level_light_color_on_off", } }, - "event_channels": ["1:0x0019"], - "manufacturer": "LEDVANCE", - "model": "FLEX RGBW", - "node_descriptor": b"\x01@\x8e\x89\x11RR\x00\x00\x00R\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "LEDVANCE", + SIG_MODEL: "FLEX RGBW", + SIG_NODE_DESC: b"\x01@\x8e\x89\x11RR\x00\x00\x00R\x00\x00", }, { - "device_no": 38, - "endpoints": { + DEV_SIG_DEV_NO: 38, + SIG_ENDPOINTS: { 1: { - "device_type": 81, - "endpoint_id": 1, - "in_clusters": [0, 3, 4, 5, 6, 2821, 64513, 64520], - "out_clusters": [3, 25], - "profile_id": 260, + SIG_EP_TYPE: 81, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 2821, 64513, 64520], + SIG_EP_OUTPUT: [3, 25], + SIG_EP_PROFILE: 260, } }, - "entities": ["switch.ledvance_plug_77665544_on_off"], - "entity_map": { + DEV_SIG_ENTITIES: ["switch.ledvance_plug_77665544_on_off"], + DEV_SIG_ENT_MAP: { ("switch", "00:11:22:33:44:55:66:77-1"): { - "channels": ["on_off"], - "entity_class": "Switch", - "entity_id": "switch.ledvance_plug_77665544_on_off", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.ledvance_plug_77665544_on_off", } }, - "event_channels": ["1:0x0019"], - "manufacturer": "LEDVANCE", - "model": "PLUG", - "node_descriptor": b"\x01@\x8e\x89\x11RR\x00\x00\x00R\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "LEDVANCE", + SIG_MODEL: "PLUG", + SIG_NODE_DESC: b"\x01@\x8e\x89\x11RR\x00\x00\x00R\x00\x00", }, { - "device_no": 39, - "endpoints": { + DEV_SIG_DEV_NO: 39, + SIG_ENDPOINTS: { 1: { - "device_type": 258, - "endpoint_id": 1, - "in_clusters": [0, 3, 4, 5, 6, 8, 768, 2821, 64513], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_TYPE: 258, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 768, 2821, 64513], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, } }, - "entities": ["light.ledvance_rt_rgbw_77665544_level_light_color_on_off"], - "entity_map": { + DEV_SIG_ENTITIES: ["light.ledvance_rt_rgbw_77665544_level_light_color_on_off"], + DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - "channels": ["level", "light_color", "on_off"], - "entity_class": "Light", - "entity_id": "light.ledvance_rt_rgbw_77665544_level_light_color_on_off", + DEV_SIG_CHANNELS: ["level", "light_color", "on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.ledvance_rt_rgbw_77665544_level_light_color_on_off", } }, - "event_channels": ["1:0x0019"], - "manufacturer": "LEDVANCE", - "model": "RT RGBW", - "node_descriptor": b"\x01@\x8e\x89\x11RR\x00\x00\x00R\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "LEDVANCE", + SIG_MODEL: "RT RGBW", + SIG_NODE_DESC: b"\x01@\x8e\x89\x11RR\x00\x00\x00R\x00\x00", }, { - "device_no": 40, - "endpoints": { + DEV_SIG_DEV_NO: 40, + SIG_ENDPOINTS: { 1: { - "device_type": 81, - "endpoint_id": 1, - "in_clusters": [0, 1, 2, 3, 4, 5, 6, 10, 16, 2820], - "out_clusters": [10, 25], - "profile_id": 260, + SIG_EP_TYPE: 81, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 2, 3, 4, 5, 6, 10, 16, 2820], + SIG_EP_OUTPUT: [10, 25], + SIG_EP_PROFILE: 260, }, 2: { - "device_type": 9, - "endpoint_id": 2, - "in_clusters": [12], - "out_clusters": [4, 12], - "profile_id": 260, + SIG_EP_TYPE: 9, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [12], + SIG_EP_OUTPUT: [4, 12], + SIG_EP_PROFILE: 260, }, 3: { - "device_type": 83, - "endpoint_id": 3, - "in_clusters": [12], - "out_clusters": [12], - "profile_id": 260, + SIG_EP_TYPE: 83, + DEV_SIG_EP_ID: 3, + SIG_EP_INPUT: [12], + SIG_EP_OUTPUT: [12], + SIG_EP_PROFILE: 260, }, 100: { - "device_type": 263, - "endpoint_id": 100, - "in_clusters": [15], - "out_clusters": [4, 15], - "profile_id": 260, + SIG_EP_TYPE: 263, + DEV_SIG_EP_ID: 100, + SIG_EP_INPUT: [15], + SIG_EP_OUTPUT: [4, 15], + SIG_EP_PROFILE: 260, }, }, - "entities": [ + DEV_SIG_ENTITIES: [ "sensor.lumi_lumi_plug_maus01_77665544_analog_input", "sensor.lumi_lumi_plug_maus01_77665544_analog_input_2", "sensor.lumi_lumi_plug_maus01_77665544_electrical_measurement", "switch.lumi_lumi_plug_maus01_77665544_on_off", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-2-12"): { - "channels": ["analog_input"], - "entity_class": "AnalogInput", - "entity_id": "sensor.lumi_lumi_plug_maus01_77665544_analog_input", + DEV_SIG_CHANNELS: ["analog_input"], + DEV_SIG_ENT_MAP_CLASS: "AnalogInput", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_77665544_analog_input", }, ("sensor", "00:11:22:33:44:55:66:77-3-12"): { - "channels": ["analog_input"], - "entity_class": "AnalogInput", - "entity_id": "sensor.lumi_lumi_plug_maus01_77665544_analog_input_2", + DEV_SIG_CHANNELS: ["analog_input"], + DEV_SIG_ENT_MAP_CLASS: "AnalogInput", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_77665544_analog_input_2", }, ("switch", "00:11:22:33:44:55:66:77-1"): { - "channels": ["on_off"], - "entity_class": "Switch", - "entity_id": "switch.lumi_lumi_plug_maus01_77665544_on_off", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.lumi_lumi_plug_maus01_77665544_on_off", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { - "channels": ["electrical_measurement"], - "entity_class": "ElectricalMeasurement", - "entity_id": "sensor.lumi_lumi_plug_maus01_77665544_electrical_measurement", + DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_77665544_electrical_measurement", }, ("binary_sensor", "00:11:22:33:44:55:66:77-100-15"): { - "channels": ["binary_input"], - "entity_class": "BinaryInput", - "entity_id": "binary_sensor.lumi_lumi_plug_maus01_77665544_binary_input", + DEV_SIG_CHANNELS: ["binary_input"], + DEV_SIG_ENT_MAP_CLASS: "BinaryInput", + DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_plug_maus01_77665544_binary_input", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "LUMI", - "model": "lumi.plug.maus01", - "node_descriptor": b"\x01@\x8e_\x11\x7fd\x00\x00\x00d\x00\x00", - "zha_quirks": "Plug", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.plug.maus01", + SIG_NODE_DESC: b"\x01@\x8e_\x11\x7fd\x00\x00\x00d\x00\x00", + DEV_SIG_ZHA_QUIRK: "Plug", }, { - "device_no": 41, - "endpoints": { + DEV_SIG_DEV_NO: 41, + SIG_ENDPOINTS: { 1: { - "device_type": 257, - "endpoint_id": 1, - "in_clusters": [0, 1, 2, 3, 4, 5, 6, 10, 12, 16, 2820], - "out_clusters": [10, 25], - "profile_id": 260, + SIG_EP_TYPE: 257, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 2, 3, 4, 5, 6, 10, 12, 16, 2820], + SIG_EP_OUTPUT: [10, 25], + SIG_EP_PROFILE: 260, }, 2: { - "device_type": 257, - "endpoint_id": 2, - "in_clusters": [4, 5, 6, 16], - "out_clusters": [], - "profile_id": 260, + SIG_EP_TYPE: 257, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [4, 5, 6, 16], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 260, }, }, - "entities": [ + DEV_SIG_ENTITIES: [ "light.lumi_lumi_relay_c2acn01_77665544_on_off", "light.lumi_lumi_relay_c2acn01_77665544_on_off_2", "sensor.lumi_lumi_relay_c2acn01_77665544_electrical_measurement", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - "channels": ["on_off"], - "entity_class": "Light", - "entity_id": "light.lumi_lumi_relay_c2acn01_77665544_on_off", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.lumi_lumi_relay_c2acn01_77665544_on_off", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { - "channels": ["electrical_measurement"], - "entity_class": "ElectricalMeasurement", - "entity_id": "sensor.lumi_lumi_relay_c2acn01_77665544_electrical_measurement", + DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_77665544_electrical_measurement", }, ("light", "00:11:22:33:44:55:66:77-2"): { - "channels": ["on_off"], - "entity_class": "Light", - "entity_id": "light.lumi_lumi_relay_c2acn01_77665544_on_off_2", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.lumi_lumi_relay_c2acn01_77665544_on_off_2", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "LUMI", - "model": "lumi.relay.c2acn01", - "node_descriptor": b"\x01@\x8e7\x10\x7fd\x00\x00\x00d\x00\x00", - "zha_quirks": "Relay", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.relay.c2acn01", + SIG_NODE_DESC: b"\x01@\x8e7\x10\x7fd\x00\x00\x00d\x00\x00", + DEV_SIG_ZHA_QUIRK: "Relay", }, { - "device_no": 42, - "endpoints": { + DEV_SIG_DEV_NO: 42, + SIG_ENDPOINTS: { 1: { - "device_type": 24321, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 18, 25, 65535], - "out_clusters": [0, 3, 4, 5, 18, 25, 65535], - "profile_id": 260, + SIG_EP_TYPE: 24321, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 18, 25, 65535], + SIG_EP_OUTPUT: [0, 3, 4, 5, 18, 25, 65535], + SIG_EP_PROFILE: 260, }, 2: { - "device_type": 24322, - "endpoint_id": 2, - "in_clusters": [3, 18], - "out_clusters": [3, 4, 5, 18], - "profile_id": 260, + SIG_EP_TYPE: 24322, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [3, 18], + SIG_EP_OUTPUT: [3, 4, 5, 18], + SIG_EP_PROFILE: 260, }, 3: { - "device_type": 24323, - "endpoint_id": 3, - "in_clusters": [3, 18], - "out_clusters": [3, 4, 5, 12, 18], - "profile_id": 260, + SIG_EP_TYPE: 24323, + DEV_SIG_EP_ID: 3, + SIG_EP_INPUT: [3, 18], + SIG_EP_OUTPUT: [3, 4, 5, 12, 18], + SIG_EP_PROFILE: 260, }, }, - "entities": ["sensor.lumi_lumi_remote_b186acn01_77665544_power"], - "entity_map": { + DEV_SIG_ENTITIES: ["sensor.lumi_lumi_remote_b186acn01_77665544_power"], + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.lumi_lumi_remote_b186acn01_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b186acn01_77665544_power", }, }, - "event_channels": ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"], - "manufacturer": "LUMI", - "model": "lumi.remote.b186acn01", - "node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", - "zha_quirks": "RemoteB186ACN01", + DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"], + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.remote.b186acn01", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", + DEV_SIG_ZHA_QUIRK: "RemoteB186ACN01", }, { - "device_no": 43, - "endpoints": { + DEV_SIG_DEV_NO: 43, + SIG_ENDPOINTS: { 1: { - "device_type": 24321, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 18, 25, 65535], - "out_clusters": [0, 3, 4, 5, 18, 25, 65535], - "profile_id": 260, + SIG_EP_TYPE: 24321, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 18, 25, 65535], + SIG_EP_OUTPUT: [0, 3, 4, 5, 18, 25, 65535], + SIG_EP_PROFILE: 260, }, 2: { - "device_type": 24322, - "endpoint_id": 2, - "in_clusters": [3, 18], - "out_clusters": [3, 4, 5, 18], - "profile_id": 260, + SIG_EP_TYPE: 24322, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [3, 18], + SIG_EP_OUTPUT: [3, 4, 5, 18], + SIG_EP_PROFILE: 260, }, 3: { - "device_type": 24323, - "endpoint_id": 3, - "in_clusters": [3, 18], - "out_clusters": [3, 4, 5, 12, 18], - "profile_id": 260, + SIG_EP_TYPE: 24323, + DEV_SIG_EP_ID: 3, + SIG_EP_INPUT: [3, 18], + SIG_EP_OUTPUT: [3, 4, 5, 12, 18], + SIG_EP_PROFILE: 260, }, }, - "entities": ["sensor.lumi_lumi_remote_b286acn01_77665544_power"], - "entity_map": { + DEV_SIG_ENTITIES: ["sensor.lumi_lumi_remote_b286acn01_77665544_power"], + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.lumi_lumi_remote_b286acn01_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b286acn01_77665544_power", }, }, - "event_channels": ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"], - "manufacturer": "LUMI", - "model": "lumi.remote.b286acn01", - "node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", - "zha_quirks": "RemoteB286ACN01", + DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"], + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.remote.b286acn01", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", + DEV_SIG_ZHA_QUIRK: "RemoteB286ACN01", }, { - "device_no": 44, - "endpoints": { + DEV_SIG_DEV_NO: 44, + SIG_ENDPOINTS: { 1: { - "device_type": 261, - "endpoint_id": 1, - "in_clusters": [0, 1, 3], - "out_clusters": [3, 6, 8, 768], - "profile_id": 260, + SIG_EP_TYPE: 261, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3], + SIG_EP_OUTPUT: [3, 6, 8, 768], + SIG_EP_PROFILE: 260, }, 2: { - "device_type": -1, - "endpoint_id": 2, - "in_clusters": [], - "out_clusters": [], - "profile_id": -1, + SIG_EP_TYPE: -1, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: -1, }, 3: { - "device_type": -1, - "endpoint_id": 3, - "in_clusters": [], - "out_clusters": [], - "profile_id": -1, + SIG_EP_TYPE: -1, + DEV_SIG_EP_ID: 3, + SIG_EP_INPUT: [], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: -1, }, 4: { - "device_type": -1, - "endpoint_id": 4, - "in_clusters": [], - "out_clusters": [], - "profile_id": -1, + SIG_EP_TYPE: -1, + DEV_SIG_EP_ID: 4, + SIG_EP_INPUT: [], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: -1, }, 5: { - "device_type": -1, - "endpoint_id": 5, - "in_clusters": [], - "out_clusters": [], - "profile_id": -1, + SIG_EP_TYPE: -1, + DEV_SIG_EP_ID: 5, + SIG_EP_INPUT: [], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: -1, }, 6: { - "device_type": -1, - "endpoint_id": 6, - "in_clusters": [], - "out_clusters": [], - "profile_id": -1, + SIG_EP_TYPE: -1, + DEV_SIG_EP_ID: 6, + SIG_EP_INPUT: [], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: -1, }, }, - "entities": [], - "entity_map": {}, - "event_channels": ["1:0x0006", "1:0x0008", "1:0x0300"], - "manufacturer": "LUMI", - "model": "lumi.remote.b286opcn01", - "node_descriptor": b"\x02@\x84_\x11\x7fd\x00\x00,d\x00\x00", + DEV_SIG_ENTITIES: [], + DEV_SIG_ENT_MAP: {}, + DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0300"], + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.remote.b286opcn01", + SIG_NODE_DESC: b"\x02@\x84_\x11\x7fd\x00\x00,d\x00\x00", }, { - "device_no": 45, - "endpoints": { + DEV_SIG_DEV_NO: 45, + SIG_ENDPOINTS: { 1: { - "device_type": 261, - "endpoint_id": 1, - "in_clusters": [0, 1, 3], - "out_clusters": [3, 6, 8, 768], - "profile_id": 260, + SIG_EP_TYPE: 261, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3], + SIG_EP_OUTPUT: [3, 6, 8, 768], + SIG_EP_PROFILE: 260, }, 2: { - "device_type": 259, - "endpoint_id": 2, - "in_clusters": [3], - "out_clusters": [3, 6], - "profile_id": 260, + SIG_EP_TYPE: 259, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [3], + SIG_EP_OUTPUT: [3, 6], + SIG_EP_PROFILE: 260, }, 3: { - "device_type": -1, - "endpoint_id": 3, - "in_clusters": [], - "out_clusters": [], - "profile_id": -1, + SIG_EP_TYPE: -1, + DEV_SIG_EP_ID: 3, + SIG_EP_INPUT: [], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: -1, }, 4: { - "device_type": -1, - "endpoint_id": 4, - "in_clusters": [], - "out_clusters": [], - "profile_id": -1, + SIG_EP_TYPE: -1, + DEV_SIG_EP_ID: 4, + SIG_EP_INPUT: [], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: -1, }, 5: { - "device_type": -1, - "endpoint_id": 5, - "in_clusters": [], - "out_clusters": [], - "profile_id": -1, + SIG_EP_TYPE: -1, + DEV_SIG_EP_ID: 5, + SIG_EP_INPUT: [], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: -1, }, 6: { - "device_type": -1, - "endpoint_id": 6, - "in_clusters": [], - "out_clusters": [], - "profile_id": -1, + SIG_EP_TYPE: -1, + DEV_SIG_EP_ID: 6, + SIG_EP_INPUT: [], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: -1, }, }, - "entities": [], - "entity_map": {}, - "event_channels": ["1:0x0006", "1:0x0008", "1:0x0300", "2:0x0006"], - "manufacturer": "LUMI", - "model": "lumi.remote.b486opcn01", - "node_descriptor": b"\x02@\x84_\x11\x7fd\x00\x00,d\x00\x00", + DEV_SIG_ENTITIES: [], + DEV_SIG_ENT_MAP: {}, + DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0300", "2:0x0006"], + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.remote.b486opcn01", + SIG_NODE_DESC: b"\x02@\x84_\x11\x7fd\x00\x00,d\x00\x00", }, { - "device_no": 46, - "endpoints": { + DEV_SIG_DEV_NO: 46, + SIG_ENDPOINTS: { 1: { - "device_type": 261, - "endpoint_id": 1, - "in_clusters": [0, 1, 3], - "out_clusters": [3, 6, 8, 768], - "profile_id": 260, + SIG_EP_TYPE: 261, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3], + SIG_EP_OUTPUT: [3, 6, 8, 768], + SIG_EP_PROFILE: 260, } }, - "entities": [], - "entity_map": {}, - "event_channels": ["1:0x0006", "1:0x0008", "1:0x0300"], - "manufacturer": "LUMI", - "model": "lumi.remote.b686opcn01", - "node_descriptor": b"\x02@\x84_\x11\x7fd\x00\x00,d\x00\x00", + DEV_SIG_ENTITIES: [], + DEV_SIG_ENT_MAP: {}, + DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0300"], + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.remote.b686opcn01", + SIG_NODE_DESC: b"\x02@\x84_\x11\x7fd\x00\x00,d\x00\x00", }, { - "device_no": 47, - "endpoints": { + DEV_SIG_DEV_NO: 47, + SIG_ENDPOINTS: { 1: { - "device_type": 261, - "endpoint_id": 1, - "in_clusters": [0, 1, 3], - "out_clusters": [3, 6, 8, 768], - "profile_id": 260, + SIG_EP_TYPE: 261, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3], + SIG_EP_OUTPUT: [3, 6, 8, 768], + SIG_EP_PROFILE: 260, }, 2: { - "device_type": 259, - "endpoint_id": 2, - "in_clusters": [3], - "out_clusters": [3, 6], - "profile_id": 260, + SIG_EP_TYPE: 259, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [3], + SIG_EP_OUTPUT: [3, 6], + SIG_EP_PROFILE: 260, }, 3: { - "device_type": None, - "endpoint_id": 3, - "in_clusters": [], - "out_clusters": [], - "profile_id": None, + SIG_EP_TYPE: None, + DEV_SIG_EP_ID: 3, + SIG_EP_INPUT: [], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: None, }, 4: { - "device_type": None, - "endpoint_id": 4, - "in_clusters": [], - "out_clusters": [], - "profile_id": None, + SIG_EP_TYPE: None, + DEV_SIG_EP_ID: 4, + SIG_EP_INPUT: [], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: None, }, 5: { - "device_type": None, - "endpoint_id": 5, - "in_clusters": [], - "out_clusters": [], - "profile_id": None, + SIG_EP_TYPE: None, + DEV_SIG_EP_ID: 5, + SIG_EP_INPUT: [], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: None, }, 6: { - "device_type": None, - "endpoint_id": 6, - "in_clusters": [], - "out_clusters": [], - "profile_id": None, + SIG_EP_TYPE: None, + DEV_SIG_EP_ID: 6, + SIG_EP_INPUT: [], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: None, }, }, - "entities": [], - "entity_map": {}, - "event_channels": ["1:0x0006", "1:0x0008", "1:0x0300", "2:0x0006"], - "manufacturer": "LUMI", - "model": "lumi.remote.b686opcn01", - "node_descriptor": b"\x02@\x84_\x11\x7fd\x00\x00,d\x00\x00", + DEV_SIG_ENTITIES: [], + DEV_SIG_ENT_MAP: {}, + DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0300", "2:0x0006"], + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.remote.b686opcn01", + SIG_NODE_DESC: b"\x02@\x84_\x11\x7fd\x00\x00,d\x00\x00", }, { - "device_no": 48, - "endpoints": { + DEV_SIG_DEV_NO: 48, + SIG_ENDPOINTS: { 8: { - "device_type": 256, - "endpoint_id": 8, - "in_clusters": [0, 6], - "out_clusters": [0, 6], - "profile_id": 260, + SIG_EP_TYPE: 256, + DEV_SIG_EP_ID: 8, + SIG_EP_INPUT: [0, 6], + SIG_EP_OUTPUT: [0, 6], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "binary_sensor.lumi_lumi_router_77665544_on_off", "light.lumi_lumi_router_77665544_on_off", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("binary_sensor", "00:11:22:33:44:55:66:77-8-6"): { - "channels": ["on_off", "on_off"], - "entity_class": "Opening", - "entity_id": "binary_sensor.lumi_lumi_router_77665544_on_off", + DEV_SIG_CHANNELS: ["on_off", "on_off"], + DEV_SIG_ENT_MAP_CLASS: "Opening", + DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_router_77665544_on_off", }, ("light", "00:11:22:33:44:55:66:77-8"): { - "channels": ["on_off", "on_off"], - "entity_class": "Light", - "entity_id": "light.lumi_lumi_router_77665544_on_off", + DEV_SIG_CHANNELS: ["on_off", "on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.lumi_lumi_router_77665544_on_off", }, }, - "event_channels": ["8:0x0006"], - "manufacturer": "LUMI", - "model": "lumi.router", - "node_descriptor": b"\x01@\x8e_\x11P\xa0\x00\x00\x00\xa0\x00\x00", + DEV_SIG_EVT_CHANNELS: ["8:0x0006"], + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.router", + SIG_NODE_DESC: b"\x01@\x8e_\x11P\xa0\x00\x00\x00\xa0\x00\x00", }, { - "device_no": 49, - "endpoints": { + DEV_SIG_DEV_NO: 49, + SIG_ENDPOINTS: { 8: { - "device_type": 256, - "endpoint_id": 8, - "in_clusters": [0, 6, 11, 17], - "out_clusters": [0, 6], - "profile_id": 260, + SIG_EP_TYPE: 256, + DEV_SIG_EP_ID: 8, + SIG_EP_INPUT: [0, 6, 11, 17], + SIG_EP_OUTPUT: [0, 6], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "binary_sensor.lumi_lumi_router_77665544_on_off", "light.lumi_lumi_router_77665544_on_off", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("binary_sensor", "00:11:22:33:44:55:66:77-8-6"): { - "channels": ["on_off", "on_off"], - "entity_class": "Opening", - "entity_id": "binary_sensor.lumi_lumi_router_77665544_on_off", + DEV_SIG_CHANNELS: ["on_off", "on_off"], + DEV_SIG_ENT_MAP_CLASS: "Opening", + DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_router_77665544_on_off", }, ("light", "00:11:22:33:44:55:66:77-8"): { - "channels": ["on_off", "on_off"], - "entity_class": "Light", - "entity_id": "light.lumi_lumi_router_77665544_on_off", + DEV_SIG_CHANNELS: ["on_off", "on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.lumi_lumi_router_77665544_on_off", }, }, - "event_channels": ["8:0x0006"], - "manufacturer": "LUMI", - "model": "lumi.router", - "node_descriptor": b"\x01@\x8e_\x11P\xa0\x00\x00\x00\xa0\x00\x00", + DEV_SIG_EVT_CHANNELS: ["8:0x0006"], + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.router", + SIG_NODE_DESC: b"\x01@\x8e_\x11P\xa0\x00\x00\x00\xa0\x00\x00", }, { - "device_no": 50, - "endpoints": { + DEV_SIG_DEV_NO: 50, + SIG_ENDPOINTS: { 8: { - "device_type": 256, - "endpoint_id": 8, - "in_clusters": [0, 6, 17], - "out_clusters": [0, 6], - "profile_id": 260, + SIG_EP_TYPE: 256, + DEV_SIG_EP_ID: 8, + SIG_EP_INPUT: [0, 6, 17], + SIG_EP_OUTPUT: [0, 6], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "binary_sensor.lumi_lumi_router_77665544_on_off", "light.lumi_lumi_router_77665544_on_off", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("binary_sensor", "00:11:22:33:44:55:66:77-8-6"): { - "channels": ["on_off", "on_off"], - "entity_class": "Opening", - "entity_id": "binary_sensor.lumi_lumi_router_77665544_on_off", + DEV_SIG_CHANNELS: ["on_off", "on_off"], + DEV_SIG_ENT_MAP_CLASS: "Opening", + DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_router_77665544_on_off", }, ("light", "00:11:22:33:44:55:66:77-8"): { - "channels": ["on_off", "on_off"], - "entity_class": "Light", - "entity_id": "light.lumi_lumi_router_77665544_on_off", + DEV_SIG_CHANNELS: ["on_off", "on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.lumi_lumi_router_77665544_on_off", }, }, - "event_channels": ["8:0x0006"], - "manufacturer": "LUMI", - "model": "lumi.router", - "node_descriptor": b"\x01@\x8e_\x11P\xa0\x00\x00\x00\xa0\x00\x00", + DEV_SIG_EVT_CHANNELS: ["8:0x0006"], + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.router", + SIG_NODE_DESC: b"\x01@\x8e_\x11P\xa0\x00\x00\x00\xa0\x00\x00", }, { - "device_no": 51, - "endpoints": { + DEV_SIG_DEV_NO: 51, + SIG_ENDPOINTS: { 1: { - "device_type": 262, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 1024], - "out_clusters": [3], - "profile_id": 260, + SIG_EP_TYPE: 262, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 1024], + SIG_EP_OUTPUT: [3], + SIG_EP_PROFILE: 260, } }, - "entities": ["sensor.lumi_lumi_sen_ill_mgl01_77665544_illuminance"], - "entity_map": { + DEV_SIG_ENTITIES: ["sensor.lumi_lumi_sen_ill_mgl01_77665544_illuminance"], + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1024"): { - "channels": ["illuminance"], - "entity_class": "Illuminance", - "entity_id": "sensor.lumi_lumi_sen_ill_mgl01_77665544_illuminance", + DEV_SIG_CHANNELS: ["illuminance"], + DEV_SIG_ENT_MAP_CLASS: "Illuminance", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sen_ill_mgl01_77665544_illuminance", } }, - "event_channels": [], - "manufacturer": "LUMI", - "model": "lumi.sen_ill.mgl01", - "node_descriptor": b"\x02@\x84n\x12\x7fd\x00\x00,d\x00\x00", + DEV_SIG_EVT_CHANNELS: [], + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.sen_ill.mgl01", + SIG_NODE_DESC: b"\x02@\x84n\x12\x7fd\x00\x00,d\x00\x00", }, { - "device_no": 52, - "endpoints": { + DEV_SIG_DEV_NO: 52, + SIG_ENDPOINTS: { 1: { - "device_type": 24321, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 18, 25, 65535], - "out_clusters": [0, 3, 4, 5, 18, 25, 65535], - "profile_id": 260, + SIG_EP_TYPE: 24321, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 18, 25, 65535], + SIG_EP_OUTPUT: [0, 3, 4, 5, 18, 25, 65535], + SIG_EP_PROFILE: 260, }, 2: { - "device_type": 24322, - "endpoint_id": 2, - "in_clusters": [3, 18], - "out_clusters": [3, 4, 5, 18], - "profile_id": 260, + SIG_EP_TYPE: 24322, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [3, 18], + SIG_EP_OUTPUT: [3, 4, 5, 18], + SIG_EP_PROFILE: 260, }, 3: { - "device_type": 24323, - "endpoint_id": 3, - "in_clusters": [3, 18], - "out_clusters": [3, 4, 5, 12, 18], - "profile_id": 260, + SIG_EP_TYPE: 24323, + DEV_SIG_EP_ID: 3, + SIG_EP_INPUT: [3, 18], + SIG_EP_OUTPUT: [3, 4, 5, 12, 18], + SIG_EP_PROFILE: 260, }, }, - "entities": ["sensor.lumi_lumi_sensor_86sw1_77665544_power"], - "entity_map": { + DEV_SIG_ENTITIES: ["sensor.lumi_lumi_sensor_86sw1_77665544_power"], + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.lumi_lumi_sensor_86sw1_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_86sw1_77665544_power", }, }, - "event_channels": ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"], - "manufacturer": "LUMI", - "model": "lumi.sensor_86sw1", - "node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", - "zha_quirks": "RemoteB186ACN01", + DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"], + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.sensor_86sw1", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", + DEV_SIG_ZHA_QUIRK: "RemoteB186ACN01", }, { - "device_no": 53, - "endpoints": { + DEV_SIG_DEV_NO: 53, + SIG_ENDPOINTS: { 1: { - "device_type": 28417, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 25], - "out_clusters": [0, 3, 4, 5, 18, 25], - "profile_id": 260, + SIG_EP_TYPE: 28417, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 25], + SIG_EP_OUTPUT: [0, 3, 4, 5, 18, 25], + SIG_EP_PROFILE: 260, }, 2: { - "device_type": 28418, - "endpoint_id": 2, - "in_clusters": [3, 18], - "out_clusters": [3, 4, 5, 18], - "profile_id": 260, + SIG_EP_TYPE: 28418, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [3, 18], + SIG_EP_OUTPUT: [3, 4, 5, 18], + SIG_EP_PROFILE: 260, }, 3: { - "device_type": 28419, - "endpoint_id": 3, - "in_clusters": [3, 12], - "out_clusters": [3, 4, 5, 12], - "profile_id": 260, + SIG_EP_TYPE: 28419, + DEV_SIG_EP_ID: 3, + SIG_EP_INPUT: [3, 12], + SIG_EP_OUTPUT: [3, 4, 5, 12], + SIG_EP_PROFILE: 260, }, }, - "entities": ["sensor.lumi_lumi_sensor_cube_aqgl01_77665544_power"], - "entity_map": { + DEV_SIG_ENTITIES: ["sensor.lumi_lumi_sensor_cube_aqgl01_77665544_power"], + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.lumi_lumi_sensor_cube_aqgl01_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_cube_aqgl01_77665544_power", }, }, - "event_channels": ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"], - "manufacturer": "LUMI", - "model": "lumi.sensor_cube.aqgl01", - "node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", - "zha_quirks": "CubeAQGL01", + DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"], + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.sensor_cube.aqgl01", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", + DEV_SIG_ZHA_QUIRK: "CubeAQGL01", }, { - "device_no": 54, - "endpoints": { + DEV_SIG_DEV_NO: 54, + SIG_ENDPOINTS: { 1: { - "device_type": 24322, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 25, 1026, 1029, 65535], - "out_clusters": [0, 3, 4, 5, 18, 25, 65535], - "profile_id": 260, + SIG_EP_TYPE: 24322, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 25, 1026, 1029, 65535], + SIG_EP_OUTPUT: [0, 3, 4, 5, 18, 25, 65535], + SIG_EP_PROFILE: 260, }, 2: { - "device_type": 24322, - "endpoint_id": 2, - "in_clusters": [3], - "out_clusters": [3, 4, 5, 18], - "profile_id": 260, + SIG_EP_TYPE: 24322, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [3], + SIG_EP_OUTPUT: [3, 4, 5, 18], + SIG_EP_PROFILE: 260, }, 3: { - "device_type": 24323, - "endpoint_id": 3, - "in_clusters": [3], - "out_clusters": [3, 4, 5, 12], - "profile_id": 260, + SIG_EP_TYPE: 24323, + DEV_SIG_EP_ID: 3, + SIG_EP_INPUT: [3], + SIG_EP_OUTPUT: [3, 4, 5, 12], + SIG_EP_PROFILE: 260, }, }, - "entities": [ + DEV_SIG_ENTITIES: [ "sensor.lumi_lumi_sensor_ht_77665544_humidity", "sensor.lumi_lumi_sensor_ht_77665544_power", "sensor.lumi_lumi_sensor_ht_77665544_temperature", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.lumi_lumi_sensor_ht_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_ht_77665544_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { - "channels": ["temperature"], - "entity_class": "Temperature", - "entity_id": "sensor.lumi_lumi_sensor_ht_77665544_temperature", + DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_ht_77665544_temperature", }, ("sensor", "00:11:22:33:44:55:66:77-1-1029"): { - "channels": ["humidity"], - "entity_class": "Humidity", - "entity_id": "sensor.lumi_lumi_sensor_ht_77665544_humidity", + DEV_SIG_CHANNELS: ["humidity"], + DEV_SIG_ENT_MAP_CLASS: "Humidity", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_ht_77665544_humidity", }, }, - "event_channels": ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"], - "manufacturer": "LUMI", - "model": "lumi.sensor_ht", - "node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", - "zha_quirks": "Weather", + DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"], + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.sensor_ht", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", + DEV_SIG_ZHA_QUIRK: "Weather", }, { - "device_no": 55, - "endpoints": { + DEV_SIG_DEV_NO: 55, + SIG_ENDPOINTS: { 1: { - "device_type": 2128, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 25, 65535], - "out_clusters": [0, 3, 4, 5, 6, 8, 25], - "profile_id": 260, + SIG_EP_TYPE: 2128, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 25, 65535], + SIG_EP_OUTPUT: [0, 3, 4, 5, 6, 8, 25], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "binary_sensor.lumi_lumi_sensor_magnet_77665544_on_off", "sensor.lumi_lumi_sensor_magnet_77665544_power", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.lumi_lumi_sensor_magnet_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_magnet_77665544_power", }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-6"): { - "channels": ["on_off"], - "entity_class": "Opening", - "entity_id": "binary_sensor.lumi_lumi_sensor_magnet_77665544_on_off", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Opening", + DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_sensor_magnet_77665544_on_off", }, }, - "event_channels": ["1:0x0005", "1:0x0006", "1:0x0008", "1:0x0019"], - "manufacturer": "LUMI", - "model": "lumi.sensor_magnet", - "node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", - "zha_quirks": "Magnet", + DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0006", "1:0x0008", "1:0x0019"], + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.sensor_magnet", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", + DEV_SIG_ZHA_QUIRK: "Magnet", }, { - "device_no": 56, - "endpoints": { + DEV_SIG_DEV_NO: 56, + SIG_ENDPOINTS: { 1: { - "device_type": 24321, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 65535], - "out_clusters": [0, 4, 6, 65535], - "profile_id": 260, + SIG_EP_TYPE: 24321, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 65535], + SIG_EP_OUTPUT: [0, 4, 6, 65535], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "binary_sensor.lumi_lumi_sensor_magnet_aq2_77665544_on_off", "sensor.lumi_lumi_sensor_magnet_aq2_77665544_power", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.lumi_lumi_sensor_magnet_aq2_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_magnet_aq2_77665544_power", }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-6"): { - "channels": ["on_off"], - "entity_class": "Opening", - "entity_id": "binary_sensor.lumi_lumi_sensor_magnet_aq2_77665544_on_off", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Opening", + DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_sensor_magnet_aq2_77665544_on_off", }, }, - "event_channels": ["1:0x0006"], - "manufacturer": "LUMI", - "model": "lumi.sensor_magnet.aq2", - "node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", - "zha_quirks": "MagnetAQ2", + DEV_SIG_EVT_CHANNELS: ["1:0x0006"], + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.sensor_magnet.aq2", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", + DEV_SIG_ZHA_QUIRK: "MagnetAQ2", }, { - "device_no": 57, - "endpoints": { + DEV_SIG_DEV_NO: 57, + SIG_ENDPOINTS: { 1: { - "device_type": 263, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 1024, 1030, 1280, 65535], - "out_clusters": [0, 25], - "profile_id": 260, + SIG_EP_TYPE: 263, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 1024, 1030, 1280, 65535], + SIG_EP_OUTPUT: [0, 25], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "binary_sensor.lumi_lumi_sensor_motion_aq2_77665544_ias_zone", "binary_sensor.lumi_lumi_sensor_motion_aq2_77665544_occupancy", "sensor.lumi_lumi_sensor_motion_aq2_77665544_illuminance", "sensor.lumi_lumi_sensor_motion_aq2_77665544_power", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.lumi_lumi_sensor_motion_aq2_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_motion_aq2_77665544_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-1024"): { - "channels": ["illuminance"], - "entity_class": "Illuminance", - "entity_id": "sensor.lumi_lumi_sensor_motion_aq2_77665544_illuminance", + DEV_SIG_CHANNELS: ["illuminance"], + DEV_SIG_ENT_MAP_CLASS: "Illuminance", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_motion_aq2_77665544_illuminance", }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-1030"): { - "channels": ["occupancy"], - "entity_class": "Occupancy", - "entity_id": "binary_sensor.lumi_lumi_sensor_motion_aq2_77665544_occupancy", + DEV_SIG_CHANNELS: ["occupancy"], + DEV_SIG_ENT_MAP_CLASS: "Occupancy", + DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_sensor_motion_aq2_77665544_occupancy", }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - "channels": ["ias_zone"], - "entity_class": "IASZone", - "entity_id": "binary_sensor.lumi_lumi_sensor_motion_aq2_77665544_ias_zone", + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_sensor_motion_aq2_77665544_ias_zone", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "LUMI", - "model": "lumi.sensor_motion.aq2", - "node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", - "zha_quirks": "MotionAQ2", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.sensor_motion.aq2", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", + DEV_SIG_ZHA_QUIRK: "MotionAQ2", }, { - "device_no": 58, - "endpoints": { + DEV_SIG_DEV_NO: 58, + SIG_ENDPOINTS: { 1: { - "device_type": 1026, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 12, 18, 1280], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 12, 18, 1280], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "binary_sensor.lumi_lumi_sensor_smoke_77665544_ias_zone", "sensor.lumi_lumi_sensor_smoke_77665544_power", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.lumi_lumi_sensor_smoke_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_smoke_77665544_power", }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - "channels": ["ias_zone"], - "entity_class": "IASZone", - "entity_id": "binary_sensor.lumi_lumi_sensor_smoke_77665544_ias_zone", + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_sensor_smoke_77665544_ias_zone", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "LUMI", - "model": "lumi.sensor_smoke", - "node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", - "zha_quirks": "MijiaHoneywellSmokeDetectorSensor", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.sensor_smoke", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", + DEV_SIG_ZHA_QUIRK: "MijiaHoneywellSmokeDetectorSensor", }, { - "device_no": 59, - "endpoints": { + DEV_SIG_DEV_NO: 59, + SIG_ENDPOINTS: { 1: { - "device_type": 6, - "endpoint_id": 1, - "in_clusters": [0, 1, 3], - "out_clusters": [0, 4, 5, 6, 8, 25], - "profile_id": 260, + SIG_EP_TYPE: 6, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3], + SIG_EP_OUTPUT: [0, 4, 5, 6, 8, 25], + SIG_EP_PROFILE: 260, } }, - "entities": ["sensor.lumi_lumi_sensor_switch_77665544_power"], - "entity_map": { + DEV_SIG_ENTITIES: ["sensor.lumi_lumi_sensor_switch_77665544_power"], + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.lumi_lumi_sensor_switch_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_switch_77665544_power", } }, - "event_channels": ["1:0x0005", "1:0x0006", "1:0x0008", "1:0x0019"], - "manufacturer": "LUMI", - "model": "lumi.sensor_switch", - "node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", - "zha_quirks": "MijaButton", + DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0006", "1:0x0008", "1:0x0019"], + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.sensor_switch", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", + DEV_SIG_ZHA_QUIRK: "MijaButton", }, { - "device_no": 60, - "endpoints": { + DEV_SIG_DEV_NO: 60, + SIG_ENDPOINTS: { 1: { - "device_type": 6, - "endpoint_id": 1, - "in_clusters": [0, 1, 65535], - "out_clusters": [0, 4, 6, 65535], - "profile_id": 260, + SIG_EP_TYPE: 6, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 65535], + SIG_EP_OUTPUT: [0, 4, 6, 65535], + SIG_EP_PROFILE: 260, } }, - "entities": ["sensor.lumi_lumi_sensor_switch_aq2_77665544_power"], - "entity_map": { + DEV_SIG_ENTITIES: ["sensor.lumi_lumi_sensor_switch_aq2_77665544_power"], + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.lumi_lumi_sensor_switch_aq2_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_switch_aq2_77665544_power", } }, - "event_channels": ["1:0x0006"], - "manufacturer": "LUMI", - "model": "lumi.sensor_switch.aq2", - "node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", - "zha_quirks": "SwitchAQ2", + DEV_SIG_EVT_CHANNELS: ["1:0x0006"], + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.sensor_switch.aq2", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", + DEV_SIG_ZHA_QUIRK: "SwitchAQ2", }, { - "device_no": 61, - "endpoints": { + DEV_SIG_DEV_NO: 61, + SIG_ENDPOINTS: { 1: { - "device_type": 6, - "endpoint_id": 1, - "in_clusters": [0, 1, 18], - "out_clusters": [0, 6], - "profile_id": 260, + SIG_EP_TYPE: 6, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 18], + SIG_EP_OUTPUT: [0, 6], + SIG_EP_PROFILE: 260, } }, - "entities": ["sensor.lumi_lumi_sensor_switch_aq3_77665544_power"], - "entity_map": { + DEV_SIG_ENTITIES: ["sensor.lumi_lumi_sensor_switch_aq3_77665544_power"], + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.lumi_lumi_sensor_switch_aq3_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_switch_aq3_77665544_power", }, }, - "event_channels": ["1:0x0006"], - "manufacturer": "LUMI", - "model": "lumi.sensor_switch.aq3", - "node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", - "zha_quirks": "SwitchAQ3", + DEV_SIG_EVT_CHANNELS: ["1:0x0006"], + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.sensor_switch.aq3", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", + DEV_SIG_ZHA_QUIRK: "SwitchAQ3", }, { - "device_no": 62, - "endpoints": { + DEV_SIG_DEV_NO: 62, + SIG_ENDPOINTS: { 1: { - "device_type": 1026, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 1280], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 1280], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "binary_sensor.lumi_lumi_sensor_wleak_aq1_77665544_ias_zone", "sensor.lumi_lumi_sensor_wleak_aq1_77665544_power", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.lumi_lumi_sensor_wleak_aq1_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_wleak_aq1_77665544_power", }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - "channels": ["ias_zone"], - "entity_class": "IASZone", - "entity_id": "binary_sensor.lumi_lumi_sensor_wleak_aq1_77665544_ias_zone", + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_sensor_wleak_aq1_77665544_ias_zone", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "LUMI", - "model": "lumi.sensor_wleak.aq1", - "node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", - "zha_quirks": "LeakAQ1", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.sensor_wleak.aq1", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", + DEV_SIG_ZHA_QUIRK: "LeakAQ1", }, { - "device_no": 63, - "endpoints": { + DEV_SIG_DEV_NO: 63, + SIG_ENDPOINTS: { 1: { - "device_type": 10, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 25, 257, 1280], - "out_clusters": [0, 3, 4, 5, 25], - "profile_id": 260, + SIG_EP_TYPE: 10, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 25, 257, 1280], + SIG_EP_OUTPUT: [0, 3, 4, 5, 25], + SIG_EP_PROFILE: 260, }, 2: { - "device_type": 24322, - "endpoint_id": 2, - "in_clusters": [3], - "out_clusters": [3, 4, 5, 18], - "profile_id": 260, + SIG_EP_TYPE: 24322, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [3], + SIG_EP_OUTPUT: [3, 4, 5, 18], + SIG_EP_PROFILE: 260, }, }, - "entities": [ + DEV_SIG_ENTITIES: [ "binary_sensor.lumi_lumi_vibration_aq1_77665544_ias_zone", "lock.lumi_lumi_vibration_aq1_77665544_door_lock", "sensor.lumi_lumi_vibration_aq1_77665544_power", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.lumi_lumi_vibration_aq1_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_vibration_aq1_77665544_power", }, ("lock", "00:11:22:33:44:55:66:77-1-257"): { - "channels": ["door_lock"], - "entity_class": "ZhaDoorLock", - "entity_id": "lock.lumi_lumi_vibration_aq1_77665544_door_lock", + DEV_SIG_CHANNELS: ["door_lock"], + DEV_SIG_ENT_MAP_CLASS: "ZhaDoorLock", + DEV_SIG_ENT_MAP_ID: "lock.lumi_lumi_vibration_aq1_77665544_door_lock", }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - "channels": ["ias_zone"], - "entity_class": "IASZone", - "entity_id": "binary_sensor.lumi_lumi_vibration_aq1_77665544_ias_zone", + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_vibration_aq1_77665544_ias_zone", }, }, - "event_channels": ["1:0x0005", "1:0x0019", "2:0x0005"], - "manufacturer": "LUMI", - "model": "lumi.vibration.aq1", - "node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", - "zha_quirks": "VibrationAQ1", + DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019", "2:0x0005"], + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.vibration.aq1", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", + DEV_SIG_ZHA_QUIRK: "VibrationAQ1", }, { - "device_no": 64, - "endpoints": { + DEV_SIG_DEV_NO: 64, + SIG_ENDPOINTS: { 1: { - "device_type": 24321, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 1026, 1027, 1029, 65535], - "out_clusters": [0, 4, 65535], - "profile_id": 260, + SIG_EP_TYPE: 24321, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 1026, 1027, 1029, 65535], + SIG_EP_OUTPUT: [0, 4, 65535], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "sensor.lumi_lumi_weather_77665544_humidity", "sensor.lumi_lumi_weather_77665544_power", "sensor.lumi_lumi_weather_77665544_pressure", "sensor.lumi_lumi_weather_77665544_temperature", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.lumi_lumi_weather_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_weather_77665544_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { - "channels": ["temperature"], - "entity_class": "Temperature", - "entity_id": "sensor.lumi_lumi_weather_77665544_temperature", + DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_weather_77665544_temperature", }, ("sensor", "00:11:22:33:44:55:66:77-1-1027"): { - "channels": ["pressure"], - "entity_class": "Pressure", - "entity_id": "sensor.lumi_lumi_weather_77665544_pressure", + DEV_SIG_CHANNELS: ["pressure"], + DEV_SIG_ENT_MAP_CLASS: "Pressure", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_weather_77665544_pressure", }, ("sensor", "00:11:22:33:44:55:66:77-1-1029"): { - "channels": ["humidity"], - "entity_class": "Humidity", - "entity_id": "sensor.lumi_lumi_weather_77665544_humidity", + DEV_SIG_CHANNELS: ["humidity"], + DEV_SIG_ENT_MAP_CLASS: "Humidity", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_weather_77665544_humidity", }, }, - "event_channels": [], - "manufacturer": "LUMI", - "model": "lumi.weather", - "node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", - "zha_quirks": "Weather", + DEV_SIG_EVT_CHANNELS: [], + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.weather", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", + DEV_SIG_ZHA_QUIRK: "Weather", }, { - "device_no": 65, - "endpoints": { + DEV_SIG_DEV_NO: 65, + SIG_ENDPOINTS: { 1: { - "device_type": 1026, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 32, 1280], - "out_clusters": [], - "profile_id": 260, + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 32, 1280], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "binary_sensor.nyce_3010_77665544_ias_zone", "sensor.nyce_3010_77665544_power", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.nyce_3010_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.nyce_3010_77665544_power", }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - "channels": ["ias_zone"], - "entity_class": "IASZone", - "entity_id": "binary_sensor.nyce_3010_77665544_ias_zone", + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.nyce_3010_77665544_ias_zone", }, }, - "event_channels": [], - "manufacturer": "NYCE", - "model": "3010", - "node_descriptor": b"\x02@\x80\xb9\x10RR\x00\x00\x00R\x00\x00", + DEV_SIG_EVT_CHANNELS: [], + SIG_MANUFACTURER: "NYCE", + SIG_MODEL: "3010", + SIG_NODE_DESC: b"\x02@\x80\xb9\x10RR\x00\x00\x00R\x00\x00", }, { - "device_no": 66, - "endpoints": { + DEV_SIG_DEV_NO: 66, + SIG_ENDPOINTS: { 1: { - "device_type": 1026, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 32, 1280], - "out_clusters": [], - "profile_id": 260, + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 32, 1280], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "binary_sensor.nyce_3014_77665544_ias_zone", "sensor.nyce_3014_77665544_power", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.nyce_3014_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.nyce_3014_77665544_power", }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - "channels": ["ias_zone"], - "entity_class": "IASZone", - "entity_id": "binary_sensor.nyce_3014_77665544_ias_zone", + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.nyce_3014_77665544_ias_zone", }, }, - "event_channels": [], - "manufacturer": "NYCE", - "model": "3014", - "node_descriptor": b"\x02@\x80\xb9\x10RR\x00\x00\x00R\x00\x00", + DEV_SIG_EVT_CHANNELS: [], + SIG_MANUFACTURER: "NYCE", + SIG_MODEL: "3014", + SIG_NODE_DESC: b"\x02@\x80\xb9\x10RR\x00\x00\x00R\x00\x00", }, { - "device_no": 67, - "endpoints": { + DEV_SIG_DEV_NO: 67, + SIG_ENDPOINTS: { 1: { - "device_type": 5, - "endpoint_id": 1, - "in_clusters": [10, 25], - "out_clusters": [1280], - "profile_id": 260, + SIG_EP_TYPE: 5, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [10, 25], + SIG_EP_OUTPUT: [1280], + SIG_EP_PROFILE: 260, }, 242: { - "device_type": 100, - "endpoint_id": 242, - "in_clusters": [], - "out_clusters": [33], - "profile_id": 41440, + SIG_EP_TYPE: 100, + DEV_SIG_EP_ID: 242, + SIG_EP_INPUT: [], + SIG_EP_OUTPUT: [33], + SIG_EP_PROFILE: 41440, }, }, - "entities": ["1:0x0019"], - "entity_map": {}, - "event_channels": [], - "manufacturer": None, - "model": None, - "node_descriptor": b"\x10@\x0f5\x11Y=\x00@\x00=\x00\x00", + DEV_SIG_ENTITIES: ["1:0x0019"], + DEV_SIG_ENT_MAP: {}, + DEV_SIG_EVT_CHANNELS: [], + SIG_MANUFACTURER: None, + SIG_MODEL: None, + SIG_NODE_DESC: b"\x10@\x0f5\x11Y=\x00@\x00=\x00\x00", }, { - "device_no": 68, - "endpoints": { + DEV_SIG_DEV_NO: 68, + SIG_ENDPOINTS: { 1: { - "device_type": 48879, - "endpoint_id": 1, - "in_clusters": [], - "out_clusters": [1280], - "profile_id": 260, + SIG_EP_TYPE: 48879, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [], + SIG_EP_OUTPUT: [1280], + SIG_EP_PROFILE: 260, } }, - "entities": [], - "entity_map": {}, - "event_channels": [], - "manufacturer": None, - "model": None, - "node_descriptor": b"\x00@\x8f\xcd\xabR\x80\x00\x00\x00\x80\x00\x00", + DEV_SIG_ENTITIES: [], + DEV_SIG_ENT_MAP: {}, + DEV_SIG_EVT_CHANNELS: [], + SIG_MANUFACTURER: None, + SIG_MODEL: None, + SIG_NODE_DESC: b"\x00@\x8f\xcd\xabR\x80\x00\x00\x00\x80\x00\x00", }, { - "device_no": 69, - "endpoints": { + DEV_SIG_DEV_NO: 69, + SIG_ENDPOINTS: { 3: { - "device_type": 258, - "endpoint_id": 3, - "in_clusters": [0, 3, 4, 5, 6, 8, 768, 64527], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_TYPE: 258, + DEV_SIG_EP_ID: 3, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 768, 64527], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, } }, - "entities": ["light.osram_lightify_a19_rgbw_77665544_level_light_color_on_off"], - "entity_map": { + DEV_SIG_ENTITIES: [ + "light.osram_lightify_a19_rgbw_77665544_level_light_color_on_off" + ], + DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-3"): { - "channels": ["level", "light_color", "on_off"], - "entity_class": "Light", - "entity_id": "light.osram_lightify_a19_rgbw_77665544_level_light_color_on_off", + DEV_SIG_CHANNELS: ["level", "light_color", "on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.osram_lightify_a19_rgbw_77665544_level_light_color_on_off", } }, - "event_channels": ["3:0x0019"], - "manufacturer": "OSRAM", - "model": "LIGHTIFY A19 RGBW", - "node_descriptor": b"\x01@\x8e\xaa\xbb@\x00\x00\x00\x00\x00\x00\x03", - "zha_quirks": "LIGHTIFYA19RGBW", + DEV_SIG_EVT_CHANNELS: ["3:0x0019"], + SIG_MANUFACTURER: "OSRAM", + SIG_MODEL: "LIGHTIFY A19 RGBW", + SIG_NODE_DESC: b"\x01@\x8e\xaa\xbb@\x00\x00\x00\x00\x00\x00\x03", + DEV_SIG_ZHA_QUIRK: "LIGHTIFYA19RGBW", }, { - "device_no": 70, - "endpoints": { + DEV_SIG_DEV_NO: 70, + SIG_ENDPOINTS: { 1: { - "device_type": 1, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 32, 2821], - "out_clusters": [3, 6, 8, 25], - "profile_id": 260, + SIG_EP_TYPE: 1, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 32, 2821], + SIG_EP_OUTPUT: [3, 6, 8, 25], + SIG_EP_PROFILE: 260, } }, - "entities": ["sensor.osram_lightify_dimming_switch_77665544_power"], - "entity_map": { + DEV_SIG_ENTITIES: ["sensor.osram_lightify_dimming_switch_77665544_power"], + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.osram_lightify_dimming_switch_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_dimming_switch_77665544_power", } }, - "event_channels": ["1:0x0006", "1:0x0008", "1:0x0019"], - "manufacturer": "OSRAM", - "model": "LIGHTIFY Dimming Switch", - "node_descriptor": b"\x02@\x80\x0c\x11RR\x00\x00\x00R\x00\x00", - "zha_quirks": "CentraLite3130", + DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0019"], + SIG_MANUFACTURER: "OSRAM", + SIG_MODEL: "LIGHTIFY Dimming Switch", + SIG_NODE_DESC: b"\x02@\x80\x0c\x11RR\x00\x00\x00R\x00\x00", + DEV_SIG_ZHA_QUIRK: "CentraLite3130", }, { - "device_no": 71, - "endpoints": { + DEV_SIG_DEV_NO: 71, + SIG_ENDPOINTS: { 3: { - "device_type": 258, - "endpoint_id": 3, - "in_clusters": [0, 3, 4, 5, 6, 8, 768, 64527], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_TYPE: 258, + DEV_SIG_EP_ID: 3, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 768, 64527], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "light.osram_lightify_flex_rgbw_77665544_level_light_color_on_off" ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-3"): { - "channels": ["level", "light_color", "on_off"], - "entity_class": "Light", - "entity_id": "light.osram_lightify_flex_rgbw_77665544_level_light_color_on_off", + DEV_SIG_CHANNELS: ["level", "light_color", "on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.osram_lightify_flex_rgbw_77665544_level_light_color_on_off", } }, - "event_channels": ["3:0x0019"], - "manufacturer": "OSRAM", - "model": "LIGHTIFY Flex RGBW", - "node_descriptor": b"\x19@\x8e\xaa\xbb@\x00\x00\x00\x00\x00\x00\x03", - "zha_quirks": "FlexRGBW", + DEV_SIG_EVT_CHANNELS: ["3:0x0019"], + SIG_MANUFACTURER: "OSRAM", + SIG_MODEL: "LIGHTIFY Flex RGBW", + SIG_NODE_DESC: b"\x19@\x8e\xaa\xbb@\x00\x00\x00\x00\x00\x00\x03", + DEV_SIG_ZHA_QUIRK: "FlexRGBW", }, { - "device_no": 72, - "endpoints": { + DEV_SIG_DEV_NO: 72, + SIG_ENDPOINTS: { 3: { - "device_type": 258, - "endpoint_id": 3, - "in_clusters": [0, 3, 4, 5, 6, 8, 768, 2820, 64527], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_TYPE: 258, + DEV_SIG_EP_ID: 3, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 768, 2820, 64527], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "light.osram_lightify_rt_tunable_white_77665544_level_light_color_on_off", "sensor.osram_lightify_rt_tunable_white_77665544_electrical_measurement", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-3"): { - "channels": ["level", "light_color", "on_off"], - "entity_class": "Light", - "entity_id": "light.osram_lightify_rt_tunable_white_77665544_level_light_color_on_off", + DEV_SIG_CHANNELS: ["level", "light_color", "on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.osram_lightify_rt_tunable_white_77665544_level_light_color_on_off", }, ("sensor", "00:11:22:33:44:55:66:77-3-2820"): { - "channels": ["electrical_measurement"], - "entity_class": "ElectricalMeasurement", - "entity_id": "sensor.osram_lightify_rt_tunable_white_77665544_electrical_measurement", + DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", + DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_rt_tunable_white_77665544_electrical_measurement", }, }, - "event_channels": ["3:0x0019"], - "manufacturer": "OSRAM", - "model": "LIGHTIFY RT Tunable White", - "node_descriptor": b"\x01@\x8e\xaa\xbb@\x00\x00\x00\x00\x00\x00\x03", - "zha_quirks": "A19TunableWhite", + DEV_SIG_EVT_CHANNELS: ["3:0x0019"], + SIG_MANUFACTURER: "OSRAM", + SIG_MODEL: "LIGHTIFY RT Tunable White", + SIG_NODE_DESC: b"\x01@\x8e\xaa\xbb@\x00\x00\x00\x00\x00\x00\x03", + DEV_SIG_ZHA_QUIRK: "A19TunableWhite", }, { - "device_no": 73, - "endpoints": { + DEV_SIG_DEV_NO: 73, + SIG_ENDPOINTS: { 3: { - "device_type": 16, - "endpoint_id": 3, - "in_clusters": [0, 3, 4, 5, 6, 2820, 4096, 64527], - "out_clusters": [25], - "profile_id": 49246, + SIG_EP_TYPE: 16, + DEV_SIG_EP_ID: 3, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 2820, 4096, 64527], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 49246, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "sensor.osram_plug_01_77665544_electrical_measurement", "switch.osram_plug_01_77665544_on_off", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("switch", "00:11:22:33:44:55:66:77-3"): { - "channels": ["on_off"], - "entity_class": "Switch", - "entity_id": "switch.osram_plug_01_77665544_on_off", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.osram_plug_01_77665544_on_off", }, ("sensor", "00:11:22:33:44:55:66:77-3-2820"): { - "channels": ["electrical_measurement"], - "entity_class": "ElectricalMeasurement", - "entity_id": "sensor.osram_plug_01_77665544_electrical_measurement", + DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", + DEV_SIG_ENT_MAP_ID: "sensor.osram_plug_01_77665544_electrical_measurement", }, }, - "event_channels": ["3:0x0019"], - "manufacturer": "OSRAM", - "model": "Plug 01", - "node_descriptor": b"\x01@\x8e\xaa\xbb@\x00\x00\x00\x00\x00\x00\x03", + DEV_SIG_EVT_CHANNELS: ["3:0x0019"], + SIG_MANUFACTURER: "OSRAM", + SIG_MODEL: "Plug 01", + SIG_NODE_DESC: b"\x01@\x8e\xaa\xbb@\x00\x00\x00\x00\x00\x00\x03", }, { - "device_no": 74, - "endpoints": { + DEV_SIG_DEV_NO: 74, + SIG_ENDPOINTS: { 1: { - "device_type": 2064, - "endpoint_id": 1, - "in_clusters": [0, 1, 32, 4096, 64768], - "out_clusters": [3, 4, 5, 6, 8, 25, 768, 4096], - "profile_id": 260, + SIG_EP_TYPE: 2064, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 32, 4096, 64768], + SIG_EP_OUTPUT: [3, 4, 5, 6, 8, 25, 768, 4096], + SIG_EP_PROFILE: 260, }, 2: { - "device_type": 2064, - "endpoint_id": 2, - "in_clusters": [0, 4096, 64768], - "out_clusters": [3, 4, 5, 6, 8, 768, 4096], - "profile_id": 260, + SIG_EP_TYPE: 2064, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [0, 4096, 64768], + SIG_EP_OUTPUT: [3, 4, 5, 6, 8, 768, 4096], + SIG_EP_PROFILE: 260, }, 3: { - "device_type": 2064, - "endpoint_id": 3, - "in_clusters": [0, 4096, 64768], - "out_clusters": [3, 4, 5, 6, 8, 768, 4096], - "profile_id": 260, + SIG_EP_TYPE: 2064, + DEV_SIG_EP_ID: 3, + SIG_EP_INPUT: [0, 4096, 64768], + SIG_EP_OUTPUT: [3, 4, 5, 6, 8, 768, 4096], + SIG_EP_PROFILE: 260, }, 4: { - "device_type": 2064, - "endpoint_id": 4, - "in_clusters": [0, 4096, 64768], - "out_clusters": [3, 4, 5, 6, 8, 768, 4096], - "profile_id": 260, + SIG_EP_TYPE: 2064, + DEV_SIG_EP_ID: 4, + SIG_EP_INPUT: [0, 4096, 64768], + SIG_EP_OUTPUT: [3, 4, 5, 6, 8, 768, 4096], + SIG_EP_PROFILE: 260, }, 5: { - "device_type": 2064, - "endpoint_id": 5, - "in_clusters": [0, 4096, 64768], - "out_clusters": [3, 4, 5, 6, 8, 768, 4096], - "profile_id": 260, + SIG_EP_TYPE: 2064, + DEV_SIG_EP_ID: 5, + SIG_EP_INPUT: [0, 4096, 64768], + SIG_EP_OUTPUT: [3, 4, 5, 6, 8, 768, 4096], + SIG_EP_PROFILE: 260, }, 6: { - "device_type": 2064, - "endpoint_id": 6, - "in_clusters": [0, 4096, 64768], - "out_clusters": [3, 4, 5, 6, 8, 768, 4096], - "profile_id": 260, + SIG_EP_TYPE: 2064, + DEV_SIG_EP_ID: 6, + SIG_EP_INPUT: [0, 4096, 64768], + SIG_EP_OUTPUT: [3, 4, 5, 6, 8, 768, 4096], + SIG_EP_PROFILE: 260, }, }, - "entities": ["sensor.osram_switch_4x_lightify_77665544_power"], - "entity_map": { + DEV_SIG_ENTITIES: ["sensor.osram_switch_4x_lightify_77665544_power"], + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.osram_switch_4x_lightify_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.osram_switch_4x_lightify_77665544_power", } }, - "event_channels": [ + DEV_SIG_EVT_CHANNELS: [ "1:0x0005", "1:0x0006", "1:0x0008", @@ -2635,984 +2668,986 @@ DEVICES = [ "6:0x0008", "6:0x0300", ], - "manufacturer": "OSRAM", - "model": "Switch 4x-LIGHTIFY", - "node_descriptor": b"\x02@\x80\x0c\x11RR\x00\x00\x00R\x00\x00", - "zha_quirks": "LightifyX4", + SIG_MANUFACTURER: "OSRAM", + SIG_MODEL: "Switch 4x-LIGHTIFY", + SIG_NODE_DESC: b"\x02@\x80\x0c\x11RR\x00\x00\x00R\x00\x00", + DEV_SIG_ZHA_QUIRK: "LightifyX4", }, { - "device_no": 75, - "endpoints": { + DEV_SIG_DEV_NO: 75, + SIG_ENDPOINTS: { 1: { - "device_type": 2096, - "endpoint_id": 1, - "in_clusters": [0], - "out_clusters": [0, 3, 4, 5, 6, 8], - "profile_id": 49246, + SIG_EP_TYPE: 2096, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0], + SIG_EP_OUTPUT: [0, 3, 4, 5, 6, 8], + SIG_EP_PROFILE: 49246, }, 2: { - "device_type": 12, - "endpoint_id": 2, - "in_clusters": [0, 1, 3, 15, 64512], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_TYPE: 12, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [0, 1, 3, 15, 64512], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, }, }, - "entities": ["sensor.philips_rwl020_77665544_power"], - "entity_map": { + DEV_SIG_ENTITIES: ["sensor.philips_rwl020_77665544_power"], + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-2-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.philips_rwl020_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.philips_rwl020_77665544_power", }, ("binary_sensor", "00:11:22:33:44:55:66:77-2-15"): { - "channels": ["binary_input"], - "entity_class": "BinaryInput", - "entity_id": "binary_sensor.philips_rwl020_77665544_binary_input", + DEV_SIG_CHANNELS: ["binary_input"], + DEV_SIG_ENT_MAP_CLASS: "BinaryInput", + DEV_SIG_ENT_MAP_ID: "binary_sensor.philips_rwl020_77665544_binary_input", }, }, - "event_channels": ["1:0x0005", "1:0x0006", "1:0x0008", "2:0x0019"], - "manufacturer": "Philips", - "model": "RWL020", - "node_descriptor": b"\x02@\x80\x0b\x10G-\x00\x00\x00-\x00\x00", - "zha_quirks": "PhilipsRWL021", + DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0006", "1:0x0008", "2:0x0019"], + SIG_MANUFACTURER: "Philips", + SIG_MODEL: "RWL020", + SIG_NODE_DESC: b"\x02@\x80\x0b\x10G-\x00\x00\x00-\x00\x00", + DEV_SIG_ZHA_QUIRK: "PhilipsRWL021", }, { - "device_no": 76, - "endpoints": { + DEV_SIG_DEV_NO: 76, + SIG_ENDPOINTS: { 1: { - "device_type": 1026, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 32, 1026, 1280, 2821], - "out_clusters": [3, 25], - "profile_id": 260, + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 32, 1026, 1280, 2821], + SIG_EP_OUTPUT: [3, 25], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "binary_sensor.samjin_button_77665544_ias_zone", "sensor.samjin_button_77665544_power", "sensor.samjin_button_77665544_temperature", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.samjin_button_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.samjin_button_77665544_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { - "channels": ["temperature"], - "entity_class": "Temperature", - "entity_id": "sensor.samjin_button_77665544_temperature", + DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.samjin_button_77665544_temperature", }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - "channels": ["ias_zone"], - "entity_class": "IASZone", - "entity_id": "binary_sensor.samjin_button_77665544_ias_zone", + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.samjin_button_77665544_ias_zone", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "Samjin", - "model": "button", - "node_descriptor": b"\x02@\x80A\x12RR\x00\x00,R\x00\x00", - "zha_quirks": "SamjinButton", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "Samjin", + SIG_MODEL: "button", + SIG_NODE_DESC: b"\x02@\x80A\x12RR\x00\x00,R\x00\x00", + DEV_SIG_ZHA_QUIRK: "SamjinButton", }, { - "device_no": 77, - "endpoints": { + DEV_SIG_DEV_NO: 77, + SIG_ENDPOINTS: { 1: { - "device_type": 1026, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 32, 1026, 1280, 64514], - "out_clusters": [3, 25], - "profile_id": 260, + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 32, 1026, 1280, 64514], + SIG_EP_OUTPUT: [3, 25], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "binary_sensor.samjin_multi_77665544_ias_zone", "binary_sensor.samjin_multi_77665544_manufacturer_specific", "sensor.samjin_multi_77665544_power", "sensor.samjin_multi_77665544_temperature", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.samjin_multi_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.samjin_multi_77665544_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { - "channels": ["temperature"], - "entity_class": "Temperature", - "entity_id": "sensor.samjin_multi_77665544_temperature", + DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.samjin_multi_77665544_temperature", }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - "channels": ["ias_zone"], - "entity_class": "IASZone", - "entity_id": "binary_sensor.samjin_multi_77665544_ias_zone", + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.samjin_multi_77665544_ias_zone", }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-64514"): { - "channels": ["manufacturer_specific"], - "entity_class": "BinaryInput", - "entity_id": "binary_sensor.samjin_multi_77665544_manufacturer_specific", + DEV_SIG_CHANNELS: ["manufacturer_specific"], + DEV_SIG_ENT_MAP_CLASS: "BinaryInput", + DEV_SIG_ENT_MAP_ID: "binary_sensor.samjin_multi_77665544_manufacturer_specific", "default_match": True, }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "Samjin", - "model": "multi", - "node_descriptor": b"\x02@\x80A\x12RR\x00\x00,R\x00\x00", - "zha_quirks": "SmartthingsMultiPurposeSensor", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "Samjin", + SIG_MODEL: "multi", + SIG_NODE_DESC: b"\x02@\x80A\x12RR\x00\x00,R\x00\x00", + DEV_SIG_ZHA_QUIRK: "SmartthingsMultiPurposeSensor", }, { - "device_no": 78, - "endpoints": { + DEV_SIG_DEV_NO: 78, + SIG_ENDPOINTS: { 1: { - "device_type": 1026, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 32, 1026, 1280], - "out_clusters": [3, 25], - "profile_id": 260, + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 32, 1026, 1280], + SIG_EP_OUTPUT: [3, 25], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "binary_sensor.samjin_water_77665544_ias_zone", "sensor.samjin_water_77665544_power", "sensor.samjin_water_77665544_temperature", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.samjin_water_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.samjin_water_77665544_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { - "channels": ["temperature"], - "entity_class": "Temperature", - "entity_id": "sensor.samjin_water_77665544_temperature", + DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.samjin_water_77665544_temperature", }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - "channels": ["ias_zone"], - "entity_class": "IASZone", - "entity_id": "binary_sensor.samjin_water_77665544_ias_zone", + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.samjin_water_77665544_ias_zone", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "Samjin", - "model": "water", - "node_descriptor": b"\x02@\x80A\x12RR\x00\x00,R\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "Samjin", + SIG_MODEL: "water", + SIG_NODE_DESC: b"\x02@\x80A\x12RR\x00\x00,R\x00\x00", }, { - "device_no": 79, - "endpoints": { + DEV_SIG_DEV_NO: 79, + SIG_ENDPOINTS: { 1: { - "device_type": 0, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 4, 5, 6, 2820, 2821], - "out_clusters": [0, 1, 3, 4, 5, 6, 25, 2820, 2821], - "profile_id": 260, + SIG_EP_TYPE: 0, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 4, 5, 6, 2820, 2821], + SIG_EP_OUTPUT: [0, 1, 3, 4, 5, 6, 25, 2820, 2821], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "sensor.securifi_ltd_unk_model_77665544_electrical_measurement", "switch.securifi_ltd_unk_model_77665544_on_off", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("switch", "00:11:22:33:44:55:66:77-1-6"): { - "channels": ["on_off"], - "entity_class": "Switch", - "entity_id": "switch.securifi_ltd_unk_model_77665544_on_off", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.securifi_ltd_unk_model_77665544_on_off", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { - "channels": ["electrical_measurement"], - "entity_class": "ElectricalMeasurement", - "entity_id": "sensor.securifi_ltd_unk_model_77665544_electrical_measurement", + DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", + DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_77665544_electrical_measurement", }, }, - "event_channels": ["1:0x0005", "1:0x0006", "1:0x0019"], - "manufacturer": "Securifi Ltd.", - "model": None, - "node_descriptor": b"\x01@\x8e\x02\x10RR\x00\x00\x00R\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0006", "1:0x0019"], + SIG_MANUFACTURER: "Securifi Ltd.", + SIG_MODEL: None, + SIG_NODE_DESC: b"\x01@\x8e\x02\x10RR\x00\x00\x00R\x00\x00", }, { - "device_no": 80, - "endpoints": { + DEV_SIG_DEV_NO: 80, + SIG_ENDPOINTS: { 1: { - "device_type": 1026, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 32, 1026, 1280, 2821], - "out_clusters": [3, 25], - "profile_id": 260, + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 32, 1026, 1280, 2821], + SIG_EP_OUTPUT: [3, 25], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "binary_sensor.sercomm_corp_sz_dws04n_sf_77665544_ias_zone", "sensor.sercomm_corp_sz_dws04n_sf_77665544_power", "sensor.sercomm_corp_sz_dws04n_sf_77665544_temperature", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.sercomm_corp_sz_dws04n_sf_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_dws04n_sf_77665544_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { - "channels": ["temperature"], - "entity_class": "Temperature", - "entity_id": "sensor.sercomm_corp_sz_dws04n_sf_77665544_temperature", + DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_dws04n_sf_77665544_temperature", }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - "channels": ["ias_zone"], - "entity_class": "IASZone", - "entity_id": "binary_sensor.sercomm_corp_sz_dws04n_sf_77665544_ias_zone", + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.sercomm_corp_sz_dws04n_sf_77665544_ias_zone", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "Sercomm Corp.", - "model": "SZ-DWS04N_SF", - "node_descriptor": b"\x02@\x801\x11R\xff\x00\x00\x00\xff\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "Sercomm Corp.", + SIG_MODEL: "SZ-DWS04N_SF", + SIG_NODE_DESC: b"\x02@\x801\x11R\xff\x00\x00\x00\xff\x00\x00", }, { - "device_no": 81, - "endpoints": { + DEV_SIG_DEV_NO: 81, + SIG_ENDPOINTS: { 1: { - "device_type": 256, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 4, 5, 6, 1794, 2820, 2821], - "out_clusters": [3, 10, 25, 2821], - "profile_id": 260, + SIG_EP_TYPE: 256, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 4, 5, 6, 1794, 2820, 2821], + SIG_EP_OUTPUT: [3, 10, 25, 2821], + SIG_EP_PROFILE: 260, }, 2: { - "device_type": 259, - "endpoint_id": 2, - "in_clusters": [0, 1, 3], - "out_clusters": [3, 6], - "profile_id": 260, + SIG_EP_TYPE: 259, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [0, 1, 3], + SIG_EP_OUTPUT: [3, 6], + SIG_EP_PROFILE: 260, }, }, - "entities": [ + DEV_SIG_ENTITIES: [ "light.sercomm_corp_sz_esw01_77665544_on_off", "sensor.sercomm_corp_sz_esw01_77665544_electrical_measurement", "sensor.sercomm_corp_sz_esw01_77665544_smartenergy_metering", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - "channels": ["on_off"], - "entity_class": "Light", - "entity_id": "light.sercomm_corp_sz_esw01_77665544_on_off", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.sercomm_corp_sz_esw01_77665544_on_off", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { - "channels": ["smartenergy_metering"], - "entity_class": "SmartEnergyMetering", - "entity_id": "sensor.sercomm_corp_sz_esw01_77665544_smartenergy_metering", + DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_77665544_smartenergy_metering", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { - "channels": ["electrical_measurement"], - "entity_class": "ElectricalMeasurement", - "entity_id": "sensor.sercomm_corp_sz_esw01_77665544_electrical_measurement", + DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_77665544_electrical_measurement", }, }, - "event_channels": ["1:0x0019", "2:0x0006"], - "manufacturer": "Sercomm Corp.", - "model": "SZ-ESW01", - "node_descriptor": b"\x01@\x8e1\x11RR\x00\x00\x00R\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0019", "2:0x0006"], + SIG_MANUFACTURER: "Sercomm Corp.", + SIG_MODEL: "SZ-ESW01", + SIG_NODE_DESC: b"\x01@\x8e1\x11RR\x00\x00\x00R\x00\x00", }, { - "device_no": 82, - "endpoints": { + DEV_SIG_DEV_NO: 82, + SIG_ENDPOINTS: { 1: { - "device_type": 1026, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 32, 1024, 1026, 1280, 2821], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 32, 1024, 1026, 1280, 2821], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "binary_sensor.sercomm_corp_sz_pir04_77665544_ias_zone", "sensor.sercomm_corp_sz_pir04_77665544_illuminance", "sensor.sercomm_corp_sz_pir04_77665544_power", "sensor.sercomm_corp_sz_pir04_77665544_temperature", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.sercomm_corp_sz_pir04_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_pir04_77665544_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-1024"): { - "channels": ["illuminance"], - "entity_class": "Illuminance", - "entity_id": "sensor.sercomm_corp_sz_pir04_77665544_illuminance", + DEV_SIG_CHANNELS: ["illuminance"], + DEV_SIG_ENT_MAP_CLASS: "Illuminance", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_pir04_77665544_illuminance", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { - "channels": ["temperature"], - "entity_class": "Temperature", - "entity_id": "sensor.sercomm_corp_sz_pir04_77665544_temperature", + DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_pir04_77665544_temperature", }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - "channels": ["ias_zone"], - "entity_class": "IASZone", - "entity_id": "binary_sensor.sercomm_corp_sz_pir04_77665544_ias_zone", + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.sercomm_corp_sz_pir04_77665544_ias_zone", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "Sercomm Corp.", - "model": "SZ-PIR04", - "node_descriptor": b"\x02@\x801\x11RR\x00\x00\x00R\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "Sercomm Corp.", + SIG_MODEL: "SZ-PIR04", + SIG_NODE_DESC: b"\x02@\x801\x11RR\x00\x00\x00R\x00\x00", }, { - "device_no": 83, - "endpoints": { + DEV_SIG_DEV_NO: 83, + SIG_ENDPOINTS: { 1: { - "device_type": 2, - "endpoint_id": 1, - "in_clusters": [0, 3, 4, 5, 6, 2820, 2821, 65281], - "out_clusters": [3, 4, 25], - "profile_id": 260, + SIG_EP_TYPE: 2, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 2820, 2821, 65281], + SIG_EP_OUTPUT: [3, 4, 25], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "sensor.sinope_technologies_rm3250zb_77665544_electrical_measurement", "switch.sinope_technologies_rm3250zb_77665544_on_off", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("switch", "00:11:22:33:44:55:66:77-1-6"): { - "channels": ["on_off"], - "entity_class": "Switch", - "entity_id": "switch.sinope_technologies_rm3250zb_77665544_on_off", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.sinope_technologies_rm3250zb_77665544_on_off", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { - "channels": ["electrical_measurement"], - "entity_class": "ElectricalMeasurement", - "entity_id": "sensor.sinope_technologies_rm3250zb_77665544_electrical_measurement", + DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_rm3250zb_77665544_electrical_measurement", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "Sinope Technologies", - "model": "RM3250ZB", - "node_descriptor": b"\x11@\x8e\x9c\x11G+\x00\x00*+\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "Sinope Technologies", + SIG_MODEL: "RM3250ZB", + SIG_NODE_DESC: b"\x11@\x8e\x9c\x11G+\x00\x00*+\x00\x00", }, { - "device_no": 84, - "endpoints": { + DEV_SIG_DEV_NO: 84, + SIG_ENDPOINTS: { 1: { - "device_type": 769, - "endpoint_id": 1, - "in_clusters": [0, 3, 4, 5, 513, 516, 1026, 2820, 2821, 65281], - "out_clusters": [25, 65281], - "profile_id": 260, + SIG_EP_TYPE: 769, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 513, 516, 1026, 2820, 2821, 65281], + SIG_EP_OUTPUT: [25, 65281], + SIG_EP_PROFILE: 260, }, 196: { - "device_type": 769, - "endpoint_id": 196, - "in_clusters": [1], - "out_clusters": [], - "profile_id": 49757, + SIG_EP_TYPE: 769, + DEV_SIG_EP_ID: 196, + SIG_EP_INPUT: [1], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 49757, }, }, - "entities": [ + DEV_SIG_ENTITIES: [ "climate.sinope_technologies_th1123zb_77665544_thermostat", "sensor.sinope_technologies_th1123zb_77665544_electrical_measurement", "sensor.sinope_technologies_th1123zb_77665544_temperature", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("climate", "00:11:22:33:44:55:66:77-1"): { - "channels": ["thermostat"], - "entity_class": "Thermostat", - "entity_id": "climate.sinope_technologies_th1123zb_77665544_thermostat", + DEV_SIG_CHANNELS: ["thermostat"], + DEV_SIG_ENT_MAP_CLASS: "Thermostat", + DEV_SIG_ENT_MAP_ID: "climate.sinope_technologies_th1123zb_77665544_thermostat", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { - "channels": ["temperature"], - "entity_class": "Temperature", - "entity_id": "sensor.sinope_technologies_th1123zb_77665544_temperature", + DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_77665544_temperature", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { - "channels": ["electrical_measurement"], - "entity_class": "ElectricalMeasurement", - "entity_id": "sensor.sinope_technologies_th1123zb_77665544_electrical_measurement", + DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_77665544_electrical_measurement", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "Sinope Technologies", - "model": "TH1123ZB", - "node_descriptor": b"\x12@\x8c\x9c\x11G+\x00\x00\x00+\x00\x00", - "zha_quirks": "SinopeTechnologiesThermostat", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "Sinope Technologies", + SIG_MODEL: "TH1123ZB", + SIG_NODE_DESC: b"\x12@\x8c\x9c\x11G+\x00\x00\x00+\x00\x00", + DEV_SIG_ZHA_QUIRK: "SinopeTechnologiesThermostat", }, { - "device_no": 85, - "endpoints": { + DEV_SIG_DEV_NO: 85, + SIG_ENDPOINTS: { 1: { - "device_type": 769, - "endpoint_id": 1, - "in_clusters": [0, 3, 4, 5, 513, 516, 1026, 2820, 2821, 65281], - "out_clusters": [25, 65281], - "profile_id": 260, + SIG_EP_TYPE: 769, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 513, 516, 1026, 2820, 2821, 65281], + SIG_EP_OUTPUT: [25, 65281], + SIG_EP_PROFILE: 260, }, 196: { - "device_type": 769, - "endpoint_id": 196, - "in_clusters": [1], - "out_clusters": [], - "profile_id": 49757, + SIG_EP_TYPE: 769, + DEV_SIG_EP_ID: 196, + SIG_EP_INPUT: [1], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 49757, }, }, - "entities": [ + DEV_SIG_ENTITIES: [ "sensor.sinope_technologies_th1124zb_77665544_electrical_measurement", "sensor.sinope_technologies_th1124zb_77665544_temperature", "climate.sinope_technologies_th1124zb_77665544_thermostat", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("climate", "00:11:22:33:44:55:66:77-1"): { - "channels": ["thermostat"], - "entity_class": "Thermostat", - "entity_id": "climate.sinope_technologies_th1124zb_77665544_thermostat", + DEV_SIG_CHANNELS: ["thermostat"], + DEV_SIG_ENT_MAP_CLASS: "Thermostat", + DEV_SIG_ENT_MAP_ID: "climate.sinope_technologies_th1124zb_77665544_thermostat", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { - "channels": ["temperature"], - "entity_class": "Temperature", - "entity_id": "sensor.sinope_technologies_th1124zb_77665544_temperature", + DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_77665544_temperature", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { - "channels": ["electrical_measurement"], - "entity_class": "ElectricalMeasurement", - "entity_id": "sensor.sinope_technologies_th1124zb_77665544_electrical_measurement", + DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_77665544_electrical_measurement", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "Sinope Technologies", - "model": "TH1124ZB", - "node_descriptor": b"\x11@\x8e\x9c\x11G+\x00\x00\x00+\x00\x00", - "zha_quirks": "SinopeTechnologiesThermostat", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "Sinope Technologies", + SIG_MODEL: "TH1124ZB", + SIG_NODE_DESC: b"\x11@\x8e\x9c\x11G+\x00\x00\x00+\x00\x00", + DEV_SIG_ZHA_QUIRK: "SinopeTechnologiesThermostat", }, { - "device_no": 86, - "endpoints": { + DEV_SIG_DEV_NO: 86, + SIG_ENDPOINTS: { 1: { - "device_type": 2, - "endpoint_id": 1, - "in_clusters": [0, 3, 4, 5, 6, 9, 15, 2820], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_TYPE: 2, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 9, 15, 2820], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "sensor.smartthings_outletv4_77665544_electrical_measurement", "switch.smartthings_outletv4_77665544_on_off", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("switch", "00:11:22:33:44:55:66:77-1-6"): { - "channels": ["on_off"], - "entity_class": "Switch", - "entity_id": "switch.smartthings_outletv4_77665544_on_off", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.smartthings_outletv4_77665544_on_off", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { - "channels": ["electrical_measurement"], - "entity_class": "ElectricalMeasurement", - "entity_id": "sensor.smartthings_outletv4_77665544_electrical_measurement", + DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", + DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_77665544_electrical_measurement", }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-15"): { - "channels": ["binary_input"], - "entity_class": "BinaryInput", - "entity_id": "binary_sensor.smartthings_outletv4_77665544_binary_input", + DEV_SIG_CHANNELS: ["binary_input"], + DEV_SIG_ENT_MAP_CLASS: "BinaryInput", + DEV_SIG_ENT_MAP_ID: "binary_sensor.smartthings_outletv4_77665544_binary_input", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "SmartThings", - "model": "outletv4", - "node_descriptor": b"\x01@\x8e\n\x11RR\x00\x00\x00R\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "SmartThings", + SIG_MODEL: "outletv4", + SIG_NODE_DESC: b"\x01@\x8e\n\x11RR\x00\x00\x00R\x00\x00", }, { - "device_no": 87, - "endpoints": { + DEV_SIG_DEV_NO: 87, + SIG_ENDPOINTS: { 1: { - "device_type": 32768, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 15, 32], - "out_clusters": [3, 25], - "profile_id": 260, + SIG_EP_TYPE: 32768, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 15, 32], + SIG_EP_OUTPUT: [3, 25], + SIG_EP_PROFILE: 260, } }, - "entities": ["device_tracker.smartthings_tagv4_77665544_power"], - "entity_map": { + DEV_SIG_ENTITIES: ["device_tracker.smartthings_tagv4_77665544_power"], + DEV_SIG_ENT_MAP: { ("device_tracker", "00:11:22:33:44:55:66:77-1"): { - "channels": ["power"], - "entity_class": "ZHADeviceScannerEntity", - "entity_id": "device_tracker.smartthings_tagv4_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "ZHADeviceScannerEntity", + DEV_SIG_ENT_MAP_ID: "device_tracker.smartthings_tagv4_77665544_power", }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-15"): { - "channels": ["binary_input"], - "entity_class": "BinaryInput", - "entity_id": "binary_sensor.smartthings_tagv4_77665544_binary_input", + DEV_SIG_CHANNELS: ["binary_input"], + DEV_SIG_ENT_MAP_CLASS: "BinaryInput", + DEV_SIG_ENT_MAP_ID: "binary_sensor.smartthings_tagv4_77665544_binary_input", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "SmartThings", - "model": "tagv4", - "node_descriptor": b"\x02@\x80\n\x11RR\x00\x00\x00R\x00\x00", - "zha_quirks": "SmartThingsTagV4", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "SmartThings", + SIG_MODEL: "tagv4", + SIG_NODE_DESC: b"\x02@\x80\n\x11RR\x00\x00\x00R\x00\x00", + DEV_SIG_ZHA_QUIRK: "SmartThingsTagV4", }, { - "device_no": 88, - "endpoints": { + DEV_SIG_DEV_NO: 88, + SIG_ENDPOINTS: { 1: { - "device_type": 2, - "endpoint_id": 1, - "in_clusters": [0, 3, 4, 5, 6, 25], - "out_clusters": [], - "profile_id": 260, + SIG_EP_TYPE: 2, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 25], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 260, } }, - "entities": ["switch.third_reality_inc_3rss007z_77665544_on_off"], - "entity_map": { + DEV_SIG_ENTITIES: ["switch.third_reality_inc_3rss007z_77665544_on_off"], + DEV_SIG_ENT_MAP: { ("switch", "00:11:22:33:44:55:66:77-1-6"): { - "channels": ["on_off"], - "entity_class": "Switch", - "entity_id": "switch.third_reality_inc_3rss007z_77665544_on_off", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.third_reality_inc_3rss007z_77665544_on_off", } }, - "event_channels": [], - "manufacturer": "Third Reality, Inc", - "model": "3RSS007Z", - "node_descriptor": b"\x02@\x803\x12\x7fd\x00\x00,d\x00\x00", + DEV_SIG_EVT_CHANNELS: [], + SIG_MANUFACTURER: "Third Reality, Inc", + SIG_MODEL: "3RSS007Z", + SIG_NODE_DESC: b"\x02@\x803\x12\x7fd\x00\x00,d\x00\x00", }, { - "device_no": 89, - "endpoints": { + DEV_SIG_DEV_NO: 89, + SIG_ENDPOINTS: { 1: { - "device_type": 2, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 4, 5, 6, 25], - "out_clusters": [1], - "profile_id": 260, + SIG_EP_TYPE: 2, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 4, 5, 6, 25], + SIG_EP_OUTPUT: [1], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "sensor.third_reality_inc_3rss008z_77665544_power", "switch.third_reality_inc_3rss008z_77665544_on_off", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.third_reality_inc_3rss008z_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.third_reality_inc_3rss008z_77665544_power", }, ("switch", "00:11:22:33:44:55:66:77-1-6"): { - "channels": ["on_off"], - "entity_class": "Switch", - "entity_id": "switch.third_reality_inc_3rss008z_77665544_on_off", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.third_reality_inc_3rss008z_77665544_on_off", }, }, - "event_channels": [], - "manufacturer": "Third Reality, Inc", - "model": "3RSS008Z", - "node_descriptor": b"\x02@\x803\x12\x7fd\x00\x00,d\x00\x00", - "zha_quirks": "Switch", + DEV_SIG_EVT_CHANNELS: [], + SIG_MANUFACTURER: "Third Reality, Inc", + SIG_MODEL: "3RSS008Z", + SIG_NODE_DESC: b"\x02@\x803\x12\x7fd\x00\x00,d\x00\x00", + DEV_SIG_ZHA_QUIRK: "Switch", }, { - "device_no": 90, - "endpoints": { + DEV_SIG_DEV_NO: 90, + SIG_ENDPOINTS: { 1: { - "device_type": 1026, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 32, 1026, 1280, 2821], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 32, 1026, 1280, 2821], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "binary_sensor.visonic_mct_340_e_77665544_ias_zone", "sensor.visonic_mct_340_e_77665544_power", "sensor.visonic_mct_340_e_77665544_temperature", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.visonic_mct_340_e_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.visonic_mct_340_e_77665544_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { - "channels": ["temperature"], - "entity_class": "Temperature", - "entity_id": "sensor.visonic_mct_340_e_77665544_temperature", + DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.visonic_mct_340_e_77665544_temperature", }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - "channels": ["ias_zone"], - "entity_class": "IASZone", - "entity_id": "binary_sensor.visonic_mct_340_e_77665544_ias_zone", + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.visonic_mct_340_e_77665544_ias_zone", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "Visonic", - "model": "MCT-340 E", - "node_descriptor": b"\x02@\x80\x11\x10RR\x00\x00\x00R\x00\x00", - "zha_quirks": "MCT340E", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "Visonic", + SIG_MODEL: "MCT-340 E", + SIG_NODE_DESC: b"\x02@\x80\x11\x10RR\x00\x00\x00R\x00\x00", + DEV_SIG_ZHA_QUIRK: "MCT340E", }, { - "device_no": 91, - "endpoints": { + DEV_SIG_DEV_NO: 91, + SIG_ENDPOINTS: { 1: { - "device_type": 769, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 4, 5, 32, 513, 514, 516, 2821], - "out_clusters": [10, 25], - "profile_id": 260, + SIG_EP_TYPE: 769, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 4, 5, 32, 513, 514, 516, 2821], + SIG_EP_OUTPUT: [10, 25], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "climate.zen_within_zen_01_77665544_fan_thermostat", "sensor.zen_within_zen_01_77665544_power", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.zen_within_zen_01_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.zen_within_zen_01_77665544_power", }, ("climate", "00:11:22:33:44:55:66:77-1"): { - "channels": ["thermostat", "fan"], - "entity_class": "ZenWithinThermostat", - "entity_id": "climate.zen_within_zen_01_77665544_fan_thermostat", + DEV_SIG_CHANNELS: ["thermostat", "fan"], + DEV_SIG_ENT_MAP_CLASS: "ZenWithinThermostat", + DEV_SIG_ENT_MAP_ID: "climate.zen_within_zen_01_77665544_fan_thermostat", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "Zen Within", - "model": "Zen-01", - "node_descriptor": b"\x02@\x80X\x11R\x80\x00\x00\x00\x80\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "Zen Within", + SIG_MODEL: "Zen-01", + SIG_NODE_DESC: b"\x02@\x80X\x11R\x80\x00\x00\x00\x80\x00\x00", }, { - "device_no": 92, - "endpoints": { + DEV_SIG_DEV_NO: 92, + SIG_ENDPOINTS: { 1: { - "device_type": 256, - "endpoint_id": 1, - "in_clusters": [0, 4, 5, 6, 10], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_TYPE: 256, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 4, 5, 6, 10], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, }, 2: { - "device_type": 256, - "endpoint_id": 2, - "in_clusters": [4, 5, 6], - "out_clusters": [], - "profile_id": 260, + SIG_EP_TYPE: 256, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [4, 5, 6], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 260, }, 3: { - "device_type": 256, - "endpoint_id": 3, - "in_clusters": [4, 5, 6], - "out_clusters": [], - "profile_id": 260, + SIG_EP_TYPE: 256, + DEV_SIG_EP_ID: 3, + SIG_EP_INPUT: [4, 5, 6], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 260, }, 4: { - "device_type": 256, - "endpoint_id": 4, - "in_clusters": [4, 5, 6], - "out_clusters": [], - "profile_id": 260, + SIG_EP_TYPE: 256, + DEV_SIG_EP_ID: 4, + SIG_EP_INPUT: [4, 5, 6], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 260, }, }, - "entities": [ + DEV_SIG_ENTITIES: [ "light.tyzb01_ns1ndbww_ts0004_77665544_on_off", "light.tyzb01_ns1ndbww_ts0004_77665544_on_off_2", "light.tyzb01_ns1ndbww_ts0004_77665544_on_off_3", "light.tyzb01_ns1ndbww_ts0004_77665544_on_off_4", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - "channels": ["on_off"], - "entity_class": "Light", - "entity_id": "light.tyzb01_ns1ndbww_ts0004_77665544_on_off_4", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.tyzb01_ns1ndbww_ts0004_77665544_on_off_4", }, ("light", "00:11:22:33:44:55:66:77-2"): { - "channels": ["on_off"], - "entity_class": "Light", - "entity_id": "light.tyzb01_ns1ndbww_ts0004_77665544_on_off_3", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.tyzb01_ns1ndbww_ts0004_77665544_on_off_3", }, ("light", "00:11:22:33:44:55:66:77-3"): { - "channels": ["on_off"], - "entity_class": "Light", - "entity_id": "light.tyzb01_ns1ndbww_ts0004_77665544_on_off", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.tyzb01_ns1ndbww_ts0004_77665544_on_off", }, ("light", "00:11:22:33:44:55:66:77-4"): { - "channels": ["on_off"], - "entity_class": "Light", - "entity_id": "light.tyzb01_ns1ndbww_ts0004_77665544_on_off_2", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.tyzb01_ns1ndbww_ts0004_77665544_on_off_2", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "_TYZB01_ns1ndbww", - "model": "TS0004", - "node_descriptor": b"\x01@\x8e\x02\x10R\x00\x02\x00,\x00\x02\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "_TYZB01_ns1ndbww", + SIG_MODEL: "TS0004", + SIG_NODE_DESC: b"\x01@\x8e\x02\x10R\x00\x02\x00,\x00\x02\x00", }, { - "device_no": 93, - "endpoints": { + DEV_SIG_DEV_NO: 93, + SIG_ENDPOINTS: { 1: { - "device_type": 1026, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 21, 32, 1280, 2821], - "out_clusters": [], - "profile_id": 260, + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 21, 32, 1280, 2821], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "binary_sensor.netvox_z308e3ed_77665544_ias_zone", "sensor.netvox_z308e3ed_77665544_power", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.netvox_z308e3ed_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.netvox_z308e3ed_77665544_power", }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - "channels": ["ias_zone"], - "entity_class": "IASZone", - "entity_id": "binary_sensor.netvox_z308e3ed_77665544_ias_zone", + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.netvox_z308e3ed_77665544_ias_zone", }, }, - "event_channels": [], - "manufacturer": "netvox", - "model": "Z308E3ED", - "node_descriptor": b"\x02@\x80\x9f\x10RR\x00\x00\x00R\x00\x00", - "zha_quirks": "Z308E3ED", + DEV_SIG_EVT_CHANNELS: [], + SIG_MANUFACTURER: "netvox", + SIG_MODEL: "Z308E3ED", + SIG_NODE_DESC: b"\x02@\x80\x9f\x10RR\x00\x00\x00R\x00\x00", + DEV_SIG_ZHA_QUIRK: "Z308E3ED", }, { - "device_no": 94, - "endpoints": { + DEV_SIG_DEV_NO: 94, + SIG_ENDPOINTS: { 1: { - "device_type": 257, - "endpoint_id": 1, - "in_clusters": [0, 3, 4, 5, 6, 8, 1794, 2821], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_TYPE: 257, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 1794, 2821], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "light.sengled_e11_g13_77665544_level_on_off", "sensor.sengled_e11_g13_77665544_smartenergy_metering", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - "channels": ["level", "on_off"], - "entity_class": "Light", - "entity_id": "light.sengled_e11_g13_77665544_level_on_off", + DEV_SIG_CHANNELS: ["level", "on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.sengled_e11_g13_77665544_level_on_off", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { - "channels": ["smartenergy_metering"], - "entity_class": "SmartEnergyMetering", - "entity_id": "sensor.sengled_e11_g13_77665544_smartenergy_metering", + DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", + DEV_SIG_ENT_MAP_ID: "sensor.sengled_e11_g13_77665544_smartenergy_metering", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "sengled", - "model": "E11-G13", - "node_descriptor": b"\x02@\x8c`\x11RR\x00\x00\x00R\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "sengled", + SIG_MODEL: "E11-G13", + SIG_NODE_DESC: b"\x02@\x8c`\x11RR\x00\x00\x00R\x00\x00", }, { - "device_no": 95, - "endpoints": { + DEV_SIG_DEV_NO: 95, + SIG_ENDPOINTS: { 1: { - "device_type": 257, - "endpoint_id": 1, - "in_clusters": [0, 3, 4, 5, 6, 8, 1794, 2821], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_TYPE: 257, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 1794, 2821], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "light.sengled_e12_n14_77665544_level_on_off", "sensor.sengled_e12_n14_77665544_smartenergy_metering", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - "channels": ["level", "on_off"], - "entity_class": "Light", - "entity_id": "light.sengled_e12_n14_77665544_level_on_off", + DEV_SIG_CHANNELS: ["level", "on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.sengled_e12_n14_77665544_level_on_off", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { - "channels": ["smartenergy_metering"], - "entity_class": "SmartEnergyMetering", - "entity_id": "sensor.sengled_e12_n14_77665544_smartenergy_metering", + DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", + DEV_SIG_ENT_MAP_ID: "sensor.sengled_e12_n14_77665544_smartenergy_metering", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "sengled", - "model": "E12-N14", - "node_descriptor": b"\x02@\x8c`\x11RR\x00\x00\x00R\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "sengled", + SIG_MODEL: "E12-N14", + SIG_NODE_DESC: b"\x02@\x8c`\x11RR\x00\x00\x00R\x00\x00", }, { - "device_no": 96, - "endpoints": { + DEV_SIG_DEV_NO: 96, + SIG_ENDPOINTS: { 1: { - "device_type": 257, - "endpoint_id": 1, - "in_clusters": [0, 3, 4, 5, 6, 8, 768, 1794, 2821], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_TYPE: 257, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 768, 1794, 2821], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "light.sengled_z01_a19nae26_77665544_level_light_color_on_off", "sensor.sengled_z01_a19nae26_77665544_smartenergy_metering", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - "channels": ["level", "light_color", "on_off"], - "entity_class": "Light", - "entity_id": "light.sengled_z01_a19nae26_77665544_level_light_color_on_off", + DEV_SIG_CHANNELS: ["level", "light_color", "on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.sengled_z01_a19nae26_77665544_level_light_color_on_off", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { - "channels": ["smartenergy_metering"], - "entity_class": "SmartEnergyMetering", - "entity_id": "sensor.sengled_z01_a19nae26_77665544_smartenergy_metering", + DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", + DEV_SIG_ENT_MAP_ID: "sensor.sengled_z01_a19nae26_77665544_smartenergy_metering", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "sengled", - "model": "Z01-A19NAE26", - "node_descriptor": b"\x02@\x8c`\x11RR\x00\x00\x00R\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "sengled", + SIG_MODEL: "Z01-A19NAE26", + SIG_NODE_DESC: b"\x02@\x8c`\x11RR\x00\x00\x00R\x00\x00", }, { - "device_no": 97, - "endpoints": { + DEV_SIG_DEV_NO: 97, + SIG_ENDPOINTS: { 1: { - "device_type": 512, - "endpoint_id": 1, - "in_clusters": [0, 3, 4, 5, 6, 8, 10, 21, 256, 64544, 64545], - "out_clusters": [3, 64544], - "profile_id": 260, + SIG_EP_TYPE: 512, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 10, 21, 256, 64544, 64545], + SIG_EP_OUTPUT: [3, 64544], + SIG_EP_PROFILE: 260, } }, - "entities": ["cover.unk_manufacturer_unk_model_77665544_level_on_off_shade"], - "entity_map": { + DEV_SIG_ENTITIES: [ + "cover.unk_manufacturer_unk_model_77665544_level_on_off_shade" + ], + DEV_SIG_ENT_MAP: { ("cover", "00:11:22:33:44:55:66:77-1"): { - "channels": ["level", "on_off", "shade"], - "entity_class": "Shade", - "entity_id": "cover.unk_manufacturer_unk_model_77665544_level_on_off_shade", + DEV_SIG_CHANNELS: ["level", "on_off", "shade"], + DEV_SIG_ENT_MAP_CLASS: "Shade", + DEV_SIG_ENT_MAP_ID: "cover.unk_manufacturer_unk_model_77665544_level_on_off_shade", } }, - "event_channels": [], - "manufacturer": "unk_manufacturer", - "model": "unk_model", - "node_descriptor": b"\x01@\x8e\x10\x11RR\x00\x00\x00R\x00\x00", + DEV_SIG_EVT_CHANNELS: [], + SIG_MANUFACTURER: "unk_manufacturer", + SIG_MODEL: "unk_model", + SIG_NODE_DESC: b"\x01@\x8e\x10\x11RR\x00\x00\x00R\x00\x00", }, { - "device_no": 98, - "endpoints": { + DEV_SIG_DEV_NO: 98, + SIG_ENDPOINTS: { 208: { - "endpoint_id": 208, - "profile_id": 49413, - "device_type": 0x0001, - "in_clusters": [0x0006, 0x000C], - "out_clusters": [], + DEV_SIG_EP_ID: 208, + SIG_EP_PROFILE: 49413, + SIG_EP_TYPE: 0x0001, + SIG_EP_INPUT: [0x0006, 0x000C], + SIG_EP_OUTPUT: [], }, 209: { - "endpoint_id": 209, - "profile_id": 49413, - "device_type": 0x0001, - "in_clusters": [0x0006, 0x000C], - "out_clusters": [], + DEV_SIG_EP_ID: 209, + SIG_EP_PROFILE: 49413, + SIG_EP_TYPE: 0x0001, + SIG_EP_INPUT: [0x0006, 0x000C], + SIG_EP_OUTPUT: [], }, 210: { - "endpoint_id": 210, - "profile_id": 49413, - "device_type": 0x0001, - "in_clusters": [0x0006, 0x000C], - "out_clusters": [], + DEV_SIG_EP_ID: 210, + SIG_EP_PROFILE: 49413, + SIG_EP_TYPE: 0x0001, + SIG_EP_INPUT: [0x0006, 0x000C], + SIG_EP_OUTPUT: [], }, 211: { - "endpoint_id": 211, - "profile_id": 49413, - "device_type": 0x0001, - "in_clusters": [0x0006, 0x000C], - "out_clusters": [], + DEV_SIG_EP_ID: 211, + SIG_EP_PROFILE: 49413, + SIG_EP_TYPE: 0x0001, + SIG_EP_INPUT: [0x0006, 0x000C], + SIG_EP_OUTPUT: [], }, 212: { - "endpoint_id": 212, - "profile_id": 49413, - "device_type": 0x0001, - "in_clusters": [0x0006], - "out_clusters": [], + DEV_SIG_EP_ID: 212, + SIG_EP_PROFILE: 49413, + SIG_EP_TYPE: 0x0001, + SIG_EP_INPUT: [0x0006], + SIG_EP_OUTPUT: [], }, 213: { - "endpoint_id": 213, - "profile_id": 49413, - "device_type": 0x0001, - "in_clusters": [0x0006], - "out_clusters": [], + DEV_SIG_EP_ID: 213, + SIG_EP_PROFILE: 49413, + SIG_EP_TYPE: 0x0001, + SIG_EP_INPUT: [0x0006], + SIG_EP_OUTPUT: [], }, 214: { - "endpoint_id": 214, - "profile_id": 49413, - "device_type": 0x0001, - "in_clusters": [0x0006], - "out_clusters": [], + DEV_SIG_EP_ID: 214, + SIG_EP_PROFILE: 49413, + SIG_EP_TYPE: 0x0001, + SIG_EP_INPUT: [0x0006], + SIG_EP_OUTPUT: [], }, 215: { - "endpoint_id": 215, - "profile_id": 49413, - "device_type": 0x0001, - "in_clusters": [0x0006, 0x000C], - "out_clusters": [], + DEV_SIG_EP_ID: 215, + SIG_EP_PROFILE: 49413, + SIG_EP_TYPE: 0x0001, + SIG_EP_INPUT: [0x0006, 0x000C], + SIG_EP_OUTPUT: [], }, 216: { - "endpoint_id": 216, - "profile_id": 49413, - "device_type": 0x0001, - "in_clusters": [0x0006], - "out_clusters": [], + DEV_SIG_EP_ID: 216, + SIG_EP_PROFILE: 49413, + SIG_EP_TYPE: 0x0001, + SIG_EP_INPUT: [0x0006], + SIG_EP_OUTPUT: [], }, 217: { - "endpoint_id": 217, - "profile_id": 49413, - "device_type": 0x0001, - "in_clusters": [0x0006], - "out_clusters": [], + DEV_SIG_EP_ID: 217, + SIG_EP_PROFILE: 49413, + SIG_EP_TYPE: 0x0001, + SIG_EP_INPUT: [0x0006], + SIG_EP_OUTPUT: [], }, 218: { - "endpoint_id": 218, - "profile_id": 49413, - "device_type": 0x0001, - "in_clusters": [0x0006, 0x000D], - "out_clusters": [], + DEV_SIG_EP_ID: 218, + SIG_EP_PROFILE: 49413, + SIG_EP_TYPE: 0x0001, + SIG_EP_INPUT: [0x0006, 0x000D], + SIG_EP_OUTPUT: [], }, 219: { - "endpoint_id": 219, - "profile_id": 49413, - "device_type": 0x0001, - "in_clusters": [0x0006, 0x000D], - "out_clusters": [], + DEV_SIG_EP_ID: 219, + SIG_EP_PROFILE: 49413, + SIG_EP_TYPE: 0x0001, + SIG_EP_INPUT: [0x0006, 0x000D], + SIG_EP_OUTPUT: [], }, 220: { - "endpoint_id": 220, - "profile_id": 49413, - "device_type": 0x0001, - "in_clusters": [0x0006], - "out_clusters": [], + DEV_SIG_EP_ID: 220, + SIG_EP_PROFILE: 49413, + SIG_EP_TYPE: 0x0001, + SIG_EP_INPUT: [0x0006], + SIG_EP_OUTPUT: [], }, 221: { - "endpoint_id": 221, - "profile_id": 49413, - "device_type": 0x0001, - "in_clusters": [0x0006], - "out_clusters": [], + DEV_SIG_EP_ID: 221, + SIG_EP_PROFILE: 49413, + SIG_EP_TYPE: 0x0001, + SIG_EP_INPUT: [0x0006], + SIG_EP_OUTPUT: [], }, 222: { - "endpoint_id": 222, - "profile_id": 49413, - "device_type": 0x0001, - "in_clusters": [0x0006], - "out_clusters": [], + DEV_SIG_EP_ID: 222, + SIG_EP_PROFILE: 49413, + SIG_EP_TYPE: 0x0001, + SIG_EP_INPUT: [0x0006], + SIG_EP_OUTPUT: [], }, 232: { - "endpoint_id": 232, - "profile_id": 49413, - "device_type": 0x0001, - "in_clusters": [0x0011, 0x0092], - "out_clusters": [0x0008, 0x0011], + DEV_SIG_EP_ID: 232, + SIG_EP_PROFILE: 49413, + SIG_EP_TYPE: 0x0001, + SIG_EP_INPUT: [0x0011, 0x0092], + SIG_EP_OUTPUT: [0x0008, 0x0011], }, }, - "entities": [ + DEV_SIG_ENTITIES: [ "switch.digi_xbee3_77665544_on_off", "switch.digi_xbee3_77665544_on_off_2", "switch.digi_xbee3_77665544_on_off_3", @@ -3635,121 +3670,121 @@ DEVICES = [ "number.digi_xbee3_77665544_analog_output", "number.digi_xbee3_77665544_analog_output_2", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("switch", "00:11:22:33:44:55:66:77-208-6"): { - "channels": ["on_off"], - "entity_class": "Switch", - "entity_id": "switch.digi_xbee3_77665544_on_off", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_77665544_on_off", }, ("switch", "00:11:22:33:44:55:66:77-209-6"): { - "channels": ["on_off"], - "entity_class": "Switch", - "entity_id": "switch.digi_xbee3_77665544_on_off_2", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_77665544_on_off_2", }, ("switch", "00:11:22:33:44:55:66:77-210-6"): { - "channels": ["on_off"], - "entity_class": "Switch", - "entity_id": "switch.digi_xbee3_77665544_on_off_3", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_77665544_on_off_3", }, ("switch", "00:11:22:33:44:55:66:77-211-6"): { - "channels": ["on_off"], - "entity_class": "Switch", - "entity_id": "switch.digi_xbee3_77665544_on_off_4", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_77665544_on_off_4", }, ("switch", "00:11:22:33:44:55:66:77-212-6"): { - "channels": ["on_off"], - "entity_class": "Switch", - "entity_id": "switch.digi_xbee3_77665544_on_off_5", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_77665544_on_off_5", }, ("switch", "00:11:22:33:44:55:66:77-213-6"): { - "channels": ["on_off"], - "entity_class": "Switch", - "entity_id": "switch.digi_xbee3_77665544_on_off_6", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_77665544_on_off_6", }, ("switch", "00:11:22:33:44:55:66:77-214-6"): { - "channels": ["on_off"], - "entity_class": "Switch", - "entity_id": "switch.digi_xbee3_77665544_on_off_7", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_77665544_on_off_7", }, ("switch", "00:11:22:33:44:55:66:77-215-6"): { - "channels": ["on_off"], - "entity_class": "Switch", - "entity_id": "switch.digi_xbee3_77665544_on_off_8", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_77665544_on_off_8", }, ("switch", "00:11:22:33:44:55:66:77-216-6"): { - "channels": ["on_off"], - "entity_class": "Switch", - "entity_id": "switch.digi_xbee3_77665544_on_off_9", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_77665544_on_off_9", }, ("switch", "00:11:22:33:44:55:66:77-217-6"): { - "channels": ["on_off"], - "entity_class": "Switch", - "entity_id": "switch.digi_xbee3_77665544_on_off_10", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_77665544_on_off_10", }, ("switch", "00:11:22:33:44:55:66:77-218-6"): { - "channels": ["on_off"], - "entity_class": "Switch", - "entity_id": "switch.digi_xbee3_77665544_on_off_11", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_77665544_on_off_11", }, ("switch", "00:11:22:33:44:55:66:77-219-6"): { - "channels": ["on_off"], - "entity_class": "Switch", - "entity_id": "switch.digi_xbee3_77665544_on_off_12", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_77665544_on_off_12", }, ("switch", "00:11:22:33:44:55:66:77-220-6"): { - "channels": ["on_off"], - "entity_class": "Switch", - "entity_id": "switch.digi_xbee3_77665544_on_off_13", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_77665544_on_off_13", }, ("switch", "00:11:22:33:44:55:66:77-221-6"): { - "channels": ["on_off"], - "entity_class": "Switch", - "entity_id": "switch.digi_xbee3_77665544_on_off_14", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_77665544_on_off_14", }, ("switch", "00:11:22:33:44:55:66:77-222-6"): { - "channels": ["on_off"], - "entity_class": "Switch", - "entity_id": "switch.digi_xbee3_77665544_on_off_15", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_77665544_on_off_15", }, ("sensor", "00:11:22:33:44:55:66:77-208-12"): { - "channels": ["analog_input"], - "entity_class": "AnalogInput", - "entity_id": "sensor.digi_xbee3_77665544_analog_input", + DEV_SIG_CHANNELS: ["analog_input"], + DEV_SIG_ENT_MAP_CLASS: "AnalogInput", + DEV_SIG_ENT_MAP_ID: "sensor.digi_xbee3_77665544_analog_input", }, ("sensor", "00:11:22:33:44:55:66:77-209-12"): { - "channels": ["analog_input"], - "entity_class": "AnalogInput", - "entity_id": "sensor.digi_xbee3_77665544_analog_input_2", + DEV_SIG_CHANNELS: ["analog_input"], + DEV_SIG_ENT_MAP_CLASS: "AnalogInput", + DEV_SIG_ENT_MAP_ID: "sensor.digi_xbee3_77665544_analog_input_2", }, ("sensor", "00:11:22:33:44:55:66:77-210-12"): { - "channels": ["analog_input"], - "entity_class": "AnalogInput", - "entity_id": "sensor.digi_xbee3_77665544_analog_input_3", + DEV_SIG_CHANNELS: ["analog_input"], + DEV_SIG_ENT_MAP_CLASS: "AnalogInput", + DEV_SIG_ENT_MAP_ID: "sensor.digi_xbee3_77665544_analog_input_3", }, ("sensor", "00:11:22:33:44:55:66:77-211-12"): { - "channels": ["analog_input"], - "entity_class": "AnalogInput", - "entity_id": "sensor.digi_xbee3_77665544_analog_input_4", + DEV_SIG_CHANNELS: ["analog_input"], + DEV_SIG_ENT_MAP_CLASS: "AnalogInput", + DEV_SIG_ENT_MAP_ID: "sensor.digi_xbee3_77665544_analog_input_4", }, ("sensor", "00:11:22:33:44:55:66:77-215-12"): { - "channels": ["analog_input"], - "entity_class": "AnalogInput", - "entity_id": "sensor.digi_xbee3_77665544_analog_input_5", + DEV_SIG_CHANNELS: ["analog_input"], + DEV_SIG_ENT_MAP_CLASS: "AnalogInput", + DEV_SIG_ENT_MAP_ID: "sensor.digi_xbee3_77665544_analog_input_5", }, ("number", "00:11:22:33:44:55:66:77-218-13"): { - "channels": ["analog_output"], - "entity_class": "ZhaNumber", - "entity_id": "number.digi_xbee3_77665544_analog_output", + DEV_SIG_CHANNELS: ["analog_output"], + DEV_SIG_ENT_MAP_CLASS: "ZhaNumber", + DEV_SIG_ENT_MAP_ID: "number.digi_xbee3_77665544_analog_output", }, ("number", "00:11:22:33:44:55:66:77-219-13"): { - "channels": ["analog_output"], - "entity_class": "ZhaNumber", - "entity_id": "number.digi_xbee3_77665544_analog_output_2", + DEV_SIG_CHANNELS: ["analog_output"], + DEV_SIG_ENT_MAP_CLASS: "ZhaNumber", + DEV_SIG_ENT_MAP_ID: "number.digi_xbee3_77665544_analog_output_2", }, }, - "event_channels": ["232:0x0008"], - "manufacturer": "Digi", - "model": "XBee3", - "node_descriptor": b"\x01@\x8e\x1e\x10R\xff\x00\x00,\xff\x00\x00", + DEV_SIG_EVT_CHANNELS: ["232:0x0008"], + SIG_MANUFACTURER: "Digi", + SIG_MODEL: "XBee3", + SIG_NODE_DESC: b"\x01@\x8e\x1e\x10R\xff\x00\x00,\xff\x00\x00", }, ] From 93083513b4715e6093b3949ef13c0dbe09b6d62f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 6 Sep 2021 16:05:33 -0700 Subject: [PATCH 271/843] Bump hass-nabucasa 49 (#55823) --- homeassistant/components/cloud/http_api.py | 89 ++++++-------------- homeassistant/components/cloud/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/cloud/conftest.py | 2 +- tests/components/cloud/test_http_api.py | 49 +---------- 7 files changed, 36 insertions(+), 112 deletions(-) diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index e9771012379..cab41ebb0b8 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -6,7 +6,7 @@ import logging import aiohttp import async_timeout import attr -from hass_nabucasa import Cloud, auth, thingtalk +from hass_nabucasa import Cloud, auth, cloud_api, thingtalk from hass_nabucasa.const import STATE_DISCONNECTED from hass_nabucasa.voice import MAP_VOICE import voluptuous as vol @@ -24,7 +24,6 @@ from homeassistant.const import ( HTTP_BAD_GATEWAY, HTTP_BAD_REQUEST, HTTP_INTERNAL_SERVER_ERROR, - HTTP_OK, HTTP_UNAUTHORIZED, ) @@ -47,30 +46,6 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -WS_TYPE_STATUS = "cloud/status" -SCHEMA_WS_STATUS = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( - {vol.Required("type"): WS_TYPE_STATUS} -) - - -WS_TYPE_SUBSCRIPTION = "cloud/subscription" -SCHEMA_WS_SUBSCRIPTION = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( - {vol.Required("type"): WS_TYPE_SUBSCRIPTION} -) - - -WS_TYPE_HOOK_CREATE = "cloud/cloudhook/create" -SCHEMA_WS_HOOK_CREATE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( - {vol.Required("type"): WS_TYPE_HOOK_CREATE, vol.Required("webhook_id"): str} -) - - -WS_TYPE_HOOK_DELETE = "cloud/cloudhook/delete" -SCHEMA_WS_HOOK_DELETE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( - {vol.Required("type"): WS_TYPE_HOOK_DELETE, vol.Required("webhook_id"): str} -) - - _CLOUD_ERRORS = { InvalidTrustedNetworks: ( HTTP_INTERNAL_SERVER_ERROR, @@ -94,17 +69,11 @@ _CLOUD_ERRORS = { async def async_setup(hass): """Initialize the HTTP API.""" async_register_command = hass.components.websocket_api.async_register_command - async_register_command(WS_TYPE_STATUS, websocket_cloud_status, SCHEMA_WS_STATUS) - async_register_command( - WS_TYPE_SUBSCRIPTION, websocket_subscription, SCHEMA_WS_SUBSCRIPTION - ) + async_register_command(websocket_cloud_status) + async_register_command(websocket_subscription) async_register_command(websocket_update_prefs) - async_register_command( - WS_TYPE_HOOK_CREATE, websocket_hook_create, SCHEMA_WS_HOOK_CREATE - ) - async_register_command( - WS_TYPE_HOOK_DELETE, websocket_hook_delete, SCHEMA_WS_HOOK_DELETE - ) + async_register_command(websocket_hook_create) + async_register_command(websocket_hook_delete) async_register_command(websocket_remote_connect) async_register_command(websocket_remote_disconnect) @@ -311,6 +280,7 @@ class CloudForgotPasswordView(HomeAssistantView): @websocket_api.async_response +@websocket_api.websocket_command({vol.Required("type"): "cloud/status"}) async def websocket_cloud_status(hass, connection, msg): """Handle request for account info. @@ -344,36 +314,19 @@ def _require_cloud_login(handler): @_require_cloud_login @websocket_api.async_response +@websocket_api.websocket_command({vol.Required("type"): "cloud/subscription"}) async def websocket_subscription(hass, connection, msg): """Handle request for account info.""" - cloud = hass.data[DOMAIN] - - with async_timeout.timeout(REQUEST_TIMEOUT): - response = await cloud.fetch_subscription_info() - - if response.status != HTTP_OK: - connection.send_message( - websocket_api.error_message( - msg["id"], "request_failed", "Failed to request subscription" - ) + try: + with async_timeout.timeout(REQUEST_TIMEOUT): + data = await cloud_api.async_subscription_info(cloud) + except aiohttp.ClientError: + connection.send_error( + msg["id"], "request_failed", "Failed to request subscription" ) - - data = await response.json() - - # Check if a user is subscribed but local info is outdated - # In that case, let's refresh and reconnect - if data.get("provider") and not cloud.is_connected: - _LOGGER.debug("Found disconnected account with valid subscriotion, connecting") - await cloud.auth.async_renew_access_token() - - # Cancel reconnect in progress - if cloud.iot.state != STATE_DISCONNECTED: - await cloud.iot.disconnect() - - hass.async_create_task(cloud.iot.connect()) - - connection.send_message(websocket_api.result_message(msg["id"], data)) + else: + connection.send_result(msg["id"], data) @_require_cloud_login @@ -429,6 +382,12 @@ async def websocket_update_prefs(hass, connection, msg): @_require_cloud_login @websocket_api.async_response @_ws_handle_cloud_errors +@websocket_api.websocket_command( + { + vol.Required("type"): "cloud/cloudhook/create", + vol.Required("webhook_id"): str, + } +) async def websocket_hook_create(hass, connection, msg): """Handle request for account info.""" cloud = hass.data[DOMAIN] @@ -439,6 +398,12 @@ async def websocket_hook_create(hass, connection, msg): @_require_cloud_login @websocket_api.async_response @_ws_handle_cloud_errors +@websocket_api.websocket_command( + { + vol.Required("type"): "cloud/cloudhook/delete", + vol.Required("webhook_id"): str, + } +) async def websocket_hook_delete(hass, connection, msg): """Handle request for account info.""" cloud = hass.data[DOMAIN] diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index d5e93a2a370..8eaad3b4129 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -2,7 +2,7 @@ "domain": "cloud", "name": "Home Assistant Cloud", "documentation": "https://www.home-assistant.io/integrations/cloud", - "requirements": ["hass-nabucasa==0.47.1"], + "requirements": ["hass-nabucasa==0.49.0"], "dependencies": ["http", "webhook"], "after_dependencies": ["google_assistant", "alexa"], "codeowners": ["@home-assistant/cloud"], diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index d32947244c5..e1d95a5cd23 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -15,7 +15,7 @@ ciso8601==2.1.3 cryptography==3.3.2 defusedxml==0.7.1 emoji==1.2.0 -hass-nabucasa==0.47.1 +hass-nabucasa==0.49.0 home-assistant-frontend==20210830.0 httpx==0.19.0 ifaddr==0.1.7 diff --git a/requirements_all.txt b/requirements_all.txt index 7fa52e65976..d6b076cd778 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -760,7 +760,7 @@ habitipy==0.2.0 hangups==0.4.14 # homeassistant.components.cloud -hass-nabucasa==0.47.1 +hass-nabucasa==0.49.0 # homeassistant.components.splunk hass_splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2266c9a0a6e..180c67cd3aa 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -441,7 +441,7 @@ habitipy==0.2.0 hangups==0.4.14 # homeassistant.components.cloud -hass-nabucasa==0.47.1 +hass-nabucasa==0.49.0 # homeassistant.components.tasmota hatasmota==0.2.20 diff --git a/tests/components/cloud/conftest.py b/tests/components/cloud/conftest.py index baa1dd6bae8..4bd5868db5f 100644 --- a/tests/components/cloud/conftest.py +++ b/tests/components/cloud/conftest.py @@ -12,7 +12,7 @@ from . import mock_cloud, mock_cloud_prefs @pytest.fixture(autouse=True) def mock_user_data(): """Mock os module.""" - with patch("hass_nabucasa.Cloud.write_user_info") as writer: + with patch("hass_nabucasa.Cloud._write_user_info") as writer: yield writer diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 35d261d5603..b678796a5c4 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -71,7 +71,7 @@ def setup_api_fixture(hass, aioclient_mock): @pytest.fixture(name="cloud_client") def cloud_client_fixture(hass, hass_client): """Fixture that can fetch from the cloud client.""" - with patch("hass_nabucasa.Cloud.write_user_info"): + with patch("hass_nabucasa.Cloud._write_user_info"): yield hass.loop.run_until_complete(hass_client()) @@ -394,59 +394,18 @@ async def test_websocket_status_not_logged_in(hass, hass_ws_client): assert response["result"] == {"logged_in": False, "cloud": "disconnected"} -async def test_websocket_subscription_reconnect( +async def test_websocket_subscription_info( hass, hass_ws_client, aioclient_mock, mock_auth, mock_cloud_login ): """Test querying the status and connecting because valid account.""" aioclient_mock.get(SUBSCRIPTION_INFO_URL, json={"provider": "stripe"}) client = await hass_ws_client(hass) - with patch( - "hass_nabucasa.auth.CognitoAuth.async_renew_access_token" - ) as mock_renew, patch("hass_nabucasa.iot.CloudIoT.connect") as mock_connect: + with patch("hass_nabucasa.auth.CognitoAuth.async_renew_access_token") as mock_renew: await client.send_json({"id": 5, "type": "cloud/subscription"}) response = await client.receive_json() - assert response["result"] == {"provider": "stripe"} assert len(mock_renew.mock_calls) == 1 - assert len(mock_connect.mock_calls) == 1 - - -async def test_websocket_subscription_no_reconnect_if_connected( - hass, hass_ws_client, aioclient_mock, mock_auth, mock_cloud_login -): - """Test querying the status and not reconnecting because still expired.""" - aioclient_mock.get(SUBSCRIPTION_INFO_URL, json={"provider": "stripe"}) - hass.data[DOMAIN].iot.state = STATE_CONNECTED - client = await hass_ws_client(hass) - - with patch( - "hass_nabucasa.auth.CognitoAuth.async_renew_access_token" - ) as mock_renew, patch("hass_nabucasa.iot.CloudIoT.connect") as mock_connect: - await client.send_json({"id": 5, "type": "cloud/subscription"}) - response = await client.receive_json() - - assert response["result"] == {"provider": "stripe"} - assert len(mock_renew.mock_calls) == 0 - assert len(mock_connect.mock_calls) == 0 - - -async def test_websocket_subscription_no_reconnect_if_expired( - hass, hass_ws_client, aioclient_mock, mock_auth, mock_cloud_login -): - """Test querying the status and not reconnecting because still expired.""" - aioclient_mock.get(SUBSCRIPTION_INFO_URL, json={"provider": "stripe"}) - client = await hass_ws_client(hass) - - with patch( - "hass_nabucasa.auth.CognitoAuth.async_renew_access_token" - ) as mock_renew, patch("hass_nabucasa.iot.CloudIoT.connect") as mock_connect: - await client.send_json({"id": 5, "type": "cloud/subscription"}) - response = await client.receive_json() - - assert response["result"] == {"provider": "stripe"} - assert len(mock_renew.mock_calls) == 1 - assert len(mock_connect.mock_calls) == 1 async def test_websocket_subscription_fail( @@ -466,7 +425,7 @@ async def test_websocket_subscription_not_logged_in(hass, hass_ws_client): """Test querying the status.""" client = await hass_ws_client(hass) with patch( - "hass_nabucasa.Cloud.fetch_subscription_info", + "hass_nabucasa.cloud_api.async_subscription_info", return_value={"return": "value"}, ): await client.send_json({"id": 5, "type": "cloud/subscription"}) From 9da3fa5d75ff4c8aec6ff85407080e01c08159c3 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Tue, 7 Sep 2021 00:11:29 +0000 Subject: [PATCH 272/843] [ci skip] Translation update --- .../components/cloud/translations/nl.json | 1 + .../components/cloud/translations/th.json | 7 ++++++ .../components/homekit/translations/th.json | 11 ++++++++++ .../components/iotawatt/translations/nl.json | 14 ++++++++++++ .../components/iotawatt/translations/th.json | 18 +++++++++++++++ .../components/mqtt/translations/th.json | 3 +++ .../components/nanoleaf/translations/th.json | 22 +++++++++++++++++++ .../nmap_tracker/translations/th.json | 11 ++++++++++ .../components/openuv/translations/th.json | 13 +++++++++++ .../components/renault/translations/de.json | 6 +++++ .../components/renault/translations/hu.json | 10 ++++++++- .../components/renault/translations/nl.json | 3 +++ .../components/renault/translations/th.json | 15 +++++++++++++ .../synology_dsm/translations/th.json | 7 ++++++ .../components/zha/translations/th.json | 7 ++++++ 15 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/cloud/translations/th.json create mode 100644 homeassistant/components/homekit/translations/th.json create mode 100644 homeassistant/components/iotawatt/translations/nl.json create mode 100644 homeassistant/components/iotawatt/translations/th.json create mode 100644 homeassistant/components/nanoleaf/translations/th.json create mode 100644 homeassistant/components/nmap_tracker/translations/th.json create mode 100644 homeassistant/components/openuv/translations/th.json create mode 100644 homeassistant/components/renault/translations/th.json create mode 100644 homeassistant/components/synology_dsm/translations/th.json create mode 100644 homeassistant/components/zha/translations/th.json diff --git a/homeassistant/components/cloud/translations/nl.json b/homeassistant/components/cloud/translations/nl.json index 7d02a04cd01..eebe8d14be5 100644 --- a/homeassistant/components/cloud/translations/nl.json +++ b/homeassistant/components/cloud/translations/nl.json @@ -10,6 +10,7 @@ "relayer_connected": "Relayer verbonden", "remote_connected": "Op afstand verbonden", "remote_enabled": "Op afstand ingeschakeld", + "remote_server": "Externe server", "subscription_expiration": "Afloop abonnement" } } diff --git a/homeassistant/components/cloud/translations/th.json b/homeassistant/components/cloud/translations/th.json new file mode 100644 index 00000000000..1171381d568 --- /dev/null +++ b/homeassistant/components/cloud/translations/th.json @@ -0,0 +1,7 @@ +{ + "system_health": { + "info": { + "remote_server": "\u0e40\u0e0b\u0e34\u0e23\u0e4c\u0e1f\u0e40\u0e27\u0e2d\u0e23\u0e4c\u0e23\u0e30\u0e22\u0e30\u0e44\u0e01\u0e25" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit/translations/th.json b/homeassistant/components/homekit/translations/th.json new file mode 100644 index 00000000000..50e648c9e6e --- /dev/null +++ b/homeassistant/components/homekit/translations/th.json @@ -0,0 +1,11 @@ +{ + "options": { + "step": { + "advanced": { + "data": { + "devices": "\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c (\u0e17\u0e23\u0e34\u0e01\u0e40\u0e01\u0e2d\u0e23\u0e4c)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/iotawatt/translations/nl.json b/homeassistant/components/iotawatt/translations/nl.json new file mode 100644 index 00000000000..3d1e6d3ef17 --- /dev/null +++ b/homeassistant/components/iotawatt/translations/nl.json @@ -0,0 +1,14 @@ +{ + "config": { + "step": { + "auth": { + "description": "Het IoTawatt-apparaat vereist authenticatie. Voer de gebruikersnaam en het wachtwoord in en klik op de knop Verzenden." + }, + "user": { + "data": { + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/iotawatt/translations/th.json b/homeassistant/components/iotawatt/translations/th.json new file mode 100644 index 00000000000..705e334ef92 --- /dev/null +++ b/homeassistant/components/iotawatt/translations/th.json @@ -0,0 +1,18 @@ +{ + "config": { + "error": { + "cannot_connect": "\u0e01\u0e32\u0e23\u0e40\u0e0a\u0e37\u0e48\u0e2d\u0e21\u0e15\u0e48\u0e2d\u0e25\u0e49\u0e21\u0e40\u0e2b\u0e25\u0e27", + "invalid_auth": "\u0e01\u0e32\u0e23\u0e15\u0e23\u0e27\u0e08\u0e2a\u0e2d\u0e1a\u0e2a\u0e34\u0e17\u0e18\u0e34\u0e4c\u0e17\u0e35\u0e48\u0e44\u0e21\u0e48\u0e16\u0e39\u0e01\u0e15\u0e49\u0e2d\u0e07", + "unknown": "\u0e02\u0e49\u0e2d\u0e1c\u0e34\u0e14\u0e1e\u0e25\u0e32\u0e14\u0e17\u0e35\u0e48\u0e44\u0e21\u0e48\u0e04\u0e32\u0e14\u0e04\u0e34\u0e14" + }, + "step": { + "auth": { + "data": { + "password": "\u0e23\u0e2b\u0e31\u0e2a\u0e1c\u0e48\u0e32\u0e19", + "username": "\u0e0a\u0e37\u0e48\u0e2d\u0e1c\u0e39\u0e49\u0e43\u0e0a\u0e49" + }, + "description": "\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c IoTawatt \u0e15\u0e49\u0e2d\u0e07\u0e01\u0e32\u0e23\u0e01\u0e32\u0e23\u0e23\u0e31\u0e1a\u0e23\u0e2d\u0e07\u0e04\u0e27\u0e32\u0e21\u0e16\u0e39\u0e01\u0e15\u0e49\u0e2d\u0e07 \u0e01\u0e23\u0e38\u0e13\u0e32\u0e43\u0e2a\u0e48\u0e0a\u0e37\u0e48\u0e2d\u0e1c\u0e39\u0e49\u0e43\u0e0a\u0e49\u0e01\u0e31\u0e1a\u0e23\u0e2b\u0e31\u0e2a\u0e1c\u0e48\u0e32\u0e19 \u0e41\u0e25\u0e30\u0e04\u0e25\u0e34\u0e01\u0e1b\u0e38\u0e48\u0e21\u0e2a\u0e48\u0e07" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mqtt/translations/th.json b/homeassistant/components/mqtt/translations/th.json index 293b7e34314..624df71b786 100644 --- a/homeassistant/components/mqtt/translations/th.json +++ b/homeassistant/components/mqtt/translations/th.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "\u0e1a\u0e23\u0e34\u0e01\u0e32\u0e23\u0e19\u0e35\u0e49\u0e01\u0e33\u0e2b\u0e19\u0e14\u0e04\u0e48\u0e32\u0e41\u0e25\u0e49\u0e27" + }, "step": { "broker": { "data": { diff --git a/homeassistant/components/nanoleaf/translations/th.json b/homeassistant/components/nanoleaf/translations/th.json new file mode 100644 index 00000000000..c2cf2963754 --- /dev/null +++ b/homeassistant/components/nanoleaf/translations/th.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "reauth_successful": "\u0e01\u0e32\u0e23\u0e15\u0e23\u0e27\u0e08\u0e2a\u0e2d\u0e1a\u0e2a\u0e34\u0e17\u0e18\u0e34\u0e4c\u0e2d\u0e35\u0e01\u0e04\u0e23\u0e31\u0e49\u0e07\u0e2a\u0e33\u0e40\u0e23\u0e47\u0e08" + }, + "error": { + "unknown": "\u0e02\u0e49\u0e2d\u0e1c\u0e34\u0e14\u0e1e\u0e25\u0e32\u0e14\u0e17\u0e35\u0e48\u0e44\u0e21\u0e48\u0e04\u0e32\u0e14\u0e14\u0e34\u0e14" + }, + "flow_title": "{name}", + "step": { + "link": { + "description": "\u0e01\u0e14\u0e1b\u0e38\u0e48\u0e21\u0e40\u0e1b\u0e34\u0e14/\u0e1b\u0e34\u0e14\u0e1a\u0e19 Nanoleaf \u0e02\u0e2d\u0e07\u0e04\u0e38\u0e13\u0e04\u0e49\u0e32\u0e07\u0e44\u0e27\u0e49 5 \u0e27\u0e34\u0e19\u0e32\u0e17\u0e35 \u0e08\u0e19\u0e01\u0e27\u0e48\u0e32\u0e44\u0e1f LED \u0e02\u0e2d\u0e07\u0e1b\u0e38\u0e48\u0e21\u0e40\u0e23\u0e34\u0e48\u0e21\u0e01\u0e30\u0e1e\u0e23\u0e34\u0e1a \u0e08\u0e32\u0e01\u0e19\u0e31\u0e49\u0e19\u0e04\u0e25\u0e34\u0e01 **\u0e2a\u0e48\u0e07** \u0e20\u0e32\u0e22\u0e43\u0e19 30 \u0e27\u0e34\u0e19\u0e32\u0e17\u0e35", + "title": "\u0e25\u0e34\u0e07\u0e01\u0e4c\u0e01\u0e31\u0e1a Nanoleaf" + }, + "user": { + "data": { + "host": "\u0e42\u0e2e\u0e2a\u0e15\u0e4c" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nmap_tracker/translations/th.json b/homeassistant/components/nmap_tracker/translations/th.json new file mode 100644 index 00000000000..f5e99824ffd --- /dev/null +++ b/homeassistant/components/nmap_tracker/translations/th.json @@ -0,0 +1,11 @@ +{ + "options": { + "step": { + "init": { + "data": { + "consider_home": "\u0e42\u0e1b\u0e23\u0e14\u0e23\u0e2d\u0e2a\u0e31\u0e01\u0e04\u0e23\u0e39\u0e48 \u0e08\u0e19\u0e01\u0e27\u0e48\u0e32\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c\u0e15\u0e34\u0e14\u0e15\u0e32\u0e21 \u0e17\u0e33\u0e40\u0e04\u0e23\u0e37\u0e48\u0e2d\u0e07\u0e2b\u0e21\u0e32\u0e22\u0e27\u0e48\u0e32\u0e44\u0e21\u0e48\u0e2d\u0e22\u0e39\u0e48\u0e1a\u0e49\u0e32\u0e19 \u0e2b\u0e25\u0e31\u0e07\u0e08\u0e32\u0e01\u0e17\u0e35\u0e48\u0e44\u0e21\u0e48\u0e21\u0e35\u0e43\u0e04\u0e23\u0e40\u0e2b\u0e47\u0e19" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/openuv/translations/th.json b/homeassistant/components/openuv/translations/th.json new file mode 100644 index 00000000000..4f741655c5c --- /dev/null +++ b/homeassistant/components/openuv/translations/th.json @@ -0,0 +1,13 @@ +{ + "options": { + "step": { + "init": { + "data": { + "from_window": "\u0e40\u0e23\u0e34\u0e48\u0e21\u0e14\u0e31\u0e0a\u0e19\u0e35 UV \u0e2a\u0e4d\u0e32\u0e2b\u0e23\u0e31\u0e1a\u0e2b\u0e19\u0e49\u0e32\u0e15\u0e48\u0e32\u0e07\u0e1b\u0e49\u0e2d\u0e07\u0e01\u0e31\u0e19", + "to_window": "\u0e2a\u0e34\u0e49\u0e19\u0e2a\u0e38\u0e14\u0e14\u0e31\u0e0a\u0e19\u0e35 UV \u0e2a\u0e33\u0e2b\u0e23\u0e31\u0e1a\u0e2b\u0e19\u0e49\u0e32\u0e15\u0e48\u0e32\u0e07\u0e1b\u0e49\u0e2d\u0e07\u0e01\u0e31\u0e19" + }, + "title": "\u0e01\u0e33\u0e2b\u0e19\u0e14\u0e04\u0e48\u0e32 OpenUV" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/renault/translations/de.json b/homeassistant/components/renault/translations/de.json index 16650b8d63e..2cf2b2e2805 100644 --- a/homeassistant/components/renault/translations/de.json +++ b/homeassistant/components/renault/translations/de.json @@ -14,6 +14,12 @@ }, "title": "Kamereon-Kontonummer ausw\u00e4hlen" }, + "reauth_confirm": { + "data": { + "password": "Passwort" + }, + "description": "Bitte \u00e4ndere Dein Passwort f\u00fcr {username}" + }, "user": { "data": { "locale": "Gebietsschema", diff --git a/homeassistant/components/renault/translations/hu.json b/homeassistant/components/renault/translations/hu.json index eeace0b9b85..9e63117a7c4 100644 --- a/homeassistant/components/renault/translations/hu.json +++ b/homeassistant/components/renault/translations/hu.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van", - "kamereon_no_account": "Nem tal\u00e1lhat\u00f3 a Kamereon-fi\u00f3k." + "kamereon_no_account": "Nem tal\u00e1lhat\u00f3 a Kamereon-fi\u00f3k.", + "reauth_successful": "Az ism\u00e9telt hiteles\u00edt\u00e9s sikeres volt" }, "error": { "invalid_credentials": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s" @@ -14,6 +15,13 @@ }, "title": "V\u00e1lassza ki a Kamereon-fi\u00f3k azonos\u00edt\u00f3j\u00e1t" }, + "reauth_confirm": { + "data": { + "password": "Jelsz\u00f3" + }, + "description": "K\u00e9rj\u00fck, friss\u00edtse a (z) {username} jelszav\u00e1t", + "title": "Integr\u00e1ci\u00f3 \u00fajb\u00f3li hiteles\u00edt\u00e9se" + }, "user": { "data": { "locale": "Helysz\u00edn", diff --git a/homeassistant/components/renault/translations/nl.json b/homeassistant/components/renault/translations/nl.json index 4840dd0c07b..1ca9e0ae32b 100644 --- a/homeassistant/components/renault/translations/nl.json +++ b/homeassistant/components/renault/translations/nl.json @@ -14,6 +14,9 @@ }, "title": "Selecteer Kamereon-account-ID" }, + "reauth_confirm": { + "description": "Werk uw wachtwoord voor {gebruikersnaam} bij" + }, "user": { "data": { "locale": "Locale", diff --git a/homeassistant/components/renault/translations/th.json b/homeassistant/components/renault/translations/th.json new file mode 100644 index 00000000000..e6b4b542eba --- /dev/null +++ b/homeassistant/components/renault/translations/th.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "reauth_successful": "\u0e01\u0e32\u0e23\u0e15\u0e23\u0e27\u0e08\u0e2a\u0e2d\u0e1a\u0e2a\u0e34\u0e17\u0e18\u0e34\u0e4c\u0e2d\u0e35\u0e01\u0e04\u0e23\u0e31\u0e49\u0e07\u0e2a\u0e33\u0e40\u0e23\u0e47\u0e08" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "\u0e23\u0e2b\u0e31\u0e2a\u0e1c\u0e48\u0e32\u0e19" + }, + "description": "\u0e42\u0e1b\u0e23\u0e14\u0e2d\u0e31\u0e1b\u0e40\u0e14\u0e15\u0e23\u0e2b\u0e31\u0e2a\u0e1c\u0e48\u0e32\u0e19\u0e02\u0e2d\u0e07\u0e04\u0e38\u0e13\u0e2a\u0e33\u0e2b\u0e23\u0e31\u0e1a {username}" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/synology_dsm/translations/th.json b/homeassistant/components/synology_dsm/translations/th.json new file mode 100644 index 00000000000..301c5267873 --- /dev/null +++ b/homeassistant/components/synology_dsm/translations/th.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "reconfigure_successful": "\u0e01\u0e33\u0e2b\u0e19\u0e14\u0e04\u0e48\u0e32\u0e43\u0e2b\u0e21\u0e48\u0e2a\u0e33\u0e40\u0e23\u0e47\u0e08" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/zha/translations/th.json b/homeassistant/components/zha/translations/th.json new file mode 100644 index 00000000000..cd83e39dc92 --- /dev/null +++ b/homeassistant/components/zha/translations/th.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "usb_probe_failed": "\u0e15\u0e23\u0e27\u0e08\u0e2a\u0e2d\u0e1a\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c USB \u0e44\u0e21\u0e48\u0e2a\u0e33\u0e40\u0e23\u0e47\u0e08" + } + } +} \ No newline at end of file From 789f21c427bb19df7cac560c1fc458d7c2597553 Mon Sep 17 00:00:00 2001 From: Sean Vig Date: Mon, 6 Sep 2021 22:52:45 -0400 Subject: [PATCH 273/843] Fix assignment of amcrest camera model (#55266) --- homeassistant/components/amcrest/camera.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/amcrest/camera.py b/homeassistant/components/amcrest/camera.py index 98dd15adcfb..12fec04ce9c 100644 --- a/homeassistant/components/amcrest/camera.py +++ b/homeassistant/components/amcrest/camera.py @@ -374,15 +374,16 @@ class AmcrestCam(Camera): try: if self._brand is None: resp = self._api.vendor_information.strip() + _LOGGER.debug("Assigned brand=%s", resp) if resp.startswith("vendor="): self._brand = resp.split("=")[-1] else: self._brand = "unknown" if self._model is None: resp = self._api.device_type.strip() - _LOGGER.debug("Device_type=%s", resp) - if resp.startswith("type="): - self._model = resp.split("=")[-1] + _LOGGER.debug("Assigned model=%s", resp) + if resp: + self._model = resp else: self._model = "unknown" if self._attr_unique_id is None: From 1ca9deb520101a105d6653fb88146575529174e9 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Tue, 7 Sep 2021 07:12:54 +0100 Subject: [PATCH 274/843] Integration Sensor Initial State (#55875) * initial state is UNAVAILABLE * update tests --- homeassistant/components/integration/sensor.py | 11 ++++++++--- tests/components/integration/test_sensor.py | 7 +++---- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index d9a41cf714e..c0e4a723515 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -106,7 +106,7 @@ class IntegrationSensor(RestoreEntity, SensorEntity): """Initialize the integration sensor.""" self._sensor_source_id = source_entity self._round_digits = round_digits - self._state = 0 + self._state = STATE_UNAVAILABLE self._method = integration_method self._name = name if name is not None else f"{source_entity} integral" @@ -187,7 +187,10 @@ class IntegrationSensor(RestoreEntity, SensorEntity): except AssertionError as err: _LOGGER.error("Could not calculate integral: %s", err) else: - self._state += integral + if isinstance(self._state, Decimal): + self._state += integral + else: + self._state = integral self.async_write_ha_state() async_track_state_change_event( @@ -202,7 +205,9 @@ class IntegrationSensor(RestoreEntity, SensorEntity): @property def native_value(self): """Return the state of the sensor.""" - return round(self._state, self._round_digits) + if isinstance(self._state, Decimal): + return round(self._state, self._round_digits) + return self._state @property def native_unit_of_measurement(self): diff --git a/tests/components/integration/test_sensor.py b/tests/components/integration/test_sensor.py index 5eff62835ba..354f4af95ba 100644 --- a/tests/components/integration/test_sensor.py +++ b/tests/components/integration/test_sensor.py @@ -81,7 +81,6 @@ async def test_restore_state(hass: HomeAssistant) -> None: "platform": "integration", "name": "integration", "source": "sensor.power", - "unit": ENERGY_KILO_WATT_HOUR, "round": 2, } } @@ -116,7 +115,6 @@ async def test_restore_state_failed(hass: HomeAssistant) -> None: "platform": "integration", "name": "integration", "source": "sensor.power", - "unit": ENERGY_KILO_WATT_HOUR, } } @@ -125,9 +123,10 @@ async def test_restore_state_failed(hass: HomeAssistant) -> None: state = hass.states.get("sensor.integration") assert state - assert state.state == "0" - assert state.attributes.get("unit_of_measurement") == ENERGY_KILO_WATT_HOUR + assert state.state == "unavailable" + assert state.attributes.get("unit_of_measurement") is None assert state.attributes.get("state_class") == STATE_CLASS_TOTAL + assert "device_class" not in state.attributes From 0d1412ea1726a4bf3f2d3a4cea0cbb6eda423a38 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 7 Sep 2021 08:13:14 +0200 Subject: [PATCH 275/843] Set state class to total for net utility_meter sensors (#55877) * Set state class to total for net utility_meter sensors * Update tests --- homeassistant/components/utility_meter/sensor.py | 4 ++-- tests/components/utility_meter/test_sensor.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index ee3fed02a6b..4ff3c04355d 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -8,7 +8,7 @@ import voluptuous as vol from homeassistant.components.sensor import ( ATTR_LAST_RESET, - STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL, STATE_CLASS_TOTAL_INCREASING, SensorEntity, ) @@ -357,7 +357,7 @@ class UtilityMeterSensor(RestoreEntity, SensorEntity): def state_class(self): """Return the device class of the sensor.""" return ( - STATE_CLASS_MEASUREMENT + STATE_CLASS_TOTAL if self._sensor_net_consumption else STATE_CLASS_TOTAL_INCREASING ) diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index 5627daec7f8..ff30f0d66c2 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -5,7 +5,7 @@ from unittest.mock import patch from homeassistant.components.sensor import ( ATTR_STATE_CLASS, - STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL, STATE_CLASS_TOTAL_INCREASING, ) from homeassistant.components.utility_meter.const import ( @@ -219,7 +219,7 @@ async def test_device_class(hass): assert state is not None assert state.state == "0" assert state.attributes.get(ATTR_DEVICE_CLASS) is None - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None state = hass.states.get("sensor.gas_meter") @@ -241,7 +241,7 @@ async def test_device_class(hass): assert state is not None assert state.state == "1" assert state.attributes.get(ATTR_DEVICE_CLASS) == "energy" - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR state = hass.states.get("sensor.gas_meter") From 2f3a11f930e093ac243fa33fb6fd40c0d4914b72 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Tue, 7 Sep 2021 09:10:50 +0200 Subject: [PATCH 276/843] Rewrite re-auth mechanism in Synology DSM integration (#54298) --- .../components/synology_dsm/__init__.py | 31 ++----- .../components/synology_dsm/config_flow.py | 92 +++++++++---------- .../components/synology_dsm/strings.json | 3 +- .../synology_dsm/translations/en.json | 3 +- .../synology_dsm/test_config_flow.py | 33 +++---- 5 files changed, 71 insertions(+), 91 deletions(-) diff --git a/homeassistant/components/synology_dsm/__init__.py b/homeassistant/components/synology_dsm/__init__.py index 685d2c27d60..87eb345b03d 100644 --- a/homeassistant/components/synology_dsm/__init__.py +++ b/homeassistant/components/synology_dsm/__init__.py @@ -26,7 +26,7 @@ from synology_dsm.exceptions import ( SynologyDSMRequestException, ) -from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ATTRIBUTION, CONF_HOST, @@ -40,7 +40,7 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry, entity_registry import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import ( @@ -193,27 +193,14 @@ async def async_setup_entry( # noqa: C901 details = err.args[0].get(EXCEPTION_DETAILS, EXCEPTION_UNKNOWN) else: details = EXCEPTION_UNKNOWN - _LOGGER.debug( - "Reauthentication for DSM '%s' needed - reason: %s", - entry.unique_id, - details, - ) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={ - "source": SOURCE_REAUTH, - "data": {**entry.data}, - EXCEPTION_DETAILS: details, - }, - ) - ) - return False + raise ConfigEntryAuthFailed(f"reason: {details}") from err except (SynologyDSMLoginFailedException, SynologyDSMRequestException) as err: - _LOGGER.debug( - "Unable to connect to DSM '%s' during setup: %s", entry.unique_id, err - ) - raise ConfigEntryNotReady from err + if err.args[0] and isinstance(err.args[0], dict): + # pylint: disable=no-member + details = err.args[0].get(EXCEPTION_DETAILS, EXCEPTION_UNKNOWN) + else: + details = EXCEPTION_UNKNOWN + raise ConfigEntryNotReady(details) from err hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.unique_id] = { diff --git a/homeassistant/components/synology_dsm/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py index 003af39ecdf..ae24adc7960 100644 --- a/homeassistant/components/synology_dsm/config_flow.py +++ b/homeassistant/components/synology_dsm/config_flow.py @@ -46,7 +46,6 @@ from .const import ( DEFAULT_USE_SSL, DEFAULT_VERIFY_SSL, DOMAIN, - EXCEPTION_DETAILS, ) _LOGGER = logging.getLogger(__name__) @@ -58,11 +57,11 @@ def _discovery_schema_with_defaults(discovery_info: DiscoveryInfoType) -> vol.Sc return vol.Schema(_ordered_shared_schema(discovery_info)) -def _reauth_schema_with_defaults(user_input: dict[str, Any]) -> vol.Schema: +def _reauth_schema() -> vol.Schema: return vol.Schema( { - vol.Required(CONF_USERNAME, default=user_input.get(CONF_USERNAME, "")): str, - vol.Required(CONF_PASSWORD, default=user_input.get(CONF_PASSWORD, "")): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, } ) @@ -113,8 +112,9 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): self.reauth_conf: dict[str, Any] = {} self.reauth_reason: str | None = None - async def _show_setup_form( + def _show_form( self, + step_id: str, user_input: dict[str, Any] | None = None, errors: dict[str, str] | None = None, ) -> FlowResult: @@ -123,19 +123,15 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): user_input = {} description_placeholders = {} + data_schema = {} - if self.discovered_conf: + if step_id == "link": user_input.update(self.discovered_conf) - step_id = "link" data_schema = _discovery_schema_with_defaults(user_input) description_placeholders = self.discovered_conf - elif self.reauth_conf: - user_input.update(self.reauth_conf) - step_id = "reauth" - data_schema = _reauth_schema_with_defaults(user_input) - description_placeholders = {EXCEPTION_DETAILS: self.reauth_reason} - else: - step_id = "user" + elif step_id == "reauth_confirm": + data_schema = _reauth_schema() + elif step_id == "user": data_schema = _user_schema_with_defaults(user_input) return self.async_show_form( @@ -145,27 +141,10 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): description_placeholders=description_placeholders, ) - async def async_step_user( - self, user_input: dict[str, Any] | None = None + async def async_validate_input_create_entry( + self, user_input: dict[str, Any], step_id: str ) -> FlowResult: - """Handle a flow initiated by the user.""" - errors = {} - - if user_input is None: - return await self._show_setup_form(user_input, None) - - if self.discovered_conf: - user_input.update(self.discovered_conf) - - if self.reauth_conf: - self.reauth_conf.update( - { - CONF_USERNAME: user_input[CONF_USERNAME], - CONF_PASSWORD: user_input[CONF_PASSWORD], - } - ) - user_input.update(self.reauth_conf) - + """Process user input and create new or update existing config entry.""" host = user_input[CONF_HOST] port = user_input.get(CONF_PORT) username = user_input[CONF_USERNAME] @@ -184,6 +163,7 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): host, port, username, password, use_ssl, verify_ssl, timeout=30 ) + errors = {} try: serial = await self.hass.async_add_executor_job( _login_and_fetch_syno_info, api, otp_code @@ -207,7 +187,7 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): errors["base"] = "missing_data" if errors: - return await self._show_setup_form(user_input, errors) + return self._show_form(step_id, user_input, errors) # unique_id should be serial for services purpose existing_entry = await self.async_set_unique_id(serial, raise_on_progress=False) @@ -239,6 +219,15 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=host, data=config_data) + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by the user.""" + step = "user" + if not user_input: + return self._show_form(step) + return await self.async_validate_input_create_entry(user_input, step_id=step) + async def async_step_ssdp(self, discovery_info: DiscoveryInfoType) -> FlowResult: """Handle a discovered synology_dsm.""" parsed_url = urlparse(discovery_info[ssdp.ATTR_SSDP_LOCATION]) @@ -272,21 +261,32 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): CONF_HOST: parsed_url.hostname, } self.context["title_placeholders"] = self.discovered_conf - return await self.async_step_user() + return await self.async_step_link() - async def async_step_reauth( + async def async_step_link( self, user_input: dict[str, Any] | None = None ) -> FlowResult: - """Perform reauth upon an API authentication error.""" - self.reauth_conf = self.context.get("data", {}) - self.reauth_reason = self.context.get(EXCEPTION_DETAILS) - if user_input is None: - return await self.async_step_user() - return await self.async_step_user(user_input) - - async def async_step_link(self, user_input: dict[str, Any]) -> FlowResult: """Link a config entry from discovery.""" - return await self.async_step_user(user_input) + step = "link" + if not user_input: + return self._show_form(step) + user_input = {**self.discovered_conf, **user_input} + return await self.async_validate_input_create_entry(user_input, step_id=step) + + async def async_step_reauth(self, data: dict[str, Any]) -> FlowResult: + """Perform reauth upon an API authentication error.""" + self.reauth_conf = data.copy() + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Perform reauth confirm upon an API authentication error.""" + step = "reauth_confirm" + if not user_input: + return self._show_form(step) + user_input = {**self.reauth_conf, **user_input} + return await self.async_validate_input_create_entry(user_input, step_id=step) async def async_step_2sa( self, user_input: dict[str, Any], errors: dict[str, str] | None = None diff --git a/homeassistant/components/synology_dsm/strings.json b/homeassistant/components/synology_dsm/strings.json index 9a7500e08bc..d5d0728db77 100644 --- a/homeassistant/components/synology_dsm/strings.json +++ b/homeassistant/components/synology_dsm/strings.json @@ -30,9 +30,8 @@ "port": "[%key:common::config_flow::data::port%]" } }, - "reauth": { + "reauth_confirm": { "title": "Synology DSM [%key:common::config_flow::title::reauth%]", - "description": "Reason: {details}", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" diff --git a/homeassistant/components/synology_dsm/translations/en.json b/homeassistant/components/synology_dsm/translations/en.json index 15384a1690e..789f20f57bd 100644 --- a/homeassistant/components/synology_dsm/translations/en.json +++ b/homeassistant/components/synology_dsm/translations/en.json @@ -31,12 +31,11 @@ "description": "Do you want to setup {name} ({host})?", "title": "Synology DSM" }, - "reauth": { + "reauth_confirm": { "data": { "password": "Password", "username": "Username" }, - "description": "Reason: {details}", "title": "Synology DSM Reauthenticate Integration" }, "user": { diff --git a/tests/components/synology_dsm/test_config_flow.py b/tests/components/synology_dsm/test_config_flow.py index 34434eb4a43..dec720cfd72 100644 --- a/tests/components/synology_dsm/test_config_flow.py +++ b/tests/components/synology_dsm/test_config_flow.py @@ -257,7 +257,7 @@ async def test_user_vdsm(hass: HomeAssistant, service_vdsm: MagicMock): async def test_reauth(hass: HomeAssistant, service: MagicMock): """Test reauthentication.""" - MockConfigEntry( + entry = MockConfigEntry( domain=DOMAIN, data={ CONF_HOST: HOST, @@ -265,7 +265,8 @@ async def test_reauth(hass: HomeAssistant, service: MagicMock): CONF_PASSWORD: f"{PASSWORD}_invalid", }, unique_id=SERIAL, - ).add_to_hass(hass) + ) + entry.add_to_hass(hass) with patch( "homeassistant.config_entries.ConfigEntries.async_reload", @@ -276,27 +277,21 @@ async def test_reauth(hass: HomeAssistant, service: MagicMock): DOMAIN, context={ "source": SOURCE_REAUTH, - "data": { - CONF_HOST: HOST, - CONF_USERNAME: USERNAME, - CONF_PASSWORD: PASSWORD, - }, + "entry_id": entry.entry_id, + "unique_id": entry.unique_id, + }, + data={ + CONF_HOST: HOST, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, }, ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "reauth" + assert result["step_id"] == "reauth_confirm" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={ - "source": SOURCE_REAUTH, - "data": { - CONF_HOST: HOST, - CONF_USERNAME: USERNAME, - CONF_PASSWORD: PASSWORD, - }, - }, - data={ + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD, }, From 53ea24ec159875de2c57390bbd4494ee2d389f74 Mon Sep 17 00:00:00 2001 From: cnico Date: Tue, 7 Sep 2021 09:52:42 +0200 Subject: [PATCH 277/843] Add Flipr binary sensor (#53525) --- homeassistant/components/flipr/__init__.py | 33 ++++--- .../components/flipr/binary_sensor.py | 46 +++++++++ homeassistant/components/flipr/sensor.py | 96 +++++++------------ tests/components/flipr/test_sensors.py | 22 +++-- 4 files changed, 116 insertions(+), 81 deletions(-) create mode 100644 homeassistant/components/flipr/binary_sensor.py diff --git a/homeassistant/components/flipr/__init__.py b/homeassistant/components/flipr/__init__.py index 66ea93484f7..f1320dafda1 100644 --- a/homeassistant/components/flipr/__init__.py +++ b/homeassistant/components/flipr/__init__.py @@ -5,21 +5,22 @@ import logging from flipr_api import FliprAPIRestClient from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.const import ATTR_ATTRIBUTION, CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, ) -from .const import CONF_FLIPR_ID, DOMAIN, MANUFACTURER, NAME +from .const import ATTRIBUTION, CONF_FLIPR_ID, DOMAIN, MANUFACTURER, NAME _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(minutes=60) -PLATFORMS = ["sensor"] +PLATFORMS = ["sensor", "binary_sensor"] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -75,14 +76,22 @@ class FliprDataUpdateCoordinator(DataUpdateCoordinator): class FliprEntity(CoordinatorEntity): """Implements a common class elements representing the Flipr component.""" - def __init__(self, coordinator, flipr_id, info_type): + _attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} + + def __init__( + self, coordinator: DataUpdateCoordinator, description: EntityDescription + ) -> None: """Initialize Flipr sensor.""" super().__init__(coordinator) - self._attr_unique_id = f"{flipr_id}-{info_type}" - self._attr_device_info = { - "identifiers": {(DOMAIN, flipr_id)}, - "name": NAME, - "manufacturer": MANUFACTURER, - } - self.info_type = info_type - self.flipr_id = flipr_id + self.entity_description = description + if coordinator.config_entry: + flipr_id = coordinator.config_entry.data[CONF_FLIPR_ID] + self._attr_unique_id = f"{flipr_id}-{description.key}" + + self._attr_device_info = { + "identifiers": {(DOMAIN, flipr_id)}, + "name": NAME, + "manufacturer": MANUFACTURER, + } + + self._attr_name = f"Flipr {flipr_id} {description.name}" diff --git a/homeassistant/components/flipr/binary_sensor.py b/homeassistant/components/flipr/binary_sensor.py new file mode 100644 index 00000000000..400c1562088 --- /dev/null +++ b/homeassistant/components/flipr/binary_sensor.py @@ -0,0 +1,46 @@ +"""Support for Flipr binary sensors.""" +from __future__ import annotations + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_PROBLEM, + BinarySensorEntity, + BinarySensorEntityDescription, +) + +from . import FliprEntity +from .const import DOMAIN + +BINARY_SENSORS_TYPES: tuple[BinarySensorEntityDescription, ...] = ( + BinarySensorEntityDescription( + key="ph_status", + name="PH Status", + device_class=DEVICE_CLASS_PROBLEM, + ), + BinarySensorEntityDescription( + key="chlorine_status", + name="Chlorine Status", + device_class=DEVICE_CLASS_PROBLEM, + ), +) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Defer sensor setup of flipr binary sensors.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + FliprBinarySensor(coordinator, description) + for description in BINARY_SENSORS_TYPES + ) + + +class FliprBinarySensor(FliprEntity, BinarySensorEntity): + """Representation of Flipr binary sensors.""" + + @property + def is_on(self): + """Return true if the binary sensor is on in case of a Problem is detected.""" + return ( + self.coordinator.data[self.entity_description.key] == "TooLow" + or self.coordinator.data[self.entity_description.key] == "TooHigh" + ) diff --git a/homeassistant/components/flipr/sensor.py b/homeassistant/components/flipr/sensor.py index 0d986114659..6466c58fae2 100644 --- a/homeassistant/components/flipr/sensor.py +++ b/homeassistant/components/flipr/sensor.py @@ -1,9 +1,10 @@ """Sensor platform for the Flipr's pool_sensor.""" +from __future__ import annotations + from datetime import datetime -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import ( - ATTR_ATTRIBUTION, DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_TIMESTAMP, ELECTRIC_POTENTIAL_MILLIVOLT, @@ -11,78 +12,55 @@ from homeassistant.const import ( ) from . import FliprEntity -from .const import ATTRIBUTION, CONF_FLIPR_ID, DOMAIN +from .const import DOMAIN -SENSORS = { - "chlorine": { - "unit": ELECTRIC_POTENTIAL_MILLIVOLT, - "icon": "mdi:pool", - "name": "Chlorine", - "device_class": None, - }, - "ph": {"unit": None, "icon": "mdi:pool", "name": "pH", "device_class": None}, - "temperature": { - "unit": TEMP_CELSIUS, - "icon": None, - "name": "Water Temp", - "device_class": DEVICE_CLASS_TEMPERATURE, - }, - "date_time": { - "unit": None, - "icon": None, - "name": "Last Measured", - "device_class": DEVICE_CLASS_TIMESTAMP, - }, - "red_ox": { - "unit": ELECTRIC_POTENTIAL_MILLIVOLT, - "icon": "mdi:pool", - "name": "Red OX", - "device_class": None, - }, -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="chlorine", + name="Chlorine", + native_unit_of_measurement=ELECTRIC_POTENTIAL_MILLIVOLT, + icon="mdi:pool", + ), + SensorEntityDescription( + key="ph", + name="pH", + icon="mdi:pool", + ), + SensorEntityDescription( + key="temperature", + name="Water Temp", + device_class=DEVICE_CLASS_TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + ), + SensorEntityDescription( + key="date_time", + name="Last Measured", + device_class=DEVICE_CLASS_TIMESTAMP, + ), + SensorEntityDescription( + key="red_ox", + name="Red OX", + native_unit_of_measurement=ELECTRIC_POTENTIAL_MILLIVOLT, + icon="mdi:pool", + ), +) async def async_setup_entry(hass, config_entry, async_add_entities): """Defer sensor setup to the shared sensor module.""" - flipr_id = config_entry.data[CONF_FLIPR_ID] coordinator = hass.data[DOMAIN][config_entry.entry_id] - sensors_list = [] - for sensor in SENSORS: - sensors_list.append(FliprSensor(coordinator, flipr_id, sensor)) - - async_add_entities(sensors_list, True) + sensors = [FliprSensor(coordinator, description) for description in SENSOR_TYPES] + async_add_entities(sensors, True) class FliprSensor(FliprEntity, SensorEntity): """Sensor representing FliprSensor data.""" - _attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} - - @property - def name(self): - """Return the name of the particular component.""" - return f"Flipr {self.flipr_id} {SENSORS[self.info_type]['name']}" - @property def native_value(self): """State of the sensor.""" - state = self.coordinator.data[self.info_type] + state = self.coordinator.data[self.entity_description.key] if isinstance(state, datetime): return state.isoformat() return state - - @property - def device_class(self): - """Return the device class.""" - return SENSORS[self.info_type]["device_class"] - - @property - def icon(self): - """Return the icon.""" - return SENSORS[self.info_type]["icon"] - - @property - def native_unit_of_measurement(self): - """Return unit of measurement.""" - return SENSORS[self.info_type]["unit"] diff --git a/tests/components/flipr/test_sensors.py b/tests/components/flipr/test_sensors.py index 244ec61507c..7c4855dae0a 100644 --- a/tests/components/flipr/test_sensors.py +++ b/tests/components/flipr/test_sensors.py @@ -3,7 +3,6 @@ from datetime import datetime from unittest.mock import patch from homeassistant.components.flipr.const import CONF_FLIPR_ID, DOMAIN -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import ( ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, @@ -45,15 +44,6 @@ async def test_sensors(hass: HomeAssistant) -> None: registry = await hass.helpers.entity_registry.async_get_registry() - # Pre-create registry entries for sensors - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "my_random_entity_id", - suggested_object_id="sensor.flipr_myfliprid_chlorine", - disabled_by=None, - ) - with patch( "flipr_api.FliprAPIRestClient.get_pool_measure_latest", return_value=MOCK_FLIPR_MEASURE, @@ -61,6 +51,10 @@ async def test_sensors(hass: HomeAssistant) -> None: await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() + # Check entity unique_id value that is generated in FliprEntity base class. + entity = registry.async_get("sensor.flipr_myfliprid_red_ox") + assert entity.unique_id == "myfliprid-red_ox" + state = hass.states.get("sensor.flipr_myfliprid_ph") assert state assert state.attributes.get(ATTR_ICON) == "mdi:pool" @@ -90,3 +84,11 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_ICON) == "mdi:pool" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "mV" assert state.state == "0.23654886" + + state = hass.states.get("binary_sensor.flipr_myfliprid_ph_status") + assert state + assert state.state == "on" # Alert is on for binary sensor + + state = hass.states.get("binary_sensor.flipr_myfliprid_chlorine_status") + assert state + assert state.state == "off" From 86247c93fc8ab9e6bfb50757b3ceb6a50080d59e Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 7 Sep 2021 11:34:41 +0200 Subject: [PATCH 278/843] Fix available property for Xiaomi Miio fan platform (#55889) * Fix available * Suggested change --- homeassistant/components/xiaomi_miio/fan.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index 75a7fda60d0..b3dbb4fa379 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -311,7 +311,7 @@ class XiaomiGenericDevice(XiaomiCoordinatedMiioEntity, FanEntity): @property def available(self): """Return true when state is known.""" - return self._available + return super().available and self._available @property def extra_state_attributes(self): From 3aed58f825c310826d4ea16b785f34d49853d221 Mon Sep 17 00:00:00 2001 From: RDFurman Date: Tue, 7 Sep 2021 08:32:26 -0600 Subject: [PATCH 279/843] Try to avoid rate limiting in honeywell (#55304) * Limit parallel update and sleep loop * Use asyncio sleep instead * Extract sleep to const for testing * Make loop sleep 0 in test --- homeassistant/components/honeywell/__init__.py | 17 ++++++++++------- homeassistant/components/honeywell/climate.py | 4 +++- tests/components/honeywell/test_init.py | 4 ++++ 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/honeywell/__init__.py b/homeassistant/components/honeywell/__init__.py index 29f0dbb8392..03dc9ea9c8c 100644 --- a/homeassistant/components/honeywell/__init__.py +++ b/homeassistant/components/honeywell/__init__.py @@ -1,4 +1,5 @@ """Support for Honeywell (US) Total Connect Comfort climate systems.""" +import asyncio from datetime import timedelta import somecomfort @@ -9,7 +10,8 @@ from homeassistant.util import Throttle from .const import _LOGGER, CONF_DEV_ID, CONF_LOC_ID, DOMAIN -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=180) +UPDATE_LOOP_SLEEP_TIME = 5 +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=300) PLATFORMS = ["climate"] @@ -42,7 +44,7 @@ async def async_setup_entry(hass, config): return False data = HoneywellData(hass, client, username, password, devices) - await data.update() + await data.async_update() hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][config.entry_id] = data hass.config_entries.async_setup_platforms(config, PLATFORMS) @@ -102,18 +104,19 @@ class HoneywellData: self.devices = devices return True - def _refresh_devices(self): + async def _refresh_devices(self): """Refresh each enabled device.""" for device in self.devices: - device.refresh() + await self._hass.async_add_executor_job(device.refresh) + await asyncio.sleep(UPDATE_LOOP_SLEEP_TIME) @Throttle(MIN_TIME_BETWEEN_UPDATES) - async def update(self) -> None: + async def async_update(self) -> None: """Update the state.""" retries = 3 while retries > 0: try: - await self._hass.async_add_executor_job(self._refresh_devices) + await self._refresh_devices() break except ( somecomfort.client.APIRateLimited, @@ -124,7 +127,7 @@ class HoneywellData: if retries == 0: raise exp - result = await self._hass.async_add_executor_job(self._retry()) + result = await self._retry() if not result: raise exp diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index 230aa8ec424..8088a73506d 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -107,6 +107,8 @@ HW_FAN_MODE_TO_HA = { "follow schedule": FAN_AUTO, } +PARALLEL_UPDATES = 1 + async def async_setup_entry(hass, config, async_add_entities, discovery_info=None): """Set up the Honeywell thermostat.""" @@ -384,4 +386,4 @@ class HoneywellUSThermostat(ClimateEntity): async def async_update(self): """Get the latest state from the service.""" - await self._data.update() + await self._data.async_update() diff --git a/tests/components/honeywell/test_init.py b/tests/components/honeywell/test_init.py index 7cc6b64cd63..619d770c59e 100644 --- a/tests/components/honeywell/test_init.py +++ b/tests/components/honeywell/test_init.py @@ -1,11 +1,14 @@ """Test honeywell setup process.""" +from unittest.mock import patch + from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry +@patch("homeassistant.components.honeywell.UPDATE_LOOP_SLEEP_TIME", 0) async def test_setup_entry(hass: HomeAssistant, config_entry: MockConfigEntry): """Initialize the config entry.""" config_entry.add_to_hass(hass) @@ -15,6 +18,7 @@ async def test_setup_entry(hass: HomeAssistant, config_entry: MockConfigEntry): assert hass.states.async_entity_ids_count() == 1 +@patch("homeassistant.components.honeywell.UPDATE_LOOP_SLEEP_TIME", 0) async def test_setup_multiple_thermostats( hass: HomeAssistant, config_entry: MockConfigEntry, location, another_device ) -> None: From 0684f8bddf699bc217d3fa61c47a6aa79facd558 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Tue, 7 Sep 2021 17:46:12 +0300 Subject: [PATCH 280/843] Add date device class (#55887) * Add date device class https://github.com/home-assistant/architecture/discussions/610 * Add date device class to sensors device classes list --- homeassistant/components/sensor/__init__.py | 2 ++ homeassistant/const.py | 1 + 2 files changed, 3 insertions(+) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 370b36bf1fc..3306bbb4241 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -18,6 +18,7 @@ from homeassistant.const import ( DEVICE_CLASS_CO, DEVICE_CLASS_CO2, DEVICE_CLASS_CURRENT, + DEVICE_CLASS_DATE, DEVICE_CLASS_ENERGY, DEVICE_CLASS_GAS, DEVICE_CLASS_HUMIDITY, @@ -69,6 +70,7 @@ DEVICE_CLASSES: Final[list[str]] = [ DEVICE_CLASS_CO, # ppm (parts per million) Carbon Monoxide gas concentration DEVICE_CLASS_CO2, # ppm (parts per million) Carbon Dioxide gas concentration DEVICE_CLASS_CURRENT, # current (A) + DEVICE_CLASS_DATE, # date (ISO8601) DEVICE_CLASS_ENERGY, # energy (kWh, Wh) DEVICE_CLASS_HUMIDITY, # % of humidity in the air DEVICE_CLASS_ILLUMINANCE, # current light level (lx/lm) diff --git a/homeassistant/const.py b/homeassistant/const.py index 4f495e93cea..f0aec3aaa79 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -237,6 +237,7 @@ DEVICE_CLASS_BATTERY: Final = "battery" DEVICE_CLASS_CO: Final = "carbon_monoxide" DEVICE_CLASS_CO2: Final = "carbon_dioxide" DEVICE_CLASS_CURRENT: Final = "current" +DEVICE_CLASS_DATE: Final = "date" DEVICE_CLASS_ENERGY: Final = "energy" DEVICE_CLASS_HUMIDITY: Final = "humidity" DEVICE_CLASS_ILLUMINANCE: Final = "illuminance" From d705b35ea1fb7800efcc77f4e3f08a3d1ec961d7 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Tue, 7 Sep 2021 18:40:20 +0100 Subject: [PATCH 281/843] Address comment in integration Riemann sum PR #55875 (#55895) * https://github.com/home-assistant/core/pull/55875\#discussion_r703334504 * missing test update --- homeassistant/components/integration/sensor.py | 2 +- tests/components/integration/test_sensor.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index c0e4a723515..250d10cd9c4 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -106,7 +106,7 @@ class IntegrationSensor(RestoreEntity, SensorEntity): """Initialize the integration sensor.""" self._sensor_source_id = source_entity self._round_digits = round_digits - self._state = STATE_UNAVAILABLE + self._state = None self._method = integration_method self._name = name if name is not None else f"{source_entity} integral" diff --git a/tests/components/integration/test_sensor.py b/tests/components/integration/test_sensor.py index 354f4af95ba..03a43fd2c66 100644 --- a/tests/components/integration/test_sensor.py +++ b/tests/components/integration/test_sensor.py @@ -123,7 +123,7 @@ async def test_restore_state_failed(hass: HomeAssistant) -> None: state = hass.states.get("sensor.integration") assert state - assert state.state == "unavailable" + assert state.state == "unknown" assert state.attributes.get("unit_of_measurement") is None assert state.attributes.get("state_class") == STATE_CLASS_TOTAL From 42f586c5853d7261e99056a3a0278a2f7acad27c Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 7 Sep 2021 22:27:03 +0200 Subject: [PATCH 282/843] Fix upnp add_entities (#55904) * Fix upnp add_entities * Remove nesting level --- homeassistant/components/upnp/const.py | 3 - homeassistant/components/upnp/sensor.py | 184 ++++++++++++------------ 2 files changed, 89 insertions(+), 98 deletions(-) diff --git a/homeassistant/components/upnp/const.py b/homeassistant/components/upnp/const.py index 142c00ad27f..d66a954962f 100644 --- a/homeassistant/components/upnp/const.py +++ b/homeassistant/components/upnp/const.py @@ -31,6 +31,3 @@ DEFAULT_SCAN_INTERVAL = timedelta(seconds=30).total_seconds() ST_IGD_V1 = "urn:schemas-upnp-org:device:InternetGatewayDevice:1" ST_IGD_V2 = "urn:schemas-upnp-org:device:InternetGatewayDevice:2" SSDP_SEARCH_TIMEOUT = 4 - -RAW_SENSOR = "raw_sensor" -DERIVED_SENSOR = "derived_sensor" diff --git a/homeassistant/components/upnp/sensor.py b/homeassistant/components/upnp/sensor.py index bebb8e3e957..8ad8677b647 100644 --- a/homeassistant/components/upnp/sensor.py +++ b/homeassistant/components/upnp/sensor.py @@ -13,99 +13,95 @@ from .const import ( BYTES_SENT, DATA_PACKETS, DATA_RATE_PACKETS_PER_SECOND, - DERIVED_SENSOR, DOMAIN, KIBIBYTE, - LOGGER, PACKETS_RECEIVED, PACKETS_SENT, - RAW_SENSOR, ROUTER_IP, ROUTER_UPTIME, TIMESTAMP, WAN_STATUS, ) -SENSOR_ENTITY_DESCRIPTIONS: dict[str, tuple[UpnpSensorEntityDescription, ...]] = { - RAW_SENSOR: ( - UpnpSensorEntityDescription( - key=BYTES_RECEIVED, - name=f"{DATA_BYTES} received", - icon="mdi:server-network", - native_unit_of_measurement=DATA_BYTES, - format="d", - ), - UpnpSensorEntityDescription( - key=BYTES_SENT, - name=f"{DATA_BYTES} sent", - icon="mdi:server-network", - native_unit_of_measurement=DATA_BYTES, - format="d", - ), - UpnpSensorEntityDescription( - key=PACKETS_RECEIVED, - name=f"{DATA_PACKETS} received", - icon="mdi:server-network", - native_unit_of_measurement=DATA_PACKETS, - format="d", - ), - UpnpSensorEntityDescription( - key=PACKETS_SENT, - name=f"{DATA_PACKETS} sent", - icon="mdi:server-network", - native_unit_of_measurement=DATA_PACKETS, - format="d", - ), - UpnpSensorEntityDescription( - key=ROUTER_IP, - name="External IP", - icon="mdi:server-network", - ), - UpnpSensorEntityDescription( - key=ROUTER_UPTIME, - name="Uptime", - icon="mdi:server-network", - native_unit_of_measurement=TIME_SECONDS, - entity_registry_enabled_default=False, - format="d", - ), - UpnpSensorEntityDescription( - key=WAN_STATUS, - name="wan status", - icon="mdi:server-network", - ), +RAW_SENSORS: tuple[UpnpSensorEntityDescription, ...] = ( + UpnpSensorEntityDescription( + key=BYTES_RECEIVED, + name=f"{DATA_BYTES} received", + icon="mdi:server-network", + native_unit_of_measurement=DATA_BYTES, + format="d", ), - DERIVED_SENSOR: ( - UpnpSensorEntityDescription( - key="KiB/sec_received", - name=f"{DATA_RATE_KIBIBYTES_PER_SECOND} received", - icon="mdi:server-network", - native_unit_of_measurement=DATA_RATE_KIBIBYTES_PER_SECOND, - format=".1f", - ), - UpnpSensorEntityDescription( - key="KiB/sent", - name=f"{DATA_RATE_KIBIBYTES_PER_SECOND} sent", - icon="mdi:server-network", - native_unit_of_measurement=DATA_RATE_KIBIBYTES_PER_SECOND, - format=".1f", - ), - UpnpSensorEntityDescription( - key="packets/sec_received", - name=f"{DATA_RATE_PACKETS_PER_SECOND} received", - icon="mdi:server-network", - native_unit_of_measurement=DATA_RATE_PACKETS_PER_SECOND, - format=".1f", - ), - UpnpSensorEntityDescription( - key="packets/sent", - name=f"{DATA_RATE_PACKETS_PER_SECOND} sent", - icon="mdi:server-network", - native_unit_of_measurement=DATA_RATE_PACKETS_PER_SECOND, - format=".1f", - ), + UpnpSensorEntityDescription( + key=BYTES_SENT, + name=f"{DATA_BYTES} sent", + icon="mdi:server-network", + native_unit_of_measurement=DATA_BYTES, + format="d", ), -} + UpnpSensorEntityDescription( + key=PACKETS_RECEIVED, + name=f"{DATA_PACKETS} received", + icon="mdi:server-network", + native_unit_of_measurement=DATA_PACKETS, + format="d", + ), + UpnpSensorEntityDescription( + key=PACKETS_SENT, + name=f"{DATA_PACKETS} sent", + icon="mdi:server-network", + native_unit_of_measurement=DATA_PACKETS, + format="d", + ), + UpnpSensorEntityDescription( + key=ROUTER_IP, + name="External IP", + icon="mdi:server-network", + ), + UpnpSensorEntityDescription( + key=ROUTER_UPTIME, + name="Uptime", + icon="mdi:server-network", + native_unit_of_measurement=TIME_SECONDS, + entity_registry_enabled_default=False, + format="d", + ), + UpnpSensorEntityDescription( + key=WAN_STATUS, + name="wan status", + icon="mdi:server-network", + ), +) + +DERIVED_SENSORS: tuple[UpnpSensorEntityDescription, ...] = ( + UpnpSensorEntityDescription( + key="KiB/sec_received", + name=f"{DATA_RATE_KIBIBYTES_PER_SECOND} received", + icon="mdi:server-network", + native_unit_of_measurement=DATA_RATE_KIBIBYTES_PER_SECOND, + format=".1f", + ), + UpnpSensorEntityDescription( + key="KiB/sent", + name=f"{DATA_RATE_KIBIBYTES_PER_SECOND} sent", + icon="mdi:server-network", + native_unit_of_measurement=DATA_RATE_KIBIBYTES_PER_SECOND, + format=".1f", + ), + UpnpSensorEntityDescription( + key="packets/sec_received", + name=f"{DATA_RATE_PACKETS_PER_SECOND} received", + icon="mdi:server-network", + native_unit_of_measurement=DATA_RATE_PACKETS_PER_SECOND, + format=".1f", + ), + UpnpSensorEntityDescription( + key="packets/sent", + name=f"{DATA_RATE_PACKETS_PER_SECOND} sent", + icon="mdi:server-network", + native_unit_of_measurement=DATA_RATE_PACKETS_PER_SECOND, + format=".1f", + ), +) async def async_setup_entry( @@ -116,25 +112,23 @@ async def async_setup_entry( """Set up the UPnP/IGD sensors.""" coordinator = hass.data[DOMAIN][config_entry.entry_id] - LOGGER.debug("Adding sensors") - - entities = [] - entities.append( + entities: list[UpnpSensor] = [ RawUpnpSensor( coordinator=coordinator, entity_description=entity_description, ) - for entity_description in SENSOR_ENTITY_DESCRIPTIONS[RAW_SENSOR] - if coordinator.data.get(entity_description.key) is not None - ) - - entities.append( - DerivedUpnpSensor( - coordinator=coordinator, - entity_description=entity_description, - ) - for entity_description in SENSOR_ENTITY_DESCRIPTIONS[DERIVED_SENSOR] + for entity_description in RAW_SENSORS if coordinator.data.get(entity_description.key) is not None + ] + entities.extend( + [ + DerivedUpnpSensor( + coordinator=coordinator, + entity_description=entity_description, + ) + for entity_description in DERIVED_SENSORS + if coordinator.data.get(entity_description.key) is not None + ] ) async_add_entities(entities) From 37d75e8a030f32fb54fb891954da62c079c5f3a3 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Tue, 7 Sep 2021 17:50:07 -0400 Subject: [PATCH 283/843] Allow multiple template.select platform entries (#55908) --- homeassistant/components/template/select.py | 26 +++++++-------- tests/components/template/test_select.py | 36 +++++++++++++++++++-- 2 files changed, 47 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/template/select.py b/homeassistant/components/template/select.py index 944c80cbfa4..96e86e8caec 100644 --- a/homeassistant/components/template/select.py +++ b/homeassistant/components/template/select.py @@ -46,27 +46,27 @@ SELECT_SCHEMA = vol.Schema( async def _async_create_entities( - hass: HomeAssistant, entities: list[dict[str, Any]], unique_id_prefix: str | None + hass: HomeAssistant, definitions: list[dict[str, Any]], unique_id_prefix: str | None ) -> list[TemplateSelect]: """Create the Template select.""" - for entity in entities: - unique_id = entity.get(CONF_UNIQUE_ID) - + entities = [] + for definition in definitions: + unique_id = definition.get(CONF_UNIQUE_ID) if unique_id and unique_id_prefix: unique_id = f"{unique_id_prefix}-{unique_id}" - - return [ + entities.append( TemplateSelect( hass, - entity.get(CONF_NAME, DEFAULT_NAME), - entity[CONF_STATE], - entity.get(CONF_AVAILABILITY), - entity[CONF_SELECT_OPTION], - entity[ATTR_OPTIONS], - entity.get(CONF_OPTIMISTIC, DEFAULT_OPTIMISTIC), + definition.get(CONF_NAME, DEFAULT_NAME), + definition[CONF_STATE], + definition.get(CONF_AVAILABILITY), + definition[CONF_SELECT_OPTION], + definition[ATTR_OPTIONS], + definition.get(CONF_OPTIMISTIC, DEFAULT_OPTIMISTIC), unique_id, ) - ] + ) + return entities async def async_setup_platform( diff --git a/tests/components/template/test_select.py b/tests/components/template/test_select.py index eb94a9284f4..ca4a30b1cd6 100644 --- a/tests/components/template/test_select.py +++ b/tests/components/template/test_select.py @@ -60,6 +60,38 @@ async def test_missing_optional_config(hass, calls): _verify(hass, "a", ["a", "b"]) +async def test_multiple_configs(hass, calls): + """Test: multiple select entities get created.""" + with assert_setup_component(1, "template"): + assert await setup.async_setup_component( + hass, + "template", + { + "template": { + "select": [ + { + "state": "{{ 'a' }}", + "select_option": {"service": "script.select_option"}, + "options": "{{ ['a', 'b'] }}", + }, + { + "state": "{{ 'a' }}", + "select_option": {"service": "script.select_option"}, + "options": "{{ ['a', 'b'] }}", + }, + ] + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + _verify(hass, "a", ["a", "b"]) + _verify(hass, "a", ["a", "b"], f"{_TEST_SELECT}_2") + + async def test_missing_required_keys(hass, calls): """Test: missing required fields will fail.""" with assert_setup_component(0, "template"): @@ -250,9 +282,9 @@ async def test_trigger_select(hass): assert events[0].event_type == "test_number_event" -def _verify(hass, expected_current_option, expected_options): +def _verify(hass, expected_current_option, expected_options, entity_name=_TEST_SELECT): """Verify select's state.""" - state = hass.states.get(_TEST_SELECT) + state = hass.states.get(entity_name) attributes = state.attributes assert state.state == str(expected_current_option) assert attributes.get(SELECT_ATTR_OPTIONS) == expected_options From f9e6e616f49d9d4f4b70ed4b2fc3e5d1b2c27e3a Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Wed, 8 Sep 2021 00:10:52 +0000 Subject: [PATCH 284/843] [ci skip] Translation update --- .../components/binary_sensor/translations/he.json | 2 +- homeassistant/components/cloud/translations/tr.json | 1 + .../components/device_tracker/translations/he.json | 12 +++++++++++- homeassistant/components/group/translations/he.json | 2 +- .../components/mikrotik/translations/he.json | 11 +++++++++++ .../components/mysensors/translations/he.json | 5 +++++ homeassistant/components/person/translations/he.json | 2 +- .../components/renault/translations/de.json | 6 ++++-- .../components/renault/translations/fr.json | 3 +++ .../components/renault/translations/tr.json | 11 +++++++++++ .../components/screenlogic/translations/he.json | 5 +++++ .../components/synology_dsm/translations/ca.json | 7 +++++++ .../components/synology_dsm/translations/de.json | 7 +++++++ .../components/synology_dsm/translations/en.json | 8 ++++++++ .../components/synology_dsm/translations/et.json | 7 +++++++ .../components/synology_dsm/translations/fr.json | 6 ++++++ .../components/synology_dsm/translations/he.json | 6 ++++++ .../components/synology_dsm/translations/tr.json | 6 ++++++ homeassistant/components/tuya/translations/he.json | 2 +- homeassistant/components/unifi/translations/he.json | 9 +++++++-- 20 files changed, 109 insertions(+), 9 deletions(-) create mode 100644 homeassistant/components/renault/translations/tr.json diff --git a/homeassistant/components/binary_sensor/translations/he.json b/homeassistant/components/binary_sensor/translations/he.json index b0fb2780089..65501c6e698 100644 --- a/homeassistant/components/binary_sensor/translations/he.json +++ b/homeassistant/components/binary_sensor/translations/he.json @@ -82,7 +82,7 @@ "off": "\u05de\u05e0\u05d5\u05ea\u05e7" }, "presence": { - "off": "\u05dc\u05d0 \u05d1\u05d1\u05d9\u05ea", + "off": "\u05d1\u05d7\u05d5\u05e5", "on": "\u05d1\u05d1\u05d9\u05ea" }, "problem": { diff --git a/homeassistant/components/cloud/translations/tr.json b/homeassistant/components/cloud/translations/tr.json index 75d1c768beb..b9b82f2c08c 100644 --- a/homeassistant/components/cloud/translations/tr.json +++ b/homeassistant/components/cloud/translations/tr.json @@ -8,6 +8,7 @@ "relayer_connected": "Yeniden Katman ba\u011fl\u0131", "remote_connected": "Uzaktan Ba\u011fl\u0131", "remote_enabled": "Uzaktan Etkinle\u015ftirildi", + "remote_server": "Sunucuyu Uzaktan Kontrol et", "subscription_expiration": "Aboneli\u011fin Sona Ermesi" } } diff --git a/homeassistant/components/device_tracker/translations/he.json b/homeassistant/components/device_tracker/translations/he.json index 2f3ccc1ec1e..e20a3291008 100644 --- a/homeassistant/components/device_tracker/translations/he.json +++ b/homeassistant/components/device_tracker/translations/he.json @@ -1,8 +1,18 @@ { + "device_automation": { + "condition_type": { + "is_home": "{entity_name} \u05d1\u05d1\u05d9\u05ea", + "is_not_home": "{entity_name} \u05dc\u05d0 \u05d1\u05d1\u05d9\u05ea" + }, + "trigger_type": { + "enters": "{entity_name} \u05e0\u05db\u05e0\u05e1 \u05dc\u05d0\u05d6\u05d5\u05e8", + "leaves": "{entity_name} \u05d9\u05e6\u05d0 \u05de\u05d0\u05d6\u05d5\u05e8" + } + }, "state": { "_": { "home": "\u05d1\u05d1\u05d9\u05ea", - "not_home": "\u05dc\u05d0 \u05d1\u05d1\u05d9\u05ea" + "not_home": "\u05d1\u05d7\u05d5\u05e5" } }, "title": "\u05de\u05e2\u05e7\u05d1 \u05d0\u05d7\u05e8 \u05d4\u05ea\u05e7\u05e0\u05d9\u05dd" diff --git a/homeassistant/components/group/translations/he.json b/homeassistant/components/group/translations/he.json index 0ca969e6812..798a8e1e7c6 100644 --- a/homeassistant/components/group/translations/he.json +++ b/homeassistant/components/group/translations/he.json @@ -4,7 +4,7 @@ "closed": "\u05e1\u05d2\u05d5\u05e8", "home": "\u05d1\u05d1\u05d9\u05ea", "locked": "\u05e0\u05e2\u05d5\u05dc", - "not_home": "\u05dc\u05d0 \u05d1\u05d1\u05d9\u05ea", + "not_home": "\u05d1\u05d7\u05d5\u05e5", "off": "\u05db\u05d1\u05d5\u05d9", "ok": "\u05ea\u05e7\u05d9\u05df", "on": "\u05de\u05d5\u05e4\u05e2\u05dc", diff --git a/homeassistant/components/mikrotik/translations/he.json b/homeassistant/components/mikrotik/translations/he.json index 6f8286290d4..bef2f812e0f 100644 --- a/homeassistant/components/mikrotik/translations/he.json +++ b/homeassistant/components/mikrotik/translations/he.json @@ -18,5 +18,16 @@ } } } + }, + "options": { + "step": { + "device_tracker": { + "data": { + "arp_ping": "\u05d4\u05e4\u05d9\u05db\u05ea \u05e4\u05d9\u05e0\u05d2 \u05e9\u05dc ARP \u05dc\u05d6\u05de\u05d9\u05df", + "detection_time": "\u05e9\u05e7\u05d5\u05dc \u05de\u05e8\u05d5\u05d5\u05d7 \u05d6\u05de\u05df \u05d1\u05d9\u05ea\u05d9", + "force_dhcp": "\u05db\u05e4\u05d9\u05d9\u05ea \u05e1\u05e8\u05d9\u05e7\u05d4 \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea DHCP" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/mysensors/translations/he.json b/homeassistant/components/mysensors/translations/he.json index 587c3ae9132..3ade3fcdad4 100644 --- a/homeassistant/components/mysensors/translations/he.json +++ b/homeassistant/components/mysensors/translations/he.json @@ -16,6 +16,11 @@ "step": { "gw_mqtt": { "description": "\u05d4\u05d2\u05d3\u05e8\u05ea \u05e9\u05e2\u05e8 MQTT" + }, + "gw_tcp": { + "data": { + "tcp_port": "\u05e4\u05ea\u05d7\u05d4" + } } } } diff --git a/homeassistant/components/person/translations/he.json b/homeassistant/components/person/translations/he.json index 1c36d16f936..1064c3f12b0 100644 --- a/homeassistant/components/person/translations/he.json +++ b/homeassistant/components/person/translations/he.json @@ -2,7 +2,7 @@ "state": { "_": { "home": "\u05d1\u05d1\u05d9\u05ea", - "not_home": "\u05dc\u05d0 \u05d1\u05d1\u05d9\u05ea" + "not_home": "\u05d1\u05d7\u05d5\u05e5" } }, "title": "\u05d0\u05d3\u05dd" diff --git a/homeassistant/components/renault/translations/de.json b/homeassistant/components/renault/translations/de.json index 2cf2b2e2805..808d8b174f8 100644 --- a/homeassistant/components/renault/translations/de.json +++ b/homeassistant/components/renault/translations/de.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "Konto wurde bereits konfiguriert", - "kamereon_no_account": "Kamereon-Konto kann nicht gefunden werden." + "kamereon_no_account": "Kamereon-Konto kann nicht gefunden werden.", + "reauth_successful": "Die erneute Authentifizierung war erfolgreich" }, "error": { "invalid_credentials": "Ung\u00fcltige Authentifizierung" @@ -18,7 +19,8 @@ "data": { "password": "Passwort" }, - "description": "Bitte \u00e4ndere Dein Passwort f\u00fcr {username}" + "description": "Bitte \u00e4ndere Dein Passwort f\u00fcr {username}", + "title": "Integration erneut authentifizieren" }, "user": { "data": { diff --git a/homeassistant/components/renault/translations/fr.json b/homeassistant/components/renault/translations/fr.json index c6dd881ecb4..d0ea9d0284c 100644 --- a/homeassistant/components/renault/translations/fr.json +++ b/homeassistant/components/renault/translations/fr.json @@ -14,6 +14,9 @@ }, "title": "S\u00e9lectionner l'identifiant du compte Kamereon" }, + "reauth_confirm": { + "title": "R\u00e9-authentifier l'int\u00e9gration" + }, "user": { "data": { "locale": "Lieu", diff --git a/homeassistant/components/renault/translations/tr.json b/homeassistant/components/renault/translations/tr.json new file mode 100644 index 00000000000..866fc513d4a --- /dev/null +++ b/homeassistant/components/renault/translations/tr.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "reauth_confirm": { + "data": { + "password": "\u015eifre" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/screenlogic/translations/he.json b/homeassistant/components/screenlogic/translations/he.json index 8e592e373e6..fdce873fdb2 100644 --- a/homeassistant/components/screenlogic/translations/he.json +++ b/homeassistant/components/screenlogic/translations/he.json @@ -13,6 +13,11 @@ "ip_address": "\u05db\u05ea\u05d5\u05d1\u05ea IP", "port": "\u05e4\u05ea\u05d7\u05d4" } + }, + "gateway_select": { + "data": { + "selected_gateway": "\u05e9\u05e2\u05e8" + } } } } diff --git a/homeassistant/components/synology_dsm/translations/ca.json b/homeassistant/components/synology_dsm/translations/ca.json index 89194754c54..d60d560ef1a 100644 --- a/homeassistant/components/synology_dsm/translations/ca.json +++ b/homeassistant/components/synology_dsm/translations/ca.json @@ -39,6 +39,13 @@ "description": "Motiu: {details}", "title": "Reautenticaci\u00f3 de la integraci\u00f3 Synology DSM" }, + "reauth_confirm": { + "data": { + "password": "Contrasenya", + "username": "Nom d'usuari" + }, + "title": "Reautenticaci\u00f3 de la integraci\u00f3 Synology DSM" + }, "user": { "data": { "host": "Amfitri\u00f3", diff --git a/homeassistant/components/synology_dsm/translations/de.json b/homeassistant/components/synology_dsm/translations/de.json index c377ef2adc0..56f835f18f5 100644 --- a/homeassistant/components/synology_dsm/translations/de.json +++ b/homeassistant/components/synology_dsm/translations/de.json @@ -39,6 +39,13 @@ "description": "Grund: {details}", "title": "Synology DSM Integration erneut authentifizieren" }, + "reauth_confirm": { + "data": { + "password": "Passwort", + "username": "Benutzername" + }, + "title": "Synology DSM Integration erneut authentifizieren" + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/synology_dsm/translations/en.json b/homeassistant/components/synology_dsm/translations/en.json index 789f20f57bd..b86c8a3fe57 100644 --- a/homeassistant/components/synology_dsm/translations/en.json +++ b/homeassistant/components/synology_dsm/translations/en.json @@ -31,6 +31,14 @@ "description": "Do you want to setup {name} ({host})?", "title": "Synology DSM" }, + "reauth": { + "data": { + "password": "Password", + "username": "Username" + }, + "description": "Reason: {details}", + "title": "Synology DSM Reauthenticate Integration" + }, "reauth_confirm": { "data": { "password": "Password", diff --git a/homeassistant/components/synology_dsm/translations/et.json b/homeassistant/components/synology_dsm/translations/et.json index 1d81312305b..aea7e31ca72 100644 --- a/homeassistant/components/synology_dsm/translations/et.json +++ b/homeassistant/components/synology_dsm/translations/et.json @@ -39,6 +39,13 @@ "description": "P\u00f5hjus: {details}", "title": "Synology DSM: Taastuvasta sidumine" }, + "reauth_confirm": { + "data": { + "password": "Salas\u00f5na", + "username": "Kasutajanimi" + }, + "title": "Taastuvasta Synology DSM" + }, "user": { "data": { "host": "", diff --git a/homeassistant/components/synology_dsm/translations/fr.json b/homeassistant/components/synology_dsm/translations/fr.json index 449752106c7..2589139b9ec 100644 --- a/homeassistant/components/synology_dsm/translations/fr.json +++ b/homeassistant/components/synology_dsm/translations/fr.json @@ -39,6 +39,12 @@ "description": "Raison: {details}", "title": "Synology DSM R\u00e9-authentifier l'int\u00e9gration" }, + "reauth_confirm": { + "data": { + "password": "Mot de passe", + "username": "Nom d'utilisateur" + } + }, "user": { "data": { "host": "H\u00f4te", diff --git a/homeassistant/components/synology_dsm/translations/he.json b/homeassistant/components/synology_dsm/translations/he.json index 974f36f501a..7adcac9af84 100644 --- a/homeassistant/components/synology_dsm/translations/he.json +++ b/homeassistant/components/synology_dsm/translations/he.json @@ -29,6 +29,12 @@ "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" } }, + "reauth_confirm": { + "data": { + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + }, "user": { "data": { "host": "\u05de\u05d0\u05e8\u05d7", diff --git a/homeassistant/components/synology_dsm/translations/tr.json b/homeassistant/components/synology_dsm/translations/tr.json index 681d85d2ef5..f2b93648da0 100644 --- a/homeassistant/components/synology_dsm/translations/tr.json +++ b/homeassistant/components/synology_dsm/translations/tr.json @@ -17,6 +17,12 @@ "verify_ssl": "SSL sertifikalar\u0131n\u0131 do\u011frula" } }, + "reauth_confirm": { + "data": { + "password": "\u015eifre", + "username": "Kullan\u0131c\u0131 ad\u0131" + } + }, "user": { "data": { "host": "Ana Bilgisayar", diff --git a/homeassistant/components/tuya/translations/he.json b/homeassistant/components/tuya/translations/he.json index 45980842a75..44a7699e511 100644 --- a/homeassistant/components/tuya/translations/he.json +++ b/homeassistant/components/tuya/translations/he.json @@ -12,7 +12,7 @@ "step": { "user": { "data": { - "country_code": "\u05e7\u05d5\u05d3 \u05de\u05d3\u05d9\u05e0\u05d4 \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05e9\u05dc\u05da (\u05dc\u05de\u05e9\u05dc, 1 \u05dc\u05d0\u05e8\u05d4\"\u05d1 \u05d0\u05d5 972 \u05dc\u05d9\u05e9\u05e8\u05d0\u05dc)", + "country_code": "\u05e7\u05d5\u05d3 \u05de\u05d3\u05d9\u05e0\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05e9\u05dc\u05da (\u05dc\u05de\u05e9\u05dc, 1 \u05dc\u05d0\u05e8\u05d4\"\u05d1 \u05d0\u05d5 972 \u05dc\u05d9\u05e9\u05e8\u05d0\u05dc)", "password": "\u05e1\u05d9\u05e1\u05de\u05d4", "platform": "\u05d4\u05d9\u05d9\u05e9\u05d5\u05dd \u05d1\u05d5 \u05e8\u05e9\u05d5\u05dd \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05e9\u05dc\u05da", "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" diff --git a/homeassistant/components/unifi/translations/he.json b/homeassistant/components/unifi/translations/he.json index 83c34cb9c77..848b1c5fbc5 100644 --- a/homeassistant/components/unifi/translations/he.json +++ b/homeassistant/components/unifi/translations/he.json @@ -29,9 +29,14 @@ }, "device_tracker": { "data": { + "detection_time": "\u05d4\u05d6\u05de\u05df \u05d1\u05e9\u05e0\u05d9\u05d5\u05ea \u05de\u05d4\u05e4\u05e2\u05dd \u05d4\u05d0\u05d7\u05e8\u05d5\u05e0\u05d4 \u05e9\u05e0\u05e8\u05d0\u05d4 \u05e2\u05d3 \u05e9\u05e0\u05d7\u05e9\u05d1 \u05d1\u05d7\u05d5\u05e5", + "ignore_wired_bug": "\u05d4\u05e9\u05d1\u05ea \u05d0\u05ea \u05dc\u05d5\u05d2\u05d9\u05e7\u05ea \u05d1\u05d0\u05d2\u05d9\u05dd \u05e7\u05d5\u05d5\u05d9\u05ea UniFi", + "ssid_filter": "\u05d1\u05d7\u05d9\u05e8\u05ea SSID \u05dc\u05de\u05e2\u05e7\u05d1 \u05d0\u05d7\u05e8 \u05dc\u05e7\u05d5\u05d7\u05d5\u05ea \u05d0\u05dc\u05d7\u05d5\u05d8\u05d9\u05d9\u05dd \u05d1-", "track_clients": "\u05de\u05e2\u05e7\u05d1 \u05d0\u05d7\u05e8 \u05dc\u05e7\u05d5\u05d7\u05d5\u05ea \u05e8\u05e9\u05ea", - "track_devices": "\u05de\u05e2\u05e7\u05d1 \u05d0\u05d7\u05e8 \u05d4\u05ea\u05e7\u05e0\u05d9 \u05e8\u05e9\u05ea (\u05d4\u05ea\u05e7\u05e0\u05d9 Ubiquiti)" - } + "track_devices": "\u05de\u05e2\u05e7\u05d1 \u05d0\u05d7\u05e8 \u05d4\u05ea\u05e7\u05e0\u05d9 \u05e8\u05e9\u05ea (\u05d4\u05ea\u05e7\u05e0\u05d9 Ubiquiti)", + "track_wired_clients": "\u05d4\u05db\u05dc\u05dc\u05ea \u05dc\u05e7\u05d5\u05d7\u05d5\u05ea \u05e8\u05e9\u05ea \u05e7\u05d5\u05d5\u05d9\u05ea" + }, + "description": "\u05e7\u05d1\u05d9\u05e2\u05ea \u05ea\u05e6\u05d5\u05e8\u05d4 \u05e9\u05dc \u05de\u05e2\u05e7\u05d1 \u05d0\u05d7\u05e8 \u05d4\u05ea\u05e7\u05e0\u05d9\u05dd" }, "simple_options": { "data": { From efafe827994857500eddb2c1dd471c7ba8b0383f Mon Sep 17 00:00:00 2001 From: Fuzzy Date: Tue, 7 Sep 2021 23:01:58 -0400 Subject: [PATCH 285/843] Remove Trackr integration (API removed) (#55917) * Delete Trackr component directory * Update .coverage.rc to remove Trackr * Update requirements_all.txt --- .coveragerc | 1 - homeassistant/components/trackr/__init__.py | 1 - .../components/trackr/device_tracker.py | 73 ------------------- homeassistant/components/trackr/manifest.json | 8 -- requirements_all.txt | 3 - 5 files changed, 86 deletions(-) delete mode 100644 homeassistant/components/trackr/__init__.py delete mode 100644 homeassistant/components/trackr/device_tracker.py delete mode 100644 homeassistant/components/trackr/manifest.json diff --git a/.coveragerc b/.coveragerc index bc6486283c4..684322359b5 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1095,7 +1095,6 @@ omit = homeassistant/components/tplink_lte/* homeassistant/components/traccar/device_tracker.py homeassistant/components/traccar/const.py - homeassistant/components/trackr/device_tracker.py homeassistant/components/tractive/__init__.py homeassistant/components/tractive/device_tracker.py homeassistant/components/tractive/entity.py diff --git a/homeassistant/components/trackr/__init__.py b/homeassistant/components/trackr/__init__.py deleted file mode 100644 index b78eb8078a2..00000000000 --- a/homeassistant/components/trackr/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The trackr component.""" diff --git a/homeassistant/components/trackr/device_tracker.py b/homeassistant/components/trackr/device_tracker.py deleted file mode 100644 index c08a990ea16..00000000000 --- a/homeassistant/components/trackr/device_tracker.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Support for the TrackR platform.""" -import logging - -from pytrackr.api import trackrApiInterface -import voluptuous as vol - -from homeassistant.components.device_tracker import ( - PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, -) -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.event import track_utc_time_change -from homeassistant.util import slugify - -_LOGGER = logging.getLogger(__name__) - -PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( - {vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string} -) - - -def setup_scanner(hass, config: dict, see, discovery_info=None): - """Validate the configuration and return a TrackR scanner.""" - TrackRDeviceScanner(hass, config, see) - return True - - -class TrackRDeviceScanner: - """A class representing a TrackR device.""" - - def __init__(self, hass, config: dict, see) -> None: - """Initialize the TrackR device scanner.""" - - self.hass = hass - self.api = trackrApiInterface( - config.get(CONF_USERNAME), config.get(CONF_PASSWORD) - ) - self.see = see - self.devices = self.api.get_trackrs() - self._update_info() - - track_utc_time_change(self.hass, self._update_info, second=range(0, 60, 30)) - - def _update_info(self, now=None) -> None: - """Update the device info.""" - _LOGGER.debug("Updating devices %s", now) - - # Update self.devices to collect new devices added - # to the users account. - self.devices = self.api.get_trackrs() - - for trackr in self.devices: - trackr.update_state() - trackr_id = trackr.tracker_id() - trackr_device_id = trackr.id() - lost = trackr.lost() - dev_id = slugify(trackr.name()) - if dev_id is None: - dev_id = trackr_id - location = trackr.last_known_location() - lat = location["latitude"] - lon = location["longitude"] - - attrs = { - "last_updated": trackr.last_updated(), - "last_seen": trackr.last_time_seen(), - "trackr_id": trackr_id, - "id": trackr_device_id, - "lost": lost, - "battery_level": trackr.battery_level(), - } - - self.see(dev_id=dev_id, gps=(lat, lon), attributes=attrs) diff --git a/homeassistant/components/trackr/manifest.json b/homeassistant/components/trackr/manifest.json deleted file mode 100644 index 04a629d49c6..00000000000 --- a/homeassistant/components/trackr/manifest.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "domain": "trackr", - "name": "TrackR", - "documentation": "https://www.home-assistant.io/integrations/trackr", - "requirements": ["pytrackr==0.0.5"], - "codeowners": [], - "iot_class": "cloud_polling" -} diff --git a/requirements_all.txt b/requirements_all.txt index d6b076cd778..bbbbe45b0d2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1954,9 +1954,6 @@ pytouchline==0.7 # homeassistant.components.traccar pytraccar==0.9.0 -# homeassistant.components.trackr -pytrackr==0.0.5 - # homeassistant.components.tradfri pytradfri[async]==7.0.6 From ec337101ddfb738eb3b68d82180ddf48cf72a027 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 7 Sep 2021 20:53:43 -0700 Subject: [PATCH 286/843] Fix gas validation (#55886) --- homeassistant/components/energy/validate.py | 83 +++++++++++++++++---- tests/components/energy/test_validate.py | 56 ++++++++++++++ 2 files changed, 125 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/energy/validate.py b/homeassistant/components/energy/validate.py index 01709081d68..9ee6df30b5e 100644 --- a/homeassistant/components/energy/validate.py +++ b/homeassistant/components/energy/validate.py @@ -1,6 +1,7 @@ """Validate the energy preferences provide valid data.""" from __future__ import annotations +from collections.abc import Sequence import dataclasses from typing import Any @@ -10,12 +11,24 @@ from homeassistant.const import ( ENERGY_WATT_HOUR, STATE_UNAVAILABLE, STATE_UNKNOWN, + VOLUME_CUBIC_FEET, + VOLUME_CUBIC_METERS, ) from homeassistant.core import HomeAssistant, callback, valid_entity_id from . import data from .const import DOMAIN +ENERGY_USAGE_UNITS = (ENERGY_KILO_WATT_HOUR, ENERGY_WATT_HOUR) +ENERGY_UNIT_ERROR = "entity_unexpected_unit_energy" +GAS_USAGE_UNITS = ( + ENERGY_WATT_HOUR, + ENERGY_KILO_WATT_HOUR, + VOLUME_CUBIC_METERS, + VOLUME_CUBIC_FEET, +) +GAS_UNIT_ERROR = "entity_unexpected_unit_gas" + @dataclasses.dataclass class ValidationIssue: @@ -43,8 +56,12 @@ class EnergyPreferencesValidation: @callback -def _async_validate_energy_stat( - hass: HomeAssistant, stat_value: str, result: list[ValidationIssue] +def _async_validate_usage_stat( + hass: HomeAssistant, + stat_value: str, + allowed_units: Sequence[str], + unit_error: str, + result: list[ValidationIssue], ) -> None: """Validate a statistic.""" has_entity_source = valid_entity_id(stat_value) @@ -91,10 +108,8 @@ def _async_validate_energy_stat( unit = state.attributes.get("unit_of_measurement") - if unit not in (ENERGY_KILO_WATT_HOUR, ENERGY_WATT_HOUR): - result.append( - ValidationIssue("entity_unexpected_unit_energy", stat_value, unit) - ) + if unit not in allowed_units: + result.append(ValidationIssue(unit_error, stat_value, unit)) state_class = state.attributes.get("state_class") @@ -211,8 +226,12 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: if source["type"] == "grid": for flow in source["flow_from"]: - _async_validate_energy_stat( - hass, flow["stat_energy_from"], source_result + _async_validate_usage_stat( + hass, + flow["stat_energy_from"], + ENERGY_USAGE_UNITS, + ENERGY_UNIT_ERROR, + source_result, ) if flow.get("stat_cost") is not None: @@ -229,7 +248,13 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: ) for flow in source["flow_to"]: - _async_validate_energy_stat(hass, flow["stat_energy_to"], source_result) + _async_validate_usage_stat( + hass, + flow["stat_energy_to"], + ENERGY_USAGE_UNITS, + ENERGY_UNIT_ERROR, + source_result, + ) if flow.get("stat_compensation") is not None: _async_validate_cost_stat( @@ -247,7 +272,13 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: ) elif source["type"] == "gas": - _async_validate_energy_stat(hass, source["stat_energy_from"], source_result) + _async_validate_usage_stat( + hass, + source["stat_energy_from"], + GAS_USAGE_UNITS, + GAS_UNIT_ERROR, + source_result, + ) if source.get("stat_cost") is not None: _async_validate_cost_stat(hass, source["stat_cost"], source_result) @@ -263,15 +294,39 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: ) elif source["type"] == "solar": - _async_validate_energy_stat(hass, source["stat_energy_from"], source_result) + _async_validate_usage_stat( + hass, + source["stat_energy_from"], + ENERGY_USAGE_UNITS, + ENERGY_UNIT_ERROR, + source_result, + ) elif source["type"] == "battery": - _async_validate_energy_stat(hass, source["stat_energy_from"], source_result) - _async_validate_energy_stat(hass, source["stat_energy_to"], source_result) + _async_validate_usage_stat( + hass, + source["stat_energy_from"], + ENERGY_USAGE_UNITS, + ENERGY_UNIT_ERROR, + source_result, + ) + _async_validate_usage_stat( + hass, + source["stat_energy_to"], + ENERGY_USAGE_UNITS, + ENERGY_UNIT_ERROR, + source_result, + ) for device in manager.data["device_consumption"]: device_result: list[ValidationIssue] = [] result.device_consumption.append(device_result) - _async_validate_energy_stat(hass, device["stat_consumption"], device_result) + _async_validate_usage_stat( + hass, + device["stat_consumption"], + ENERGY_USAGE_UNITS, + ENERGY_UNIT_ERROR, + device_result, + ) return result diff --git a/tests/components/energy/test_validate.py b/tests/components/energy/test_validate.py index 9a0b2105007..31f40bd24ea 100644 --- a/tests/components/energy/test_validate.py +++ b/tests/components/energy/test_validate.py @@ -441,3 +441,59 @@ async def test_validation_grid_price_errors( ], "device_consumption": [], } + + +async def test_validation_gas(hass, mock_energy_manager, mock_is_entity_recorded): + """Test validating gas with sensors for energy and cost/compensation.""" + mock_is_entity_recorded["sensor.gas_cost_1"] = False + mock_is_entity_recorded["sensor.gas_compensation_1"] = False + await mock_energy_manager.async_update( + { + "energy_sources": [ + { + "type": "gas", + "stat_energy_from": "sensor.gas_consumption_1", + "stat_cost": "sensor.gas_cost_1", + }, + { + "type": "gas", + "stat_energy_from": "sensor.gas_consumption_2", + "stat_cost": "sensor.gas_cost_2", + }, + ] + } + ) + hass.states.async_set( + "sensor.gas_consumption_1", + "10.10", + {"unit_of_measurement": "beers", "state_class": "total_increasing"}, + ) + hass.states.async_set( + "sensor.gas_consumption_2", + "10.10", + {"unit_of_measurement": "kWh", "state_class": "total_increasing"}, + ) + hass.states.async_set( + "sensor.gas_cost_2", + "10.10", + {"unit_of_measurement": "EUR/kWh", "state_class": "total_increasing"}, + ) + + assert (await validate.async_validate(hass)).as_dict() == { + "energy_sources": [ + [ + { + "type": "entity_unexpected_unit_gas", + "identifier": "sensor.gas_consumption_1", + "value": "beers", + }, + { + "type": "recorder_untracked", + "identifier": "sensor.gas_cost_1", + "value": None, + }, + ], + [], + ], + "device_consumption": [], + } From a764c79b6f4d05a09107b6527c7a1c9f415698bf Mon Sep 17 00:00:00 2001 From: Pascal Winters Date: Wed, 8 Sep 2021 05:54:40 +0200 Subject: [PATCH 287/843] Edit unit of measurement for gas/electricity supplier prices (#55771) Co-authored-by: Paulus Schoutsen --- .../components/dsmr_reader/definitions.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/dsmr_reader/definitions.py b/homeassistant/components/dsmr_reader/definitions.py index 533b2f0dd38..1e9834e7e5e 100644 --- a/homeassistant/components/dsmr_reader/definitions.py +++ b/homeassistant/components/dsmr_reader/definitions.py @@ -2,7 +2,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Callable +from typing import Callable, Final from homeassistant.components.sensor import ( STATE_CLASS_MEASUREMENT, @@ -24,6 +24,9 @@ from homeassistant.const import ( VOLUME_CUBIC_METERS, ) +PRICE_EUR_KWH: Final = f"EUR/{ENERGY_KILO_WATT_HOUR}" +PRICE_EUR_M3: Final = f"EUR/{VOLUME_CUBIC_METERS}" + def dsmr_transform(value): """Transform DSMR version value to right format.""" @@ -301,31 +304,31 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( key="dsmr/day-consumption/energy_supplier_price_electricity_delivered_1", name="Low tariff delivered price", icon="mdi:currency-eur", - native_unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=PRICE_EUR_KWH, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/energy_supplier_price_electricity_delivered_2", name="High tariff delivered price", icon="mdi:currency-eur", - native_unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=PRICE_EUR_KWH, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/energy_supplier_price_electricity_returned_1", name="Low tariff returned price", icon="mdi:currency-eur", - native_unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=PRICE_EUR_KWH, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/energy_supplier_price_electricity_returned_2", name="High tariff returned price", icon="mdi:currency-eur", - native_unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=PRICE_EUR_KWH, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/energy_supplier_price_gas", name="Gas price", icon="mdi:currency-eur", - native_unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=PRICE_EUR_M3, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/fixed_cost", From 7195b8222bb2fa981817685f05c27b6153abcd37 Mon Sep 17 00:00:00 2001 From: Ruslan Sayfutdinov Date: Wed, 8 Sep 2021 04:59:02 +0100 Subject: [PATCH 288/843] Bump PyJWT to 2.1.0 (#55911) --- .github/workflows/ci.yaml | 4 ++-- homeassistant/auth/__init__.py | 6 ++++-- homeassistant/components/google_assistant/http.py | 2 +- homeassistant/components/html5/notify.py | 6 ++++-- homeassistant/components/http/auth.py | 2 +- homeassistant/helpers/config_entry_oauth2_flow.py | 2 +- homeassistant/package_constraints.txt | 2 +- requirements.txt | 2 +- requirements_test.txt | 1 - setup.py | 2 +- tests/auth/test_init.py | 6 +++--- 11 files changed, 19 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ec7aeb7afb0..27ab710e1ff 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -10,7 +10,7 @@ on: pull_request: ~ env: - CACHE_VERSION: 2 + CACHE_VERSION: 3 DEFAULT_PYTHON: 3.8 PRE_COMMIT_CACHE: ~/.cache/pre-commit SQLALCHEMY_WARN_20: 1 @@ -580,7 +580,7 @@ jobs: python -m venv venv . venv/bin/activate - pip install -U "pip<20.3" setuptools wheel + pip install -U "pip<20.3" "setuptools<58" wheel pip install -r requirements_all.txt pip install -r requirements_test.txt pip install -e . diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index 519582ea48c..717285d7b51 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -466,7 +466,7 @@ class AuthManager: }, refresh_token.jwt_key, algorithm="HS256", - ).decode() + ) @callback def _async_resolve_provider( @@ -507,7 +507,9 @@ class AuthManager: ) -> models.RefreshToken | None: """Return refresh token if an access token is valid.""" try: - unverif_claims = jwt.decode(token, verify=False) + unverif_claims = jwt.decode( + token, algorithms=["HS256"], options={"verify_signature": False} + ) except jwt.InvalidTokenError: return None diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py index 3787a63a514..61768ff2be8 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -51,7 +51,7 @@ def _get_homegraph_jwt(time, iss, key): "iat": now, "exp": now + 3600, } - return jwt.encode(jwt_raw, key, algorithm="RS256").decode("utf-8") + return jwt.encode(jwt_raw, key, algorithm="RS256") async def _get_homegraph_token(hass, jwt_signed): diff --git a/homeassistant/components/html5/notify.py b/homeassistant/components/html5/notify.py index b3d5a081d1b..eceaa0b73b9 100644 --- a/homeassistant/components/html5/notify.py +++ b/homeassistant/components/html5/notify.py @@ -320,7 +320,9 @@ class HTML5PushCallbackView(HomeAssistantView): # 2a. If decode is successful, return the payload. # 2b. If decode is unsuccessful, return a 401. - target_check = jwt.decode(token, verify=False) + target_check = jwt.decode( + token, algorithms=["ES256", "HS256"], options={"verify_signature": False} + ) if target_check.get(ATTR_TARGET) in self.registrations: possible_target = self.registrations[target_check[ATTR_TARGET]] key = possible_target[ATTR_SUBSCRIPTION][ATTR_KEYS][ATTR_AUTH] @@ -557,7 +559,7 @@ def add_jwt(timestamp, target, tag, jwt_secret): ATTR_TARGET: target, ATTR_TAG: tag, } - return jwt.encode(jwt_claims, jwt_secret).decode("utf-8") + return jwt.encode(jwt_claims, jwt_secret) def create_vapid_headers(vapid_email, subscription_info, vapid_private_key): diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 7004b279bd0..43ea0522594 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -45,7 +45,7 @@ def async_sign_path( secret, algorithm="HS256", ) - return f"{path}?{SIGN_QUERY_PARAM}={encoded.decode()}" + return f"{path}?{SIGN_QUERY_PARAM}={encoded}" @callback diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index 8704932db73..71281b57a30 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -505,7 +505,7 @@ def _encode_jwt(hass: HomeAssistant, data: dict) -> str: if secret is None: secret = hass.data[DATA_JWT_SECRET] = secrets.token_hex() - return jwt.encode(data, secret, algorithm="HS256").decode() + return jwt.encode(data, secret, algorithm="HS256") @callback diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e1d95a5cd23..1f8d19e9dde 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,4 +1,4 @@ -PyJWT==1.7.1 +PyJWT==2.1.0 PyNaCl==1.4.0 aiodiscover==1.4.2 aiohttp==3.7.4.post0 diff --git a/requirements.txt b/requirements.txt index e6b4b5845c4..1ea8772728e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ certifi>=2020.12.5 ciso8601==2.1.3 httpx==0.19.0 jinja2==3.0.1 -PyJWT==1.7.1 +PyJWT==2.1.0 cryptography==3.3.2 pip>=8.0.3,<20.3 python-slugify==4.0.1 diff --git a/requirements_test.txt b/requirements_test.txt index 86114cc02b1..c0207dddc14 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -37,7 +37,6 @@ types-decorator==0.1.7 types-emoji==1.2.4 types-enum34==0.1.8 types-ipaddress==0.1.5 -types-jwt==0.1.3 types-pkg-resources==0.1.3 types-python-slugify==0.1.2 types-pytz==2021.1.2 diff --git a/setup.py b/setup.py index e9ab189406b..eb33b492beb 100755 --- a/setup.py +++ b/setup.py @@ -43,7 +43,7 @@ REQUIRES = [ "ciso8601==2.1.3", "httpx==0.19.0", "jinja2==3.0.1", - "PyJWT==1.7.1", + "PyJWT==2.1.0", # PyJWT has loose dependency. We want the latest one. "cryptography==3.3.2", "pip>=8.0.3,<20.3", diff --git a/tests/auth/test_init.py b/tests/auth/test_init.py index 4a763a6e995..4c3d93ede15 100644 --- a/tests/auth/test_init.py +++ b/tests/auth/test_init.py @@ -539,7 +539,7 @@ async def test_create_access_token(mock_hass): access_token = manager.async_create_access_token(refresh_token) assert access_token is not None assert refresh_token.jwt_key == jwt_key - jwt_payload = jwt.decode(access_token, jwt_key, algorithm=["HS256"]) + jwt_payload = jwt.decode(access_token, jwt_key, algorithms=["HS256"]) assert jwt_payload["iss"] == refresh_token.id assert ( jwt_payload["exp"] - jwt_payload["iat"] == timedelta(minutes=30).total_seconds() @@ -558,7 +558,7 @@ async def test_create_long_lived_access_token(mock_hass): ) assert refresh_token.token_type == auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN access_token = manager.async_create_access_token(refresh_token) - jwt_payload = jwt.decode(access_token, refresh_token.jwt_key, algorithm=["HS256"]) + jwt_payload = jwt.decode(access_token, refresh_token.jwt_key, algorithms=["HS256"]) assert jwt_payload["iss"] == refresh_token.id assert ( jwt_payload["exp"] - jwt_payload["iat"] == timedelta(days=300).total_seconds() @@ -610,7 +610,7 @@ async def test_one_long_lived_access_token_per_refresh_token(mock_hass): assert jwt_key != jwt_key_2 rt = await manager.async_validate_access_token(access_token_2) - jwt_payload = jwt.decode(access_token_2, rt.jwt_key, algorithm=["HS256"]) + jwt_payload = jwt.decode(access_token_2, rt.jwt_key, algorithms=["HS256"]) assert jwt_payload["iss"] == refresh_token_2.id assert ( jwt_payload["exp"] - jwt_payload["iat"] == timedelta(days=3000).total_seconds() From 5429a6787751f0a6476d29de0d8bb7341b0e5325 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 8 Sep 2021 08:48:55 +0200 Subject: [PATCH 289/843] Use EntityDescription - zoneminder (#55922) --- homeassistant/components/zoneminder/sensor.py | 75 ++++++++++++------- 1 file changed, 49 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/zoneminder/sensor.py b/homeassistant/components/zoneminder/sensor.py index d392901b633..3384bad758c 100644 --- a/homeassistant/components/zoneminder/sensor.py +++ b/homeassistant/components/zoneminder/sensor.py @@ -1,10 +1,16 @@ """Support for ZoneMinder sensors.""" +from __future__ import annotations + import logging import voluptuous as vol from zoneminder.monitor import TimePeriod -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import CONF_MONITORED_CONDITIONS import homeassistant.helpers.config_validation as cv @@ -16,13 +22,30 @@ CONF_INCLUDE_ARCHIVED = "include_archived" DEFAULT_INCLUDE_ARCHIVED = False -SENSOR_TYPES = { - "all": ["Events"], - "hour": ["Events Last Hour"], - "day": ["Events Last Day"], - "week": ["Events Last Week"], - "month": ["Events Last Month"], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="all", + name="Events", + ), + SensorEntityDescription( + key="hour", + name="Events Last Hour", + ), + SensorEntityDescription( + key="day", + name="Events Last Day", + ), + SensorEntityDescription( + key="week", + name="Events Last Week", + ), + SensorEntityDescription( + key="month", + name="Events Last Month", + ), +) + +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -30,7 +53,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( CONF_INCLUDE_ARCHIVED, default=DEFAULT_INCLUDE_ARCHIVED ): cv.boolean, vol.Optional(CONF_MONITORED_CONDITIONS, default=["all"]): vol.All( - cv.ensure_list, [vol.In(list(SENSOR_TYPES))] + cv.ensure_list, [vol.In(SENSOR_KEYS)] ), } ) @@ -38,7 +61,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the ZoneMinder sensor platform.""" - include_archived = config.get(CONF_INCLUDE_ARCHIVED) + include_archived = config[CONF_INCLUDE_ARCHIVED] + monitored_conditions = config[CONF_MONITORED_CONDITIONS] sensors = [] for zm_client in hass.data[ZONEMINDER_DOMAIN].values(): @@ -49,8 +73,13 @@ def setup_platform(hass, config, add_entities, discovery_info=None): for monitor in monitors: sensors.append(ZMSensorMonitors(monitor)) - for sensor in config[CONF_MONITORED_CONDITIONS]: - sensors.append(ZMSensorEvents(monitor, include_archived, sensor)) + sensors.extend( + [ + ZMSensorEvents(monitor, include_archived, description) + for description in SENSOR_TYPES + if description.key in monitored_conditions + ] + ) sensors.append(ZMSensorRunState(zm_client)) add_entities(sensors) @@ -93,32 +122,26 @@ class ZMSensorMonitors(SensorEntity): class ZMSensorEvents(SensorEntity): """Get the number of events for each monitor.""" - def __init__(self, monitor, include_archived, sensor_type): + _attr_native_unit_of_measurement = "Events" + + def __init__(self, monitor, include_archived, description: SensorEntityDescription): """Initialize event sensor.""" + self.entity_description = description self._monitor = monitor self._include_archived = include_archived - self.time_period = TimePeriod.get_time_period(sensor_type) - self._state = None + self.time_period = TimePeriod.get_time_period(description.key) @property def name(self): """Return the name of the sensor.""" return f"{self._monitor.name} {self.time_period.title}" - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return "Events" - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - def update(self): """Update the sensor.""" - self._state = self._monitor.get_events(self.time_period, self._include_archived) + self._attr_native_value = self._monitor.get_events( + self.time_period, self._include_archived + ) class ZMSensorRunState(SensorEntity): From 69d6d5ffce893c3c2cdec9b766cdc2995e965911 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 8 Sep 2021 08:51:17 +0200 Subject: [PATCH 290/843] Use EntityDescription - incomfort (#55930) --- homeassistant/components/incomfort/sensor.py | 110 ++++++++++--------- 1 file changed, 56 insertions(+), 54 deletions(-) diff --git a/homeassistant/components/incomfort/sensor.py b/homeassistant/components/incomfort/sensor.py index 9fb99321ff2..0ce2372867a 100644 --- a/homeassistant/components/incomfort/sensor.py +++ b/homeassistant/components/incomfort/sensor.py @@ -1,9 +1,14 @@ """Support for an Intergas heater via an InComfort/InTouch Lan2RF gateway.""" from __future__ import annotations +from dataclasses import dataclass from typing import Any -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorEntity +from homeassistant.components.sensor import ( + DOMAIN as SENSOR_DOMAIN, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( DEVICE_CLASS_PRESSURE, DEVICE_CLASS_TEMPERATURE, @@ -18,11 +23,36 @@ INCOMFORT_HEATER_TEMP = "CV Temp" INCOMFORT_PRESSURE = "CV Pressure" INCOMFORT_TAP_TEMP = "Tap Temp" -INCOMFORT_MAP_ATTRS = { - INCOMFORT_HEATER_TEMP: ["heater_temp", "is_pumping"], - INCOMFORT_PRESSURE: ["pressure", None], - INCOMFORT_TAP_TEMP: ["tap_temp", "is_tapping"], -} + +@dataclass +class IncomfortSensorEntityDescription(SensorEntityDescription): + """Describes Incomfort sensor entity.""" + + extra_key: str | None = None + + +SENSOR_TYPES: tuple[IncomfortSensorEntityDescription, ...] = ( + IncomfortSensorEntityDescription( + key="pressure", + name=INCOMFORT_PRESSURE, + device_class=DEVICE_CLASS_PRESSURE, + native_unit_of_measurement=PRESSURE_BAR, + ), + IncomfortSensorEntityDescription( + key="heater_temp", + name=INCOMFORT_HEATER_TEMP, + device_class=DEVICE_CLASS_TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + extra_key="is_pumping", + ), + IncomfortSensorEntityDescription( + key="tap_temp", + name=INCOMFORT_TAP_TEMP, + device_class=DEVICE_CLASS_TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + extra_key="is_tapping", + ), +) async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): @@ -33,70 +63,42 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= client = hass.data[DOMAIN]["client"] heaters = hass.data[DOMAIN]["heaters"] - async_add_entities( - [IncomfortPressure(client, h, INCOMFORT_PRESSURE) for h in heaters] - + [IncomfortTemperature(client, h, INCOMFORT_HEATER_TEMP) for h in heaters] - + [IncomfortTemperature(client, h, INCOMFORT_TAP_TEMP) for h in heaters] - ) + entities = [ + IncomfortSensor(client, heater, description) + for heater in heaters + for description in SENSOR_TYPES + ] + + async_add_entities(entities) class IncomfortSensor(IncomfortChild, SensorEntity): """Representation of an InComfort/InTouch sensor device.""" - def __init__(self, client, heater, name) -> None: + entity_description: IncomfortSensorEntityDescription + + def __init__( + self, client, heater, description: IncomfortSensorEntityDescription + ) -> None: """Initialize the sensor.""" super().__init__() + self.entity_description = description self._client = client self._heater = heater - self._unique_id = f"{heater.serial_no}_{slugify(name)}" - self.entity_id = f"{SENSOR_DOMAIN}.{DOMAIN}_{slugify(name)}" - self._name = f"Boiler {name}" - - self._device_class = None - self._state_attr = INCOMFORT_MAP_ATTRS[name][0] - self._unit_of_measurement = None + self._unique_id = f"{heater.serial_no}_{slugify(description.name)}" + self.entity_id = f"{SENSOR_DOMAIN}.{DOMAIN}_{slugify(description.name)}" + self._name = f"Boiler {description.name}" @property def native_value(self) -> str | None: """Return the state of the sensor.""" - return self._heater.status[self._state_attr] - - @property - def device_class(self) -> str | None: - """Return the device class of the sensor.""" - return self._device_class - - @property - def native_unit_of_measurement(self) -> str | None: - """Return the unit of measurement of the sensor.""" - return self._unit_of_measurement - - -class IncomfortPressure(IncomfortSensor): - """Representation of an InTouch CV Pressure sensor.""" - - def __init__(self, client, heater, name) -> None: - """Initialize the sensor.""" - super().__init__(client, heater, name) - - self._device_class = DEVICE_CLASS_PRESSURE - self._unit_of_measurement = PRESSURE_BAR - - -class IncomfortTemperature(IncomfortSensor): - """Representation of an InTouch Temperature sensor.""" - - def __init__(self, client, heater, name) -> None: - """Initialize the signal strength sensor.""" - super().__init__(client, heater, name) - - self._attr = INCOMFORT_MAP_ATTRS[name][1] - self._device_class = DEVICE_CLASS_TEMPERATURE - self._unit_of_measurement = TEMP_CELSIUS + return self._heater.status[self.entity_description.key] @property def extra_state_attributes(self) -> dict[str, Any] | None: """Return the device state attributes.""" - return {self._attr: self._heater.status[self._attr]} + if (extra_key := self.entity_description.extra_key) is None: + return None + return {extra_key: self._heater.status[extra_key]} From 32b8be5a6eda4b6a14a3f97dc7c5d3305ce9909d Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 8 Sep 2021 14:28:04 +0200 Subject: [PATCH 291/843] Use EntityDescription - repetier (#55926) --- homeassistant/components/repetier/__init__.py | 91 +++++++++++++------ homeassistant/components/repetier/sensor.py | 87 +++++++----------- 2 files changed, 97 insertions(+), 81 deletions(-) diff --git a/homeassistant/components/repetier/__init__.py b/homeassistant/components/repetier/__init__.py index 08306396e96..6a47f7bbdf5 100644 --- a/homeassistant/components/repetier/__init__.py +++ b/homeassistant/components/repetier/__init__.py @@ -1,10 +1,14 @@ """Support for Repetier-Server sensors.""" +from __future__ import annotations + +from dataclasses import dataclass from datetime import timedelta import logging import pyrepetier import voluptuous as vol +from homeassistant.components.sensor import SensorEntityDescription from homeassistant.const import ( CONF_API_KEY, CONF_HOST, @@ -109,33 +113,66 @@ def has_all_unique_names(value): return value -SENSOR_TYPES = { - # Type, Unit, Icon, post - "bed_temperature": [ - "temperature", - TEMP_CELSIUS, - None, - "_bed_", - DEVICE_CLASS_TEMPERATURE, - ], - "extruder_temperature": [ - "temperature", - TEMP_CELSIUS, - None, - "_extruder_", - DEVICE_CLASS_TEMPERATURE, - ], - "chamber_temperature": [ - "temperature", - TEMP_CELSIUS, - None, - "_chamber_", - DEVICE_CLASS_TEMPERATURE, - ], - "current_state": ["state", None, "mdi:printer-3d", "", None], - "current_job": ["progress", PERCENTAGE, "mdi:file-percent", "_current_job", None], - "job_end": ["progress", None, "mdi:clock-end", "_job_end", None], - "job_start": ["progress", None, "mdi:clock-start", "_job_start", None], +@dataclass +class RepetierRequiredKeysMixin: + """Mixin for required keys.""" + + type: str + + +@dataclass +class RepetierSensorEntityDescription( + SensorEntityDescription, RepetierRequiredKeysMixin +): + """Describes Repetier sensor entity.""" + + +SENSOR_TYPES: dict[str, RepetierSensorEntityDescription] = { + "bed_temperature": RepetierSensorEntityDescription( + key="bed_temperature", + type="temperature", + native_unit_of_measurement=TEMP_CELSIUS, + name="_bed_", + device_class=DEVICE_CLASS_TEMPERATURE, + ), + "extruder_temperature": RepetierSensorEntityDescription( + key="extruder_temperature", + type="temperature", + native_unit_of_measurement=TEMP_CELSIUS, + name="_extruder_", + device_class=DEVICE_CLASS_TEMPERATURE, + ), + "chamber_temperature": RepetierSensorEntityDescription( + key="chamber_temperature", + type="temperature", + native_unit_of_measurement=TEMP_CELSIUS, + name="_chamber_", + device_class=DEVICE_CLASS_TEMPERATURE, + ), + "current_state": RepetierSensorEntityDescription( + key="current_state", + type="state", + icon="mdi:printer-3d", + ), + "current_job": RepetierSensorEntityDescription( + key="current_job", + type="progress", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:file-percent", + name="_current_job", + ), + "job_end": RepetierSensorEntityDescription( + key="job_end", + type="progress", + icon="mdi:clock-end", + name="_job_end", + ), + "job_start": RepetierSensorEntityDescription( + key="job_start", + type="progress", + icon="mdi:clock-start", + name="_job_start", + ), } SENSOR_SCHEMA = vol.Schema( diff --git a/homeassistant/components/repetier/sensor.py b/homeassistant/components/repetier/sensor.py index 04cff82bcf3..b21ff092c67 100644 --- a/homeassistant/components/repetier/sensor.py +++ b/homeassistant/components/repetier/sensor.py @@ -8,7 +8,7 @@ from homeassistant.const import DEVICE_CLASS_TIMESTAMP from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from . import REPETIER_API, SENSOR_TYPES, UPDATE_SIGNAL +from . import REPETIER_API, SENSOR_TYPES, UPDATE_SIGNAL, RepetierSensorEntityDescription _LOGGER = logging.getLogger(__name__) @@ -35,12 +35,13 @@ def setup_platform(hass, config, add_entities, discovery_info=None): printer_id = info["printer_id"] sensor_type = info["sensor_type"] temp_id = info["temp_id"] - name = f"{info['name']}{SENSOR_TYPES[sensor_type][3]}" + description = SENSOR_TYPES[sensor_type] + name = f"{info['name']}{description.name or ''}" if temp_id is not None: _LOGGER.debug("%s Temp_id: %s", sensor_type, temp_id) name = f"{name}{temp_id}" sensor_class = sensor_map[sensor_type] - entity = sensor_class(api, temp_id, name, printer_id, sensor_type) + entity = sensor_class(api, temp_id, name, printer_id, description) entities.append(entity) add_entities(entities, True) @@ -49,48 +50,33 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class RepetierSensor(SensorEntity): """Class to create and populate a Repetier Sensor.""" - def __init__(self, api, temp_id, name, printer_id, sensor_type): - """Init new sensor.""" - self._api = api - self._attributes = {} - self._available = False - self._temp_id = temp_id - self._name = name - self._printer_id = printer_id - self._sensor_type = sensor_type - self._state = None - self._attr_device_class = SENSOR_TYPES[self._sensor_type][4] + entity_description: RepetierSensorEntityDescription + _attr_should_poll = False - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._available + def __init__( + self, + api, + temp_id, + name, + printer_id, + description: RepetierSensorEntityDescription, + ): + """Init new sensor.""" + self.entity_description = description + self._api = api + self._attributes: dict = {} + self._temp_id = temp_id + self._printer_id = printer_id + self._state = None + + self._attr_name = name + self._attr_available = False @property def extra_state_attributes(self): """Return sensor attributes.""" return self._attributes - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return SENSOR_TYPES[self._sensor_type][1] - - @property - def icon(self): - """Icon to use in the frontend.""" - return SENSOR_TYPES[self._sensor_type][2] - - @property - def should_poll(self): - """Return False as entity is updated from the component.""" - return False - @property def native_value(self): """Return sensor state.""" @@ -109,14 +95,13 @@ class RepetierSensor(SensorEntity): def _get_data(self): """Return new data from the api cache.""" - data = self._api.get_data(self._printer_id, self._sensor_type, self._temp_id) + sensor_type = self.entity_description.key + data = self._api.get_data(self._printer_id, sensor_type, self._temp_id) if data is None: - _LOGGER.debug( - "Data not found for %s and %s", self._sensor_type, self._temp_id - ) - self._available = False + _LOGGER.debug("Data not found for %s and %s", sensor_type, self._temp_id) + self._attr_available = False return None - self._available = True + self._attr_available = True return data def update(self): @@ -125,7 +110,7 @@ class RepetierSensor(SensorEntity): if data is None: return state = data.pop("state") - _LOGGER.debug("Printer %s State %s", self._name, state) + _LOGGER.debug("Printer %s State %s", self.name, state) self._attributes.update(data) self._state = state @@ -147,7 +132,7 @@ class RepetierTempSensor(RepetierSensor): return state = data.pop("state") temp_set = data["temp_set"] - _LOGGER.debug("Printer %s Setpoint: %s, Temp: %s", self._name, temp_set, state) + _LOGGER.debug("Printer %s Setpoint: %s, Temp: %s", self.name, temp_set, state) self._attributes.update(data) self._state = state @@ -166,10 +151,7 @@ class RepetierJobSensor(RepetierSensor): class RepetierJobEndSensor(RepetierSensor): """Class to create and populate a Repetier Job End timestamp Sensor.""" - @property - def device_class(self): - """Return the device class.""" - return DEVICE_CLASS_TIMESTAMP + _attr_device_class = DEVICE_CLASS_TIMESTAMP def update(self): """Update the sensor.""" @@ -194,10 +176,7 @@ class RepetierJobEndSensor(RepetierSensor): class RepetierJobStartSensor(RepetierSensor): """Class to create and populate a Repetier Job Start timestamp Sensor.""" - @property - def device_class(self): - """Return the device class.""" - return DEVICE_CLASS_TIMESTAMP + _attr_device_class = DEVICE_CLASS_TIMESTAMP def update(self): """Update the sensor.""" From 0b23ce658e3e1b86f68f9312c9d3a2e2f1c53f3b Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 8 Sep 2021 15:14:03 +0200 Subject: [PATCH 292/843] Use EntityDescription - konnected (#55923) --- homeassistant/components/konnected/sensor.py | 78 ++++++++++---------- 1 file changed, 39 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/konnected/sensor.py b/homeassistant/components/konnected/sensor.py index a22b30f6862..ae43e771068 100644 --- a/homeassistant/components/konnected/sensor.py +++ b/homeassistant/components/konnected/sensor.py @@ -1,5 +1,7 @@ """Support for DHT and DS18B20 sensors attached to a Konnected device.""" -from homeassistant.components.sensor import SensorEntity +from __future__ import annotations + +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import ( CONF_DEVICES, CONF_NAME, @@ -16,9 +18,19 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from .const import DOMAIN as KONNECTED_DOMAIN, SIGNAL_DS18B20_NEW -SENSOR_TYPES = { - DEVICE_CLASS_TEMPERATURE: ["Temperature", TEMP_CELSIUS], - DEVICE_CLASS_HUMIDITY: ["Humidity", PERCENTAGE], +SENSOR_TYPES: dict[str, SensorEntityDescription] = { + "temperature": SensorEntityDescription( + key=DEVICE_CLASS_TEMPERATURE, + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + "humidity": SensorEntityDescription( + key=DEVICE_CLASS_HUMIDITY, + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), } @@ -26,7 +38,6 @@ async def async_setup_entry(hass, config_entry, async_add_entities): """Set up sensors attached to a Konnected device from a config entry.""" data = hass.data[KONNECTED_DOMAIN] device_id = config_entry.data["id"] - sensors = [] # Initialize all DHT sensors. dht_sensors = [ @@ -34,11 +45,13 @@ async def async_setup_entry(hass, config_entry, async_add_entities): for sensor in data[CONF_DEVICES][device_id][CONF_SENSORS] if sensor[CONF_TYPE] == "dht" ] - for sensor in dht_sensors: - sensors.append(KonnectedSensor(device_id, sensor, DEVICE_CLASS_TEMPERATURE)) - sensors.append(KonnectedSensor(device_id, sensor, DEVICE_CLASS_HUMIDITY)) + entities = [ + KonnectedSensor(device_id, data=sensor_config, description=description) + for sensor_config in dht_sensors + for description in SENSOR_TYPES.values() + ] - async_add_entities(sensors) + async_add_entities(entities) @callback def async_add_ds18b20(attrs): @@ -57,7 +70,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): KonnectedSensor( device_id, sensor_config, - DEVICE_CLASS_TEMPERATURE, + SENSOR_TYPES["temperature"], addr=attrs.get("addr"), initial_state=attrs.get("temp"), ) @@ -73,15 +86,20 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class KonnectedSensor(SensorEntity): """Represents a Konnected DHT Sensor.""" - def __init__(self, device_id, data, sensor_type, addr=None, initial_state=None): + def __init__( + self, + device_id, + data, + description: SensorEntityDescription, + addr=None, + initial_state=None, + ): """Initialize the entity for a single sensor_type.""" + self.entity_description = description self._addr = addr self._data = data - self._device_id = device_id - self._type = sensor_type self._zone_num = self._data.get(CONF_ZONE) - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] - self._unique_id = addr or f"{device_id}-{self._zone_num}-{sensor_type}" + self._attr_unique_id = addr or f"{device_id}-{self._zone_num}-{description.key}" # set initial state if known at initialization self._state = initial_state @@ -89,38 +107,20 @@ class KonnectedSensor(SensorEntity): self._state = round(float(self._state), 1) # set entity name if given - self._name = self._data.get(CONF_NAME) - if self._name: - self._name += f" {SENSOR_TYPES[sensor_type][0]}" + if name := self._data.get(CONF_NAME): + name += f" {description.name}" + self._attr_name = name - @property - def unique_id(self) -> str: - """Return the unique id.""" - return self._unique_id - - @property - def name(self): - """Return the name of the sensor.""" - return self._name + self._attr_device_info = {"identifiers": {(KONNECTED_DOMAIN, device_id)}} @property def native_value(self): """Return the state of the sensor.""" return self._state - @property - def native_unit_of_measurement(self): - """Return the unit of measurement.""" - return self._unit_of_measurement - - @property - def device_info(self): - """Return the device info.""" - return {"identifiers": {(KONNECTED_DOMAIN, self._device_id)}} - async def async_added_to_hass(self): """Store entity_id and register state change callback.""" - entity_id_key = self._addr or self._type + entity_id_key = self._addr or self.entity_description.key self._data[entity_id_key] = self.entity_id async_dispatcher_connect( self.hass, f"konnected.{self.entity_id}.update", self.async_set_state @@ -129,7 +129,7 @@ class KonnectedSensor(SensorEntity): @callback def async_set_state(self, state): """Update the sensor's state.""" - if self._type == DEVICE_CLASS_HUMIDITY: + if self.entity_description.key == DEVICE_CLASS_HUMIDITY: self._state = int(float(state)) else: self._state = round(float(state), 1) From 84140a547be3c26a78f91cfe8c3778d129c6369e Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Wed, 8 Sep 2021 15:33:53 +0100 Subject: [PATCH 293/843] deprecated unit_of_measurement (#55876) --- .../components/integration/sensor.py | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index 250d10cd9c4..a2fd77fb4e1 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -60,18 +60,21 @@ ICON = "mdi:chart-histogram" DEFAULT_ROUND = 3 -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_NAME): cv.string, - vol.Required(CONF_SOURCE_SENSOR): cv.entity_id, - vol.Optional(CONF_ROUND_DIGITS, default=DEFAULT_ROUND): vol.Coerce(int), - vol.Optional(CONF_UNIT_PREFIX, default=None): vol.In(UNIT_PREFIXES), - vol.Optional(CONF_UNIT_TIME, default=TIME_HOURS): vol.In(UNIT_TIME), - vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, - vol.Optional(CONF_METHOD, default=TRAPEZOIDAL_METHOD): vol.In( - INTEGRATION_METHOD - ), - } +PLATFORM_SCHEMA = vol.All( + cv.deprecated(CONF_UNIT_OF_MEASUREMENT), + PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_NAME): cv.string, + vol.Required(CONF_SOURCE_SENSOR): cv.entity_id, + vol.Optional(CONF_ROUND_DIGITS, default=DEFAULT_ROUND): vol.Coerce(int), + vol.Optional(CONF_UNIT_PREFIX, default=None): vol.In(UNIT_PREFIXES), + vol.Optional(CONF_UNIT_TIME, default=TIME_HOURS): vol.In(UNIT_TIME), + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, + vol.Optional(CONF_METHOD, default=TRAPEZOIDAL_METHOD): vol.In( + INTEGRATION_METHOD + ), + } + ), ) From c514f72c708b2abcab39cc3a3bf2fc6e71ef0d4a Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Wed, 8 Sep 2021 17:54:44 +0300 Subject: [PATCH 294/843] Bump aioswitcher to 2.0.5 (#55934) --- homeassistant/components/switcher_kis/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/switcher_kis/manifest.json b/homeassistant/components/switcher_kis/manifest.json index e982855e497..33ec7a67d92 100644 --- a/homeassistant/components/switcher_kis/manifest.json +++ b/homeassistant/components/switcher_kis/manifest.json @@ -3,7 +3,7 @@ "name": "Switcher", "documentation": "https://www.home-assistant.io/integrations/switcher_kis/", "codeowners": ["@tomerfi","@thecode"], - "requirements": ["aioswitcher==2.0.4"], + "requirements": ["aioswitcher==2.0.5"], "iot_class": "local_push", "config_flow": true } diff --git a/requirements_all.txt b/requirements_all.txt index bbbbe45b0d2..303a5b83523 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -243,7 +243,7 @@ aiorecollect==1.0.8 aioshelly==0.6.4 # homeassistant.components.switcher_kis -aioswitcher==2.0.4 +aioswitcher==2.0.5 # homeassistant.components.syncthing aiosyncthing==0.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 180c67cd3aa..88723d94c70 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -164,7 +164,7 @@ aiorecollect==1.0.8 aioshelly==0.6.4 # homeassistant.components.switcher_kis -aioswitcher==2.0.4 +aioswitcher==2.0.5 # homeassistant.components.syncthing aiosyncthing==0.5.1 From 22e6ddf8dfb0e53849ca9eb48b92ba1400f5faf2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 8 Sep 2021 16:55:40 +0200 Subject: [PATCH 295/843] Do not let one bad statistic spoil the bunch (#55942) --- .../components/recorder/statistics.py | 10 +- tests/components/recorder/test_statistics.py | 105 +++++++++++++++++- 2 files changed, 113 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index ddc542d23b7..21c286f8eb6 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -8,6 +8,7 @@ import logging from typing import TYPE_CHECKING, Any, Callable from sqlalchemy import bindparam +from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext import baked from sqlalchemy.orm.scoping import scoped_session @@ -215,7 +216,14 @@ def compile_statistics(instance: Recorder, start: datetime) -> bool: metadata_id = _update_or_add_metadata( instance.hass, session, entity_id, stat["meta"] ) - session.add(Statistics.from_stats(metadata_id, start, stat["stat"])) + try: + session.add(Statistics.from_stats(metadata_id, start, stat["stat"])) + except SQLAlchemyError: + _LOGGER.exception( + "Unexpected exception when inserting statistics %s:%s ", + metadata_id, + stat, + ) session.add(StatisticsRuns(start=start)) return True diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index 318d82422d7..ac1681e2628 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -3,11 +3,15 @@ from datetime import timedelta from unittest.mock import patch, sentinel +import pytest from pytest import approx from homeassistant.components.recorder import history from homeassistant.components.recorder.const import DATA_INSTANCE -from homeassistant.components.recorder.models import process_timestamp_to_utc_isoformat +from homeassistant.components.recorder.models import ( + Statistics, + process_timestamp_to_utc_isoformat, +) from homeassistant.components.recorder.statistics import ( get_last_statistics, statistics_during_period, @@ -94,6 +98,105 @@ def test_compile_hourly_statistics(hass_recorder): assert stats == {} +@pytest.fixture +def mock_sensor_statistics(): + """Generate some fake statistics.""" + sensor_stats = { + "meta": {"unit_of_measurement": "dogs", "has_mean": True, "has_sum": False}, + "stat": {}, + } + + def get_fake_stats(): + return { + "sensor.test1": sensor_stats, + "sensor.test2": sensor_stats, + "sensor.test3": sensor_stats, + } + + with patch( + "homeassistant.components.sensor.recorder.compile_statistics", + return_value=get_fake_stats(), + ): + yield + + +@pytest.fixture +def mock_from_stats(): + """Mock out Statistics.from_stats.""" + counter = 0 + real_from_stats = Statistics.from_stats + + def from_stats(metadata_id, start, stats): + nonlocal counter + if counter == 0 and metadata_id == 2: + counter += 1 + return None + return real_from_stats(metadata_id, start, stats) + + with patch( + "homeassistant.components.recorder.statistics.Statistics.from_stats", + side_effect=from_stats, + autospec=True, + ): + yield + + +def test_compile_hourly_statistics_exception( + hass_recorder, mock_sensor_statistics, mock_from_stats +): + """Test exception handling when compiling hourly statistics.""" + + def mock_from_stats(): + raise ValueError + + hass = hass_recorder() + recorder = hass.data[DATA_INSTANCE] + setup_component(hass, "sensor", {}) + + now = dt_util.utcnow() + recorder.do_adhoc_statistics(period="hourly", start=now) + recorder.do_adhoc_statistics(period="hourly", start=now + timedelta(hours=1)) + wait_recording_done(hass) + expected_1 = { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(now), + "mean": None, + "min": None, + "max": None, + "last_reset": None, + "state": None, + "sum": None, + } + expected_2 = { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(now + timedelta(hours=1)), + "mean": None, + "min": None, + "max": None, + "last_reset": None, + "state": None, + "sum": None, + } + expected_stats1 = [ + {**expected_1, "statistic_id": "sensor.test1"}, + {**expected_2, "statistic_id": "sensor.test1"}, + ] + expected_stats2 = [ + {**expected_2, "statistic_id": "sensor.test2"}, + ] + expected_stats3 = [ + {**expected_1, "statistic_id": "sensor.test3"}, + {**expected_2, "statistic_id": "sensor.test3"}, + ] + + stats = statistics_during_period(hass, now) + assert stats == { + "sensor.test1": expected_stats1, + "sensor.test2": expected_stats2, + "sensor.test3": expected_stats3, + } + + def test_rename_entity(hass_recorder): """Test statistics is migrated when entity_id is changed.""" hass = hass_recorder() From 9f1e503784127e3847396daf4f7e9a95fa47f706 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 8 Sep 2021 17:05:16 +0200 Subject: [PATCH 296/843] Do not allow `inf` or `nan` sensor states in statistics (#55943) --- homeassistant/components/sensor/recorder.py | 38 +++++++----- tests/components/sensor/test_recorder.py | 65 +++++++++++++++++++++ 2 files changed, 89 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index c6c8482669e..0b24744406e 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -4,6 +4,7 @@ from __future__ import annotations import datetime import itertools import logging +import math from typing import Callable from homeassistant.components.recorder import history, statistics @@ -175,6 +176,14 @@ def _get_units(fstates: list[tuple[float, State]]) -> set[str | None]: return {item[1].attributes.get(ATTR_UNIT_OF_MEASUREMENT) for item in fstates} +def _parse_float(state: str) -> float: + """Parse a float string, throw on inf or nan.""" + fstate = float(state) + if math.isnan(fstate) or math.isinf(fstate): + raise ValueError + return fstate + + def _normalize_states( hass: HomeAssistant, entity_history: list[State], @@ -189,9 +198,10 @@ def _normalize_states( fstates = [] for state in entity_history: try: - fstates.append((float(state.state), state)) - except ValueError: + fstate = _parse_float(state.state) + except (ValueError, TypeError): # TypeError to guard for NULL state in DB continue + fstates.append((fstate, state)) if fstates: all_units = _get_units(fstates) @@ -221,20 +231,20 @@ def _normalize_states( for state in entity_history: try: - fstate = float(state.state) - unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - # Exclude unsupported units from statistics - if unit not in UNIT_CONVERSIONS[device_class]: - if WARN_UNSUPPORTED_UNIT not in hass.data: - hass.data[WARN_UNSUPPORTED_UNIT] = set() - if entity_id not in hass.data[WARN_UNSUPPORTED_UNIT]: - hass.data[WARN_UNSUPPORTED_UNIT].add(entity_id) - _LOGGER.warning("%s has unknown unit %s", entity_id, unit) - continue - - fstates.append((UNIT_CONVERSIONS[device_class][unit](fstate), state)) + fstate = _parse_float(state.state) except ValueError: continue + unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + # Exclude unsupported units from statistics + if unit not in UNIT_CONVERSIONS[device_class]: + if WARN_UNSUPPORTED_UNIT not in hass.data: + hass.data[WARN_UNSUPPORTED_UNIT] = set() + if entity_id not in hass.data[WARN_UNSUPPORTED_UNIT]: + hass.data[WARN_UNSUPPORTED_UNIT].add(entity_id) + _LOGGER.warning("%s has unknown unit %s", entity_id, unit) + continue + + fstates.append((UNIT_CONVERSIONS[device_class][unit](fstate), state)) return DEVICE_CLASS_UNITS[device_class], fstates diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 41fa80d3f24..8b1283a3653 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -1,6 +1,7 @@ """The tests for sensor recorder platform.""" # pylint: disable=protected-access,invalid-name from datetime import timedelta +import math from unittest.mock import patch import pytest @@ -349,6 +350,70 @@ def test_compile_hourly_sum_statistics_amount_reset_every_state_change( assert "Error while processing event StatisticsTask" not in caplog.text +@pytest.mark.parametrize("state_class", ["measurement"]) +@pytest.mark.parametrize( + "device_class,unit,native_unit,factor", + [ + ("energy", "kWh", "kWh", 1), + ], +) +def test_compile_hourly_sum_statistics_nan_inf_state( + hass_recorder, caplog, state_class, device_class, unit, native_unit, factor +): + """Test compiling hourly statistics with nan and inf states.""" + zero = dt_util.utcnow() + hass = hass_recorder() + recorder = hass.data[DATA_INSTANCE] + setup_component(hass, "sensor", {}) + attributes = { + "device_class": device_class, + "state_class": state_class, + "unit_of_measurement": unit, + "last_reset": None, + } + seq = [10, math.nan, 15, 15, 20, math.inf, 20, 10] + + states = {"sensor.test1": []} + one = zero + for i in range(len(seq)): + one = one + timedelta(minutes=1) + _states = record_meter_state( + hass, one, "sensor.test1", attributes, seq[i : i + 1] + ) + states["sensor.test1"].extend(_states["sensor.test1"]) + + hist = history.get_significant_states( + hass, + zero - timedelta.resolution, + one + timedelta.resolution, + significant_changes_only=False, + ) + assert dict(states)["sensor.test1"] == dict(hist)["sensor.test1"] + + recorder.do_adhoc_statistics(period="hourly", start=zero) + wait_recording_done(hass) + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + {"statistic_id": "sensor.test1", "unit_of_measurement": native_unit} + ] + stats = statistics_during_period(hass, zero) + assert stats == { + "sensor.test1": [ + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero), + "max": None, + "mean": None, + "min": None, + "last_reset": process_timestamp_to_utc_isoformat(one), + "state": approx(factor * seq[7]), + "sum": approx(factor * (seq[2] + seq[3] + seq[4] + seq[6] + seq[7])), + }, + ] + } + assert "Error while processing event StatisticsTask" not in caplog.text + + @pytest.mark.parametrize( "device_class,unit,native_unit,factor", [ From 27764e998570d9c8224b61226fc7d2ed96ee24b2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 8 Sep 2021 17:08:48 +0200 Subject: [PATCH 297/843] Fix handling of imperial units in long term statistics (#55959) --- .../components/recorder/statistics.py | 21 ++++++++++----- homeassistant/components/sensor/recorder.py | 2 +- tests/components/recorder/test_statistics.py | 14 +++++----- tests/components/sensor/test_recorder.py | 26 ++++++++++++------- 4 files changed, 40 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 21c286f8eb6..db82eb1ee39 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -377,11 +377,11 @@ def statistics_during_period( ) if not stats: return {} - return _sorted_statistics_to_dict(hass, stats, statistic_ids, metadata) + return _sorted_statistics_to_dict(hass, stats, statistic_ids, metadata, True) def get_last_statistics( - hass: HomeAssistant, number_of_stats: int, statistic_id: str + hass: HomeAssistant, number_of_stats: int, statistic_id: str, convert_units: bool ) -> dict[str, list[dict]]: """Return the last number_of_stats statistics for a statistic_id.""" statistic_ids = [statistic_id] @@ -411,7 +411,9 @@ def get_last_statistics( if not stats: return {} - return _sorted_statistics_to_dict(hass, stats, statistic_ids, metadata) + return _sorted_statistics_to_dict( + hass, stats, statistic_ids, metadata, convert_units + ) def _sorted_statistics_to_dict( @@ -419,11 +421,16 @@ def _sorted_statistics_to_dict( stats: list, statistic_ids: list[str] | None, metadata: dict[str, StatisticMetaData], + convert_units: bool, ) -> dict[str, list[dict]]: """Convert SQL results into JSON friendly data structure.""" result: dict = defaultdict(list) units = hass.config.units + def no_conversion(val: Any, _: Any) -> float | None: + """Return x.""" + return val # type: ignore + # Set all statistic IDs to empty lists in result set to maintain the order if statistic_ids is not None: for stat_id in statistic_ids: @@ -436,9 +443,11 @@ def _sorted_statistics_to_dict( for meta_id, group in groupby(stats, lambda stat: stat.metadata_id): # type: ignore unit = metadata[meta_id]["unit_of_measurement"] statistic_id = metadata[meta_id]["statistic_id"] - convert: Callable[[Any, Any], float | None] = UNIT_CONVERSIONS.get( - unit, lambda x, units: x # type: ignore - ) + convert: Callable[[Any, Any], float | None] + if convert_units: + convert = UNIT_CONVERSIONS.get(unit, lambda x, units: x) # type: ignore + else: + convert = no_conversion ent_results = result[meta_id] ent_results.extend( { diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 0b24744406e..e78f9a942c6 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -393,7 +393,7 @@ def compile_statistics( # noqa: C901 last_reset = old_last_reset = None new_state = old_state = None _sum = 0 - last_stats = statistics.get_last_statistics(hass, 1, entity_id) + last_stats = statistics.get_last_statistics(hass, 1, entity_id, False) if entity_id in last_stats: # We have compiled history for this sensor before, use that as a starting point last_reset = old_last_reset = last_stats[entity_id][0]["last_reset"] diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index ac1681e2628..0580460a537 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -36,7 +36,7 @@ def test_compile_hourly_statistics(hass_recorder): for kwargs in ({}, {"statistic_ids": ["sensor.test1"]}): stats = statistics_during_period(hass, zero, **kwargs) assert stats == {} - stats = get_last_statistics(hass, 0, "sensor.test1") + stats = get_last_statistics(hass, 0, "sensor.test1", True) assert stats == {} recorder.do_adhoc_statistics(period="hourly", start=zero) @@ -82,19 +82,19 @@ def test_compile_hourly_statistics(hass_recorder): assert stats == {} # Test get_last_statistics - stats = get_last_statistics(hass, 0, "sensor.test1") + stats = get_last_statistics(hass, 0, "sensor.test1", True) assert stats == {} - stats = get_last_statistics(hass, 1, "sensor.test1") + stats = get_last_statistics(hass, 1, "sensor.test1", True) assert stats == {"sensor.test1": [{**expected_2, "statistic_id": "sensor.test1"}]} - stats = get_last_statistics(hass, 2, "sensor.test1") + stats = get_last_statistics(hass, 2, "sensor.test1", True) assert stats == {"sensor.test1": expected_stats1[::-1]} - stats = get_last_statistics(hass, 3, "sensor.test1") + stats = get_last_statistics(hass, 3, "sensor.test1", True) assert stats == {"sensor.test1": expected_stats1[::-1]} - stats = get_last_statistics(hass, 1, "sensor.test3") + stats = get_last_statistics(hass, 1, "sensor.test3", True) assert stats == {} @@ -219,7 +219,7 @@ def test_rename_entity(hass_recorder): for kwargs in ({}, {"statistic_ids": ["sensor.test1"]}): stats = statistics_during_period(hass, zero, **kwargs) assert stats == {} - stats = get_last_statistics(hass, 0, "sensor.test1") + stats = get_last_statistics(hass, 0, "sensor.test1", True) assert stats == {} recorder.do_adhoc_statistics(period="hourly", start=zero) diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 8b1283a3653..84b683cf3c3 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -18,6 +18,7 @@ from homeassistant.components.recorder.statistics import ( from homeassistant.const import STATE_UNAVAILABLE from homeassistant.setup import setup_component import homeassistant.util.dt as dt_util +from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM from tests.components.recorder.common import wait_recording_done @@ -194,22 +195,29 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes @pytest.mark.parametrize("state_class", ["measurement", "total"]) @pytest.mark.parametrize( - "device_class,unit,native_unit,factor", + "units,device_class,unit,display_unit,factor", [ - ("energy", "kWh", "kWh", 1), - ("energy", "Wh", "kWh", 1 / 1000), - ("monetary", "EUR", "EUR", 1), - ("monetary", "SEK", "SEK", 1), - ("gas", "m³", "m³", 1), - ("gas", "ft³", "m³", 0.0283168466), + (IMPERIAL_SYSTEM, "energy", "kWh", "kWh", 1), + (IMPERIAL_SYSTEM, "energy", "Wh", "kWh", 1 / 1000), + (IMPERIAL_SYSTEM, "monetary", "EUR", "EUR", 1), + (IMPERIAL_SYSTEM, "monetary", "SEK", "SEK", 1), + (IMPERIAL_SYSTEM, "gas", "m³", "ft³", 35.314666711), + (IMPERIAL_SYSTEM, "gas", "ft³", "ft³", 1), + (METRIC_SYSTEM, "energy", "kWh", "kWh", 1), + (METRIC_SYSTEM, "energy", "Wh", "kWh", 1 / 1000), + (METRIC_SYSTEM, "monetary", "EUR", "EUR", 1), + (METRIC_SYSTEM, "monetary", "SEK", "SEK", 1), + (METRIC_SYSTEM, "gas", "m³", "m³", 1), + (METRIC_SYSTEM, "gas", "ft³", "m³", 0.0283168466), ], ) def test_compile_hourly_sum_statistics_amount( - hass_recorder, caplog, state_class, device_class, unit, native_unit, factor + hass_recorder, caplog, units, state_class, device_class, unit, display_unit, factor ): """Test compiling hourly statistics.""" zero = dt_util.utcnow() hass = hass_recorder() + hass.config.units = units recorder = hass.data[DATA_INSTANCE] setup_component(hass, "sensor", {}) attributes = { @@ -236,7 +244,7 @@ def test_compile_hourly_sum_statistics_amount( wait_recording_done(hass) statistic_ids = list_statistic_ids(hass) assert statistic_ids == [ - {"statistic_id": "sensor.test1", "unit_of_measurement": native_unit} + {"statistic_id": "sensor.test1", "unit_of_measurement": display_unit} ] stats = statistics_during_period(hass, zero) assert stats == { From ee7202d10a7b5389631739adb7143ddc7af5756c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 8 Sep 2021 12:06:30 -0700 Subject: [PATCH 298/843] Bump pillow to 8.3.2 (#55970) --- homeassistant/components/doods/manifest.json | 2 +- homeassistant/components/image/manifest.json | 2 +- homeassistant/components/proxy/manifest.json | 2 +- homeassistant/components/qrcode/manifest.json | 2 +- homeassistant/components/seven_segments/manifest.json | 2 +- homeassistant/components/sighthound/manifest.json | 2 +- homeassistant/components/tensorflow/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/doods/manifest.json b/homeassistant/components/doods/manifest.json index ae584af5916..44597ac8aeb 100644 --- a/homeassistant/components/doods/manifest.json +++ b/homeassistant/components/doods/manifest.json @@ -2,7 +2,7 @@ "domain": "doods", "name": "DOODS - Dedicated Open Object Detection Service", "documentation": "https://www.home-assistant.io/integrations/doods", - "requirements": ["pydoods==1.0.2", "pillow==8.2.0"], + "requirements": ["pydoods==1.0.2", "pillow==8.3.2"], "codeowners": [], "iot_class": "local_polling" } diff --git a/homeassistant/components/image/manifest.json b/homeassistant/components/image/manifest.json index 82b7e58a653..9416ea7ef9e 100644 --- a/homeassistant/components/image/manifest.json +++ b/homeassistant/components/image/manifest.json @@ -3,7 +3,7 @@ "name": "Image", "config_flow": false, "documentation": "https://www.home-assistant.io/integrations/image", - "requirements": ["pillow==8.2.0"], + "requirements": ["pillow==8.3.2"], "dependencies": ["http"], "codeowners": ["@home-assistant/core"], "quality_scale": "internal" diff --git a/homeassistant/components/proxy/manifest.json b/homeassistant/components/proxy/manifest.json index 68c7717e16c..47982ac120e 100644 --- a/homeassistant/components/proxy/manifest.json +++ b/homeassistant/components/proxy/manifest.json @@ -2,6 +2,6 @@ "domain": "proxy", "name": "Camera Proxy", "documentation": "https://www.home-assistant.io/integrations/proxy", - "requirements": ["pillow==8.2.0"], + "requirements": ["pillow==8.3.2"], "codeowners": [] } diff --git a/homeassistant/components/qrcode/manifest.json b/homeassistant/components/qrcode/manifest.json index a414e197fd6..adfad7569e8 100644 --- a/homeassistant/components/qrcode/manifest.json +++ b/homeassistant/components/qrcode/manifest.json @@ -2,7 +2,7 @@ "domain": "qrcode", "name": "QR Code", "documentation": "https://www.home-assistant.io/integrations/qrcode", - "requirements": ["pillow==8.2.0", "pyzbar==0.1.7"], + "requirements": ["pillow==8.3.2", "pyzbar==0.1.7"], "codeowners": [], "iot_class": "calculated" } diff --git a/homeassistant/components/seven_segments/manifest.json b/homeassistant/components/seven_segments/manifest.json index 9a0287b2132..14dc16814a6 100644 --- a/homeassistant/components/seven_segments/manifest.json +++ b/homeassistant/components/seven_segments/manifest.json @@ -2,7 +2,7 @@ "domain": "seven_segments", "name": "Seven Segments OCR", "documentation": "https://www.home-assistant.io/integrations/seven_segments", - "requirements": ["pillow==8.2.0"], + "requirements": ["pillow==8.3.2"], "codeowners": ["@fabaff"], "iot_class": "local_polling" } diff --git a/homeassistant/components/sighthound/manifest.json b/homeassistant/components/sighthound/manifest.json index b22b645a7e8..b0febac8150 100644 --- a/homeassistant/components/sighthound/manifest.json +++ b/homeassistant/components/sighthound/manifest.json @@ -2,7 +2,7 @@ "domain": "sighthound", "name": "Sighthound", "documentation": "https://www.home-assistant.io/integrations/sighthound", - "requirements": ["pillow==8.2.0", "simplehound==0.3"], + "requirements": ["pillow==8.3.2", "simplehound==0.3"], "codeowners": ["@robmarkcole"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json index b3162a19364..4ec44ee3f4b 100644 --- a/homeassistant/components/tensorflow/manifest.json +++ b/homeassistant/components/tensorflow/manifest.json @@ -7,7 +7,7 @@ "tf-models-official==2.3.0", "pycocotools==2.0.1", "numpy==1.21.1", - "pillow==8.2.0" + "pillow==8.3.2" ], "codeowners": [], "iot_class": "local_polling" diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 1f8d19e9dde..454e85daeaa 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -21,7 +21,7 @@ httpx==0.19.0 ifaddr==0.1.7 jinja2==3.0.1 paho-mqtt==1.5.1 -pillow==8.2.0 +pillow==8.3.2 pip>=8.0.3,<20.3 pyserial==3.5 python-slugify==4.0.1 diff --git a/requirements_all.txt b/requirements_all.txt index 303a5b83523..20f5472f62d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1194,7 +1194,7 @@ pilight==0.1.1 # homeassistant.components.seven_segments # homeassistant.components.sighthound # homeassistant.components.tensorflow -pillow==8.2.0 +pillow==8.3.2 # homeassistant.components.dominos pizzapi==0.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 88723d94c70..532faf9456c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -675,7 +675,7 @@ pilight==0.1.1 # homeassistant.components.seven_segments # homeassistant.components.sighthound # homeassistant.components.tensorflow -pillow==8.2.0 +pillow==8.3.2 # homeassistant.components.plex plexapi==4.7.0 From bb6c2093a270413c4f596fecb4ec28e32859be49 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 8 Sep 2021 21:46:28 +0200 Subject: [PATCH 299/843] Add support for state class measurement to energy cost sensor (#55962) --- homeassistant/components/energy/sensor.py | 52 +++- homeassistant/components/energy/validate.py | 17 +- tests/components/energy/test_sensor.py | 288 +++++++++++++++++++- tests/components/energy/test_validate.py | 11 +- 4 files changed, 332 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index 45ef8ea5c17..5db085343bc 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -1,13 +1,16 @@ """Helper sensor for calculating utility costs.""" from __future__ import annotations +import copy from dataclasses import dataclass import logging from typing import Any, Final, Literal, TypeVar, cast from homeassistant.components.sensor import ( + ATTR_LAST_RESET, ATTR_STATE_CLASS, DEVICE_CLASS_MONETARY, + STATE_CLASS_MEASUREMENT, STATE_CLASS_TOTAL_INCREASING, SensorEntity, ) @@ -18,14 +21,19 @@ from homeassistant.const import ( ENERGY_WATT_HOUR, VOLUME_CUBIC_METERS, ) -from homeassistant.core import HomeAssistant, callback, split_entity_id +from homeassistant.core import HomeAssistant, State, callback, split_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +import homeassistant.util.dt as dt_util from .const import DOMAIN from .data import EnergyManager, async_get_manager +SUPPORTED_STATE_CLASSES = [ + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, +] _LOGGER = logging.getLogger(__name__) @@ -206,15 +214,16 @@ class EnergyCostSensor(SensorEntity): f"{config[adapter.entity_energy_key]}_{adapter.entity_id_suffix}" ) self._attr_device_class = DEVICE_CLASS_MONETARY - self._attr_state_class = STATE_CLASS_TOTAL_INCREASING + self._attr_state_class = STATE_CLASS_MEASUREMENT self._config = config - self._last_energy_sensor_state: StateType | None = None + self._last_energy_sensor_state: State | None = None self._cur_value = 0.0 - def _reset(self, energy_state: StateType) -> None: + def _reset(self, energy_state: State) -> None: """Reset the cost sensor.""" self._attr_native_value = 0.0 self._cur_value = 0.0 + self._attr_last_reset = dt_util.utcnow() self._last_energy_sensor_state = energy_state self.async_write_ha_state() @@ -228,9 +237,8 @@ class EnergyCostSensor(SensorEntity): if energy_state is None: return - if ( - state_class := energy_state.attributes.get(ATTR_STATE_CLASS) - ) != STATE_CLASS_TOTAL_INCREASING: + state_class = energy_state.attributes.get(ATTR_STATE_CLASS) + if state_class not in SUPPORTED_STATE_CLASSES: if not self._wrong_state_class_reported: self._wrong_state_class_reported = True _LOGGER.warning( @@ -240,6 +248,13 @@ class EnergyCostSensor(SensorEntity): ) return + # last_reset must be set if the sensor is STATE_CLASS_MEASUREMENT + if ( + state_class == STATE_CLASS_MEASUREMENT + and ATTR_LAST_RESET not in energy_state.attributes + ): + return + try: energy = float(energy_state.state) except ValueError: @@ -273,7 +288,7 @@ class EnergyCostSensor(SensorEntity): if self._last_energy_sensor_state is None: # Initialize as it's the first time all required entities are in place. - self._reset(energy_state.state) + self._reset(energy_state) return energy_unit = energy_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) @@ -298,20 +313,29 @@ class EnergyCostSensor(SensorEntity): ) return - if reset_detected( + if state_class != STATE_CLASS_TOTAL_INCREASING and energy_state.attributes.get( + ATTR_LAST_RESET + ) != self._last_energy_sensor_state.attributes.get(ATTR_LAST_RESET): + # Energy meter was reset, reset cost sensor too + energy_state_copy = copy.copy(energy_state) + energy_state_copy.state = "0.0" + self._reset(energy_state_copy) + elif state_class == STATE_CLASS_TOTAL_INCREASING and reset_detected( self.hass, cast(str, self._config[self._adapter.entity_energy_key]), energy, - float(self._last_energy_sensor_state), + float(self._last_energy_sensor_state.state), ): # Energy meter was reset, reset cost sensor too - self._reset(0) + energy_state_copy = copy.copy(energy_state) + energy_state_copy.state = "0.0" + self._reset(energy_state_copy) # Update with newly incurred cost - old_energy_value = float(self._last_energy_sensor_state) + old_energy_value = float(self._last_energy_sensor_state.state) self._cur_value += (energy - old_energy_value) * energy_price self._attr_native_value = round(self._cur_value, 2) - self._last_energy_sensor_state = energy_state.state + self._last_energy_sensor_state = energy_state async def async_added_to_hass(self) -> None: """Register callbacks.""" diff --git a/homeassistant/components/energy/validate.py b/homeassistant/components/energy/validate.py index 9ee6df30b5e..7097788aa30 100644 --- a/homeassistant/components/energy/validate.py +++ b/homeassistant/components/energy/validate.py @@ -113,7 +113,11 @@ def _async_validate_usage_stat( state_class = state.attributes.get("state_class") - if state_class != sensor.STATE_CLASS_TOTAL_INCREASING: + supported_state_classes = [ + sensor.STATE_CLASS_MEASUREMENT, + sensor.STATE_CLASS_TOTAL_INCREASING, + ] + if state_class not in supported_state_classes: result.append( ValidationIssue( "entity_unexpected_state_class_total_increasing", @@ -140,16 +144,13 @@ def _async_validate_price_entity( return try: - value: float | None = float(state.state) + float(state.state) except ValueError: result.append( ValidationIssue("entity_state_non_numeric", entity_id, state.state) ) return - if value is not None and value < 0: - result.append(ValidationIssue("entity_negative_state", entity_id, value)) - unit = state.attributes.get("unit_of_measurement") if unit is None or not unit.endswith( @@ -203,7 +204,11 @@ def _async_validate_cost_entity( state_class = state.attributes.get("state_class") - if state_class != sensor.STATE_CLASS_TOTAL_INCREASING: + supported_state_classes = [ + sensor.STATE_CLASS_MEASUREMENT, + sensor.STATE_CLASS_TOTAL_INCREASING, + ] + if state_class not in supported_state_classes: result.append( ValidationIssue( "entity_unexpected_state_class_total_increasing", entity_id, state_class diff --git a/tests/components/energy/test_sensor.py b/tests/components/energy/test_sensor.py index f91ddd92206..1f0da2e45a6 100644 --- a/tests/components/energy/test_sensor.py +++ b/tests/components/energy/test_sensor.py @@ -78,7 +78,7 @@ async def test_cost_sensor_no_states(hass, hass_storage) -> None: ), ], ) -async def test_cost_sensor_price_entity( +async def test_cost_sensor_price_entity_total_increasing( hass, hass_storage, hass_ws_client, @@ -90,7 +90,7 @@ async def test_cost_sensor_price_entity( cost_sensor_entity_id, flow_type, ) -> None: - """Test energy cost price from sensor entity.""" + """Test energy cost price from total_increasing type sensor entity.""" def _compile_statistics(_): return compile_statistics(hass, now, now + timedelta(seconds=1)) @@ -137,6 +137,7 @@ async def test_cost_sensor_price_entity( } now = dt_util.utcnow() + last_reset_cost_sensor = now.isoformat() # Optionally initialize dependent entities if initial_energy is not None: @@ -153,7 +154,9 @@ async def test_cost_sensor_price_entity( state = hass.states.get(cost_sensor_entity_id) assert state.state == initial_cost assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_MONETARY - assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING + if initial_cost != "unknown": + assert state.attributes["last_reset"] == last_reset_cost_sensor + assert state.attributes[ATTR_STATE_CLASS] == "measurement" assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "EUR" # Optional late setup of dependent entities @@ -169,7 +172,8 @@ async def test_cost_sensor_price_entity( state = hass.states.get(cost_sensor_entity_id) assert state.state == "0.0" assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_MONETARY - assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING + assert state.attributes["last_reset"] == last_reset_cost_sensor + assert state.attributes[ATTR_STATE_CLASS] == "measurement" assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "EUR" # # Unique ID temp disabled @@ -186,6 +190,7 @@ async def test_cost_sensor_price_entity( await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) assert state.state == "10.0" # 0 EUR + (10-0) kWh * 1 EUR/kWh = 10 EUR + assert state.attributes["last_reset"] == last_reset_cost_sensor # Nothing happens when price changes if price_entity is not None: @@ -200,6 +205,7 @@ async def test_cost_sensor_price_entity( assert msg["success"] state = hass.states.get(cost_sensor_entity_id) assert state.state == "10.0" # 10 EUR + (10-10) kWh * 2 EUR/kWh = 10 EUR + assert state.attributes["last_reset"] == last_reset_cost_sensor # Additional consumption is using the new price hass.states.async_set( @@ -210,6 +216,7 @@ async def test_cost_sensor_price_entity( await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) assert state.state == "19.0" # 10 EUR + (14.5-10) kWh * 2 EUR/kWh = 19 EUR + assert state.attributes["last_reset"] == last_reset_cost_sensor # Check generated statistics await async_wait_recording_done_without_instance(hass) @@ -226,6 +233,7 @@ async def test_cost_sensor_price_entity( await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) assert state.state == "18.0" # 19 EUR + (14-14.5) kWh * 2 EUR/kWh = 18 EUR + assert state.attributes["last_reset"] == last_reset_cost_sensor # Energy sensor is reset, with initial state at 4kWh, 0 kWh is used as zero-point hass.states.async_set( @@ -236,6 +244,8 @@ async def test_cost_sensor_price_entity( await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) assert state.state == "8.0" # 0 EUR + (4-0) kWh * 2 EUR/kWh = 8 EUR + assert state.attributes["last_reset"] != last_reset_cost_sensor + last_reset_cost_sensor = state.attributes["last_reset"] # Energy use bumped to 10 kWh hass.states.async_set( @@ -246,6 +256,213 @@ async def test_cost_sensor_price_entity( await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) assert state.state == "20.0" # 8 EUR + (10-4) kWh * 2 EUR/kWh = 20 EUR + assert state.attributes["last_reset"] == last_reset_cost_sensor + + # Check generated statistics + await async_wait_recording_done_without_instance(hass) + statistics = await hass.loop.run_in_executor(None, _compile_statistics, hass) + assert cost_sensor_entity_id in statistics + assert statistics[cost_sensor_entity_id]["stat"]["sum"] == 38.0 + + +@pytest.mark.parametrize("initial_energy,initial_cost", [(0, "0.0"), (None, "unknown")]) +@pytest.mark.parametrize( + "price_entity,fixed_price", [("sensor.energy_price", None), (None, 1)] +) +@pytest.mark.parametrize( + "usage_sensor_entity_id,cost_sensor_entity_id,flow_type", + [ + ("sensor.energy_consumption", "sensor.energy_consumption_cost", "flow_from"), + ( + "sensor.energy_production", + "sensor.energy_production_compensation", + "flow_to", + ), + ], +) +@pytest.mark.parametrize("energy_state_class", ["measurement"]) +async def test_cost_sensor_price_entity_total( + hass, + hass_storage, + hass_ws_client, + initial_energy, + initial_cost, + price_entity, + fixed_price, + usage_sensor_entity_id, + cost_sensor_entity_id, + flow_type, + energy_state_class, +) -> None: + """Test energy cost price from total type sensor entity.""" + + def _compile_statistics(_): + return compile_statistics(hass, now, now + timedelta(seconds=1)) + + energy_attributes = { + ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, + ATTR_STATE_CLASS: energy_state_class, + } + + await async_init_recorder_component(hass) + energy_data = data.EnergyManager.default_preferences() + energy_data["energy_sources"].append( + { + "type": "grid", + "flow_from": [ + { + "stat_energy_from": "sensor.energy_consumption", + "entity_energy_from": "sensor.energy_consumption", + "stat_cost": None, + "entity_energy_price": price_entity, + "number_energy_price": fixed_price, + } + ] + if flow_type == "flow_from" + else [], + "flow_to": [ + { + "stat_energy_to": "sensor.energy_production", + "entity_energy_to": "sensor.energy_production", + "stat_compensation": None, + "entity_energy_price": price_entity, + "number_energy_price": fixed_price, + } + ] + if flow_type == "flow_to" + else [], + "cost_adjustment_day": 0, + } + ) + + hass_storage[data.STORAGE_KEY] = { + "version": 1, + "data": energy_data, + } + + now = dt_util.utcnow() + last_reset = dt_util.utc_from_timestamp(0).isoformat() + last_reset_cost_sensor = now.isoformat() + + # Optionally initialize dependent entities + if initial_energy is not None: + hass.states.async_set( + usage_sensor_entity_id, + initial_energy, + {**energy_attributes, **{"last_reset": last_reset}}, + ) + hass.states.async_set("sensor.energy_price", "1") + + with patch("homeassistant.util.dt.utcnow", return_value=now): + await setup_integration(hass) + + state = hass.states.get(cost_sensor_entity_id) + assert state.state == initial_cost + assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_MONETARY + if initial_cost != "unknown": + assert state.attributes["last_reset"] == last_reset_cost_sensor + assert state.attributes[ATTR_STATE_CLASS] == "measurement" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "EUR" + + # Optional late setup of dependent entities + if initial_energy is None: + with patch("homeassistant.util.dt.utcnow", return_value=now): + hass.states.async_set( + usage_sensor_entity_id, + "0", + {**energy_attributes, **{"last_reset": last_reset}}, + ) + await hass.async_block_till_done() + + state = hass.states.get(cost_sensor_entity_id) + assert state.state == "0.0" + assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_MONETARY + assert state.attributes["last_reset"] == last_reset_cost_sensor + assert state.attributes[ATTR_STATE_CLASS] == "measurement" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "EUR" + + # # Unique ID temp disabled + # # entity_registry = er.async_get(hass) + # # entry = entity_registry.async_get(cost_sensor_entity_id) + # # assert entry.unique_id == "energy_energy_consumption cost" + + # Energy use bumped to 10 kWh + hass.states.async_set( + usage_sensor_entity_id, + "10", + {**energy_attributes, **{"last_reset": last_reset}}, + ) + await hass.async_block_till_done() + state = hass.states.get(cost_sensor_entity_id) + assert state.state == "10.0" # 0 EUR + (10-0) kWh * 1 EUR/kWh = 10 EUR + assert state.attributes["last_reset"] == last_reset_cost_sensor + + # Nothing happens when price changes + if price_entity is not None: + hass.states.async_set(price_entity, "2") + await hass.async_block_till_done() + else: + energy_data = copy.deepcopy(energy_data) + energy_data["energy_sources"][0][flow_type][0]["number_energy_price"] = 2 + client = await hass_ws_client(hass) + await client.send_json({"id": 5, "type": "energy/save_prefs", **energy_data}) + msg = await client.receive_json() + assert msg["success"] + state = hass.states.get(cost_sensor_entity_id) + assert state.state == "10.0" # 10 EUR + (10-10) kWh * 2 EUR/kWh = 10 EUR + assert state.attributes["last_reset"] == last_reset_cost_sensor + + # Additional consumption is using the new price + hass.states.async_set( + usage_sensor_entity_id, + "14.5", + {**energy_attributes, **{"last_reset": last_reset}}, + ) + await hass.async_block_till_done() + state = hass.states.get(cost_sensor_entity_id) + assert state.state == "19.0" # 10 EUR + (14.5-10) kWh * 2 EUR/kWh = 19 EUR + assert state.attributes["last_reset"] == last_reset_cost_sensor + + # Check generated statistics + await async_wait_recording_done_without_instance(hass) + statistics = await hass.loop.run_in_executor(None, _compile_statistics, hass) + assert cost_sensor_entity_id in statistics + assert statistics[cost_sensor_entity_id]["stat"]["sum"] == 19.0 + + # Energy sensor has a small dip + hass.states.async_set( + usage_sensor_entity_id, + "14", + {**energy_attributes, **{"last_reset": last_reset}}, + ) + await hass.async_block_till_done() + state = hass.states.get(cost_sensor_entity_id) + assert state.state == "18.0" # 19 EUR + (14-14.5) kWh * 2 EUR/kWh = 18 EUR + assert state.attributes["last_reset"] == last_reset_cost_sensor + + # Energy sensor is reset, with initial state at 4kWh, 0 kWh is used as zero-point + last_reset = (now + timedelta(seconds=1)).isoformat() + hass.states.async_set( + usage_sensor_entity_id, + "4", + {**energy_attributes, **{"last_reset": last_reset}}, + ) + await hass.async_block_till_done() + state = hass.states.get(cost_sensor_entity_id) + assert state.state == "8.0" # 0 EUR + (4-0) kWh * 2 EUR/kWh = 8 EUR + assert state.attributes["last_reset"] != last_reset_cost_sensor + last_reset_cost_sensor = state.attributes["last_reset"] + + # Energy use bumped to 10 kWh + hass.states.async_set( + usage_sensor_entity_id, + "10", + {**energy_attributes, **{"last_reset": last_reset}}, + ) + await hass.async_block_till_done() + state = hass.states.get(cost_sensor_entity_id) + assert state.state == "20.0" # 8 EUR + (10-4) kWh * 2 EUR/kWh = 20 EUR + assert state.attributes["last_reset"] == last_reset_cost_sensor # Check generated statistics await async_wait_recording_done_without_instance(hass) @@ -285,6 +502,7 @@ async def test_cost_sensor_handle_wh(hass, hass_storage) -> None: now = dt_util.utcnow() + # Initial state: 10kWh hass.states.async_set( "sensor.energy_consumption", 10000, @@ -297,7 +515,7 @@ async def test_cost_sensor_handle_wh(hass, hass_storage) -> None: state = hass.states.get("sensor.energy_consumption_cost") assert state.state == "0.0" - # Energy use bumped to 10 kWh + # Energy use bumped by 10 kWh hass.states.async_set( "sensor.energy_consumption", 20000, @@ -362,7 +580,7 @@ async def test_cost_sensor_handle_gas(hass, hass_storage) -> None: async def test_cost_sensor_wrong_state_class( hass, hass_storage, caplog, state_class ) -> None: - """Test energy sensor rejects wrong state_class.""" + """Test energy sensor rejects state_class with wrong state_class.""" energy_attributes = { ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, ATTR_STATE_CLASS: state_class, @@ -418,3 +636,61 @@ async def test_cost_sensor_wrong_state_class( state = hass.states.get("sensor.energy_consumption_cost") assert state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize("state_class", ["measurement"]) +async def test_cost_sensor_state_class_measurement_no_reset( + hass, hass_storage, caplog, state_class +) -> None: + """Test energy sensor rejects state_class with no last_reset.""" + energy_attributes = { + ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, + ATTR_STATE_CLASS: state_class, + } + energy_data = data.EnergyManager.default_preferences() + energy_data["energy_sources"].append( + { + "type": "grid", + "flow_from": [ + { + "stat_energy_from": "sensor.energy_consumption", + "entity_energy_from": "sensor.energy_consumption", + "stat_cost": None, + "entity_energy_price": None, + "number_energy_price": 0.5, + } + ], + "flow_to": [], + "cost_adjustment_day": 0, + } + ) + + hass_storage[data.STORAGE_KEY] = { + "version": 1, + "data": energy_data, + } + + now = dt_util.utcnow() + + hass.states.async_set( + "sensor.energy_consumption", + 10000, + energy_attributes, + ) + + with patch("homeassistant.util.dt.utcnow", return_value=now): + await setup_integration(hass) + + state = hass.states.get("sensor.energy_consumption_cost") + assert state.state == STATE_UNKNOWN + + # Energy use bumped to 10 kWh + hass.states.async_set( + "sensor.energy_consumption", + 20000, + energy_attributes, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_consumption_cost") + assert state.state == STATE_UNKNOWN diff --git a/tests/components/energy/test_validate.py b/tests/components/energy/test_validate.py index 31f40bd24ea..8c67f3eabaf 100644 --- a/tests/components/energy/test_validate.py +++ b/tests/components/energy/test_validate.py @@ -382,15 +382,6 @@ async def test_validation_grid_price_not_exist(hass, mock_energy_manager): "value": "123,123.12", }, ), - ( - "-100", - "$/kWh", - { - "type": "entity_negative_state", - "identifier": "sensor.grid_price_1", - "value": -100.0, - }, - ), ( "123", "$/Ws", @@ -414,7 +405,7 @@ async def test_validation_grid_price_errors( hass.states.async_set( "sensor.grid_price_1", state, - {"unit_of_measurement": unit, "state_class": "total_increasing"}, + {"unit_of_measurement": unit, "state_class": "measurement"}, ) await mock_energy_manager.async_update( { From 232943c93d9df23620140201067297b601a9a6ff Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 8 Sep 2021 21:47:48 +0200 Subject: [PATCH 300/843] Add significant change support to AQI type sensors (#55833) --- .../components/google_assistant/trait.py | 14 +-- .../components/light/significant_change.py | 18 ++-- .../components/sensor/significant_change.py | 60 ++++++++++-- homeassistant/helpers/significant_change.py | 50 ++++++++-- .../sensor/test_significant_change.py | 93 ++++++++++--------- 5 files changed, 152 insertions(+), 83 deletions(-) diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index d1ed328703e..393f8b22fbe 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -2311,16 +2311,12 @@ class SensorStateTrait(_Trait): name = TRAIT_SENSOR_STATE commands = [] - @staticmethod - def supported(domain, features, device_class, _): + @classmethod + def supported(cls, domain, features, device_class, _): """Test if state is supported.""" - return domain == sensor.DOMAIN and device_class in ( - sensor.DEVICE_CLASS_AQI, - sensor.DEVICE_CLASS_CO, - sensor.DEVICE_CLASS_CO2, - sensor.DEVICE_CLASS_PM25, - sensor.DEVICE_CLASS_PM10, - sensor.DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, + return ( + domain == sensor.DOMAIN + and device_class in SensorStateTrait.sensor_types.keys() ) def sync_attributes(self): diff --git a/homeassistant/components/light/significant_change.py b/homeassistant/components/light/significant_change.py index 9e0f10fae47..79f447f5794 100644 --- a/homeassistant/components/light/significant_change.py +++ b/homeassistant/components/light/significant_change.py @@ -4,10 +4,7 @@ from __future__ import annotations from typing import Any from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.significant_change import ( - check_numeric_changed, - either_one_none, -) +from homeassistant.helpers.significant_change import check_absolute_change from . import ( ATTR_BRIGHTNESS, @@ -37,24 +34,21 @@ def async_check_significant_change( old_color = old_attrs.get(ATTR_HS_COLOR) new_color = new_attrs.get(ATTR_HS_COLOR) - if either_one_none(old_color, new_color): - return True - if old_color and new_color: # Range 0..360 - if check_numeric_changed(old_color[0], new_color[0], 5): + if check_absolute_change(old_color[0], new_color[0], 5): return True # Range 0..100 - if check_numeric_changed(old_color[1], new_color[1], 3): + if check_absolute_change(old_color[1], new_color[1], 3): return True - if check_numeric_changed( + if check_absolute_change( old_attrs.get(ATTR_BRIGHTNESS), new_attrs.get(ATTR_BRIGHTNESS), 3 ): return True - if check_numeric_changed( + if check_absolute_change( # Default range 153..500 old_attrs.get(ATTR_COLOR_TEMP), new_attrs.get(ATTR_COLOR_TEMP), @@ -62,7 +56,7 @@ def async_check_significant_change( ): return True - if check_numeric_changed( + if check_absolute_change( # Range 0..255 old_attrs.get(ATTR_WHITE_VALUE), new_attrs.get(ATTR_WHITE_VALUE), diff --git a/homeassistant/components/sensor/significant_change.py b/homeassistant/components/sensor/significant_change.py index cda80991242..5c180be62f3 100644 --- a/homeassistant/components/sensor/significant_change.py +++ b/homeassistant/components/sensor/significant_change.py @@ -9,8 +9,33 @@ from homeassistant.const import ( TEMP_FAHRENHEIT, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.significant_change import ( + check_absolute_change, + check_percentage_change, +) -from . import DEVICE_CLASS_BATTERY, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE +from . import ( + DEVICE_CLASS_AQI, + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_CO, + DEVICE_CLASS_CO2, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_PM10, + DEVICE_CLASS_PM25, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, +) + + +def _absolute_and_relative_change( + old_state: int | float | None, + new_state: int | float | None, + absolute_change: int | float, + percentage_change: int | float, +) -> bool: + return check_absolute_change( + old_state, new_state, absolute_change + ) and check_percentage_change(old_state, new_state, percentage_change) @callback @@ -28,20 +53,35 @@ def async_check_significant_change( if device_class is None: return None + absolute_change: float | None = None + percentage_change: float | None = None if device_class == DEVICE_CLASS_TEMPERATURE: if new_attrs.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_FAHRENHEIT: - change: float | int = 1 + absolute_change = 1.0 else: - change = 0.5 - - old_value = float(old_state) - new_value = float(new_state) - return abs(old_value - new_value) >= change + absolute_change = 0.5 if device_class in (DEVICE_CLASS_BATTERY, DEVICE_CLASS_HUMIDITY): - old_value = float(old_state) - new_value = float(new_state) + absolute_change = 1.0 - return abs(old_value - new_value) >= 1 + if device_class in ( + DEVICE_CLASS_AQI, + DEVICE_CLASS_CO, + DEVICE_CLASS_CO2, + DEVICE_CLASS_PM25, + DEVICE_CLASS_PM10, + DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, + ): + absolute_change = 1.0 + percentage_change = 2.0 + + if absolute_change is not None and percentage_change is not None: + return _absolute_and_relative_change( + float(old_state), float(new_state), absolute_change, percentage_change + ) + if absolute_change is not None: + return check_absolute_change( + float(old_state), float(new_state), absolute_change + ) return None diff --git a/homeassistant/helpers/significant_change.py b/homeassistant/helpers/significant_change.py index b34df0075a3..d2791def987 100644 --- a/homeassistant/helpers/significant_change.py +++ b/homeassistant/helpers/significant_change.py @@ -95,25 +95,55 @@ def either_one_none(val1: Any | None, val2: Any | None) -> bool: return (val1 is None and val2 is not None) or (val1 is not None and val2 is None) -def check_numeric_changed( +def _check_numeric_change( + old_state: int | float | None, + new_state: int | float | None, + change: int | float, + metric: Callable[[int | float, int | float], int | float], +) -> bool: + """Check if two numeric values have changed.""" + if old_state is None and new_state is None: + return False + + if either_one_none(old_state, new_state): + return True + + assert old_state is not None + assert new_state is not None + + if metric(old_state, new_state) >= change: + return True + + return False + + +def check_absolute_change( val1: int | float | None, val2: int | float | None, change: int | float, ) -> bool: """Check if two numeric values have changed.""" - if val1 is None and val2 is None: - return False + return _check_numeric_change( + val1, val2, change, lambda val1, val2: abs(val1 - val2) + ) - if either_one_none(val1, val2): - return True - assert val1 is not None - assert val2 is not None +def check_percentage_change( + old_state: int | float | None, + new_state: int | float | None, + change: int | float, +) -> bool: + """Check if two numeric values have changed.""" - if abs(val1 - val2) >= change: - return True + def percentage_change(old_state: int | float, new_state: int | float) -> float: + if old_state == new_state: + return 0 + try: + return (abs(new_state - old_state) / old_state) * 100.0 + except ZeroDivisionError: + return float("inf") - return False + return _check_numeric_change(old_state, new_state, change, percentage_change) class SignificantlyChangedChecker: diff --git a/tests/components/sensor/test_significant_change.py b/tests/components/sensor/test_significant_change.py index 12b74345011..22a2c22ecc7 100644 --- a/tests/components/sensor/test_significant_change.py +++ b/tests/components/sensor/test_significant_change.py @@ -1,5 +1,8 @@ """Test the sensor significant change platform.""" +import pytest + from homeassistant.components.sensor.significant_change import ( + DEVICE_CLASS_AQI, DEVICE_CLASS_BATTERY, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, @@ -12,48 +15,54 @@ from homeassistant.const import ( TEMP_FAHRENHEIT, ) +AQI_ATTRS = { + ATTR_DEVICE_CLASS: DEVICE_CLASS_AQI, +} -async def test_significant_change_temperature(): +BATTERY_ATTRS = { + ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY, +} + +HUMIDITY_ATTRS = { + ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, +} + +TEMP_CELSIUS_ATTRS = { + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, +} + +TEMP_FREEDOM_ATTRS = { + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: TEMP_FAHRENHEIT, +} + + +@pytest.mark.parametrize( + "old_state,new_state,attrs,result", + [ + ("0", "1", AQI_ATTRS, True), + ("1", "0", AQI_ATTRS, True), + ("0.1", "0.5", AQI_ATTRS, False), + ("0.5", "0.1", AQI_ATTRS, False), + ("99", "100", AQI_ATTRS, False), + ("100", "99", AQI_ATTRS, False), + ("101", "99", AQI_ATTRS, False), + ("99", "101", AQI_ATTRS, True), + ("100", "100", BATTERY_ATTRS, False), + ("100", "99", BATTERY_ATTRS, True), + ("100", "100", HUMIDITY_ATTRS, False), + ("100", "99", HUMIDITY_ATTRS, True), + ("12", "12", TEMP_CELSIUS_ATTRS, False), + ("12", "13", TEMP_CELSIUS_ATTRS, True), + ("12.1", "12.2", TEMP_CELSIUS_ATTRS, False), + ("70", "71", TEMP_FREEDOM_ATTRS, True), + ("70", "70.5", TEMP_FREEDOM_ATTRS, False), + ], +) +async def test_significant_change_temperature(old_state, new_state, attrs, result): """Detect temperature significant changes.""" - celsius_attrs = { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, - } - assert not async_check_significant_change( - None, "12", celsius_attrs, "12", celsius_attrs + assert ( + async_check_significant_change(None, old_state, attrs, new_state, attrs) + is result ) - assert async_check_significant_change( - None, "12", celsius_attrs, "13", celsius_attrs - ) - assert not async_check_significant_change( - None, "12.1", celsius_attrs, "12.2", celsius_attrs - ) - - freedom_attrs = { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_UNIT_OF_MEASUREMENT: TEMP_FAHRENHEIT, - } - assert async_check_significant_change( - None, "70", freedom_attrs, "71", freedom_attrs - ) - assert not async_check_significant_change( - None, "70", freedom_attrs, "70.5", freedom_attrs - ) - - -async def test_significant_change_battery(): - """Detect battery significant changes.""" - attrs = { - ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY, - } - assert not async_check_significant_change(None, "100", attrs, "100", attrs) - assert async_check_significant_change(None, "100", attrs, "99", attrs) - - -async def test_significant_change_humidity(): - """Detect humidity significant changes.""" - attrs = { - ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, - } - assert not async_check_significant_change(None, "100", attrs, "100", attrs) - assert async_check_significant_change(None, "100", attrs, "99", attrs) From 4d2432cffbd4847b1573fabe77c9e2a074d2fc43 Mon Sep 17 00:00:00 2001 From: Philip Allgaier Date: Wed, 8 Sep 2021 22:16:12 +0200 Subject: [PATCH 301/843] Consistent lower-case spelling of "optional" (#55976) --- homeassistant/components/flick_electric/strings.json | 4 ++-- homeassistant/components/nightscout/strings.json | 2 +- homeassistant/components/plex/strings.json | 2 +- homeassistant/components/tesla/strings.json | 2 +- homeassistant/components/yeelight/strings.json | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/flick_electric/strings.json b/homeassistant/components/flick_electric/strings.json index a20b5059ef7..cb8382539b4 100644 --- a/homeassistant/components/flick_electric/strings.json +++ b/homeassistant/components/flick_electric/strings.json @@ -6,8 +6,8 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "client_id": "Client ID (Optional)", - "client_secret": "Client Secret (Optional)" + "client_id": "Client ID (optional)", + "client_secret": "Client Secret (optional)" } } }, diff --git a/homeassistant/components/nightscout/strings.json b/homeassistant/components/nightscout/strings.json index 709788c5818..b3b99485587 100644 --- a/homeassistant/components/nightscout/strings.json +++ b/homeassistant/components/nightscout/strings.json @@ -3,7 +3,7 @@ "step": { "user": { "title": "Enter your Nightscout server information.", - "description": "- URL: the address of your nightscout instance. I.e.: https://myhomeassistant.duckdns.org:5423\n- API Key (Optional): Only use if your instance is protected (auth_default_roles != readable).", + "description": "- URL: the address of your nightscout instance. I.e.: https://myhomeassistant.duckdns.org:5423\n- API Key (optional): Only use if your instance is protected (auth_default_roles != readable).", "data": { "url": "[%key:common::config_flow::data::url%]", "api_key": "[%key:common::config_flow::data::api_key%]" diff --git a/homeassistant/components/plex/strings.json b/homeassistant/components/plex/strings.json index bfe6375f5ac..c16a84b1cd8 100644 --- a/homeassistant/components/plex/strings.json +++ b/homeassistant/components/plex/strings.json @@ -19,7 +19,7 @@ "port": "[%key:common::config_flow::data::port%]", "ssl": "[%key:common::config_flow::data::ssl%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", - "token": "Token (Optional)" + "token": "Token (optional)" } }, "select_server": { diff --git a/homeassistant/components/tesla/strings.json b/homeassistant/components/tesla/strings.json index 0f5a7666175..755e5d5a7cf 100644 --- a/homeassistant/components/tesla/strings.json +++ b/homeassistant/components/tesla/strings.json @@ -14,7 +14,7 @@ "data": { "username": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]", - "mfa": "MFA Code (Optional)" + "mfa": "MFA Code (optional)" }, "description": "Please enter your information.", "title": "Tesla - Configuration" diff --git a/homeassistant/components/yeelight/strings.json b/homeassistant/components/yeelight/strings.json index a0ce26550c8..73868b6c571 100644 --- a/homeassistant/components/yeelight/strings.json +++ b/homeassistant/components/yeelight/strings.json @@ -30,7 +30,7 @@ "init": { "description": "If you leave model empty, it will be automatically detected.", "data": { - "model": "Model (Optional)", + "model": "Model (optional)", "transition": "Transition Time (ms)", "use_music_mode": "Enable Music Mode", "save_on_change": "Save Status On Change", From a1aca208189f0142adf2d81aff5e5a37d5d5e5b5 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 8 Sep 2021 22:48:31 +0200 Subject: [PATCH 302/843] Address review comment from #55833 (#55985) --- homeassistant/components/google_assistant/trait.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 393f8b22fbe..b2af1d6f9a9 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -2314,10 +2314,7 @@ class SensorStateTrait(_Trait): @classmethod def supported(cls, domain, features, device_class, _): """Test if state is supported.""" - return ( - domain == sensor.DOMAIN - and device_class in SensorStateTrait.sensor_types.keys() - ) + return domain == sensor.DOMAIN and device_class in cls.sensor_types def sync_attributes(self): """Return attributes for a sync request.""" From 675426dc259ef2c99cd8a963d59e36609e97b299 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Thu, 9 Sep 2021 00:11:32 +0000 Subject: [PATCH 303/843] [ci skip] Translation update --- .../components/flick_electric/translations/en.json | 4 ++-- homeassistant/components/nightscout/translations/de.json | 2 +- homeassistant/components/nightscout/translations/en.json | 2 +- homeassistant/components/plex/translations/en.json | 2 +- homeassistant/components/sensor/translations/de.json | 4 ++-- homeassistant/components/synology_dsm/translations/no.json | 7 +++++++ homeassistant/components/synology_dsm/translations/ru.json | 7 +++++++ .../components/synology_dsm/translations/zh-Hant.json | 7 +++++++ homeassistant/components/tesla/translations/en.json | 2 +- homeassistant/components/yeelight/translations/en.json | 2 +- 10 files changed, 30 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/flick_electric/translations/en.json b/homeassistant/components/flick_electric/translations/en.json index ecade0c677b..9fdef5dd01d 100644 --- a/homeassistant/components/flick_electric/translations/en.json +++ b/homeassistant/components/flick_electric/translations/en.json @@ -11,8 +11,8 @@ "step": { "user": { "data": { - "client_id": "Client ID (Optional)", - "client_secret": "Client Secret (Optional)", + "client_id": "Client ID (optional)", + "client_secret": "Client Secret (optional)", "password": "Password", "username": "Username" }, diff --git a/homeassistant/components/nightscout/translations/de.json b/homeassistant/components/nightscout/translations/de.json index 21bea5ee877..32bc6653af1 100644 --- a/homeassistant/components/nightscout/translations/de.json +++ b/homeassistant/components/nightscout/translations/de.json @@ -15,7 +15,7 @@ "api_key": "API-Schl\u00fcssel", "url": "URL" }, - "description": "- URL: die Adresse deiner Nightscout-Instanz. Z.B.: https://myhomeassistant.duckdns.org:5423\n- API-Schl\u00fcssel (Optional): Nur verwenden, wenn deine Instanz gesch\u00fctzt ist (auth_default_roles != readable).", + "description": "- URL: die Adresse deiner Nightscout-Instanz. Z.B.: https://myhomeassistant.duckdns.org:5423\n- API-Schl\u00fcssel (optional): Nur verwenden, wenn deine Instanz gesch\u00fctzt ist (auth_default_roles != readable).", "title": "Gib deine Nightscout-Serverinformationen ein." } } diff --git a/homeassistant/components/nightscout/translations/en.json b/homeassistant/components/nightscout/translations/en.json index d8b4c441283..baec475fc2d 100644 --- a/homeassistant/components/nightscout/translations/en.json +++ b/homeassistant/components/nightscout/translations/en.json @@ -15,7 +15,7 @@ "api_key": "API Key", "url": "URL" }, - "description": "- URL: the address of your nightscout instance. I.e.: https://myhomeassistant.duckdns.org:5423\n- API Key (Optional): Only use if your instance is protected (auth_default_roles != readable).", + "description": "- URL: the address of your nightscout instance. I.e.: https://myhomeassistant.duckdns.org:5423\n- API Key (optional): Only use if your instance is protected (auth_default_roles != readable).", "title": "Enter your Nightscout server information." } } diff --git a/homeassistant/components/plex/translations/en.json b/homeassistant/components/plex/translations/en.json index 87615c9f42e..834594e1d27 100644 --- a/homeassistant/components/plex/translations/en.json +++ b/homeassistant/components/plex/translations/en.json @@ -22,7 +22,7 @@ "host": "Host", "port": "Port", "ssl": "Uses an SSL certificate", - "token": "Token (Optional)", + "token": "Token (optional)", "verify_ssl": "Verify SSL certificate" }, "title": "Manual Plex Configuration" diff --git a/homeassistant/components/sensor/translations/de.json b/homeassistant/components/sensor/translations/de.json index 1b041b576fc..a042e3102f9 100644 --- a/homeassistant/components/sensor/translations/de.json +++ b/homeassistant/components/sensor/translations/de.json @@ -13,8 +13,8 @@ "is_nitrogen_monoxide": "Aktuelle Stickstoffmonoxidkonzentration von {entity_name}", "is_nitrous_oxide": "Aktuelle Lachgaskonzentration von {entity_name}", "is_ozone": "Aktuelle Ozonkonzentration von {entity_name}", - "is_pm1": "Aktuelle PM1-Konzentrationswert von {entity_name}", - "is_pm10": "Aktuelle PM10-Konzentrationswert von {entity_name}", + "is_pm1": "Aktuelle PM1-Konzentration von {entity_name}", + "is_pm10": "Aktuelle PM10-Konzentration von {entity_name}", "is_pm25": "Aktuelle PM2.5-Konzentration von {entity_name}", "is_power": "Aktuelle {entity_name} Leistung", "is_power_factor": "Aktueller Leistungsfaktor f\u00fcr {entity_name}", diff --git a/homeassistant/components/synology_dsm/translations/no.json b/homeassistant/components/synology_dsm/translations/no.json index 6ba7531cfd8..121ffb0c6bf 100644 --- a/homeassistant/components/synology_dsm/translations/no.json +++ b/homeassistant/components/synology_dsm/translations/no.json @@ -39,6 +39,13 @@ "description": "\u00c5rsak: {details}", "title": "Synology DSM Godkjenne integrering p\u00e5 nytt" }, + "reauth_confirm": { + "data": { + "password": "Passord", + "username": "Brukernavn" + }, + "title": "Synology DSM Godkjenne integrering p\u00e5 nytt" + }, "user": { "data": { "host": "Vert", diff --git a/homeassistant/components/synology_dsm/translations/ru.json b/homeassistant/components/synology_dsm/translations/ru.json index 37a2890e491..a6b8a8ed201 100644 --- a/homeassistant/components/synology_dsm/translations/ru.json +++ b/homeassistant/components/synology_dsm/translations/ru.json @@ -39,6 +39,13 @@ "description": "\u041f\u0440\u0438\u0447\u0438\u043d\u0430: {details}", "title": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f Synology DSM" }, + "reauth_confirm": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" + }, + "title": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f Synology DSM" + }, "user": { "data": { "host": "\u0425\u043e\u0441\u0442", diff --git a/homeassistant/components/synology_dsm/translations/zh-Hant.json b/homeassistant/components/synology_dsm/translations/zh-Hant.json index 30c97c853cb..41eddacdbac 100644 --- a/homeassistant/components/synology_dsm/translations/zh-Hant.json +++ b/homeassistant/components/synology_dsm/translations/zh-Hant.json @@ -39,6 +39,13 @@ "description": "\u8a73\u7d30\u8cc7\u8a0a\uff1a{details}", "title": "Synology DSM \u91cd\u65b0\u8a8d\u8b49\u6574\u5408" }, + "reauth_confirm": { + "data": { + "password": "\u5bc6\u78bc", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + }, + "title": "Synology DSM \u91cd\u65b0\u8a8d\u8b49\u6574\u5408" + }, "user": { "data": { "host": "\u4e3b\u6a5f\u7aef", diff --git a/homeassistant/components/tesla/translations/en.json b/homeassistant/components/tesla/translations/en.json index 16a1c185138..80cbee7e122 100644 --- a/homeassistant/components/tesla/translations/en.json +++ b/homeassistant/components/tesla/translations/en.json @@ -12,7 +12,7 @@ "step": { "user": { "data": { - "mfa": "MFA Code (Optional)", + "mfa": "MFA Code (optional)", "password": "Password", "username": "Email" }, diff --git a/homeassistant/components/yeelight/translations/en.json b/homeassistant/components/yeelight/translations/en.json index 3ed5bbe5515..4ed9440aa8f 100644 --- a/homeassistant/components/yeelight/translations/en.json +++ b/homeassistant/components/yeelight/translations/en.json @@ -29,7 +29,7 @@ "step": { "init": { "data": { - "model": "Model (Optional)", + "model": "Model (optional)", "nightlight_switch": "Use Nightlight Switch", "save_on_change": "Save Status On Change", "transition": "Transition Time (ms)", From 98ecf2888ce143dab56e7654e559de633531bc75 Mon Sep 17 00:00:00 2001 From: Alan Tse Date: Wed, 8 Sep 2021 22:12:03 -0700 Subject: [PATCH 304/843] Remove tesla integration (#55988) Co-authored-by: Paulus Schoutsen --- .coveragerc | 8 - CODEOWNERS | 1 - homeassistant/components/tesla/__init__.py | 357 ------------------ .../components/tesla/binary_sensor.py | 39 -- homeassistant/components/tesla/climate.py | 120 ------ homeassistant/components/tesla/config_flow.py | 191 ---------- homeassistant/components/tesla/const.py | 33 -- .../components/tesla/device_tracker.py | 57 --- homeassistant/components/tesla/lock.py | 41 -- homeassistant/components/tesla/manifest.json | 23 -- homeassistant/components/tesla/sensor.py | 97 ----- homeassistant/components/tesla/strings.json | 34 -- homeassistant/components/tesla/switch.py | 130 ------- .../components/tesla/translations/ca.json | 34 -- .../components/tesla/translations/cs.json | 32 -- .../components/tesla/translations/da.json | 23 -- .../components/tesla/translations/de.json | 34 -- .../components/tesla/translations/en.json | 34 -- .../components/tesla/translations/es-419.json | 24 -- .../components/tesla/translations/es.json | 34 -- .../components/tesla/translations/et.json | 34 -- .../components/tesla/translations/fi.json | 11 - .../components/tesla/translations/fr.json | 34 -- .../components/tesla/translations/he.json | 21 -- .../components/tesla/translations/hu.json | 34 -- .../components/tesla/translations/id.json | 33 -- .../components/tesla/translations/it.json | 34 -- .../components/tesla/translations/ka.json | 7 - .../components/tesla/translations/ko.json | 33 -- .../components/tesla/translations/lb.json | 29 -- .../components/tesla/translations/lv.json | 12 - .../components/tesla/translations/nl.json | 34 -- .../components/tesla/translations/no.json | 34 -- .../components/tesla/translations/pl.json | 34 -- .../components/tesla/translations/pt-BR.json | 23 -- .../components/tesla/translations/pt.json | 17 - .../components/tesla/translations/ru.json | 34 -- .../components/tesla/translations/sl.json | 24 -- .../components/tesla/translations/sv.json | 23 -- .../components/tesla/translations/tr.json | 18 - .../components/tesla/translations/uk.json | 29 -- .../tesla/translations/zh-Hans.json | 11 - .../tesla/translations/zh-Hant.json | 34 -- homeassistant/generated/config_flows.py | 1 - homeassistant/generated/dhcp.py | 15 - mypy.ini | 3 - requirements_all.txt | 3 - requirements_test_all.txt | 3 - script/hassfest/mypy_config.py | 1 - tests/components/tesla/__init__.py | 1 - tests/components/tesla/test_config_flow.py | 270 ------------- 51 files changed, 2240 deletions(-) delete mode 100644 homeassistant/components/tesla/__init__.py delete mode 100644 homeassistant/components/tesla/binary_sensor.py delete mode 100644 homeassistant/components/tesla/climate.py delete mode 100644 homeassistant/components/tesla/config_flow.py delete mode 100644 homeassistant/components/tesla/const.py delete mode 100644 homeassistant/components/tesla/device_tracker.py delete mode 100644 homeassistant/components/tesla/lock.py delete mode 100644 homeassistant/components/tesla/manifest.json delete mode 100644 homeassistant/components/tesla/sensor.py delete mode 100644 homeassistant/components/tesla/strings.json delete mode 100644 homeassistant/components/tesla/switch.py delete mode 100644 homeassistant/components/tesla/translations/ca.json delete mode 100644 homeassistant/components/tesla/translations/cs.json delete mode 100644 homeassistant/components/tesla/translations/da.json delete mode 100644 homeassistant/components/tesla/translations/de.json delete mode 100644 homeassistant/components/tesla/translations/en.json delete mode 100644 homeassistant/components/tesla/translations/es-419.json delete mode 100644 homeassistant/components/tesla/translations/es.json delete mode 100644 homeassistant/components/tesla/translations/et.json delete mode 100644 homeassistant/components/tesla/translations/fi.json delete mode 100644 homeassistant/components/tesla/translations/fr.json delete mode 100644 homeassistant/components/tesla/translations/he.json delete mode 100644 homeassistant/components/tesla/translations/hu.json delete mode 100644 homeassistant/components/tesla/translations/id.json delete mode 100644 homeassistant/components/tesla/translations/it.json delete mode 100644 homeassistant/components/tesla/translations/ka.json delete mode 100644 homeassistant/components/tesla/translations/ko.json delete mode 100644 homeassistant/components/tesla/translations/lb.json delete mode 100644 homeassistant/components/tesla/translations/lv.json delete mode 100644 homeassistant/components/tesla/translations/nl.json delete mode 100644 homeassistant/components/tesla/translations/no.json delete mode 100644 homeassistant/components/tesla/translations/pl.json delete mode 100644 homeassistant/components/tesla/translations/pt-BR.json delete mode 100644 homeassistant/components/tesla/translations/pt.json delete mode 100644 homeassistant/components/tesla/translations/ru.json delete mode 100644 homeassistant/components/tesla/translations/sl.json delete mode 100644 homeassistant/components/tesla/translations/sv.json delete mode 100644 homeassistant/components/tesla/translations/tr.json delete mode 100644 homeassistant/components/tesla/translations/uk.json delete mode 100644 homeassistant/components/tesla/translations/zh-Hans.json delete mode 100644 homeassistant/components/tesla/translations/zh-Hant.json delete mode 100644 tests/components/tesla/__init__.py delete mode 100644 tests/components/tesla/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 684322359b5..28b62776d8e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1049,14 +1049,6 @@ omit = homeassistant/components/telnet/switch.py homeassistant/components/temper/sensor.py homeassistant/components/tensorflow/image_processing.py - homeassistant/components/tesla/__init__.py - homeassistant/components/tesla/binary_sensor.py - homeassistant/components/tesla/climate.py - homeassistant/components/tesla/const.py - homeassistant/components/tesla/device_tracker.py - homeassistant/components/tesla/lock.py - homeassistant/components/tesla/sensor.py - homeassistant/components/tesla/switch.py homeassistant/components/tfiac/climate.py homeassistant/components/thermoworks_smoke/sensor.py homeassistant/components/thethingsnetwork/* diff --git a/CODEOWNERS b/CODEOWNERS index 1a9e4bb62f7..35572933213 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -518,7 +518,6 @@ homeassistant/components/tasmota/* @emontnemery homeassistant/components/tautulli/* @ludeeus homeassistant/components/tellduslive/* @fredrike homeassistant/components/template/* @PhracturedBlue @tetienne @home-assistant/core -homeassistant/components/tesla/* @zabuldon @alandtse homeassistant/components/tfiac/* @fredrike @mellado homeassistant/components/thethingsnetwork/* @fabaff homeassistant/components/threshold/* @fabaff diff --git a/homeassistant/components/tesla/__init__.py b/homeassistant/components/tesla/__init__.py deleted file mode 100644 index 798e769dc47..00000000000 --- a/homeassistant/components/tesla/__init__.py +++ /dev/null @@ -1,357 +0,0 @@ -"""Support for Tesla cars.""" -import asyncio -from collections import defaultdict -from datetime import timedelta -import logging - -import async_timeout -import httpx -from teslajsonpy import Controller as TeslaAPI -from teslajsonpy.exceptions import IncompleteCredentials, TeslaException -import voluptuous as vol - -from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import ( - ATTR_BATTERY_CHARGING, - ATTR_BATTERY_LEVEL, - CONF_ACCESS_TOKEN, - CONF_PASSWORD, - CONF_SCAN_INTERVAL, - CONF_TOKEN, - CONF_USERNAME, - EVENT_HOMEASSISTANT_CLOSE, - HTTP_UNAUTHORIZED, -) -from homeassistant.core import callback -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.httpx_client import SERVER_SOFTWARE, USER_AGENT -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) -from homeassistant.util import slugify - -from .config_flow import CannotConnect, InvalidAuth, validate_input -from .const import ( - CONF_EXPIRATION, - CONF_WAKE_ON_START, - DATA_LISTENER, - DEFAULT_SCAN_INTERVAL, - DEFAULT_WAKE_ON_START, - DOMAIN, - ICONS, - MIN_SCAN_INTERVAL, - PLATFORMS, -) - -_LOGGER = logging.getLogger(__name__) - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional( - CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL - ): vol.All(cv.positive_int, vol.Clamp(min=MIN_SCAN_INTERVAL)), - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - - -@callback -def _async_save_tokens(hass, config_entry, access_token, refresh_token): - hass.config_entries.async_update_entry( - config_entry, - data={ - **config_entry.data, - CONF_ACCESS_TOKEN: access_token, - CONF_TOKEN: refresh_token, - }, - ) - - -@callback -def _async_configured_emails(hass): - """Return a set of configured Tesla emails.""" - return { - entry.data[CONF_USERNAME] - for entry in hass.config_entries.async_entries(DOMAIN) - if CONF_USERNAME in entry.data - } - - -async def async_setup(hass, base_config): - """Set up of Tesla component.""" - - def _update_entry(email, data=None, options=None): - data = data or {} - options = options or { - CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL, - CONF_WAKE_ON_START: DEFAULT_WAKE_ON_START, - } - for entry in hass.config_entries.async_entries(DOMAIN): - if email != entry.title: - continue - hass.config_entries.async_update_entry(entry, data=data, options=options) - - config = base_config.get(DOMAIN) - if not config: - return True - email = config[CONF_USERNAME] - password = config[CONF_PASSWORD] - scan_interval = config[CONF_SCAN_INTERVAL] - if email in _async_configured_emails(hass): - try: - info = await validate_input(hass, config) - except (CannotConnect, InvalidAuth): - return False - _update_entry( - email, - data={ - CONF_USERNAME: email, - CONF_PASSWORD: password, - CONF_ACCESS_TOKEN: info[CONF_ACCESS_TOKEN], - CONF_TOKEN: info[CONF_TOKEN], - CONF_EXPIRATION: info[CONF_EXPIRATION], - }, - options={CONF_SCAN_INTERVAL: scan_interval}, - ) - else: - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_USERNAME: email, CONF_PASSWORD: password}, - ) - ) - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][email] = {CONF_SCAN_INTERVAL: scan_interval} - return True - - -async def async_setup_entry(hass, config_entry): - """Set up Tesla as config entry.""" - hass.data.setdefault(DOMAIN, {}) - config = config_entry.data - # Because users can have multiple accounts, we always create a new session so they have separate cookies - async_client = httpx.AsyncClient(headers={USER_AGENT: SERVER_SOFTWARE}, timeout=60) - email = config_entry.title - if email in hass.data[DOMAIN] and CONF_SCAN_INTERVAL in hass.data[DOMAIN][email]: - scan_interval = hass.data[DOMAIN][email][CONF_SCAN_INTERVAL] - hass.config_entries.async_update_entry( - config_entry, options={CONF_SCAN_INTERVAL: scan_interval} - ) - hass.data[DOMAIN].pop(email) - try: - controller = TeslaAPI( - async_client, - email=config.get(CONF_USERNAME), - password=config.get(CONF_PASSWORD), - refresh_token=config[CONF_TOKEN], - access_token=config[CONF_ACCESS_TOKEN], - expiration=config.get(CONF_EXPIRATION, 0), - update_interval=config_entry.options.get( - CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL - ), - ) - result = await controller.connect( - wake_if_asleep=config_entry.options.get( - CONF_WAKE_ON_START, DEFAULT_WAKE_ON_START - ) - ) - refresh_token = result["refresh_token"] - access_token = result["access_token"] - except IncompleteCredentials as ex: - await async_client.aclose() - raise ConfigEntryAuthFailed from ex - except httpx.ConnectTimeout as ex: - await async_client.aclose() - raise ConfigEntryNotReady from ex - except TeslaException as ex: - await async_client.aclose() - if ex.code == HTTP_UNAUTHORIZED: - raise ConfigEntryAuthFailed from ex - if ex.message in [ - "VEHICLE_UNAVAILABLE", - "TOO_MANY_REQUESTS", - "SERVICE_MAINTENANCE", - "UPSTREAM_TIMEOUT", - ]: - raise ConfigEntryNotReady( - f"Temporarily unable to communicate with Tesla API: {ex.message}" - ) from ex - _LOGGER.error("Unable to communicate with Tesla API: %s", ex.message) - return False - - async def _async_close_client(*_): - await async_client.aclose() - - @callback - def _async_create_close_task(): - asyncio.create_task(_async_close_client()) - - config_entry.async_on_unload( - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_CLOSE, _async_close_client) - ) - config_entry.async_on_unload(_async_create_close_task) - - _async_save_tokens(hass, config_entry, access_token, refresh_token) - coordinator = TeslaDataUpdateCoordinator( - hass, config_entry=config_entry, controller=controller - ) - # Fetch initial data so we have data when entities subscribe - entry_data = hass.data[DOMAIN][config_entry.entry_id] = { - "coordinator": coordinator, - "devices": defaultdict(list), - DATA_LISTENER: [config_entry.add_update_listener(update_listener)], - } - _LOGGER.debug("Connected to the Tesla API") - - await coordinator.async_config_entry_first_refresh() - - all_devices = controller.get_homeassistant_components() - - if not all_devices: - return False - - for device in all_devices: - entry_data["devices"][device.hass_type].append(device) - - hass.config_entries.async_setup_platforms(config_entry, PLATFORMS) - - return True - - -async def async_unload_entry(hass, config_entry) -> bool: - """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms( - config_entry, PLATFORMS - ) - for listener in hass.data[DOMAIN][config_entry.entry_id][DATA_LISTENER]: - listener() - username = config_entry.title - if unload_ok: - hass.data[DOMAIN].pop(config_entry.entry_id) - _LOGGER.debug("Unloaded entry for %s", username) - return True - return False - - -async def update_listener(hass, config_entry): - """Update when config_entry options update.""" - controller = hass.data[DOMAIN][config_entry.entry_id]["coordinator"].controller - old_update_interval = controller.update_interval - controller.update_interval = config_entry.options.get(CONF_SCAN_INTERVAL) - if old_update_interval != controller.update_interval: - _LOGGER.debug( - "Changing scan_interval from %s to %s", - old_update_interval, - controller.update_interval, - ) - - -class TeslaDataUpdateCoordinator(DataUpdateCoordinator): - """Class to manage fetching Tesla data.""" - - def __init__(self, hass, *, config_entry, controller): - """Initialize global Tesla data updater.""" - self.controller = controller - self.config_entry = config_entry - - update_interval = timedelta(seconds=MIN_SCAN_INTERVAL) - - super().__init__( - hass, - _LOGGER, - name=DOMAIN, - update_interval=update_interval, - ) - - async def _async_update_data(self): - """Fetch data from API endpoint.""" - if self.controller.is_token_refreshed(): - result = self.controller.get_tokens() - refresh_token = result["refresh_token"] - access_token = result["access_token"] - _async_save_tokens( - self.hass, self.config_entry, access_token, refresh_token - ) - _LOGGER.debug("Saving new tokens in config_entry") - - try: - # Note: asyncio.TimeoutError and aiohttp.ClientError are already - # handled by the data update coordinator. - async with async_timeout.timeout(30): - return await self.controller.update() - except TeslaException as err: - raise UpdateFailed(f"Error communicating with API: {err}") from err - - -class TeslaDevice(CoordinatorEntity): - """Representation of a Tesla device.""" - - def __init__(self, tesla_device, coordinator): - """Initialise the Tesla device.""" - super().__init__(coordinator) - self.tesla_device = tesla_device - self._name = self.tesla_device.name - self._unique_id = slugify(self.tesla_device.uniq_name) - self._attributes = self.tesla_device.attrs.copy() - - @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return self._unique_id - - @property - def icon(self): - """Return the icon of the sensor.""" - if self.device_class: - return None - - return ICONS.get(self.tesla_device.type) - - @property - def extra_state_attributes(self): - """Return the state attributes of the device.""" - attr = self._attributes - if self.tesla_device.has_battery(): - attr[ATTR_BATTERY_LEVEL] = self.tesla_device.battery_level() - attr[ATTR_BATTERY_CHARGING] = self.tesla_device.battery_charging() - return attr - - @property - def device_info(self): - """Return the device_info of the device.""" - return { - "identifiers": {(DOMAIN, self.tesla_device.id())}, - "name": self.tesla_device.car_name(), - "manufacturer": "Tesla", - "model": self.tesla_device.car_type, - "sw_version": self.tesla_device.car_version, - } - - async def async_added_to_hass(self): - """Register state update callback.""" - self.async_on_remove(self.coordinator.async_add_listener(self.refresh)) - - @callback - def refresh(self) -> None: - """Refresh the state of the device. - - This assumes the coordinator has updated the controller. - """ - self.tesla_device.refresh() - self._attributes = self.tesla_device.attrs.copy() - self.async_write_ha_state() diff --git a/homeassistant/components/tesla/binary_sensor.py b/homeassistant/components/tesla/binary_sensor.py deleted file mode 100644 index 77315ef1e3c..00000000000 --- a/homeassistant/components/tesla/binary_sensor.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Support for Tesla binary sensor.""" - -from homeassistant.components.binary_sensor import DEVICE_CLASSES, BinarySensorEntity - -from . import DOMAIN as TESLA_DOMAIN, TeslaDevice - - -async def async_setup_entry(hass, config_entry, async_add_entities): - """Set up the Tesla binary_sensors by config_entry.""" - async_add_entities( - [ - TeslaBinarySensor( - device, - hass.data[TESLA_DOMAIN][config_entry.entry_id]["coordinator"], - ) - for device in hass.data[TESLA_DOMAIN][config_entry.entry_id]["devices"][ - "binary_sensor" - ] - ], - True, - ) - - -class TeslaBinarySensor(TeslaDevice, BinarySensorEntity): - """Implement an Tesla binary sensor for parking and charger.""" - - @property - def device_class(self): - """Return the class of this binary sensor.""" - return ( - self.tesla_device.sensor_type - if self.tesla_device.sensor_type in DEVICE_CLASSES - else None - ) - - @property - def is_on(self): - """Return the state of the binary sensor.""" - return self.tesla_device.get_value() diff --git a/homeassistant/components/tesla/climate.py b/homeassistant/components/tesla/climate.py deleted file mode 100644 index 81639bc3fe4..00000000000 --- a/homeassistant/components/tesla/climate.py +++ /dev/null @@ -1,120 +0,0 @@ -"""Support for Tesla HVAC system.""" -from __future__ import annotations - -import logging - -from teslajsonpy.exceptions import UnknownPresetMode - -from homeassistant.components.climate import ClimateEntity -from homeassistant.components.climate.const import ( - HVAC_MODE_HEAT_COOL, - HVAC_MODE_OFF, - SUPPORT_PRESET_MODE, - SUPPORT_TARGET_TEMPERATURE, -) -from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT - -from . import DOMAIN as TESLA_DOMAIN, TeslaDevice - -_LOGGER = logging.getLogger(__name__) - -SUPPORT_HVAC = [HVAC_MODE_HEAT_COOL, HVAC_MODE_OFF] - - -async def async_setup_entry(hass, config_entry, async_add_entities): - """Set up the Tesla binary_sensors by config_entry.""" - async_add_entities( - [ - TeslaThermostat( - device, - hass.data[TESLA_DOMAIN][config_entry.entry_id]["coordinator"], - ) - for device in hass.data[TESLA_DOMAIN][config_entry.entry_id]["devices"][ - "climate" - ] - ], - True, - ) - - -class TeslaThermostat(TeslaDevice, ClimateEntity): - """Representation of a Tesla climate.""" - - @property - def supported_features(self): - """Return the list of supported features.""" - return SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE - - @property - def hvac_mode(self): - """Return hvac operation ie. heat, cool mode. - - Need to be one of HVAC_MODE_*. - """ - if self.tesla_device.is_hvac_enabled(): - return HVAC_MODE_HEAT_COOL - return HVAC_MODE_OFF - - @property - def hvac_modes(self): - """Return the list of available hvac operation modes. - - Need to be a subset of HVAC_MODES. - """ - return SUPPORT_HVAC - - @property - def temperature_unit(self): - """Return the unit of measurement.""" - if self.tesla_device.measurement == "F": - return TEMP_FAHRENHEIT - return TEMP_CELSIUS - - @property - def current_temperature(self): - """Return the current temperature.""" - return self.tesla_device.get_current_temp() - - @property - def target_temperature(self): - """Return the temperature we try to reach.""" - return self.tesla_device.get_goal_temp() - - async def async_set_temperature(self, **kwargs): - """Set new target temperatures.""" - temperature = kwargs.get(ATTR_TEMPERATURE) - if temperature: - _LOGGER.debug("%s: Setting temperature to %s", self.name, temperature) - await self.tesla_device.set_temperature(temperature) - - async def async_set_hvac_mode(self, hvac_mode): - """Set new target hvac mode.""" - _LOGGER.debug("%s: Setting hvac mode to %s", self.name, hvac_mode) - if hvac_mode == HVAC_MODE_OFF: - await self.tesla_device.set_status(False) - elif hvac_mode == HVAC_MODE_HEAT_COOL: - await self.tesla_device.set_status(True) - - async def async_set_preset_mode(self, preset_mode: str) -> None: - """Set new preset mode.""" - _LOGGER.debug("%s: Setting preset_mode to: %s", self.name, preset_mode) - try: - await self.tesla_device.set_preset_mode(preset_mode) - except UnknownPresetMode as ex: - _LOGGER.error("%s", ex.message) - - @property - def preset_mode(self) -> str | None: - """Return the current preset mode, e.g., home, away, temp. - - Requires SUPPORT_PRESET_MODE. - """ - return self.tesla_device.preset_mode - - @property - def preset_modes(self) -> list[str] | None: - """Return a list of available preset modes. - - Requires SUPPORT_PRESET_MODE. - """ - return self.tesla_device.preset_modes diff --git a/homeassistant/components/tesla/config_flow.py b/homeassistant/components/tesla/config_flow.py deleted file mode 100644 index 5a88999a7e3..00000000000 --- a/homeassistant/components/tesla/config_flow.py +++ /dev/null @@ -1,191 +0,0 @@ -"""Tesla Config Flow.""" -import logging - -import httpx -from teslajsonpy import Controller as TeslaAPI, TeslaException -from teslajsonpy.exceptions import IncompleteCredentials -import voluptuous as vol - -from homeassistant import config_entries, core, exceptions -from homeassistant.const import ( - CONF_ACCESS_TOKEN, - CONF_PASSWORD, - CONF_SCAN_INTERVAL, - CONF_TOKEN, - CONF_USERNAME, - HTTP_UNAUTHORIZED, -) -from homeassistant.core import callback -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.httpx_client import SERVER_SOFTWARE, USER_AGENT - -from .const import ( - CONF_EXPIRATION, - CONF_MFA, - CONF_WAKE_ON_START, - DEFAULT_SCAN_INTERVAL, - DEFAULT_WAKE_ON_START, - DOMAIN, - MIN_SCAN_INTERVAL, -) - -_LOGGER = logging.getLogger(__name__) - - -class TeslaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): - """Handle a config flow for Tesla.""" - - VERSION = 1 - - def __init__(self) -> None: - """Initialize the tesla flow.""" - self.username = None - self.reauth = False - - async def async_step_import(self, import_config): - """Import a config entry from configuration.yaml.""" - return await self.async_step_user(import_config) - - async def async_step_user(self, user_input=None): - """Handle the start of the config flow.""" - errors = {} - - if user_input is not None: - existing_entry = self._async_entry_for_username(user_input[CONF_USERNAME]) - if existing_entry and not self.reauth: - return self.async_abort(reason="already_configured") - - try: - info = await validate_input(self.hass, user_input) - except CannotConnect: - errors["base"] = "cannot_connect" - except InvalidAuth: - errors["base"] = "invalid_auth" - - if not errors: - if existing_entry: - self.hass.config_entries.async_update_entry( - existing_entry, data=info - ) - await self.hass.config_entries.async_reload(existing_entry.entry_id) - return self.async_abort(reason="reauth_successful") - - return self.async_create_entry( - title=user_input[CONF_USERNAME], data=info - ) - - return self.async_show_form( - step_id="user", - data_schema=self._async_schema(), - errors=errors, - description_placeholders={}, - ) - - async def async_step_reauth(self, data): - """Handle configuration by re-auth.""" - self.username = data[CONF_USERNAME] - self.reauth = True - return await self.async_step_user() - - @staticmethod - @callback - def async_get_options_flow(config_entry): - """Get the options flow for this handler.""" - return OptionsFlowHandler(config_entry) - - @callback - def _async_schema(self): - """Fetch schema with defaults.""" - return vol.Schema( - { - vol.Required(CONF_USERNAME, default=self.username): str, - vol.Required(CONF_PASSWORD): str, - vol.Optional(CONF_MFA): str, - } - ) - - @callback - def _async_entry_for_username(self, username): - """Find an existing entry for a username.""" - for entry in self._async_current_entries(): - if entry.data.get(CONF_USERNAME) == username: - return entry - return None - - -class OptionsFlowHandler(config_entries.OptionsFlow): - """Handle a option flow for Tesla.""" - - def __init__(self, config_entry: config_entries.ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - - async def async_step_init(self, user_input=None): - """Handle options flow.""" - if user_input is not None: - return self.async_create_entry(title="", data=user_input) - - data_schema = vol.Schema( - { - vol.Optional( - CONF_SCAN_INTERVAL, - default=self.config_entry.options.get( - CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL - ), - ): vol.All(cv.positive_int, vol.Clamp(min=MIN_SCAN_INTERVAL)), - vol.Optional( - CONF_WAKE_ON_START, - default=self.config_entry.options.get( - CONF_WAKE_ON_START, DEFAULT_WAKE_ON_START - ), - ): bool, - } - ) - return self.async_show_form(step_id="init", data_schema=data_schema) - - -async def validate_input(hass: core.HomeAssistant, data): - """Validate the user input allows us to connect. - - Data has the keys from DATA_SCHEMA with values provided by the user. - """ - - config = {} - async_client = httpx.AsyncClient(headers={USER_AGENT: SERVER_SOFTWARE}, timeout=60) - - try: - controller = TeslaAPI( - async_client, - email=data[CONF_USERNAME], - password=data[CONF_PASSWORD], - update_interval=DEFAULT_SCAN_INTERVAL, - ) - result = await controller.connect( - test_login=True, mfa_code=(data[CONF_MFA] if CONF_MFA in data else "") - ) - config[CONF_TOKEN] = result["refresh_token"] - config[CONF_ACCESS_TOKEN] = result["access_token"] - config[CONF_EXPIRATION] = result[CONF_EXPIRATION] - config[CONF_USERNAME] = data[CONF_USERNAME] - config[CONF_PASSWORD] = data[CONF_PASSWORD] - except IncompleteCredentials as ex: - _LOGGER.error("Authentication error: %s %s", ex.message, ex) - raise InvalidAuth() from ex - except TeslaException as ex: - if ex.code == HTTP_UNAUTHORIZED: - _LOGGER.error("Invalid credentials: %s", ex) - raise InvalidAuth() from ex - _LOGGER.error("Unable to communicate with Tesla API: %s", ex.message) - raise CannotConnect() from ex - finally: - await async_client.aclose() - _LOGGER.debug("Credentials successfully connected to the Tesla API") - return config - - -class CannotConnect(exceptions.HomeAssistantError): - """Error to indicate we cannot connect.""" - - -class InvalidAuth(exceptions.HomeAssistantError): - """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/tesla/const.py b/homeassistant/components/tesla/const.py deleted file mode 100644 index c288b3c1cda..00000000000 --- a/homeassistant/components/tesla/const.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Const file for Tesla cars.""" -CONF_EXPIRATION = "expiration" -CONF_WAKE_ON_START = "enable_wake_on_start" -CONF_MFA = "mfa" -DOMAIN = "tesla" -DATA_LISTENER = "listener" -DEFAULT_SCAN_INTERVAL = 660 -DEFAULT_WAKE_ON_START = False -MIN_SCAN_INTERVAL = 60 - -PLATFORMS = [ - "sensor", - "lock", - "climate", - "binary_sensor", - "device_tracker", - "switch", -] - -ICONS = { - "battery sensor": "mdi:battery", - "range sensor": "mdi:gauge", - "mileage sensor": "mdi:counter", - "parking brake sensor": "mdi:car-brake-parking", - "charger sensor": "mdi:ev-station", - "charger switch": "mdi:battery-charging", - "update switch": "mdi:update", - "maxrange switch": "mdi:gauge-full", - "temperature sensor": "mdi:thermometer", - "location tracker": "mdi:crosshairs-gps", - "charging rate sensor": "mdi:speedometer", - "sentry mode switch": "mdi:shield-car", -} diff --git a/homeassistant/components/tesla/device_tracker.py b/homeassistant/components/tesla/device_tracker.py deleted file mode 100644 index 6813b3769e7..00000000000 --- a/homeassistant/components/tesla/device_tracker.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Support for tracking Tesla cars.""" -from __future__ import annotations - -from homeassistant.components.device_tracker import SOURCE_TYPE_GPS -from homeassistant.components.device_tracker.config_entry import TrackerEntity - -from . import DOMAIN as TESLA_DOMAIN, TeslaDevice - - -async def async_setup_entry(hass, config_entry, async_add_entities): - """Set up the Tesla binary_sensors by config_entry.""" - entities = [ - TeslaDeviceEntity( - device, - hass.data[TESLA_DOMAIN][config_entry.entry_id]["coordinator"], - ) - for device in hass.data[TESLA_DOMAIN][config_entry.entry_id]["devices"][ - "devices_tracker" - ] - ] - async_add_entities(entities, True) - - -class TeslaDeviceEntity(TeslaDevice, TrackerEntity): - """A class representing a Tesla device.""" - - @property - def latitude(self) -> float | None: - """Return latitude value of the device.""" - location = self.tesla_device.get_location() - return self.tesla_device.get_location().get("latitude") if location else None - - @property - def longitude(self) -> float | None: - """Return longitude value of the device.""" - location = self.tesla_device.get_location() - return self.tesla_device.get_location().get("longitude") if location else None - - @property - def source_type(self): - """Return the source type, eg gps or router, of the device.""" - return SOURCE_TYPE_GPS - - @property - def extra_state_attributes(self): - """Return the state attributes of the device.""" - attr = super().extra_state_attributes.copy() - location = self.tesla_device.get_location() - if location: - attr.update( - { - "trackr_id": self.unique_id, - "heading": location["heading"], - "speed": location["speed"], - } - ) - return attr diff --git a/homeassistant/components/tesla/lock.py b/homeassistant/components/tesla/lock.py deleted file mode 100644 index 7a74d2ececb..00000000000 --- a/homeassistant/components/tesla/lock.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Support for Tesla door locks.""" -import logging - -from homeassistant.components.lock import LockEntity - -from . import DOMAIN as TESLA_DOMAIN, TeslaDevice - -_LOGGER = logging.getLogger(__name__) - - -async def async_setup_entry(hass, config_entry, async_add_entities): - """Set up the Tesla binary_sensors by config_entry.""" - entities = [ - TeslaLock( - device, - hass.data[TESLA_DOMAIN][config_entry.entry_id]["coordinator"], - ) - for device in hass.data[TESLA_DOMAIN][config_entry.entry_id]["devices"]["lock"] - ] - async_add_entities(entities, True) - - -class TeslaLock(TeslaDevice, LockEntity): - """Representation of a Tesla door lock.""" - - async def async_lock(self, **kwargs): - """Send the lock command.""" - _LOGGER.debug("Locking doors for: %s", self.name) - await self.tesla_device.lock() - - async def async_unlock(self, **kwargs): - """Send the unlock command.""" - _LOGGER.debug("Unlocking doors for: %s", self.name) - await self.tesla_device.unlock() - - @property - def is_locked(self): - """Get whether the lock is in locked state.""" - if self.tesla_device.is_locked() is None: - return None - return self.tesla_device.is_locked() diff --git a/homeassistant/components/tesla/manifest.json b/homeassistant/components/tesla/manifest.json deleted file mode 100644 index 8604436d5a4..00000000000 --- a/homeassistant/components/tesla/manifest.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "domain": "tesla", - "name": "Tesla", - "config_flow": true, - "documentation": "https://www.home-assistant.io/integrations/tesla", - "requirements": ["teslajsonpy==0.18.3"], - "codeowners": ["@zabuldon", "@alandtse"], - "dhcp": [ - { - "hostname": "tesla_*", - "macaddress": "4CFCAA*" - }, - { - "hostname": "tesla_*", - "macaddress": "044EAF*" - }, - { - "hostname": "tesla_*", - "macaddress": "98ED5C*" - } - ], - "iot_class": "cloud_polling" -} diff --git a/homeassistant/components/tesla/sensor.py b/homeassistant/components/tesla/sensor.py deleted file mode 100644 index 60e3e19047d..00000000000 --- a/homeassistant/components/tesla/sensor.py +++ /dev/null @@ -1,97 +0,0 @@ -"""Support for the Tesla sensors.""" -from __future__ import annotations - -from homeassistant.components.sensor import DEVICE_CLASSES, SensorEntity -from homeassistant.const import ( - LENGTH_KILOMETERS, - LENGTH_MILES, - TEMP_CELSIUS, - TEMP_FAHRENHEIT, -) -from homeassistant.util.distance import convert - -from . import DOMAIN as TESLA_DOMAIN, TeslaDevice - - -async def async_setup_entry(hass, config_entry, async_add_entities): - """Set up the Tesla binary_sensors by config_entry.""" - coordinator = hass.data[TESLA_DOMAIN][config_entry.entry_id]["coordinator"] - entities = [] - for device in hass.data[TESLA_DOMAIN][config_entry.entry_id]["devices"]["sensor"]: - if device.type == "temperature sensor": - entities.append(TeslaSensor(device, coordinator, "inside")) - entities.append(TeslaSensor(device, coordinator, "outside")) - else: - entities.append(TeslaSensor(device, coordinator)) - async_add_entities(entities, True) - - -class TeslaSensor(TeslaDevice, SensorEntity): - """Representation of Tesla sensors.""" - - def __init__(self, tesla_device, coordinator, sensor_type=None): - """Initialize of the sensor.""" - super().__init__(tesla_device, coordinator) - self.type = sensor_type - if self.type: - self._name = f"{super().name} ({self.type})" - self._unique_id = f"{super().unique_id}_{self.type}" - - @property - def native_value(self) -> float | None: - """Return the state of the sensor.""" - if self.tesla_device.type == "temperature sensor": - if self.type == "outside": - return self.tesla_device.get_outside_temp() - return self.tesla_device.get_inside_temp() - if self.tesla_device.type in ("range sensor", "mileage sensor"): - units = self.tesla_device.measurement - if units == "LENGTH_MILES": - return self.tesla_device.get_value() - return round( - convert(self.tesla_device.get_value(), LENGTH_MILES, LENGTH_KILOMETERS), - 2, - ) - if self.tesla_device.type == "charging rate sensor": - return self.tesla_device.charging_rate - return self.tesla_device.get_value() - - @property - def native_unit_of_measurement(self) -> str | None: - """Return the unit_of_measurement of the device.""" - units = self.tesla_device.measurement - if units == "F": - return TEMP_FAHRENHEIT - if units == "C": - return TEMP_CELSIUS - if units == "LENGTH_MILES": - return LENGTH_MILES - if units == "LENGTH_KILOMETERS": - return LENGTH_KILOMETERS - return units - - @property - def device_class(self) -> str | None: - """Return the device_class of the device.""" - return ( - self.tesla_device.device_class - if self.tesla_device.device_class in DEVICE_CLASSES - else None - ) - - @property - def extra_state_attributes(self): - """Return the state attributes of the device.""" - attr = self._attributes.copy() - if self.tesla_device.type == "charging rate sensor": - attr.update( - { - "time_left": self.tesla_device.time_left, - "added_range": self.tesla_device.added_range, - "charge_energy_added": self.tesla_device.charge_energy_added, - "charge_current_request": self.tesla_device.charge_current_request, - "charger_actual_current": self.tesla_device.charger_actual_current, - "charger_voltage": self.tesla_device.charger_voltage, - } - ) - return attr diff --git a/homeassistant/components/tesla/strings.json b/homeassistant/components/tesla/strings.json deleted file mode 100644 index 755e5d5a7cf..00000000000 --- a/homeassistant/components/tesla/strings.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "config": { - "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" - }, - "abort": { - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" - }, - "step": { - "user": { - "data": { - "username": "[%key:common::config_flow::data::email%]", - "password": "[%key:common::config_flow::data::password%]", - "mfa": "MFA Code (optional)" - }, - "description": "Please enter your information.", - "title": "Tesla - Configuration" - } - } - }, - "options": { - "step": { - "init": { - "data": { - "scan_interval": "Seconds between scans", - "enable_wake_on_start": "Force cars awake on startup" - } - } - } - } -} diff --git a/homeassistant/components/tesla/switch.py b/homeassistant/components/tesla/switch.py deleted file mode 100644 index efcb955ebf8..00000000000 --- a/homeassistant/components/tesla/switch.py +++ /dev/null @@ -1,130 +0,0 @@ -"""Support for Tesla charger switches.""" -import logging - -from homeassistant.components.switch import SwitchEntity - -from . import DOMAIN as TESLA_DOMAIN, TeslaDevice - -_LOGGER = logging.getLogger(__name__) - - -async def async_setup_entry(hass, config_entry, async_add_entities): - """Set up the Tesla binary_sensors by config_entry.""" - coordinator = hass.data[TESLA_DOMAIN][config_entry.entry_id]["coordinator"] - entities = [] - for device in hass.data[TESLA_DOMAIN][config_entry.entry_id]["devices"]["switch"]: - if device.type == "charger switch": - entities.append(ChargerSwitch(device, coordinator)) - entities.append(UpdateSwitch(device, coordinator)) - elif device.type == "maxrange switch": - entities.append(RangeSwitch(device, coordinator)) - elif device.type == "sentry mode switch": - entities.append(SentryModeSwitch(device, coordinator)) - async_add_entities(entities, True) - - -class ChargerSwitch(TeslaDevice, SwitchEntity): - """Representation of a Tesla charger switch.""" - - async def async_turn_on(self, **kwargs): - """Send the on command.""" - _LOGGER.debug("Enable charging: %s", self.name) - await self.tesla_device.start_charge() - self.async_write_ha_state() - - async def async_turn_off(self, **kwargs): - """Send the off command.""" - _LOGGER.debug("Disable charging for: %s", self.name) - await self.tesla_device.stop_charge() - self.async_write_ha_state() - - @property - def is_on(self): - """Get whether the switch is in on state.""" - if self.tesla_device.is_charging() is None: - return None - return self.tesla_device.is_charging() - - -class RangeSwitch(TeslaDevice, SwitchEntity): - """Representation of a Tesla max range charging switch.""" - - async def async_turn_on(self, **kwargs): - """Send the on command.""" - _LOGGER.debug("Enable max range charging: %s", self.name) - await self.tesla_device.set_max() - self.async_write_ha_state() - - async def async_turn_off(self, **kwargs): - """Send the off command.""" - _LOGGER.debug("Disable max range charging: %s", self.name) - await self.tesla_device.set_standard() - self.async_write_ha_state() - - @property - def is_on(self): - """Get whether the switch is in on state.""" - if self.tesla_device.is_maxrange() is None: - return None - return bool(self.tesla_device.is_maxrange()) - - -class UpdateSwitch(TeslaDevice, SwitchEntity): - """Representation of a Tesla update switch.""" - - def __init__(self, tesla_device, coordinator): - """Initialise the switch.""" - super().__init__(tesla_device, coordinator) - self.controller = coordinator.controller - - @property - def name(self): - """Return the name of the device.""" - return super().name.replace("charger", "update") - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return super().unique_id.replace("charger", "update") - - async def async_turn_on(self, **kwargs): - """Send the on command.""" - _LOGGER.debug("Enable updates: %s %s", self.name, self.tesla_device.id()) - self.controller.set_updates(self.tesla_device.id(), True) - self.async_write_ha_state() - - async def async_turn_off(self, **kwargs): - """Send the off command.""" - _LOGGER.debug("Disable updates: %s %s", self.name, self.tesla_device.id()) - self.controller.set_updates(self.tesla_device.id(), False) - self.async_write_ha_state() - - @property - def is_on(self): - """Get whether the switch is in on state.""" - if self.controller.get_updates(self.tesla_device.id()) is None: - return None - return bool(self.controller.get_updates(self.tesla_device.id())) - - -class SentryModeSwitch(TeslaDevice, SwitchEntity): - """Representation of a Tesla sentry mode switch.""" - - async def async_turn_on(self, **kwargs): - """Send the on command.""" - _LOGGER.debug("Enable sentry mode: %s", self.name) - await self.tesla_device.enable_sentry_mode() - self.async_write_ha_state() - - async def async_turn_off(self, **kwargs): - """Send the off command.""" - _LOGGER.debug("Disable sentry mode: %s", self.name) - await self.tesla_device.disable_sentry_mode() - self.async_write_ha_state() - - @property - def is_on(self): - """Get whether the switch is in on state.""" - if self.tesla_device.is_on() is None: - return None - return self.tesla_device.is_on() diff --git a/homeassistant/components/tesla/translations/ca.json b/homeassistant/components/tesla/translations/ca.json deleted file mode 100644 index f5c0117f6a0..00000000000 --- a/homeassistant/components/tesla/translations/ca.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "El compte ja ha estat configurat", - "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" - }, - "error": { - "already_configured": "El compte ja ha estat configurat", - "cannot_connect": "Ha fallat la connexi\u00f3", - "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida" - }, - "step": { - "user": { - "data": { - "mfa": "Codi MFA (opcional)", - "password": "Contrasenya", - "username": "Correu electr\u00f2nic" - }, - "description": "Introdueix la teva informaci\u00f3.", - "title": "Configuraci\u00f3 de Tesla" - } - } - }, - "options": { - "step": { - "init": { - "data": { - "enable_wake_on_start": "For\u00e7a el despertar del cotxe en la posada en marxa", - "scan_interval": "Segons entre escanejos" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/cs.json b/homeassistant/components/tesla/translations/cs.json deleted file mode 100644 index 9c117223d40..00000000000 --- a/homeassistant/components/tesla/translations/cs.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "\u00da\u010det je ji\u017e nastaven", - "reauth_successful": "Op\u011btovn\u00e9 ov\u011b\u0159en\u00ed bylo \u00fasp\u011b\u0161n\u00e9" - }, - "error": { - "already_configured": "\u00da\u010det je ji\u017e nastaven", - "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", - "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed" - }, - "step": { - "user": { - "data": { - "password": "Heslo", - "username": "E-mail" - }, - "description": "Zadejte sv\u00e9 \u00fadaje.", - "title": "Tesla - Nastaven\u00ed" - } - } - }, - "options": { - "step": { - "init": { - "data": { - "scan_interval": "Po\u010det sekund mezi sledov\u00e1n\u00edm" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/da.json b/homeassistant/components/tesla/translations/da.json deleted file mode 100644 index c6cb8b5b208..00000000000 --- a/homeassistant/components/tesla/translations/da.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "step": { - "user": { - "data": { - "password": "Adgangskode", - "username": "Email-adresse" - }, - "description": "Indtast dine oplysninger.", - "title": "Tesla - Konfiguration" - } - } - }, - "options": { - "step": { - "init": { - "data": { - "scan_interval": "Sekunder mellem scanninger" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/de.json b/homeassistant/components/tesla/translations/de.json deleted file mode 100644 index 09934369f6b..00000000000 --- a/homeassistant/components/tesla/translations/de.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Konto wurde bereits konfiguriert", - "reauth_successful": "Die erneute Authentifizierung war erfolgreich" - }, - "error": { - "already_configured": "Konto wurde bereits konfiguriert", - "cannot_connect": "Verbindung fehlgeschlagen", - "invalid_auth": "Ung\u00fcltige Authentifizierung" - }, - "step": { - "user": { - "data": { - "mfa": "MFA-Code (optional)", - "password": "Passwort", - "username": "E-Mail" - }, - "description": "Bitte gib deine Daten ein.", - "title": "Tesla - Konfiguration" - } - } - }, - "options": { - "step": { - "init": { - "data": { - "enable_wake_on_start": "Aufwachen des Autos beim Start erzwingen", - "scan_interval": "Sekunden zwischen den Scans" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/en.json b/homeassistant/components/tesla/translations/en.json deleted file mode 100644 index 80cbee7e122..00000000000 --- a/homeassistant/components/tesla/translations/en.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Account is already configured", - "reauth_successful": "Re-authentication was successful" - }, - "error": { - "already_configured": "Account is already configured", - "cannot_connect": "Failed to connect", - "invalid_auth": "Invalid authentication" - }, - "step": { - "user": { - "data": { - "mfa": "MFA Code (optional)", - "password": "Password", - "username": "Email" - }, - "description": "Please enter your information.", - "title": "Tesla - Configuration" - } - } - }, - "options": { - "step": { - "init": { - "data": { - "enable_wake_on_start": "Force cars awake on startup", - "scan_interval": "Seconds between scans" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/es-419.json b/homeassistant/components/tesla/translations/es-419.json deleted file mode 100644 index 20fe7b3c436..00000000000 --- a/homeassistant/components/tesla/translations/es-419.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "config": { - "step": { - "user": { - "data": { - "password": "Contrase\u00f1a", - "username": "Direcci\u00f3n de correo electr\u00f3nico" - }, - "description": "Por favor ingrese su informaci\u00f3n.", - "title": "Tesla - Configuraci\u00f3n" - } - } - }, - "options": { - "step": { - "init": { - "data": { - "enable_wake_on_start": "Forzar a autom\u00f3viles despertar al inicio", - "scan_interval": "Segundos entre escaneos" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/es.json b/homeassistant/components/tesla/translations/es.json deleted file mode 100644 index 8211e806741..00000000000 --- a/homeassistant/components/tesla/translations/es.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "La cuenta ya ha sido configurada", - "reauth_successful": "La reautenticaci\u00f3n se realiz\u00f3 correctamente" - }, - "error": { - "already_configured": "La cuenta ya ha sido configurada", - "cannot_connect": "No se pudo conectar", - "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida" - }, - "step": { - "user": { - "data": { - "mfa": "C\u00f3digo MFA (opcional)", - "password": "Contrase\u00f1a", - "username": "Correo electr\u00f3nico" - }, - "description": "Por favor, introduzca su informaci\u00f3n.", - "title": "Tesla - Configuraci\u00f3n" - } - } - }, - "options": { - "step": { - "init": { - "data": { - "enable_wake_on_start": "Forzar autom\u00f3viles despiertos al inicio", - "scan_interval": "Segundos entre escaneos" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/et.json b/homeassistant/components/tesla/translations/et.json deleted file mode 100644 index ab36a4e503d..00000000000 --- a/homeassistant/components/tesla/translations/et.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Kasutaja on juba seadistatud", - "reauth_successful": "Taastuvastamine \u00f5nnestus" - }, - "error": { - "already_configured": "Konto on juba h\u00e4\u00e4lestatud", - "cannot_connect": "\u00dchendamine nurjus", - "invalid_auth": "Tuvastamise viga" - }, - "step": { - "user": { - "data": { - "mfa": "MFA kood (valikuline)", - "password": "Salas\u00f5na", - "username": "E-post" - }, - "description": "Palun sisesta oma andmed.", - "title": "Tesla - seadistamine" - } - } - }, - "options": { - "step": { - "init": { - "data": { - "enable_wake_on_start": "Sunni autod k\u00e4ivitamisel \u00e4rkama (?)", - "scan_interval": "P\u00e4ringute vahe sekundites" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/fi.json b/homeassistant/components/tesla/translations/fi.json deleted file mode 100644 index b7ed0a4bd5c..00000000000 --- a/homeassistant/components/tesla/translations/fi.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "config": { - "step": { - "user": { - "data": { - "mfa": "MFA-koodi (valinnainen)" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/fr.json b/homeassistant/components/tesla/translations/fr.json deleted file mode 100644 index 174b687f26f..00000000000 --- a/homeassistant/components/tesla/translations/fr.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9", - "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" - }, - "error": { - "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9", - "cannot_connect": "\u00c9chec de connexion", - "invalid_auth": "Authentification invalide" - }, - "step": { - "user": { - "data": { - "mfa": "Code MFA (facultatif)", - "password": "Mot de passe", - "username": "Email" - }, - "description": "Veuillez saisir vos informations.", - "title": "Tesla - Configuration" - } - } - }, - "options": { - "step": { - "init": { - "data": { - "enable_wake_on_start": "Forcer les voitures \u00e0 se r\u00e9veiller au d\u00e9marrage", - "scan_interval": "Secondes entre les scans" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/he.json b/homeassistant/components/tesla/translations/he.json deleted file mode 100644 index 9f3eeb2fc21..00000000000 --- a/homeassistant/components/tesla/translations/he.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", - "reauth_successful": "\u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7" - }, - "error": { - "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", - "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", - "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9" - }, - "step": { - "user": { - "data": { - "password": "\u05e1\u05d9\u05e1\u05de\u05d4", - "username": "\u05d3\u05d5\u05d0\"\u05dc" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/hu.json b/homeassistant/components/tesla/translations/hu.json deleted file mode 100644 index 75a93566df5..00000000000 --- a/homeassistant/components/tesla/translations/hu.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van", - "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt" - }, - "error": { - "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van", - "cannot_connect": "Sikertelen csatlakoz\u00e1s", - "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s" - }, - "step": { - "user": { - "data": { - "mfa": "MFA k\u00f3d (opcion\u00e1lis)", - "password": "Jelsz\u00f3", - "username": "E-mail" - }, - "description": "K\u00e9rlek, add meg az adataidat.", - "title": "Tesla - Konfigur\u00e1ci\u00f3" - } - } - }, - "options": { - "step": { - "init": { - "data": { - "enable_wake_on_start": "Az aut\u00f3k \u00e9bred\u00e9sre k\u00e9nyszer\u00edt\u00e9se ind\u00edt\u00e1skor", - "scan_interval": "Szkennel\u00e9sek k\u00f6z\u00f6tti m\u00e1sodpercek" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/id.json b/homeassistant/components/tesla/translations/id.json deleted file mode 100644 index 681504d0d42..00000000000 --- a/homeassistant/components/tesla/translations/id.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Akun sudah dikonfigurasi", - "reauth_successful": "Autentikasi ulang berhasil" - }, - "error": { - "already_configured": "Akun sudah dikonfigurasi", - "cannot_connect": "Gagal terhubung", - "invalid_auth": "Autentikasi tidak valid" - }, - "step": { - "user": { - "data": { - "password": "Kata Sandi", - "username": "Email" - }, - "description": "Masukkan informasi Anda.", - "title": "Tesla - Konfigurasi" - } - } - }, - "options": { - "step": { - "init": { - "data": { - "enable_wake_on_start": "Paksa mobil bangun saat dinyalakan", - "scan_interval": "Interval pemindaian dalam detik" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/it.json b/homeassistant/components/tesla/translations/it.json deleted file mode 100644 index 05a663df149..00000000000 --- a/homeassistant/components/tesla/translations/it.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "L'account \u00e8 gi\u00e0 configurato", - "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente" - }, - "error": { - "already_configured": "L'account \u00e8 gi\u00e0 configurato", - "cannot_connect": "Impossibile connettersi", - "invalid_auth": "Autenticazione non valida" - }, - "step": { - "user": { - "data": { - "mfa": "Codice autenticazione a pi\u00f9 fattori MFA (facoltativo)", - "password": "Password", - "username": "E-mail" - }, - "description": "Si prega di inserire le tue informazioni.", - "title": "Tesla - Configurazione" - } - } - }, - "options": { - "step": { - "init": { - "data": { - "enable_wake_on_start": "Forza il risveglio delle auto all'avvio", - "scan_interval": "Secondi tra le scansioni" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/ka.json b/homeassistant/components/tesla/translations/ka.json deleted file mode 100644 index 249c8f6cffb..00000000000 --- a/homeassistant/components/tesla/translations/ka.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "config": { - "error": { - "already_configured": "\u10d0\u10dc\u10d2\u10d0\u10e0\u10d8\u10e8\u10d8 \u10e3\u10d9\u10d5\u10d4 \u10d9\u10dd\u10dc\u10e4\u10d8\u10d2\u10e3\u10e0\u10d8\u10e0\u10d4\u10d1\u10e3\u10da\u10d8\u10d0" - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/ko.json b/homeassistant/components/tesla/translations/ko.json deleted file mode 100644 index 285326f39de..00000000000 --- a/homeassistant/components/tesla/translations/ko.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", - "reauth_successful": "\uc7ac\uc778\uc99d\uc5d0 \uc131\uacf5\ud588\uc2b5\ub2c8\ub2e4" - }, - "error": { - "already_configured": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", - "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", - "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" - }, - "step": { - "user": { - "data": { - "password": "\ube44\ubc00\ubc88\ud638", - "username": "\uc774\uba54\uc77c" - }, - "description": "\uc0ac\uc6a9\uc790 \uc815\ubcf4\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694", - "title": "Tesla - \uad6c\uc131" - } - } - }, - "options": { - "step": { - "init": { - "data": { - "enable_wake_on_start": "\uc2dc\ub3d9 \uc2dc \ucc28\ub7c9 \uae68\uc6b0\uae30", - "scan_interval": "\uc2a4\uce94 \uac04\uaca9 (\ucd08)" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/lb.json b/homeassistant/components/tesla/translations/lb.json deleted file mode 100644 index 32353c99b3e..00000000000 --- a/homeassistant/components/tesla/translations/lb.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "config": { - "error": { - "already_configured": "Kont ass scho konfigur\u00e9iert", - "cannot_connect": "Feeler beim verbannen", - "invalid_auth": "Ong\u00eblteg Authentifikatioun" - }, - "step": { - "user": { - "data": { - "password": "Passwuert", - "username": "E-Mail" - }, - "description": "F\u00ebllt \u00e4r Informatiounen aus.", - "title": "Tesla - Konfiguratioun" - } - } - }, - "options": { - "step": { - "init": { - "data": { - "enable_wake_on_start": "Forc\u00e9ier d'Erw\u00e4chen vun den Autoen beim starten", - "scan_interval": "Sekonnen t\u00ebscht Scannen" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/lv.json b/homeassistant/components/tesla/translations/lv.json deleted file mode 100644 index eab98211e14..00000000000 --- a/homeassistant/components/tesla/translations/lv.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "config": { - "step": { - "user": { - "data": { - "password": "Parole", - "username": "E-pasta adrese" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/nl.json b/homeassistant/components/tesla/translations/nl.json deleted file mode 100644 index 689766cd906..00000000000 --- a/homeassistant/components/tesla/translations/nl.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Account is al geconfigureerd", - "reauth_successful": "Herauthenticatie was succesvol" - }, - "error": { - "already_configured": "Account is al geconfigureerd", - "cannot_connect": "Kan geen verbinding maken", - "invalid_auth": "Ongeldige authenticatie" - }, - "step": { - "user": { - "data": { - "mfa": "MFA Code (optioneel)", - "password": "Wachtwoord", - "username": "E-mail" - }, - "description": "Vul alstublieft uw gegevens in.", - "title": "Tesla - Configuratie" - } - } - }, - "options": { - "step": { - "init": { - "data": { - "enable_wake_on_start": "Forceer auto's wakker bij het opstarten", - "scan_interval": "Seconden tussen scans" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/no.json b/homeassistant/components/tesla/translations/no.json deleted file mode 100644 index 11e49486107..00000000000 --- a/homeassistant/components/tesla/translations/no.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Kontoen er allerede konfigurert", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" - }, - "error": { - "already_configured": "Kontoen er allerede konfigurert", - "cannot_connect": "Tilkobling mislyktes", - "invalid_auth": "Ugyldig godkjenning" - }, - "step": { - "user": { - "data": { - "mfa": "MFA -kode (valgfritt)", - "password": "Passord", - "username": "E-post" - }, - "description": "Vennligst fyll inn din informasjonen.", - "title": "Tesla - Konfigurasjon" - } - } - }, - "options": { - "step": { - "init": { - "data": { - "enable_wake_on_start": "Tving biler til \u00e5 v\u00e5kne ved oppstart", - "scan_interval": "Sekunder mellom skanninger" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/pl.json b/homeassistant/components/tesla/translations/pl.json deleted file mode 100644 index 266a0e82dbe..00000000000 --- a/homeassistant/components/tesla/translations/pl.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Konto jest ju\u017c skonfigurowane", - "reauth_successful": "Ponowne uwierzytelnienie powiod\u0142o si\u0119" - }, - "error": { - "already_configured": "Konto jest ju\u017c skonfigurowane", - "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", - "invalid_auth": "Niepoprawne uwierzytelnienie" - }, - "step": { - "user": { - "data": { - "mfa": "Kod uwierzytelniania wielosk\u0142adnikowego (opcjonalnie)", - "password": "Has\u0142o", - "username": "Adres e-mail" - }, - "description": "Wprowad\u017a dane", - "title": "Tesla \u2014 konfiguracja" - } - } - }, - "options": { - "step": { - "init": { - "data": { - "enable_wake_on_start": "Wymu\u015b wybudzenie samochod\u00f3w podczas uruchamiania", - "scan_interval": "Cz\u0119stotliwo\u015b\u0107 aktualizacji" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/pt-BR.json b/homeassistant/components/tesla/translations/pt-BR.json deleted file mode 100644 index 1317f4b1dd7..00000000000 --- a/homeassistant/components/tesla/translations/pt-BR.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "step": { - "user": { - "data": { - "password": "Senha", - "username": "Endere\u00e7o de e-mail" - }, - "description": "Por favor, insira suas informa\u00e7\u00f5es.", - "title": "Tesla - Configura\u00e7\u00e3o" - } - } - }, - "options": { - "step": { - "init": { - "data": { - "enable_wake_on_start": "For\u00e7ar carros a acordar na inicializa\u00e7\u00e3o" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/pt.json b/homeassistant/components/tesla/translations/pt.json deleted file mode 100644 index c249c325adc..00000000000 --- a/homeassistant/components/tesla/translations/pt.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "config": { - "error": { - "already_configured": "Conta j\u00e1 configurada", - "cannot_connect": "Falha na liga\u00e7\u00e3o", - "invalid_auth": "Autentica\u00e7\u00e3o inv\u00e1lida" - }, - "step": { - "user": { - "data": { - "password": "Palavra-passe", - "username": "Endere\u00e7o de e-mail" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/ru.json b/homeassistant/components/tesla/translations/ru.json deleted file mode 100644 index 191d10b8bea..00000000000 --- a/homeassistant/components/tesla/translations/ru.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "\u042d\u0442\u0430 \u0443\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430 \u0432 Home Assistant.", - "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430 \u0443\u0441\u043f\u0435\u0448\u043d\u043e." - }, - "error": { - "already_configured": "\u042d\u0442\u0430 \u0443\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430 \u0432 Home Assistant.", - "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", - "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438." - }, - "step": { - "user": { - "data": { - "mfa": "\u041a\u043e\u0434 MFA (\u043d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e)", - "password": "\u041f\u0430\u0440\u043e\u043b\u044c", - "username": "\u0410\u0434\u0440\u0435\u0441 \u044d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u043e\u0439 \u043f\u043e\u0447\u0442\u044b" - }, - "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0434\u0430\u043d\u043d\u044b\u0435 \u0443\u0447\u0451\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438.", - "title": "Tesla" - } - } - }, - "options": { - "step": { - "init": { - "data": { - "enable_wake_on_start": "\u041f\u0440\u0438\u043d\u0443\u0434\u0438\u0442\u0435\u043b\u044c\u043d\u043e \u0440\u0430\u0437\u0431\u0443\u0434\u0438\u0442\u044c \u043c\u0430\u0448\u0438\u043d\u0443 \u043f\u0440\u0438 \u0437\u0430\u043f\u0443\u0441\u043a\u0435", - "scan_interval": "\u0418\u043d\u0442\u0435\u0440\u0432\u0430\u043b \u043c\u0435\u0436\u0434\u0443 \u0441\u043a\u0430\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f\u043c\u0438 (\u0441\u0435\u043a.)" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/sl.json b/homeassistant/components/tesla/translations/sl.json deleted file mode 100644 index e72538e09bc..00000000000 --- a/homeassistant/components/tesla/translations/sl.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "config": { - "step": { - "user": { - "data": { - "password": "Geslo", - "username": "E-po\u0161tni naslov" - }, - "description": "Prosimo, vnesite svoje podatke.", - "title": "Tesla - konfiguracija" - } - } - }, - "options": { - "step": { - "init": { - "data": { - "enable_wake_on_start": "Vsili zbujanje avtomobila ob zagonu", - "scan_interval": "Sekund med skeniranjem" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/sv.json b/homeassistant/components/tesla/translations/sv.json deleted file mode 100644 index d347634cb14..00000000000 --- a/homeassistant/components/tesla/translations/sv.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "step": { - "user": { - "data": { - "password": "L\u00f6senord", - "username": "E-postadress" - }, - "description": "V\u00e4nligen ange din information.", - "title": "Tesla - Konfiguration" - } - } - }, - "options": { - "step": { - "init": { - "data": { - "scan_interval": "Sekunder mellan skanningar" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/tr.json b/homeassistant/components/tesla/translations/tr.json deleted file mode 100644 index cf0d144c1ed..00000000000 --- a/homeassistant/components/tesla/translations/tr.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "config": { - "error": { - "already_configured": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", - "cannot_connect": "Ba\u011flanma hatas\u0131", - "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama" - }, - "step": { - "user": { - "data": { - "password": "Parola", - "username": "E-posta" - }, - "description": "L\u00fctfen bilgilerinizi giriniz." - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/uk.json b/homeassistant/components/tesla/translations/uk.json deleted file mode 100644 index 90d47ec2ff5..00000000000 --- a/homeassistant/components/tesla/translations/uk.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "config": { - "error": { - "already_configured": "\u0426\u0435\u0439 \u043e\u0431\u043b\u0456\u043a\u043e\u0432\u0438\u0439 \u0437\u0430\u043f\u0438\u0441 \u0432\u0436\u0435 \u0434\u043e\u0434\u0430\u043d\u043e \u0432 Home Assistant.", - "cannot_connect": "\u041d\u0435 \u0432\u0434\u0430\u043b\u043e\u0441\u044f \u043f\u0456\u0434'\u0454\u0434\u043d\u0430\u0442\u0438\u0441\u044f", - "invalid_auth": "\u041d\u0435\u0432\u0456\u0440\u043d\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0456\u043a\u0430\u0446\u0456\u044f." - }, - "step": { - "user": { - "data": { - "password": "\u041f\u0430\u0440\u043e\u043b\u044c", - "username": "\u0410\u0434\u0440\u0435\u0441\u0430 \u0435\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u043e\u0457 \u043f\u043e\u0448\u0442\u0438" - }, - "description": "\u0412\u0432\u0435\u0434\u0456\u0442\u044c \u0434\u0430\u043d\u0456 \u043e\u0431\u043b\u0456\u043a\u043e\u0432\u043e\u0433\u043e \u0437\u0430\u043f\u0438\u0441\u0443.", - "title": "Tesla" - } - } - }, - "options": { - "step": { - "init": { - "data": { - "enable_wake_on_start": "\u041f\u0440\u0438\u043c\u0443\u0441\u043e\u0432\u043e \u0440\u043e\u0437\u0431\u0443\u0434\u0438\u0442\u0438 \u043c\u0430\u0448\u0438\u043d\u0443 \u043f\u0440\u0438 \u0437\u0430\u043f\u0443\u0441\u043a\u0443", - "scan_interval": "\u0406\u043d\u0442\u0435\u0440\u0432\u0430\u043b \u043c\u0456\u0436 \u0441\u043a\u0430\u043d\u0443\u0432\u0430\u043d\u043d\u044f\u043c\u0438 (\u0441\u0435\u043a.)" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/zh-Hans.json b/homeassistant/components/tesla/translations/zh-Hans.json deleted file mode 100644 index 35635ce3be3..00000000000 --- a/homeassistant/components/tesla/translations/zh-Hans.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "config": { - "step": { - "user": { - "data": { - "mfa": "MFA \u4ee3\u7801\uff08\u53ef\u9009\uff09" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/zh-Hant.json b/homeassistant/components/tesla/translations/zh-Hant.json deleted file mode 100644 index 9ff407efaa3..00000000000 --- a/homeassistant/components/tesla/translations/zh-Hant.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", - "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f" - }, - "error": { - "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", - "cannot_connect": "\u9023\u7dda\u5931\u6557", - "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548" - }, - "step": { - "user": { - "data": { - "mfa": "MFA \u78bc\uff08\u9078\u9805\uff09", - "password": "\u5bc6\u78bc", - "username": "\u96fb\u5b50\u90f5\u4ef6" - }, - "description": "\u8acb\u8f38\u5165\u8cc7\u8a0a\u3002", - "title": "Tesla - \u8a2d\u5b9a" - } - } - }, - "options": { - "step": { - "init": { - "data": { - "enable_wake_on_start": "\u65bc\u555f\u52d5\u6642\u5f37\u5236\u559a\u9192\u6c7d\u8eca", - "scan_interval": "\u6383\u63cf\u9593\u9694\u79d2\u6578" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 2eb4e43fe32..57f152bb5a2 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -272,7 +272,6 @@ FLOWS = [ "tado", "tasmota", "tellduslive", - "tesla", "tibber", "tile", "toon", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index cf442504121..3e00f8f5605 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -259,21 +259,6 @@ DHCP = [ "domain": "tado", "hostname": "tado*" }, - { - "domain": "tesla", - "hostname": "tesla_*", - "macaddress": "4CFCAA*" - }, - { - "domain": "tesla", - "hostname": "tesla_*", - "macaddress": "044EAF*" - }, - { - "domain": "tesla", - "hostname": "tesla_*", - "macaddress": "98ED5C*" - }, { "domain": "toon", "hostname": "eneco-*", diff --git a/mypy.ini b/mypy.ini index e2e570c18c2..8967a0502d1 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1618,9 +1618,6 @@ ignore_errors = true [mypy-homeassistant.components.template.*] ignore_errors = true -[mypy-homeassistant.components.tesla.*] -ignore_errors = true - [mypy-homeassistant.components.toon.*] ignore_errors = true diff --git a/requirements_all.txt b/requirements_all.txt index 20f5472f62d..4d8230c8bee 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2283,9 +2283,6 @@ temperusb==1.5.3 # homeassistant.components.powerwall tesla-powerwall==0.3.10 -# homeassistant.components.tesla -teslajsonpy==0.18.3 - # homeassistant.components.tensorflow # tf-models-official==2.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 532faf9456c..47598fde7f8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1275,9 +1275,6 @@ tellduslive==0.10.11 # homeassistant.components.powerwall tesla-powerwall==0.3.10 -# homeassistant.components.tesla -teslajsonpy==0.18.3 - # homeassistant.components.toon toonapi==0.2.0 diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 78707494ef5..6a076a5a5e6 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -128,7 +128,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.tado.*", "homeassistant.components.telegram_bot.*", "homeassistant.components.template.*", - "homeassistant.components.tesla.*", "homeassistant.components.toon.*", "homeassistant.components.tplink.*", "homeassistant.components.unifi.*", diff --git a/tests/components/tesla/__init__.py b/tests/components/tesla/__init__.py deleted file mode 100644 index 89b1e1c0c54..00000000000 --- a/tests/components/tesla/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the Tesla integration.""" diff --git a/tests/components/tesla/test_config_flow.py b/tests/components/tesla/test_config_flow.py deleted file mode 100644 index 4a45aac5124..00000000000 --- a/tests/components/tesla/test_config_flow.py +++ /dev/null @@ -1,270 +0,0 @@ -"""Test the Tesla config flow.""" -import datetime -from unittest.mock import patch - -from teslajsonpy.exceptions import IncompleteCredentials, TeslaException - -from homeassistant import config_entries, data_entry_flow, setup -from homeassistant.components.tesla.const import ( - CONF_EXPIRATION, - CONF_WAKE_ON_START, - DEFAULT_SCAN_INTERVAL, - DEFAULT_WAKE_ON_START, - DOMAIN, - MIN_SCAN_INTERVAL, -) -from homeassistant.const import ( - CONF_ACCESS_TOKEN, - CONF_PASSWORD, - CONF_SCAN_INTERVAL, - CONF_TOKEN, - CONF_USERNAME, - HTTP_NOT_FOUND, -) - -from tests.common import MockConfigEntry - -TEST_USERNAME = "test-username" -TEST_TOKEN = "test-token" -TEST_PASSWORD = "test-password" -TEST_ACCESS_TOKEN = "test-access-token" -TEST_VALID_EXPIRATION = datetime.datetime.now().timestamp() * 2 - - -async def test_form(hass): - """Test we get the form.""" - await setup.async_setup_component(hass, "persistent_notification", {}) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == "form" - assert result["errors"] == {} - - with patch( - "homeassistant.components.tesla.config_flow.TeslaAPI.connect", - return_value={ - "refresh_token": TEST_TOKEN, - CONF_ACCESS_TOKEN: TEST_ACCESS_TOKEN, - CONF_EXPIRATION: TEST_VALID_EXPIRATION, - }, - ), patch( - "homeassistant.components.tesla.async_setup", return_value=True - ) as mock_setup, patch( - "homeassistant.components.tesla.async_setup_entry", return_value=True - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_PASSWORD: "test", CONF_USERNAME: "test@email.com"} - ) - await hass.async_block_till_done() - - assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result2["title"] == "test@email.com" - assert result2["data"] == { - CONF_USERNAME: "test@email.com", - CONF_PASSWORD: "test", - CONF_TOKEN: TEST_TOKEN, - CONF_ACCESS_TOKEN: TEST_ACCESS_TOKEN, - CONF_EXPIRATION: TEST_VALID_EXPIRATION, - } - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_form_invalid_auth(hass): - """Test we handle invalid auth.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "homeassistant.components.tesla.config_flow.TeslaAPI.connect", - side_effect=TeslaException(401), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: TEST_PASSWORD}, - ) - - assert result2["type"] == "form" - assert result2["errors"] == {"base": "invalid_auth"} - - -async def test_form_invalid_auth_incomplete_credentials(hass): - """Test we handle invalid auth with incomplete credentials.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "homeassistant.components.tesla.config_flow.TeslaAPI.connect", - side_effect=IncompleteCredentials(401), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: TEST_PASSWORD}, - ) - - assert result2["type"] == "form" - assert result2["errors"] == {"base": "invalid_auth"} - - -async def test_form_cannot_connect(hass): - """Test we handle cannot connect error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "homeassistant.components.tesla.config_flow.TeslaAPI.connect", - side_effect=TeslaException(code=HTTP_NOT_FOUND), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_PASSWORD: TEST_PASSWORD, CONF_USERNAME: TEST_USERNAME}, - ) - - assert result2["type"] == "form" - assert result2["errors"] == {"base": "cannot_connect"} - - -async def test_form_repeat_identifier(hass): - """Test we handle repeat identifiers.""" - entry = MockConfigEntry( - domain=DOMAIN, - title=TEST_USERNAME, - data={"username": TEST_USERNAME, "password": TEST_PASSWORD}, - options=None, - ) - entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - with patch( - "homeassistant.components.tesla.config_flow.TeslaAPI.connect", - return_value={ - "refresh_token": TEST_TOKEN, - CONF_ACCESS_TOKEN: TEST_ACCESS_TOKEN, - CONF_EXPIRATION: TEST_VALID_EXPIRATION, - }, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: TEST_PASSWORD}, - ) - - assert result2["type"] == "abort" - assert result2["reason"] == "already_configured" - - -async def test_form_reauth(hass): - """Test we handle reauth.""" - entry = MockConfigEntry( - domain=DOMAIN, - title=TEST_USERNAME, - data={"username": TEST_USERNAME, "password": "same"}, - options=None, - ) - entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_REAUTH}, - data={"username": TEST_USERNAME}, - ) - with patch( - "homeassistant.components.tesla.config_flow.TeslaAPI.connect", - return_value={ - "refresh_token": TEST_TOKEN, - CONF_ACCESS_TOKEN: TEST_ACCESS_TOKEN, - CONF_EXPIRATION: TEST_VALID_EXPIRATION, - }, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: "new-password"}, - ) - - assert result2["type"] == "abort" - assert result2["reason"] == "reauth_successful" - - -async def test_import(hass): - """Test import step.""" - - with patch( - "homeassistant.components.tesla.config_flow.TeslaAPI.connect", - return_value={ - "refresh_token": TEST_TOKEN, - CONF_ACCESS_TOKEN: TEST_ACCESS_TOKEN, - CONF_EXPIRATION: TEST_VALID_EXPIRATION, - }, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={CONF_PASSWORD: TEST_PASSWORD, CONF_USERNAME: TEST_USERNAME}, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == TEST_USERNAME - assert result["data"][CONF_ACCESS_TOKEN] == TEST_ACCESS_TOKEN - assert result["data"][CONF_TOKEN] == TEST_TOKEN - assert result["description_placeholders"] is None - - -async def test_option_flow(hass): - """Test config flow options.""" - entry = MockConfigEntry(domain=DOMAIN, data={}, options=None) - entry.add_to_hass(hass) - - result = await hass.config_entries.options.async_init(entry.entry_id) - - assert result["type"] == "form" - assert result["step_id"] == "init" - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={CONF_SCAN_INTERVAL: 350, CONF_WAKE_ON_START: True}, - ) - assert result["type"] == "create_entry" - assert result["data"] == {CONF_SCAN_INTERVAL: 350, CONF_WAKE_ON_START: True} - - -async def test_option_flow_defaults(hass): - """Test config flow options.""" - entry = MockConfigEntry(domain=DOMAIN, data={}, options=None) - entry.add_to_hass(hass) - - result = await hass.config_entries.options.async_init(entry.entry_id) - - assert result["type"] == "form" - assert result["step_id"] == "init" - - result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={} - ) - assert result["type"] == "create_entry" - assert result["data"] == { - CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL, - CONF_WAKE_ON_START: DEFAULT_WAKE_ON_START, - } - - -async def test_option_flow_input_floor(hass): - """Test config flow options.""" - entry = MockConfigEntry(domain=DOMAIN, data={}, options=None) - entry.add_to_hass(hass) - - result = await hass.config_entries.options.async_init(entry.entry_id) - - assert result["type"] == "form" - assert result["step_id"] == "init" - - result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={CONF_SCAN_INTERVAL: 1} - ) - assert result["type"] == "create_entry" - assert result["data"] == { - CONF_SCAN_INTERVAL: MIN_SCAN_INTERVAL, - CONF_WAKE_ON_START: DEFAULT_WAKE_ON_START, - } From a8cbb949fa83410f3bda959a8bbba846a6d8a037 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Thu, 9 Sep 2021 07:17:02 +0200 Subject: [PATCH 305/843] Rfxtrx drop yaml configuration (#54173) --- homeassistant/components/rfxtrx/__init__.py | 81 +---------- .../components/rfxtrx/binary_sensor.py | 20 ++- .../components/rfxtrx/config_flow.py | 24 --- homeassistant/components/rfxtrx/const.py | 1 - homeassistant/components/rfxtrx/cover.py | 4 +- homeassistant/components/rfxtrx/light.py | 11 +- homeassistant/components/rfxtrx/switch.py | 11 +- tests/components/rfxtrx/test_config_flow.py | 137 ------------------ tests/components/rfxtrx/test_init.py | 47 ------ 9 files changed, 26 insertions(+), 310 deletions(-) diff --git a/homeassistant/components/rfxtrx/__init__.py b/homeassistant/components/rfxtrx/__init__.py index 34b7c01600a..970aed38335 100644 --- a/homeassistant/components/rfxtrx/__init__.py +++ b/homeassistant/components/rfxtrx/__init__.py @@ -10,13 +10,9 @@ import async_timeout import voluptuous as vol from homeassistant import config_entries -from homeassistant.components.binary_sensor import DEVICE_CLASSES_SCHEMA from homeassistant.const import ( ATTR_DEVICE_ID, - CONF_COMMAND_OFF, - CONF_COMMAND_ON, CONF_DEVICE, - CONF_DEVICE_CLASS, CONF_DEVICE_ID, CONF_DEVICES, CONF_HOST, @@ -33,11 +29,8 @@ from .const import ( COMMAND_GROUP_LIST, CONF_AUTOMATIC_ADD, CONF_DATA_BITS, - CONF_DEBUG, CONF_FIRE_EVENT, - CONF_OFF_DELAY, CONF_REMOVE_DEVICE, - CONF_SIGNAL_REPETITIONS, DATA_CLEANUP_CALLBACKS, DATA_LISTENER, DATA_RFXOBJECT, @@ -65,83 +58,11 @@ def _bytearray_string(data): ) from err -def _ensure_device(value): - if value is None: - return DEVICE_DATA_SCHEMA({}) - return DEVICE_DATA_SCHEMA(value) - - SERVICE_SEND_SCHEMA = vol.Schema({ATTR_EVENT: _bytearray_string}) -DEVICE_DATA_SCHEMA = vol.Schema( - { - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_FIRE_EVENT, default=False): cv.boolean, - vol.Optional(CONF_OFF_DELAY): vol.All( - cv.time_period, cv.positive_timedelta, lambda value: value.total_seconds() - ), - vol.Optional(CONF_DATA_BITS): cv.positive_int, - vol.Optional(CONF_COMMAND_ON): cv.byte, - vol.Optional(CONF_COMMAND_OFF): cv.byte, - vol.Optional(CONF_SIGNAL_REPETITIONS, default=1): cv.positive_int, - } -) - -BASE_SCHEMA = vol.Schema( - { - vol.Optional(CONF_DEBUG): cv.boolean, - vol.Optional(CONF_AUTOMATIC_ADD, default=False): cv.boolean, - vol.Optional(CONF_DEVICES, default={}): {cv.string: _ensure_device}, - }, -) - -DEVICE_SCHEMA = BASE_SCHEMA.extend({vol.Required(CONF_DEVICE): cv.string}) - -PORT_SCHEMA = BASE_SCHEMA.extend( - {vol.Required(CONF_PORT): cv.port, vol.Optional(CONF_HOST): cv.string} -) - -CONFIG_SCHEMA = vol.Schema( - {DOMAIN: vol.All(cv.deprecated(CONF_DEBUG), vol.Any(DEVICE_SCHEMA, PORT_SCHEMA))}, - extra=vol.ALLOW_EXTRA, -) - PLATFORMS = ["switch", "sensor", "light", "binary_sensor", "cover"] -async def async_setup(hass, config): - """Set up the RFXtrx component.""" - if DOMAIN not in config: - return True - - data = { - CONF_HOST: config[DOMAIN].get(CONF_HOST), - CONF_PORT: config[DOMAIN].get(CONF_PORT), - CONF_DEVICE: config[DOMAIN].get(CONF_DEVICE), - CONF_AUTOMATIC_ADD: config[DOMAIN].get(CONF_AUTOMATIC_ADD), - CONF_DEVICES: config[DOMAIN][CONF_DEVICES], - } - - # Read device_id from the event code add to the data that will end up in the ConfigEntry - for event_code, event_config in data[CONF_DEVICES].items(): - event = get_rfx_object(event_code) - if event is None: - continue - device_id = get_device_id( - event.device, data_bits=event_config.get(CONF_DATA_BITS) - ) - event_config[CONF_DEVICE_ID] = device_id - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=data, - ) - ) - return True - - async def async_setup_entry(hass, entry: config_entries.ConfigEntry): """Set up the RFXtrx component.""" hass.data.setdefault(DOMAIN, {}) @@ -272,7 +193,7 @@ async def async_setup_internal(hass, entry: config_entries.ConfigEntry): @callback def _add_device(event, device_id): """Add a device to config entry.""" - config = DEVICE_DATA_SCHEMA({}) + config = {} config[CONF_DEVICE_ID] = device_id data = entry.data.copy() diff --git a/homeassistant/components/rfxtrx/binary_sensor.py b/homeassistant/components/rfxtrx/binary_sensor.py index f6751d760b2..788c5dec436 100644 --- a/homeassistant/components/rfxtrx/binary_sensor.py +++ b/homeassistant/components/rfxtrx/binary_sensor.py @@ -1,7 +1,6 @@ """Support for RFXtrx binary sensors.""" from __future__ import annotations -from dataclasses import replace import logging import RFXtrx as rfxtrxmod @@ -15,7 +14,6 @@ from homeassistant.components.binary_sensor import ( from homeassistant.const import ( CONF_COMMAND_OFF, CONF_COMMAND_ON, - CONF_DEVICE_CLASS, CONF_DEVICES, STATE_ON, ) @@ -23,8 +21,6 @@ from homeassistant.core import callback from homeassistant.helpers import event as evt from . import ( - CONF_DATA_BITS, - CONF_OFF_DELAY, RfxtrxEntity, connect_auto_add, find_possible_pt2262_device, @@ -32,7 +28,13 @@ from . import ( get_pt2262_cmd, get_rfx_object, ) -from .const import COMMAND_OFF_LIST, COMMAND_ON_LIST, DEVICE_PACKET_TYPE_LIGHTING4 +from .const import ( + COMMAND_OFF_LIST, + COMMAND_ON_LIST, + CONF_DATA_BITS, + CONF_OFF_DELAY, + DEVICE_PACKET_TYPE_LIGHTING4, +) _LOGGER = logging.getLogger(__name__) @@ -106,12 +108,10 @@ async def async_setup_entry( discovery_info = config_entry.data - def get_sensor_description(type_string: str, device_class: str | None = None): + def get_sensor_description(type_string: str): description = SENSOR_TYPES_DICT.get(type_string) if description is None: description = BinarySensorEntityDescription(key=type_string) - if device_class: - description = replace(description, device_class=device_class) return description for packet_id, entity_info in discovery_info[CONF_DEVICES].items(): @@ -136,9 +136,7 @@ async def async_setup_entry( device = RfxtrxBinarySensor( event.device, device_id, - get_sensor_description( - event.device.type_string, entity_info.get(CONF_DEVICE_CLASS) - ), + get_sensor_description(event.device.type_string), entity_info.get(CONF_OFF_DELAY), entity_info.get(CONF_DATA_BITS), entity_info.get(CONF_COMMAND_ON), diff --git a/homeassistant/components/rfxtrx/config_flow.py b/homeassistant/components/rfxtrx/config_flow.py index 91afd9da999..01bcc6ea035 100644 --- a/homeassistant/components/rfxtrx/config_flow.py +++ b/homeassistant/components/rfxtrx/config_flow.py @@ -547,30 +547,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_import(self, import_config=None): - """Handle the initial step.""" - entry = await self.async_set_unique_id(DOMAIN) - if entry: - if CONF_DEVICES not in entry.data: - # In version 0.113, devices key was not written to config entry. Update the entry with import data - self._abort_if_unique_id_configured(import_config) - else: - self._abort_if_unique_id_configured() - - host = import_config[CONF_HOST] - port = import_config[CONF_PORT] - device = import_config[CONF_DEVICE] - - try: - if host is not None: - await self.async_validate_rfx(host=host, port=port) - else: - await self.async_validate_rfx(device=device) - except CannotConnect: - return self.async_abort(reason="cannot_connect") - - return self.async_create_entry(title="RFXTRX", data=import_config) - async def async_validate_rfx(self, host=None, port=None, device=None): """Create data for rfxtrx entry.""" success = await self.hass.async_add_executor_job( diff --git a/homeassistant/components/rfxtrx/const.py b/homeassistant/components/rfxtrx/const.py index d457435f85c..17f54ef24c9 100644 --- a/homeassistant/components/rfxtrx/const.py +++ b/homeassistant/components/rfxtrx/const.py @@ -4,7 +4,6 @@ CONF_FIRE_EVENT = "fire_event" CONF_DATA_BITS = "data_bits" CONF_AUTOMATIC_ADD = "automatic_add" CONF_SIGNAL_REPETITIONS = "signal_repetitions" -CONF_DEBUG = "debug" CONF_OFF_DELAY = "off_delay" CONF_VENETIAN_BLIND_MODE = "venetian_blind_mode" diff --git a/homeassistant/components/rfxtrx/cover.py b/homeassistant/components/rfxtrx/cover.py index a5f5edd0e42..26a938141a2 100644 --- a/homeassistant/components/rfxtrx/cover.py +++ b/homeassistant/components/rfxtrx/cover.py @@ -14,8 +14,6 @@ from homeassistant.const import CONF_DEVICES, STATE_OPEN from homeassistant.core import callback from . import ( - CONF_DATA_BITS, - CONF_SIGNAL_REPETITIONS, DEFAULT_SIGNAL_REPETITIONS, RfxtrxCommandEntity, connect_auto_add, @@ -25,6 +23,8 @@ from . import ( from .const import ( COMMAND_OFF_LIST, COMMAND_ON_LIST, + CONF_DATA_BITS, + CONF_SIGNAL_REPETITIONS, CONF_VENETIAN_BLIND_MODE, CONST_VENETIAN_BLIND_MODE_EU, CONST_VENETIAN_BLIND_MODE_US, diff --git a/homeassistant/components/rfxtrx/light.py b/homeassistant/components/rfxtrx/light.py index fd790581eda..ea197b5ebc4 100644 --- a/homeassistant/components/rfxtrx/light.py +++ b/homeassistant/components/rfxtrx/light.py @@ -12,15 +12,18 @@ from homeassistant.const import CONF_DEVICES, STATE_ON from homeassistant.core import callback from . import ( - CONF_DATA_BITS, - CONF_SIGNAL_REPETITIONS, DEFAULT_SIGNAL_REPETITIONS, RfxtrxCommandEntity, connect_auto_add, get_device_id, get_rfx_object, ) -from .const import COMMAND_OFF_LIST, COMMAND_ON_LIST +from .const import ( + COMMAND_OFF_LIST, + COMMAND_ON_LIST, + CONF_DATA_BITS, + CONF_SIGNAL_REPETITIONS, +) _LOGGER = logging.getLogger(__name__) @@ -62,7 +65,7 @@ async def async_setup_entry( device_ids.add(device_id) entity = RfxtrxLight( - event.device, device_id, entity_info[CONF_SIGNAL_REPETITIONS] + event.device, device_id, entity_info.get(CONF_SIGNAL_REPETITIONS, 1) ) entities.append(entity) diff --git a/homeassistant/components/rfxtrx/switch.py b/homeassistant/components/rfxtrx/switch.py index 60ddb9a4d16..2a09d027345 100644 --- a/homeassistant/components/rfxtrx/switch.py +++ b/homeassistant/components/rfxtrx/switch.py @@ -8,8 +8,6 @@ from homeassistant.const import CONF_DEVICES, STATE_ON from homeassistant.core import callback from . import ( - CONF_DATA_BITS, - CONF_SIGNAL_REPETITIONS, DEFAULT_SIGNAL_REPETITIONS, DOMAIN, RfxtrxCommandEntity, @@ -17,7 +15,12 @@ from . import ( get_device_id, get_rfx_object, ) -from .const import COMMAND_OFF_LIST, COMMAND_ON_LIST +from .const import ( + COMMAND_OFF_LIST, + COMMAND_ON_LIST, + CONF_DATA_BITS, + CONF_SIGNAL_REPETITIONS, +) DATA_SWITCH = f"{DOMAIN}_switch" @@ -61,7 +64,7 @@ async def async_setup_entry( device_ids.add(device_id) entity = RfxtrxSwitch( - event.device, device_id, entity_info[CONF_SIGNAL_REPETITIONS] + event.device, device_id, entity_info.get(CONF_SIGNAL_REPETITIONS, 1) ) entities.append(entity) diff --git a/tests/components/rfxtrx/test_config_flow.py b/tests/components/rfxtrx/test_config_flow.py index 1c16507f960..2b55db0b889 100644 --- a/tests/components/rfxtrx/test_config_flow.py +++ b/tests/components/rfxtrx/test_config_flow.py @@ -277,143 +277,6 @@ async def test_setup_serial_manual_fail(com_mock, hass): assert result["errors"] == {"base": "cannot_connect"} -@patch( - "homeassistant.components.rfxtrx.rfxtrxmod.PySerialTransport.connect", - serial_connect, -) -@patch( - "homeassistant.components.rfxtrx.rfxtrxmod.PySerialTransport.close", - return_value=None, -) -async def test_import_serial(connect_mock, hass): - """Test we can import.""" - await setup.async_setup_component(hass, "persistent_notification", {}) - - with patch("homeassistant.components.rfxtrx.async_setup_entry", return_value=True): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={"host": None, "port": None, "device": "/dev/tty123", "debug": False}, - ) - - assert result["type"] == "create_entry" - assert result["title"] == "RFXTRX" - assert result["data"] == { - "host": None, - "port": None, - "device": "/dev/tty123", - "debug": False, - } - - -@patch( - "homeassistant.components.rfxtrx.rfxtrxmod.PyNetworkTransport.connect", - return_value=None, -) -async def test_import_network(connect_mock, hass): - """Test we can import.""" - await setup.async_setup_component(hass, "persistent_notification", {}) - - with patch("homeassistant.components.rfxtrx.async_setup_entry", return_value=True): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={"host": "localhost", "port": 1234, "device": None, "debug": False}, - ) - - assert result["type"] == "create_entry" - assert result["title"] == "RFXTRX" - assert result["data"] == { - "host": "localhost", - "port": 1234, - "device": None, - "debug": False, - } - - -@patch( - "homeassistant.components.rfxtrx.rfxtrxmod.PyNetworkTransport.connect", - side_effect=OSError, -) -async def test_import_network_connection_fail(connect_mock, hass): - """Test we can import.""" - await setup.async_setup_component(hass, "persistent_notification", {}) - - with patch("homeassistant.components.rfxtrx.async_setup_entry", return_value=True): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={"host": "localhost", "port": 1234, "device": None, "debug": False}, - ) - - assert result["type"] == "abort" - assert result["reason"] == "cannot_connect" - - -async def test_import_update(hass): - """Test we can import.""" - await setup.async_setup_component(hass, "persistent_notification", {}) - - entry = MockConfigEntry( - domain=DOMAIN, - data={ - "host": None, - "port": None, - "device": "/dev/tty123", - "debug": False, - "devices": {}, - }, - unique_id=DOMAIN, - ) - entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - "host": None, - "port": None, - "device": "/dev/tty123", - "debug": True, - "devices": {}, - }, - ) - - assert result["type"] == "abort" - assert result["reason"] == "already_configured" - - -async def test_import_migrate(hass): - """Test we can import.""" - await setup.async_setup_component(hass, "persistent_notification", {}) - - entry = MockConfigEntry( - domain=DOMAIN, - data={"host": None, "port": None, "device": "/dev/tty123", "debug": False}, - unique_id=DOMAIN, - ) - entry.add_to_hass(hass) - - with patch("homeassistant.components.rfxtrx.async_setup_entry", return_value=True): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - "host": None, - "port": None, - "device": "/dev/tty123", - "debug": True, - "automatic_add": True, - "devices": {}, - }, - ) - - assert result["type"] == "abort" - assert result["reason"] == "already_configured" - - assert entry.data["devices"] == {} - - async def test_options_global(hass): """Test if we can set global options.""" await setup.async_setup_component(hass, "persistent_notification", {}) diff --git a/tests/components/rfxtrx/test_init.py b/tests/components/rfxtrx/test_init.py index 3625c23ebb8..0c904896090 100644 --- a/tests/components/rfxtrx/test_init.py +++ b/tests/components/rfxtrx/test_init.py @@ -6,58 +6,11 @@ from homeassistant.components.rfxtrx import DOMAIN from homeassistant.components.rfxtrx.const import EVENT_RFXTRX_EVENT from homeassistant.core import callback from homeassistant.helpers import device_registry as dr -from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry from tests.components.rfxtrx.conftest import create_rfx_test_cfg -async def test_valid_config(hass): - """Test configuration.""" - assert await async_setup_component( - hass, - "rfxtrx", - { - "rfxtrx": { - "device": "/dev/serial/by-id/usb" - + "-RFXCOM_RFXtrx433_A1Y0NJGR-if00-port0", - } - }, - ) - - -async def test_valid_config2(hass): - """Test configuration.""" - assert await async_setup_component( - hass, - "rfxtrx", - { - "rfxtrx": { - "device": "/dev/serial/by-id/usb" - + "-RFXCOM_RFXtrx433_A1Y0NJGR-if00-port0", - "debug": True, - } - }, - ) - - -async def test_invalid_config(hass): - """Test configuration.""" - assert not await async_setup_component(hass, "rfxtrx", {"rfxtrx": {}}) - - assert not await async_setup_component( - hass, - "rfxtrx", - { - "rfxtrx": { - "device": "/dev/serial/by-id/usb" - + "-RFXCOM_RFXtrx433_A1Y0NJGR-if00-port0", - "invalid_key": True, - } - }, - ) - - async def test_fire_event(hass, rfxtrx): """Test fire event.""" entry_data = create_rfx_test_cfg( From 80fd33047988f61c652dd765237873278fdf6e5a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 9 Sep 2021 08:35:53 +0200 Subject: [PATCH 306/843] Add sum_decrease and sum_increase statistics (#55850) --- .../components/recorder/migration.py | 6 ++ homeassistant/components/recorder/models.py | 4 +- .../components/recorder/statistics.py | 5 +- homeassistant/components/sensor/recorder.py | 23 +++++- tests/components/history/test_init.py | 2 + tests/components/recorder/test_statistics.py | 10 +++ tests/components/sensor/test_recorder.py | 80 +++++++++++++++++++ 7 files changed, 124 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 4a5c456df28..c694aa678f0 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -501,6 +501,12 @@ def _apply_update(engine, session, new_version, old_version): # noqa: C901 "sum DOUBLE PRECISION", ], ) + elif new_version == 21: + _add_columns( + connection, + "statistics", + ["sum_increase DOUBLE PRECISION"], + ) else: raise ValueError(f"No schema migration defined for version {new_version}") diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py index 28eff4d9d95..11c614c9ea1 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -39,7 +39,7 @@ import homeassistant.util.dt as dt_util # pylint: disable=invalid-name Base = declarative_base() -SCHEMA_VERSION = 20 +SCHEMA_VERSION = 21 _LOGGER = logging.getLogger(__name__) @@ -229,6 +229,7 @@ class StatisticData(TypedDict, total=False): last_reset: datetime | None state: float sum: float + sum_increase: float class Statistics(Base): # type: ignore @@ -253,6 +254,7 @@ class Statistics(Base): # type: ignore last_reset = Column(DATETIME_TYPE) state = Column(DOUBLE_TYPE) sum = Column(DOUBLE_TYPE) + sum_increase = Column(DOUBLE_TYPE) @staticmethod def from_stats(metadata_id: str, start: datetime, stats: StatisticData): diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index db82eb1ee39..c8f4e48563c 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -48,6 +48,7 @@ QUERY_STATISTICS = [ Statistics.last_reset, Statistics.state, Statistics.sum, + Statistics.sum_increase, ] QUERY_STATISTIC_META = [ @@ -458,7 +459,9 @@ def _sorted_statistics_to_dict( "max": convert(db_state.max, units), "last_reset": _process_timestamp_to_utc_isoformat(db_state.last_reset), "state": convert(db_state.state, units), - "sum": convert(db_state.sum, units), + "sum": (_sum := convert(db_state.sum, units)), + "sum_increase": (inc := convert(db_state.sum_increase, units)), + "sum_decrease": None if _sum is None or inc is None else inc - _sum, } for db_state in group ) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index e78f9a942c6..d38ae589ef0 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -138,7 +138,7 @@ def _time_weighted_average( ) -> float: """Calculate a time weighted average. - The average is calculated by, weighting the states by duration in seconds between + The average is calculated by weighting the states by duration in seconds between state changes. Note: there's no interpolation of values between state changes. """ @@ -342,7 +342,11 @@ def compile_statistics( # noqa: C901 ) history_list = {**history_list, **_history_list} - for entity_id, state_class, device_class in entities: + for ( # pylint: disable=too-many-nested-blocks + entity_id, + state_class, + device_class, + ) in entities: if entity_id not in history_list: continue @@ -392,13 +396,16 @@ def compile_statistics( # noqa: C901 if "sum" in wanted_statistics[entity_id]: last_reset = old_last_reset = None new_state = old_state = None - _sum = 0 + _sum = 0.0 + sum_increase = 0.0 + sum_increase_tmp = 0.0 last_stats = statistics.get_last_statistics(hass, 1, entity_id, False) if entity_id in last_stats: # We have compiled history for this sensor before, use that as a starting point last_reset = old_last_reset = last_stats[entity_id][0]["last_reset"] new_state = old_state = last_stats[entity_id][0]["state"] - _sum = last_stats[entity_id][0]["sum"] or 0 + _sum = last_stats[entity_id][0]["sum"] or 0.0 + sum_increase = last_stats[entity_id][0]["sum_increase"] or 0.0 for fstate, state in fstates: @@ -452,6 +459,10 @@ def compile_statistics( # noqa: C901 # The sensor has been reset, update the sum if old_state is not None: _sum += new_state - old_state + sum_increase += sum_increase_tmp + sum_increase_tmp = 0.0 + if fstate > 0: + sum_increase_tmp += fstate # ..and update the starting point new_state = fstate old_last_reset = last_reset @@ -461,6 +472,8 @@ def compile_statistics( # noqa: C901 else: old_state = new_state else: + if new_state is not None and fstate > new_state: + sum_increase_tmp += fstate - new_state new_state = fstate # Deprecated, will be removed in Home Assistant 2021.11 @@ -476,9 +489,11 @@ def compile_statistics( # noqa: C901 # Update the sum with the last state _sum += new_state - old_state + sum_increase += sum_increase_tmp if last_reset is not None: stat["last_reset"] = dt_util.parse_datetime(last_reset) stat["sum"] = _sum + stat["sum_increase"] = sum_increase stat["state"] = new_state result[entity_id]["stat"] = stat diff --git a/tests/components/history/test_init.py b/tests/components/history/test_init.py index 7909d8f0239..27c2024750c 100644 --- a/tests/components/history/test_init.py +++ b/tests/components/history/test_init.py @@ -914,6 +914,8 @@ async def test_statistics_during_period( "last_reset": None, "state": None, "sum": None, + "sum_decrease": None, + "sum_increase": None, } ] } diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index 0580460a537..2434f8b4703 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -51,6 +51,8 @@ def test_compile_hourly_statistics(hass_recorder): "last_reset": None, "state": None, "sum": None, + "sum_decrease": None, + "sum_increase": None, } expected_2 = { "statistic_id": "sensor.test1", @@ -61,6 +63,8 @@ def test_compile_hourly_statistics(hass_recorder): "last_reset": None, "state": None, "sum": None, + "sum_decrease": None, + "sum_increase": None, } expected_stats1 = [ {**expected_1, "statistic_id": "sensor.test1"}, @@ -166,6 +170,8 @@ def test_compile_hourly_statistics_exception( "last_reset": None, "state": None, "sum": None, + "sum_decrease": None, + "sum_increase": None, } expected_2 = { "statistic_id": "sensor.test1", @@ -176,6 +182,8 @@ def test_compile_hourly_statistics_exception( "last_reset": None, "state": None, "sum": None, + "sum_decrease": None, + "sum_increase": None, } expected_stats1 = [ {**expected_1, "statistic_id": "sensor.test1"}, @@ -233,6 +241,8 @@ def test_rename_entity(hass_recorder): "last_reset": None, "state": None, "sum": None, + "sum_decrease": None, + "sum_increase": None, } expected_stats1 = [ {**expected_1, "statistic_id": "sensor.test1"}, diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 84b683cf3c3..6108f4a7ef8 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -101,6 +101,8 @@ def test_compile_hourly_statistics( "last_reset": None, "state": None, "sum": None, + "sum_decrease": None, + "sum_increase": None, } ] } @@ -163,6 +165,8 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes "last_reset": None, "state": None, "sum": None, + "sum_decrease": None, + "sum_increase": None, } ], "sensor.test6": [ @@ -175,6 +179,8 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes "last_reset": None, "state": None, "sum": None, + "sum_decrease": None, + "sum_increase": None, } ], "sensor.test7": [ @@ -187,6 +193,8 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes "last_reset": None, "state": None, "sum": None, + "sum_decrease": None, + "sum_increase": None, } ], } @@ -258,6 +266,8 @@ def test_compile_hourly_sum_statistics_amount( "last_reset": process_timestamp_to_utc_isoformat(zero), "state": approx(factor * seq[2]), "sum": approx(factor * 10.0), + "sum_decrease": approx(factor * 0.0), + "sum_increase": approx(factor * 10.0), }, { "statistic_id": "sensor.test1", @@ -268,6 +278,8 @@ def test_compile_hourly_sum_statistics_amount( "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(factor * seq[5]), "sum": approx(factor * 40.0), + "sum_decrease": approx(factor * 10.0), + "sum_increase": approx(factor * 50.0), }, { "statistic_id": "sensor.test1", @@ -278,6 +290,8 @@ def test_compile_hourly_sum_statistics_amount( "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(factor * seq[8]), "sum": approx(factor * 70.0), + "sum_decrease": approx(factor * 10.0), + "sum_increase": approx(factor * 80.0), }, ] } @@ -352,6 +366,8 @@ def test_compile_hourly_sum_statistics_amount_reset_every_state_change( "last_reset": process_timestamp_to_utc_isoformat(one), "state": approx(factor * seq[7]), "sum": approx(factor * (sum(seq) - seq[0])), + "sum_decrease": approx(factor * 0.0), + "sum_increase": approx(factor * (sum(seq) - seq[0])), }, ] } @@ -416,6 +432,10 @@ def test_compile_hourly_sum_statistics_nan_inf_state( "last_reset": process_timestamp_to_utc_isoformat(one), "state": approx(factor * seq[7]), "sum": approx(factor * (seq[2] + seq[3] + seq[4] + seq[6] + seq[7])), + "sum_decrease": approx(factor * 0.0), + "sum_increase": approx( + factor * (seq[2] + seq[3] + seq[4] + seq[6] + seq[7]) + ), }, ] } @@ -478,6 +498,8 @@ def test_compile_hourly_sum_statistics_total_no_reset( "last_reset": None, "state": approx(factor * seq[2]), "sum": approx(factor * 10.0), + "sum_decrease": approx(factor * 0.0), + "sum_increase": approx(factor * 10.0), }, { "statistic_id": "sensor.test1", @@ -488,6 +510,8 @@ def test_compile_hourly_sum_statistics_total_no_reset( "last_reset": None, "state": approx(factor * seq[5]), "sum": approx(factor * 30.0), + "sum_decrease": approx(factor * 10.0), + "sum_increase": approx(factor * 40.0), }, { "statistic_id": "sensor.test1", @@ -498,6 +522,8 @@ def test_compile_hourly_sum_statistics_total_no_reset( "last_reset": None, "state": approx(factor * seq[8]), "sum": approx(factor * 60.0), + "sum_decrease": approx(factor * 10.0), + "sum_increase": approx(factor * 70.0), }, ] } @@ -558,6 +584,8 @@ def test_compile_hourly_sum_statistics_total_increasing( "last_reset": None, "state": approx(factor * seq[2]), "sum": approx(factor * 10.0), + "sum_decrease": approx(factor * 0.0), + "sum_increase": approx(factor * 10.0), }, { "statistic_id": "sensor.test1", @@ -568,6 +596,8 @@ def test_compile_hourly_sum_statistics_total_increasing( "last_reset": None, "state": approx(factor * seq[5]), "sum": approx(factor * 50.0), + "sum_decrease": approx(factor * 0.0), + "sum_increase": approx(factor * 50.0), }, { "statistic_id": "sensor.test1", @@ -578,6 +608,8 @@ def test_compile_hourly_sum_statistics_total_increasing( "last_reset": None, "state": approx(factor * seq[8]), "sum": approx(factor * 80.0), + "sum_decrease": approx(factor * 0.0), + "sum_increase": approx(factor * 80.0), }, ] } @@ -648,6 +680,8 @@ def test_compile_hourly_sum_statistics_total_increasing_small_dip( "min": None, "state": approx(factor * seq[2]), "sum": approx(factor * 10.0), + "sum_decrease": approx(factor * 0.0), + "sum_increase": approx(factor * 10.0), }, { "last_reset": None, @@ -658,6 +692,8 @@ def test_compile_hourly_sum_statistics_total_increasing_small_dip( "min": None, "state": approx(factor * seq[5]), "sum": approx(factor * 30.0), + "sum_decrease": approx(factor * 1.0), + "sum_increase": approx(factor * 31.0), }, { "last_reset": None, @@ -668,6 +704,8 @@ def test_compile_hourly_sum_statistics_total_increasing_small_dip( "min": None, "state": approx(factor * seq[8]), "sum": approx(factor * 60.0), + "sum_decrease": approx(factor * 2.0), + "sum_increase": approx(factor * 62.0), }, ] } @@ -735,6 +773,8 @@ def test_compile_hourly_energy_statistics_unsupported(hass_recorder, caplog): "last_reset": process_timestamp_to_utc_isoformat(zero), "state": approx(20.0), "sum": approx(10.0), + "sum_decrease": approx(0.0), + "sum_increase": approx(10.0), }, { "statistic_id": "sensor.test1", @@ -745,6 +785,8 @@ def test_compile_hourly_energy_statistics_unsupported(hass_recorder, caplog): "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(40.0), "sum": approx(40.0), + "sum_decrease": approx(10.0), + "sum_increase": approx(50.0), }, { "statistic_id": "sensor.test1", @@ -755,6 +797,8 @@ def test_compile_hourly_energy_statistics_unsupported(hass_recorder, caplog): "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(70.0), "sum": approx(70.0), + "sum_decrease": approx(10.0), + "sum_increase": approx(80.0), }, ] } @@ -818,6 +862,8 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "last_reset": process_timestamp_to_utc_isoformat(zero), "state": approx(20.0), "sum": approx(10.0), + "sum_decrease": approx(0.0), + "sum_increase": approx(10.0), }, { "statistic_id": "sensor.test1", @@ -828,6 +874,8 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(40.0), "sum": approx(40.0), + "sum_decrease": approx(10.0), + "sum_increase": approx(50.0), }, { "statistic_id": "sensor.test1", @@ -838,6 +886,8 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(70.0), "sum": approx(70.0), + "sum_decrease": approx(10.0), + "sum_increase": approx(80.0), }, ], "sensor.test2": [ @@ -850,6 +900,8 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "last_reset": process_timestamp_to_utc_isoformat(zero), "state": approx(130.0), "sum": approx(20.0), + "sum_decrease": approx(0.0), + "sum_increase": approx(20.0), }, { "statistic_id": "sensor.test2", @@ -860,6 +912,8 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(45.0), "sum": approx(-65.0), + "sum_decrease": approx(130.0), + "sum_increase": approx(65.0), }, { "statistic_id": "sensor.test2", @@ -870,6 +924,8 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(75.0), "sum": approx(-35.0), + "sum_decrease": approx(130.0), + "sum_increase": approx(95.0), }, ], "sensor.test3": [ @@ -882,6 +938,8 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "last_reset": process_timestamp_to_utc_isoformat(zero), "state": approx(5.0 / 1000), "sum": approx(5.0 / 1000), + "sum_decrease": approx(0.0 / 1000), + "sum_increase": approx(5.0 / 1000), }, { "statistic_id": "sensor.test3", @@ -892,6 +950,8 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(50.0 / 1000), "sum": approx(60.0 / 1000), + "sum_decrease": approx(0.0 / 1000), + "sum_increase": approx(60.0 / 1000), }, { "statistic_id": "sensor.test3", @@ -902,6 +962,8 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(90.0 / 1000), "sum": approx(100.0 / 1000), + "sum_decrease": approx(0.0 / 1000), + "sum_increase": approx(100.0 / 1000), }, ], } @@ -955,6 +1017,8 @@ def test_compile_hourly_statistics_unchanged( "last_reset": None, "state": None, "sum": None, + "sum_decrease": None, + "sum_increase": None, } ] } @@ -987,6 +1051,8 @@ def test_compile_hourly_statistics_partially_unavailable(hass_recorder, caplog): "last_reset": None, "state": None, "sum": None, + "sum_decrease": None, + "sum_increase": None, } ] } @@ -1044,6 +1110,8 @@ def test_compile_hourly_statistics_unavailable( "last_reset": None, "state": None, "sum": None, + "sum_decrease": None, + "sum_increase": None, } ] } @@ -1194,6 +1262,8 @@ def test_compile_hourly_statistics_changing_units_1( "last_reset": None, "state": None, "sum": None, + "sum_decrease": None, + "sum_increase": None, } ] } @@ -1220,6 +1290,8 @@ def test_compile_hourly_statistics_changing_units_1( "last_reset": None, "state": None, "sum": None, + "sum_decrease": None, + "sum_increase": None, } ] } @@ -1325,6 +1397,8 @@ def test_compile_hourly_statistics_changing_units_3( "last_reset": None, "state": None, "sum": None, + "sum_decrease": None, + "sum_increase": None, } ] } @@ -1349,6 +1423,8 @@ def test_compile_hourly_statistics_changing_units_3( "last_reset": None, "state": None, "sum": None, + "sum_decrease": None, + "sum_increase": None, } ] } @@ -1427,6 +1503,8 @@ def test_compile_hourly_statistics_changing_statistics( "last_reset": None, "state": None, "sum": None, + "sum_decrease": None, + "sum_increase": None, }, { "statistic_id": "sensor.test1", @@ -1437,6 +1515,8 @@ def test_compile_hourly_statistics_changing_statistics( "last_reset": None, "state": approx(30.0), "sum": approx(30.0), + "sum_decrease": approx(10.0), + "sum_increase": approx(40.0), }, ] } From 065e858a032f1315f702ccc19b7c23a8c5015025 Mon Sep 17 00:00:00 2001 From: cnico Date: Thu, 9 Sep 2021 09:45:58 +0200 Subject: [PATCH 307/843] Address post merge review of flipr binary sensor (#55983) --- homeassistant/components/flipr/__init__.py | 2 +- homeassistant/components/flipr/sensor.py | 2 +- tests/components/flipr/test_binary_sensor.py | 58 +++++++++++++++++++ .../flipr/{test_sensors.py => test_sensor.py} | 10 +--- 4 files changed, 61 insertions(+), 11 deletions(-) create mode 100644 tests/components/flipr/test_binary_sensor.py rename tests/components/flipr/{test_sensors.py => test_sensor.py} (89%) diff --git a/homeassistant/components/flipr/__init__.py b/homeassistant/components/flipr/__init__.py index f1320dafda1..fd7c3f5c02a 100644 --- a/homeassistant/components/flipr/__init__.py +++ b/homeassistant/components/flipr/__init__.py @@ -20,7 +20,7 @@ _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(minutes=60) -PLATFORMS = ["sensor", "binary_sensor"] +PLATFORMS = ["binary_sensor", "sensor"] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/flipr/sensor.py b/homeassistant/components/flipr/sensor.py index 6466c58fae2..e79ba131618 100644 --- a/homeassistant/components/flipr/sensor.py +++ b/homeassistant/components/flipr/sensor.py @@ -51,7 +51,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): coordinator = hass.data[DOMAIN][config_entry.entry_id] sensors = [FliprSensor(coordinator, description) for description in SENSOR_TYPES] - async_add_entities(sensors, True) + async_add_entities(sensors) class FliprSensor(FliprEntity, SensorEntity): diff --git a/tests/components/flipr/test_binary_sensor.py b/tests/components/flipr/test_binary_sensor.py new file mode 100644 index 00000000000..48f9361723c --- /dev/null +++ b/tests/components/flipr/test_binary_sensor.py @@ -0,0 +1,58 @@ +"""Test the Flipr binary sensor.""" +from datetime import datetime +from unittest.mock import patch + +from homeassistant.components.flipr.const import CONF_FLIPR_ID, DOMAIN +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from tests.common import MockConfigEntry + +# Data for the mocked object returned via flipr_api client. +MOCK_DATE_TIME = datetime(2021, 2, 15, 9, 10, 32, tzinfo=dt_util.UTC) +MOCK_FLIPR_MEASURE = { + "temperature": 10.5, + "ph": 7.03, + "chlorine": 0.23654886, + "red_ox": 657.58, + "date_time": MOCK_DATE_TIME, + "ph_status": "TooLow", + "chlorine_status": "Medium", +} + + +async def test_sensors(hass: HomeAssistant) -> None: + """Test the creation and values of the Flipr binary sensors.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="test_entry_unique_id", + data={ + CONF_EMAIL: "toto@toto.com", + CONF_PASSWORD: "myPassword", + CONF_FLIPR_ID: "myfliprid", + }, + ) + + entry.add_to_hass(hass) + + registry = await hass.helpers.entity_registry.async_get_registry() + + with patch( + "flipr_api.FliprAPIRestClient.get_pool_measure_latest", + return_value=MOCK_FLIPR_MEASURE, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # Check entity unique_id value that is generated in FliprEntity base class. + entity = registry.async_get("binary_sensor.flipr_myfliprid_ph_status") + assert entity.unique_id == "myfliprid-ph_status" + + state = hass.states.get("binary_sensor.flipr_myfliprid_ph_status") + assert state + assert state.state == "on" # Alert is on for binary sensor + + state = hass.states.get("binary_sensor.flipr_myfliprid_chlorine_status") + assert state + assert state.state == "off" diff --git a/tests/components/flipr/test_sensors.py b/tests/components/flipr/test_sensor.py similarity index 89% rename from tests/components/flipr/test_sensors.py rename to tests/components/flipr/test_sensor.py index 7c4855dae0a..7fd04fbc992 100644 --- a/tests/components/flipr/test_sensors.py +++ b/tests/components/flipr/test_sensor.py @@ -1,4 +1,4 @@ -"""Test the Flipr sensor and binary sensor.""" +"""Test the Flipr sensor.""" from datetime import datetime from unittest.mock import patch @@ -84,11 +84,3 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_ICON) == "mdi:pool" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "mV" assert state.state == "0.23654886" - - state = hass.states.get("binary_sensor.flipr_myfliprid_ph_status") - assert state - assert state.state == "on" # Alert is on for binary sensor - - state = hass.states.get("binary_sensor.flipr_myfliprid_chlorine_status") - assert state - assert state.state == "off" From 9d2861afe3c7a305593dec275af868d037c24ecb Mon Sep 17 00:00:00 2001 From: jan iversen Date: Thu, 9 Sep 2021 13:14:28 +0200 Subject: [PATCH 308/843] Add mypy to elkm1. (#55964) --- homeassistant/components/elkm1/__init__.py | 8 ++++++-- mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/elkm1/__init__.py b/homeassistant/components/elkm1/__init__.py index 6a96a73de22..a392fbd302a 100644 --- a/homeassistant/components/elkm1/__init__.py +++ b/homeassistant/components/elkm1/__init__.py @@ -1,7 +1,11 @@ """Support the ElkM1 Gold and ElkM1 EZ8 alarm/integration panels.""" +from __future__ import annotations + import asyncio import logging import re +from types import MappingProxyType +from typing import Any import async_timeout import elkm1_lib as elkm1 @@ -197,7 +201,7 @@ def _async_find_matching_config_entry(hass, prefix): async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Elk-M1 Control from a config entry.""" - conf = entry.data + conf: MappingProxyType[str, Any] = entry.data _LOGGER.debug("Setting up elkm1 %s", conf["host"]) @@ -205,7 +209,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if conf[CONF_TEMPERATURE_UNIT] in (BARE_TEMP_CELSIUS, TEMP_CELSIUS): temperature_unit = TEMP_CELSIUS - config = {"temperature_unit": temperature_unit} + config: dict[str, Any] = {"temperature_unit": temperature_unit} if not conf[CONF_AUTO_CONFIGURE]: # With elkm1-lib==0.7.16 and later auto configure is available diff --git a/mypy.ini b/mypy.ini index 8967a0502d1..a43682136e8 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1315,9 +1315,6 @@ ignore_errors = true [mypy-homeassistant.components.doorbird.*] ignore_errors = true -[mypy-homeassistant.components.elkm1.*] -ignore_errors = true - [mypy-homeassistant.components.enphase_envoy.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 6a076a5a5e6..a66e880544c 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -27,7 +27,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.dhcp.*", "homeassistant.components.directv.*", "homeassistant.components.doorbird.*", - "homeassistant.components.elkm1.*", "homeassistant.components.enphase_envoy.*", "homeassistant.components.entur_public_transport.*", "homeassistant.components.evohome.*", From cbbbc3c4f07ad1c656badcc590dd58c44e914cd1 Mon Sep 17 00:00:00 2001 From: Adam Feldman Date: Thu, 9 Sep 2021 08:32:03 -0500 Subject: [PATCH 309/843] Add state class to Smart Meter Texas sensor (#55665) --- homeassistant/components/smart_meter_texas/sensor.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/smart_meter_texas/sensor.py b/homeassistant/components/smart_meter_texas/sensor.py index 6914d3ef1ac..ed5c84f0bce 100644 --- a/homeassistant/components/smart_meter_texas/sensor.py +++ b/homeassistant/components/smart_meter_texas/sensor.py @@ -1,8 +1,8 @@ """Support for Smart Meter Texas sensors.""" from smart_meter_texas import Meter -from homeassistant.components.sensor import SensorEntity -from homeassistant.const import CONF_ADDRESS, ENERGY_KILO_WATT_HOUR +from homeassistant.components.sensor import STATE_CLASS_TOTAL_INCREASING, SensorEntity +from homeassistant.const import CONF_ADDRESS, DEVICE_CLASS_ENERGY, ENERGY_KILO_WATT_HOUR from homeassistant.core import callback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.update_coordinator import ( @@ -33,6 +33,8 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class SmartMeterTexasSensor(CoordinatorEntity, RestoreEntity, SensorEntity): """Representation of an Smart Meter Texas sensor.""" + _attr_device_class = DEVICE_CLASS_ENERGY + _attr_state_class = STATE_CLASS_TOTAL_INCREASING _attr_native_unit_of_measurement = ENERGY_KILO_WATT_HOUR def __init__(self, meter: Meter, coordinator: DataUpdateCoordinator) -> None: From 011817b1224a8c13668b3afce784d56b1032274e Mon Sep 17 00:00:00 2001 From: joshs85 Date: Thu, 9 Sep 2021 09:32:32 -0400 Subject: [PATCH 310/843] Add state belief services to bond integration (#54735) Co-authored-by: J. Nick Koston --- CODEOWNERS | 2 +- homeassistant/components/bond/const.py | 6 + homeassistant/components/bond/fan.py | 49 +++- homeassistant/components/bond/light.py | 86 +++++- homeassistant/components/bond/manifest.json | 4 +- homeassistant/components/bond/services.yaml | 95 +++++++ homeassistant/components/bond/switch.py | 24 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/bond/common.py | 12 + tests/components/bond/test_fan.py | 64 +++++ tests/components/bond/test_light.py | 296 +++++++++++++++++++- tests/components/bond/test_switch.py | 46 +++ 13 files changed, 678 insertions(+), 10 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 35572933213..db4a5b04069 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -73,7 +73,7 @@ homeassistant/components/blink/* @fronzbot homeassistant/components/blueprint/* @home-assistant/core homeassistant/components/bmp280/* @belidzs homeassistant/components/bmw_connected_drive/* @gerard33 @rikroe -homeassistant/components/bond/* @prystupa +homeassistant/components/bond/* @prystupa @joshs85 homeassistant/components/bosch_shc/* @tschamm homeassistant/components/braviatv/* @bieniu @Drafteed homeassistant/components/broadlink/* @danielhiversen @felipediel diff --git a/homeassistant/components/bond/const.py b/homeassistant/components/bond/const.py index 818288a5764..bf1af003e96 100644 --- a/homeassistant/components/bond/const.py +++ b/homeassistant/components/bond/const.py @@ -10,3 +10,9 @@ CONF_BOND_ID: str = "bond_id" HUB = "hub" BPUP_SUBS = "bpup_subs" BPUP_STOP = "bpup_stop" + +SERVICE_SET_FAN_SPEED_BELIEF = "set_fan_speed_belief" +SERVICE_SET_POWER_BELIEF = "set_switch_power_belief" +SERVICE_SET_LIGHT_POWER_BELIEF = "set_light_power_belief" +SERVICE_SET_LIGHT_BRIGHTNESS_BELIEF = "set_light_brightness_belief" +ATTR_POWER_STATE = "power_state" diff --git a/homeassistant/components/bond/fan.py b/homeassistant/components/bond/fan.py index 92ce0b81658..a5e10cd371a 100644 --- a/homeassistant/components/bond/fan.py +++ b/homeassistant/components/bond/fan.py @@ -5,9 +5,12 @@ import logging import math from typing import Any +from aiohttp.client_exceptions import ClientResponseError from bond_api import Action, BPUPSubscriptions, DeviceType, Direction +import voluptuous as vol from homeassistant.components.fan import ( + ATTR_SPEED, DIRECTION_FORWARD, DIRECTION_REVERSE, SUPPORT_DIRECTION, @@ -16,6 +19,8 @@ from homeassistant.components.fan import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_platform from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( @@ -24,7 +29,7 @@ from homeassistant.util.percentage import ( ranged_value_to_percentage, ) -from .const import BPUP_SUBS, DOMAIN, HUB +from .const import BPUP_SUBS, DOMAIN, HUB, SERVICE_SET_FAN_SPEED_BELIEF from .entity import BondEntity from .utils import BondDevice, BondHub @@ -40,6 +45,7 @@ async def async_setup_entry( data = hass.data[DOMAIN][entry.entry_id] hub: BondHub = data[HUB] bpup_subs: BPUPSubscriptions = data[BPUP_SUBS] + platform = entity_platform.async_get_current_platform() fans: list[Entity] = [ BondFan(hub, device, bpup_subs) @@ -47,6 +53,12 @@ async def async_setup_entry( if DeviceType.is_fan(device.type) ] + platform.async_register_entity_service( + SERVICE_SET_FAN_SPEED_BELIEF, + {vol.Required(ATTR_SPEED): vol.All(vol.Number(scale=0), vol.Range(0, 100))}, + "async_set_speed_belief", + ) + async_add_entities(fans, True) @@ -128,6 +140,41 @@ class BondFan(BondEntity, FanEntity): self._device.device_id, Action.set_speed(bond_speed) ) + async def async_set_power_belief(self, power_state: bool) -> None: + """Set the believed state to on or off.""" + try: + await self._hub.bond.action( + self._device.device_id, Action.set_power_state_belief(power_state) + ) + except ClientResponseError as ex: + raise HomeAssistantError( + f"The bond API returned an error calling set_power_state_belief for {self.entity_id}. Code: {ex.code} Message: {ex.message}" + ) from ex + + async def async_set_speed_belief(self, speed: int) -> None: + """Set the believed speed for the fan.""" + _LOGGER.debug("async_set_speed_belief called with percentage %s", speed) + if speed == 0: + await self.async_set_power_belief(False) + return + + await self.async_set_power_belief(True) + + bond_speed = math.ceil(percentage_to_ranged_value(self._speed_range, speed)) + _LOGGER.debug( + "async_set_percentage converted percentage %s to bond speed %s", + speed, + bond_speed, + ) + try: + await self._hub.bond.action( + self._device.device_id, Action.set_speed_belief(bond_speed) + ) + except ClientResponseError as ex: + raise HomeAssistantError( + f"The bond API returned an error calling set_speed_belief for {self.entity_id}. Code: {ex.code} Message: {ex.message}" + ) from ex + async def async_turn_on( self, speed: str | None = None, diff --git a/homeassistant/components/bond/light.py b/homeassistant/components/bond/light.py index 9fe33e8e99e..c47147a5648 100644 --- a/homeassistant/components/bond/light.py +++ b/homeassistant/components/bond/light.py @@ -4,7 +4,9 @@ from __future__ import annotations import logging from typing import Any +from aiohttp.client_exceptions import ClientResponseError from bond_api import Action, BPUPSubscriptions, DeviceType +import voluptuous as vol from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -14,12 +16,19 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_platform +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import BondHub -from .const import BPUP_SUBS, DOMAIN, HUB +from .const import ( + ATTR_POWER_STATE, + BPUP_SUBS, + DOMAIN, + HUB, + SERVICE_SET_LIGHT_BRIGHTNESS_BELIEF, + SERVICE_SET_LIGHT_POWER_BELIEF, +) from .entity import BondEntity from .utils import BondDevice @@ -45,6 +54,7 @@ async def async_setup_entry( data = hass.data[DOMAIN][entry.entry_id] hub: BondHub = data[HUB] bpup_subs: BPUPSubscriptions = data[BPUP_SUBS] + platform = entity_platform.async_get_current_platform() platform = entity_platform.async_get_current_platform() for service in ENTITY_SERVICES: @@ -92,6 +102,22 @@ async def async_setup_entry( if DeviceType.is_light(device.type) ] + platform.async_register_entity_service( + SERVICE_SET_LIGHT_BRIGHTNESS_BELIEF, + { + vol.Required(ATTR_BRIGHTNESS): vol.All( + vol.Number(scale=0), vol.Range(0, 255) + ) + }, + "async_set_brightness_belief", + ) + + platform.async_register_entity_service( + SERVICE_SET_LIGHT_POWER_BELIEF, + {vol.Required(ATTR_POWER_STATE): vol.All(cv.boolean)}, + "async_set_power_belief", + ) + async_add_entities( fan_lights + fan_up_lights + fan_down_lights + fireplaces + fp_lights + lights, True, @@ -103,6 +129,34 @@ class BondBaseLight(BondEntity, LightEntity): _attr_supported_features = 0 + async def async_set_brightness_belief(self, brightness: int) -> None: + """Set the belief state of the light.""" + if not self._device.supports_set_brightness(): + raise HomeAssistantError("This device does not support setting brightness") + if brightness == 0: + await self.async_set_power_belief(False) + return + try: + await self._hub.bond.action( + self._device.device_id, + Action.set_brightness_belief(round((brightness * 100) / 255)), + ) + except ClientResponseError as ex: + raise HomeAssistantError( + f"The bond API returned an error calling set_brightness_belief for {self.entity_id}. Code: {ex.code} Message: {ex.message}" + ) from ex + + async def async_set_power_belief(self, power_state: bool) -> None: + """Set the belief state of the light.""" + try: + await self._hub.bond.action( + self._device.device_id, Action.set_light_state_belief(power_state) + ) + except ClientResponseError as ex: + raise HomeAssistantError( + f"The bond API returned an error calling set_light_state_belief for {self.entity_id}. Code: {ex.code} Message: {ex.message}" + ) from ex + class BondLight(BondBaseLight, BondEntity, LightEntity): """Representation of a Bond light.""" @@ -231,3 +285,31 @@ class BondFireplace(BondEntity, LightEntity): _LOGGER.debug("Fireplace async_turn_off called with: %s", kwargs) await self._hub.bond.action(self._device.device_id, Action.turn_off()) + + async def async_set_brightness_belief(self, brightness: int) -> None: + """Set the belief state of the light.""" + if not self._device.supports_set_brightness(): + raise HomeAssistantError("This device does not support setting brightness") + if brightness == 0: + await self.async_set_power_belief(False) + return + try: + await self._hub.bond.action( + self._device.device_id, + Action.set_brightness_belief(round((brightness * 100) / 255)), + ) + except ClientResponseError as ex: + raise HomeAssistantError( + f"The bond API returned an error calling set_brightness_belief for {self.entity_id}. Code: {ex.code} Message: {ex.message}" + ) from ex + + async def async_set_power_belief(self, power_state: bool) -> None: + """Set the belief state of the light.""" + try: + await self._hub.bond.action( + self._device.device_id, Action.set_power_state_belief(power_state) + ) + except ClientResponseError as ex: + raise HomeAssistantError( + f"The bond API returned an error calling set_power_state_belief for {self.entity_id}. Code: {ex.code} Message: {ex.message}" + ) from ex diff --git a/homeassistant/components/bond/manifest.json b/homeassistant/components/bond/manifest.json index 3995ecf5024..7d1486b2e8f 100644 --- a/homeassistant/components/bond/manifest.json +++ b/homeassistant/components/bond/manifest.json @@ -3,9 +3,9 @@ "name": "Bond", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/bond", - "requirements": ["bond-api==0.1.12"], + "requirements": ["bond-api==0.1.13"], "zeroconf": ["_bond._tcp.local."], - "codeowners": ["@prystupa"], + "codeowners": ["@prystupa", "@joshs85"], "quality_scale": "platinum", "iot_class": "local_push" } diff --git a/homeassistant/components/bond/services.yaml b/homeassistant/components/bond/services.yaml index 1cb24c5ed71..32a3e882739 100644 --- a/homeassistant/components/bond/services.yaml +++ b/homeassistant/components/bond/services.yaml @@ -1,3 +1,97 @@ +# Describes the format for available bond services + +set_fan_speed_belief: + name: Set believed fan speed + description: Sets the believed fan speed for a bond fan + fields: + entity_id: + description: Name(s) of entities to set the believed fan speed. + example: "fan.living_room_fan" + name: Entity + required: true + selector: + entity: + integration: bond + domain: fan + speed: + required: true + name: Fan Speed + description: Fan Speed as %. + example: 50 + selector: + number: + min: 0 + max: 100 + step: 1 + mode: slider + +set_switch_power_belief: + name: Set believed switch power state + description: Sets the believed power state of a bond switch + fields: + entity_id: + description: Name(s) of entities to set the believed power state of. + example: "switch.whatever" + name: Entity + required: true + selector: + entity: + integration: bond + domain: switch + power_state: + required: true + name: Power state + description: Power state + example: true + selector: + boolean: + +set_light_power_belief: + name: Set believed light power state + description: Sets the believed light power state of a bond light + fields: + entity_id: + description: Name(s) of entities to set the believed power state of. + example: "light.living_room_lights" + name: Entity + required: true + selector: + entity: + integration: bond + domain: light + power_state: + required: true + name: Power state + description: Power state + example: true + selector: + boolean: + +set_light_brightness_belief: + name: Set believed light brightness state + description: Sets the believed light brightness state of a bond light + fields: + entity_id: + description: Name(s) of entities to set the believed power state of. + example: "light.living_room_lights" + name: Entity + required: true + selector: + entity: + integration: bond + domain: light + brightness: + required: true + name: Brightness + description: Brightness + example: 50 + selector: + number: + min: 0 + max: 255 + step: 1 + mode: slider + start_increasing_brightness: name: Start increasing brightness description: "Start increasing the brightness of the light." @@ -21,3 +115,4 @@ stop: entity: integration: bond domain: light + diff --git a/homeassistant/components/bond/switch.py b/homeassistant/components/bond/switch.py index 0bb58946f0f..b493ac07945 100644 --- a/homeassistant/components/bond/switch.py +++ b/homeassistant/components/bond/switch.py @@ -3,15 +3,19 @@ from __future__ import annotations from typing import Any +from aiohttp.client_exceptions import ClientResponseError from bond_api import Action, BPUPSubscriptions, DeviceType +import voluptuous as vol from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import BPUP_SUBS, DOMAIN, HUB +from .const import ATTR_POWER_STATE, BPUP_SUBS, DOMAIN, HUB, SERVICE_SET_POWER_BELIEF from .entity import BondEntity from .utils import BondHub @@ -25,6 +29,7 @@ async def async_setup_entry( data = hass.data[DOMAIN][entry.entry_id] hub: BondHub = data[HUB] bpup_subs: BPUPSubscriptions = data[BPUP_SUBS] + platform = entity_platform.async_get_current_platform() switches: list[Entity] = [ BondSwitch(hub, device, bpup_subs) @@ -32,6 +37,12 @@ async def async_setup_entry( if DeviceType.is_generic(device.type) ] + platform.async_register_entity_service( + SERVICE_SET_POWER_BELIEF, + {vol.Required(ATTR_POWER_STATE): cv.boolean}, + "async_set_power_belief", + ) + async_add_entities(switches, True) @@ -48,3 +59,14 @@ class BondSwitch(BondEntity, SwitchEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" await self._hub.bond.action(self._device.device_id, Action.turn_off()) + + async def async_set_power_belief(self, power_state: bool) -> None: + """Set switch power belief.""" + try: + await self._hub.bond.action( + self._device.device_id, Action.set_power_state_belief(power_state) + ) + except ClientResponseError as ex: + raise HomeAssistantError( + f"The bond API returned an error calling set_power_state_belief for {self.entity_id}. Code: {ex.code} Message: {ex.message}" + ) from ex diff --git a/requirements_all.txt b/requirements_all.txt index 4d8230c8bee..788698ccf66 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -406,7 +406,7 @@ blockchain==1.4.4 # bme680==1.0.5 # homeassistant.components.bond -bond-api==0.1.12 +bond-api==0.1.13 # homeassistant.components.bosch_shc boschshcpy==0.2.19 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 47598fde7f8..bc673eb2d33 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -239,7 +239,7 @@ blebox_uniapi==1.3.3 blinkpy==0.17.0 # homeassistant.components.bond -bond-api==0.1.12 +bond-api==0.1.13 # homeassistant.components.bosch_shc boschshcpy==0.2.19 diff --git a/tests/components/bond/common.py b/tests/components/bond/common.py index 0791d002fed..0400b466e34 100644 --- a/tests/components/bond/common.py +++ b/tests/components/bond/common.py @@ -7,6 +7,8 @@ from datetime import timedelta from typing import Any from unittest.mock import MagicMock, patch +from aiohttp.client_exceptions import ClientResponseError + from homeassistant import core from homeassistant.components.bond.const import DOMAIN as BOND_DOMAIN from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, STATE_UNAVAILABLE @@ -184,6 +186,16 @@ def patch_bond_action(): return patch("homeassistant.components.bond.Bond.action") +def patch_bond_action_returns_clientresponseerror(): + """Patch Bond API action endpoint to throw ClientResponseError.""" + return patch( + "homeassistant.components.bond.Bond.action", + side_effect=ClientResponseError( + request_info=None, history=None, code=405, message="Method Not Allowed" + ), + ) + + def patch_bond_device_properties(return_value=None): """Patch Bond API device properties endpoint.""" if return_value is None: diff --git a/tests/components/bond/test_fan.py b/tests/components/bond/test_fan.py index bd5994f5182..e975f586ff4 100644 --- a/tests/components/bond/test_fan.py +++ b/tests/components/bond/test_fan.py @@ -4,9 +4,14 @@ from __future__ import annotations from datetime import timedelta from bond_api import Action, DeviceType, Direction +import pytest from homeassistant import core from homeassistant.components import fan +from homeassistant.components.bond.const import ( + DOMAIN as BOND_DOMAIN, + SERVICE_SET_FAN_SPEED_BELIEF, +) from homeassistant.components.fan import ( ATTR_DIRECTION, ATTR_SPEED, @@ -19,6 +24,7 @@ from homeassistant.components.fan import ( SPEED_OFF, ) from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.util import utcnow @@ -26,6 +32,7 @@ from homeassistant.util import utcnow from .common import ( help_test_entity_available, patch_bond_action, + patch_bond_action_returns_clientresponseerror, patch_bond_device_state, setup_platform, ) @@ -254,6 +261,63 @@ async def test_turn_off_fan(hass: core.HomeAssistant): mock_turn_off.assert_called_once_with("test-device-id", Action.turn_off()) +async def test_set_speed_belief_speed_zero(hass: core.HomeAssistant): + """Tests that set power belief service delegates to API.""" + await setup_platform( + hass, FAN_DOMAIN, ceiling_fan("name-1"), bond_device_id="test-device-id" + ) + + with patch_bond_action() as mock_action, patch_bond_device_state(): + await hass.services.async_call( + BOND_DOMAIN, + SERVICE_SET_FAN_SPEED_BELIEF, + {ATTR_ENTITY_ID: "fan.name_1", ATTR_SPEED: 0}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_action.assert_called_once_with( + "test-device-id", Action.set_power_state_belief(False) + ) + + +async def test_set_speed_belief_speed_api_error(hass: core.HomeAssistant): + """Tests that set power belief service delegates to API.""" + await setup_platform( + hass, FAN_DOMAIN, ceiling_fan("name-1"), bond_device_id="test-device-id" + ) + + with pytest.raises( + HomeAssistantError + ), patch_bond_action_returns_clientresponseerror(), patch_bond_device_state(): + await hass.services.async_call( + BOND_DOMAIN, + SERVICE_SET_FAN_SPEED_BELIEF, + {ATTR_ENTITY_ID: "fan.name_1", ATTR_SPEED: 100}, + blocking=True, + ) + await hass.async_block_till_done() + + +async def test_set_speed_belief_speed_100(hass: core.HomeAssistant): + """Tests that set power belief service delegates to API.""" + await setup_platform( + hass, FAN_DOMAIN, ceiling_fan("name-1"), bond_device_id="test-device-id" + ) + + with patch_bond_action() as mock_action, patch_bond_device_state(): + await hass.services.async_call( + BOND_DOMAIN, + SERVICE_SET_FAN_SPEED_BELIEF, + {ATTR_ENTITY_ID: "fan.name_1", ATTR_SPEED: 100}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_action.assert_any_call("test-device-id", Action.set_power_state_belief(True)) + mock_action.assert_called_with("test-device-id", Action.set_speed_belief(3)) + + async def test_update_reports_fan_on(hass: core.HomeAssistant): """Tests that update command sets correct state when Bond API reports fan power is on.""" await setup_platform(hass, FAN_DOMAIN, ceiling_fan("name-1")) diff --git a/tests/components/bond/test_light.py b/tests/components/bond/test_light.py index 545feee21a5..3b846f3d996 100644 --- a/tests/components/bond/test_light.py +++ b/tests/components/bond/test_light.py @@ -5,7 +5,12 @@ from bond_api import Action, DeviceType import pytest from homeassistant import core -from homeassistant.components.bond.const import DOMAIN +from homeassistant.components.bond.const import ( + ATTR_POWER_STATE, + DOMAIN, + SERVICE_SET_LIGHT_BRIGHTNESS_BELIEF, + SERVICE_SET_LIGHT_POWER_BELIEF, +) from homeassistant.components.bond.light import ( SERVICE_START_DECREASING_BRIGHTNESS, SERVICE_START_INCREASING_BRIGHTNESS, @@ -31,6 +36,7 @@ from homeassistant.util import utcnow from .common import ( help_test_entity_available, patch_bond_action, + patch_bond_action_returns_clientresponseerror, patch_bond_device_state, setup_platform, ) @@ -47,6 +53,15 @@ def light(name: str): } +def light_no_brightness(name: str): + """Create a light with a given name.""" + return { + "name": name, + "type": DeviceType.LIGHT, + "actions": [Action.TURN_LIGHT_ON, Action.TURN_LIGHT_OFF], + } + + def ceiling_fan(name: str): """Create a ceiling fan (that has built-in light) with given name.""" return { @@ -106,6 +121,21 @@ def fireplace_with_light(name: str): } +def fireplace_with_light_supports_brightness(name: str): + """Create a fireplace with given name.""" + return { + "name": name, + "type": DeviceType.FIREPLACE, + "actions": [ + Action.TURN_ON, + Action.TURN_OFF, + Action.TURN_LIGHT_ON, + Action.TURN_LIGHT_OFF, + Action.SET_BRIGHTNESS, + ], + } + + def light_brightness_increase_decrease_only(name: str): """Create a light that can only increase or decrease brightness.""" return { @@ -254,6 +284,270 @@ async def test_no_trust_state(hass: core.HomeAssistant): assert device.attributes.get(ATTR_ASSUMED_STATE) is not True +async def test_light_set_brightness_belief_full(hass: core.HomeAssistant): + """Tests that the set brightness belief function of a light delegates to API.""" + await setup_platform( + hass, + LIGHT_DOMAIN, + light("name-1"), + bond_device_id="test-device-id", + ) + + with patch_bond_action() as mock_bond_action, patch_bond_device_state(): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_LIGHT_BRIGHTNESS_BELIEF, + {ATTR_ENTITY_ID: "light.name_1", ATTR_BRIGHTNESS: 255}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_bond_action.assert_called_once_with( + "test-device-id", Action.set_brightness_belief(brightness=100) + ) + + +async def test_light_set_brightness_belief_api_error(hass: core.HomeAssistant): + """Tests that the set brightness belief throws HomeAssistantError in the event of an api error.""" + await setup_platform( + hass, + LIGHT_DOMAIN, + light("name-1"), + bond_device_id="test-device-id", + ) + + with pytest.raises( + HomeAssistantError + ), patch_bond_action_returns_clientresponseerror(), patch_bond_device_state(): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_LIGHT_BRIGHTNESS_BELIEF, + {ATTR_ENTITY_ID: "light.name_1", ATTR_BRIGHTNESS: 255}, + blocking=True, + ) + await hass.async_block_till_done() + + +async def test_fp_light_set_brightness_belief_full(hass: core.HomeAssistant): + """Tests that the set brightness belief function of a light delegates to API.""" + await setup_platform( + hass, + LIGHT_DOMAIN, + fireplace_with_light_supports_brightness("name-1"), + bond_device_id="test-device-id", + ) + + with patch_bond_action() as mock_bond_action, patch_bond_device_state(): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_LIGHT_BRIGHTNESS_BELIEF, + {ATTR_ENTITY_ID: "light.name_1", ATTR_BRIGHTNESS: 255}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_bond_action.assert_called_once_with( + "test-device-id", Action.set_brightness_belief(brightness=100) + ) + + +async def test_fp_light_set_brightness_belief_api_error(hass: core.HomeAssistant): + """Tests that the set brightness belief throws HomeAssistantError in the event of an api error.""" + await setup_platform( + hass, + LIGHT_DOMAIN, + fireplace_with_light_supports_brightness("name-1"), + bond_device_id="test-device-id", + ) + + with pytest.raises( + HomeAssistantError + ), patch_bond_action_returns_clientresponseerror(), patch_bond_device_state(): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_LIGHT_BRIGHTNESS_BELIEF, + {ATTR_ENTITY_ID: "light.name_1", ATTR_BRIGHTNESS: 255}, + blocking=True, + ) + await hass.async_block_till_done() + + +async def test_light_set_brightness_belief_brightnes_not_supported( + hass: core.HomeAssistant, +): + """Tests that the set brightness belief function of a light that doesn't support setting brightness returns an error.""" + await setup_platform( + hass, + LIGHT_DOMAIN, + light_no_brightness("name-1"), + bond_device_id="test-device-id", + ) + + with pytest.raises(HomeAssistantError), patch_bond_device_state(): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_LIGHT_BRIGHTNESS_BELIEF, + {ATTR_ENTITY_ID: "light.name_1", ATTR_BRIGHTNESS: 255}, + blocking=True, + ) + await hass.async_block_till_done() + + +async def test_light_set_brightness_belief_zero(hass: core.HomeAssistant): + """Tests that the set brightness belief function of a light delegates to API.""" + await setup_platform( + hass, + LIGHT_DOMAIN, + light("name-1"), + bond_device_id="test-device-id", + ) + + with patch_bond_action() as mock_bond_action, patch_bond_device_state(): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_LIGHT_BRIGHTNESS_BELIEF, + {ATTR_ENTITY_ID: "light.name_1", ATTR_BRIGHTNESS: 0}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_bond_action.assert_called_once_with( + "test-device-id", Action.set_light_state_belief(False) + ) + + +async def test_fp_light_set_brightness_belief_zero(hass: core.HomeAssistant): + """Tests that the set brightness belief function of a light delegates to API.""" + await setup_platform( + hass, + LIGHT_DOMAIN, + fireplace_with_light_supports_brightness("name-1"), + bond_device_id="test-device-id", + ) + + with patch_bond_action() as mock_bond_action, patch_bond_device_state(): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_LIGHT_BRIGHTNESS_BELIEF, + {ATTR_ENTITY_ID: "light.name_1", ATTR_BRIGHTNESS: 0}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_bond_action.assert_called_once_with( + "test-device-id", Action.set_power_state_belief(False) + ) + + +async def test_light_set_power_belief(hass: core.HomeAssistant): + """Tests that the set brightness belief function of a light delegates to API.""" + await setup_platform( + hass, + LIGHT_DOMAIN, + light("name-1"), + bond_device_id="test-device-id", + ) + + with patch_bond_action() as mock_bond_action, patch_bond_device_state(): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_LIGHT_POWER_BELIEF, + {ATTR_ENTITY_ID: "light.name_1", ATTR_POWER_STATE: False}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_bond_action.assert_called_once_with( + "test-device-id", Action.set_light_state_belief(False) + ) + + +async def test_light_set_power_belief_api_error(hass: core.HomeAssistant): + """Tests that the set brightness belief function of a light throws HomeAssistantError in the event of an api error.""" + await setup_platform( + hass, + LIGHT_DOMAIN, + light("name-1"), + bond_device_id="test-device-id", + ) + + with pytest.raises( + HomeAssistantError + ), patch_bond_action_returns_clientresponseerror(), patch_bond_device_state(): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_LIGHT_POWER_BELIEF, + {ATTR_ENTITY_ID: "light.name_1", ATTR_POWER_STATE: False}, + blocking=True, + ) + await hass.async_block_till_done() + + +async def test_fp_light_set_power_belief(hass: core.HomeAssistant): + """Tests that the set brightness belief function of a light delegates to API.""" + await setup_platform( + hass, + LIGHT_DOMAIN, + fireplace_with_light("name-1"), + bond_device_id="test-device-id", + ) + + with patch_bond_action() as mock_bond_action, patch_bond_device_state(): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_LIGHT_POWER_BELIEF, + {ATTR_ENTITY_ID: "light.name_1", ATTR_POWER_STATE: False}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_bond_action.assert_called_once_with( + "test-device-id", Action.set_power_state_belief(False) + ) + + +async def test_fp_light_set_power_belief_api_error(hass: core.HomeAssistant): + """Tests that the set brightness belief function of a light throws HomeAssistantError in the event of an api error.""" + await setup_platform( + hass, + LIGHT_DOMAIN, + fireplace_with_light("name-1"), + bond_device_id="test-device-id", + ) + + with pytest.raises( + HomeAssistantError + ), patch_bond_action_returns_clientresponseerror(), patch_bond_device_state(): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_LIGHT_POWER_BELIEF, + {ATTR_ENTITY_ID: "light.name_1", ATTR_POWER_STATE: False}, + blocking=True, + ) + await hass.async_block_till_done() + + +async def test_fp_light_set_brightness_belief_brightnes_not_supported( + hass: core.HomeAssistant, +): + """Tests that the set brightness belief function of a fireplace light that doesn't support setting brightness returns an error.""" + await setup_platform( + hass, + LIGHT_DOMAIN, + fireplace_with_light("name-1"), + bond_device_id="test-device-id", + ) + + with pytest.raises(HomeAssistantError), patch_bond_device_state(): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_LIGHT_BRIGHTNESS_BELIEF, + {ATTR_ENTITY_ID: "light.name_1", ATTR_BRIGHTNESS: 255}, + blocking=True, + ) + await hass.async_block_till_done() + + async def test_light_start_increasing_brightness(hass: core.HomeAssistant): """Tests a light that can only increase or decrease brightness delegates to API can start increasing brightness.""" await setup_platform( diff --git a/tests/components/bond/test_switch.py b/tests/components/bond/test_switch.py index 94a9179d3a7..f2ed6e9c3b5 100644 --- a/tests/components/bond/test_switch.py +++ b/tests/components/bond/test_switch.py @@ -2,10 +2,17 @@ from datetime import timedelta from bond_api import Action, DeviceType +import pytest from homeassistant import core +from homeassistant.components.bond.const import ( + ATTR_POWER_STATE, + DOMAIN as BOND_DOMAIN, + SERVICE_SET_POWER_BELIEF, +) from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.util import utcnow @@ -13,6 +20,7 @@ from homeassistant.util import utcnow from .common import ( help_test_entity_available, patch_bond_action, + patch_bond_action_returns_clientresponseerror, patch_bond_device_state, setup_platform, ) @@ -76,6 +84,44 @@ async def test_turn_off_switch(hass: core.HomeAssistant): mock_turn_off.assert_called_once_with("test-device-id", Action.turn_off()) +async def test_switch_set_power_belief(hass: core.HomeAssistant): + """Tests that the set power belief service delegates to API.""" + await setup_platform( + hass, SWITCH_DOMAIN, generic_device("name-1"), bond_device_id="test-device-id" + ) + + with patch_bond_action() as mock_bond_action, patch_bond_device_state(): + await hass.services.async_call( + BOND_DOMAIN, + SERVICE_SET_POWER_BELIEF, + {ATTR_ENTITY_ID: "switch.name_1", ATTR_POWER_STATE: False}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_bond_action.assert_called_once_with( + "test-device-id", Action.set_power_state_belief(False) + ) + + +async def test_switch_set_power_belief_api_error(hass: core.HomeAssistant): + """Tests that the set power belief service throws HomeAssistantError in the event of an api error.""" + await setup_platform( + hass, SWITCH_DOMAIN, generic_device("name-1"), bond_device_id="test-device-id" + ) + + with pytest.raises( + HomeAssistantError + ), patch_bond_action_returns_clientresponseerror(), patch_bond_device_state(): + await hass.services.async_call( + BOND_DOMAIN, + SERVICE_SET_POWER_BELIEF, + {ATTR_ENTITY_ID: "switch.name_1", ATTR_POWER_STATE: False}, + blocking=True, + ) + await hass.async_block_till_done() + + async def test_update_reports_switch_is_on(hass: core.HomeAssistant): """Tests that update command sets correct state when Bond API reports the device is on.""" await setup_platform(hass, SWITCH_DOMAIN, generic_device("name-1")) From 556dcf6abbb244adcb68317715a9a66048da2eac Mon Sep 17 00:00:00 2001 From: Jean-Yves Avenard Date: Thu, 9 Sep 2021 23:32:43 +1000 Subject: [PATCH 311/843] Add iotawatt high-accuracy energy readout sensors (#55512) --- CODEOWNERS | 2 +- homeassistant/components/iotawatt/const.py | 2 + .../components/iotawatt/coordinator.py | 15 +- .../components/iotawatt/manifest.json | 5 +- homeassistant/components/iotawatt/sensor.py | 118 +++++++++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/iotawatt/__init__.py | 37 +++- tests/components/iotawatt/test_sensor.py | 185 +++++++++++++++++- 9 files changed, 334 insertions(+), 34 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index db4a5b04069..54ce1818ce4 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -248,7 +248,7 @@ homeassistant/components/integration/* @dgomes homeassistant/components/intent/* @home-assistant/core homeassistant/components/intesishome/* @jnimmo homeassistant/components/ios/* @robbiet480 -homeassistant/components/iotawatt/* @gtdiehl +homeassistant/components/iotawatt/* @gtdiehl @jyavenard homeassistant/components/iperf3/* @rohankapoorcom homeassistant/components/ipma/* @dgomes @abmantis homeassistant/components/ipp/* @ctalkington diff --git a/homeassistant/components/iotawatt/const.py b/homeassistant/components/iotawatt/const.py index db847f3dfe8..0b80e108238 100644 --- a/homeassistant/components/iotawatt/const.py +++ b/homeassistant/components/iotawatt/const.py @@ -9,4 +9,6 @@ DOMAIN = "iotawatt" VOLT_AMPERE_REACTIVE = "VAR" VOLT_AMPERE_REACTIVE_HOURS = "VARh" +ATTR_LAST_UPDATE = "last_update" + CONNECTION_ERRORS = (KeyError, json.JSONDecodeError, httpx.HTTPError) diff --git a/homeassistant/components/iotawatt/coordinator.py b/homeassistant/components/iotawatt/coordinator.py index 1a722d52a1e..ada9c9fb346 100644 --- a/homeassistant/components/iotawatt/coordinator.py +++ b/homeassistant/components/iotawatt/coordinator.py @@ -1,7 +1,7 @@ """IoTaWatt DataUpdateCoordinator.""" from __future__ import annotations -from datetime import timedelta +from datetime import datetime, timedelta import logging from iotawattpy.iotawatt import Iotawatt @@ -32,6 +32,16 @@ class IotawattUpdater(DataUpdateCoordinator): update_interval=timedelta(seconds=30), ) + self._last_run: datetime | None = None + + def update_last_run(self, last_run: datetime) -> None: + """Notify coordinator of a sensor last update time.""" + # We want to fetch the data from the iotawatt since HA was last shutdown. + # We retrieve from the sensor last updated. + # This method is called from each sensor upon their state being restored. + if self._last_run is None or last_run > self._last_run: + self._last_run = last_run + async def _async_update_data(self): """Fetch sensors from IoTaWatt device.""" if self.api is None: @@ -52,5 +62,6 @@ class IotawattUpdater(DataUpdateCoordinator): self.api = api - await self.api.update() + await self.api.update(lastUpdate=self._last_run) + self._last_run = None return self.api.getSensors() diff --git a/homeassistant/components/iotawatt/manifest.json b/homeassistant/components/iotawatt/manifest.json index d78e546d71f..42e1e074c8e 100644 --- a/homeassistant/components/iotawatt/manifest.json +++ b/homeassistant/components/iotawatt/manifest.json @@ -4,10 +4,11 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/iotawatt", "requirements": [ - "iotawattpy==0.0.8" + "iotawattpy==0.1.0" ], "codeowners": [ - "@gtdiehl" + "@gtdiehl", + "@jyavenard" ], "iot_class": "local_polling" } \ No newline at end of file diff --git a/homeassistant/components/iotawatt/sensor.py b/homeassistant/components/iotawatt/sensor.py index 1b4c166eb27..c52f8cb9189 100644 --- a/homeassistant/components/iotawatt/sensor.py +++ b/homeassistant/components/iotawatt/sensor.py @@ -2,12 +2,14 @@ from __future__ import annotations from dataclasses import dataclass +import logging from typing import Callable from iotawattpy.sensor import Sensor from homeassistant.components.sensor import ( STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntity, SensorEntityDescription, ) @@ -28,10 +30,19 @@ from homeassistant.const import ( from homeassistant.core import callback from homeassistant.helpers import entity, entity_registry, update_coordinator from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.util import dt -from .const import DOMAIN, VOLT_AMPERE_REACTIVE, VOLT_AMPERE_REACTIVE_HOURS +from .const import ( + ATTR_LAST_UPDATE, + DOMAIN, + VOLT_AMPERE_REACTIVE, + VOLT_AMPERE_REACTIVE_HOURS, +) from .coordinator import IotawattUpdater +_LOGGER = logging.getLogger(__name__) + @dataclass class IotaWattSensorEntityDescription(SensorEntityDescription): @@ -114,15 +125,19 @@ async def async_setup_entry(hass, config_entry, async_add_entities): def _create_entity(key: str) -> IotaWattSensor: """Create a sensor entity.""" created.add(key) + data = coordinator.data["sensors"][key] + description = ENTITY_DESCRIPTION_KEY_MAP.get( + data.getUnit(), IotaWattSensorEntityDescription("base_sensor") + ) + if data.getUnit() == "WattHours" and not data.getFromStart(): + return IotaWattAccumulatingSensor( + coordinator=coordinator, key=key, entity_description=description + ) + return IotaWattSensor( coordinator=coordinator, key=key, - mac_address=coordinator.data["sensors"][key].hub_mac_address, - name=coordinator.data["sensors"][key].getName(), - entity_description=ENTITY_DESCRIPTION_KEY_MAP.get( - coordinator.data["sensors"][key].getUnit(), - IotaWattSensorEntityDescription("base_sensor"), - ), + entity_description=description, ) async_add_entities(_create_entity(key) for key in coordinator.data["sensors"]) @@ -145,16 +160,14 @@ class IotaWattSensor(update_coordinator.CoordinatorEntity, SensorEntity): """Defines a IoTaWatt Energy Sensor.""" entity_description: IotaWattSensorEntityDescription - _attr_force_update = True + coordinator: IotawattUpdater def __init__( self, - coordinator, - key, - mac_address, - name, + coordinator: IotawattUpdater, + key: str, entity_description: IotaWattSensorEntityDescription, - ): + ) -> None: """Initialize the sensor.""" super().__init__(coordinator=coordinator) @@ -196,17 +209,15 @@ class IotaWattSensor(update_coordinator.CoordinatorEntity, SensorEntity): else: self.hass.async_create_task(self.async_remove()) return - super()._handle_coordinator_update() @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, str]: """Return the extra state attributes of the entity.""" data = self._sensor_data attrs = {"type": data.getType()} if attrs["type"] == "Input": attrs["channel"] = data.getChannel() - return attrs @property @@ -216,3 +227,78 @@ class IotaWattSensor(update_coordinator.CoordinatorEntity, SensorEntity): return func(self._sensor_data.getValue()) return self._sensor_data.getValue() + + +class IotaWattAccumulatingSensor(IotaWattSensor, RestoreEntity): + """Defines a IoTaWatt Accumulative Energy (High Accuracy) Sensor.""" + + def __init__( + self, + coordinator: IotawattUpdater, + key: str, + entity_description: IotaWattSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + + super().__init__(coordinator, key, entity_description) + + self._attr_state_class = STATE_CLASS_TOTAL_INCREASING + if self._attr_unique_id is not None: + self._attr_unique_id += ".accumulated" + + self._accumulated_value: float | None = None + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + assert ( + self._accumulated_value is not None + ), "async_added_to_hass must have been called first" + self._accumulated_value += float(self._sensor_data.getValue()) + + super()._handle_coordinator_update() + + @property + def native_value(self) -> entity.StateType: + """Return the state of the sensor.""" + if self._accumulated_value is None: + return None + return round(self._accumulated_value, 1) + + async def async_added_to_hass(self) -> None: + """Load the last known state value of the entity if the accumulated type.""" + await super().async_added_to_hass() + state = await self.async_get_last_state() + self._accumulated_value = 0.0 + if state: + try: + # Previous value could be `unknown` if the connection didn't originally + # complete. + self._accumulated_value = float(state.state) + except (ValueError) as err: + _LOGGER.warning("Could not restore last state: %s", err) + else: + if ATTR_LAST_UPDATE in state.attributes: + last_run = dt.parse_datetime(state.attributes[ATTR_LAST_UPDATE]) + if last_run is not None: + self.coordinator.update_last_run(last_run) + # Force a second update from the iotawatt to ensure that sensors are up to date. + await self.coordinator.async_request_refresh() + + @property + def name(self) -> str | None: + """Return name of the entity.""" + return f"{self._sensor_data.getSourceName()} Accumulated" + + @property + def extra_state_attributes(self) -> dict[str, str]: + """Return the extra state attributes of the entity.""" + attrs = super().extra_state_attributes + + assert ( + self.coordinator.api is not None + and self.coordinator.api.getLastUpdateTime() is not None + ) + attrs[ATTR_LAST_UPDATE] = self.coordinator.api.getLastUpdateTime().isoformat() + + return attrs diff --git a/requirements_all.txt b/requirements_all.txt index 788698ccf66..7bf094727be 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -865,7 +865,7 @@ influxdb-client==1.14.0 influxdb==5.2.3 # homeassistant.components.iotawatt -iotawattpy==0.0.8 +iotawattpy==0.1.0 # homeassistant.components.iperf3 iperf3==0.1.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bc673eb2d33..62e13269a31 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -505,7 +505,7 @@ influxdb-client==1.14.0 influxdb==5.2.3 # homeassistant.components.iotawatt -iotawattpy==0.0.8 +iotawattpy==0.1.0 # homeassistant.components.gogogate2 ismartgate==4.0.0 diff --git a/tests/components/iotawatt/__init__.py b/tests/components/iotawatt/__init__.py index 3d1afe1b88b..07ea6dfc15c 100644 --- a/tests/components/iotawatt/__init__.py +++ b/tests/components/iotawatt/__init__.py @@ -3,19 +3,46 @@ from iotawattpy.sensor import Sensor INPUT_SENSOR = Sensor( channel="1", - name="My Sensor", + base_name="My Sensor", + suffix=None, io_type="Input", - unit="WattHours", - value="23", + unit="Watts", + value=23, begin="", mac_addr="mock-mac", ) OUTPUT_SENSOR = Sensor( channel="N/A", - name="My WattHour Sensor", + base_name="My WattHour Sensor", + suffix=None, io_type="Output", unit="WattHours", - value="243", + value=243, begin="", mac_addr="mock-mac", + fromStart=True, +) + +INPUT_ACCUMULATED_SENSOR = Sensor( + channel="N/A", + base_name="My WattHour Accumulated Input Sensor", + suffix=".wh", + io_type="Input", + unit="WattHours", + value=500, + begin="", + mac_addr="mock-mac", + fromStart=False, +) + +OUTPUT_ACCUMULATED_SENSOR = Sensor( + channel="N/A", + base_name="My WattHour Accumulated Output Sensor", + suffix=".wh", + io_type="Output", + unit="WattHours", + value=200, + begin="", + mac_addr="mock-mac", + fromStart=False, ) diff --git a/tests/components/iotawatt/test_sensor.py b/tests/components/iotawatt/test_sensor.py index a5fc2250b84..8928c012d48 100644 --- a/tests/components/iotawatt/test_sensor.py +++ b/tests/components/iotawatt/test_sensor.py @@ -1,19 +1,33 @@ """Test setting up sensors.""" from datetime import timedelta -from homeassistant.components.sensor import ATTR_STATE_CLASS, DEVICE_CLASS_ENERGY +from homeassistant.components.iotawatt.const import ATTR_LAST_UPDATE +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + DEVICE_CLASS_ENERGY, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, +) from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT, + DEVICE_CLASS_POWER, ENERGY_WATT_HOUR, + POWER_WATT, ) +from homeassistant.core import State from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow -from . import INPUT_SENSOR, OUTPUT_SENSOR +from . import ( + INPUT_ACCUMULATED_SENSOR, + INPUT_SENSOR, + OUTPUT_ACCUMULATED_SENSOR, + OUTPUT_SENSOR, +) -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, mock_restore_cache async def test_sensor_type_input(hass, mock_iotawatt): @@ -33,10 +47,10 @@ async def test_sensor_type_input(hass, mock_iotawatt): state = hass.states.get("sensor.my_sensor") assert state is not None assert state.state == "23" - assert ATTR_STATE_CLASS not in state.attributes + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_MEASUREMENT assert state.attributes[ATTR_FRIENDLY_NAME] == "My Sensor" - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == ENERGY_WATT_HOUR - assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_ENERGY + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == POWER_WATT + assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_POWER assert state.attributes["channel"] == "1" assert state.attributes["type"] == "Input" @@ -60,6 +74,7 @@ async def test_sensor_type_output(hass, mock_iotawatt): state = hass.states.get("sensor.my_watthour_sensor") assert state is not None assert state.state == "243" + assert ATTR_STATE_CLASS not in state.attributes assert state.attributes[ATTR_FRIENDLY_NAME] == "My WattHour Sensor" assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == ENERGY_WATT_HOUR assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_ENERGY @@ -70,3 +85,161 @@ async def test_sensor_type_output(hass, mock_iotawatt): await hass.async_block_till_done() assert hass.states.get("sensor.my_watthour_sensor") is None + + +async def test_sensor_type_accumulated_output(hass, mock_iotawatt): + """Tests the sensor type of Accumulated Output and that it's properly restored from saved state.""" + mock_iotawatt.getSensors.return_value["sensors"][ + "my_watthour_accumulated_output_sensor_key" + ] = OUTPUT_ACCUMULATED_SENSOR + + DUMMY_DATE = "2021-09-01T14:00:00+10:00" + + mock_restore_cache( + hass, + ( + State( + "sensor.my_watthour_accumulated_output_sensor_wh_accumulated", + "100.0", + { + "device_class": DEVICE_CLASS_ENERGY, + "unit_of_measurement": ENERGY_WATT_HOUR, + "last_update": DUMMY_DATE, + }, + ), + ), + ) + + assert await async_setup_component(hass, "iotawatt", {}) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids()) == 1 + + state = hass.states.get( + "sensor.my_watthour_accumulated_output_sensor_wh_accumulated" + ) + assert state is not None + + assert state.state == "300.0" # 100 + 200 + assert ( + state.attributes[ATTR_FRIENDLY_NAME] + == "My WattHour Accumulated Output Sensor.wh Accumulated" + ) + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == ENERGY_WATT_HOUR + assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_ENERGY + assert state.attributes["type"] == "Output" + assert state.attributes[ATTR_LAST_UPDATE] is not None + assert state.attributes[ATTR_LAST_UPDATE] != DUMMY_DATE + + +async def test_sensor_type_accumulated_output_error_restore(hass, mock_iotawatt): + """Tests the sensor type of Accumulated Output and that it's properly restored from saved state.""" + mock_iotawatt.getSensors.return_value["sensors"][ + "my_watthour_accumulated_output_sensor_key" + ] = OUTPUT_ACCUMULATED_SENSOR + + DUMMY_DATE = "2021-09-01T14:00:00+10:00" + + mock_restore_cache( + hass, + ( + State( + "sensor.my_watthour_accumulated_output_sensor_wh_accumulated", + "unknown", + ), + ), + ) + + assert await async_setup_component(hass, "iotawatt", {}) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids()) == 1 + + state = hass.states.get( + "sensor.my_watthour_accumulated_output_sensor_wh_accumulated" + ) + assert state is not None + + assert state.state == "200.0" # Returns the new read as restore failed. + assert ( + state.attributes[ATTR_FRIENDLY_NAME] + == "My WattHour Accumulated Output Sensor.wh Accumulated" + ) + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == ENERGY_WATT_HOUR + assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_ENERGY + assert state.attributes["type"] == "Output" + assert state.attributes[ATTR_LAST_UPDATE] is not None + assert state.attributes[ATTR_LAST_UPDATE] != DUMMY_DATE + + +async def test_sensor_type_multiple_accumulated_output(hass, mock_iotawatt): + """Tests the sensor type of Accumulated Output and that it's properly restored from saved state.""" + mock_iotawatt.getSensors.return_value["sensors"][ + "my_watthour_accumulated_output_sensor_key" + ] = OUTPUT_ACCUMULATED_SENSOR + mock_iotawatt.getSensors.return_value["sensors"][ + "my_watthour_accumulated_input_sensor_key" + ] = INPUT_ACCUMULATED_SENSOR + + DUMMY_DATE = "2021-09-01T14:00:00+10:00" + + mock_restore_cache( + hass, + ( + State( + "sensor.my_watthour_accumulated_output_sensor_wh_accumulated", + "100.0", + { + "device_class": DEVICE_CLASS_ENERGY, + "unit_of_measurement": ENERGY_WATT_HOUR, + "last_update": DUMMY_DATE, + }, + ), + State( + "sensor.my_watthour_accumulated_input_sensor_wh_accumulated", + "50.0", + { + "device_class": DEVICE_CLASS_ENERGY, + "unit_of_measurement": ENERGY_WATT_HOUR, + "last_update": DUMMY_DATE, + }, + ), + ), + ) + + assert await async_setup_component(hass, "iotawatt", {}) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids()) == 2 + + state = hass.states.get( + "sensor.my_watthour_accumulated_output_sensor_wh_accumulated" + ) + assert state is not None + + assert state.state == "300.0" # 100 + 200 + assert ( + state.attributes[ATTR_FRIENDLY_NAME] + == "My WattHour Accumulated Output Sensor.wh Accumulated" + ) + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == ENERGY_WATT_HOUR + assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_ENERGY + assert state.attributes["type"] == "Output" + assert state.attributes[ATTR_LAST_UPDATE] is not None + assert state.attributes[ATTR_LAST_UPDATE] != DUMMY_DATE + + state = hass.states.get( + "sensor.my_watthour_accumulated_input_sensor_wh_accumulated" + ) + assert state is not None + + assert state.state == "550.0" # 50 + 500 + assert ( + state.attributes[ATTR_FRIENDLY_NAME] + == "My WattHour Accumulated Input Sensor.wh Accumulated" + ) + assert state.attributes[ATTR_LAST_UPDATE] is not None + assert state.attributes[ATTR_LAST_UPDATE] != DUMMY_DATE From dd9bfe7aa04cf3751779a2c7d27538618349e70f Mon Sep 17 00:00:00 2001 From: uvjustin <46082645+uvjustin@users.noreply.github.com> Date: Thu, 9 Sep 2021 21:33:09 +0800 Subject: [PATCH 312/843] Add package constraint anyio>=3.3.1 (#55997) --- homeassistant/package_constraints.txt | 4 ++++ script/gen_requirements_all.py | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 454e85daeaa..f88b9778bb0 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -76,3 +76,7 @@ pandas==1.3.0 # https://bitbucket.org/mrabarnett/mrab-regex/issues/421/2021827-results-in-fatal-python-error # This is fixed in 2021.8.28 regex==2021.8.28 + +# anyio has a bug that was fixed in 3.3.1 +# can remove after httpx/httpcore updates its anyio version pin +anyio>=3.3.1 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 6581c89bc63..939806d379e 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -102,6 +102,10 @@ pandas==1.3.0 # https://bitbucket.org/mrabarnett/mrab-regex/issues/421/2021827-results-in-fatal-python-error # This is fixed in 2021.8.28 regex==2021.8.28 + +# anyio has a bug that was fixed in 3.3.1 +# can remove after httpx/httpcore updates its anyio version pin +anyio>=3.3.1 """ IGNORE_PRE_COMMIT_HOOK_ID = ( From a47532c69b0343e4797b40cf75aa8f69268635c8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 9 Sep 2021 17:24:20 +0200 Subject: [PATCH 313/843] Change character set of statistics_meta table to utf8 (#56011) --- homeassistant/components/recorder/migration.py | 11 +++++++++++ homeassistant/components/recorder/models.py | 1 + 2 files changed, 12 insertions(+) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index c694aa678f0..24220a27eee 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -507,6 +507,17 @@ def _apply_update(engine, session, new_version, old_version): # noqa: C901 "statistics", ["sum_increase DOUBLE PRECISION"], ) + # Try to change the character set of the statistic_meta table + if engine.dialect.name == "mysql": + try: + connection.execute( + text( + "ALTER TABLE statistics_meta CONVERT TO " + "CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + ) + ) + except SQLAlchemyError: + pass else: raise ValueError(f"No schema migration defined for version {new_version}") diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py index 11c614c9ea1..0a4362ba68c 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -238,6 +238,7 @@ class Statistics(Base): # type: ignore __table_args__ = ( # Used for fetching statistics for a certain entity at a specific time Index("ix_statistics_statistic_id_start", "metadata_id", "start"), + {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, ) __tablename__ = TABLE_STATISTICS id = Column(Integer, primary_key=True) From 1b0e014783214dcbd64dfbdffba943efed4782e3 Mon Sep 17 00:00:00 2001 From: Oscar Calvo <2091582+ocalvo@users.noreply.github.com> Date: Thu, 9 Sep 2021 09:29:11 -0700 Subject: [PATCH 314/843] Support incoming SMS messages via polling (#54237) * Add support to incomming SMS via polling * Update dependencies * Only send notification for unread messages * Only inform if callback is not getting used * Update gateway.py * Apply PR feedback * Update homeassistant/components/sms/gateway.py Co-authored-by: Martin Hjelmare * Apply PR comments * Make black happy Co-authored-by: Martin Hjelmare --- homeassistant/components/sms/const.py | 1 + homeassistant/components/sms/gateway.py | 71 ++++++++++++++-------- homeassistant/components/sms/manifest.json | 2 +- requirements_all.txt | 2 +- 4 files changed, 50 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/sms/const.py b/homeassistant/components/sms/const.py index b73e7954fc1..ab2c15a0c49 100644 --- a/homeassistant/components/sms/const.py +++ b/homeassistant/components/sms/const.py @@ -2,3 +2,4 @@ DOMAIN = "sms" SMS_GATEWAY = "SMS_GATEWAY" +SMS_STATE_UNREAD = "UnRead" diff --git a/homeassistant/components/sms/gateway.py b/homeassistant/components/sms/gateway.py index 5003f7019ca..3034580d5e0 100644 --- a/homeassistant/components/sms/gateway.py +++ b/homeassistant/components/sms/gateway.py @@ -6,7 +6,7 @@ from gammu.asyncworker import GammuAsyncWorker # pylint: disable=import-error from homeassistant.core import callback -from .const import DOMAIN +from .const import DOMAIN, SMS_STATE_UNREAD _LOGGER = logging.getLogger(__name__) @@ -14,24 +14,39 @@ _LOGGER = logging.getLogger(__name__) class Gateway: """SMS gateway to interact with a GSM modem.""" - def __init__(self, worker, hass): + def __init__(self, config, hass): """Initialize the sms gateway.""" - self._worker = worker + self._worker = GammuAsyncWorker(self.sms_pull) + self._worker.configure(config) self._hass = hass + self._first_pull = True async def init_async(self): """Initialize the sms gateway asynchronously.""" + await self._worker.init_async() try: await self._worker.set_incoming_sms_async() except gammu.ERR_NOTSUPPORTED: - _LOGGER.warning("Your phone does not support incoming SMS notifications!") + _LOGGER.warning("Falling back to pulling method for SMS notifications") except gammu.GSMError: _LOGGER.warning( - "GSM error, your phone does not support incoming SMS notifications!" + "GSM error, falling back to pulling method for SMS notifications" ) else: await self._worker.set_incoming_callback_async(self.sms_callback) + def sms_pull(self, state_machine): + """Pull device. + + @param state_machine: state machine + @type state_machine: gammu.StateMachine + """ + state_machine.ReadDevice() + + _LOGGER.debug("Pulling modem") + self.sms_read_messages(state_machine, self._first_pull) + self._first_pull = False + def sms_callback(self, state_machine, callback_type, callback_data): """Receive notification about incoming event. @@ -45,7 +60,15 @@ class Gateway: _LOGGER.debug( "Received incoming event type:%s,data:%s", callback_type, callback_data ) - entries = self.get_and_delete_all_sms(state_machine) + self.sms_read_messages(state_machine) + + def sms_read_messages(self, state_machine, force=False): + """Read all received SMS messages. + + @param state_machine: state machine which invoked action + @type state_machine: gammu.StateMachine + """ + entries = self.get_and_delete_all_sms(state_machine, force) _LOGGER.debug("SMS entries:%s", entries) data = [] @@ -53,22 +76,25 @@ class Gateway: decoded_entry = gammu.DecodeSMS(entry) message = entry[0] _LOGGER.debug("Processing sms:%s,decoded:%s", message, decoded_entry) - if decoded_entry is None: - text = message["Text"] - else: - text = "" - for inner_entry in decoded_entry["Entries"]: - if inner_entry["Buffer"] is not None: - text = text + inner_entry["Buffer"] + sms_state = message["State"] + _LOGGER.debug("SMS state:%s", sms_state) + if sms_state == SMS_STATE_UNREAD: + if decoded_entry is None: + text = message["Text"] + else: + text = "" + for inner_entry in decoded_entry["Entries"]: + if inner_entry["Buffer"] is not None: + text += inner_entry["Buffer"] - event_data = { - "phone": message["Number"], - "date": str(message["DateTime"]), - "message": text, - } + event_data = { + "phone": message["Number"], + "date": str(message["DateTime"]), + "message": text, + } - _LOGGER.debug("Append event data:%s", event_data) - data.append(event_data) + _LOGGER.debug("Append event data:%s", event_data) + data.append(event_data) self._hass.add_job(self._notify_incoming_sms, data) @@ -161,10 +187,7 @@ class Gateway: async def create_sms_gateway(config, hass): """Create the sms gateway.""" try: - worker = GammuAsyncWorker() - worker.configure(config) - await worker.init_async() - gateway = Gateway(worker, hass) + gateway = Gateway(config, hass) await gateway.init_async() return gateway except gammu.GSMError as exc: diff --git a/homeassistant/components/sms/manifest.json b/homeassistant/components/sms/manifest.json index 9a466236758..6d736ac44e7 100644 --- a/homeassistant/components/sms/manifest.json +++ b/homeassistant/components/sms/manifest.json @@ -3,7 +3,7 @@ "name": "SMS notifications via GSM-modem", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sms", - "requirements": ["python-gammu==3.1"], + "requirements": ["python-gammu==3.2.3"], "codeowners": ["@ocalvo"], "iot_class": "local_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index 7bf094727be..818981947c5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1856,7 +1856,7 @@ python-family-hub-local==0.0.2 python-forecastio==1.4.0 # homeassistant.components.sms -# python-gammu==3.1 +# python-gammu==3.2.3 # homeassistant.components.gc100 python-gc100==1.0.3a0 From 88dbc6373f8d1608944785a2327fa0ef41c43e54 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 9 Sep 2021 19:26:28 +0200 Subject: [PATCH 315/843] Make sure character set of events, states tables is utf8 (#56012) * Make sure character set of events, states tables is utf8 * Pylint * Apply suggestions from code review --- homeassistant/components/recorder/migration.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 24220a27eee..74d95bb6c9c 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -1,4 +1,5 @@ """Schema migration helpers.""" +import contextlib from datetime import timedelta import logging @@ -509,15 +510,14 @@ def _apply_update(engine, session, new_version, old_version): # noqa: C901 ) # Try to change the character set of the statistic_meta table if engine.dialect.name == "mysql": - try: - connection.execute( - text( - "ALTER TABLE statistics_meta CONVERT TO " - "CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + for table in ("events", "states", "statistics_meta"): + with contextlib.suppress(SQLAlchemyError): + connection.execute( + text( + f"ALTER TABLE {table} CONVERT TO " + "CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + ) ) - ) - except SQLAlchemyError: - pass else: raise ValueError(f"No schema migration defined for version {new_version}") From 113288cb1f7ec83bd3088db03c55cc65e16c5d91 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Thu, 9 Sep 2021 14:04:27 -0400 Subject: [PATCH 316/843] Fix zwave_js/node_state WS API command (#55979) * Fix zwave_js/node_state WS API command * Add negative assertion check to avoid regression * Update tests/components/zwave_js/test_api.py Co-authored-by: jan iversen * use constant Co-authored-by: jan iversen --- homeassistant/components/zwave_js/api.py | 2 +- tests/components/zwave_js/common.py | 2 ++ tests/components/zwave_js/test_api.py | 39 ++++++++++++++++++++++-- 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 6cd0ea4fe44..549f1b1b950 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -307,7 +307,7 @@ async def websocket_node_state( """Get the state data of a Z-Wave JS node.""" connection.send_result( msg[ID], - node.data, + {**node.data, "values": [value.data for value in node.values.values()]}, ) diff --git a/tests/components/zwave_js/common.py b/tests/components/zwave_js/common.py index 2590149c462..e8e3151134c 100644 --- a/tests/components/zwave_js/common.py +++ b/tests/components/zwave_js/common.py @@ -33,3 +33,5 @@ ID_LOCK_CONFIG_PARAMETER_SENSOR = ( ZEN_31_ENTITY = "light.kitchen_under_cabinet_lights" METER_ENERGY_SENSOR = "sensor.smart_switch_6_electric_consumed_kwh" METER_VOLTAGE_SENSOR = "sensor.smart_switch_6_electric_consumed_v" + +PROPERTY_ULTRAVIOLET = "Ultraviolet" diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index b3bb924413d..29c0ce4bba4 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -3,7 +3,7 @@ import json from unittest.mock import patch import pytest -from zwave_js_server.const import InclusionStrategy, LogLevel +from zwave_js_server.const import CommandClass, InclusionStrategy, LogLevel from zwave_js_server.event import Event from zwave_js_server.exceptions import ( FailedCommand, @@ -12,6 +12,7 @@ from zwave_js_server.exceptions import ( NotFoundError, SetValueFailed, ) +from zwave_js_server.model.value import _get_value_id_from_dict, get_value_id from homeassistant.components.websocket_api.const import ERR_NOT_FOUND from homeassistant.components.zwave_js.api import ( @@ -39,6 +40,8 @@ from homeassistant.components.zwave_js.const import ( ) from homeassistant.helpers import device_registry as dr +from .common import PROPERTY_ULTRAVIOLET + async def test_network_status(hass, integration, hass_ws_client): """Test the network status websocket command.""" @@ -127,6 +130,28 @@ async def test_node_state(hass, multisensor_6, integration, hass_ws_client): ws_client = await hass_ws_client(hass) node = multisensor_6 + + # Update a value and ensure it is reflected in the node state + value_id = get_value_id(node, CommandClass.SENSOR_MULTILEVEL, PROPERTY_ULTRAVIOLET) + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Multilevel Sensor", + "commandClass": 49, + "endpoint": 0, + "property": PROPERTY_ULTRAVIOLET, + "newValue": 1, + "prevValue": 0, + "propertyName": PROPERTY_ULTRAVIOLET, + }, + }, + ) + node.receive_event(event) + await ws_client.send_json( { ID: 3, @@ -136,7 +161,17 @@ async def test_node_state(hass, multisensor_6, integration, hass_ws_client): } ) msg = await ws_client.receive_json() - assert msg["result"] == node.data + + # Assert that the data returned doesn't match the stale node state data + assert msg["result"] != node.data + + # Replace data for the value we updated and assert the new node data is the same + # as what's returned + updated_node_data = node.data.copy() + for n, value in enumerate(updated_node_data["values"]): + if _get_value_id_from_dict(node, value) == value_id: + updated_node_data["values"][n] = node.values[value_id].data.copy() + assert msg["result"] == updated_node_data # Test getting non-existent node fails await ws_client.send_json( From f79de2a5bc42ae333fa1996eddcbfadedc8b89a8 Mon Sep 17 00:00:00 2001 From: popoviciri Date: Thu, 9 Sep 2021 20:19:49 +0200 Subject: [PATCH 317/843] Bump pysma to 0.6.6 & Fix Unit Checks (#56018) --- homeassistant/components/sma/manifest.json | 2 +- homeassistant/components/sma/sensor.py | 4 ++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/sma/manifest.json b/homeassistant/components/sma/manifest.json index a462d0c854b..da24627d268 100644 --- a/homeassistant/components/sma/manifest.json +++ b/homeassistant/components/sma/manifest.json @@ -3,7 +3,7 @@ "name": "SMA Solar", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sma", - "requirements": ["pysma==0.6.5"], + "requirements": ["pysma==0.6.6"], "codeowners": ["@kellerza", "@rklomp"], "iot_class": "local_polling" } diff --git a/homeassistant/components/sma/sensor.py b/homeassistant/components/sma/sensor.py index f0a10a5d5e1..922ec9f9212 100644 --- a/homeassistant/components/sma/sensor.py +++ b/homeassistant/components/sma/sensor.py @@ -166,10 +166,10 @@ class SMAsensor(CoordinatorEntity, SensorEntity): self._config_entry_unique_id = config_entry_unique_id self._device_info = device_info - if self.unit_of_measurement == ENERGY_KILO_WATT_HOUR: + if self.native_unit_of_measurement == ENERGY_KILO_WATT_HOUR: self._attr_state_class = STATE_CLASS_TOTAL_INCREASING self._attr_device_class = DEVICE_CLASS_ENERGY - if self.unit_of_measurement == POWER_WATT: + if self.native_unit_of_measurement == POWER_WATT: self._attr_state_class = STATE_CLASS_MEASUREMENT self._attr_device_class = DEVICE_CLASS_POWER diff --git a/requirements_all.txt b/requirements_all.txt index 818981947c5..73e21ea4ec9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1781,7 +1781,7 @@ pysignalclirestapi==0.3.4 pyskyqhub==0.1.3 # homeassistant.components.sma -pysma==0.6.5 +pysma==0.6.6 # homeassistant.components.smappee pysmappee==0.2.27 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 62e13269a31..a661f772dcf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1031,7 +1031,7 @@ pysiaalarm==3.0.0 pysignalclirestapi==0.3.4 # homeassistant.components.sma -pysma==0.6.5 +pysma==0.6.6 # homeassistant.components.smappee pysmappee==0.2.27 From 3af4b2639b709d57e73a4be2df54807c052dc9c9 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 9 Sep 2021 21:24:23 +0200 Subject: [PATCH 318/843] Exclude @overload from coverage (#56021) --- .coveragerc | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.coveragerc b/.coveragerc index 28b62776d8e..72249c7684a 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1276,5 +1276,6 @@ exclude_lines = raise AssertionError raise NotImplementedError - # TYPE_CHECKING block is never executed during pytest run + # TYPE_CHECKING and @overload blocks are never executed during pytest run if TYPE_CHECKING: + @overload From 1fd3faf76638b60efab57d05fa93ca6162369647 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Thu, 9 Sep 2021 16:19:28 -0400 Subject: [PATCH 319/843] Fix state class for zwave_js energy entities (#56026) --- homeassistant/components/zwave_js/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 6532da8a5e0..61fae8ac834 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -105,7 +105,7 @@ ENTITY_DESCRIPTION_KEY_MAP: dict[str, ZwaveSensorEntityDescription] = { state_class=STATE_CLASS_MEASUREMENT, ), ENTITY_DESC_KEY_ENERGY_MEASUREMENT: ZwaveSensorEntityDescription( - ENTITY_DESC_KEY_ENERGY_TOTAL_INCREASING, + ENTITY_DESC_KEY_ENERGY_MEASUREMENT, device_class=DEVICE_CLASS_ENERGY, state_class=STATE_CLASS_MEASUREMENT, ), From 2a8121bdcd36ca4e5e795246ee6517233de39f8f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 9 Sep 2021 22:55:51 +0200 Subject: [PATCH 320/843] Really change character set of statistics_meta table to utf8 (#56029) --- homeassistant/components/recorder/models.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py index 0a4362ba68c..e33f2e62da2 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -238,7 +238,6 @@ class Statistics(Base): # type: ignore __table_args__ = ( # Used for fetching statistics for a certain entity at a specific time Index("ix_statistics_statistic_id_start", "metadata_id", "start"), - {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, ) __tablename__ = TABLE_STATISTICS id = Column(Integer, primary_key=True) @@ -279,6 +278,9 @@ class StatisticMetaData(TypedDict, total=False): class StatisticsMeta(Base): # type: ignore """Statistics meta data.""" + __table_args__ = ( + {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, + ) __tablename__ = TABLE_STATISTICS_META id = Column(Integer, primary_key=True) statistic_id = Column(String(255), index=True) From 57096404534e34e7943f5cbf7edfd4516f48f342 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Sep 2021 05:39:22 +0200 Subject: [PATCH 321/843] Report integrations that block startup wrap up (#56003) --- homeassistant/bootstrap.py | 16 ++++++++-------- tests/test_bootstrap.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 66312f7283a..3877a6bf6e1 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -564,6 +564,14 @@ async def _async_set_up_integrations( except asyncio.TimeoutError: _LOGGER.warning("Setup timed out for stage 2 - moving forward") + # Wrap up startup + _LOGGER.debug("Waiting for startup to wrap up") + try: + async with hass.timeout.async_timeout(WRAP_UP_TIMEOUT, cool_down=COOLDOWN_TIME): + await hass.async_block_till_done() + except asyncio.TimeoutError: + _LOGGER.warning("Setup timed out for bootstrap - moving forward") + watch_task.cancel() async_dispatcher_send(hass, SIGNAL_BOOTSTRAP_INTEGRATONS, {}) @@ -576,11 +584,3 @@ async def _async_set_up_integrations( ) }, ) - - # Wrap up startup - _LOGGER.debug("Waiting for startup to wrap up") - try: - async with hass.timeout.async_timeout(WRAP_UP_TIMEOUT, cool_down=COOLDOWN_TIME): - await hass.async_block_till_done() - except asyncio.TimeoutError: - _LOGGER.warning("Setup timed out for bootstrap - moving forward") diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 3eeb06d056c..1ad64e58bd7 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -678,6 +678,7 @@ async def test_empty_integrations_list_is_only_sent_at_the_end_of_bootstrap(hass await bootstrap._async_set_up_integrations( hass, {"normal_integration": {}, "an_after_dep": {}} ) + await hass.async_block_till_done() assert integrations[0] != {} assert "an_after_dep" in integrations[0] @@ -686,3 +687,35 @@ async def test_empty_integrations_list_is_only_sent_at_the_end_of_bootstrap(hass assert "normal_integration" in hass.config.components assert order == ["an_after_dep", "normal_integration"] + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_warning_logged_on_wrap_up_timeout(hass, caplog): + """Test we log a warning on bootstrap timeout.""" + + def gen_domain_setup(domain): + async def async_setup(hass, config): + await asyncio.sleep(0.1) + + async def _background_task(): + await asyncio.sleep(0.2) + + await hass.async_create_task(_background_task()) + return True + + return async_setup + + mock_integration( + hass, + MockModule( + domain="normal_integration", + async_setup=gen_domain_setup("normal_integration"), + partial_manifest={}, + ), + ) + + with patch.object(bootstrap, "WRAP_UP_TIMEOUT", 0): + await bootstrap._async_set_up_integrations(hass, {"normal_integration": {}}) + await hass.async_block_till_done() + + assert "Setup timed out for bootstrap - moving forward" in caplog.text From e3a7a253ea7eebcc442d852addb563adfd4eded3 Mon Sep 17 00:00:00 2001 From: wranglatang <30660751+wranglatang@users.noreply.github.com> Date: Fri, 10 Sep 2021 05:02:06 +0100 Subject: [PATCH 322/843] Add nut Watts datapoint (#55491) Co-authored-by: J. Nick Koston --- homeassistant/components/nut/const.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/homeassistant/components/nut/const.py b/homeassistant/components/nut/const.py index a180c2224f7..3861c608631 100644 --- a/homeassistant/components/nut/const.py +++ b/homeassistant/components/nut/const.py @@ -453,6 +453,13 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { device_class=DEVICE_CLASS_TEMPERATURE, state_class=STATE_CLASS_MEASUREMENT, ), + "watts": SensorEntityDescription( + key="watts", + name="Watts", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), } STATE_TYPES = { From 89281a273ca18ba89982f9f6ed6864743225d8c8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 10 Sep 2021 08:26:29 +0200 Subject: [PATCH 323/843] Correct confusing log message in sensor statistics (#56016) --- homeassistant/components/sensor/recorder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index d38ae589ef0..7e3fb5ddd9f 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -451,8 +451,8 @@ def compile_statistics( # noqa: C901 _LOGGER.info( "Detected new cycle for %s, value dropped from %s to %s", entity_id, - fstate, new_state, + fstate, ) if reset: From c27ad3078a6c34dca85d21899cbdbc1baaf8e9e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Fri, 10 Sep 2021 08:37:00 +0200 Subject: [PATCH 324/843] Surepetcare, use DataUpdateCoordinator (#55982) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Surepetcare, use dataupdater Signed-off-by: Daniel Hjelseth Høyer * Review comment Signed-off-by: Daniel Hjelseth Høyer * Apply suggestions from code review Co-authored-by: J. Nick Koston * style Co-authored-by: J. Nick Koston --- .../components/surepetcare/__init__.py | 84 +++++++------------ .../components/surepetcare/binary_sensor.py | 83 +++++++++--------- homeassistant/components/surepetcare/const.py | 5 -- .../components/surepetcare/sensor.py | 50 +++++------ 4 files changed, 93 insertions(+), 129 deletions(-) diff --git a/homeassistant/components/surepetcare/__init__.py b/homeassistant/components/surepetcare/__init__.py index 00c45701423..a6e9dca703c 100644 --- a/homeassistant/components/surepetcare/__init__.py +++ b/homeassistant/components/surepetcare/__init__.py @@ -3,9 +3,8 @@ from __future__ import annotations from datetime import timedelta import logging -from typing import Any -from surepy import Surepy +from surepy import Surepy, SurepyEntity from surepy.enums import LockState from surepy.exceptions import SurePetcareAuthenticationError, SurePetcareError import voluptuous as vol @@ -14,9 +13,8 @@ from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( ATTR_FLAP_ID, @@ -26,9 +24,7 @@ from .const import ( CONF_PETS, DOMAIN, SERVICE_SET_LOCK_STATE, - SPC, SURE_API_TIMEOUT, - TOPIC_UPDATE, ) _LOGGER = logging.getLogger(__name__) @@ -83,12 +79,23 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: _LOGGER.error("Unable to connect to surepetcare.io: Wrong %s!", error) return False - spc = SurePetcareAPI(hass, surepy) - hass.data[DOMAIN][SPC] = spc + async def _update_method() -> dict[int, SurepyEntity]: + """Get the latest data from Sure Petcare.""" + try: + return await surepy.get_entities(refresh=True) + except SurePetcareError as err: + raise UpdateFailed(f"Unable to fetch data: {err}") from err - await spc.async_update() + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name=DOMAIN, + update_method=_update_method, + update_interval=SCAN_INTERVAL, + ) - async_track_time_interval(hass, spc.async_update, SCAN_INTERVAL) + hass.data[DOMAIN] = coordinator + await coordinator.async_config_entry_first_refresh() # load platforms for platform in PLATFORMS: @@ -96,27 +103,29 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.helpers.discovery.async_load_platform(platform, DOMAIN, {}, config) ) + lock_states = { + LockState.UNLOCKED.name.lower(): surepy.sac.unlock, + LockState.LOCKED_IN.name.lower(): surepy.sac.lock_in, + LockState.LOCKED_OUT.name.lower(): surepy.sac.lock_out, + LockState.LOCKED_ALL.name.lower(): surepy.sac.lock, + } + async def handle_set_lock_state(call): """Call when setting the lock state.""" - await spc.set_lock_state(call.data[ATTR_FLAP_ID], call.data[ATTR_LOCK_STATE]) - await spc.async_update() + flap_id = call.data[ATTR_FLAP_ID] + state = call.data[ATTR_LOCK_STATE] + await lock_states[state](flap_id) + await coordinator.async_request_refresh() lock_state_service_schema = vol.Schema( { vol.Required(ATTR_FLAP_ID): vol.All( - cv.positive_int, vol.In(spc.states.keys()) + cv.positive_int, vol.In(coordinator.data.keys()) ), vol.Required(ATTR_LOCK_STATE): vol.All( cv.string, vol.Lower, - vol.In( - [ - LockState.UNLOCKED.name.lower(), - LockState.LOCKED_IN.name.lower(), - LockState.LOCKED_OUT.name.lower(), - LockState.LOCKED_ALL.name.lower(), - ] - ), + vol.In(lock_states.keys()), ), } ) @@ -129,36 +138,3 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) return True - - -class SurePetcareAPI: - """Define a generic Sure Petcare object.""" - - def __init__(self, hass: HomeAssistant, surepy: Surepy) -> None: - """Initialize the Sure Petcare object.""" - self.hass = hass - self.surepy = surepy - self.states: dict[int, Any] = {} - - async def async_update(self, _: Any = None) -> None: - """Get the latest data from Sure Petcare.""" - - try: - self.states = await self.surepy.get_entities(refresh=True) - except SurePetcareError as error: - _LOGGER.error("Unable to fetch data: %s", error) - return - - async_dispatcher_send(self.hass, TOPIC_UPDATE) - - async def set_lock_state(self, flap_id: int, state: str) -> None: - """Update the lock state of a flap.""" - - if state == LockState.UNLOCKED.name.lower(): - await self.surepy.sac.unlock(flap_id) - elif state == LockState.LOCKED_IN.name.lower(): - await self.surepy.sac.lock_in(flap_id) - elif state == LockState.LOCKED_OUT.name.lower(): - await self.surepy.sac.lock_out(flap_id) - elif state == LockState.LOCKED_ALL.name.lower(): - await self.surepy.sac.lock(flap_id) diff --git a/homeassistant/components/surepetcare/binary_sensor.py b/homeassistant/components/surepetcare/binary_sensor.py index 0f536d6135d..50a890112bc 100644 --- a/homeassistant/components/surepetcare/binary_sensor.py +++ b/homeassistant/components/surepetcare/binary_sensor.py @@ -13,10 +13,12 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, ) from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) -from . import SurePetcareAPI -from .const import DOMAIN, SPC, TOPIC_UPDATE +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -30,9 +32,9 @@ async def async_setup_platform( entities: list[SurepyEntity | Pet | Hub | DeviceConnectivity] = [] - spc: SurePetcareAPI = hass.data[DOMAIN][SPC] + coordinator: DataUpdateCoordinator = hass.data[DOMAIN] - for surepy_entity in spc.states.values(): + for surepy_entity in coordinator.data.values(): # connectivity if surepy_entity.type in [ @@ -41,32 +43,30 @@ async def async_setup_platform( EntityType.FEEDER, EntityType.FELAQUA, ]: - entities.append(DeviceConnectivity(surepy_entity.id, spc)) + entities.append(DeviceConnectivity(surepy_entity.id, coordinator)) elif surepy_entity.type == EntityType.PET: - entities.append(Pet(surepy_entity.id, spc)) + entities.append(Pet(surepy_entity.id, coordinator)) elif surepy_entity.type == EntityType.HUB: - entities.append(Hub(surepy_entity.id, spc)) + entities.append(Hub(surepy_entity.id, coordinator)) - async_add_entities(entities, True) + async_add_entities(entities) -class SurePetcareBinarySensor(BinarySensorEntity): +class SurePetcareBinarySensor(BinarySensorEntity, CoordinatorEntity): """A binary sensor implementation for Sure Petcare Entities.""" - _attr_should_poll = False - def __init__( self, _id: int, - spc: SurePetcareAPI, + coordinator: DataUpdateCoordinator, device_class: str, ) -> None: """Initialize a Sure Petcare binary sensor.""" + super().__init__(coordinator) self._id = _id - self._spc: SurePetcareAPI = spc - surepy_entity: SurepyEntity = self._spc.states[self._id] + surepy_entity: SurepyEntity = coordinator.data[self._id] # cover special case where a device has no name set if surepy_entity.name: @@ -77,31 +77,36 @@ class SurePetcareBinarySensor(BinarySensorEntity): self._attr_device_class = device_class self._attr_name = f"{surepy_entity.type.name.capitalize()} {name.capitalize()}" self._attr_unique_id = f"{surepy_entity.household_id}-{self._id}" + self._update_attr() @abstractmethod @callback - def _async_update(self) -> None: - """Get the latest data and update the state.""" + def _update_attr(self) -> None: + """Update the state and attributes.""" - async def async_added_to_hass(self) -> None: - """Register callbacks.""" - self.async_on_remove( - async_dispatcher_connect(self.hass, TOPIC_UPDATE, self._async_update) - ) - self._async_update() + @callback + def _handle_coordinator_update(self) -> None: + """Get the latest data and update the state.""" + self._update_attr() + self.async_write_ha_state() class Hub(SurePetcareBinarySensor): """Sure Petcare Hub.""" - def __init__(self, _id: int, spc: SurePetcareAPI) -> None: + def __init__(self, _id: int, coordinator: DataUpdateCoordinator) -> None: """Initialize a Sure Petcare Hub.""" - super().__init__(_id, spc, DEVICE_CLASS_CONNECTIVITY) + super().__init__(_id, coordinator, DEVICE_CLASS_CONNECTIVITY) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and bool(self._attr_is_on) @callback - def _async_update(self) -> None: + def _update_attr(self) -> None: """Get the latest data and update the state.""" - surepy_entity = self._spc.states[self._id] + surepy_entity = self.coordinator.data[self._id] state = surepy_entity.raw_data()["status"] self._attr_is_on = self._attr_available = bool(state["online"]) if surepy_entity.raw_data(): @@ -114,20 +119,19 @@ class Hub(SurePetcareBinarySensor): else: self._attr_extra_state_attributes = {} _LOGGER.debug("%s -> state: %s", self.name, state) - self.async_write_ha_state() class Pet(SurePetcareBinarySensor): """Sure Petcare Pet.""" - def __init__(self, _id: int, spc: SurePetcareAPI) -> None: + def __init__(self, _id: int, coordinator: DataUpdateCoordinator) -> None: """Initialize a Sure Petcare Pet.""" - super().__init__(_id, spc, DEVICE_CLASS_PRESENCE) + super().__init__(_id, coordinator, DEVICE_CLASS_PRESENCE) @callback - def _async_update(self) -> None: + def _update_attr(self) -> None: """Get the latest data and update the state.""" - surepy_entity = self._spc.states[self._id] + surepy_entity = self.coordinator.data[self._id] state = surepy_entity.location try: self._attr_is_on = bool(Location(state.where) == Location.INSIDE) @@ -141,7 +145,6 @@ class Pet(SurePetcareBinarySensor): else: self._attr_extra_state_attributes = {} _LOGGER.debug("%s -> state: %s", self.name, state) - self.async_write_ha_state() class DeviceConnectivity(SurePetcareBinarySensor): @@ -150,21 +153,20 @@ class DeviceConnectivity(SurePetcareBinarySensor): def __init__( self, _id: int, - spc: SurePetcareAPI, + coordinator: DataUpdateCoordinator, ) -> None: """Initialize a Sure Petcare Device.""" - super().__init__(_id, spc, DEVICE_CLASS_CONNECTIVITY) + super().__init__(_id, coordinator, DEVICE_CLASS_CONNECTIVITY) self._attr_name = f"{self.name}_connectivity" self._attr_unique_id = ( - f"{self._spc.states[self._id].household_id}-{self._id}-connectivity" + f"{self.coordinator.data[self._id].household_id}-{self._id}-connectivity" ) @callback - def _async_update(self) -> None: - """Get the latest data and update the state.""" - surepy_entity = self._spc.states[self._id] + def _update_attr(self): + surepy_entity = self.coordinator.data[self._id] state = surepy_entity.raw_data()["status"] - self._attr_is_on = self._attr_available = bool(state) + self._attr_is_on = bool(state) if state: self._attr_extra_state_attributes = { "device_rssi": f'{state["signal"]["device_rssi"]:.2f}', @@ -173,4 +175,3 @@ class DeviceConnectivity(SurePetcareBinarySensor): else: self._attr_extra_state_attributes = {} _LOGGER.debug("%s -> state: %s", self.name, state) - self.async_write_ha_state() diff --git a/homeassistant/components/surepetcare/const.py b/homeassistant/components/surepetcare/const.py index cb5a78a3c1e..6349ebe14a8 100644 --- a/homeassistant/components/surepetcare/const.py +++ b/homeassistant/components/surepetcare/const.py @@ -1,15 +1,10 @@ """Constants for the Sure Petcare component.""" DOMAIN = "surepetcare" -SPC = "spc" - CONF_FEEDERS = "feeders" CONF_FLAPS = "flaps" CONF_PETS = "pets" -# platforms -TOPIC_UPDATE = f"{DOMAIN}_data_update" - # sure petcare api SURE_API_TIMEOUT = 60 diff --git a/homeassistant/components/surepetcare/sensor.py b/homeassistant/components/surepetcare/sensor.py index 35d35e9be1f..6b07408f6b1 100644 --- a/homeassistant/components/surepetcare/sensor.py +++ b/homeassistant/components/surepetcare/sensor.py @@ -9,17 +9,13 @@ from surepy.enums import EntityType from homeassistant.components.sensor import SensorEntity from homeassistant.const import ATTR_VOLTAGE, DEVICE_CLASS_BATTERY, PERCENTAGE from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect - -from . import SurePetcareAPI -from .const import ( - DOMAIN, - SPC, - SURE_BATT_VOLTAGE_DIFF, - SURE_BATT_VOLTAGE_LOW, - TOPIC_UPDATE, +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, ) +from .const import DOMAIN, SURE_BATT_VOLTAGE_DIFF, SURE_BATT_VOLTAGE_LOW + _LOGGER = logging.getLogger(__name__) @@ -30,9 +26,9 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= entities: list[SurepyEntity] = [] - spc: SurePetcareAPI = hass.data[DOMAIN][SPC] + coordinator: DataUpdateCoordinator = hass.data[DOMAIN] - for surepy_entity in spc.states.values(): + for surepy_entity in coordinator.data.values(): if surepy_entity.type in [ EntityType.CAT_FLAP, @@ -40,23 +36,21 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= EntityType.FEEDER, EntityType.FELAQUA, ]: - entities.append(SureBattery(surepy_entity.id, spc)) + entities.append(SureBattery(surepy_entity.id, coordinator)) async_add_entities(entities) -class SureBattery(SensorEntity): +class SureBattery(SensorEntity, CoordinatorEntity): """A sensor implementation for Sure Petcare Entities.""" - _attr_should_poll = False - - def __init__(self, _id: int, spc: SurePetcareAPI) -> None: + def __init__(self, _id: int, coordinator: DataUpdateCoordinator) -> None: """Initialize a Sure Petcare sensor.""" + super().__init__(coordinator) self._id = _id - self._spc: SurePetcareAPI = spc - surepy_entity: SurepyEntity = self._spc.states[_id] + surepy_entity: SurepyEntity = coordinator.data[_id] self._attr_device_class = DEVICE_CLASS_BATTERY if surepy_entity.name: @@ -67,14 +61,20 @@ class SureBattery(SensorEntity): self._attr_unique_id = ( f"{surepy_entity.household_id}-{surepy_entity.id}-battery" ) + self._update_attr() @callback - def _async_update(self) -> None: + def _handle_coordinator_update(self) -> None: """Get the latest data and update the state.""" - surepy_entity = self._spc.states[self._id] + self._update_attr() + self.async_write_ha_state() + + @callback + def _update_attr(self) -> None: + """Update the state and attributes.""" + surepy_entity = self.coordinator.data[self._id] state = surepy_entity.raw_data()["status"] - self._attr_available = bool(state) try: per_battery_voltage = state["battery"] / 4 voltage_diff = per_battery_voltage - SURE_BATT_VOLTAGE_LOW @@ -92,12 +92,4 @@ class SureBattery(SensorEntity): } else: self._attr_extra_state_attributes = {} - self.async_write_ha_state() _LOGGER.debug("%s -> state: %s", self.name, state) - - async def async_added_to_hass(self) -> None: - """Register callbacks.""" - self.async_on_remove( - async_dispatcher_connect(self.hass, TOPIC_UPDATE, self._async_update) - ) - self._async_update() From e990ef249d781b4a201041ae72300ead8d23c44b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 10 Sep 2021 08:59:23 +0200 Subject: [PATCH 325/843] Suppress last_reset deprecation warning for energy cost sensor (#56037) --- homeassistant/components/sensor/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 3306bbb4241..530b0f39c23 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -216,6 +216,9 @@ class SensorEntity(Entity): and not self._last_reset_reported ): self._last_reset_reported = True + if self.platform and self.platform.platform_name == "energy": + return {ATTR_LAST_RESET: last_reset.isoformat()} + report_issue = self._suggest_report_issue() _LOGGER.warning( "Entity %s (%s) with state_class %s has set last_reset. Setting " From 78909b52271a40bc56234369bde8b3a4e002ccea Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 10 Sep 2021 09:05:32 +0200 Subject: [PATCH 326/843] Add support for state class total to energy cost sensor (#55955) * Add support for all state classes to energy cost sensor * Fix bug, adjust tests * Fix rebase mistake --- homeassistant/components/energy/sensor.py | 4 +- homeassistant/components/energy/validate.py | 2 + tests/components/energy/test_sensor.py | 237 +++++++++++++++++--- 3 files changed, 215 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index 5db085343bc..fadec44c1a2 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -11,6 +11,7 @@ from homeassistant.components.sensor import ( ATTR_STATE_CLASS, DEVICE_CLASS_MONETARY, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL, STATE_CLASS_TOTAL_INCREASING, SensorEntity, ) @@ -32,6 +33,7 @@ from .data import EnergyManager, async_get_manager SUPPORTED_STATE_CLASSES = [ STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL, STATE_CLASS_TOTAL_INCREASING, ] _LOGGER = logging.getLogger(__name__) @@ -214,7 +216,7 @@ class EnergyCostSensor(SensorEntity): f"{config[adapter.entity_energy_key]}_{adapter.entity_id_suffix}" ) self._attr_device_class = DEVICE_CLASS_MONETARY - self._attr_state_class = STATE_CLASS_MEASUREMENT + self._attr_state_class = STATE_CLASS_TOTAL self._config = config self._last_energy_sensor_state: State | None = None self._cur_value = 0.0 diff --git a/homeassistant/components/energy/validate.py b/homeassistant/components/energy/validate.py index 7097788aa30..9674b32df9b 100644 --- a/homeassistant/components/energy/validate.py +++ b/homeassistant/components/energy/validate.py @@ -115,6 +115,7 @@ def _async_validate_usage_stat( supported_state_classes = [ sensor.STATE_CLASS_MEASUREMENT, + sensor.STATE_CLASS_TOTAL, sensor.STATE_CLASS_TOTAL_INCREASING, ] if state_class not in supported_state_classes: @@ -206,6 +207,7 @@ def _async_validate_cost_entity( supported_state_classes = [ sensor.STATE_CLASS_MEASUREMENT, + sensor.STATE_CLASS_TOTAL, sensor.STATE_CLASS_TOTAL_INCREASING, ] if state_class not in supported_state_classes: diff --git a/tests/components/energy/test_sensor.py b/tests/components/energy/test_sensor.py index 1f0da2e45a6..db215d21c40 100644 --- a/tests/components/energy/test_sensor.py +++ b/tests/components/energy/test_sensor.py @@ -7,7 +7,9 @@ import pytest from homeassistant.components.energy import data from homeassistant.components.sensor import ( + ATTR_LAST_RESET, ATTR_STATE_CLASS, + STATE_CLASS_MEASUREMENT, STATE_CLASS_TOTAL, STATE_CLASS_TOTAL_INCREASING, ) @@ -155,8 +157,8 @@ async def test_cost_sensor_price_entity_total_increasing( assert state.state == initial_cost assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_MONETARY if initial_cost != "unknown": - assert state.attributes["last_reset"] == last_reset_cost_sensor - assert state.attributes[ATTR_STATE_CLASS] == "measurement" + assert state.attributes[ATTR_LAST_RESET] == last_reset_cost_sensor + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "EUR" # Optional late setup of dependent entities @@ -172,8 +174,8 @@ async def test_cost_sensor_price_entity_total_increasing( state = hass.states.get(cost_sensor_entity_id) assert state.state == "0.0" assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_MONETARY - assert state.attributes["last_reset"] == last_reset_cost_sensor - assert state.attributes[ATTR_STATE_CLASS] == "measurement" + assert state.attributes[ATTR_LAST_RESET] == last_reset_cost_sensor + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "EUR" # # Unique ID temp disabled @@ -190,7 +192,7 @@ async def test_cost_sensor_price_entity_total_increasing( await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) assert state.state == "10.0" # 0 EUR + (10-0) kWh * 1 EUR/kWh = 10 EUR - assert state.attributes["last_reset"] == last_reset_cost_sensor + assert state.attributes[ATTR_LAST_RESET] == last_reset_cost_sensor # Nothing happens when price changes if price_entity is not None: @@ -205,7 +207,7 @@ async def test_cost_sensor_price_entity_total_increasing( assert msg["success"] state = hass.states.get(cost_sensor_entity_id) assert state.state == "10.0" # 10 EUR + (10-10) kWh * 2 EUR/kWh = 10 EUR - assert state.attributes["last_reset"] == last_reset_cost_sensor + assert state.attributes[ATTR_LAST_RESET] == last_reset_cost_sensor # Additional consumption is using the new price hass.states.async_set( @@ -216,7 +218,7 @@ async def test_cost_sensor_price_entity_total_increasing( await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) assert state.state == "19.0" # 10 EUR + (14.5-10) kWh * 2 EUR/kWh = 19 EUR - assert state.attributes["last_reset"] == last_reset_cost_sensor + assert state.attributes[ATTR_LAST_RESET] == last_reset_cost_sensor # Check generated statistics await async_wait_recording_done_without_instance(hass) @@ -233,7 +235,7 @@ async def test_cost_sensor_price_entity_total_increasing( await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) assert state.state == "18.0" # 19 EUR + (14-14.5) kWh * 2 EUR/kWh = 18 EUR - assert state.attributes["last_reset"] == last_reset_cost_sensor + assert state.attributes[ATTR_LAST_RESET] == last_reset_cost_sensor # Energy sensor is reset, with initial state at 4kWh, 0 kWh is used as zero-point hass.states.async_set( @@ -244,8 +246,8 @@ async def test_cost_sensor_price_entity_total_increasing( await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) assert state.state == "8.0" # 0 EUR + (4-0) kWh * 2 EUR/kWh = 8 EUR - assert state.attributes["last_reset"] != last_reset_cost_sensor - last_reset_cost_sensor = state.attributes["last_reset"] + assert state.attributes[ATTR_LAST_RESET] != last_reset_cost_sensor + last_reset_cost_sensor = state.attributes[ATTR_LAST_RESET] # Energy use bumped to 10 kWh hass.states.async_set( @@ -256,7 +258,7 @@ async def test_cost_sensor_price_entity_total_increasing( await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) assert state.state == "20.0" # 8 EUR + (10-4) kWh * 2 EUR/kWh = 20 EUR - assert state.attributes["last_reset"] == last_reset_cost_sensor + assert state.attributes[ATTR_LAST_RESET] == last_reset_cost_sensor # Check generated statistics await async_wait_recording_done_without_instance(hass) @@ -280,7 +282,7 @@ async def test_cost_sensor_price_entity_total_increasing( ), ], ) -@pytest.mark.parametrize("energy_state_class", ["measurement"]) +@pytest.mark.parametrize("energy_state_class", ["total", "measurement"]) async def test_cost_sensor_price_entity_total( hass, hass_storage, @@ -360,8 +362,8 @@ async def test_cost_sensor_price_entity_total( assert state.state == initial_cost assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_MONETARY if initial_cost != "unknown": - assert state.attributes["last_reset"] == last_reset_cost_sensor - assert state.attributes[ATTR_STATE_CLASS] == "measurement" + assert state.attributes[ATTR_LAST_RESET] == last_reset_cost_sensor + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "EUR" # Optional late setup of dependent entities @@ -377,8 +379,8 @@ async def test_cost_sensor_price_entity_total( state = hass.states.get(cost_sensor_entity_id) assert state.state == "0.0" assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_MONETARY - assert state.attributes["last_reset"] == last_reset_cost_sensor - assert state.attributes[ATTR_STATE_CLASS] == "measurement" + assert state.attributes[ATTR_LAST_RESET] == last_reset_cost_sensor + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "EUR" # # Unique ID temp disabled @@ -395,7 +397,7 @@ async def test_cost_sensor_price_entity_total( await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) assert state.state == "10.0" # 0 EUR + (10-0) kWh * 1 EUR/kWh = 10 EUR - assert state.attributes["last_reset"] == last_reset_cost_sensor + assert state.attributes[ATTR_LAST_RESET] == last_reset_cost_sensor # Nothing happens when price changes if price_entity is not None: @@ -410,7 +412,7 @@ async def test_cost_sensor_price_entity_total( assert msg["success"] state = hass.states.get(cost_sensor_entity_id) assert state.state == "10.0" # 10 EUR + (10-10) kWh * 2 EUR/kWh = 10 EUR - assert state.attributes["last_reset"] == last_reset_cost_sensor + assert state.attributes[ATTR_LAST_RESET] == last_reset_cost_sensor # Additional consumption is using the new price hass.states.async_set( @@ -421,7 +423,7 @@ async def test_cost_sensor_price_entity_total( await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) assert state.state == "19.0" # 10 EUR + (14.5-10) kWh * 2 EUR/kWh = 19 EUR - assert state.attributes["last_reset"] == last_reset_cost_sensor + assert state.attributes[ATTR_LAST_RESET] == last_reset_cost_sensor # Check generated statistics await async_wait_recording_done_without_instance(hass) @@ -438,7 +440,7 @@ async def test_cost_sensor_price_entity_total( await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) assert state.state == "18.0" # 19 EUR + (14-14.5) kWh * 2 EUR/kWh = 18 EUR - assert state.attributes["last_reset"] == last_reset_cost_sensor + assert state.attributes[ATTR_LAST_RESET] == last_reset_cost_sensor # Energy sensor is reset, with initial state at 4kWh, 0 kWh is used as zero-point last_reset = (now + timedelta(seconds=1)).isoformat() @@ -450,8 +452,8 @@ async def test_cost_sensor_price_entity_total( await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) assert state.state == "8.0" # 0 EUR + (4-0) kWh * 2 EUR/kWh = 8 EUR - assert state.attributes["last_reset"] != last_reset_cost_sensor - last_reset_cost_sensor = state.attributes["last_reset"] + assert state.attributes[ATTR_LAST_RESET] != last_reset_cost_sensor + last_reset_cost_sensor = state.attributes[ATTR_LAST_RESET] # Energy use bumped to 10 kWh hass.states.async_set( @@ -462,7 +464,7 @@ async def test_cost_sensor_price_entity_total( await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) assert state.state == "20.0" # 8 EUR + (10-4) kWh * 2 EUR/kWh = 20 EUR - assert state.attributes["last_reset"] == last_reset_cost_sensor + assert state.attributes[ATTR_LAST_RESET] == last_reset_cost_sensor # Check generated statistics await async_wait_recording_done_without_instance(hass) @@ -471,6 +473,187 @@ async def test_cost_sensor_price_entity_total( assert statistics[cost_sensor_entity_id]["stat"]["sum"] == 38.0 +@pytest.mark.parametrize("initial_energy,initial_cost", [(0, "0.0"), (None, "unknown")]) +@pytest.mark.parametrize( + "price_entity,fixed_price", [("sensor.energy_price", None), (None, 1)] +) +@pytest.mark.parametrize( + "usage_sensor_entity_id,cost_sensor_entity_id,flow_type", + [ + ("sensor.energy_consumption", "sensor.energy_consumption_cost", "flow_from"), + ( + "sensor.energy_production", + "sensor.energy_production_compensation", + "flow_to", + ), + ], +) +@pytest.mark.parametrize("energy_state_class", ["total"]) +async def test_cost_sensor_price_entity_total_no_reset( + hass, + hass_storage, + hass_ws_client, + initial_energy, + initial_cost, + price_entity, + fixed_price, + usage_sensor_entity_id, + cost_sensor_entity_id, + flow_type, + energy_state_class, +) -> None: + """Test energy cost price from total type sensor entity with no last_reset.""" + + def _compile_statistics(_): + return compile_statistics(hass, now, now + timedelta(seconds=1)) + + energy_attributes = { + ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, + ATTR_STATE_CLASS: energy_state_class, + } + + await async_init_recorder_component(hass) + energy_data = data.EnergyManager.default_preferences() + energy_data["energy_sources"].append( + { + "type": "grid", + "flow_from": [ + { + "stat_energy_from": "sensor.energy_consumption", + "entity_energy_from": "sensor.energy_consumption", + "stat_cost": None, + "entity_energy_price": price_entity, + "number_energy_price": fixed_price, + } + ] + if flow_type == "flow_from" + else [], + "flow_to": [ + { + "stat_energy_to": "sensor.energy_production", + "entity_energy_to": "sensor.energy_production", + "stat_compensation": None, + "entity_energy_price": price_entity, + "number_energy_price": fixed_price, + } + ] + if flow_type == "flow_to" + else [], + "cost_adjustment_day": 0, + } + ) + + hass_storage[data.STORAGE_KEY] = { + "version": 1, + "data": energy_data, + } + + now = dt_util.utcnow() + last_reset_cost_sensor = now.isoformat() + + # Optionally initialize dependent entities + if initial_energy is not None: + hass.states.async_set( + usage_sensor_entity_id, + initial_energy, + energy_attributes, + ) + hass.states.async_set("sensor.energy_price", "1") + + with patch("homeassistant.util.dt.utcnow", return_value=now): + await setup_integration(hass) + + state = hass.states.get(cost_sensor_entity_id) + assert state.state == initial_cost + assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_MONETARY + if initial_cost != "unknown": + assert state.attributes[ATTR_LAST_RESET] == last_reset_cost_sensor + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "EUR" + + # Optional late setup of dependent entities + if initial_energy is None: + with patch("homeassistant.util.dt.utcnow", return_value=now): + hass.states.async_set( + usage_sensor_entity_id, + "0", + energy_attributes, + ) + await hass.async_block_till_done() + + state = hass.states.get(cost_sensor_entity_id) + assert state.state == "0.0" + assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_MONETARY + assert state.attributes[ATTR_LAST_RESET] == last_reset_cost_sensor + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "EUR" + + # # Unique ID temp disabled + # # entity_registry = er.async_get(hass) + # # entry = entity_registry.async_get(cost_sensor_entity_id) + # # assert entry.unique_id == "energy_energy_consumption cost" + + # Energy use bumped to 10 kWh + hass.states.async_set( + usage_sensor_entity_id, + "10", + energy_attributes, + ) + await hass.async_block_till_done() + state = hass.states.get(cost_sensor_entity_id) + assert state.state == "10.0" # 0 EUR + (10-0) kWh * 1 EUR/kWh = 10 EUR + assert state.attributes[ATTR_LAST_RESET] == last_reset_cost_sensor + + # Nothing happens when price changes + if price_entity is not None: + hass.states.async_set(price_entity, "2") + await hass.async_block_till_done() + else: + energy_data = copy.deepcopy(energy_data) + energy_data["energy_sources"][0][flow_type][0]["number_energy_price"] = 2 + client = await hass_ws_client(hass) + await client.send_json({"id": 5, "type": "energy/save_prefs", **energy_data}) + msg = await client.receive_json() + assert msg["success"] + state = hass.states.get(cost_sensor_entity_id) + assert state.state == "10.0" # 10 EUR + (10-10) kWh * 2 EUR/kWh = 10 EUR + assert state.attributes[ATTR_LAST_RESET] == last_reset_cost_sensor + + # Additional consumption is using the new price + hass.states.async_set( + usage_sensor_entity_id, + "14.5", + energy_attributes, + ) + await hass.async_block_till_done() + state = hass.states.get(cost_sensor_entity_id) + assert state.state == "19.0" # 10 EUR + (14.5-10) kWh * 2 EUR/kWh = 19 EUR + assert state.attributes[ATTR_LAST_RESET] == last_reset_cost_sensor + + # Check generated statistics + await async_wait_recording_done_without_instance(hass) + statistics = await hass.loop.run_in_executor(None, _compile_statistics, hass) + assert cost_sensor_entity_id in statistics + assert statistics[cost_sensor_entity_id]["stat"]["sum"] == 19.0 + + # Energy sensor has a small dip + hass.states.async_set( + usage_sensor_entity_id, + "14", + energy_attributes, + ) + await hass.async_block_till_done() + state = hass.states.get(cost_sensor_entity_id) + assert state.state == "18.0" # 19 EUR + (14-14.5) kWh * 2 EUR/kWh = 18 EUR + assert state.attributes[ATTR_LAST_RESET] == last_reset_cost_sensor + + # Check generated statistics + await async_wait_recording_done_without_instance(hass) + statistics = await hass.loop.run_in_executor(None, _compile_statistics, hass) + assert cost_sensor_entity_id in statistics + assert statistics[cost_sensor_entity_id]["stat"]["sum"] == 18.0 + + async def test_cost_sensor_handle_wh(hass, hass_storage) -> None: """Test energy cost price from sensor entity.""" energy_attributes = { @@ -576,11 +759,11 @@ async def test_cost_sensor_handle_gas(hass, hass_storage) -> None: assert state.state == "50.0" -@pytest.mark.parametrize("state_class", [None, STATE_CLASS_TOTAL]) +@pytest.mark.parametrize("state_class", [None]) async def test_cost_sensor_wrong_state_class( hass, hass_storage, caplog, state_class ) -> None: - """Test energy sensor rejects state_class with wrong state_class.""" + """Test energy sensor rejects sensor with wrong state_class.""" energy_attributes = { ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, ATTR_STATE_CLASS: state_class, @@ -638,11 +821,11 @@ async def test_cost_sensor_wrong_state_class( assert state.state == STATE_UNKNOWN -@pytest.mark.parametrize("state_class", ["measurement"]) +@pytest.mark.parametrize("state_class", [STATE_CLASS_MEASUREMENT]) async def test_cost_sensor_state_class_measurement_no_reset( hass, hass_storage, caplog, state_class ) -> None: - """Test energy sensor rejects state_class with no last_reset.""" + """Test energy sensor rejects state_class measurement with no last_reset.""" energy_attributes = { ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, ATTR_STATE_CLASS: state_class, From 970a7f96624ee379ffd39c8e61bdf5556999facb Mon Sep 17 00:00:00 2001 From: Oxan van Leeuwen Date: Fri, 10 Sep 2021 09:46:21 +0200 Subject: [PATCH 327/843] Fix compounds in sensor device class comments (#55900) --- homeassistant/components/sensor/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 530b0f39c23..9c77c33a29c 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -77,8 +77,8 @@ DEVICE_CLASSES: Final[list[str]] = [ DEVICE_CLASS_MONETARY, # Amount of money (currency) DEVICE_CLASS_OZONE, # Amount of O3 (µg/m³) DEVICE_CLASS_NITROGEN_DIOXIDE, # Amount of NO2 (µg/m³) - DEVICE_CLASS_NITROUS_OXIDE, # Amount of NO (µg/m³) - DEVICE_CLASS_NITROGEN_MONOXIDE, # Amount of N2O (µg/m³) + DEVICE_CLASS_NITROUS_OXIDE, # Amount of N2O (µg/m³) + DEVICE_CLASS_NITROGEN_MONOXIDE, # Amount of NO (µg/m³) DEVICE_CLASS_PM1, # Particulate matter <= 0.1 μm (µg/m³) DEVICE_CLASS_PM10, # Particulate matter <= 10 μm (µg/m³) DEVICE_CLASS_PM25, # Particulate matter <= 2.5 μm (µg/m³) From ff1b39cda6e84c2e3046f7d436b479994f24ce7a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Sep 2021 10:04:54 +0200 Subject: [PATCH 328/843] Fix circular import of scapy in dhcp (#56040) * Fix circular import of scapy in dhcp * Tweak import, add comment * Tweak import, add comment * pylint --- homeassistant/components/dhcp/__init__.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/homeassistant/components/dhcp/__init__.py b/homeassistant/components/dhcp/__init__.py index cc89c9b785d..e6debfea2eb 100644 --- a/homeassistant/components/dhcp/__init__.py +++ b/homeassistant/components/dhcp/__init__.py @@ -279,6 +279,17 @@ class DHCPWatcher(WatcherBase): """Start watching for dhcp packets.""" # Local import because importing from scapy has side effects such as opening # sockets + from scapy import ( # pylint: disable=import-outside-toplevel,unused-import # noqa: F401 + arch, + ) + + # + # Importing scapy.sendrecv will cause a scapy resync which will + # import scapy.arch.read_routes which will import scapy.sendrecv + # + # We avoid this circular import by importing arch above to ensure + # the module is loaded and avoid the problem + # from scapy.sendrecv import ( # pylint: disable=import-outside-toplevel AsyncSniffer, ) From d5a8f1af1d2dc74a12fb6870a4f1cb5318f88bf9 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 10 Sep 2021 11:27:47 +0200 Subject: [PATCH 329/843] Revert "Suppress last_reset deprecation warning for energy cost sensor (#56037)" (#56042) This reverts commit e990ef249d781b4a201041ae72300ead8d23c44b. --- homeassistant/components/sensor/__init__.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 9c77c33a29c..91bff740ffd 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -216,9 +216,6 @@ class SensorEntity(Entity): and not self._last_reset_reported ): self._last_reset_reported = True - if self.platform and self.platform.platform_name == "energy": - return {ATTR_LAST_RESET: last_reset.isoformat()} - report_issue = self._suggest_report_issue() _LOGGER.warning( "Entity %s (%s) with state_class %s has set last_reset. Setting " From 948a942a0dd49e6723a8946c327c033d5f395e83 Mon Sep 17 00:00:00 2001 From: micha91 Date: Fri, 10 Sep 2021 15:03:34 +0200 Subject: [PATCH 330/843] Fix UDP message handling by upgrading aiomusiccast to 0.9.2 (#56041) --- homeassistant/components/yamaha_musiccast/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/yamaha_musiccast/manifest.json b/homeassistant/components/yamaha_musiccast/manifest.json index bd614e368dc..be52b8a4558 100644 --- a/homeassistant/components/yamaha_musiccast/manifest.json +++ b/homeassistant/components/yamaha_musiccast/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/yamaha_musiccast", "requirements": [ - "aiomusiccast==0.9.1" + "aiomusiccast==0.9.2" ], "ssdp": [ { diff --git a/requirements_all.txt b/requirements_all.txt index 73e21ea4ec9..e2caee77f4d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -216,7 +216,7 @@ aiolyric==1.0.7 aiomodernforms==0.1.8 # homeassistant.components.yamaha_musiccast -aiomusiccast==0.9.1 +aiomusiccast==0.9.2 # homeassistant.components.keyboard_remote aionotify==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a661f772dcf..434a31f6cec 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -140,7 +140,7 @@ aiolyric==1.0.7 aiomodernforms==0.1.8 # homeassistant.components.yamaha_musiccast -aiomusiccast==0.9.1 +aiomusiccast==0.9.2 # homeassistant.components.notion aionotion==3.0.2 From 03df48af9cd75e5bd162ed8ed3e089c8a3db552c Mon Sep 17 00:00:00 2001 From: Sean Vig Date: Fri, 10 Sep 2021 09:38:01 -0400 Subject: [PATCH 331/843] Bump amcrest version to 1.8.1 (#56058) The current version of the `amcrest` package has a bug in exposing if the video stream is enabled, which leads to the substream status being used to set if the camera is on or off. The updated version of `amcrest` fixes this bug. Fixes #55661 --- homeassistant/components/amcrest/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/amcrest/manifest.json b/homeassistant/components/amcrest/manifest.json index acd93c4e2ed..725ff96b3ad 100644 --- a/homeassistant/components/amcrest/manifest.json +++ b/homeassistant/components/amcrest/manifest.json @@ -2,7 +2,7 @@ "domain": "amcrest", "name": "Amcrest", "documentation": "https://www.home-assistant.io/integrations/amcrest", - "requirements": ["amcrest==1.8.0"], + "requirements": ["amcrest==1.8.1"], "dependencies": ["ffmpeg"], "codeowners": ["@flacjacket"], "iot_class": "local_polling" diff --git a/requirements_all.txt b/requirements_all.txt index e2caee77f4d..9ffac914550 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -276,7 +276,7 @@ ambee==0.3.0 ambiclimate==0.2.1 # homeassistant.components.amcrest -amcrest==1.8.0 +amcrest==1.8.1 # homeassistant.components.androidtv androidtv[async]==0.0.60 From aa39e582c320d21388f352c73b9af50b20a3b58b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Fri, 10 Sep 2021 16:49:42 +0200 Subject: [PATCH 332/843] Surepetcare, fix late review (#56065) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Daniel Hjelseth Høyer --- homeassistant/components/surepetcare/__init__.py | 2 +- homeassistant/components/surepetcare/binary_sensor.py | 2 +- homeassistant/components/surepetcare/sensor.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/surepetcare/__init__.py b/homeassistant/components/surepetcare/__init__.py index a6e9dca703c..f04af0dd795 100644 --- a/homeassistant/components/surepetcare/__init__.py +++ b/homeassistant/components/surepetcare/__init__.py @@ -95,7 +95,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) hass.data[DOMAIN] = coordinator - await coordinator.async_config_entry_first_refresh() + await coordinator.async_refresh() # load platforms for platform in PLATFORMS: diff --git a/homeassistant/components/surepetcare/binary_sensor.py b/homeassistant/components/surepetcare/binary_sensor.py index 50a890112bc..0c223ae3ac1 100644 --- a/homeassistant/components/surepetcare/binary_sensor.py +++ b/homeassistant/components/surepetcare/binary_sensor.py @@ -52,7 +52,7 @@ async def async_setup_platform( async_add_entities(entities) -class SurePetcareBinarySensor(BinarySensorEntity, CoordinatorEntity): +class SurePetcareBinarySensor(CoordinatorEntity, BinarySensorEntity): """A binary sensor implementation for Sure Petcare Entities.""" def __init__( diff --git a/homeassistant/components/surepetcare/sensor.py b/homeassistant/components/surepetcare/sensor.py index 6b07408f6b1..53d5d985e41 100644 --- a/homeassistant/components/surepetcare/sensor.py +++ b/homeassistant/components/surepetcare/sensor.py @@ -41,7 +41,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= async_add_entities(entities) -class SureBattery(SensorEntity, CoordinatorEntity): +class SureBattery(CoordinatorEntity, SensorEntity): """A sensor implementation for Sure Petcare Entities.""" def __init__(self, _id: int, coordinator: DataUpdateCoordinator) -> None: From c59540cfc7f36cc3a2d53bf50a7956d4faa6dc84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Fri, 10 Sep 2021 17:01:51 +0200 Subject: [PATCH 333/843] Revert "Bump pillow to 8.3.2 (#55970)" (#56048) This reverts commit ee7202d10a7b5389631739adb7143ddc7af5756c. --- homeassistant/components/doods/manifest.json | 2 +- homeassistant/components/image/manifest.json | 2 +- homeassistant/components/proxy/manifest.json | 2 +- homeassistant/components/qrcode/manifest.json | 2 +- homeassistant/components/seven_segments/manifest.json | 2 +- homeassistant/components/sighthound/manifest.json | 2 +- homeassistant/components/tensorflow/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/doods/manifest.json b/homeassistant/components/doods/manifest.json index 44597ac8aeb..ae584af5916 100644 --- a/homeassistant/components/doods/manifest.json +++ b/homeassistant/components/doods/manifest.json @@ -2,7 +2,7 @@ "domain": "doods", "name": "DOODS - Dedicated Open Object Detection Service", "documentation": "https://www.home-assistant.io/integrations/doods", - "requirements": ["pydoods==1.0.2", "pillow==8.3.2"], + "requirements": ["pydoods==1.0.2", "pillow==8.2.0"], "codeowners": [], "iot_class": "local_polling" } diff --git a/homeassistant/components/image/manifest.json b/homeassistant/components/image/manifest.json index 9416ea7ef9e..82b7e58a653 100644 --- a/homeassistant/components/image/manifest.json +++ b/homeassistant/components/image/manifest.json @@ -3,7 +3,7 @@ "name": "Image", "config_flow": false, "documentation": "https://www.home-assistant.io/integrations/image", - "requirements": ["pillow==8.3.2"], + "requirements": ["pillow==8.2.0"], "dependencies": ["http"], "codeowners": ["@home-assistant/core"], "quality_scale": "internal" diff --git a/homeassistant/components/proxy/manifest.json b/homeassistant/components/proxy/manifest.json index 47982ac120e..68c7717e16c 100644 --- a/homeassistant/components/proxy/manifest.json +++ b/homeassistant/components/proxy/manifest.json @@ -2,6 +2,6 @@ "domain": "proxy", "name": "Camera Proxy", "documentation": "https://www.home-assistant.io/integrations/proxy", - "requirements": ["pillow==8.3.2"], + "requirements": ["pillow==8.2.0"], "codeowners": [] } diff --git a/homeassistant/components/qrcode/manifest.json b/homeassistant/components/qrcode/manifest.json index adfad7569e8..a414e197fd6 100644 --- a/homeassistant/components/qrcode/manifest.json +++ b/homeassistant/components/qrcode/manifest.json @@ -2,7 +2,7 @@ "domain": "qrcode", "name": "QR Code", "documentation": "https://www.home-assistant.io/integrations/qrcode", - "requirements": ["pillow==8.3.2", "pyzbar==0.1.7"], + "requirements": ["pillow==8.2.0", "pyzbar==0.1.7"], "codeowners": [], "iot_class": "calculated" } diff --git a/homeassistant/components/seven_segments/manifest.json b/homeassistant/components/seven_segments/manifest.json index 14dc16814a6..9a0287b2132 100644 --- a/homeassistant/components/seven_segments/manifest.json +++ b/homeassistant/components/seven_segments/manifest.json @@ -2,7 +2,7 @@ "domain": "seven_segments", "name": "Seven Segments OCR", "documentation": "https://www.home-assistant.io/integrations/seven_segments", - "requirements": ["pillow==8.3.2"], + "requirements": ["pillow==8.2.0"], "codeowners": ["@fabaff"], "iot_class": "local_polling" } diff --git a/homeassistant/components/sighthound/manifest.json b/homeassistant/components/sighthound/manifest.json index b0febac8150..b22b645a7e8 100644 --- a/homeassistant/components/sighthound/manifest.json +++ b/homeassistant/components/sighthound/manifest.json @@ -2,7 +2,7 @@ "domain": "sighthound", "name": "Sighthound", "documentation": "https://www.home-assistant.io/integrations/sighthound", - "requirements": ["pillow==8.3.2", "simplehound==0.3"], + "requirements": ["pillow==8.2.0", "simplehound==0.3"], "codeowners": ["@robmarkcole"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json index 4ec44ee3f4b..b3162a19364 100644 --- a/homeassistant/components/tensorflow/manifest.json +++ b/homeassistant/components/tensorflow/manifest.json @@ -7,7 +7,7 @@ "tf-models-official==2.3.0", "pycocotools==2.0.1", "numpy==1.21.1", - "pillow==8.3.2" + "pillow==8.2.0" ], "codeowners": [], "iot_class": "local_polling" diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f88b9778bb0..4888193f6a9 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -21,7 +21,7 @@ httpx==0.19.0 ifaddr==0.1.7 jinja2==3.0.1 paho-mqtt==1.5.1 -pillow==8.3.2 +pillow==8.2.0 pip>=8.0.3,<20.3 pyserial==3.5 python-slugify==4.0.1 diff --git a/requirements_all.txt b/requirements_all.txt index 9ffac914550..9f1129892bb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1194,7 +1194,7 @@ pilight==0.1.1 # homeassistant.components.seven_segments # homeassistant.components.sighthound # homeassistant.components.tensorflow -pillow==8.3.2 +pillow==8.2.0 # homeassistant.components.dominos pizzapi==0.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 434a31f6cec..25522896a2a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -675,7 +675,7 @@ pilight==0.1.1 # homeassistant.components.seven_segments # homeassistant.components.sighthound # homeassistant.components.tensorflow -pillow==8.3.2 +pillow==8.2.0 # homeassistant.components.plex plexapi==4.7.0 From 443147e132fd3714396aaf651a80840a0d2cfd1b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 10 Sep 2021 18:07:52 +0200 Subject: [PATCH 334/843] Wait for entities when updating energy preferences (#56057) --- homeassistant/components/energy/sensor.py | 15 +++++++++++++-- tests/components/energy/test_validate.py | 3 +++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index fadec44c1a2..387a08141a2 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -1,6 +1,7 @@ """Helper sensor for calculating utility costs.""" from __future__ import annotations +import asyncio import copy from dataclasses import dataclass import logging @@ -117,12 +118,13 @@ class SensorManager: async def _process_manager_data(self) -> None: """Process manager data.""" - to_add: list[SensorEntity] = [] + to_add: list[EnergyCostSensor] = [] to_remove = dict(self.current_entities) async def finish() -> None: if to_add: self.async_add_entities(to_add) + await asyncio.gather(*(ent.add_finished.wait() for ent in to_add)) for key, entity in to_remove.items(): self.current_entities.pop(key) @@ -163,7 +165,7 @@ class SensorManager: self, adapter: SourceAdapter, config: dict, - to_add: list[SensorEntity], + to_add: list[EnergyCostSensor], to_remove: dict[tuple[str, str | None, str], EnergyCostSensor], ) -> None: """Process sensor data.""" @@ -220,6 +222,9 @@ class EnergyCostSensor(SensorEntity): self._config = config self._last_energy_sensor_state: State | None = None self._cur_value = 0.0 + # add_finished is set when either of async_added_to_hass or add_to_platform_abort + # is called + self.add_finished = asyncio.Event() def _reset(self, energy_state: State) -> None: """Reset the cost sensor.""" @@ -373,6 +378,12 @@ class EnergyCostSensor(SensorEntity): async_state_changed_listener, ) ) + self.add_finished.set() + + @callback + def add_to_platform_abort(self) -> None: + """Abort adding an entity to a platform.""" + self.add_finished.set() async def async_will_remove_from_hass(self) -> None: """Handle removing from hass.""" diff --git a/tests/components/energy/test_validate.py b/tests/components/energy/test_validate.py index 8c67f3eabaf..e893c71d1f2 100644 --- a/tests/components/energy/test_validate.py +++ b/tests/components/energy/test_validate.py @@ -341,12 +341,14 @@ async def test_validation_grid_price_not_exist(hass, mock_energy_manager): "stat_energy_from": "sensor.grid_consumption_1", "entity_energy_from": "sensor.grid_consumption_1", "entity_energy_price": "sensor.grid_price_1", + "number_energy_price": None, } ], "flow_to": [ { "stat_energy_to": "sensor.grid_production_1", "entity_energy_to": "sensor.grid_production_1", + "entity_energy_price": None, "number_energy_price": 0.10, } ], @@ -417,6 +419,7 @@ async def test_validation_grid_price_errors( "stat_energy_from": "sensor.grid_consumption_1", "entity_energy_from": "sensor.grid_consumption_1", "entity_energy_price": "sensor.grid_price_1", + "number_energy_price": None, } ], "flow_to": [], From dec787767191abcb734e9c10d0f229b9fb96eb15 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 10 Sep 2021 09:08:43 -0700 Subject: [PATCH 335/843] Handle logout prefs update for Google/Alexa (#56045) --- .../components/cloud/alexa_config.py | 21 ++++++--- .../components/cloud/google_config.py | 7 +++ tests/components/cloud/test_alexa_config.py | 43 ++++++++++++++++--- tests/components/cloud/test_google_config.py | 29 +++++++++++++ 4 files changed, 88 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/cloud/alexa_config.py b/homeassistant/components/cloud/alexa_config.py index 7394936f355..43ef0ee62da 100644 --- a/homeassistant/components/cloud/alexa_config.py +++ b/homeassistant/components/cloud/alexa_config.py @@ -56,12 +56,6 @@ class AlexaConfig(alexa_config.AbstractConfig): self._alexa_sync_unsub = None self._endpoint = None - prefs.async_listen_updates(self._async_prefs_updated) - hass.bus.async_listen( - entity_registry.EVENT_ENTITY_REGISTRY_UPDATED, - self._handle_entity_registry_updated, - ) - @property def enabled(self): """Return if Alexa is enabled.""" @@ -114,6 +108,12 @@ class AlexaConfig(alexa_config.AbstractConfig): start.async_at_start(self.hass, hass_started) + self._prefs.async_listen_updates(self._async_prefs_updated) + self.hass.bus.async_listen( + entity_registry.EVENT_ENTITY_REGISTRY_UPDATED, + self._handle_entity_registry_updated, + ) + def should_expose(self, entity_id): """If an entity should be exposed.""" if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: @@ -171,6 +171,15 @@ class AlexaConfig(alexa_config.AbstractConfig): async def _async_prefs_updated(self, prefs): """Handle updated preferences.""" + if not self._cloud.is_logged_in: + if self.is_reporting_states: + await self.async_disable_proactive_mode() + + if self._alexa_sync_unsub: + self._alexa_sync_unsub() + self._alexa_sync_unsub = None + return + if ALEXA_DOMAIN not in self.hass.config.components and self.enabled: await async_setup_component(self.hass, ALEXA_DOMAIN, {}) diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py index aed66ae179d..f1783771f2f 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -172,6 +172,13 @@ class CloudGoogleConfig(AbstractConfig): async def _async_prefs_updated(self, prefs): """Handle updated preferences.""" + if not self._cloud.is_logged_in: + if self.is_reporting_state: + self.async_disable_report_state() + if self.is_local_sdk_active: + self.async_disable_local_sdk() + return + if self.enabled and GOOGLE_DOMAIN not in self.hass.config.components: await async_setup_component(self.hass, GOOGLE_DOMAIN, {}) diff --git a/tests/components/cloud/test_alexa_config.py b/tests/components/cloud/test_alexa_config.py index 83c2a5aa2d1..60ef992dafb 100644 --- a/tests/components/cloud/test_alexa_config.py +++ b/tests/components/cloud/test_alexa_config.py @@ -28,6 +28,7 @@ async def test_alexa_config_expose_entity_prefs(hass, cloud_prefs, cloud_stub): conf = alexa_config.AlexaConfig( hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub ) + await conf.async_initialize() assert not conf.should_expose("light.kitchen") entity_conf["should_expose"] = True @@ -50,6 +51,7 @@ async def test_alexa_config_report_state(hass, cloud_prefs, cloud_stub): conf = alexa_config.AlexaConfig( hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub ) + await conf.async_initialize() assert cloud_prefs.alexa_report_state is False assert conf.should_report_state is False @@ -131,9 +133,9 @@ def patch_sync_helper(): async def test_alexa_update_expose_trigger_sync(hass, cloud_prefs, cloud_stub): """Test Alexa config responds to updating exposed entities.""" - alexa_config.AlexaConfig( + await alexa_config.AlexaConfig( hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub - ) + ).async_initialize() with patch_sync_helper() as (to_update, to_remove): await cloud_prefs.async_update_alexa_entity_config( @@ -166,9 +168,9 @@ async def test_alexa_update_expose_trigger_sync(hass, cloud_prefs, cloud_stub): async def test_alexa_entity_registry_sync(hass, mock_cloud_login, cloud_prefs): """Test Alexa config responds to entity registry.""" - alexa_config.AlexaConfig( + await alexa_config.AlexaConfig( hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, hass.data["cloud"] - ) + ).async_initialize() with patch_sync_helper() as (to_update, to_remove): hass.bus.async_fire( @@ -218,9 +220,9 @@ async def test_alexa_entity_registry_sync(hass, mock_cloud_login, cloud_prefs): async def test_alexa_update_report_state(hass, cloud_prefs, cloud_stub): """Test Alexa config responds to reporting state.""" - alexa_config.AlexaConfig( + await alexa_config.AlexaConfig( hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub - ) + ).async_initialize() with patch( "homeassistant.components.cloud.alexa_config.AlexaConfig.async_sync_entities", @@ -244,3 +246,32 @@ def test_enabled_requires_valid_sub(hass, mock_expired_cloud_login, cloud_prefs) ) assert not config.enabled + + +async def test_alexa_handle_logout(hass, cloud_prefs, cloud_stub): + """Test Alexa config responds to logging out.""" + aconf = alexa_config.AlexaConfig( + hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub + ) + + await aconf.async_initialize() + + with patch( + "homeassistant.components.alexa.config.async_enable_proactive_mode", + return_value=Mock(), + ) as mock_enable: + await aconf.async_enable_proactive_mode() + + # This will trigger a prefs update when we logout. + await cloud_prefs.get_cloud_user() + + cloud_stub.is_logged_in = False + with patch.object( + cloud_stub.auth, + "async_check_token", + side_effect=AssertionError("Should not be called"), + ): + await cloud_prefs.async_set_username(None) + await hass.async_block_till_done() + + assert len(mock_enable.return_value.mock_calls) == 1 diff --git a/tests/components/cloud/test_google_config.py b/tests/components/cloud/test_google_config.py index 1f513dbf53e..f2528de221d 100644 --- a/tests/components/cloud/test_google_config.py +++ b/tests/components/cloud/test_google_config.py @@ -264,3 +264,32 @@ async def test_setup_integration(hass, mock_conf, cloud_prefs): await cloud_prefs.async_update() await hass.async_block_till_done() assert "google_assistant" in hass.config.components + + +async def test_google_handle_logout(hass, cloud_prefs, mock_cloud_login): + """Test Google config responds to logging out.""" + gconf = CloudGoogleConfig( + hass, GACTIONS_SCHEMA({}), "mock-user-id", cloud_prefs, Mock(is_logged_in=False) + ) + + await gconf.async_initialize() + + with patch( + "homeassistant.components.google_assistant.report_state.async_enable_report_state", + ) as mock_enable: + gconf.async_enable_report_state() + + assert len(mock_enable.mock_calls) == 1 + + # This will trigger a prefs update when we logout. + await cloud_prefs.get_cloud_user() + + with patch.object( + hass.data["cloud"].auth, + "async_check_token", + side_effect=AssertionError("Should not be called"), + ): + await cloud_prefs.async_set_username(None) + await hass.async_block_till_done() + + assert len(mock_enable.return_value.mock_calls) == 1 From ac1251c52be36a82e21b4bba79859112b49d3b79 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Fri, 10 Sep 2021 18:55:51 +0200 Subject: [PATCH 336/843] Update template/test_trigger.py to use pytest (#55950) --- tests/components/template/test_trigger.py | 860 +++++++++------------- 1 file changed, 345 insertions(+), 515 deletions(-) diff --git a/tests/components/template/test_trigger.py b/tests/components/template/test_trigger.py index e9634248c72..72cf41f3528 100644 --- a/tests/components/template/test_trigger.py +++ b/tests/components/template/test_trigger.py @@ -12,33 +12,20 @@ from homeassistant.core import Context, callback from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from tests.common import ( - assert_setup_component, - async_fire_time_changed, - async_mock_service, - mock_component, -) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 - - -@pytest.fixture -def calls(hass): - """Track calls to a mock service.""" - return async_mock_service(hass, "test", "automation") +from tests.common import async_fire_time_changed, mock_component @pytest.fixture(autouse=True) -def setup_comp(hass): +def setup_comp(hass, calls): """Initialize components.""" mock_component(hass, "group") hass.states.async_set("test.entity", "hello") -async def test_if_fires_on_change_bool(hass, calls): - """Test for firing on boolean change.""" - assert await async_setup_component( - hass, - automation.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, automation.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { automation.DOMAIN: { "trigger": { @@ -51,8 +38,10 @@ async def test_if_fires_on_change_bool(hass, calls): }, } }, - ) - + ], +) +async def test_if_fires_on_change_bool(hass, start_ha, calls): + """Test for firing on boolean change.""" assert len(calls) == 0 hass.states.async_set("test.entity", "world") @@ -65,309 +54,252 @@ async def test_if_fires_on_change_bool(hass, calls): {ATTR_ENTITY_ID: ENTITY_MATCH_ALL}, blocking=True, ) - hass.states.async_set("test.entity", "planet") await hass.async_block_till_done() assert len(calls) == 1 assert calls[0].data["id"] == 0 -async def test_if_fires_on_change_str(hass, calls): +@pytest.mark.parametrize("count,domain", [(1, automation.DOMAIN)]) +@pytest.mark.parametrize( + "config, call_setup", + [ + ( + { + automation.DOMAIN: { + "trigger": { + "platform": "template", + "value_template": '{{ states.test.entity.state == "world" and "true" }}', + }, + "action": {"service": "test.automation"}, + } + }, + [(1, "world", False)], + ), + ( + { + automation.DOMAIN: { + "trigger": { + "platform": "template", + "value_template": '{{ states.test.entity.state == "world" and "TrUE" }}', + }, + "action": {"service": "test.automation"}, + } + }, + [(1, "world", False)], + ), + ( + { + automation.DOMAIN: { + "trigger": { + "platform": "template", + "value_template": '{{ states.test.entity.state == "world" and false }}', + }, + "action": {"service": "test.automation"}, + } + }, + [(0, "world", False)], + ), + ( + { + automation.DOMAIN: { + "trigger": {"platform": "template", "value_template": "true"}, + "action": {"service": "test.automation"}, + } + }, + [(0, "world", False)], + ), + ( + { + automation.DOMAIN: { + "trigger": { + "platform": "template", + "value_template": '{{ "Anything other than true is false." }}', + }, + "action": {"service": "test.automation"}, + } + }, + [(0, "world", False)], + ), + ( + { + automation.DOMAIN: { + "trigger": { + "platform": "template", + "value_template": '{{ is_state("test.entity", "world") }}', + }, + "action": {"service": "test.automation"}, + } + }, + [(1, "world", False)], + ), + ( + { + automation.DOMAIN: { + "trigger": { + "platform": "template", + "value_template": '{{ is_state("test.entity", "hello") }}', + }, + "action": {"service": "test.automation"}, + } + }, + [(0, "world", False)], + ), + ( + { + automation.DOMAIN: { + "trigger": { + "platform": "template", + "value_template": "{{ states.test.entity.state == 'world' }}", + }, + "action": {"service": "test.automation"}, + } + }, + [(1, "world", False), (1, "world", False)], + ), + ( + { + automation.DOMAIN: { + "trigger": { + "platform": "template", + "value_template": '{{ states.test.entity.state == "hello" }}', + }, + "action": {"service": "test.automation"}, + }, + }, + [(0, "world", True)], + ), + ( + { + automation.DOMAIN: { + "trigger_variables": {"entity": "test.entity"}, + "trigger": { + "platform": "template", + "value_template": '{{ is_state(entity|default("test.entity2"), "hello") }}', + }, + "action": {"service": "test.automation"}, + }, + }, + [(0, "hello", True), (0, "goodbye", True), (1, "hello", True)], + ), + ( + { + automation.DOMAIN: { + "trigger": { + "platform": "template", + "value_template": """{%- if is_state("test.entity", "world") -%} + true + {%- else -%} + false + {%- endif -%}""", + }, + "action": {"service": "test.automation"}, + } + }, + [(0, "worldz", False), (0, "hello", True)], + ), + ( + { + automation.DOMAIN: { + "trigger": { + "platform": "template", + "value_template": '{{ not is_state("test.entity", "world") }}', + }, + "action": {"service": "test.automation"}, + } + }, + [ + (0, "world", False), + (1, "home", False), + (1, "work", False), + (1, "not_home", False), + (1, "world", False), + (2, "home", False), + ], + ), + ( + { + automation.DOMAIN: { + "trigger": { + "platform": "template", + "value_template": "{{ xyz | round(0) }}", + }, + "action": {"service": "test.automation"}, + } + }, + [(0, "world", False)], + ), + ( + { + automation.DOMAIN: { + "trigger": { + "platform": "template", + "value_template": "{{ is_state('test.entity', 'world') }}", + "for": {"seconds": 0}, + }, + "action": {"service": "test.automation"}, + } + }, + [(1, "world", False)], + ), + ( + { + automation.DOMAIN: { + "trigger": {"platform": "template", "value_template": "{{ true }}"}, + "action": {"service": "test.automation"}, + } + }, + [(0, "hello", False)], + ), + ], +) +async def test_general(hass, call_setup, start_ha, calls): """Test for firing on change.""" - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "template", - "value_template": '{{ states.test.entity.state == "world" and "true" }}', - }, - "action": {"service": "test.automation"}, - } - }, - ) - assert len(calls) == 0 - hass.states.async_set("test.entity", "world") - await hass.async_block_till_done() - assert len(calls) == 1 + for call_len, call_name, call_force in call_setup: + hass.states.async_set("test.entity", call_name, force_update=call_force) + await hass.async_block_till_done() + assert len(calls) == call_len -async def test_if_fires_on_change_str_crazy(hass, calls): - """Test for firing on change.""" - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "template", - "value_template": '{{ states.test.entity.state == "world" and "TrUE" }}', - }, - "action": {"service": "test.automation"}, - } - }, - ) - - hass.states.async_set("test.entity", "world") - await hass.async_block_till_done() - assert len(calls) == 1 - - -async def test_if_not_fires_when_true_at_setup(hass, calls): - """Test for not firing during startup.""" - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "template", - "value_template": '{{ states.test.entity.state == "hello" }}', - }, - "action": {"service": "test.automation"}, - } - }, - ) - - assert len(calls) == 0 - - hass.states.async_set("test.entity", "hello", force_update=True) - await hass.async_block_till_done() - assert len(calls) == 0 - - -async def test_if_not_fires_when_true_at_setup_variables(hass, calls): - """Test for not firing during startup + trigger_variables.""" - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger_variables": {"entity": "test.entity"}, - "trigger": { - "platform": "template", - "value_template": '{{ is_state(entity|default("test.entity2"), "hello") }}', - }, - "action": {"service": "test.automation"}, - } - }, - ) - - assert len(calls) == 0 - - # Assert that the trigger doesn't fire immediately when it's setup - # If trigger_variable 'entity' is not passed to initial check at setup, the - # trigger will immediately fire - hass.states.async_set("test.entity", "hello", force_update=True) - await hass.async_block_till_done() - assert len(calls) == 0 - - hass.states.async_set("test.entity", "goodbye", force_update=True) - await hass.async_block_till_done() - assert len(calls) == 0 - - # Assert that the trigger fires after state change - # If trigger_variable 'entity' is not passed to the template trigger, the - # trigger will never fire because it falls back to 'test.entity2' - hass.states.async_set("test.entity", "hello", force_update=True) - await hass.async_block_till_done() - assert len(calls) == 1 - - -async def test_if_not_fires_because_fail(hass, calls): +@pytest.mark.parametrize("count,domain", [(1, automation.DOMAIN)]) +@pytest.mark.parametrize( + "config, call_setup", + [ + ( + { + automation.DOMAIN: { + "trigger": { + "platform": "template", + "value_template": "{{ 84 / states.test.number.state|int == 42 }}", + }, + "action": {"service": "test.automation"}, + } + }, + [ + (0, "1"), + (1, "2"), + (1, "0"), + (1, "2"), + ], + ), + ], +) +async def test_if_not_fires_because_fail(hass, call_setup, start_ha, calls): """Test for not firing after TemplateError.""" - hass.states.async_set("test.number", "1") - - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "template", - "value_template": "{{ 84 / states.test.number.state|int == 42 }}", - }, - "action": {"service": "test.automation"}, - } - }, - ) - assert len(calls) == 0 - hass.states.async_set("test.number", "2") - await hass.async_block_till_done() - assert len(calls) == 1 - - hass.states.async_set("test.number", "0") - await hass.async_block_till_done() - assert len(calls) == 1 - - hass.states.async_set("test.number", "2") - await hass.async_block_till_done() - assert len(calls) == 1 + for call_len, call_number in call_setup: + hass.states.async_set("test.number", call_number) + await hass.async_block_till_done() + assert len(calls) == call_len -async def test_if_not_fires_on_change_bool(hass, calls): - """Test for not firing on boolean change.""" - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "template", - "value_template": '{{ states.test.entity.state == "world" and false }}', - }, - "action": {"service": "test.automation"}, - } - }, - ) - - hass.states.async_set("test.entity", "world") - await hass.async_block_till_done() - assert len(calls) == 0 - - -async def test_if_not_fires_on_change_str(hass, calls): - """Test for not firing on string change.""" - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": {"platform": "template", "value_template": "true"}, - "action": {"service": "test.automation"}, - } - }, - ) - - hass.states.async_set("test.entity", "world") - await hass.async_block_till_done() - assert len(calls) == 0 - - -async def test_if_not_fires_on_change_str_crazy(hass, calls): - """Test for not firing on string change.""" - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "template", - "value_template": '{{ "Anything other than true is false." }}', - }, - "action": {"service": "test.automation"}, - } - }, - ) - - hass.states.async_set("test.entity", "world") - await hass.async_block_till_done() - assert len(calls) == 0 - - -async def test_if_fires_on_no_change(hass, calls): - """Test for firing on no change.""" - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": {"platform": "template", "value_template": "{{ true }}"}, - "action": {"service": "test.automation"}, - } - }, - ) - - await hass.async_block_till_done() - cur_len = len(calls) - - hass.states.async_set("test.entity", "hello") - await hass.async_block_till_done() - assert cur_len == len(calls) - - -async def test_if_fires_on_two_change(hass, calls): - """Test for firing on two changes.""" - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "template", - "value_template": "{{ states.test.entity.state == 'world' }}", - }, - "action": {"service": "test.automation"}, - } - }, - ) - - # Trigger once - hass.states.async_set("test.entity", "world") - await hass.async_block_till_done() - assert len(calls) == 1 - - # Trigger again - hass.states.async_set("test.entity", "world") - await hass.async_block_till_done() - assert len(calls) == 1 - - -async def test_if_fires_on_change_with_template(hass, calls): - """Test for firing on change with template.""" - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "template", - "value_template": '{{ is_state("test.entity", "world") }}', - }, - "action": {"service": "test.automation"}, - } - }, - ) - - hass.states.async_set("test.entity", "world") - await hass.async_block_till_done() - assert len(calls) == 1 - - -async def test_if_not_fires_on_change_with_template(hass, calls): - """Test for not firing on change with template.""" - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "template", - "value_template": '{{ is_state("test.entity", "hello") }}', - }, - "action": {"service": "test.automation"}, - } - }, - ) - - await hass.async_block_till_done() - - hass.states.async_set("test.entity", "world") - await hass.async_block_till_done() - assert len(calls) == 0 - - -async def test_if_fires_on_change_with_template_advanced(hass, calls): - """Test for firing on change with template advanced.""" - context = Context() - assert await async_setup_component( - hass, - automation.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, automation.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { automation.DOMAIN: { "trigger": { @@ -391,8 +323,11 @@ async def test_if_fires_on_change_with_template_advanced(hass, calls): }, } }, - ) - + ], +) +async def test_if_fires_on_change_with_template_advanced(hass, start_ha, calls): + """Test for firing on change with template advanced.""" + context = Context() await hass.async_block_till_done() hass.states.async_set("test.entity", "world", context=context) @@ -402,85 +337,10 @@ async def test_if_fires_on_change_with_template_advanced(hass, calls): assert calls[0].data["some"] == "template - test.entity - hello - world - None" -async def test_if_fires_on_no_change_with_template_advanced(hass, calls): - """Test for firing on no change with template advanced.""" - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "template", - "value_template": """{%- if is_state("test.entity", "world") -%} - true - {%- else -%} - false - {%- endif -%}""", - }, - "action": {"service": "test.automation"}, - } - }, - ) - - # Different state - hass.states.async_set("test.entity", "worldz") - await hass.async_block_till_done() - assert len(calls) == 0 - - # Different state - hass.states.async_set("test.entity", "hello") - await hass.async_block_till_done() - assert len(calls) == 0 - - -async def test_if_fires_on_change_with_template_2(hass, calls): - """Test for firing on change with template.""" - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "template", - "value_template": '{{ not is_state("test.entity", "world") }}', - }, - "action": {"service": "test.automation"}, - } - }, - ) - - await hass.async_block_till_done() - - hass.states.async_set("test.entity", "world") - await hass.async_block_till_done() - assert len(calls) == 0 - - hass.states.async_set("test.entity", "home") - await hass.async_block_till_done() - assert len(calls) == 1 - - hass.states.async_set("test.entity", "work") - await hass.async_block_till_done() - assert len(calls) == 1 - - hass.states.async_set("test.entity", "not_home") - await hass.async_block_till_done() - assert len(calls) == 1 - - hass.states.async_set("test.entity", "world") - await hass.async_block_till_done() - assert len(calls) == 1 - - hass.states.async_set("test.entity", "home") - await hass.async_block_till_done() - assert len(calls) == 2 - - -async def test_if_action(hass, calls): - """Test for firing if action.""" - assert await async_setup_component( - hass, - automation.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, automation.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { automation.DOMAIN: { "trigger": {"platform": "event", "event_type": "test_event"}, @@ -493,8 +353,10 @@ async def test_if_action(hass, calls): "action": {"service": "test.automation"}, } }, - ) - + ], +) +async def test_if_action(hass, start_ha, calls): + """Test for firing if action.""" # Condition is not true yet hass.bus.async_fire("test_event") await hass.async_block_till_done() @@ -511,47 +373,26 @@ async def test_if_action(hass, calls): assert len(calls) == 1 -async def test_if_fires_on_change_with_bad_template(hass, calls): - """Test for firing on change with bad template.""" - with assert_setup_component(0, automation.DOMAIN): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": {"platform": "template", "value_template": "{{ "}, - "action": {"service": "test.automation"}, - } - }, - ) - - -async def test_if_fires_on_change_with_bad_template_2(hass, calls): - """Test for firing on change with bad template.""" - assert await async_setup_component( - hass, - automation.DOMAIN, +@pytest.mark.parametrize("count,domain", [(0, automation.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { automation.DOMAIN: { - "trigger": { - "platform": "template", - "value_template": "{{ xyz | round(0) }}", - }, + "trigger": {"platform": "template", "value_template": "{{ "}, "action": {"service": "test.automation"}, } }, - ) - - hass.states.async_set("test.entity", "world") - await hass.async_block_till_done() - assert len(calls) == 0 + ], +) +async def test_if_fires_on_change_with_bad_template(hass, start_ha, calls): + """Test for firing on change with bad template.""" -async def test_wait_template_with_trigger(hass, calls): - """Test using wait template with 'trigger.entity_id'.""" - assert await async_setup_component( - hass, - automation.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, automation.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { automation.DOMAIN: { "trigger": { @@ -579,8 +420,10 @@ async def test_wait_template_with_trigger(hass, calls): ], } }, - ) - + ], +) +async def test_wait_template_with_trigger(hass, start_ha, calls): + """Test using wait template with 'trigger.entity_id'.""" await hass.async_block_till_done() @callback @@ -620,12 +463,10 @@ async def test_if_fires_on_change_with_for(hass, calls): assert len(calls) == 1 -async def test_if_fires_on_change_with_for_advanced(hass, calls): - """Test for firing on change with for advanced.""" - context = Context() - assert await async_setup_component( - hass, - automation.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, automation.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { automation.DOMAIN: { "trigger": { @@ -650,8 +491,11 @@ async def test_if_fires_on_change_with_for_advanced(hass, calls): }, } }, - ) - + ], +) +async def test_if_fires_on_change_with_for_advanced(hass, start_ha, calls): + """Test for firing on change with for advanced.""" + context = Context() await hass.async_block_till_done() hass.states.async_set("test.entity", "world", context=context) @@ -664,34 +508,10 @@ async def test_if_fires_on_change_with_for_advanced(hass, calls): assert calls[0].data["some"] == "template - test.entity - hello - world - 0:00:05" -async def test_if_fires_on_change_with_for_0(hass, calls): - """Test for firing on change with for: 0.""" - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "template", - "value_template": "{{ is_state('test.entity', 'world') }}", - "for": {"seconds": 0}, - }, - "action": {"service": "test.automation"}, - } - }, - ) - - hass.states.async_set("test.entity", "world") - await hass.async_block_till_done() - assert len(calls) == 1 - - -async def test_if_fires_on_change_with_for_0_advanced(hass, calls): - """Test for firing on change with for: 0 advanced.""" - context = Context() - assert await async_setup_component( - hass, - automation.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, automation.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { automation.DOMAIN: { "trigger": { @@ -716,8 +536,11 @@ async def test_if_fires_on_change_with_for_0_advanced(hass, calls): }, } }, - ) - + ], +) +async def test_if_fires_on_change_with_for_0_advanced(hass, start_ha, calls): + """Test for firing on change with for: 0 advanced.""" + context = Context() await hass.async_block_till_done() hass.states.async_set("test.entity", "world", context=context) @@ -727,12 +550,10 @@ async def test_if_fires_on_change_with_for_0_advanced(hass, calls): assert calls[0].data["some"] == "template - test.entity - hello - world - 0:00:00" -async def test_if_fires_on_change_with_for_2(hass, calls): - """Test for firing on change with for.""" - context = Context() - assert await async_setup_component( - hass, - automation.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, automation.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { automation.DOMAIN: { "trigger": { @@ -757,8 +578,11 @@ async def test_if_fires_on_change_with_for_2(hass, calls): }, } }, - ) - + ], +) +async def test_if_fires_on_change_with_for_2(hass, start_ha, calls): + """Test for firing on change with for.""" + context = Context() hass.states.async_set("test.entity", "world", context=context) await hass.async_block_till_done() assert len(calls) == 0 @@ -769,11 +593,10 @@ async def test_if_fires_on_change_with_for_2(hass, calls): assert calls[0].data["some"] == "template - test.entity - hello - world - 0:00:05" -async def test_if_not_fires_on_change_with_for(hass, calls): - """Test for firing on change with for.""" - assert await async_setup_component( - hass, - automation.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, automation.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { automation.DOMAIN: { "trigger": { @@ -784,8 +607,10 @@ async def test_if_not_fires_on_change_with_for(hass, calls): "action": {"service": "test.automation"}, } }, - ) - + ], +) +async def test_if_not_fires_on_change_with_for(hass, start_ha, calls): + """Test for firing on change with for.""" hass.states.async_set("test.entity", "world") await hass.async_block_till_done() assert len(calls) == 0 @@ -800,11 +625,10 @@ async def test_if_not_fires_on_change_with_for(hass, calls): assert len(calls) == 0 -async def test_if_not_fires_when_turned_off_with_for(hass, calls): - """Test for firing on change with for.""" - assert await async_setup_component( - hass, - automation.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, automation.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { automation.DOMAIN: { "trigger": { @@ -815,8 +639,10 @@ async def test_if_not_fires_when_turned_off_with_for(hass, calls): "action": {"service": "test.automation"}, } }, - ) - + ], +) +async def test_if_not_fires_when_turned_off_with_for(hass, start_ha, calls): + """Test for firing on change with for.""" hass.states.async_set("test.entity", "world") await hass.async_block_till_done() assert len(calls) == 0 @@ -835,11 +661,10 @@ async def test_if_not_fires_when_turned_off_with_for(hass, calls): assert len(calls) == 0 -async def test_if_fires_on_change_with_for_template_1(hass, calls): - """Test for firing on change with for template.""" - assert await async_setup_component( - hass, - automation.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, automation.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { automation.DOMAIN: { "trigger": { @@ -850,8 +675,10 @@ async def test_if_fires_on_change_with_for_template_1(hass, calls): "action": {"service": "test.automation"}, } }, - ) - + ], +) +async def test_if_fires_on_change_with_for_template_1(hass, start_ha, calls): + """Test for firing on change with for template.""" hass.states.async_set("test.entity", "world") await hass.async_block_till_done() assert len(calls) == 0 @@ -860,11 +687,10 @@ async def test_if_fires_on_change_with_for_template_1(hass, calls): assert len(calls) == 1 -async def test_if_fires_on_change_with_for_template_2(hass, calls): - """Test for firing on change with for template.""" - assert await async_setup_component( - hass, - automation.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, automation.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { automation.DOMAIN: { "trigger": { @@ -875,8 +701,10 @@ async def test_if_fires_on_change_with_for_template_2(hass, calls): "action": {"service": "test.automation"}, } }, - ) - + ], +) +async def test_if_fires_on_change_with_for_template_2(hass, start_ha, calls): + """Test for firing on change with for template.""" hass.states.async_set("test.entity", "world") await hass.async_block_till_done() assert len(calls) == 0 @@ -885,11 +713,10 @@ async def test_if_fires_on_change_with_for_template_2(hass, calls): assert len(calls) == 1 -async def test_if_fires_on_change_with_for_template_3(hass, calls): - """Test for firing on change with for template.""" - assert await async_setup_component( - hass, - automation.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, automation.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { automation.DOMAIN: { "trigger": { @@ -900,8 +727,10 @@ async def test_if_fires_on_change_with_for_template_3(hass, calls): "action": {"service": "test.automation"}, } }, - ) - + ], +) +async def test_if_fires_on_change_with_for_template_3(hass, start_ha, calls): + """Test for firing on change with for template.""" hass.states.async_set("test.entity", "world") await hass.async_block_till_done() assert len(calls) == 0 @@ -910,11 +739,10 @@ async def test_if_fires_on_change_with_for_template_3(hass, calls): assert len(calls) == 1 -async def test_invalid_for_template_1(hass, calls): - """Test for invalid for template.""" - assert await async_setup_component( - hass, - automation.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, automation.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { automation.DOMAIN: { "trigger": { @@ -925,8 +753,10 @@ async def test_invalid_for_template_1(hass, calls): "action": {"service": "test.automation"}, } }, - ) - + ], +) +async def test_invalid_for_template_1(hass, start_ha, calls): + """Test for invalid for template.""" with mock.patch.object(template_trigger, "_LOGGER") as mock_logger: hass.states.async_set("test.entity", "world") await hass.async_block_till_done() From 8c3c2ad8e34e14bbe5d9c68b44166bb9c8fecbb2 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Sat, 11 Sep 2021 00:48:55 +0300 Subject: [PATCH 337/843] Updated changes for aioshelly 1.0.0 (#56083) --- homeassistant/components/shelly/__init__.py | 28 +++++---- .../components/shelly/binary_sensor.py | 2 +- .../components/shelly/config_flow.py | 13 ++-- homeassistant/components/shelly/cover.py | 8 +-- .../components/shelly/device_trigger.py | 4 ++ homeassistant/components/shelly/entity.py | 20 +++--- homeassistant/components/shelly/light.py | 4 +- homeassistant/components/shelly/manifest.json | 2 +- homeassistant/components/shelly/sensor.py | 10 +-- homeassistant/components/shelly/switch.py | 2 +- homeassistant/components/shelly/utils.py | 34 +++++----- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/shelly/test_config_flow.py | 63 +++++++++++-------- 14 files changed, 108 insertions(+), 86 deletions(-) diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 48e27203288..6009f8613fe 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -7,6 +7,7 @@ import logging from typing import Any, Final, cast import aioshelly +from aioshelly.block_device import BlockDevice import async_timeout import voluptuous as vol @@ -89,7 +90,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: temperature_unit = "C" if hass.config.units.is_metric else "F" - options = aioshelly.ConnectionOptions( + options = aioshelly.common.ConnectionOptions( entry.data[CONF_HOST], entry.data.get(CONF_USERNAME), entry.data.get(CONF_PASSWORD), @@ -98,7 +99,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coap_context = await get_coap_context(hass) - device = await aioshelly.Device.create( + device = await BlockDevice.create( aiohttp_client.async_get_clientsession(hass), coap_context, options, @@ -134,7 +135,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.debug("Setting up online device %s", entry.title) try: async with async_timeout.timeout(AIOSHELLY_DEVICE_TIMEOUT_SEC): - await device.initialize(True) + await device.initialize() except (asyncio.TimeoutError, OSError) as err: raise ConfigEntryNotReady from err @@ -146,7 +147,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: "Setup for device %s will resume when device is online", entry.title ) device.subscribe_updates(_async_device_online) - await device.coap_request("s") else: # Restore sensors for sleeping device _LOGGER.debug("Setting up offline device %s", entry.title) @@ -156,7 +156,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_device_setup( - hass: HomeAssistant, entry: ConfigEntry, device: aioshelly.Device + hass: HomeAssistant, entry: ConfigEntry, device: BlockDevice ) -> None: """Set up a device that is online.""" device_wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][ @@ -179,7 +179,7 @@ class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator): """Wrapper for a Shelly device with Home Assistant specific functions.""" def __init__( - self, hass: HomeAssistant, entry: ConfigEntry, device: aioshelly.Device + self, hass: HomeAssistant, entry: ConfigEntry, device: BlockDevice ) -> None: """Initialize the Shelly device wrapper.""" self.device_id: str | None = None @@ -208,7 +208,9 @@ class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator): ) self._last_input_events_count: dict = {} - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop) + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop) + ) @callback def _async_device_updates_handler(self) -> None: @@ -216,6 +218,8 @@ class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator): if not self.device.initialized: return + assert self.device.blocks + # For buttons which are battery powered - set initial value for last_event_count if self.model in SHBTN_MODELS and self._last_input_events_count.get(1) is None: for block in self.device.blocks: @@ -298,7 +302,7 @@ class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator): # This is duplicate but otherwise via_device can't work identifiers={(DOMAIN, self.mac)}, manufacturer="Shelly", - model=aioshelly.MODEL_NAMES.get(self.model, self.model), + model=aioshelly.const.MODEL_NAMES.get(self.model, self.model), sw_version=sw_version, ) self.device_id = entry.id @@ -306,10 +310,8 @@ class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator): def shutdown(self) -> None: """Shutdown the wrapper.""" - if self.device: - self.device.shutdown() - self._async_remove_device_updates_handler() - self.device = None + self.device.shutdown() + self._async_remove_device_updates_handler() @callback def _handle_ha_stop(self, _event: Event) -> None: @@ -321,7 +323,7 @@ class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator): class ShellyDeviceRestWrapper(update_coordinator.DataUpdateCoordinator): """Rest Wrapper for a Shelly device with Home Assistant specific functions.""" - def __init__(self, hass: HomeAssistant, device: aioshelly.Device) -> None: + def __init__(self, hass: HomeAssistant, device: BlockDevice) -> None: """Initialize the Shelly device wrapper.""" if ( device.settings["device"]["type"] diff --git a/homeassistant/components/shelly/binary_sensor.py b/homeassistant/components/shelly/binary_sensor.py index f4b2daf8159..02183c3628e 100644 --- a/homeassistant/components/shelly/binary_sensor.py +++ b/homeassistant/components/shelly/binary_sensor.py @@ -48,7 +48,7 @@ SENSORS: Final = { ("sensor", "dwIsOpened"): BlockAttributeDescription( name="Door", device_class=DEVICE_CLASS_OPENING, - available=lambda block: cast(bool, block.dwIsOpened != -1), + available=lambda block: cast(int, block.dwIsOpened) != -1, ), ("sensor", "flood"): BlockAttributeDescription( name="Flood", device_class=DEVICE_CLASS_MOISTURE diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py index c4ddbc0b0aa..da4413e16b7 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -7,6 +7,7 @@ from typing import Any, Dict, Final, cast import aiohttp import aioshelly +from aioshelly.block_device import BlockDevice import async_timeout import voluptuous as vol @@ -39,13 +40,13 @@ async def validate_input( Data has the keys from DATA_SCHEMA with values provided by the user. """ - options = aioshelly.ConnectionOptions( + options = aioshelly.common.ConnectionOptions( host, data.get(CONF_USERNAME), data.get(CONF_PASSWORD) ) coap_context = await get_coap_context(hass) async with async_timeout.timeout(AIOSHELLY_DEVICE_TIMEOUT_SEC): - device = await aioshelly.Device.create( + device = await BlockDevice.create( aiohttp_client.async_get_clientsession(hass), coap_context, options, @@ -82,7 +83,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): info = await self._async_get_info(host) except HTTP_CONNECT_ERRORS: errors["base"] = "cannot_connect" - except aioshelly.FirmwareUnsupported: + except aioshelly.exceptions.FirmwareUnsupported: return self.async_abort(reason="unsupported_firmware") except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") @@ -165,7 +166,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.info = info = await self._async_get_info(discovery_info["host"]) except HTTP_CONNECT_ERRORS: return self.async_abort(reason="cannot_connect") - except aioshelly.FirmwareUnsupported: + except aioshelly.exceptions.FirmwareUnsupported: return self.async_abort(reason="unsupported_firmware") await self.async_set_unique_id(info["mac"]) @@ -206,7 +207,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="confirm_discovery", description_placeholders={ - "model": aioshelly.MODEL_NAMES.get( + "model": aioshelly.const.MODEL_NAMES.get( self.info["type"], self.info["type"] ), "host": self.host, @@ -219,7 +220,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async with async_timeout.timeout(AIOSHELLY_DEVICE_TIMEOUT_SEC): return cast( Dict[str, Any], - await aioshelly.get_info( + await aioshelly.common.get_info( aiohttp_client.async_get_clientsession(self.hass), host, ), diff --git a/homeassistant/components/shelly/cover.py b/homeassistant/components/shelly/cover.py index 73b8b1baae3..40441ab74d3 100644 --- a/homeassistant/components/shelly/cover.py +++ b/homeassistant/components/shelly/cover.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any, cast -from aioshelly import Block +from aioshelly.block_device import Block from homeassistant.components.cover import ( ATTR_POSITION, @@ -57,7 +57,7 @@ class ShellyCover(ShellyBlockEntity, CoverEntity): if self.control_result: return cast(bool, self.control_result["current_pos"] == 0) - return cast(bool, self.block.rollerPos == 0) + return cast(int, self.block.rollerPos) == 0 @property def current_cover_position(self) -> int: @@ -73,7 +73,7 @@ class ShellyCover(ShellyBlockEntity, CoverEntity): if self.control_result: return cast(bool, self.control_result["state"] == "close") - return cast(bool, self.block.roller == "close") + return self.block.roller == "close" @property def is_opening(self) -> bool: @@ -81,7 +81,7 @@ class ShellyCover(ShellyBlockEntity, CoverEntity): if self.control_result: return cast(bool, self.control_result["state"] == "open") - return cast(bool, self.block.roller == "open") + return self.block.roller == "open" @property def supported_features(self) -> int: diff --git a/homeassistant/components/shelly/device_trigger.py b/homeassistant/components/shelly/device_trigger.py index eae2953e5b8..5d90a10dabc 100644 --- a/homeassistant/components/shelly/device_trigger.py +++ b/homeassistant/components/shelly/device_trigger.py @@ -60,6 +60,8 @@ async def async_validate_trigger_config( trigger = (config[CONF_TYPE], config[CONF_SUBTYPE]) + assert wrapper.device.blocks + for block in wrapper.device.blocks: input_triggers = get_input_triggers(wrapper.device, block) if trigger in input_triggers: @@ -93,6 +95,8 @@ async def async_get_triggers( ) return triggers + assert wrapper.device.blocks + for block in wrapper.device.blocks: input_triggers = get_input_triggers(wrapper.device, block) diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 743dd07414e..a7b75116132 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -6,7 +6,7 @@ from dataclasses import dataclass import logging from typing import Any, Callable, Final, cast -import aioshelly +from aioshelly.block_device import Block import async_timeout from homeassistant.components.sensor import ATTR_STATE_CLASS @@ -62,6 +62,8 @@ async def async_setup_block_attribute_entities( """Set up entities for block attributes.""" blocks = [] + assert wrapper.device.blocks + for block in wrapper.device.blocks: for sensor_id in block.sensor_ids: description = sensors.get((block.type, sensor_id)) @@ -175,10 +177,10 @@ class BlockAttributeDescription: device_class: str | None = None state_class: str | None = None default_enabled: bool = True - available: Callable[[aioshelly.Block], bool] | None = None + available: Callable[[Block], bool] | None = None # Callable (settings, block), return true if entity should be removed - removal_condition: Callable[[dict, aioshelly.Block], bool] | None = None - extra_state_attributes: Callable[[aioshelly.Block], dict | None] | None = None + removal_condition: Callable[[dict, Block], bool] | None = None + extra_state_attributes: Callable[[Block], dict | None] | None = None @dataclass @@ -198,7 +200,7 @@ class RestAttributeDescription: class ShellyBlockEntity(entity.Entity): """Helper class to represent a block.""" - def __init__(self, wrapper: ShellyDeviceWrapper, block: aioshelly.Block) -> None: + def __init__(self, wrapper: ShellyDeviceWrapper, block: Block) -> None: """Initialize Shelly entity.""" self.wrapper = wrapper self.block = block @@ -267,7 +269,7 @@ class ShellyBlockAttributeEntity(ShellyBlockEntity, entity.Entity): def __init__( self, wrapper: ShellyDeviceWrapper, - block: aioshelly.Block, + block: Block, attribute: str, description: BlockAttributeDescription, ) -> None: @@ -418,7 +420,7 @@ class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity, RestoreEnti def __init__( self, wrapper: ShellyDeviceWrapper, - block: aioshelly.Block, + block: Block | None, attribute: str, description: BlockAttributeDescription, entry: entity_registry.RegistryEntry | None = None, @@ -429,7 +431,7 @@ class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity, RestoreEnti self.last_state: StateType = None self.wrapper = wrapper self.attribute = attribute - self.block = block + self.block: Block | None = block # type: ignore[assignment] self.description = description self._unit = self.description.unit @@ -468,6 +470,8 @@ class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity, RestoreEnti _, entity_block, entity_sensor = self.unique_id.split("-") + assert self.wrapper.device.blocks + for block in self.wrapper.device.blocks: if block.description != entity_block: continue diff --git a/homeassistant/components/shelly/light.py b/homeassistant/components/shelly/light.py index 86624410708..9ecc16ecc5a 100644 --- a/homeassistant/components/shelly/light.py +++ b/homeassistant/components/shelly/light.py @@ -5,7 +5,7 @@ import asyncio import logging from typing import Any, Final, cast -from aioshelly import Block +from aioshelly.block_device import Block import async_timeout from homeassistant.components.light import ( @@ -117,7 +117,7 @@ class ShellyLight(ShellyBlockEntity, LightEntity): self._supported_features |= SUPPORT_EFFECT if wrapper.model in MODELS_SUPPORTING_LIGHT_TRANSITION: - match = FIRMWARE_PATTERN.search(wrapper.device.settings.get("fw")) + match = FIRMWARE_PATTERN.search(wrapper.device.settings.get("fw", "")) if ( match is not None and int(match[0]) >= LIGHT_TRANSITION_MIN_FIRMWARE_DATE diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index ab87c4cef38..0c1e90eaaee 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -3,7 +3,7 @@ "name": "Shelly", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/shelly", - "requirements": ["aioshelly==0.6.4"], + "requirements": ["aioshelly==1.0.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index d8d530ed94c..7ffaae82daa 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -40,7 +40,7 @@ SENSORS: Final = { device_class=sensor.DEVICE_CLASS_BATTERY, state_class=sensor.STATE_CLASS_MEASUREMENT, removal_condition=lambda settings, _: settings.get("external_power") == 1, - available=lambda block: cast(bool, block.battery != -1), + available=lambda block: cast(int, block.battery) != -1, ), ("device", "deviceTemp"): BlockAttributeDescription( name="Device Temperature", @@ -162,7 +162,7 @@ SENSORS: Final = { value=lambda value: round(value, 1), device_class=sensor.DEVICE_CLASS_TEMPERATURE, state_class=sensor.STATE_CLASS_MEASUREMENT, - available=lambda block: cast(bool, block.extTemp != 999), + available=lambda block: cast(int, block.extTemp) != 999, ), ("sensor", "humidity"): BlockAttributeDescription( name="Humidity", @@ -170,14 +170,14 @@ SENSORS: Final = { value=lambda value: round(value, 1), device_class=sensor.DEVICE_CLASS_HUMIDITY, state_class=sensor.STATE_CLASS_MEASUREMENT, - available=lambda block: cast(bool, block.extTemp != 999), + available=lambda block: cast(int, block.extTemp) != 999, ), ("sensor", "luminosity"): BlockAttributeDescription( name="Luminosity", unit=LIGHT_LUX, device_class=sensor.DEVICE_CLASS_ILLUMINANCE, state_class=sensor.STATE_CLASS_MEASUREMENT, - available=lambda block: cast(bool, block.luminosity != -1), + available=lambda block: cast(int, block.luminosity) != -1, ), ("sensor", "tilt"): BlockAttributeDescription( name="Tilt", @@ -191,7 +191,7 @@ SENSORS: Final = { icon="mdi:progress-wrench", value=lambda value: round(100 - (value / 3600 / SHAIR_MAX_WORK_HOURS), 1), extra_state_attributes=lambda block: { - "Operational hours": round(block.totalWorkTime / 3600, 1) + "Operational hours": round(cast(int, block.totalWorkTime) / 3600, 1) }, ), ("adc", "adc"): BlockAttributeDescription( diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index 3e35ba878e4..b36bcd42d59 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any, cast -from aioshelly import Block +from aioshelly.block_device import Block from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index d1e2947d5ac..dfd4b1dc78a 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -5,7 +5,7 @@ from datetime import datetime, timedelta import logging from typing import Any, Final, cast -import aioshelly +from aioshelly.block_device import BLOCK_VALUE_UNIT, COAP, Block, BlockDevice from homeassistant.const import EVENT_HOMEASSISTANT_STOP, TEMP_CELSIUS, TEMP_FAHRENHEIT from homeassistant.core import HomeAssistant, callback @@ -40,18 +40,20 @@ async def async_remove_shelly_entity( def temperature_unit(block_info: dict[str, Any]) -> str: """Detect temperature unit.""" - if block_info[aioshelly.BLOCK_VALUE_UNIT] == "F": + if block_info[BLOCK_VALUE_UNIT] == "F": return TEMP_FAHRENHEIT return TEMP_CELSIUS -def get_device_name(device: aioshelly.Device) -> str: +def get_device_name(device: BlockDevice) -> str: """Naming for device.""" return cast(str, device.settings["name"] or device.settings["device"]["hostname"]) -def get_number_of_channels(device: aioshelly.Device, block: aioshelly.Block) -> int: +def get_number_of_channels(device: BlockDevice, block: Block) -> int: """Get number of channels for block type.""" + assert isinstance(device.shelly, dict) + channels = None if block.type == "input": @@ -71,8 +73,8 @@ def get_number_of_channels(device: aioshelly.Device, block: aioshelly.Block) -> def get_entity_name( - device: aioshelly.Device, - block: aioshelly.Block, + device: BlockDevice, + block: Block | None, description: str | None = None, ) -> str: """Naming for switch and sensors.""" @@ -84,10 +86,7 @@ def get_entity_name( return channel_name -def get_device_channel_name( - device: aioshelly.Device, - block: aioshelly.Block, -) -> str: +def get_device_channel_name(device: BlockDevice, block: Block | None) -> str: """Get name based on device and channel name.""" entity_name = get_device_name(device) @@ -98,8 +97,10 @@ def get_device_channel_name( ): return entity_name + assert block.channel + channel_name: str | None = None - mode = block.type + "s" + mode = cast(str, block.type) + "s" if mode in device.settings: channel_name = device.settings[mode][int(block.channel)].get("name") @@ -114,7 +115,7 @@ def get_device_channel_name( return f"{entity_name} channel {chr(int(block.channel)+base)}" -def is_momentary_input(settings: dict[str, Any], block: aioshelly.Block) -> bool: +def is_momentary_input(settings: dict[str, Any], block: Block) -> bool: """Return true if input button settings is set to a momentary type.""" # Shelly Button type is fixed to momentary and no btn_type if settings["device"]["type"] in SHBTN_MODELS: @@ -150,9 +151,7 @@ def get_device_uptime(status: dict[str, Any], last_uptime: str | None) -> str: return last_uptime -def get_input_triggers( - device: aioshelly.Device, block: aioshelly.Block -) -> list[tuple[str, str]]: +def get_input_triggers(device: BlockDevice, block: Block) -> list[tuple[str, str]]: """Return list of input triggers for block.""" if "inputEvent" not in block.sensor_ids or "inputEventCnt" not in block.sensor_ids: return [] @@ -165,6 +164,7 @@ def get_input_triggers( if block.type == "device" or get_number_of_channels(device, block) == 1: subtype = "button" else: + assert block.channel subtype = f"button{int(block.channel)+1}" if device.settings["device"]["type"] in SHBTN_MODELS: @@ -181,9 +181,9 @@ def get_input_triggers( @singleton.singleton("shelly_coap") -async def get_coap_context(hass: HomeAssistant) -> aioshelly.COAP: +async def get_coap_context(hass: HomeAssistant) -> COAP: """Get CoAP context to be used in all Shelly devices.""" - context = aioshelly.COAP() + context = COAP() if DOMAIN in hass.data: port = hass.data[DOMAIN].get(CONF_COAP_PORT, DEFAULT_COAP_PORT) else: diff --git a/requirements_all.txt b/requirements_all.txt index 9f1129892bb..d96a8792be4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -240,7 +240,7 @@ aiopylgtv==0.4.0 aiorecollect==1.0.8 # homeassistant.components.shelly -aioshelly==0.6.4 +aioshelly==1.0.0 # homeassistant.components.switcher_kis aioswitcher==2.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 25522896a2a..2f483571e3f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -161,7 +161,7 @@ aiopylgtv==0.4.0 aiorecollect==1.0.8 # homeassistant.components.shelly -aioshelly==0.6.4 +aioshelly==1.0.0 # homeassistant.components.switcher_kis aioswitcher==2.0.5 diff --git a/tests/components/shelly/test_config_flow.py b/tests/components/shelly/test_config_flow.py index 463c9111a60..81118f928d3 100644 --- a/tests/components/shelly/test_config_flow.py +++ b/tests/components/shelly/test_config_flow.py @@ -32,10 +32,10 @@ async def test_form(hass): assert result["errors"] == {} with patch( - "aioshelly.get_info", + "aioshelly.common.get_info", return_value={"mac": "test-mac", "type": "SHSW-1", "auth": False}, ), patch( - "aioshelly.Device.create", + "aioshelly.block_device.BlockDevice.create", new=AsyncMock( return_value=Mock( settings=MOCK_SETTINGS, @@ -78,10 +78,10 @@ async def test_title_without_name(hass): settings["device"] = settings["device"].copy() settings["device"]["hostname"] = "shelly1pm-12345" with patch( - "aioshelly.get_info", + "aioshelly.common.get_info", return_value={"mac": "test-mac", "type": "SHSW-1", "auth": False}, ), patch( - "aioshelly.Device.create", + "aioshelly.block_device.BlockDevice.create", new=AsyncMock( return_value=Mock( settings=settings, @@ -119,7 +119,7 @@ async def test_form_auth(hass): assert result["errors"] == {} with patch( - "aioshelly.get_info", + "aioshelly.common.get_info", return_value={"mac": "test-mac", "type": "SHSW-1", "auth": True}, ): result2 = await hass.config_entries.flow.async_configure( @@ -131,7 +131,7 @@ async def test_form_auth(hass): assert result["errors"] == {} with patch( - "aioshelly.Device.create", + "aioshelly.block_device.BlockDevice.create", new=AsyncMock( return_value=Mock( settings=MOCK_SETTINGS, @@ -172,7 +172,7 @@ async def test_form_errors_get_info(hass, error): DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch("aioshelly.get_info", side_effect=exc): + with patch("aioshelly.common.get_info", side_effect=exc): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {"host": "1.1.1.1"}, @@ -193,8 +193,10 @@ async def test_form_errors_test_connection(hass, error): ) with patch( - "aioshelly.get_info", return_value={"mac": "test-mac", "auth": False} - ), patch("aioshelly.Device.create", new=AsyncMock(side_effect=exc)): + "aioshelly.common.get_info", return_value={"mac": "test-mac", "auth": False} + ), patch( + "aioshelly.block_device.BlockDevice.create", new=AsyncMock(side_effect=exc) + ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {"host": "1.1.1.1"}, @@ -217,7 +219,7 @@ async def test_form_already_configured(hass): ) with patch( - "aioshelly.get_info", + "aioshelly.common.get_info", return_value={"mac": "test-mac", "type": "SHSW-1", "auth": False}, ): result2 = await hass.config_entries.flow.async_configure( @@ -252,10 +254,10 @@ async def test_user_setup_ignored_device(hass): settings["fw"] = "20201124-092534/v1.9.0@57ac4ad8" with patch( - "aioshelly.get_info", + "aioshelly.common.get_info", return_value={"mac": "test-mac", "type": "SHSW-1", "auth": False}, ), patch( - "aioshelly.Device.create", + "aioshelly.block_device.BlockDevice.create", new=AsyncMock( return_value=Mock( settings=settings, @@ -287,7 +289,10 @@ async def test_form_firmware_unsupported(hass): DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch("aioshelly.get_info", side_effect=aioshelly.FirmwareUnsupported): + with patch( + "aioshelly.common.get_info", + side_effect=aioshelly.exceptions.FirmwareUnsupported, + ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {"host": "1.1.1.1"}, @@ -313,14 +318,17 @@ async def test_form_auth_errors_test_connection(hass, error): DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch("aioshelly.get_info", return_value={"mac": "test-mac", "auth": True}): + with patch( + "aioshelly.common.get_info", + return_value={"mac": "test-mac", "auth": True}, + ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {"host": "1.1.1.1"}, ) with patch( - "aioshelly.Device.create", + "aioshelly.block_device.BlockDevice.create", new=AsyncMock(side_effect=exc), ): result3 = await hass.config_entries.flow.async_configure( @@ -336,10 +344,10 @@ async def test_zeroconf(hass): await setup.async_setup_component(hass, "persistent_notification", {}) with patch( - "aioshelly.get_info", + "aioshelly.common.get_info", return_value={"mac": "test-mac", "type": "SHSW-1", "auth": False}, ), patch( - "aioshelly.Device.create", + "aioshelly.block_device.BlockDevice.create", new=AsyncMock( return_value=Mock( settings=MOCK_SETTINGS, @@ -388,7 +396,7 @@ async def test_zeroconf_sleeping_device(hass): await setup.async_setup_component(hass, "persistent_notification", {}) with patch( - "aioshelly.get_info", + "aioshelly.common.get_info", return_value={ "mac": "test-mac", "type": "SHSW-1", @@ -396,7 +404,7 @@ async def test_zeroconf_sleeping_device(hass): "sleep_mode": True, }, ), patch( - "aioshelly.Device.create", + "aioshelly.block_device.BlockDevice.create", new=AsyncMock( return_value=Mock( settings={ @@ -460,7 +468,7 @@ async def test_zeroconf_sleeping_device_error(hass, error): await setup.async_setup_component(hass, "persistent_notification", {}) with patch( - "aioshelly.get_info", + "aioshelly.common.get_info", return_value={ "mac": "test-mac", "type": "SHSW-1", @@ -468,7 +476,7 @@ async def test_zeroconf_sleeping_device_error(hass, error): "sleep_mode": True, }, ), patch( - "aioshelly.Device.create", + "aioshelly.block_device.BlockDevice.create", new=AsyncMock(side_effect=exc), ): result = await hass.config_entries.flow.async_init( @@ -489,7 +497,7 @@ async def test_zeroconf_already_configured(hass): entry.add_to_hass(hass) with patch( - "aioshelly.get_info", + "aioshelly.common.get_info", return_value={"mac": "test-mac", "type": "SHSW-1", "auth": False}, ): result = await hass.config_entries.flow.async_init( @@ -506,7 +514,10 @@ async def test_zeroconf_already_configured(hass): async def test_zeroconf_firmware_unsupported(hass): """Test we abort if device firmware is unsupported.""" - with patch("aioshelly.get_info", side_effect=aioshelly.FirmwareUnsupported): + with patch( + "aioshelly.common.get_info", + side_effect=aioshelly.exceptions.FirmwareUnsupported, + ): result = await hass.config_entries.flow.async_init( DOMAIN, data=DISCOVERY_INFO, @@ -519,7 +530,7 @@ async def test_zeroconf_firmware_unsupported(hass): async def test_zeroconf_cannot_connect(hass): """Test we get the form.""" - with patch("aioshelly.get_info", side_effect=asyncio.TimeoutError): + with patch("aioshelly.common.get_info", side_effect=asyncio.TimeoutError): result = await hass.config_entries.flow.async_init( DOMAIN, data=DISCOVERY_INFO, @@ -534,7 +545,7 @@ async def test_zeroconf_require_auth(hass): await setup.async_setup_component(hass, "persistent_notification", {}) with patch( - "aioshelly.get_info", + "aioshelly.common.get_info", return_value={"mac": "test-mac", "type": "SHSW-1", "auth": True}, ): result = await hass.config_entries.flow.async_init( @@ -546,7 +557,7 @@ async def test_zeroconf_require_auth(hass): assert result["errors"] == {} with patch( - "aioshelly.Device.create", + "aioshelly.block_device.BlockDevice.create", new=AsyncMock( return_value=Mock( settings=MOCK_SETTINGS, From c785983cce247b5c9ae31a7f8b0f07d1e1533870 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Fri, 10 Sep 2021 17:49:31 -0400 Subject: [PATCH 338/843] Handle entity creation on new added zwave_js value (#55987) * Handle new entity creation when a new value is added * spacing * Update homeassistant/components/zwave_js/__init__.py Co-authored-by: Martin Hjelmare * change variable name and use asyncio.gather * Centralized where discovered value IDs gets managed Co-authored-by: Martin Hjelmare --- homeassistant/components/zwave_js/__init__.py | 131 +++++++--- .../components/zwave_js/discovery.py | 232 +++++++++--------- tests/components/zwave_js/test_init.py | 34 +++ 3 files changed, 249 insertions(+), 148 deletions(-) diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index f38594c1594..71c5b2bf592 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -68,7 +68,11 @@ from .const import ( ZWAVE_JS_VALUE_NOTIFICATION_EVENT, ZWAVE_JS_VALUE_UPDATED_EVENT, ) -from .discovery import ZwaveDiscoveryInfo, async_discover_values +from .discovery import ( + ZwaveDiscoveryInfo, + async_discover_node_values, + async_discover_single_value, +) from .helpers import async_enable_statistics, get_device_id, get_unique_id from .migrate import async_migrate_discovered_value from .services import ZWaveServices @@ -129,13 +133,60 @@ async def async_setup_entry( # noqa: C901 entry_hass_data[DATA_PLATFORM_SETUP] = {} registered_unique_ids: dict[str, dict[str, set[str]]] = defaultdict(dict) + discovered_value_ids: dict[str, set[str]] = defaultdict(set) + + async def async_handle_discovery_info( + device: device_registry.DeviceEntry, + disc_info: ZwaveDiscoveryInfo, + value_updates_disc_info: dict[str, ZwaveDiscoveryInfo], + ) -> None: + """Handle discovery info and all dependent tasks.""" + # This migration logic was added in 2021.3 to handle a breaking change to + # the value_id format. Some time in the future, this call (as well as the + # helper functions) can be removed. + async_migrate_discovered_value( + hass, + ent_reg, + registered_unique_ids[device.id][disc_info.platform], + device, + client, + disc_info, + ) + + platform_setup_tasks = entry_hass_data[DATA_PLATFORM_SETUP] + platform = disc_info.platform + if platform not in platform_setup_tasks: + platform_setup_tasks[platform] = hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, platform) + ) + await platform_setup_tasks[platform] + + LOGGER.debug("Discovered entity: %s", disc_info) + async_dispatcher_send( + hass, f"{DOMAIN}_{entry.entry_id}_add_{platform}", disc_info + ) + + # If we don't need to watch for updates return early + if not disc_info.assumed_state: + return + value_updates_disc_info[disc_info.primary_value.value_id] = disc_info + # If this is the first time we found a value we want to watch for updates, + # return early + if len(value_updates_disc_info) != 1: + return + # add listener for value updated events + entry.async_on_unload( + disc_info.node.on( + "value updated", + lambda event: async_on_value_updated_fire_event( + value_updates_disc_info, event["value"] + ), + ) + ) async def async_on_node_ready(node: ZwaveNode) -> None: """Handle node ready event.""" LOGGER.debug("Processing node %s", node) - - platform_setup_tasks = entry_hass_data[DATA_PLATFORM_SETUP] - # register (or update) node in device registry device = register_node_in_dev_reg(hass, entry, dev_reg, client, node) # We only want to create the defaultdict once, even on reinterviews @@ -145,44 +196,22 @@ async def async_setup_entry( # noqa: C901 value_updates_disc_info: dict[str, ZwaveDiscoveryInfo] = {} # run discovery on all node values and create/update entities - for disc_info in async_discover_values(node, device): - platform = disc_info.platform - - # This migration logic was added in 2021.3 to handle a breaking change to - # the value_id format. Some time in the future, this call (as well as the - # helper functions) can be removed. - async_migrate_discovered_value( - hass, - ent_reg, - registered_unique_ids[device.id][platform], - device, - client, - disc_info, - ) - - if platform not in platform_setup_tasks: - platform_setup_tasks[platform] = hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, platform) + await asyncio.gather( + *( + async_handle_discovery_info(device, disc_info, value_updates_disc_info) + for disc_info in async_discover_node_values( + node, device, discovered_value_ids ) - - await platform_setup_tasks[platform] - - LOGGER.debug("Discovered entity: %s", disc_info) - async_dispatcher_send( - hass, f"{DOMAIN}_{entry.entry_id}_add_{platform}", disc_info ) + ) - # Capture discovery info for values we want to watch for updates - if disc_info.assumed_state: - value_updates_disc_info[disc_info.primary_value.value_id] = disc_info - - # add listener for value updated events if necessary - if value_updates_disc_info: + # add listeners to handle new values that get added later + for event in ("value added", "value updated", "metadata updated"): entry.async_on_unload( node.on( - "value updated", - lambda event: async_on_value_updated( - value_updates_disc_info, event["value"] + event, + lambda event: hass.async_create_task( + async_on_value_added(value_updates_disc_info, event["value"]) ), ) ) @@ -238,6 +267,31 @@ async def async_setup_entry( # noqa: C901 # some visual feedback that something is (in the process of) being added register_node_in_dev_reg(hass, entry, dev_reg, client, node) + async def async_on_value_added( + value_updates_disc_info: dict[str, ZwaveDiscoveryInfo], value: Value + ) -> None: + """Fire value updated event.""" + # If node isn't ready or a device for this node doesn't already exist, we can + # let the node ready event handler perform discovery. If a value has already + # been processed, we don't need to do it again + device_id = get_device_id(client, value.node) + if ( + not value.node.ready + or not (device := dev_reg.async_get_device({device_id})) + or value.value_id in discovered_value_ids[device.id] + ): + return + + LOGGER.debug("Processing node %s added value %s", value.node, value) + await asyncio.gather( + *( + async_handle_discovery_info(device, disc_info, value_updates_disc_info) + for disc_info in async_discover_single_value( + value, device, discovered_value_ids + ) + ) + ) + @callback def async_on_node_removed(node: ZwaveNode) -> None: """Handle node removed event.""" @@ -247,6 +301,7 @@ async def async_setup_entry( # noqa: C901 # note: removal of entity registry entry is handled by core dev_reg.async_remove_device(device.id) # type: ignore registered_unique_ids.pop(device.id, None) # type: ignore + discovered_value_ids.pop(device.id, None) # type: ignore @callback def async_on_value_notification(notification: ValueNotification) -> None: @@ -313,7 +368,7 @@ async def async_setup_entry( # noqa: C901 hass.bus.async_fire(ZWAVE_JS_NOTIFICATION_EVENT, event_data) @callback - def async_on_value_updated( + def async_on_value_updated_fire_event( value_updates_disc_info: dict[str, ZwaveDiscoveryInfo], value: Value ) -> None: """Fire value updated event.""" diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index d5af1c072ee..c32e74c5b5f 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -671,126 +671,138 @@ DISCOVERY_SCHEMAS = [ @callback -def async_discover_values( - node: ZwaveNode, device: DeviceEntry +def async_discover_node_values( + node: ZwaveNode, device: DeviceEntry, discovered_value_ids: dict[str, set[str]] ) -> Generator[ZwaveDiscoveryInfo, None, None]: """Run discovery on ZWave node and return matching (primary) values.""" for value in node.values.values(): - for schema in DISCOVERY_SCHEMAS: - # check manufacturer_id - if ( - schema.manufacturer_id is not None - and value.node.manufacturer_id not in schema.manufacturer_id - ): - continue + # We don't need to rediscover an already processed value_id + if value.value_id in discovered_value_ids[device.id]: + continue + yield from async_discover_single_value(value, device, discovered_value_ids) - # check product_id - if ( - schema.product_id is not None - and value.node.product_id not in schema.product_id - ): - continue - # check product_type - if ( - schema.product_type is not None - and value.node.product_type not in schema.product_type - ): - continue +@callback +def async_discover_single_value( + value: ZwaveValue, device: DeviceEntry, discovered_value_ids: dict[str, set[str]] +) -> Generator[ZwaveDiscoveryInfo, None, None]: + """Run discovery on a single ZWave value and return matching schema info.""" + discovered_value_ids[device.id].add(value.value_id) + for schema in DISCOVERY_SCHEMAS: + # check manufacturer_id + if ( + schema.manufacturer_id is not None + and value.node.manufacturer_id not in schema.manufacturer_id + ): + continue - # check firmware_version_range - if schema.firmware_version_range is not None and ( - ( - schema.firmware_version_range.min is not None - and schema.firmware_version_range.min_ver - > AwesomeVersion(value.node.firmware_version) + # check product_id + if ( + schema.product_id is not None + and value.node.product_id not in schema.product_id + ): + continue + + # check product_type + if ( + schema.product_type is not None + and value.node.product_type not in schema.product_type + ): + continue + + # check firmware_version_range + if schema.firmware_version_range is not None and ( + ( + schema.firmware_version_range.min is not None + and schema.firmware_version_range.min_ver + > AwesomeVersion(value.node.firmware_version) + ) + or ( + schema.firmware_version_range.max is not None + and schema.firmware_version_range.max_ver + < AwesomeVersion(value.node.firmware_version) + ) + ): + continue + + # check firmware_version + if ( + schema.firmware_version is not None + and value.node.firmware_version not in schema.firmware_version + ): + continue + + # check device_class_basic + if not check_device_class( + value.node.device_class.basic, schema.device_class_basic + ): + continue + + # check device_class_generic + if not check_device_class( + value.node.device_class.generic, schema.device_class_generic + ): + continue + + # check device_class_specific + if not check_device_class( + value.node.device_class.specific, schema.device_class_specific + ): + continue + + # check primary value + if not check_value(value, schema.primary_value): + continue + + # check additional required values + if schema.required_values is not None and not all( + any(check_value(val, val_scheme) for val in value.node.values.values()) + for val_scheme in schema.required_values + ): + continue + + # check for values that may not be present + if schema.absent_values is not None and any( + any(check_value(val, val_scheme) for val in value.node.values.values()) + for val_scheme in schema.absent_values + ): + continue + + # resolve helper data from template + resolved_data = None + additional_value_ids_to_watch = set() + if schema.data_template: + try: + resolved_data = schema.data_template.resolve_data(value) + except UnknownValueData as err: + LOGGER.error( + "Discovery for value %s on device '%s' (%s) will be skipped: %s", + value, + device.name_by_user or device.name, + value.node, + err, ) - or ( - schema.firmware_version_range.max is not None - and schema.firmware_version_range.max_ver - < AwesomeVersion(value.node.firmware_version) - ) - ): continue - - # check firmware_version - if ( - schema.firmware_version is not None - and value.node.firmware_version not in schema.firmware_version - ): - continue - - # check device_class_basic - if not check_device_class( - value.node.device_class.basic, schema.device_class_basic - ): - continue - - # check device_class_generic - if not check_device_class( - value.node.device_class.generic, schema.device_class_generic - ): - continue - - # check device_class_specific - if not check_device_class( - value.node.device_class.specific, schema.device_class_specific - ): - continue - - # check primary value - if not check_value(value, schema.primary_value): - continue - - # check additional required values - if schema.required_values is not None and not all( - any(check_value(val, val_scheme) for val in node.values.values()) - for val_scheme in schema.required_values - ): - continue - - # check for values that may not be present - if schema.absent_values is not None and any( - any(check_value(val, val_scheme) for val in node.values.values()) - for val_scheme in schema.absent_values - ): - continue - - # resolve helper data from template - resolved_data = None - additional_value_ids_to_watch = set() - if schema.data_template: - try: - resolved_data = schema.data_template.resolve_data(value) - except UnknownValueData as err: - LOGGER.error( - "Discovery for value %s on device '%s' (%s) will be skipped: %s", - value, - device.name_by_user or device.name, - node, - err, - ) - continue - additional_value_ids_to_watch = schema.data_template.value_ids_to_watch( - resolved_data - ) - - # all checks passed, this value belongs to an entity - yield ZwaveDiscoveryInfo( - node=value.node, - primary_value=value, - assumed_state=schema.assumed_state, - platform=schema.platform, - platform_hint=schema.hint, - platform_data_template=schema.data_template, - platform_data=resolved_data, - additional_value_ids_to_watch=additional_value_ids_to_watch, - entity_registry_enabled_default=schema.entity_registry_enabled_default, + additional_value_ids_to_watch = schema.data_template.value_ids_to_watch( + resolved_data ) - if not schema.allow_multi: - # break out of loop, this value may not be discovered by other schemas/platforms - break + # all checks passed, this value belongs to an entity + yield ZwaveDiscoveryInfo( + node=value.node, + primary_value=value, + assumed_state=schema.assumed_state, + platform=schema.platform, + platform_hint=schema.hint, + platform_data_template=schema.data_template, + platform_data=resolved_data, + additional_value_ids_to_watch=additional_value_ids_to_watch, + entity_registry_enabled_default=schema.entity_registry_enabled_default, + ) + + if not schema.allow_multi: + # return early since this value may not be discovered by other schemas/platforms + return @callback diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 447b052b8c0..5fed86c4d81 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -3,6 +3,7 @@ from copy import deepcopy from unittest.mock import call, patch import pytest +from zwave_js_server.event import Event from zwave_js_server.exceptions import BaseZwaveJSServerError, InvalidServerVersion from zwave_js_server.model.node import Node @@ -124,6 +125,39 @@ async def test_listen_failure(hass, client, error): assert entry.state is ConfigEntryState.SETUP_RETRY +async def test_new_entity_on_value_added(hass, multisensor_6, client, integration): + """Test we create a new entity if a value is added after the fact.""" + node: Node = multisensor_6 + + # Add a value on a random endpoint so we can be sure we should get a new entity + event = Event( + type="value added", + data={ + "source": "node", + "event": "value added", + "nodeId": node.node_id, + "args": { + "commandClassName": "Multilevel Sensor", + "commandClass": 49, + "endpoint": 10, + "property": "Ultraviolet", + "propertyName": "Ultraviolet", + "metadata": { + "type": "number", + "readable": True, + "writeable": False, + "label": "Ultraviolet", + "ccSpecific": {"sensorType": 27, "scale": 0}, + }, + "value": 0, + }, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + assert hass.states.get("sensor.multisensor_6_ultraviolet_10") is not None + + async def test_on_node_added_ready(hass, multisensor_6_state, client, integration): """Test we handle a ready node added event.""" dev_reg = dr.async_get(hass) From bd18bc9f3acdf62226f853673756a70e2d8ac771 Mon Sep 17 00:00:00 2001 From: ehendrix23 Date: Fri, 10 Sep 2021 20:09:54 -0600 Subject: [PATCH 339/843] Bump pymyq to 3.1.4 (#56089) --- homeassistant/components/myq/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/myq/manifest.json b/homeassistant/components/myq/manifest.json index fa9313eb9a1..c8e9c29e4e7 100644 --- a/homeassistant/components/myq/manifest.json +++ b/homeassistant/components/myq/manifest.json @@ -2,7 +2,7 @@ "domain": "myq", "name": "MyQ", "documentation": "https://www.home-assistant.io/integrations/myq", - "requirements": ["pymyq==3.1.3"], + "requirements": ["pymyq==3.1.4"], "codeowners": ["@bdraco","@ehendrix23"], "config_flow": true, "homekit": { diff --git a/requirements_all.txt b/requirements_all.txt index 9f1129892bb..22bf1bb27d2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1629,7 +1629,7 @@ pymonoprice==0.3 pymsteams==0.1.12 # homeassistant.components.myq -pymyq==3.1.3 +pymyq==3.1.4 # homeassistant.components.mysensors pymysensors==0.21.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 25522896a2a..cc9365664ad 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -942,7 +942,7 @@ pymodbus==2.5.3rc1 pymonoprice==0.3 # homeassistant.components.myq -pymyq==3.1.3 +pymyq==3.1.4 # homeassistant.components.mysensors pymysensors==0.21.0 From b11db0b1d7a5449d0f912c61d0956af074ba5fcb Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 11 Sep 2021 04:17:46 +0200 Subject: [PATCH 340/843] Remove unnecessary extra attribute from NUT sensors (#56078) --- homeassistant/components/nut/sensor.py | 7 +------ tests/components/nut/test_sensor.py | 8 -------- 2 files changed, 1 insertion(+), 14 deletions(-) diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py index 995032eb0fd..5c965274eae 100644 --- a/homeassistant/components/nut/sensor.py +++ b/homeassistant/components/nut/sensor.py @@ -5,7 +5,7 @@ import logging from homeassistant.components.nut import PyNUTData from homeassistant.components.sensor import SensorEntity, SensorEntityDescription -from homeassistant.const import ATTR_STATE, CONF_RESOURCES, STATE_UNKNOWN +from homeassistant.const import CONF_RESOURCES, STATE_UNKNOWN from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -133,11 +133,6 @@ class NUTSensor(CoordinatorEntity, SensorEntity): return _format_display_state(self._data.status) return self._data.status.get(self.entity_description.key) - @property - def extra_state_attributes(self): - """Return the sensor attributes.""" - return {ATTR_STATE: _format_display_state(self._data.status)} - def _format_display_state(status): """Return UPS display state.""" diff --git a/tests/components/nut/test_sensor.py b/tests/components/nut/test_sensor.py index 0d8fec71d51..a8c0945c6c0 100644 --- a/tests/components/nut/test_sensor.py +++ b/tests/components/nut/test_sensor.py @@ -21,7 +21,6 @@ async def test_pr3000rt2u(hass): expected_attributes = { "device_class": "battery", "friendly_name": "Ups1 Battery Charge", - "state": "Online", "unit_of_measurement": PERCENTAGE, } # Only test for a subset of attributes in case @@ -47,7 +46,6 @@ async def test_cp1350c(hass): expected_attributes = { "device_class": "battery", "friendly_name": "Ups1 Battery Charge", - "state": "Online", "unit_of_measurement": PERCENTAGE, } # Only test for a subset of attributes in case @@ -72,7 +70,6 @@ async def test_5e850i(hass): expected_attributes = { "device_class": "battery", "friendly_name": "Ups1 Battery Charge", - "state": "Online", "unit_of_measurement": PERCENTAGE, } # Only test for a subset of attributes in case @@ -97,7 +94,6 @@ async def test_5e650i(hass): expected_attributes = { "device_class": "battery", "friendly_name": "Ups1 Battery Charge", - "state": "Online Battery Charging", "unit_of_measurement": PERCENTAGE, } # Only test for a subset of attributes in case @@ -125,7 +121,6 @@ async def test_backupsses600m1(hass): expected_attributes = { "device_class": "battery", "friendly_name": "Ups1 Battery Charge", - "state": "Online", "unit_of_measurement": PERCENTAGE, } # Only test for a subset of attributes in case @@ -152,7 +147,6 @@ async def test_cp1500pfclcd(hass): expected_attributes = { "device_class": "battery", "friendly_name": "Ups1 Battery Charge", - "state": "Online", "unit_of_measurement": PERCENTAGE, } # Only test for a subset of attributes in case @@ -177,7 +171,6 @@ async def test_dl650elcd(hass): expected_attributes = { "device_class": "battery", "friendly_name": "Ups1 Battery Charge", - "state": "Online", "unit_of_measurement": PERCENTAGE, } # Only test for a subset of attributes in case @@ -202,7 +195,6 @@ async def test_blazer_usb(hass): expected_attributes = { "device_class": "battery", "friendly_name": "Ups1 Battery Charge", - "state": "Online", "unit_of_measurement": PERCENTAGE, } # Only test for a subset of attributes in case From 97f28878bb03e91fe8d9cb5e704d953dc91856d3 Mon Sep 17 00:00:00 2001 From: Brent Petit Date: Sat, 11 Sep 2021 00:00:24 -0500 Subject: [PATCH 341/843] Add state_class to Ecobee sensors (#55996) --- homeassistant/components/ecobee/sensor.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/ecobee/sensor.py b/homeassistant/components/ecobee/sensor.py index dfa6cf4cb0a..47e7af66e57 100644 --- a/homeassistant/components/ecobee/sensor.py +++ b/homeassistant/components/ecobee/sensor.py @@ -3,7 +3,11 @@ from __future__ import annotations from pyecobee.const import ECOBEE_STATE_CALIBRATING, ECOBEE_STATE_UNKNOWN -from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, @@ -19,12 +23,14 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( name="Temperature", native_unit_of_measurement=TEMP_FAHRENHEIT, device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key="humidity", name="Humidity", native_unit_of_measurement=PERCENTAGE, device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, ), ) From ec21c5f4a93e02cb770bb0f304e3f68713dd94f0 Mon Sep 17 00:00:00 2001 From: Vilppu Vuorinen Date: Sat, 11 Sep 2021 13:51:04 +0300 Subject: [PATCH 342/843] Update pymelcloud to 2.5.4 (#56096) --- homeassistant/components/melcloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/melcloud/manifest.json b/homeassistant/components/melcloud/manifest.json index 4aff46a22b6..5c8f7d7ca2c 100644 --- a/homeassistant/components/melcloud/manifest.json +++ b/homeassistant/components/melcloud/manifest.json @@ -3,7 +3,7 @@ "name": "MELCloud", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/melcloud", - "requirements": ["pymelcloud==2.5.3"], + "requirements": ["pymelcloud==2.5.4"], "codeowners": ["@vilppuvuorinen"], "iot_class": "cloud_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index 22bf1bb27d2..519c0a1539e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1605,7 +1605,7 @@ pymazda==0.2.1 pymediaroom==0.6.4.1 # homeassistant.components.melcloud -pymelcloud==2.5.3 +pymelcloud==2.5.4 # homeassistant.components.meteoclimatic pymeteoclimatic==0.0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cc9365664ad..6a0252f122b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -924,7 +924,7 @@ pymata-express==1.19 pymazda==0.2.1 # homeassistant.components.melcloud -pymelcloud==2.5.3 +pymelcloud==2.5.4 # homeassistant.components.meteoclimatic pymeteoclimatic==0.0.6 From 1b46190a0ce0445670c7a2dfedf41e49a14dc627 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Sat, 11 Sep 2021 15:38:38 +0200 Subject: [PATCH 343/843] Add view to get installation type during onboarding (#56095) --- homeassistant/components/onboarding/views.py | 24 +++++++++++++ tests/components/onboarding/test_views.py | 37 ++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index dec80642845..cedce0d1d51 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -1,6 +1,7 @@ """Onboarding views.""" import asyncio +from aiohttp.web_exceptions import HTTPUnauthorized import voluptuous as vol from homeassistant.auth.const import GROUP_ID_ADMIN @@ -10,6 +11,7 @@ from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.view import HomeAssistantView from homeassistant.const import HTTP_BAD_REQUEST, HTTP_FORBIDDEN from homeassistant.core import callback +from homeassistant.helpers.system_info import async_get_system_info from .const import ( DEFAULT_AREAS, @@ -25,6 +27,7 @@ from .const import ( async def async_setup(hass, data, store): """Set up the onboarding view.""" hass.http.register_view(OnboardingView(data, store)) + hass.http.register_view(InstallationTypeOnboardingView(data)) hass.http.register_view(UserOnboardingView(data, store)) hass.http.register_view(CoreConfigOnboardingView(data, store)) hass.http.register_view(IntegrationOnboardingView(data, store)) @@ -50,6 +53,27 @@ class OnboardingView(HomeAssistantView): ) +class InstallationTypeOnboardingView(HomeAssistantView): + """Return the installation type during onboarding.""" + + requires_auth = False + url = "/api/onboarding/installation_type" + name = "api:onboarding:installation_type" + + def __init__(self, data): + """Initialize the onboarding installation type view.""" + self._data = data + + async def get(self, request): + """Return the onboarding status.""" + if self._data["done"]: + raise HTTPUnauthorized() + + hass = request.app["hass"] + info = await async_get_system_info(hass) + return self.json({"installation_type": info["installation_type"]}) + + class _BaseOnboardingView(HomeAssistantView): """Base class for onboarding.""" diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index 66f68ad8b33..77666f18fad 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -442,3 +442,40 @@ async def test_onboarding_analytics(hass, hass_storage, hass_client, hass_admin_ resp = await client.post("/api/onboarding/analytics") assert resp.status == 403 + + +async def test_onboarding_installation_type(hass, hass_storage, hass_client): + """Test returning installation type during onboarding.""" + mock_storage(hass_storage, {"done": []}) + await async_setup_component(hass, "persistent_notification", {}) + + assert await async_setup_component(hass, "onboarding", {}) + await hass.async_block_till_done() + + client = await hass_client() + + with patch( + "homeassistant.components.onboarding.views.async_get_system_info", + return_value={"installation_type": "Home Assistant Core"}, + ): + resp = await client.get("/api/onboarding/installation_type") + + assert resp.status == 200 + + resp_content = await resp.json() + assert resp_content["installation_type"] == "Home Assistant Core" + + +async def test_onboarding_installation_type_after_done(hass, hass_storage, hass_client): + """Test raising for installation type after onboarding.""" + mock_storage(hass_storage, {"done": [const.STEP_USER]}) + await async_setup_component(hass, "persistent_notification", {}) + + assert await async_setup_component(hass, "onboarding", {}) + await hass.async_block_till_done() + + client = await hass_client() + + resp = await client.get("/api/onboarding/installation_type") + + assert resp.status == 401 From ed9b271fd093d8aae53e625d35313ac1ec10b1a8 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Sat, 11 Sep 2021 12:27:13 -0600 Subject: [PATCH 344/843] Enforce strict typing for IQVIA (#53408) * Enforce strict typing for IQVIA * Cleanup * Code review * Ignore untyped numpy function --- .strict-typing | 1 + homeassistant/components/iqvia/__init__.py | 32 ++++++++++++++----- homeassistant/components/iqvia/config_flow.py | 11 +++++-- homeassistant/components/iqvia/sensor.py | 20 ++++++++---- mypy.ini | 11 +++++++ 5 files changed, 58 insertions(+), 17 deletions(-) diff --git a/.strict-typing b/.strict-typing index e0993c2954a..68c8f62daf6 100644 --- a/.strict-typing +++ b/.strict-typing @@ -54,6 +54,7 @@ homeassistant.components.huawei_lte.* homeassistant.components.hyperion.* homeassistant.components.image_processing.* homeassistant.components.integration.* +homeassistant.components.iqvia.* homeassistant.components.knx.* homeassistant.components.kraken.* homeassistant.components.lcn.* diff --git a/homeassistant/components/iqvia/__init__.py b/homeassistant/components/iqvia/__init__.py index 37cc7bedb71..affe8622641 100644 --- a/homeassistant/components/iqvia/__init__.py +++ b/homeassistant/components/iqvia/__init__.py @@ -1,14 +1,19 @@ """Support for IQVIA.""" +from __future__ import annotations + import asyncio +from collections.abc import Awaitable from datetime import timedelta from functools import partial +from typing import Any, Callable, Dict, cast from pyiqvia import Client from pyiqvia.errors import IQVIAError from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ATTRIBUTION -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client from homeassistant.helpers.update_coordinator import ( @@ -37,7 +42,7 @@ DEFAULT_SCAN_INTERVAL = timedelta(minutes=30) PLATFORMS = ["sensor"] -async def async_setup_entry(hass, entry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up IQVIA as config entry.""" hass.data.setdefault(DOMAIN, {}) coordinators = {} @@ -51,13 +56,17 @@ async def async_setup_entry(hass, entry): websession = aiohttp_client.async_get_clientsession(hass) client = Client(entry.data[CONF_ZIP_CODE], session=websession) - async def async_get_data_from_api(api_coro): + async def async_get_data_from_api( + api_coro: Callable[..., Awaitable] + ) -> dict[str, Any]: """Get data from a particular API coroutine.""" try: - return await api_coro() + data = await api_coro() except IQVIAError as err: raise UpdateFailed from err + return cast(Dict[str, Any], data) + init_data_update_tasks = [] for sensor_type, api_coro in ( (TYPE_ALLERGY_FORECAST, client.allergens.extended), @@ -90,7 +99,7 @@ async def async_setup_entry(hass, entry): return True -async def async_unload_entry(hass, entry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload an OpenUV config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: @@ -101,7 +110,14 @@ async def async_unload_entry(hass, entry): class IQVIAEntity(CoordinatorEntity, SensorEntity): """Define a base IQVIA entity.""" - def __init__(self, coordinator, entry, sensor_type, name, icon): + def __init__( + self, + coordinator: DataUpdateCoordinator, + entry: ConfigEntry, + sensor_type: str, + name: str, + icon: str, + ) -> None: """Initialize.""" super().__init__(coordinator) @@ -122,7 +138,7 @@ class IQVIAEntity(CoordinatorEntity, SensorEntity): self.update_from_latest_data() self.async_write_ha_state() - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register callbacks.""" await super().async_added_to_hass() @@ -136,6 +152,6 @@ class IQVIAEntity(CoordinatorEntity, SensorEntity): self.update_from_latest_data() @callback - def update_from_latest_data(self): + def update_from_latest_data(self) -> None: """Update the entity from the latest data.""" raise NotImplementedError diff --git a/homeassistant/components/iqvia/config_flow.py b/homeassistant/components/iqvia/config_flow.py index 1e2a82813eb..32ce64014d7 100644 --- a/homeassistant/components/iqvia/config_flow.py +++ b/homeassistant/components/iqvia/config_flow.py @@ -1,9 +1,14 @@ """Config flow to configure the IQVIA component.""" +from __future__ import annotations + +from typing import Any + from pyiqvia import Client from pyiqvia.errors import InvalidZipError import voluptuous as vol from homeassistant import config_entries +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client from .const import CONF_ZIP_CODE, DOMAIN @@ -14,11 +19,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self): + def __init__(self) -> None: """Initialize the config flow.""" self.data_schema = vol.Schema({vol.Required(CONF_ZIP_CODE): str}) - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the start of the config flow.""" if not user_input: return self.async_show_form(step_id="user", data_schema=self.data_schema) diff --git a/homeassistant/components/iqvia/sensor.py b/homeassistant/components/iqvia/sensor.py index 10d33bfb4bf..068ba522e52 100644 --- a/homeassistant/components/iqvia/sensor.py +++ b/homeassistant/components/iqvia/sensor.py @@ -1,10 +1,14 @@ """Support for IQVIA sensors.""" +from __future__ import annotations + from statistics import mean import numpy as np +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_STATE -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import IQVIAEntity from .const import ( @@ -58,7 +62,9 @@ TREND_INCREASING = "Increasing" TREND_SUBSIDING = "Subsiding" -async def async_setup_entry(hass, entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up IQVIA sensors based on a config entry.""" sensor_class_mapping = { TYPE_ALLERGY_FORECAST: ForecastSensor, @@ -76,17 +82,17 @@ async def async_setup_entry(hass, entry, async_add_entities): api_category = API_CATEGORY_MAPPING.get(sensor_type, sensor_type) coordinator = hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id][api_category] sensor_class = sensor_class_mapping[sensor_type] - sensors.append(sensor_class(coordinator, entry, sensor_type, name, icon)) async_add_entities(sensors) -def calculate_trend(indices): +@callback +def calculate_trend(indices: list[float]) -> str: """Calculate the "moving average" of a set of indices.""" index_range = np.arange(0, len(indices)) index_array = np.array(indices) - linear_fit = np.polyfit(index_range, index_array, 1) + linear_fit = np.polyfit(index_range, index_array, 1) # type: ignore slope = round(linear_fit[0], 2) if slope > 0: @@ -102,7 +108,7 @@ class ForecastSensor(IQVIAEntity): """Define sensor related to forecast data.""" @callback - def update_from_latest_data(self): + def update_from_latest_data(self) -> None: """Update the sensor.""" if not self.available: return @@ -151,7 +157,7 @@ class IndexSensor(IQVIAEntity): """Define sensor related to indices.""" @callback - def update_from_latest_data(self): + def update_from_latest_data(self) -> None: """Update the sensor.""" if not self.coordinator.last_update_success: return diff --git a/mypy.ini b/mypy.ini index a43682136e8..f048a3d473f 100644 --- a/mypy.ini +++ b/mypy.ini @@ -605,6 +605,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.iqvia.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.knx.*] check_untyped_defs = true disallow_incomplete_defs = true From a9ed4fa4050ea6ea125ac2acb80e38d0e648b4c1 Mon Sep 17 00:00:00 2001 From: Ruslan Sayfutdinov Date: Sat, 11 Sep 2021 19:40:46 +0100 Subject: [PATCH 345/843] Bump awesomeversion to 21.8.1 (#55817) --- homeassistant/loader.py | 8 ++++++++ homeassistant/package_constraints.txt | 2 +- requirements.txt | 2 +- setup.py | 2 +- tests/test_loader.py | 2 +- 5 files changed, 12 insertions(+), 4 deletions(-) diff --git a/homeassistant/loader.py b/homeassistant/loader.py index e186c5d24ba..fbbd4ad3ae6 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -322,6 +322,14 @@ class Integration: return integration _LOGGER.warning(CUSTOM_WARNING, integration.domain) + if integration.version is None: + _LOGGER.error( + "The custom integration '%s' does not have a " + "version key in the manifest file and was blocked from loading. " + "See https://developers.home-assistant.io/blog/2021/01/29/custom-integration-changes#versions for more details", + integration.domain, + ) + return None try: AwesomeVersion( integration.version, diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 4888193f6a9..05ec806654e 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -7,7 +7,7 @@ astral==2.2 async-upnp-client==0.20.0 async_timeout==3.0.1 attrs==21.2.0 -awesomeversion==21.4.0 +awesomeversion==21.8.1 backports.zoneinfo;python_version<"3.9" bcrypt==3.1.7 certifi>=2020.12.5 diff --git a/requirements.txt b/requirements.txt index 1ea8772728e..7f986750b81 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ aiohttp==3.7.4.post0 astral==2.2 async_timeout==3.0.1 attrs==21.2.0 -awesomeversion==21.4.0 +awesomeversion==21.8.1 backports.zoneinfo;python_version<"3.9" bcrypt==3.1.7 certifi>=2020.12.5 diff --git a/setup.py b/setup.py index eb33b492beb..852cc89bbc5 100755 --- a/setup.py +++ b/setup.py @@ -36,7 +36,7 @@ REQUIRES = [ "astral==2.2", "async_timeout==3.0.1", "attrs==21.2.0", - "awesomeversion==21.4.0", + "awesomeversion==21.8.1", 'backports.zoneinfo;python_version<"3.9"', "bcrypt==3.1.7", "certifi>=2020.12.5", diff --git a/tests/test_loader.py b/tests/test_loader.py index 9786c9fdcfb..892e2da9c51 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -141,7 +141,7 @@ async def test_custom_integration_version_not_valid( await loader.async_get_integration(hass, "test_no_version") assert ( - "The custom integration 'test_no_version' does not have a valid version key (None) in the manifest file and was blocked from loading." + "The custom integration 'test_no_version' does not have a version key in the manifest file and was blocked from loading." in caplog.text ) From cb8c0cb12363869f9dceaef98e17d259c3680b61 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Sat, 11 Sep 2021 20:48:25 +0200 Subject: [PATCH 346/843] Update template/test_lock.py to use pytest (#56102) --- tests/components/template/test_lock.py | 549 ++++++++++--------------- 1 file changed, 207 insertions(+), 342 deletions(-) diff --git a/tests/components/template/test_lock.py b/tests/components/template/test_lock.py index 2cbdf23190d..109e4b348b3 100644 --- a/tests/components/template/test_lock.py +++ b/tests/components/template/test_lock.py @@ -5,42 +5,30 @@ from homeassistant import setup from homeassistant.components import lock from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNAVAILABLE -from tests.common import assert_setup_component, async_mock_service - -@pytest.fixture -def calls(hass): - """Track calls to a mock service.""" - return async_mock_service(hass, "test", "automation") - - -async def test_template_state(hass): - """Test template.""" - with assert_setup_component(1, lock.DOMAIN): - assert await setup.async_setup_component( - hass, - lock.DOMAIN, - { +@pytest.mark.parametrize("count,domain", [(1, lock.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + lock.DOMAIN: { + "platform": "template", + "name": "Test template lock", + "value_template": "{{ states.switch.test_state.state }}", "lock": { - "platform": "template", - "name": "Test template lock", - "value_template": "{{ states.switch.test_state.state }}", - "lock": { - "service": "switch.turn_on", - "entity_id": "switch.test_state", - }, - "unlock": { - "service": "switch.turn_off", - "entity_id": "switch.test_state", - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + "service": "switch.turn_on", + "entity_id": "switch.test_state", + }, + "unlock": { + "service": "switch.turn_off", + "entity_id": "switch.test_state", + }, + } + }, + ], +) +async def test_template_state(hass, start_ha): + """Test template.""" hass.states.async_set("switch.test_state", STATE_ON) await hass.async_block_till_done() @@ -54,196 +42,135 @@ async def test_template_state(hass): assert state.state == lock.STATE_UNLOCKED -async def test_template_state_boolean_on(hass): - """Test the setting of the state with boolean on.""" - with assert_setup_component(1, lock.DOMAIN): - assert await setup.async_setup_component( - hass, - lock.DOMAIN, - { +@pytest.mark.parametrize("count,domain", [(1, lock.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + lock.DOMAIN: { + "platform": "template", + "value_template": "{{ 1 == 1 }}", "lock": { - "platform": "template", - "value_template": "{{ 1 == 1 }}", - "lock": { - "service": "switch.turn_on", - "entity_id": "switch.test_state", - }, - "unlock": { - "service": "switch.turn_off", - "entity_id": "switch.test_state", - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + "service": "switch.turn_on", + "entity_id": "switch.test_state", + }, + "unlock": { + "service": "switch.turn_off", + "entity_id": "switch.test_state", + }, + } + }, + ], +) +async def test_template_state_boolean_on(hass, start_ha): + """Test the setting of the state with boolean on.""" state = hass.states.get("lock.template_lock") assert state.state == lock.STATE_LOCKED -async def test_template_state_boolean_off(hass): - """Test the setting of the state with off.""" - with assert_setup_component(1, lock.DOMAIN): - assert await setup.async_setup_component( - hass, - lock.DOMAIN, - { +@pytest.mark.parametrize("count,domain", [(1, lock.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + lock.DOMAIN: { + "platform": "template", + "value_template": "{{ 1 == 2 }}", "lock": { - "platform": "template", - "value_template": "{{ 1 == 2 }}", - "lock": { - "service": "switch.turn_on", - "entity_id": "switch.test_state", - }, - "unlock": { - "service": "switch.turn_off", - "entity_id": "switch.test_state", - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + "service": "switch.turn_on", + "entity_id": "switch.test_state", + }, + "unlock": { + "service": "switch.turn_off", + "entity_id": "switch.test_state", + }, + } + }, + ], +) +async def test_template_state_boolean_off(hass, start_ha): + """Test the setting of the state with off.""" state = hass.states.get("lock.template_lock") assert state.state == lock.STATE_UNLOCKED -async def test_template_syntax_error(hass): +@pytest.mark.parametrize("count,domain", [(0, lock.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + lock.DOMAIN: { + "platform": "template", + "value_template": "{% if rubbish %}", + "lock": { + "service": "switch.turn_on", + "entity_id": "switch.test_state", + }, + "unlock": { + "service": "switch.turn_off", + "entity_id": "switch.test_state", + }, + } + }, + { + "switch": { + "platform": "lock", + "name": "{{%}", + "value_template": "{{ rubbish }", + "lock": { + "service": "switch.turn_on", + "entity_id": "switch.test_state", + }, + "unlock": { + "service": "switch.turn_off", + "entity_id": "switch.test_state", + }, + }, + }, + {lock.DOMAIN: {"platform": "template", "value_template": "Invalid"}}, + { + lock.DOMAIN: { + "platform": "template", + "not_value_template": "{{ states.switch.test_state.state }}", + "lock": { + "service": "switch.turn_on", + "entity_id": "switch.test_state", + }, + "unlock": { + "service": "switch.turn_off", + "entity_id": "switch.test_state", + }, + } + }, + ], +) +async def test_template_syntax_error(hass, start_ha): """Test templating syntax error.""" - with assert_setup_component(0, lock.DOMAIN): - assert await setup.async_setup_component( - hass, - lock.DOMAIN, - { + assert hass.states.async_all() == [] + + +@pytest.mark.parametrize("count,domain", [(1, lock.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + lock.DOMAIN: { + "platform": "template", + "value_template": "{{ 1 + 1 }}", "lock": { - "platform": "template", - "value_template": "{% if rubbish %}", - "lock": { - "service": "switch.turn_on", - "entity_id": "switch.test_state", - }, - "unlock": { - "service": "switch.turn_off", - "entity_id": "switch.test_state", - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert hass.states.async_all() == [] - - -async def test_invalid_name_does_not_create(hass): - """Test invalid name.""" - with assert_setup_component(0, lock.DOMAIN): - assert await setup.async_setup_component( - hass, - lock.DOMAIN, - { - "switch": { - "platform": "lock", - "name": "{{%}", - "value_template": "{{ rubbish }", - "lock": { - "service": "switch.turn_on", - "entity_id": "switch.test_state", - }, - "unlock": { - "service": "switch.turn_off", - "entity_id": "switch.test_state", - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert hass.states.async_all() == [] - - -async def test_invalid_lock_does_not_create(hass): - """Test invalid lock.""" - with assert_setup_component(0, lock.DOMAIN): - assert await setup.async_setup_component( - hass, - lock.DOMAIN, - {"lock": {"platform": "template", "value_template": "Invalid"}}, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert hass.states.async_all() == [] - - -async def test_missing_template_does_not_create(hass): - """Test missing template.""" - with assert_setup_component(0, lock.DOMAIN): - assert await setup.async_setup_component( - hass, - lock.DOMAIN, - { - "lock": { - "platform": "template", - "not_value_template": "{{ states.switch.test_state.state }}", - "lock": { - "service": "switch.turn_on", - "entity_id": "switch.test_state", - }, - "unlock": { - "service": "switch.turn_off", - "entity_id": "switch.test_state", - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert hass.states.async_all() == [] - - -async def test_template_static(hass, caplog): + "service": "switch.turn_on", + "entity_id": "switch.test_state", + }, + "unlock": { + "service": "switch.turn_off", + "entity_id": "switch.test_state", + }, + } + }, + ], +) +async def test_template_static(hass, start_ha): """Test that we allow static templates.""" - with assert_setup_component(1, lock.DOMAIN): - assert await setup.async_setup_component( - hass, - lock.DOMAIN, - { - "lock": { - "platform": "template", - "value_template": "{{ 1 + 1 }}", - "lock": { - "service": "switch.turn_on", - "entity_id": "switch.test_state", - }, - "unlock": { - "service": "switch.turn_off", - "entity_id": "switch.test_state", - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - state = hass.states.get("lock.template_lock") assert state.state == lock.STATE_UNLOCKED @@ -253,13 +180,12 @@ async def test_template_static(hass, caplog): assert state.state == lock.STATE_LOCKED -async def test_lock_action(hass, calls): - """Test lock action.""" - assert await setup.async_setup_component( - hass, - lock.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, lock.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { - "lock": { + lock.DOMAIN: { "platform": "template", "value_template": "{{ states.switch.test_state.state }}", "lock": {"service": "test.automation"}, @@ -269,12 +195,10 @@ async def test_lock_action(hass, calls): }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_lock_action(hass, start_ha, calls): + """Test lock action.""" hass.states.async_set("switch.test_state", STATE_OFF) await hass.async_block_till_done() @@ -289,13 +213,12 @@ async def test_lock_action(hass, calls): assert len(calls) == 1 -async def test_unlock_action(hass, calls): - """Test unlock action.""" - assert await setup.async_setup_component( - hass, - lock.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, lock.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { - "lock": { + lock.DOMAIN: { "platform": "template", "value_template": "{{ states.switch.test_state.state }}", "lock": { @@ -305,12 +228,10 @@ async def test_unlock_action(hass, calls): "unlock": {"service": "test.automation"}, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_unlock_action(hass, start_ha, calls): + """Test unlock action.""" hass.states.async_set("switch.test_state", STATE_ON) await hass.async_block_till_done() @@ -325,92 +246,38 @@ async def test_unlock_action(hass, calls): assert len(calls) == 1 -async def test_unlocking(hass, calls): +@pytest.mark.parametrize("count,domain", [(1, lock.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + lock.DOMAIN: { + "platform": "template", + "value_template": "{{ states.input_select.test_state.state }}", + "lock": {"service": "test.automation"}, + "unlock": {"service": "test.automation"}, + } + }, + ], +) +@pytest.mark.parametrize( + "test_state", [lock.STATE_UNLOCKING, lock.STATE_LOCKING, lock.STATE_JAMMED] +) +async def test_lock_state(hass, test_state, start_ha): """Test unlocking.""" - assert await setup.async_setup_component( - hass, - lock.DOMAIN, - { - "lock": { - "platform": "template", - "value_template": "{{ states.input_select.test_state.state }}", - "lock": {"service": "test.automation"}, - "unlock": {"service": "test.automation"}, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - hass.states.async_set("input_select.test_state", lock.STATE_UNLOCKING) + hass.states.async_set("input_select.test_state", test_state) await hass.async_block_till_done() state = hass.states.get("lock.template_lock") - assert state.state == lock.STATE_UNLOCKING + assert state.state == test_state -async def test_locking(hass, calls): - """Test unlocking.""" - assert await setup.async_setup_component( - hass, - lock.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, lock.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { - "lock": { - "platform": "template", - "value_template": "{{ states.input_select.test_state.state }}", - "lock": {"service": "test.automation"}, - "unlock": {"service": "test.automation"}, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - hass.states.async_set("input_select.test_state", lock.STATE_LOCKING) - await hass.async_block_till_done() - - state = hass.states.get("lock.template_lock") - assert state.state == lock.STATE_LOCKING - - -async def test_jammed(hass, calls): - """Test jammed.""" - assert await setup.async_setup_component( - hass, - lock.DOMAIN, - { - "lock": { - "platform": "template", - "value_template": "{{ states.input_select.test_state.state }}", - "lock": {"service": "test.automation"}, - "unlock": {"service": "test.automation"}, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - hass.states.async_set("input_select.test_state", lock.STATE_JAMMED) - await hass.async_block_till_done() - - state = hass.states.get("lock.template_lock") - assert state.state == lock.STATE_JAMMED - - -async def test_available_template_with_entities(hass): - """Test availability templates with values from other entities.""" - - await setup.async_setup_component( - hass, - lock.DOMAIN, - { - "lock": { + lock.DOMAIN: { "platform": "template", "value_template": "{{ states('switch.test_state') }}", "lock": {"service": "switch.turn_on", "entity_id": "switch.test_state"}, @@ -421,12 +288,10 @@ async def test_available_template_with_entities(hass): "availability_template": "{{ is_state('availability_state.state', 'on') }}", } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_available_template_with_entities(hass, start_ha): + """Test availability templates with values from other entities.""" # When template returns true.. hass.states.async_set("availability_state.state", STATE_ON) await hass.async_block_till_done() @@ -442,13 +307,12 @@ async def test_available_template_with_entities(hass): assert hass.states.get("lock.template_lock").state == STATE_UNAVAILABLE -async def test_invalid_availability_template_keeps_component_available(hass, caplog): - """Test that an invalid availability keeps the device available.""" - await setup.async_setup_component( - hass, - lock.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, lock.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { - "lock": { + lock.DOMAIN: { "platform": "template", "value_template": "{{ 1 + 1 }}", "availability_template": "{{ x - 12 }}", @@ -459,23 +323,22 @@ async def test_invalid_availability_template_keeps_component_available(hass, cap }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_invalid_availability_template_keeps_component_available( + hass, start_ha, caplog_setup_text +): + """Test that an invalid availability keeps the device available.""" assert hass.states.get("lock.template_lock").state != STATE_UNAVAILABLE - assert ("UndefinedError: 'x' is undefined") in caplog.text + assert ("UndefinedError: 'x' is undefined") in caplog_setup_text -async def test_unique_id(hass): - """Test unique_id option only creates one lock per id.""" - await setup.async_setup_component( - hass, - lock.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, lock.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { - "lock": { + lock.DOMAIN: { "platform": "template", "name": "test_template_lock_01", "unique_id": "not-so-unique-anymore", @@ -485,10 +348,12 @@ async def test_unique_id(hass): "service": "switch.turn_off", "entity_id": "switch.test_state", }, - }, + } }, - ) - + ], +) +async def test_unique_id(hass, start_ha): + """Test unique_id option only creates one lock per id.""" await setup.async_setup_component( hass, lock.DOMAIN, From 6e7ce89c64c93938d5a1d9da0189d2854d5dd696 Mon Sep 17 00:00:00 2001 From: Oxan van Leeuwen Date: Sat, 11 Sep 2021 20:53:29 +0200 Subject: [PATCH 347/843] Fix attribute access on None on startup in ESPHome (#56105) --- homeassistant/components/esphome/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 2e33742b8e5..b3fd4c5075c 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -221,7 +221,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Only communicate changes to the state or attribute tracked if ( - "old_state" in event.data + event.data.get("old_state") is not None and "new_state" in event.data and ( ( From 8a611eb640cc92f15d2a26167f295ab64f0472fd Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 11 Sep 2021 12:02:01 -0700 Subject: [PATCH 348/843] Fix singleton not working with falsey values (#56072) --- homeassistant/helpers/restore_state.py | 70 +++++++++++--------------- homeassistant/helpers/singleton.py | 20 +++----- tests/helpers/test_restore_state.py | 9 ++-- tests/helpers/test_singleton.py | 12 +++-- 4 files changed, 51 insertions(+), 60 deletions(-) diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py index da4d2bacf15..f1e74e26908 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -6,16 +6,10 @@ from datetime import datetime, timedelta import logging from typing import Any, cast -from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import ( - CoreState, - HomeAssistant, - State, - callback, - valid_entity_id, -) +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import HomeAssistant, State, callback, valid_entity_id from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry, start from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.json import JSONEncoder @@ -63,42 +57,36 @@ class StoredState: class RestoreStateData: """Helper class for managing the helper saved data.""" - @classmethod - async def async_get_instance(cls, hass: HomeAssistant) -> RestoreStateData: + @staticmethod + @singleton(DATA_RESTORE_STATE_TASK) + async def async_get_instance(hass: HomeAssistant) -> RestoreStateData: """Get the singleton instance of this data helper.""" + data = RestoreStateData(hass) - @singleton(DATA_RESTORE_STATE_TASK) - async def load_instance(hass: HomeAssistant) -> RestoreStateData: - """Get the singleton instance of this data helper.""" - data = cls(hass) + try: + stored_states = await data.store.async_load() + except HomeAssistantError as exc: + _LOGGER.error("Error loading last states", exc_info=exc) + stored_states = None - try: - stored_states = await data.store.async_load() - except HomeAssistantError as exc: - _LOGGER.error("Error loading last states", exc_info=exc) - stored_states = None + if stored_states is None: + _LOGGER.debug("Not creating cache - no saved states found") + data.last_states = {} + else: + data.last_states = { + item["state"]["entity_id"]: StoredState.from_dict(item) + for item in stored_states + if valid_entity_id(item["state"]["entity_id"]) + } + _LOGGER.debug("Created cache with %s", list(data.last_states)) - if stored_states is None: - _LOGGER.debug("Not creating cache - no saved states found") - data.last_states = {} - else: - data.last_states = { - item["state"]["entity_id"]: StoredState.from_dict(item) - for item in stored_states - if valid_entity_id(item["state"]["entity_id"]) - } - _LOGGER.debug("Created cache with %s", list(data.last_states)) + async def hass_start(hass: HomeAssistant) -> None: + """Start the restore state task.""" + data.async_setup_dump() - if hass.state == CoreState.running: - data.async_setup_dump() - else: - hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_START, data.async_setup_dump - ) + start.async_at_start(hass, hass_start) - return data - - return cast(RestoreStateData, await load_instance(hass)) + return data @classmethod async def async_save_persistent_states(cls, hass: HomeAssistant) -> None: @@ -269,7 +257,9 @@ class RestoreEntity(Entity): # Return None if this entity isn't added to hass yet _LOGGER.warning("Cannot get last state. Entity not added to hass") # type: ignore[unreachable] return None - data = await RestoreStateData.async_get_instance(self.hass) + data = cast( + RestoreStateData, await RestoreStateData.async_get_instance(self.hass) + ) if self.entity_id not in data.last_states: return None return data.last_states[self.entity_id].state diff --git a/homeassistant/helpers/singleton.py b/homeassistant/helpers/singleton.py index a48ea5d64f0..a3cde0b2f27 100644 --- a/homeassistant/helpers/singleton.py +++ b/homeassistant/helpers/singleton.py @@ -26,31 +26,27 @@ def singleton(data_key: str) -> Callable[[FUNC], FUNC]: @bind_hass @functools.wraps(func) def wrapped(hass: HomeAssistant) -> T: - obj: T | None = hass.data.get(data_key) - if obj is None: - obj = hass.data[data_key] = func(hass) - return obj + if data_key not in hass.data: + hass.data[data_key] = func(hass) + return cast(T, hass.data[data_key]) return wrapped @bind_hass @functools.wraps(func) async def async_wrapped(hass: HomeAssistant) -> T: - obj_or_evt = hass.data.get(data_key) - - if not obj_or_evt: + if data_key not in hass.data: evt = hass.data[data_key] = asyncio.Event() - result = await func(hass) - hass.data[data_key] = result evt.set() return cast(T, result) + obj_or_evt = hass.data[data_key] + if isinstance(obj_or_evt, asyncio.Event): - evt = obj_or_evt - await evt.wait() - return cast(T, hass.data.get(data_key)) + await obj_or_evt.wait() + return cast(T, hass.data[data_key]) return cast(T, obj_or_evt) diff --git a/tests/helpers/test_restore_state.py b/tests/helpers/test_restore_state.py index d138a5381da..79719b75326 100644 --- a/tests/helpers/test_restore_state.py +++ b/tests/helpers/test_restore_state.py @@ -32,7 +32,7 @@ async def test_caching_data(hass): await data.store.async_save([state.as_dict() for state in stored_states]) # Emulate a fresh load - hass.data[DATA_RESTORE_STATE_TASK] = None + hass.data.pop(DATA_RESTORE_STATE_TASK) entity = RestoreEntity() entity.hass = hass @@ -59,7 +59,7 @@ async def test_periodic_write(hass): await data.store.async_save([]) # Emulate a fresh load - hass.data[DATA_RESTORE_STATE_TASK] = None + hass.data.pop(DATA_RESTORE_STATE_TASK) entity = RestoreEntity() entity.hass = hass @@ -105,7 +105,7 @@ async def test_save_persistent_states(hass): await data.store.async_save([]) # Emulate a fresh load - hass.data[DATA_RESTORE_STATE_TASK] = None + hass.data.pop(DATA_RESTORE_STATE_TASK) entity = RestoreEntity() entity.hass = hass @@ -170,7 +170,8 @@ async def test_hass_starting(hass): await data.store.async_save([state.as_dict() for state in stored_states]) # Emulate a fresh load - hass.data[DATA_RESTORE_STATE_TASK] = None + hass.state = CoreState.not_running + hass.data.pop(DATA_RESTORE_STATE_TASK) entity = RestoreEntity() entity.hass = hass diff --git a/tests/helpers/test_singleton.py b/tests/helpers/test_singleton.py index c695efd94a8..1d4f496a794 100644 --- a/tests/helpers/test_singleton.py +++ b/tests/helpers/test_singleton.py @@ -12,29 +12,33 @@ def mock_hass(): return Mock(data={}) -async def test_singleton_async(mock_hass): +@pytest.mark.parametrize("result", (object(), {}, [])) +async def test_singleton_async(mock_hass, result): """Test singleton with async function.""" @singleton.singleton("test_key") async def something(hass): - return object() + return result result1 = await something(mock_hass) result2 = await something(mock_hass) + assert result1 is result assert result1 is result2 assert "test_key" in mock_hass.data assert mock_hass.data["test_key"] is result1 -def test_singleton(mock_hass): +@pytest.mark.parametrize("result", (object(), {}, [])) +def test_singleton(mock_hass, result): """Test singleton with function.""" @singleton.singleton("test_key") def something(hass): - return object() + return result result1 = something(mock_hass) result2 = something(mock_hass) + assert result1 is result assert result1 is result2 assert "test_key" in mock_hass.data assert mock_hass.data["test_key"] is result1 From 64fd4969323f335ca661afa7db81835128c5523c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 11 Sep 2021 12:34:01 -0700 Subject: [PATCH 349/843] Bump frontend to 20210911.0 (#56115) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 076420656fd..615ed756138 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", "requirements": [ - "home-assistant-frontend==20210830.0" + "home-assistant-frontend==20210911.0" ], "dependencies": [ "api", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 05ec806654e..a5f5104c0ab 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -16,7 +16,7 @@ cryptography==3.3.2 defusedxml==0.7.1 emoji==1.2.0 hass-nabucasa==0.49.0 -home-assistant-frontend==20210830.0 +home-assistant-frontend==20210911.0 httpx==0.19.0 ifaddr==0.1.7 jinja2==3.0.1 diff --git a/requirements_all.txt b/requirements_all.txt index 519c0a1539e..e3fb347059b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -793,7 +793,7 @@ hole==0.5.1 holidays==0.11.2 # homeassistant.components.frontend -home-assistant-frontend==20210830.0 +home-assistant-frontend==20210911.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6a0252f122b..989f8fa5cfe 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -462,7 +462,7 @@ hole==0.5.1 holidays==0.11.2 # homeassistant.components.frontend -home-assistant-frontend==20210830.0 +home-assistant-frontend==20210911.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 From bcb3c426f434692b27cb649ab0a864d23516030a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Sat, 11 Sep 2021 21:34:19 +0200 Subject: [PATCH 350/843] Blank out discovery info (#56097) --- homeassistant/components/api/__init__.py | 48 +++++++++--------------- tests/components/api/test_init.py | 17 +++++++++ 2 files changed, 34 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index a91d8540286..a6096a14658 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -1,6 +1,5 @@ """Rest API for Home Assistant.""" import asyncio -from contextlib import suppress import json import logging @@ -30,15 +29,12 @@ from homeassistant.const import ( URL_API_STATES, URL_API_STREAM, URL_API_TEMPLATE, - __version__, ) import homeassistant.core as ha from homeassistant.exceptions import ServiceNotFound, TemplateError, Unauthorized from homeassistant.helpers import template from homeassistant.helpers.json import JSONEncoder -from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.helpers.service import async_get_all_descriptions -from homeassistant.helpers.system_info import async_get_system_info _LOGGER = logging.getLogger(__name__) @@ -173,7 +169,11 @@ class APIConfigView(HomeAssistantView): class APIDiscoveryView(HomeAssistantView): - """View to provide Discovery information.""" + """ + View to provide Discovery information. + + DEPRECATED: To be removed in 2022.1 + """ requires_auth = False url = URL_API_DISCOVERY_INFO @@ -181,32 +181,18 @@ class APIDiscoveryView(HomeAssistantView): async def get(self, request): """Get discovery information.""" - hass = request.app["hass"] - uuid = await hass.helpers.instance_id.async_get() - system_info = await async_get_system_info(hass) - - data = { - ATTR_UUID: uuid, - ATTR_BASE_URL: None, - ATTR_EXTERNAL_URL: None, - ATTR_INTERNAL_URL: None, - ATTR_LOCATION_NAME: hass.config.location_name, - ATTR_INSTALLATION_TYPE: system_info[ATTR_INSTALLATION_TYPE], - # always needs authentication - ATTR_REQUIRES_API_PASSWORD: True, - ATTR_VERSION: __version__, - } - - with suppress(NoURLAvailableError): - data["external_url"] = get_url(hass, allow_internal=False) - - with suppress(NoURLAvailableError): - data["internal_url"] = get_url(hass, allow_external=False) - - # Set old base URL based on external or internal - data["base_url"] = data["external_url"] or data["internal_url"] - - return self.json(data) + return self.json( + { + ATTR_UUID: "", + ATTR_BASE_URL: "", + ATTR_EXTERNAL_URL: "", + ATTR_INTERNAL_URL: "", + ATTR_LOCATION_NAME: "", + ATTR_INSTALLATION_TYPE: "", + ATTR_REQUIRES_API_PASSWORD: True, + ATTR_VERSION: "", + } + ) class APIStatesView(HomeAssistantView): diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py index cb3247f43cb..6d5d2608f06 100644 --- a/tests/components/api/test_init.py +++ b/tests/components/api/test_init.py @@ -559,3 +559,20 @@ async def test_api_call_service_bad_data(hass, mock_api_client): "/api/services/test_domain/test_service", json={"hello": 5} ) assert resp.status == 400 + + +async def test_api_get_discovery_info(hass, mock_api_client): + """Test the return of discovery info.""" + resp = await mock_api_client.get(const.URL_API_DISCOVERY_INFO) + result = await resp.json() + + assert result == { + "base_url": "", + "external_url": "", + "installation_type": "", + "internal_url": "", + "location_name": "", + "requires_api_password": True, + "uuid": "", + "version": "", + } From a4a6bf8a851a881a7c8cc0e3a2ddfb5f8e6dfb07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaroslav=20Hansl=C3=ADk?= Date: Sat, 11 Sep 2021 21:34:31 +0200 Subject: [PATCH 351/843] New icon names based on MDI 6.1.95 (#56085) --- homeassistant/components/history/__init__.py | 2 +- homeassistant/components/miflora/sensor.py | 2 +- homeassistant/components/xiaomi_miio/switch.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/history/__init__.py b/homeassistant/components/history/__init__.py index 518e555c280..e05b6466a24 100644 --- a/homeassistant/components/history/__init__.py +++ b/homeassistant/components/history/__init__.py @@ -103,7 +103,7 @@ async def async_setup(hass, config): hass.http.register_view(HistoryPeriodView(filters, use_include_order)) hass.components.frontend.async_register_built_in_panel( - "history", "history", "hass:poll-box" + "history", "history", "hass:chart-box" ) hass.components.websocket_api.async_register_command( ws_get_statistics_during_period diff --git a/homeassistant/components/miflora/sensor.py b/homeassistant/components/miflora/sensor.py index 0e9abe5c757..1b13b4d0537 100644 --- a/homeassistant/components/miflora/sensor.py +++ b/homeassistant/components/miflora/sensor.py @@ -81,7 +81,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="conductivity", name="Conductivity", native_unit_of_measurement=CONDUCTIVITY, - icon="mdi:flash-circle", + icon="mdi:lightning-bolt-circle", ), SensorEntityDescription( key="battery", diff --git a/homeassistant/components/xiaomi_miio/switch.py b/homeassistant/components/xiaomi_miio/switch.py index 7859ff75ec6..30854d65101 100644 --- a/homeassistant/components/xiaomi_miio/switch.py +++ b/homeassistant/components/xiaomi_miio/switch.py @@ -211,7 +211,7 @@ SWITCH_TYPES = ( key=ATTR_CLEAN, feature=FEATURE_SET_CLEAN, name="Clean Mode", - icon="mdi:sparkles", + icon="mdi:shimmer", method_on="async_set_clean_on", method_off="async_set_clean_off", available_with_device_off=False, From f1a88f0563b3d22b2823d8ce67c6f5df42ac07d5 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Sat, 11 Sep 2021 23:28:33 +0300 Subject: [PATCH 352/843] Add config flow support for RPC device (#56118) --- homeassistant/components/shelly/__init__.py | 20 +++- .../components/shelly/config_flow.py | 106 +++++++++++------- homeassistant/components/shelly/logbook.py | 4 +- homeassistant/components/shelly/manifest.json | 2 +- homeassistant/components/shelly/utils.py | 33 +++++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/shelly/test_config_flow.py | 30 ++++- 8 files changed, 145 insertions(+), 54 deletions(-) diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 6009f8613fe..c0d0016392f 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -46,7 +46,11 @@ from .const import ( SLEEP_PERIOD_MULTIPLIER, UPDATE_PERIOD_MULTIPLIER, ) -from .utils import get_coap_context, get_device_name, get_device_sleep_period +from .utils import ( + get_block_device_name, + get_block_device_sleep_period, + get_coap_context, +) PLATFORMS: Final = ["binary_sensor", "cover", "light", "sensor", "switch"] SLEEPING_PLATFORMS: Final = ["binary_sensor", "sensor"] @@ -85,6 +89,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) return False + if entry.data.get("gen") == 2: + return True + hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id] = {} hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][DEVICE] = None @@ -124,7 +131,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if sleep_period is None: data = {**entry.data} - data["sleep_period"] = get_device_sleep_period(device.settings) + data["sleep_period"] = get_block_device_sleep_period(device.settings) data["model"] = device.settings["device"]["type"] hass.config_entries.async_update_entry(entry, data=data) @@ -192,7 +199,9 @@ class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator): UPDATE_PERIOD_MULTIPLIER * device.settings["coiot"]["update_period"] ) - device_name = get_device_name(device) if device.initialized else entry.title + device_name = ( + get_block_device_name(device) if device.initialized else entry.title + ) super().__init__( hass, _LOGGER, @@ -338,7 +347,7 @@ class ShellyDeviceRestWrapper(update_coordinator.DataUpdateCoordinator): super().__init__( hass, _LOGGER, - name=get_device_name(device), + name=get_block_device_name(device), update_interval=timedelta(seconds=update_interval), ) self.device = device @@ -360,6 +369,9 @@ class ShellyDeviceRestWrapper(update_coordinator.DataUpdateCoordinator): async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" + if entry.data.get("gen") == 2: + return True + device = hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id].get(DEVICE) if device is not None: # If device is present, device wrapper is not setup yet diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py index da4413e16b7..31f99b2b1fb 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -3,27 +3,37 @@ from __future__ import annotations import asyncio import logging -from typing import Any, Dict, Final, cast +from typing import Any, Final import aiohttp import aioshelly from aioshelly.block_device import BlockDevice +from aioshelly.rpc_device import RpcDevice import async_timeout import voluptuous as vol -from homeassistant import config_entries, core +from homeassistant import config_entries from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_USERNAME, HTTP_UNAUTHORIZED, ) +from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client from homeassistant.helpers.typing import DiscoveryInfoType from .const import AIOSHELLY_DEVICE_TIMEOUT_SEC, DOMAIN -from .utils import get_coap_context, get_device_sleep_period +from .utils import ( + get_block_device_name, + get_block_device_sleep_period, + get_coap_context, + get_info_auth, + get_info_gen, + get_model_name, + get_rpc_device_name, +) _LOGGER: Final = logging.getLogger(__name__) @@ -33,34 +43,49 @@ HTTP_CONNECT_ERRORS: Final = (asyncio.TimeoutError, aiohttp.ClientError) async def validate_input( - hass: core.HomeAssistant, host: str, data: dict[str, Any] + hass: HomeAssistant, + host: str, + info: dict[str, Any], + data: dict[str, Any], ) -> dict[str, Any]: """Validate the user input allows us to connect. - Data has the keys from DATA_SCHEMA with values provided by the user. + Data has the keys from HOST_SCHEMA with values provided by the user. """ - options = aioshelly.common.ConnectionOptions( - host, data.get(CONF_USERNAME), data.get(CONF_PASSWORD) + host, + data.get(CONF_USERNAME), + data.get(CONF_PASSWORD), ) - coap_context = await get_coap_context(hass) async with async_timeout.timeout(AIOSHELLY_DEVICE_TIMEOUT_SEC): - device = await BlockDevice.create( + if get_info_gen(info) == 2: + rpc_device = await RpcDevice.create( + aiohttp_client.async_get_clientsession(hass), + options, + ) + await rpc_device.shutdown() + return { + "title": get_rpc_device_name(rpc_device), + "sleep_period": 0, + "model": rpc_device.model, + "gen": 2, + } + + # Gen1 + coap_context = await get_coap_context(hass) + block_device = await BlockDevice.create( aiohttp_client.async_get_clientsession(hass), coap_context, options, ) - - device.shutdown() - - # Return info that you want to store in the config entry. - return { - "title": device.settings["name"], - "hostname": device.settings["device"]["hostname"], - "sleep_period": get_device_sleep_period(device.settings), - "model": device.settings["device"]["type"], - } + block_device.shutdown() + return { + "title": get_block_device_name(block_device), + "sleep_period": get_block_device_sleep_period(block_device.settings), + "model": block_device.model, + "gen": 1, + } class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -80,7 +105,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: host: str = user_input[CONF_HOST] try: - info = await self._async_get_info(host) + self.info = await self._async_get_info(host) except HTTP_CONNECT_ERRORS: errors["base"] = "cannot_connect" except aioshelly.exceptions.FirmwareUnsupported: @@ -89,14 +114,16 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - await self.async_set_unique_id(info["mac"]) + await self.async_set_unique_id(self.info["mac"]) self._abort_if_unique_id_configured({CONF_HOST: host}) self.host = host - if info["auth"]: + if get_info_auth(self.info): return await self.async_step_credentials() try: - device_info = await validate_input(self.hass, self.host, {}) + device_info = await validate_input( + self.hass, self.host, self.info, {} + ) except HTTP_CONNECT_ERRORS: errors["base"] = "cannot_connect" except Exception: # pylint: disable=broad-except @@ -104,11 +131,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "unknown" else: return self.async_create_entry( - title=device_info["title"] or device_info["hostname"], + title=device_info["title"], data={ **user_input, "sleep_period": device_info["sleep_period"], "model": device_info["model"], + "gen": device_info["gen"], }, ) @@ -123,7 +151,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input is not None: try: - device_info = await validate_input(self.hass, self.host, user_input) + device_info = await validate_input( + self.hass, self.host, self.info, user_input + ) except aiohttp.ClientResponseError as error: if error.status == HTTP_UNAUTHORIZED: errors["base"] = "invalid_auth" @@ -136,12 +166,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "unknown" else: return self.async_create_entry( - title=device_info["title"] or device_info["hostname"], + title=device_info["title"], data={ **user_input, CONF_HOST: self.host, "sleep_period": device_info["sleep_period"], "model": device_info["model"], + "gen": device_info["gen"], }, ) else: @@ -163,13 +194,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) -> FlowResult: """Handle zeroconf discovery.""" try: - self.info = info = await self._async_get_info(discovery_info["host"]) + self.info = await self._async_get_info(discovery_info["host"]) except HTTP_CONNECT_ERRORS: return self.async_abort(reason="cannot_connect") except aioshelly.exceptions.FirmwareUnsupported: return self.async_abort(reason="unsupported_firmware") - await self.async_set_unique_id(info["mac"]) + await self.async_set_unique_id(self.info["mac"]) self._abort_if_unique_id_configured({CONF_HOST: discovery_info["host"]}) self.host = discovery_info["host"] @@ -177,11 +208,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): "name": discovery_info.get("name", "").split(".")[0] } - if info["auth"]: + if get_info_auth(self.info): return await self.async_step_credentials() try: - self.device_info = await validate_input(self.hass, self.host, {}) + self.device_info = await validate_input(self.hass, self.host, self.info, {}) except HTTP_CONNECT_ERRORS: return self.async_abort(reason="cannot_connect") @@ -194,11 +225,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input is not None: return self.async_create_entry( - title=self.device_info["title"] or self.device_info["hostname"], + title=self.device_info["title"], data={ "host": self.host, "sleep_period": self.device_info["sleep_period"], "model": self.device_info["model"], + "gen": self.device_info["gen"], }, ) @@ -207,9 +239,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="confirm_discovery", description_placeholders={ - "model": aioshelly.const.MODEL_NAMES.get( - self.info["type"], self.info["type"] - ), + "model": get_model_name(self.info), "host": self.host, }, errors=errors, @@ -218,10 +248,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def _async_get_info(self, host: str) -> dict[str, Any]: """Get info from shelly device.""" async with async_timeout.timeout(AIOSHELLY_DEVICE_TIMEOUT_SEC): - return cast( - Dict[str, Any], - await aioshelly.common.get_info( - aiohttp_client.async_get_clientsession(self.hass), - host, - ), + return await aioshelly.common.get_info( + aiohttp_client.async_get_clientsession(self.hass), host ) diff --git a/homeassistant/components/shelly/logbook.py b/homeassistant/components/shelly/logbook.py index deac3b5c05b..ca4818085d0 100644 --- a/homeassistant/components/shelly/logbook.py +++ b/homeassistant/components/shelly/logbook.py @@ -15,7 +15,7 @@ from .const import ( DOMAIN, EVENT_SHELLY_CLICK, ) -from .utils import get_device_name +from .utils import get_block_device_name @callback @@ -30,7 +30,7 @@ def async_describe_events( """Describe shelly.click logbook event.""" wrapper = get_device_wrapper(hass, event.data[ATTR_DEVICE_ID]) if wrapper and wrapper.device.initialized: - device_name = get_device_name(wrapper.device) + device_name = get_block_device_name(wrapper.device) else: device_name = event.data[ATTR_DEVICE] diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index 0c1e90eaaee..ca092295473 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -3,7 +3,7 @@ "name": "Shelly", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/shelly", - "requirements": ["aioshelly==1.0.0"], + "requirements": ["aioshelly==1.0.1"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index dfd4b1dc78a..405c34e6eb9 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -6,6 +6,8 @@ import logging from typing import Any, Final, cast from aioshelly.block_device import BLOCK_VALUE_UNIT, COAP, Block, BlockDevice +from aioshelly.const import MODEL_NAMES +from aioshelly.rpc_device import RpcDevice from homeassistant.const import EVENT_HOMEASSISTANT_STOP, TEMP_CELSIUS, TEMP_FAHRENHEIT from homeassistant.core import HomeAssistant, callback @@ -45,11 +47,18 @@ def temperature_unit(block_info: dict[str, Any]) -> str: return TEMP_CELSIUS -def get_device_name(device: BlockDevice) -> str: +def get_block_device_name(device: BlockDevice) -> str: """Naming for device.""" return cast(str, device.settings["name"] or device.settings["device"]["hostname"]) +def get_rpc_device_name(device: RpcDevice) -> str: + """Naming for device.""" + # Gen2 does not support setting device name + # AP SSID name is used as a nicely formatted device name + return cast(str, device.config["wifi"]["ap"]["ssid"] or device.hostname) + + def get_number_of_channels(device: BlockDevice, block: Block) -> int: """Get number of channels for block type.""" assert isinstance(device.shelly, dict) @@ -88,7 +97,7 @@ def get_entity_name( def get_device_channel_name(device: BlockDevice, block: Block | None) -> str: """Get name based on device and channel name.""" - entity_name = get_device_name(device) + entity_name = get_block_device_name(device) if ( not block @@ -200,7 +209,7 @@ async def get_coap_context(hass: HomeAssistant) -> COAP: return context -def get_device_sleep_period(settings: dict[str, Any]) -> int: +def get_block_device_sleep_period(settings: dict[str, Any]) -> int: """Return the device sleep period in seconds or 0 for non sleeping devices.""" sleep_period = 0 @@ -210,3 +219,21 @@ def get_device_sleep_period(settings: dict[str, Any]) -> int: sleep_period *= 60 # hours to minutes return sleep_period * 60 # minutes to seconds + + +def get_info_auth(info: dict[str, Any]) -> bool: + """Return true if device has authorization enabled.""" + return cast(bool, info.get("auth") or info.get("auth_en")) + + +def get_info_gen(info: dict[str, Any]) -> int: + """Return the device generation from shelly info.""" + return int(info.get("gen", 1)) + + +def get_model_name(info: dict[str, Any]) -> str: + """Return the device model name.""" + if get_info_gen(info) == 2: + return cast(str, MODEL_NAMES.get(info["model"], info["model"])) + + return cast(str, MODEL_NAMES.get(info["type"], info["type"])) diff --git a/requirements_all.txt b/requirements_all.txt index d96a8792be4..1d4c6981105 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -240,7 +240,7 @@ aiopylgtv==0.4.0 aiorecollect==1.0.8 # homeassistant.components.shelly -aioshelly==1.0.0 +aioshelly==1.0.1 # homeassistant.components.switcher_kis aioswitcher==2.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2f483571e3f..ce70997fd12 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -161,7 +161,7 @@ aiopylgtv==0.4.0 aiorecollect==1.0.8 # homeassistant.components.shelly -aioshelly==1.0.0 +aioshelly==1.0.1 # homeassistant.components.switcher_kis aioswitcher==2.0.5 diff --git a/tests/components/shelly/test_config_flow.py b/tests/components/shelly/test_config_flow.py index 81118f928d3..1cc102715c5 100644 --- a/tests/components/shelly/test_config_flow.py +++ b/tests/components/shelly/test_config_flow.py @@ -20,9 +20,13 @@ DISCOVERY_INFO = { "name": "shelly1pm-12345", "properties": {"id": "shelly1pm-12345"}, } +MOCK_CONFIG = { + "wifi": {"ap": {"ssid": "Test name"}}, +} -async def test_form(hass): +@pytest.mark.parametrize("gen", [1, 2]) +async def test_form(hass, gen): """Test we get the form.""" await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( @@ -33,14 +37,24 @@ async def test_form(hass): with patch( "aioshelly.common.get_info", - return_value={"mac": "test-mac", "type": "SHSW-1", "auth": False}, + return_value={"mac": "test-mac", "type": "SHSW-1", "auth": False, "gen": gen}, ), patch( "aioshelly.block_device.BlockDevice.create", new=AsyncMock( return_value=Mock( + model="SHSW-1", settings=MOCK_SETTINGS, ) ), + ), patch( + "aioshelly.rpc_device.RpcDevice.create", + new=AsyncMock( + return_value=Mock( + model="SHSW-1", + config=MOCK_CONFIG, + shutdown=AsyncMock(), + ) + ), ), patch( "homeassistant.components.shelly.async_setup", return_value=True ) as mock_setup, patch( @@ -59,6 +73,7 @@ async def test_form(hass): "host": "1.1.1.1", "model": "SHSW-1", "sleep_period": 0, + "gen": gen, } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -84,6 +99,7 @@ async def test_title_without_name(hass): "aioshelly.block_device.BlockDevice.create", new=AsyncMock( return_value=Mock( + model="SHSW-1", settings=settings, ) ), @@ -105,6 +121,7 @@ async def test_title_without_name(hass): "host": "1.1.1.1", "model": "SHSW-1", "sleep_period": 0, + "gen": 1, } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -134,6 +151,7 @@ async def test_form_auth(hass): "aioshelly.block_device.BlockDevice.create", new=AsyncMock( return_value=Mock( + model="SHSW-1", settings=MOCK_SETTINGS, ) ), @@ -155,6 +173,7 @@ async def test_form_auth(hass): "host": "1.1.1.1", "model": "SHSW-1", "sleep_period": 0, + "gen": 1, "username": "test username", "password": "test password", } @@ -260,6 +279,7 @@ async def test_user_setup_ignored_device(hass): "aioshelly.block_device.BlockDevice.create", new=AsyncMock( return_value=Mock( + model="SHSW-1", settings=settings, ) ), @@ -350,6 +370,7 @@ async def test_zeroconf(hass): "aioshelly.block_device.BlockDevice.create", new=AsyncMock( return_value=Mock( + model="SHSW-1", settings=MOCK_SETTINGS, ) ), @@ -386,6 +407,7 @@ async def test_zeroconf(hass): "host": "1.1.1.1", "model": "SHSW-1", "sleep_period": 0, + "gen": 1, } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -407,6 +429,7 @@ async def test_zeroconf_sleeping_device(hass): "aioshelly.block_device.BlockDevice.create", new=AsyncMock( return_value=Mock( + model="SHSW-1", settings={ "name": "Test name", "device": { @@ -450,6 +473,7 @@ async def test_zeroconf_sleeping_device(hass): "host": "1.1.1.1", "model": "SHSW-1", "sleep_period": 600, + "gen": 1, } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -560,6 +584,7 @@ async def test_zeroconf_require_auth(hass): "aioshelly.block_device.BlockDevice.create", new=AsyncMock( return_value=Mock( + model="SHSW-1", settings=MOCK_SETTINGS, ) ), @@ -581,6 +606,7 @@ async def test_zeroconf_require_auth(hass): "host": "1.1.1.1", "model": "SHSW-1", "sleep_period": 0, + "gen": 1, "username": "test username", "password": "test password", } From 73260c5b88e326f1d2f11a142a80869fcf85e264 Mon Sep 17 00:00:00 2001 From: Steven Looman Date: Sun, 12 Sep 2021 01:38:16 +0200 Subject: [PATCH 353/843] Move parts of ssdp to async_upnp_client (#55540) * Move parts of ssdp to async_upnp_client * Fix test for environments with multiple sources * Fix sonos tests * More fixes/changes * More fixes * Use async_upnp_client==0.21.0 * Pylint/test fixes * More changes after review * Fix tests * Improve testing * Fix mypy * Fix yamaha_musiccast tests? * Changes after review * Pylint * Reduce calls to combined_headers * Update to async_upnp_client==0.21.1 * Update to async_upnp_client==0.21.2 * use as_dict Co-authored-by: J. Nick Koston --- .coveragerc | 1 - .../components/dlna_dmr/manifest.json | 2 +- homeassistant/components/sonos/__init__.py | 8 +- homeassistant/components/ssdp/__init__.py | 320 ++-- homeassistant/components/ssdp/descriptions.py | 69 - homeassistant/components/ssdp/manifest.json | 2 +- homeassistant/components/ssdp/util.py | 42 - homeassistant/components/upnp/__init__.py | 42 +- homeassistant/components/upnp/config_flow.py | 21 +- homeassistant/components/upnp/const.py | 2 - homeassistant/components/upnp/device.py | 69 +- homeassistant/components/upnp/manifest.json | 2 +- .../components/yamaha_musiccast/__init__.py | 2 +- homeassistant/components/yeelight/__init__.py | 4 +- .../components/yeelight/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/sonos/conftest.py | 18 +- tests/components/ssdp/conftest.py | 25 + tests/components/ssdp/test_init.py | 1328 +++++------------ tests/components/upnp/__init__.py | 2 +- tests/components/upnp/common.py | 23 - tests/components/upnp/conftest.py | 187 +++ tests/components/upnp/mock_ssdp_scanner.py | 49 - tests/components/upnp/mock_upnp_device.py | 104 -- tests/components/upnp/test_config_flow.py | 85 +- tests/components/upnp/test_init.py | 16 +- .../yamaha_musiccast/test_config_flow.py | 11 + tests/components/yeelight/__init__.py | 6 +- 30 files changed, 890 insertions(+), 1558 deletions(-) delete mode 100644 homeassistant/components/ssdp/descriptions.py delete mode 100644 homeassistant/components/ssdp/util.py create mode 100644 tests/components/ssdp/conftest.py delete mode 100644 tests/components/upnp/common.py create mode 100644 tests/components/upnp/conftest.py delete mode 100644 tests/components/upnp/mock_ssdp_scanner.py delete mode 100644 tests/components/upnp/mock_upnp_device.py diff --git a/.coveragerc b/.coveragerc index 72249c7684a..a9fc9c433b8 100644 --- a/.coveragerc +++ b/.coveragerc @@ -990,7 +990,6 @@ omit = homeassistant/components/squeezebox/__init__.py homeassistant/components/squeezebox/browse_media.py homeassistant/components/squeezebox/media_player.py - homeassistant/components/ssdp/util.py homeassistant/components/starline/* homeassistant/components/starlingbank/sensor.py homeassistant/components/steam_online/sensor.py diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index 67d9713628a..f844bdf987b 100644 --- a/homeassistant/components/dlna_dmr/manifest.json +++ b/homeassistant/components/dlna_dmr/manifest.json @@ -2,7 +2,7 @@ "domain": "dlna_dmr", "name": "DLNA Digital Media Renderer", "documentation": "https://www.home-assistant.io/integrations/dlna_dmr", - "requirements": ["async-upnp-client==0.20.0"], + "requirements": ["async-upnp-client==0.21.2"], "dependencies": ["network"], "codeowners": [], "iot_class": "local_push" diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index ae3652683d4..6650e5f8904 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -263,8 +263,10 @@ class SonosDiscoveryManager: else: async_dispatcher_send(self.hass, f"{SONOS_SEEN}-{uid}") - @callback - def _async_ssdp_discovered_player(self, info): + async def _async_ssdp_discovered_player(self, info, change): + if change == ssdp.SsdpChange.BYEBYE: + return + discovered_ip = urlparse(info[ssdp.ATTR_SSDP_LOCATION]).hostname boot_seqnum = info.get("X-RINCON-BOOTSEQ") uid = info.get(ssdp.ATTR_UPNP_UDN) @@ -316,7 +318,7 @@ class SonosDiscoveryManager: return self.entry.async_on_unload( - ssdp.async_register_callback( + await ssdp.async_register_callback( self.hass, self._async_ssdp_discovered_player, {"st": UPNP_ST} ) ) diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index 63ad6acb181..ce2901d4f1a 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -2,14 +2,18 @@ from __future__ import annotations import asyncio -from collections.abc import Mapping +from collections.abc import Awaitable from datetime import timedelta +from enum import Enum from ipaddress import IPv4Address, IPv6Address import logging -from typing import Any, Callable +from typing import Any, Callable, Mapping -from async_upnp_client.search import SSDPListener +from async_upnp_client.aiohttp import AiohttpSessionRequester +from async_upnp_client.const import DeviceOrServiceType, SsdpHeaders, SsdpSource +from async_upnp_client.description_cache import DescriptionCache from async_upnp_client.ssdp import SSDP_PORT +from async_upnp_client.ssdp_listener import SsdpDevice, SsdpListener from async_upnp_client.utils import CaseInsensitiveDict from homeassistant import config_entries @@ -19,12 +23,12 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, MATCH_ALL, ) -from homeassistant.core import CoreState, HomeAssistant, callback as core_callback +from homeassistant.core import HomeAssistant, callback as core_callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_ssdp, bind_hass -from .descriptions import DescriptionManager from .flow import FlowDispatcher, SSDPFlow DOMAIN = "ssdp" @@ -61,14 +65,25 @@ DISCOVERY_MAPPING = { "location": ATTR_SSDP_LOCATION, } +SsdpChange = Enum("SsdpChange", "ALIVE BYEBYE UPDATE") +SsdpCallback = Callable[[Mapping[str, Any], SsdpChange], Awaitable] + + +SSDP_SOURCE_SSDP_CHANGE_MAPPING: Mapping[SsdpSource, SsdpChange] = { + SsdpSource.SEARCH: SsdpChange.ALIVE, + SsdpSource.ADVERTISEMENT_ALIVE: SsdpChange.ALIVE, + SsdpSource.ADVERTISEMENT_BYEBYE: SsdpChange.BYEBYE, + SsdpSource.ADVERTISEMENT_UPDATE: SsdpChange.UPDATE, +} + _LOGGER = logging.getLogger(__name__) @bind_hass -def async_register_callback( +async def async_register_callback( hass: HomeAssistant, - callback: Callable[[dict], None], + callback: SsdpCallback, match_dict: None | dict[str, str] = None, ) -> Callable[[], None]: """Register to receive a callback on ssdp broadcast. @@ -76,60 +91,61 @@ def async_register_callback( Returns a callback that can be used to cancel the registration. """ scanner: Scanner = hass.data[DOMAIN] - return scanner.async_register_callback(callback, match_dict) + return await scanner.async_register_callback(callback, match_dict) @bind_hass -def async_get_discovery_info_by_udn_st( # pylint: disable=invalid-name +async def async_get_discovery_info_by_udn_st( # pylint: disable=invalid-name hass: HomeAssistant, udn: str, st: str ) -> dict[str, str] | None: """Fetch the discovery info cache.""" scanner: Scanner = hass.data[DOMAIN] - return scanner.async_get_discovery_info_by_udn_st(udn, st) + return await scanner.async_get_discovery_info_by_udn_st(udn, st) @bind_hass -def async_get_discovery_info_by_st( # pylint: disable=invalid-name +async def async_get_discovery_info_by_st( # pylint: disable=invalid-name hass: HomeAssistant, st: str ) -> list[dict[str, str]]: """Fetch all the entries matching the st.""" scanner: Scanner = hass.data[DOMAIN] - return scanner.async_get_discovery_info_by_st(st) + return await scanner.async_get_discovery_info_by_st(st) @bind_hass -def async_get_discovery_info_by_udn( +async def async_get_discovery_info_by_udn( hass: HomeAssistant, udn: str ) -> list[dict[str, str]]: """Fetch all the entries matching the udn.""" scanner: Scanner = hass.data[DOMAIN] - return scanner.async_get_discovery_info_by_udn(udn) + return await scanner.async_get_discovery_info_by_udn(udn) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the SSDP integration.""" - scanner = hass.data[DOMAIN] = Scanner(hass, await async_get_ssdp(hass)) + scanner = hass.data[DOMAIN] = Scanner(hass) asyncio.create_task(scanner.async_start()) return True -@core_callback -def _async_process_callbacks( - callbacks: list[Callable[[dict], None]], discovery_info: dict[str, str] +async def _async_process_callbacks( + callbacks: list[SsdpCallback], + discovery_info: dict[str, str], + ssdp_change: SsdpChange, ) -> None: for callback in callbacks: try: - callback(discovery_info) + await callback(discovery_info, ssdp_change) except Exception: # pylint: disable=broad-except _LOGGER.exception("Failed to callback info: %s", discovery_info) @core_callback def _async_headers_match( - headers: Mapping[str, str], match_dict: dict[str, str] + headers: Mapping[str, Any], match_dict: dict[str, str] ) -> bool: for header, val in match_dict.items(): if val == MATCH_ALL: @@ -141,25 +157,39 @@ def _async_headers_match( class Scanner: - """Class to manage SSDP scanning.""" + """Class to manage SSDP searching and SSDP advertisements.""" - def __init__( - self, hass: HomeAssistant, integration_matchers: dict[str, list[dict[str, str]]] - ) -> None: + def __init__(self, hass: HomeAssistant) -> None: """Initialize class.""" self.hass = hass - self.seen: set[tuple[str, str | None]] = set() - self.cache: dict[tuple[str, str], Mapping[str, str]] = {} - self._integration_matchers = integration_matchers self._cancel_scan: Callable[[], None] | None = None - self._ssdp_listeners: list[SSDPListener] = [] - self._callbacks: list[tuple[Callable[[dict], None], dict[str, str]]] = [] - self.flow_dispatcher: FlowDispatcher | None = None - self.description_manager: DescriptionManager | None = None + self._ssdp_listeners: list[SsdpListener] = [] + self._callbacks: list[tuple[SsdpCallback, dict[str, str]]] = [] + self._flow_dispatcher: FlowDispatcher | None = None + self._description_cache: DescriptionCache | None = None + self._integration_matchers: dict[str, list[dict[str, str]]] | None = None - @core_callback - def async_register_callback( - self, callback: Callable[[dict], None], match_dict: None | dict[str, str] = None + @property + def _ssdp_devices(self) -> list[SsdpDevice]: + """Get all seen devices.""" + return [ + ssdp_device + for ssdp_listener in self._ssdp_listeners + for ssdp_device in ssdp_listener.devices.values() + ] + + @property + def _all_headers_from_ssdp_devices( + self, + ) -> dict[tuple[str, str], Mapping[str, Any]]: + return { + (ssdp_device.udn, dst): headers + for ssdp_device in self._ssdp_devices + for dst, headers in ssdp_device.all_combined_headers.items() + } + + async def async_register_callback( + self, callback: SsdpCallback, match_dict: None | dict[str, str] = None ) -> Callable[[], None]: """Register a callback.""" if match_dict is None: @@ -167,12 +197,13 @@ class Scanner: # Make sure any entries that happened # before the callback was registered are fired - if self.hass.state != CoreState.running: - for headers in self.cache.values(): - if _async_headers_match(headers, match_dict): - _async_process_callbacks( - [callback], self._async_headers_to_discovery_info(headers) - ) + for headers in self._all_headers_from_ssdp_devices.values(): + if _async_headers_match(headers, match_dict): + await _async_process_callbacks( + [callback], + await self._async_headers_to_discovery_info(headers), + SsdpChange.ALIVE, + ) callback_entry = (callback, match_dict) self._callbacks.append(callback_entry) @@ -183,14 +214,19 @@ class Scanner: return _async_remove_callback - @core_callback - def async_stop(self, *_: Any) -> None: + async def async_stop(self, *_: Any) -> None: """Stop the scanner.""" assert self._cancel_scan is not None self._cancel_scan() - for listener in self._ssdp_listeners: - listener.async_stop() - self._ssdp_listeners = [] + + await self._async_stop_ssdp_listeners() + + async def _async_stop_ssdp_listeners(self) -> None: + """Stop the SSDP listeners.""" + await asyncio.gather( + *(listener.async_stop() for listener in self._ssdp_listeners), + return_exceptions=True, + ) async def _async_build_source_set(self) -> set[IPv4Address | IPv6Address]: """Build the list of ssdp sources.""" @@ -208,34 +244,57 @@ class Scanner: } async def async_scan(self, *_: Any) -> None: - """Scan for new entries using ssdp default and broadcast target.""" + """Scan for new entries using ssdp listeners.""" + await self.async_scan_multicast() + await self.async_scan_broadcast() + + async def async_scan_multicast(self, *_: Any) -> None: + """Scan for new entries using multicase target.""" + for ssdp_listener in self._ssdp_listeners: + await ssdp_listener.async_search() + + async def async_scan_broadcast(self, *_: Any) -> None: + """Scan for new entries using broadcast target.""" + # Some sonos devices only seem to respond if we send to the broadcast + # address. This matches pysonos' behavior + # https://github.com/amelchio/pysonos/blob/d4329b4abb657d106394ae69357805269708c996/pysonos/discovery.py#L120 for listener in self._ssdp_listeners: - listener.async_search() try: IPv4Address(listener.source_ip) except ValueError: continue - # Some sonos devices only seem to respond if we send to the broadcast - # address. This matches pysonos' behavior - # https://github.com/amelchio/pysonos/blob/d4329b4abb657d106394ae69357805269708c996/pysonos/discovery.py#L120 - listener.async_search((str(IPV4_BROADCAST), SSDP_PORT)) + await listener.async_search((str(IPV4_BROADCAST), SSDP_PORT)) async def async_start(self) -> None: - """Start the scanner.""" - self.description_manager = DescriptionManager(self.hass) - self.flow_dispatcher = FlowDispatcher(self.hass) + """Start the scanners.""" + session = async_get_clientsession(self.hass) + requester = AiohttpSessionRequester(session, True, 10) + self._description_cache = DescriptionCache(requester) + self._flow_dispatcher = FlowDispatcher(self.hass) + self._integration_matchers = await async_get_ssdp(self.hass) + + await self._async_start_ssdp_listeners() + + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_stop) + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STARTED, self._flow_dispatcher.async_start + ) + self._cancel_scan = async_track_time_interval( + self.hass, self.async_scan, SCAN_INTERVAL + ) + + # Trigger the initial-scan. + await self.async_scan() + + async def _async_start_ssdp_listeners(self) -> None: + """Start the SSDP Listeners.""" for source_ip in await self._async_build_source_set(): self._ssdp_listeners.append( - SSDPListener( - async_connect_callback=self.async_scan, - async_callback=self._async_process_entry, + SsdpListener( + async_callback=self._ssdp_listener_callback, source_ip=source_ip, ) ) - self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_stop) - self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STARTED, self.flow_dispatcher.async_start - ) results = await asyncio.gather( *(listener.async_start() for listener in self._ssdp_listeners), return_exceptions=True, @@ -251,135 +310,116 @@ class Scanner: failed_listeners.append(self._ssdp_listeners[idx]) for listener in failed_listeners: self._ssdp_listeners.remove(listener) - self._cancel_scan = async_track_time_interval( - self.hass, self.async_scan, SCAN_INTERVAL - ) @core_callback def _async_get_matching_callbacks( - self, headers: Mapping[str, str] - ) -> list[Callable[[dict], None]]: + self, + combined_headers: SsdpHeaders, + ) -> list[SsdpCallback]: """Return a list of callbacks that match.""" return [ callback for callback, match_dict in self._callbacks - if _async_headers_match(headers, match_dict) + if _async_headers_match(combined_headers, match_dict) ] @core_callback - def _async_matching_domains(self, info_with_req: CaseInsensitiveDict) -> set[str]: + def _async_matching_domains(self, info_with_desc: CaseInsensitiveDict) -> set[str]: + assert self._integration_matchers is not None domains = set() for domain, matchers in self._integration_matchers.items(): for matcher in matchers: - if all(info_with_req.get(k) == v for (k, v) in matcher.items()): + if all(info_with_desc.get(k) == v for (k, v) in matcher.items()): domains.add(domain) return domains - def _async_seen(self, header_st: str | None, header_location: str | None) -> bool: - """Check if we have seen a specific st and optional location.""" - if header_st is None: - return True - return (header_st, header_location) in self.seen + async def _ssdp_listener_callback( + self, ssdp_device: SsdpDevice, dst: DeviceOrServiceType, source: SsdpSource + ) -> None: + """Handle a device/service change.""" + _LOGGER.debug( + "Change, ssdp_device: %s, dst: %s, source: %s", ssdp_device, dst, source + ) - def _async_see(self, header_st: str | None, header_location: str | None) -> None: - """Mark a specific st and optional location as seen.""" - if header_st is not None: - self.seen.add((header_st, header_location)) + location = ssdp_device.location + info_desc = await self._async_get_description_dict(location) or {} + combined_headers = ssdp_device.combined_headers(dst) + info_with_desc = CaseInsensitiveDict(combined_headers, **info_desc) + discovery_info = discovery_info_from_headers_and_description(info_with_desc) - def _async_unsee(self, header_st: str | None, header_location: str | None) -> None: - """If we see a device in a new location, unsee the original location.""" - if header_st is not None: - self.seen.discard((header_st, header_location)) + callbacks = self._async_get_matching_callbacks(combined_headers) + ssdp_change = SSDP_SOURCE_SSDP_CHANGE_MAPPING[source] + await _async_process_callbacks(callbacks, discovery_info, ssdp_change) - async def _async_process_entry(self, headers: Mapping[str, str]) -> None: - """Process SSDP entries.""" - _LOGGER.debug("_async_process_entry: %s", headers) - h_st = headers.get("st") - h_location = headers.get("location") + for domain in self._async_matching_domains(info_with_desc): + _LOGGER.debug("Discovered %s at %s", domain, location) - if h_st and (udn := _udn_from_usn(headers.get("usn"))): - cache_key = (udn, h_st) - if old_headers := self.cache.get(cache_key): - old_h_location = old_headers.get("location") - if h_location != old_h_location: - self._async_unsee(old_headers.get("st"), old_h_location) - self.cache[cache_key] = headers - - callbacks = self._async_get_matching_callbacks(headers) - if self._async_seen(h_st, h_location) and not callbacks: - return - - assert self.description_manager is not None - info_req = await self.description_manager.fetch_description(h_location) or {} - info_with_req = CaseInsensitiveDict(**headers, **info_req) - discovery_info = discovery_info_from_headers_and_request(info_with_req) - - _async_process_callbacks(callbacks, discovery_info) - - if self._async_seen(h_st, h_location): - return - self._async_see(h_st, h_location) - - for domain in self._async_matching_domains(info_with_req): - _LOGGER.debug("Discovered %s at %s", domain, h_location) flow: SSDPFlow = { "domain": domain, "context": {"source": config_entries.SOURCE_SSDP}, "data": discovery_info, } - assert self.flow_dispatcher is not None - self.flow_dispatcher.create(flow) + assert self._flow_dispatcher is not None + self._flow_dispatcher.create(flow) - @core_callback - def _async_headers_to_discovery_info( - self, headers: Mapping[str, str] - ) -> dict[str, str]: + async def _async_get_description_dict( + self, location: str | None + ) -> Mapping[str, str]: + """Get description dict.""" + assert self._description_cache is not None + return await self._description_cache.async_get_description_dict(location) or {} + + async def _async_headers_to_discovery_info( + self, headers: Mapping[str, Any] + ) -> dict[str, Any]: """Combine the headers and description into discovery_info. Building this is a bit expensive so we only do it on demand. """ - assert self.description_manager is not None + assert self._description_cache is not None location = headers["location"] - info_req = self.description_manager.async_cached_description(location) or {} - return discovery_info_from_headers_and_request( - CaseInsensitiveDict(**headers, **info_req) + info_desc = ( + await self._description_cache.async_get_description_dict(location) or {} + ) + return discovery_info_from_headers_and_description( + CaseInsensitiveDict(headers, **info_desc) ) - @core_callback - def async_get_discovery_info_by_udn_st( # pylint: disable=invalid-name + async def async_get_discovery_info_by_udn_st( # pylint: disable=invalid-name self, udn: str, st: str - ) -> dict[str, str] | None: + ) -> dict[str, Any] | None: """Return discovery_info for a udn and st.""" - if headers := self.cache.get((udn, st)): - return self._async_headers_to_discovery_info(headers) + if headers := self._all_headers_from_ssdp_devices.get((udn, st)): + return await self._async_headers_to_discovery_info(headers) return None - @core_callback - def async_get_discovery_info_by_st( # pylint: disable=invalid-name + async def async_get_discovery_info_by_st( # pylint: disable=invalid-name self, st: str - ) -> list[dict[str, str]]: + ) -> list[dict[str, Any]]: """Return matching discovery_infos for a st.""" return [ - self._async_headers_to_discovery_info(headers) - for udn_st, headers in self.cache.items() + await self._async_headers_to_discovery_info(headers) + for udn_st, headers in self._all_headers_from_ssdp_devices.items() if udn_st[1] == st ] - @core_callback - def async_get_discovery_info_by_udn(self, udn: str) -> list[dict[str, str]]: + async def async_get_discovery_info_by_udn(self, udn: str) -> list[dict[str, Any]]: """Return matching discovery_infos for a udn.""" return [ - self._async_headers_to_discovery_info(headers) - for udn_st, headers in self.cache.items() + await self._async_headers_to_discovery_info(headers) + for udn_st, headers in self._all_headers_from_ssdp_devices.items() if udn_st[0] == udn ] -def discovery_info_from_headers_and_request( - info_with_req: CaseInsensitiveDict, -) -> dict[str, str]: +def discovery_info_from_headers_and_description( + info_with_desc: CaseInsensitiveDict, +) -> dict[str, Any]: """Convert headers and description to discovery_info.""" - info = {DISCOVERY_MAPPING.get(k.lower(), k): v for k, v in info_with_req.items()} + info = { + DISCOVERY_MAPPING.get(k.lower(), k): v + for k, v in info_with_desc.as_dict().items() + } if ATTR_UPNP_UDN not in info and ATTR_SSDP_USN in info: if udn := _udn_from_usn(info[ATTR_SSDP_USN]): diff --git a/homeassistant/components/ssdp/descriptions.py b/homeassistant/components/ssdp/descriptions.py deleted file mode 100644 index e754b10669a..00000000000 --- a/homeassistant/components/ssdp/descriptions.py +++ /dev/null @@ -1,69 +0,0 @@ -"""The SSDP integration.""" -from __future__ import annotations - -import asyncio -import logging - -import aiohttp -from defusedxml import ElementTree - -from homeassistant.core import HomeAssistant, callback - -from .util import etree_to_dict - -_LOGGER = logging.getLogger(__name__) - - -class DescriptionManager: - """Class to cache and manage fetching descriptions.""" - - def __init__(self, hass: HomeAssistant) -> None: - """Init the manager.""" - self.hass = hass - self._description_cache: dict[str, None | dict[str, str]] = {} - - async def fetch_description( - self, xml_location: str | None - ) -> None | dict[str, str]: - """Fetch the location or get it from the cache.""" - if xml_location is None: - return None - if xml_location not in self._description_cache: - try: - self._description_cache[xml_location] = await self._fetch_description( - xml_location - ) - except Exception: # pylint: disable=broad-except - # If it fails, cache the failure so we do not keep trying over and over - self._description_cache[xml_location] = None - _LOGGER.exception("Failed to fetch ssdp data from: %s", xml_location) - - return self._description_cache[xml_location] - - @callback - def async_cached_description(self, xml_location: str) -> None | dict[str, str]: - """Fetch the description from the cache.""" - return self._description_cache.get(xml_location) - - async def _fetch_description(self, xml_location: str) -> None | dict[str, str]: - """Fetch an XML description.""" - session = self.hass.helpers.aiohttp_client.async_get_clientsession() - try: - for _ in range(2): - resp = await session.get(xml_location, timeout=5) - # Samsung Smart TV sometimes returns an empty document the - # first time. Retry once. - if xml := await resp.text(errors="replace"): - break - except (aiohttp.ClientError, asyncio.TimeoutError) as err: - _LOGGER.debug("Error fetching %s: %s", xml_location, err) - return None - - try: - tree = ElementTree.fromstring(xml) - except ElementTree.ParseError as err: - _LOGGER.debug("Error parsing %s: %s", xml_location, err) - return None - - root = etree_to_dict(tree).get("root") or {} - return root.get("device") or {} diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index 746e90c7388..1e7dec03d7d 100644 --- a/homeassistant/components/ssdp/manifest.json +++ b/homeassistant/components/ssdp/manifest.json @@ -4,7 +4,7 @@ "documentation": "https://www.home-assistant.io/integrations/ssdp", "requirements": [ "defusedxml==0.7.1", - "async-upnp-client==0.20.0" + "async-upnp-client==0.21.2" ], "dependencies": ["network"], "after_dependencies": ["zeroconf"], diff --git a/homeassistant/components/ssdp/util.py b/homeassistant/components/ssdp/util.py deleted file mode 100644 index c28f8ce088d..00000000000 --- a/homeassistant/components/ssdp/util.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Util functions used by SSDP.""" -from __future__ import annotations - -from collections import defaultdict -from typing import Any - -from defusedxml import ElementTree - - -# Adapted from http://stackoverflow.com/a/10077069 -# to follow the XML to JSON spec -# https://www.xml.com/pub/a/2006/05/31/converting-between-xml-and-json.html -def etree_to_dict(tree: ElementTree) -> dict[str, dict[str, Any] | None]: - """Convert an ETree object to a dict.""" - # strip namespace - tag_name = tree.tag[tree.tag.find("}") + 1 :] - - tree_dict: dict[str, dict[str, Any] | None] = { - tag_name: {} if tree.attrib else None - } - children = list(tree) - if children: - child_dict: dict[str, list] = defaultdict(list) - for child in map(etree_to_dict, children): - for k, val in child.items(): - child_dict[k].append(val) - tree_dict = { - tag_name: {k: v[0] if len(v) == 1 else v for k, v in child_dict.items()} - } - dict_meta = tree_dict[tag_name] - if tree.attrib: - assert dict_meta is not None - dict_meta.update(("@" + k, v) for k, v in tree.attrib.items()) - if tree.text: - text = tree.text.strip() - if children or tree.attrib: - if text: - assert dict_meta is not None - dict_meta["#text"] = text - else: - tree_dict[tag_name] = text - return tree_dict diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py index 9541331fe0b..3251b8c69fb 100644 --- a/homeassistant/components/upnp/__init__.py +++ b/homeassistant/components/upnp/__init__.py @@ -5,7 +5,6 @@ import asyncio from collections.abc import Mapping from dataclasses import dataclass from datetime import timedelta -from ipaddress import ip_address from typing import Any import voluptuous as vol @@ -13,13 +12,12 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import ssdp from homeassistant.components.binary_sensor import BinarySensorEntityDescription -from homeassistant.components.network import async_get_source_ip -from homeassistant.components.network.const import PUBLIC_TARGET_IP from homeassistant.components.sensor import SensorEntityDescription +from homeassistant.components.ssdp import SsdpChange from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -27,7 +25,6 @@ from homeassistant.helpers.update_coordinator import ( ) from .const import ( - CONF_LOCAL_IP, CONFIG_ENTRY_HOSTNAME, CONFIG_ENTRY_SCAN_INTERVAL, CONFIG_ENTRY_ST, @@ -36,7 +33,6 @@ from .const import ( DOMAIN, DOMAIN_CONFIG, DOMAIN_DEVICES, - DOMAIN_LOCAL_IP, LOGGER, ) from .device import Device @@ -49,9 +45,7 @@ PLATFORMS = ["binary_sensor", "sensor"] CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( - { - vol.Optional(CONF_LOCAL_IP): vol.All(ip_address, cv.string), - }, + {}, ) }, extra=vol.ALLOW_EXTRA, @@ -63,11 +57,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: LOGGER.debug("async_setup, config: %s", config) conf_default = CONFIG_SCHEMA({DOMAIN: {}})[DOMAIN] conf = config.get(DOMAIN, conf_default) - local_ip = await async_get_source_ip(hass, PUBLIC_TARGET_IP) hass.data[DOMAIN] = { DOMAIN_CONFIG: conf, DOMAIN_DEVICES: {}, - DOMAIN_LOCAL_IP: conf.get(CONF_LOCAL_IP, local_ip), } # Only start if set up via configuration.yaml. @@ -93,16 +85,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: device_discovered_event = asyncio.Event() discovery_info: Mapping[str, Any] | None = None - @callback - def device_discovered(info: Mapping[str, Any]) -> None: + async def device_discovered(headers: Mapping[str, Any], change: SsdpChange) -> None: + if change == SsdpChange.BYEBYE: + return + nonlocal discovery_info LOGGER.debug( - "Device discovered: %s, at: %s", usn, info[ssdp.ATTR_SSDP_LOCATION] + "Device discovered: %s, at: %s", usn, headers[ssdp.ATTR_SSDP_LOCATION] ) - discovery_info = info + discovery_info = headers device_discovered_event.set() - cancel_discovered_callback = ssdp.async_register_callback( + cancel_discovered_callback = await ssdp.async_register_callback( hass, device_discovered, { @@ -177,9 +171,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: LOGGER.debug("Enabling sensors") hass.config_entries.async_setup_platforms(entry, PLATFORMS) - # Start device updater. - await device.async_start() - return True @@ -187,9 +178,6 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> """Unload a UPnP/IGD device from a config entry.""" LOGGER.debug("Unloading config entry: %s", config_entry.unique_id) - if coordinator := hass.data[DOMAIN].pop(config_entry.entry_id, None): - await coordinator.device.async_stop() - LOGGER.debug("Deleting sensors") return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) @@ -228,10 +216,10 @@ class UpnpDataUpdateCoordinator(DataUpdateCoordinator): self.device.async_get_status(), ) - data = dict(update_values[0]) - data.update(update_values[1]) - - return data + return { + **update_values[0], + **update_values[1], + } class UpnpEntity(CoordinatorEntity): diff --git a/homeassistant/components/upnp/config_flow.py b/homeassistant/components/upnp/config_flow.py index 5df4e267427..9352ae0a5ff 100644 --- a/homeassistant/components/upnp/config_flow.py +++ b/homeassistant/components/upnp/config_flow.py @@ -10,6 +10,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import ssdp +from homeassistant.components.ssdp import SsdpChange from homeassistant.const import CONF_SCAN_INTERVAL from homeassistant.core import HomeAssistant, callback @@ -40,8 +41,10 @@ async def _async_wait_for_discoveries(hass: HomeAssistant) -> bool: """Wait for a device to be discovered.""" device_discovered_event = asyncio.Event() - @callback - def device_discovered(info: Mapping[str, Any]) -> None: + async def device_discovered(info: Mapping[str, Any], change: SsdpChange) -> None: + if change == SsdpChange.BYEBYE: + return + LOGGER.info( "Device discovered: %s, at: %s", info[ssdp.ATTR_SSDP_USN], @@ -49,14 +52,14 @@ async def _async_wait_for_discoveries(hass: HomeAssistant) -> bool: ) device_discovered_event.set() - cancel_discovered_callback_1 = ssdp.async_register_callback( + cancel_discovered_callback_1 = await ssdp.async_register_callback( hass, device_discovered, { ssdp.ATTR_SSDP_ST: ST_IGD_V1, }, ) - cancel_discovered_callback_2 = ssdp.async_register_callback( + cancel_discovered_callback_2 = await ssdp.async_register_callback( hass, device_discovered, { @@ -77,11 +80,11 @@ async def _async_wait_for_discoveries(hass: HomeAssistant) -> bool: return True -def _discovery_igd_devices(hass: HomeAssistant) -> list[Mapping[str, Any]]: +async def _async_discover_igd_devices(hass: HomeAssistant) -> list[Mapping[str, Any]]: """Discovery IGD devices.""" - return ssdp.async_get_discovery_info_by_st( + return await ssdp.async_get_discovery_info_by_st( hass, ST_IGD_V1 - ) + ssdp.async_get_discovery_info_by_st(hass, ST_IGD_V2) + ) + await ssdp.async_get_discovery_info_by_st(hass, ST_IGD_V2) class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @@ -121,7 +124,7 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return await self._async_create_entry_from_discovery(discovery) # Discover devices. - discoveries = _discovery_igd_devices(self.hass) + discoveries = await _async_discover_igd_devices(self.hass) # Store discoveries which have not been configured. current_unique_ids = { @@ -171,7 +174,7 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): # Discover devices. await _async_wait_for_discoveries(self.hass) - discoveries = _discovery_igd_devices(self.hass) + discoveries = await _async_discover_igd_devices(self.hass) # Ensure anything to add. If not, silently abort. if not discoveries: diff --git a/homeassistant/components/upnp/const.py b/homeassistant/components/upnp/const.py index d66a954962f..5eeb73abc4a 100644 --- a/homeassistant/components/upnp/const.py +++ b/homeassistant/components/upnp/const.py @@ -6,11 +6,9 @@ from homeassistant.const import TIME_SECONDS LOGGER = logging.getLogger(__package__) -CONF_LOCAL_IP = "local_ip" DOMAIN = "upnp" DOMAIN_CONFIG = "config" DOMAIN_DEVICES = "devices" -DOMAIN_LOCAL_IP = "local_ip" BYTES_RECEIVED = "bytes_received" BYTES_SENT = "bytes_sent" PACKETS_RECEIVED = "packets_received" diff --git a/homeassistant/components/upnp/device.py b/homeassistant/components/upnp/device.py index a1040816629..7205da71c84 100644 --- a/homeassistant/components/upnp/device.py +++ b/homeassistant/components/upnp/device.py @@ -3,25 +3,23 @@ from __future__ import annotations import asyncio from collections.abc import Mapping -from ipaddress import IPv4Address from typing import Any from urllib.parse import urlparse -from async_upnp_client import UpnpFactory +from async_upnp_client import UpnpDevice, UpnpFactory from async_upnp_client.aiohttp import AiohttpSessionRequester -from async_upnp_client.device_updater import DeviceUpdater from async_upnp_client.profiles.igd import IgdDevice +from homeassistant.components import ssdp +from homeassistant.components.ssdp import SsdpChange from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator import homeassistant.util.dt as dt_util from .const import ( BYTES_RECEIVED, BYTES_SENT, - CONF_LOCAL_IP, - DOMAIN, - DOMAIN_CONFIG, LOGGER as _LOGGER, PACKETS_RECEIVED, PACKETS_SENT, @@ -32,54 +30,61 @@ from .const import ( ) -def _get_local_ip(hass: HomeAssistant) -> IPv4Address | None: - """Get the configured local ip.""" - if DOMAIN in hass.data and DOMAIN_CONFIG in hass.data[DOMAIN]: - local_ip = hass.data[DOMAIN][DOMAIN_CONFIG].get(CONF_LOCAL_IP) - if local_ip: - return IPv4Address(local_ip) - return None - - class Device: """Home Assistant representation of a UPnP/IGD device.""" - def __init__(self, igd_device: IgdDevice, device_updater: DeviceUpdater) -> None: + def __init__(self, hass: HomeAssistant, igd_device: IgdDevice) -> None: """Initialize UPnP/IGD device.""" + self.hass = hass self._igd_device = igd_device - self._device_updater = device_updater + self.coordinator: DataUpdateCoordinator = None @classmethod - async def async_create_device( + async def async_create_upnp_device( cls, hass: HomeAssistant, ssdp_location: str - ) -> Device: - """Create UPnP/IGD device.""" + ) -> UpnpDevice: + """Create UPnP device.""" # Build async_upnp_client requester. session = async_get_clientsession(hass) requester = AiohttpSessionRequester(session, True, 10) # Create async_upnp_client device. factory = UpnpFactory(requester, disable_state_variable_validation=True) - upnp_device = await factory.async_create_device(ssdp_location) + return await factory.async_create_device(ssdp_location) + + @classmethod + async def async_create_device( + cls, hass: HomeAssistant, ssdp_location: str + ) -> Device: + """Create UPnP/IGD device.""" + upnp_device = await Device.async_create_upnp_device(hass, ssdp_location) # Create profile wrapper. igd_device = IgdDevice(upnp_device, None) + device = cls(hass, igd_device) - # Create updater. - local_ip = _get_local_ip(hass) - device_updater = DeviceUpdater( - device=upnp_device, factory=factory, source_ip=local_ip + # Register SSDP callback for updates. + usn = f"{upnp_device.udn}::{upnp_device.device_type}" + await ssdp.async_register_callback( + hass, device.async_ssdp_callback, {ssdp.ATTR_SSDP_USN: usn} ) - return cls(igd_device, device_updater) + return device - async def async_start(self) -> None: - """Start the device updater.""" - await self._device_updater.async_start() + async def async_ssdp_callback( + self, headers: Mapping[str, Any], change: SsdpChange + ) -> None: + """SSDP callback, update if needed.""" + if change != SsdpChange.UPDATE or ssdp.ATTR_SSDP_LOCATION not in headers: + return - async def async_stop(self) -> None: - """Stop the device updater.""" - await self._device_updater.async_stop() + location = headers[ssdp.ATTR_SSDP_LOCATION] + device = self._igd_device.device + if location == device.device_url: + return + + new_upnp_device = Device.async_create_upnp_device(self.hass, location) + device.reinit(new_upnp_device) @property def udn(self) -> str: diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index 5f38a827ec7..fa71fa67751 100644 --- a/homeassistant/components/upnp/manifest.json +++ b/homeassistant/components/upnp/manifest.json @@ -3,7 +3,7 @@ "name": "UPnP/IGD", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/upnp", - "requirements": ["async-upnp-client==0.20.0"], + "requirements": ["async-upnp-client==0.21.2"], "dependencies": ["network", "ssdp"], "codeowners": ["@StevenLooman","@ehendrix23"], "ssdp": [ diff --git a/homeassistant/components/yamaha_musiccast/__init__.py b/homeassistant/components/yamaha_musiccast/__init__.py index 0de8428b0dc..9f691773e11 100644 --- a/homeassistant/components/yamaha_musiccast/__init__.py +++ b/homeassistant/components/yamaha_musiccast/__init__.py @@ -30,7 +30,7 @@ SCAN_INTERVAL = timedelta(seconds=60) async def get_upnp_desc(hass: HomeAssistant, host: str): """Get the upnp description URL for a given host, using the SSPD scanner.""" - ssdp_entries = ssdp.async_get_discovery_info_by_st(hass, "upnp:rootdevice") + ssdp_entries = await ssdp.async_get_discovery_info_by_st(hass, "upnp:rootdevice") matches = [w for w in ssdp_entries if w.get("_host", "") == host] upnp_desc = None for match in matches: diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py index a0deb0fdf21..5473e8eb553 100644 --- a/homeassistant/components/yeelight/__init__.py +++ b/homeassistant/components/yeelight/__init__.py @@ -8,7 +8,7 @@ from ipaddress import IPv4Address, IPv6Address import logging from urllib.parse import urlparse -from async_upnp_client.search import SSDPListener +from async_upnp_client.search import SsdpSearchListener import voluptuous as vol from yeelight import BulbException from yeelight.aio import KEY_CONNECTED, AsyncBulb @@ -395,7 +395,7 @@ class YeelightScanner: return _async_connected self._listeners.append( - SSDPListener( + SsdpSearchListener( async_callback=self._async_process_entry, service_type=SSDP_ST, target=SSDP_TARGET, diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index 0a4b5d4499f..47329235863 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -2,7 +2,7 @@ "domain": "yeelight", "name": "Yeelight", "documentation": "https://www.home-assistant.io/integrations/yeelight", - "requirements": ["yeelight==0.7.4", "async-upnp-client==0.20.0"], + "requirements": ["yeelight==0.7.4", "async-upnp-client==0.21.2"], "codeowners": ["@rytilahti", "@zewelor", "@shenxn", "@starkillerOG"], "config_flow": true, "dependencies": ["network"], diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a5f5104c0ab..546a56aa736 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -4,7 +4,7 @@ aiodiscover==1.4.2 aiohttp==3.7.4.post0 aiohttp_cors==0.7.0 astral==2.2 -async-upnp-client==0.20.0 +async-upnp-client==0.21.2 async_timeout==3.0.1 attrs==21.2.0 awesomeversion==21.8.1 diff --git a/requirements_all.txt b/requirements_all.txt index e3fb347059b..2d43b04240f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -318,7 +318,7 @@ asterisk_mbox==0.5.0 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.20.0 +async-upnp-client==0.21.2 # homeassistant.components.supla asyncpysupla==0.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 989f8fa5cfe..b6c37dac08a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -209,7 +209,7 @@ arcam-fmj==0.7.0 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.20.0 +async-upnp-client==0.21.2 # homeassistant.components.aurora auroranoaa==0.0.2 diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index 294e243901a..b1b3dd7be10 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -74,16 +74,28 @@ def soco_fixture(music_library, speaker_info, battery_info, alarm_clock): yield mock_soco +@pytest.fixture(autouse=True) +async def silent_ssdp_scanner(hass): + """Start SSDP component and get Scanner, prevent actual SSDP traffic.""" + with patch( + "homeassistant.components.ssdp.Scanner._async_start_ssdp_listeners" + ), patch("homeassistant.components.ssdp.Scanner._async_stop_ssdp_listeners"), patch( + "homeassistant.components.ssdp.Scanner.async_scan" + ): + yield + + @pytest.fixture(name="discover", autouse=True) def discover_fixture(soco): """Create a mock soco discover fixture.""" - def do_callback(hass, callback, *args, **kwargs): - callback( + async def do_callback(hass, callback, *args, **kwargs): + await callback( { ssdp.ATTR_UPNP_UDN: soco.uid, ssdp.ATTR_SSDP_LOCATION: f"http://{soco.ip_address}/", - } + }, + ssdp.SsdpChange.ALIVE, ) return MagicMock() diff --git a/tests/components/ssdp/conftest.py b/tests/components/ssdp/conftest.py new file mode 100644 index 00000000000..0b390ae469b --- /dev/null +++ b/tests/components/ssdp/conftest.py @@ -0,0 +1,25 @@ +"""Configuration for SSDP tests.""" +from unittest.mock import AsyncMock, patch + +from async_upnp_client.ssdp_listener import SsdpListener +import pytest + + +@pytest.fixture(autouse=True) +async def silent_ssdp_listener(): + """Patch SsdpListener class, preventing any actual SSDP traffic.""" + with patch("homeassistant.components.ssdp.SsdpListener.async_start"), patch( + "homeassistant.components.ssdp.SsdpListener.async_stop" + ), patch("homeassistant.components.ssdp.SsdpListener.async_search"): + # Fixtures are initialized before patches. When the component is started here, + # certain functions/methods might not be patched in time. + yield SsdpListener + + +@pytest.fixture +def mock_flow_init(hass): + """Mock hass.config_entries.flow.async_init.""" + with patch.object( + hass.config_entries.flow, "async_init", return_value=AsyncMock() + ) as mock_init: + yield mock_init diff --git a/tests/components/ssdp/test_init.py b/tests/components/ssdp/test_init.py index f176ccff0f2..ef12d2b53f7 100644 --- a/tests/components/ssdp/test_init.py +++ b/tests/components/ssdp/test_init.py @@ -1,14 +1,14 @@ """Test the SSDP integration.""" -import asyncio -from datetime import timedelta +from datetime import datetime, timedelta from ipaddress import IPv4Address, IPv6Address -from unittest.mock import patch +from unittest.mock import ANY, AsyncMock, patch -import aiohttp -from async_upnp_client.search import SSDPListener +from async_upnp_client.ssdp import udn_from_headers +from async_upnp_client.ssdp_listener import SsdpListener from async_upnp_client.utils import CaseInsensitiveDict import pytest +import homeassistant from homeassistant import config_entries from homeassistant.components import ssdp from homeassistant.const import ( @@ -16,127 +16,163 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, MATCH_ALL, ) -from homeassistant.core import CoreState, callback from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from tests.common import async_fire_time_changed, mock_coro +from tests.common import async_fire_time_changed -def _patched_ssdp_listener(info, *args, **kwargs): - listener = SSDPListener(*args, **kwargs) - - async def _async_callback(*_): - await listener.async_callback(info) - - @callback - def _async_search(*_): - # Prevent an actual scan. - pass - - listener.async_start = _async_callback - listener.async_search = _async_search - return listener +def _ssdp_headers(headers): + return CaseInsensitiveDict( + headers, _timestamp=datetime(2021, 1, 1, 12, 00), _udn=udn_from_headers(headers) + ) -async def _async_run_mocked_scan(hass, mock_ssdp_response, mock_get_ssdp): - def _generate_fake_ssdp_listener(*args, **kwargs): - return _patched_ssdp_listener( - mock_ssdp_response, - *args, - **kwargs, - ) - - with patch( - "homeassistant.components.ssdp.async_get_ssdp", - return_value=mock_get_ssdp, - ), patch( - "homeassistant.components.ssdp.SSDPListener", - new=_generate_fake_ssdp_listener, - ), patch.object( - hass.config_entries.flow, "async_init", return_value=mock_coro() - ) as mock_init: - assert await async_setup_component(hass, ssdp.DOMAIN, {ssdp.DOMAIN: {}}) - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) - await hass.async_block_till_done() - await hass.async_block_till_done() - - return mock_init +async def init_ssdp_component(hass: homeassistant) -> SsdpListener: + """Initialize ssdp component and get SsdpListener.""" + await async_setup_component(hass, ssdp.DOMAIN, {ssdp.DOMAIN: {}}) + await hass.async_block_till_done() + return hass.data[ssdp.DOMAIN]._ssdp_listeners[0] -async def test_scan_match_st(hass, caplog, mock_get_source_ip): +@patch( + "homeassistant.components.ssdp.async_get_ssdp", + return_value={"mock-domain": [{"st": "mock-st"}]}, +) +@pytest.mark.usefixtures("mock_get_source_ip") +async def test_ssdp_flow_dispatched_on_st(mock_get_ssdp, hass, caplog, mock_flow_init): """Test matching based on ST.""" - mock_ssdp_response = { - "st": "mock-st", - "location": None, - "usn": "mock-usn", - "server": "mock-server", - "ext": "", - } - mock_get_ssdp = {"mock-domain": [{"st": "mock-st"}]} - mock_init = await _async_run_mocked_scan(hass, mock_ssdp_response, mock_get_ssdp) + mock_ssdp_search_response = _ssdp_headers( + { + "st": "mock-st", + "location": None, + "usn": "uuid:mock-udn::mock-st", + "server": "mock-server", + "ext": "", + } + ) + ssdp_listener = await init_ssdp_component(hass) + await ssdp_listener._on_search(mock_ssdp_search_response) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() - assert len(mock_init.mock_calls) == 1 - assert mock_init.mock_calls[0][1][0] == "mock-domain" - assert mock_init.mock_calls[0][2]["context"] == { + assert len(mock_flow_init.mock_calls) == 1 + assert mock_flow_init.mock_calls[0][1][0] == "mock-domain" + assert mock_flow_init.mock_calls[0][2]["context"] == { "source": config_entries.SOURCE_SSDP } - assert mock_init.mock_calls[0][2]["data"] == { + assert mock_flow_init.mock_calls[0][2]["data"] == { ssdp.ATTR_SSDP_ST: "mock-st", ssdp.ATTR_SSDP_LOCATION: None, - ssdp.ATTR_SSDP_USN: "mock-usn", + ssdp.ATTR_SSDP_USN: "uuid:mock-udn::mock-st", ssdp.ATTR_SSDP_SERVER: "mock-server", ssdp.ATTR_SSDP_EXT: "", + ssdp.ATTR_UPNP_UDN: "uuid:mock-udn", + "_udn": ANY, + "_timestamp": ANY, } assert "Failed to fetch ssdp data" not in caplog.text -async def test_partial_response(hass, caplog, mock_get_source_ip): - """Test location and st missing.""" - mock_ssdp_response = { - "usn": "mock-usn", - "server": "mock-server", - "ext": "", - } - mock_get_ssdp = {"mock-domain": [{"st": "mock-st"}]} - mock_init = await _async_run_mocked_scan(hass, mock_ssdp_response, mock_get_ssdp) - - assert len(mock_init.mock_calls) == 0 - - -@pytest.mark.parametrize( - "key", (ssdp.ATTR_UPNP_MANUFACTURER, ssdp.ATTR_UPNP_DEVICE_TYPE) +@pytest.mark.usefixtures("mock_get_source_ip") +@patch( + "homeassistant.components.ssdp.async_get_ssdp", + return_value={"mock-domain": [{"manufacturer": "Paulus"}]}, ) -async def test_scan_match_upnp_devicedesc( - hass, aioclient_mock, key, mock_get_source_ip +async def test_scan_match_upnp_devicedesc_manufacturer( + mock_get_ssdp, hass, aioclient_mock, mock_flow_init ): """Test matching based on UPnP device description data.""" aioclient_mock.get( "http://1.1.1.1", - text=f""" + text=""" - <{key}>Paulus + Paulus """, ) - mock_get_ssdp = {"mock-domain": [{key: "Paulus"}]} - mock_ssdp_response = { - "st": "mock-st", - "location": "http://1.1.1.1", - } - mock_init = await _async_run_mocked_scan(hass, mock_ssdp_response, mock_get_ssdp) + mock_ssdp_search_response = _ssdp_headers( + { + "st": "mock-st", + "location": "http://1.1.1.1", + "usn": "uuid:mock-udn::mock-st", + } + ) + ssdp_listener = await init_ssdp_component(hass) + await ssdp_listener._on_search(mock_ssdp_search_response) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + # If we get duplicate response, ensure we only look it up once assert len(aioclient_mock.mock_calls) == 1 - assert len(mock_init.mock_calls) == 1 - assert mock_init.mock_calls[0][1][0] == "mock-domain" - assert mock_init.mock_calls[0][2]["context"] == { + assert len(mock_flow_init.mock_calls) == 1 + assert mock_flow_init.mock_calls[0][1][0] == "mock-domain" + assert mock_flow_init.mock_calls[0][2]["context"] == { "source": config_entries.SOURCE_SSDP } -async def test_scan_not_all_present(hass, aioclient_mock, mock_get_source_ip): +@pytest.mark.usefixtures("mock_get_source_ip") +@patch( + "homeassistant.components.ssdp.async_get_ssdp", + return_value={"mock-domain": [{"deviceType": "Paulus"}]}, +) +async def test_scan_match_upnp_devicedesc_devicetype( + mock_get_ssdp, hass, aioclient_mock, mock_flow_init +): + """Test matching based on UPnP device description data.""" + aioclient_mock.get( + "http://1.1.1.1", + text=""" + + + Paulus + + + """, + ) + mock_ssdp_search_response = _ssdp_headers( + { + "st": "mock-st", + "location": "http://1.1.1.1", + "usn": "uuid:mock-udn::mock-st", + } + ) + ssdp_listener = await init_ssdp_component(hass) + await ssdp_listener._on_search(mock_ssdp_search_response) + await hass.async_block_till_done() + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + # If we get duplicate response, ensure we only look it up once + assert len(aioclient_mock.mock_calls) == 1 + assert len(mock_flow_init.mock_calls) == 1 + assert mock_flow_init.mock_calls[0][1][0] == "mock-domain" + assert mock_flow_init.mock_calls[0][2]["context"] == { + "source": config_entries.SOURCE_SSDP + } + + +@pytest.mark.usefixtures("mock_get_source_ip") +@patch( + "homeassistant.components.ssdp.async_get_ssdp", + return_value={ + "mock-domain": [ + { + ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", + ssdp.ATTR_UPNP_MANUFACTURER: "Paulus", + } + ] + }, +) +async def test_scan_not_all_present( + mock_get_ssdp, hass, aioclient_mock, mock_flow_init +): """Test match fails if some specified attributes are not present.""" aioclient_mock.get( "http://1.1.1.1", @@ -148,24 +184,34 @@ async def test_scan_not_all_present(hass, aioclient_mock, mock_get_source_ip): """, ) - mock_ssdp_response = { - "st": "mock-st", - "location": "http://1.1.1.1", - } - mock_get_ssdp = { + mock_ssdp_search_response = _ssdp_headers( + { + "st": "mock-st", + "location": "http://1.1.1.1", + } + ) + ssdp_listener = await init_ssdp_component(hass) + await ssdp_listener._on_search(mock_ssdp_search_response) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + assert not mock_flow_init.mock_calls + + +@pytest.mark.usefixtures("mock_get_source_ip") +@patch( + "homeassistant.components.ssdp.async_get_ssdp", + return_value={ "mock-domain": [ { ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", - ssdp.ATTR_UPNP_MANUFACTURER: "Paulus", + ssdp.ATTR_UPNP_MANUFACTURER: "Not-Paulus", } ] - } - mock_init = await _async_run_mocked_scan(hass, mock_ssdp_response, mock_get_ssdp) - - assert not mock_init.mock_calls - - -async def test_scan_not_all_match(hass, aioclient_mock, mock_get_source_ip): + }, +) +async def test_scan_not_all_match(mock_get_ssdp, hass, aioclient_mock, mock_flow_init): """Test match fails if some specified attribute values differ.""" aioclient_mock.get( "http://1.1.1.1", @@ -178,191 +224,52 @@ async def test_scan_not_all_match(hass, aioclient_mock, mock_get_source_ip): """, ) - mock_ssdp_response = { - "st": "mock-st", - "location": "http://1.1.1.1", - } - mock_get_ssdp = { - "mock-domain": [ - { - ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", - ssdp.ATTR_UPNP_MANUFACTURER: "Not-Paulus", - } - ] - } - mock_init = await _async_run_mocked_scan(hass, mock_ssdp_response, mock_get_ssdp) - - assert not mock_init.mock_calls - - -@pytest.mark.parametrize("exc", [asyncio.TimeoutError, aiohttp.ClientError]) -async def test_scan_description_fetch_fail( - hass, aioclient_mock, exc, mock_get_source_ip -): - """Test failing to fetch description.""" - aioclient_mock.get("http://1.1.1.1", exc=exc) - mock_ssdp_response = { - "st": "mock-st", - "usn": "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL::urn:mdx-netflix-com:service:target:3", - "location": "http://1.1.1.1", - } - mock_get_ssdp = { - "mock-domain": [ - { - ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", - ssdp.ATTR_UPNP_MANUFACTURER: "Paulus", - } - ] - } - mock_init = await _async_run_mocked_scan(hass, mock_ssdp_response, mock_get_ssdp) - - assert not mock_init.mock_calls - - assert ssdp.async_get_discovery_info_by_st(hass, "mock-st") == [ + mock_ssdp_search_response = _ssdp_headers( { - "UDN": "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", - "ssdp_location": "http://1.1.1.1", - "ssdp_st": "mock-st", - "ssdp_usn": "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL::urn:mdx-netflix-com:service:target:3", + "st": "mock-st", + "location": "http://1.1.1.1", + "usn": "uuid:mock-udn::mock-st", } - ] - - -async def test_scan_description_parse_fail(hass, aioclient_mock, mock_get_source_ip): - """Test invalid XML.""" - aioclient_mock.get( - "http://1.1.1.1", - text=""" -INVALIDXML - """, ) - - mock_ssdp_response = { - "st": "mock-st", - "location": "http://1.1.1.1", - } - mock_get_ssdp = { - "mock-domain": [ - { - ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", - ssdp.ATTR_UPNP_MANUFACTURER: "Paulus", - } - ] - } - mock_init = await _async_run_mocked_scan(hass, mock_ssdp_response, mock_get_ssdp) - - assert not mock_init.mock_calls - - -async def test_invalid_characters(hass, aioclient_mock, mock_get_source_ip): - """Test that we replace bad characters with placeholders.""" - aioclient_mock.get( - "http://1.1.1.1", - text=""" - - - ABC - \xff\xff\xff\xff - - - """, - ) - - mock_ssdp_response = { - "st": "mock-st", - "location": "http://1.1.1.1", - } - mock_get_ssdp = { - "mock-domain": [ - { - ssdp.ATTR_UPNP_DEVICE_TYPE: "ABC", - } - ] - } - - mock_init = await _async_run_mocked_scan(hass, mock_ssdp_response, mock_get_ssdp) - - assert len(mock_init.mock_calls) == 1 - assert mock_init.mock_calls[0][1][0] == "mock-domain" - assert mock_init.mock_calls[0][2]["context"] == { - "source": config_entries.SOURCE_SSDP - } - assert mock_init.mock_calls[0][2]["data"] == { - "ssdp_location": "http://1.1.1.1", - "ssdp_st": "mock-st", - "deviceType": "ABC", - "serialNumber": "ÿÿÿÿ", - } - - -@patch("homeassistant.components.ssdp.SSDPListener.async_start") -@patch("homeassistant.components.ssdp.SSDPListener.async_search") -@patch("homeassistant.components.ssdp.SSDPListener.async_stop") -async def test_start_stop_scanner( - async_stop_mock, async_search_mock, async_start_mock, hass, mock_get_source_ip -): - """Test we start and stop the scanner.""" - assert await async_setup_component(hass, ssdp.DOMAIN, {ssdp.DOMAIN: {}}) - + ssdp_listener = await init_ssdp_component(hass) + await ssdp_listener._on_search(mock_ssdp_search_response) + await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() + + assert not mock_flow_init.mock_calls + + +@patch( # XXX TODO: Isn't this duplicate with mock_get_source_ip? + "homeassistant.components.ssdp.Scanner._async_build_source_set", + return_value={IPv4Address("192.168.1.1")}, +) +@pytest.mark.usefixtures("mock_get_source_ip") +async def test_start_stop_scanner(mock_source_set, hass): + """Test we start and stop the scanner.""" + ssdp_listener = await init_ssdp_component(hass) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) await hass.async_block_till_done() - assert async_start_mock.call_count == 1 - # Next is 2, as async_upnp_client triggers 1 SSDPListener._async_on_connect - assert async_search_mock.call_count == 2 - assert async_stop_mock.call_count == 0 + assert ssdp_listener.async_start.call_count == 1 + assert ssdp_listener.async_search.call_count == 4 + assert ssdp_listener.async_stop.call_count == 0 hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) await hass.async_block_till_done() - assert async_start_mock.call_count == 1 - assert async_search_mock.call_count == 2 - assert async_stop_mock.call_count == 1 - - -async def test_unexpected_exception_while_fetching( - hass, aioclient_mock, caplog, mock_get_source_ip -): - """Test unexpected exception while fetching.""" - aioclient_mock.get( - "http://1.1.1.1", - text=""" - - - ABC - \xff\xff\xff\xff - - - """, - ) - mock_ssdp_response = { - "st": "mock-st", - "location": "http://1.1.1.1", - } - mock_get_ssdp = { - "mock-domain": [ - { - ssdp.ATTR_UPNP_DEVICE_TYPE: "ABC", - } - ] - } - - with patch( - "homeassistant.components.ssdp.descriptions.ElementTree.fromstring", - side_effect=ValueError, - ): - mock_init = await _async_run_mocked_scan( - hass, mock_ssdp_response, mock_get_ssdp - ) - - assert len(mock_init.mock_calls) == 0 - assert "Failed to fetch ssdp data from: http://1.1.1.1" in caplog.text + assert ssdp_listener.async_start.call_count == 1 + assert ssdp_listener.async_search.call_count == 4 + assert ssdp_listener.async_stop.call_count == 1 +@pytest.mark.usefixtures("mock_get_source_ip") +@patch("homeassistant.components.ssdp.async_get_ssdp", return_value={}) async def test_scan_with_registered_callback( - hass, aioclient_mock, caplog, mock_get_source_ip + mock_get_ssdp, hass, aioclient_mock, caplog ): """Test matching based on callback.""" aioclient_mock.get( @@ -375,221 +282,86 @@ async def test_scan_with_registered_callback( """, ) - mock_ssdp_response = { - "st": "mock-st", - "location": "http://1.1.1.1", - "usn": "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL::urn:mdx-netflix-com:service:target:3", - "server": "mock-server", - "x-rincon-bootseq": "55", - "ext": "", - } - not_matching_integration_callbacks = [] - integration_match_all_callbacks = [] - integration_match_all_not_present_callbacks = [] - integration_callbacks = [] - integration_callbacks_from_cache = [] - match_any_callbacks = [] + mock_ssdp_search_response = _ssdp_headers( + { + "st": "mock-st", + "location": "http://1.1.1.1", + "usn": "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL::mock-st", + "server": "mock-server", + "x-rincon-bootseq": "55", + "ext": "", + } + ) + ssdp_listener = await init_ssdp_component(hass) - @callback - def _async_exception_callbacks(info): - raise ValueError + async_exception_callback = AsyncMock(side_effect=ValueError) + await ssdp.async_register_callback(hass, async_exception_callback, {}) - @callback - def _async_integration_callbacks(info): - integration_callbacks.append(info) + async_integration_callback = AsyncMock() + await ssdp.async_register_callback( + hass, async_integration_callback, {"st": "mock-st"} + ) - @callback - def _async_integration_match_all_callbacks(info): - integration_match_all_callbacks.append(info) + async_integration_match_all_callback1 = AsyncMock() + await ssdp.async_register_callback( + hass, async_integration_match_all_callback1, {"x-rincon-bootseq": MATCH_ALL} + ) - @callback - def _async_integration_match_all_not_present_callbacks(info): - integration_match_all_not_present_callbacks.append(info) + async_integration_match_all_not_present_callback1 = AsyncMock() + await ssdp.async_register_callback( + hass, + async_integration_match_all_not_present_callback1, + {"x-not-there": MATCH_ALL}, + ) - @callback - def _async_integration_callbacks_from_cache(info): - integration_callbacks_from_cache.append(info) + async_not_matching_integration_callback1 = AsyncMock() + await ssdp.async_register_callback( + hass, async_not_matching_integration_callback1, {"st": "not-match-mock-st"} + ) - @callback - def _async_not_matching_integration_callbacks(info): - not_matching_integration_callbacks.append(info) + async_match_any_callback1 = AsyncMock() + await ssdp.async_register_callback(hass, async_match_any_callback1) - @callback - def _async_match_any_callbacks(info): - match_any_callbacks.append(info) + await hass.async_block_till_done() + await ssdp_listener._on_search(mock_ssdp_search_response) - def _generate_fake_ssdp_listener(*args, **kwargs): - listener = SSDPListener(*args, **kwargs) - - async def _async_callback(*_): - await listener.async_callback(mock_ssdp_response) - - @callback - def _callback(*_): - hass.async_create_task(listener.async_callback(mock_ssdp_response)) - - listener.async_start = _async_callback - listener.async_search = _callback - return listener - - with patch( - "homeassistant.components.ssdp.SSDPListener", - new=_generate_fake_ssdp_listener, - ): - hass.state = CoreState.stopped - assert await async_setup_component(hass, ssdp.DOMAIN, {ssdp.DOMAIN: {}}) - await hass.async_block_till_done() - ssdp.async_register_callback(hass, _async_exception_callbacks, {}) - ssdp.async_register_callback( - hass, - _async_integration_callbacks, - {"st": "mock-st"}, - ) - ssdp.async_register_callback( - hass, - _async_integration_match_all_callbacks, - {"x-rincon-bootseq": MATCH_ALL}, - ) - ssdp.async_register_callback( - hass, - _async_integration_match_all_not_present_callbacks, - {"x-not-there": MATCH_ALL}, - ) - ssdp.async_register_callback( - hass, - _async_not_matching_integration_callbacks, - {"st": "not-match-mock-st"}, - ) - ssdp.async_register_callback( - hass, - _async_match_any_callbacks, - ) - await hass.async_block_till_done() - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) - ssdp.async_register_callback( - hass, - _async_integration_callbacks_from_cache, - {"st": "mock-st"}, - ) - await hass.async_block_till_done() - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) - hass.state = CoreState.running - await hass.async_block_till_done() - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) - await hass.async_block_till_done() - assert hass.state == CoreState.running - - assert len(integration_callbacks) == 5 - assert len(integration_callbacks_from_cache) == 5 - assert len(integration_match_all_callbacks) == 5 - assert len(integration_match_all_not_present_callbacks) == 0 - assert len(match_any_callbacks) == 5 - assert len(not_matching_integration_callbacks) == 0 - assert integration_callbacks[0] == { - ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", - ssdp.ATTR_SSDP_EXT: "", - ssdp.ATTR_SSDP_LOCATION: "http://1.1.1.1", - ssdp.ATTR_SSDP_SERVER: "mock-server", - ssdp.ATTR_SSDP_ST: "mock-st", - ssdp.ATTR_SSDP_USN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL::urn:mdx-netflix-com:service:target:3", - ssdp.ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", - "x-rincon-bootseq": "55", - } + assert async_integration_callback.call_count == 1 + assert async_integration_match_all_callback1.call_count == 1 + assert async_integration_match_all_not_present_callback1.call_count == 0 + assert async_match_any_callback1.call_count == 1 + assert async_not_matching_integration_callback1.call_count == 0 + assert async_integration_callback.call_args[0] == ( + { + ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", + ssdp.ATTR_SSDP_EXT: "", + ssdp.ATTR_SSDP_LOCATION: "http://1.1.1.1", + ssdp.ATTR_SSDP_SERVER: "mock-server", + ssdp.ATTR_SSDP_ST: "mock-st", + ssdp.ATTR_SSDP_USN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL::mock-st", + ssdp.ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", + "x-rincon-bootseq": "55", + "_udn": ANY, + "_timestamp": ANY, + }, + ssdp.SsdpChange.ALIVE, + ) assert "Failed to callback info" in caplog.text - -async def test_unsolicited_ssdp_registered_callback( - hass, aioclient_mock, caplog, mock_get_source_ip -): - """Test matching based on callback can handle unsolicited ssdp traffic without st.""" - aioclient_mock.get( - "http://10.6.9.12:1400/xml/device_description.xml", - text=""" - - - Paulus - - - """, + async_integration_callback_from_cache = AsyncMock() + await ssdp.async_register_callback( + hass, async_integration_callback_from_cache, {"st": "mock-st"} ) - mock_ssdp_response = { - "location": "http://10.6.9.12:1400/xml/device_description.xml", - "nt": "uuid:RINCON_1111BB963FD801400", - "nts": "ssdp:alive", - "server": "Linux UPnP/1.0 Sonos/63.2-88230 (ZPS12)", - "usn": "uuid:RINCON_1111BB963FD801400", - "x-rincon-household": "Sonos_dfjfkdghjhkjfhkdjfhkd", - "x-rincon-bootseq": "250", - "bootid.upnp.org": "250", - "x-rincon-wifimode": "0", - "x-rincon-variant": "1", - "household.smartspeaker.audio": "Sonos_v3294823948542543534", - } - integration_callbacks = [] - @callback - def _async_integration_callbacks(info): - integration_callbacks.append(info) - - def _generate_fake_ssdp_listener(*args, **kwargs): - listener = SSDPListener(*args, **kwargs) - - async def _async_callback(*_): - await listener.async_callback(mock_ssdp_response) - - @callback - def _callback(*_): - hass.async_create_task(listener.async_callback(mock_ssdp_response)) - - listener.async_start = _async_callback - listener.async_search = _callback - return listener - - with patch( - "homeassistant.components.ssdp.SSDPListener", - new=_generate_fake_ssdp_listener, - ): - hass.state = CoreState.stopped - assert await async_setup_component(hass, ssdp.DOMAIN, {ssdp.DOMAIN: {}}) - await hass.async_block_till_done() - ssdp.async_register_callback( - hass, - _async_integration_callbacks, - {"nts": "ssdp:alive", "x-rincon-bootseq": MATCH_ALL}, - ) - await hass.async_block_till_done() - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) - await hass.async_block_till_done() - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) - hass.state = CoreState.running - await hass.async_block_till_done() - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) - await hass.async_block_till_done() - assert hass.state == CoreState.running - - assert ( - len(integration_callbacks) == 4 - ) # unsolicited callbacks without st are not cached - assert integration_callbacks[0] == { - "UDN": "uuid:RINCON_1111BB963FD801400", - "bootid.upnp.org": "250", - "deviceType": "Paulus", - "household.smartspeaker.audio": "Sonos_v3294823948542543534", - "nt": "uuid:RINCON_1111BB963FD801400", - "nts": "ssdp:alive", - "ssdp_location": "http://10.6.9.12:1400/xml/device_description.xml", - "ssdp_server": "Linux UPnP/1.0 Sonos/63.2-88230 (ZPS12)", - "ssdp_usn": "uuid:RINCON_1111BB963FD801400", - "x-rincon-bootseq": "250", - "x-rincon-household": "Sonos_dfjfkdghjhkjfhkdjfhkd", - "x-rincon-variant": "1", - "x-rincon-wifimode": "0", - } - assert "Failed to callback info" not in caplog.text + assert async_integration_callback_from_cache.call_count == 1 -async def test_scan_second_hit(hass, aioclient_mock, caplog, mock_get_source_ip): - """Test matching on second scan.""" +@pytest.mark.usefixtures("mock_get_source_ip") +@patch( + "homeassistant.components.ssdp.async_get_ssdp", + return_value={"mock-domain": [{"st": "mock-st"}]}, +) +async def test_getting_existing_headers(mock_get_ssdp, hass, aioclient_mock): + """Test getting existing/previously scanned headers.""" aioclient_mock.get( "http://1.1.1.1", text=""" @@ -600,9 +372,8 @@ async def test_scan_second_hit(hass, aioclient_mock, caplog, mock_get_source_ip) """, ) - - mock_ssdp_response = CaseInsensitiveDict( - **{ + mock_ssdp_search_response = _ssdp_headers( + { "ST": "mock-st", "LOCATION": "http://1.1.1.1", "USN": "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL::urn:mdx-netflix-com:service:target:3", @@ -610,121 +381,59 @@ async def test_scan_second_hit(hass, aioclient_mock, caplog, mock_get_source_ip) "EXT": "", } ) - mock_get_ssdp = {"mock-domain": [{"st": "mock-st"}]} - integration_callbacks = [] + ssdp_listener = await init_ssdp_component(hass) + await ssdp_listener._on_search(mock_ssdp_search_response) - @callback - def _async_integration_callbacks(info): - integration_callbacks.append(info) + discovery_info_by_st = await ssdp.async_get_discovery_info_by_st(hass, "mock-st") + assert discovery_info_by_st == [ + { + ssdp.ATTR_SSDP_EXT: "", + ssdp.ATTR_SSDP_LOCATION: "http://1.1.1.1", + ssdp.ATTR_SSDP_SERVER: "mock-server", + ssdp.ATTR_SSDP_ST: "mock-st", + ssdp.ATTR_SSDP_USN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL::urn:mdx-netflix-com:service:target:3", + ssdp.ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", + ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", + "_udn": ANY, + "_timestamp": ANY, + } + ] - def _generate_fake_ssdp_listener(*args, **kwargs): - listener = SSDPListener(*args, **kwargs) - - async def _async_callback(*_): - pass - - @callback - def _callback(*_): - hass.async_create_task(listener.async_callback(mock_ssdp_response)) - - listener.async_start = _async_callback - listener.async_search = _callback - return listener - - with patch( - "homeassistant.components.ssdp.async_get_ssdp", - return_value=mock_get_ssdp, - ), patch( - "homeassistant.components.ssdp.SSDPListener", - new=_generate_fake_ssdp_listener, - ), patch.object( - hass.config_entries.flow, "async_init", return_value=mock_coro() - ) as mock_init: - assert await async_setup_component(hass, ssdp.DOMAIN, {ssdp.DOMAIN: {}}) - await hass.async_block_till_done() - remove = ssdp.async_register_callback( - hass, - _async_integration_callbacks, - {"st": "mock-st"}, - ) - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) - await hass.async_block_till_done() - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) - await hass.async_block_till_done() - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) - await hass.async_block_till_done() - remove() - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) - await hass.async_block_till_done() - - assert len(integration_callbacks) == 4 - assert integration_callbacks[0] == { - ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", - ssdp.ATTR_SSDP_EXT: "", - ssdp.ATTR_SSDP_LOCATION: "http://1.1.1.1", - ssdp.ATTR_SSDP_SERVER: "mock-server", - ssdp.ATTR_SSDP_ST: "mock-st", - ssdp.ATTR_SSDP_USN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL::urn:mdx-netflix-com:service:target:3", - ssdp.ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", - } - assert len(mock_init.mock_calls) == 1 - assert mock_init.mock_calls[0][1][0] == "mock-domain" - assert mock_init.mock_calls[0][2]["context"] == { - "source": config_entries.SOURCE_SSDP - } - assert mock_init.mock_calls[0][2]["data"] == { - ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", - ssdp.ATTR_SSDP_ST: "mock-st", - ssdp.ATTR_SSDP_LOCATION: "http://1.1.1.1", - ssdp.ATTR_SSDP_SERVER: "mock-server", - ssdp.ATTR_SSDP_EXT: "", - ssdp.ATTR_SSDP_USN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL::urn:mdx-netflix-com:service:target:3", - ssdp.ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", - } - assert "Failed to fetch ssdp data" not in caplog.text - udn_discovery_info = ssdp.async_get_discovery_info_by_st(hass, "mock-st") - discovery_info = udn_discovery_info[0] - assert discovery_info[ssdp.ATTR_SSDP_LOCATION] == "http://1.1.1.1" - assert discovery_info[ssdp.ATTR_SSDP_ST] == "mock-st" - assert ( - discovery_info[ssdp.ATTR_UPNP_UDN] - == "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL" - ) - assert ( - discovery_info[ssdp.ATTR_SSDP_USN] - == "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL::urn:mdx-netflix-com:service:target:3" - ) - - st_discovery_info = ssdp.async_get_discovery_info_by_udn( + discovery_info_by_udn = await ssdp.async_get_discovery_info_by_udn( hass, "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL" ) - discovery_info = st_discovery_info[0] - assert discovery_info[ssdp.ATTR_SSDP_LOCATION] == "http://1.1.1.1" - assert discovery_info[ssdp.ATTR_SSDP_ST] == "mock-st" - assert ( - discovery_info[ssdp.ATTR_UPNP_UDN] - == "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL" - ) - assert ( - discovery_info[ssdp.ATTR_SSDP_USN] - == "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL::urn:mdx-netflix-com:service:target:3" - ) + assert discovery_info_by_udn == [ + { + ssdp.ATTR_SSDP_EXT: "", + ssdp.ATTR_SSDP_LOCATION: "http://1.1.1.1", + ssdp.ATTR_SSDP_SERVER: "mock-server", + ssdp.ATTR_SSDP_ST: "mock-st", + ssdp.ATTR_SSDP_USN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL::urn:mdx-netflix-com:service:target:3", + ssdp.ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", + ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", + "_udn": ANY, + "_timestamp": ANY, + } + ] - discovery_info = ssdp.async_get_discovery_info_by_udn_st( + discovery_info_by_udn_st = await ssdp.async_get_discovery_info_by_udn_st( hass, "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", "mock-st" ) - assert discovery_info[ssdp.ATTR_SSDP_LOCATION] == "http://1.1.1.1" - assert discovery_info[ssdp.ATTR_SSDP_ST] == "mock-st" - assert ( - discovery_info[ssdp.ATTR_UPNP_UDN] - == "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL" - ) - assert ( - discovery_info[ssdp.ATTR_SSDP_USN] - == "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL::urn:mdx-netflix-com:service:target:3" - ) + assert discovery_info_by_udn_st == { + ssdp.ATTR_SSDP_EXT: "", + ssdp.ATTR_SSDP_LOCATION: "http://1.1.1.1", + ssdp.ATTR_SSDP_SERVER: "mock-server", + ssdp.ATTR_SSDP_ST: "mock-st", + ssdp.ATTR_SSDP_USN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL::urn:mdx-netflix-com:service:target:3", + ssdp.ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", + ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", + "_udn": ANY, + "_timestamp": ANY, + } - assert ssdp.async_get_discovery_info_by_udn_st(hass, "wrong", "mock-st") is None + assert ( + await ssdp.async_get_discovery_info_by_udn_st(hass, "wrong", "mock-st") is None + ) _ADAPTERS_WITH_MANUAL_CONFIG = [ @@ -762,410 +471,99 @@ _ADAPTERS_WITH_MANUAL_CONFIG = [ ] -async def test_async_detect_interfaces_setting_empty_route(hass, mock_get_source_ip): +@pytest.mark.usefixtures("mock_get_source_ip") +@patch( + "homeassistant.components.ssdp.async_get_ssdp", + return_value={ + "mock-domain": [ + { + ssdp.ATTR_UPNP_DEVICE_TYPE: "ABC", + } + ] + }, +) +@patch( + "homeassistant.components.ssdp.network.async_get_adapters", + return_value=_ADAPTERS_WITH_MANUAL_CONFIG, +) # XXX TODO: Isn't this duplicate with mock_get_source_ip? +async def test_async_detect_interfaces_setting_empty_route( + mock_get_adapters, mock_get_ssdp, hass +): """Test without default interface config and the route returns nothing.""" - mock_get_ssdp = { + await init_ssdp_component(hass) + + ssdp_listeners = hass.data[ssdp.DOMAIN]._ssdp_listeners + source_ips = {ssdp_listener.source_ip for ssdp_listener in ssdp_listeners} + assert source_ips == {IPv6Address("2001:db8::"), IPv4Address("192.168.1.5")} + + +@pytest.mark.usefixtures("mock_get_source_ip") +@patch( + "homeassistant.components.ssdp.async_get_ssdp", + return_value={ "mock-domain": [ { ssdp.ATTR_UPNP_DEVICE_TYPE: "ABC", } ] - } - create_args = [] - - def _generate_fake_ssdp_listener(*args, **kwargs): - create_args.append([args, kwargs]) - listener = SSDPListener(*args, **kwargs) - - async def _async_callback(*_): - pass - - @callback - def _callback(*_): - pass - - listener.async_start = _async_callback - listener.async_search = _callback - return listener - - with patch( - "homeassistant.components.ssdp.async_get_ssdp", - return_value=mock_get_ssdp, - ), patch( - "homeassistant.components.ssdp.SSDPListener", - new=_generate_fake_ssdp_listener, - ), patch( - "homeassistant.components.ssdp.network.async_get_adapters", - return_value=_ADAPTERS_WITH_MANUAL_CONFIG, - ): - assert await async_setup_component(hass, ssdp.DOMAIN, {ssdp.DOMAIN: {}}) - await hass.async_block_till_done() - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) - await hass.async_block_till_done() - - argset = set() - for argmap in create_args: - argset.add((argmap[1].get("source_ip"), argmap[1].get("target_ip"))) - - assert argset == { - (IPv6Address("2001:db8::"), None), - (IPv4Address("192.168.1.5"), None), - } - - -async def test_bind_failure_skips_adapter(hass, caplog, mock_get_source_ip): + }, +) +@patch( + "homeassistant.components.ssdp.network.async_get_adapters", + return_value=_ADAPTERS_WITH_MANUAL_CONFIG, +) # XXX TODO: Isn't this duplicate with mock_get_source_ip? +async def test_bind_failure_skips_adapter( + mock_get_adapters, mock_get_ssdp, hass, caplog +): """Test that an adapter with a bind failure is skipped.""" - mock_get_ssdp = { - "mock-domain": [ - { - ssdp.ATTR_UPNP_DEVICE_TYPE: "ABC", - } - ] - } - create_args = [] - search_args = [] - @callback - def _callback(*args): - nonlocal search_args - search_args.append(args) - pass + async def _async_start(self): + if self.source_ip == IPv6Address("2001:db8::"): + raise OSError - def _generate_failing_ssdp_listener(*args, **kwargs): - create_args.append([args, kwargs]) - listener = SSDPListener(*args, **kwargs) + SsdpListener.async_start = _async_start + await init_ssdp_component(hass) - async def _async_callback(*_): - if kwargs["source_ip"] == IPv6Address("2001:db8::"): - raise OSError - pass - - listener.async_start = _async_callback - listener.async_search = _callback - return listener - - with patch( - "homeassistant.components.ssdp.async_get_ssdp", - return_value=mock_get_ssdp, - ), patch( - "homeassistant.components.ssdp.SSDPListener", - new=_generate_failing_ssdp_listener, - ), patch( - "homeassistant.components.ssdp.network.async_get_adapters", - return_value=_ADAPTERS_WITH_MANUAL_CONFIG, - ): - assert await async_setup_component(hass, ssdp.DOMAIN, {ssdp.DOMAIN: {}}) - await hass.async_block_till_done() - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) - await hass.async_block_till_done() - - argset = set() - for argmap in create_args: - argset.add((argmap[1].get("source_ip"), argmap[1].get("target_ip"))) - - assert argset == { - (IPv6Address("2001:db8::"), None), - (IPv4Address("192.168.1.5"), None), - } assert "Failed to setup listener for" in caplog.text - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) - await hass.async_block_till_done() - assert set(search_args) == { - (), - ( - ( - "255.255.255.255", - 1900, - ), - ), - } + ssdp_listeners = hass.data[ssdp.DOMAIN]._ssdp_listeners + source_ips = {ssdp_listener.source_ip for ssdp_listener in ssdp_listeners} + assert source_ips == { + IPv4Address("192.168.1.5") + } # Note no SsdpListener for IPv6 address. -async def test_ipv4_does_additional_search_for_sonos(hass, caplog, mock_get_source_ip): - """Test that only ipv4 does an additional search for Sonos.""" - mock_get_ssdp = { +@pytest.mark.usefixtures("mock_get_source_ip") +@patch( + "homeassistant.components.ssdp.async_get_ssdp", + return_value={ "mock-domain": [ { ssdp.ATTR_UPNP_DEVICE_TYPE: "ABC", } ] - } - search_args = [] + }, +) +@patch( + "homeassistant.components.ssdp.network.async_get_adapters", + return_value=_ADAPTERS_WITH_MANUAL_CONFIG, +) # XXX TODO: Isn't this duplicate with mock_get_source_ip? +async def test_ipv4_does_additional_search_for_sonos( + mock_get_adapters, mock_get_ssdp, hass +): + """Test that only ipv4 does an additional search for Sonos.""" + ssdp_listener = await init_ssdp_component(hass) - def _generate_fake_ssdp_listener(*args, **kwargs): - listener = SSDPListener(*args, **kwargs) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) + await hass.async_block_till_done() - async def _async_callback(*_): - pass - - @callback - def _callback(*args): - nonlocal search_args - search_args.append(args) - pass - - listener.async_start = _async_callback - listener.async_search = _callback - return listener - - with patch( - "homeassistant.components.ssdp.async_get_ssdp", - return_value=mock_get_ssdp, - ), patch( - "homeassistant.components.ssdp.SSDPListener", - new=_generate_fake_ssdp_listener, - ), patch( - "homeassistant.components.ssdp.network.async_get_adapters", - return_value=_ADAPTERS_WITH_MANUAL_CONFIG, - ): - assert await async_setup_component(hass, ssdp.DOMAIN, {ssdp.DOMAIN: {}}) - await hass.async_block_till_done() - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) - await hass.async_block_till_done() - - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) - await hass.async_block_till_done() - - assert set(search_args) == { - (), + assert ssdp_listener.async_search.call_count == 6 + assert ssdp_listener.async_search.call_args[0] == ( ( - ( - "255.255.255.255", - 1900, - ), + "255.255.255.255", + 1900, ), - } - - -async def test_location_change_evicts_prior_location_from_cache( - hass, aioclient_mock, mock_get_source_ip -): - """Test that a location change for a UDN will evict the prior location from the cache.""" - mock_get_ssdp = { - "hue": [{"manufacturer": "Signify", "modelName": "Philips hue bridge 2015"}] - } - - hue_response = """ - - -1 -0 - -http://{ip_address}:80/ - -urn:schemas-upnp-org:device:Basic:1 -Philips hue ({ip_address}) -Signify -http://www.philips-hue.com -Philips hue Personal Wireless Lighting -Philips hue bridge 2015 -BSB002 -http://www.philips-hue.com -001788a36abf -uuid:2f402f80-da50-11e1-9b23-001788a36abf - - - """ - - aioclient_mock.get( - "http://192.168.212.23/description.xml", - text=hue_response.format(ip_address="192.168.212.23"), ) - aioclient_mock.get( - "http://169.254.8.155/description.xml", - text=hue_response.format(ip_address="169.254.8.155"), - ) - ssdp_response_without_location = { - "ST": "uuid:2f402f80-da50-11e1-9b23-001788a36abf", - "_udn": "uuid:2f402f80-da50-11e1-9b23-001788a36abf", - "USN": "uuid:2f402f80-da50-11e1-9b23-001788a36abf", - "SERVER": "Hue/1.0 UPnP/1.0 IpBridge/1.44.0", - "hue-bridgeid": "001788FFFEA36ABF", - "EXT": "", - } - - mock_good_ip_ssdp_response = CaseInsensitiveDict( - **ssdp_response_without_location, - **{"LOCATION": "http://192.168.212.23/description.xml"}, - ) - mock_link_local_ip_ssdp_response = CaseInsensitiveDict( - **ssdp_response_without_location, - **{"LOCATION": "http://169.254.8.155/description.xml"}, - ) - mock_ssdp_response = mock_good_ip_ssdp_response - - def _generate_fake_ssdp_listener(*args, **kwargs): - listener = SSDPListener(*args, **kwargs) - - async def _async_callback(*_): - pass - - @callback - def _callback(*_): - hass.async_create_task(listener.async_callback(mock_ssdp_response)) - - listener.async_start = _async_callback - listener.async_search = _callback - return listener - - with patch( - "homeassistant.components.ssdp.async_get_ssdp", - return_value=mock_get_ssdp, - ), patch( - "homeassistant.components.ssdp.SSDPListener", - new=_generate_fake_ssdp_listener, - ), patch.object( - hass.config_entries.flow, "async_init" - ) as mock_init: - assert await async_setup_component(hass, ssdp.DOMAIN, {ssdp.DOMAIN: {}}) - await hass.async_block_till_done() - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) - await hass.async_block_till_done() - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) - await hass.async_block_till_done() - assert len(mock_init.mock_calls) == 1 - assert mock_init.mock_calls[0][1][0] == "hue" - assert mock_init.mock_calls[0][2]["context"] == { - "source": config_entries.SOURCE_SSDP - } - assert ( - mock_init.mock_calls[0][2]["data"][ssdp.ATTR_SSDP_LOCATION] - == mock_good_ip_ssdp_response["location"] - ) - - mock_init.reset_mock() - mock_ssdp_response = mock_link_local_ip_ssdp_response - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=400)) - await hass.async_block_till_done() - assert mock_init.mock_calls[0][1][0] == "hue" - assert mock_init.mock_calls[0][2]["context"] == { - "source": config_entries.SOURCE_SSDP - } - assert ( - mock_init.mock_calls[0][2]["data"][ssdp.ATTR_SSDP_LOCATION] - == mock_link_local_ip_ssdp_response["location"] - ) - - mock_init.reset_mock() - mock_ssdp_response = mock_good_ip_ssdp_response - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=600)) - await hass.async_block_till_done() - assert mock_init.mock_calls[0][1][0] == "hue" - assert mock_init.mock_calls[0][2]["context"] == { - "source": config_entries.SOURCE_SSDP - } - assert ( - mock_init.mock_calls[0][2]["data"][ssdp.ATTR_SSDP_LOCATION] - == mock_good_ip_ssdp_response["location"] - ) - - -async def test_location_change_with_overlapping_udn_st_combinations( - hass, aioclient_mock -): - """Test handling when a UDN and ST broadcast multiple locations.""" - mock_get_ssdp = { - "test_integration": [ - {"manufacturer": "test_manufacturer", "modelName": "test_model"} - ] - } - - hue_response = """ - - -test_manufacturer -test_model - - - """ - - aioclient_mock.get( - "http://192.168.72.1:49154/wps_device.xml", - text=hue_response.format(ip_address="192.168.72.1"), - ) - aioclient_mock.get( - "http://192.168.72.1:49152/wps_device.xml", - text=hue_response.format(ip_address="192.168.72.1"), - ) - ssdp_response_without_location = { - "ST": "upnp:rootdevice", - "_udn": "uuid:a793d3cc-e802-44fb-84f4-5a30f33115b6", - "USN": "uuid:a793d3cc-e802-44fb-84f4-5a30f33115b6::upnp:rootdevice", - "EXT": "", - } - - port_49154_response = CaseInsensitiveDict( - **ssdp_response_without_location, - **{"LOCATION": "http://192.168.72.1:49154/wps_device.xml"}, - ) - port_49152_response = CaseInsensitiveDict( - **ssdp_response_without_location, - **{"LOCATION": "http://192.168.72.1:49152/wps_device.xml"}, - ) - mock_ssdp_response = port_49154_response - - def _generate_fake_ssdp_listener(*args, **kwargs): - listener = SSDPListener(*args, **kwargs) - - async def _async_callback(*_): - pass - - @callback - def _callback(*_): - hass.async_create_task(listener.async_callback(mock_ssdp_response)) - - listener.async_start = _async_callback - listener.async_search = _callback - return listener - - with patch( - "homeassistant.components.ssdp.async_get_ssdp", - return_value=mock_get_ssdp, - ), patch( - "homeassistant.components.ssdp.SSDPListener", - new=_generate_fake_ssdp_listener, - ), patch.object( - hass.config_entries.flow, "async_init" - ) as mock_init: - assert await async_setup_component(hass, ssdp.DOMAIN, {ssdp.DOMAIN: {}}) - await hass.async_block_till_done() - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) - await hass.async_block_till_done() - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) - await hass.async_block_till_done() - assert len(mock_init.mock_calls) == 1 - assert mock_init.mock_calls[0][1][0] == "test_integration" - assert mock_init.mock_calls[0][2]["context"] == { - "source": config_entries.SOURCE_SSDP - } - assert ( - mock_init.mock_calls[0][2]["data"][ssdp.ATTR_SSDP_LOCATION] - == port_49154_response["location"] - ) - - mock_init.reset_mock() - mock_ssdp_response = port_49152_response - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=400)) - await hass.async_block_till_done() - assert mock_init.mock_calls[0][1][0] == "test_integration" - assert mock_init.mock_calls[0][2]["context"] == { - "source": config_entries.SOURCE_SSDP - } - assert ( - mock_init.mock_calls[0][2]["data"][ssdp.ATTR_SSDP_LOCATION] - == port_49152_response["location"] - ) - - mock_init.reset_mock() - mock_ssdp_response = port_49154_response - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=600)) - await hass.async_block_till_done() - assert mock_init.mock_calls[0][1][0] == "test_integration" - assert mock_init.mock_calls[0][2]["context"] == { - "source": config_entries.SOURCE_SSDP - } - assert ( - mock_init.mock_calls[0][2]["data"][ssdp.ATTR_SSDP_LOCATION] - == port_49154_response["location"] - ) + assert ssdp_listener.async_search.call_args[1] == {} diff --git a/tests/components/upnp/__init__.py b/tests/components/upnp/__init__.py index 4fcc4167e5b..54ceff6eb1d 100644 --- a/tests/components/upnp/__init__.py +++ b/tests/components/upnp/__init__.py @@ -1 +1 @@ -"""Tests for the IGD component.""" +"""Tests for the upnp component.""" diff --git a/tests/components/upnp/common.py b/tests/components/upnp/common.py deleted file mode 100644 index 4dd0fd4083d..00000000000 --- a/tests/components/upnp/common.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Common for upnp.""" - -from urllib.parse import urlparse - -from homeassistant.components import ssdp - -TEST_UDN = "uuid:device" -TEST_ST = "urn:schemas-upnp-org:device:InternetGatewayDevice:1" -TEST_USN = f"{TEST_UDN}::{TEST_ST}" -TEST_LOCATION = "http://192.168.1.1/desc.xml" -TEST_HOSTNAME = urlparse(TEST_LOCATION).hostname -TEST_FRIENDLY_NAME = "friendly name" -TEST_DISCOVERY = { - ssdp.ATTR_SSDP_LOCATION: TEST_LOCATION, - ssdp.ATTR_SSDP_ST: TEST_ST, - ssdp.ATTR_SSDP_USN: TEST_USN, - ssdp.ATTR_UPNP_UDN: TEST_UDN, - "usn": TEST_USN, - "location": TEST_LOCATION, - "_host": TEST_HOSTNAME, - "_udn": TEST_UDN, - "friendlyName": TEST_FRIENDLY_NAME, -} diff --git a/tests/components/upnp/conftest.py b/tests/components/upnp/conftest.py new file mode 100644 index 00000000000..5af99e9ac2d --- /dev/null +++ b/tests/components/upnp/conftest.py @@ -0,0 +1,187 @@ +"""Configuration for SSDP tests.""" +from typing import Any, Mapping +from unittest.mock import AsyncMock, MagicMock, patch +from urllib.parse import urlparse + +import pytest + +from homeassistant.components import ssdp +from homeassistant.components.upnp.const import ( + BYTES_RECEIVED, + BYTES_SENT, + PACKETS_RECEIVED, + PACKETS_SENT, + ROUTER_IP, + ROUTER_UPTIME, + TIMESTAMP, + WAN_STATUS, +) +from homeassistant.core import HomeAssistant +from homeassistant.util import dt + +TEST_UDN = "uuid:device" +TEST_ST = "urn:schemas-upnp-org:device:InternetGatewayDevice:1" +TEST_USN = f"{TEST_UDN}::{TEST_ST}" +TEST_LOCATION = "http://192.168.1.1/desc.xml" +TEST_HOSTNAME = urlparse(TEST_LOCATION).hostname +TEST_FRIENDLY_NAME = "friendly name" +TEST_DISCOVERY = { + ssdp.ATTR_SSDP_LOCATION: TEST_LOCATION, + ssdp.ATTR_SSDP_ST: TEST_ST, + ssdp.ATTR_SSDP_USN: TEST_USN, + ssdp.ATTR_UPNP_UDN: TEST_UDN, + "usn": TEST_USN, + "location": TEST_LOCATION, + "_host": TEST_HOSTNAME, + "_udn": TEST_UDN, + "friendlyName": TEST_FRIENDLY_NAME, +} + + +class MockDevice: + """Mock device for Device.""" + + def __init__(self, hass: HomeAssistant, udn: str) -> None: + """Initialize mock device.""" + self.hass = hass + self._udn = udn + self.traffic_times_polled = 0 + self.status_times_polled = 0 + + @classmethod + async def async_create_device(cls, hass, ssdp_location) -> "MockDevice": + """Return self.""" + return cls(hass, TEST_UDN) + + async def async_ssdp_callback( + self, headers: Mapping[str, Any], change: ssdp.SsdpChange + ) -> None: + """SSDP callback, update if needed.""" + pass + + @property + def udn(self) -> str: + """Get the UDN.""" + return self._udn + + @property + def manufacturer(self) -> str: + """Get manufacturer.""" + return "mock-manufacturer" + + @property + def name(self) -> str: + """Get name.""" + return "mock-name" + + @property + def model_name(self) -> str: + """Get the model name.""" + return "mock-model-name" + + @property + def device_type(self) -> str: + """Get the device type.""" + return "urn:schemas-upnp-org:device:InternetGatewayDevice:1" + + @property + def usn(self) -> str: + """Get the USN.""" + return f"{self.udn}::{self.device_type}" + + @property + def unique_id(self) -> str: + """Get the unique id.""" + return self.usn + + @property + def hostname(self) -> str: + """Get the hostname.""" + return "mock-hostname" + + async def async_get_traffic_data(self) -> Mapping[str, Any]: + """Get traffic data.""" + self.traffic_times_polled += 1 + return { + TIMESTAMP: dt.utcnow(), + BYTES_RECEIVED: 0, + BYTES_SENT: 0, + PACKETS_RECEIVED: 0, + PACKETS_SENT: 0, + } + + async def async_get_status(self) -> Mapping[str, Any]: + """Get connection status, uptime, and external IP.""" + self.status_times_polled += 1 + return { + WAN_STATUS: "Connected", + ROUTER_UPTIME: 0, + ROUTER_IP: "192.168.0.1", + } + + +@pytest.fixture(autouse=True) +def mock_upnp_device(): + """Mock homeassistant.components.upnp.Device.""" + with patch( + "homeassistant.components.upnp.Device", new=MockDevice + ) as mock_async_create_device: + yield mock_async_create_device + + +@pytest.fixture +def mock_setup_entry(): + """Mock async_setup_entry.""" + with patch( + "homeassistant.components.upnp.async_setup_entry", + return_value=AsyncMock(True), + ) as mock_setup: + yield mock_setup + + +@pytest.fixture(autouse=True) +async def silent_ssdp_scanner(hass): + """Start SSDP component and get Scanner, prevent actual SSDP traffic.""" + with patch( + "homeassistant.components.ssdp.Scanner._async_start_ssdp_listeners" + ), patch("homeassistant.components.ssdp.Scanner._async_stop_ssdp_listeners"), patch( + "homeassistant.components.ssdp.Scanner.async_scan" + ): + yield + + +@pytest.fixture +async def ssdp_instant_discovery(): + """Instance discovery.""" + # Set up device discovery callback. + async def register_callback(hass, callback, match_dict): + """Immediately do callback.""" + await callback(TEST_DISCOVERY, ssdp.SsdpChange.ALIVE) + return MagicMock() + + with patch( + "homeassistant.components.ssdp.async_register_callback", + side_effect=register_callback, + ) as mock_register, patch( + "homeassistant.components.ssdp.async_get_discovery_info_by_st", + return_value=[TEST_DISCOVERY], + ) as mock_get_info: + yield (mock_register, mock_get_info) + + +@pytest.fixture +async def ssdp_no_discovery(): + """No discovery.""" + # Set up device discovery callback. + async def register_callback(hass, callback, match_dict): + """Don't do callback.""" + return MagicMock() + + with patch( + "homeassistant.components.ssdp.async_register_callback", + side_effect=register_callback, + ) as mock_register, patch( + "homeassistant.components.ssdp.async_get_discovery_info_by_st", + return_value=[], + ) as mock_get_info: + yield (mock_register, mock_get_info) diff --git a/tests/components/upnp/mock_ssdp_scanner.py b/tests/components/upnp/mock_ssdp_scanner.py deleted file mode 100644 index 39f9a801bb6..00000000000 --- a/tests/components/upnp/mock_ssdp_scanner.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Mock ssdp.Scanner.""" -from __future__ import annotations - -from typing import Any -from unittest.mock import patch - -import pytest - -from homeassistant.components import ssdp -from homeassistant.core import callback - - -class MockSsdpDescriptionManager(ssdp.DescriptionManager): - """Mocked ssdp DescriptionManager.""" - - async def fetch_description( - self, xml_location: str | None - ) -> None | dict[str, str]: - """Fetch the location or get it from the cache.""" - if xml_location is None: - return None - return {} - - -class MockSsdpScanner(ssdp.Scanner): - """Mocked ssdp Scanner.""" - - @callback - def async_stop(self, *_: Any) -> None: - """Stop the scanner.""" - # Do nothing. - - async def async_start(self) -> None: - """Start the scanner.""" - self.description_manager = MockSsdpDescriptionManager(self.hass) - - @callback - def async_scan(self, *_: Any) -> None: - """Scan for new entries.""" - # Do nothing. - - -@pytest.fixture -def mock_ssdp_scanner(): - """Mock ssdp Scanner.""" - with patch( - "homeassistant.components.ssdp.Scanner", new=MockSsdpScanner - ) as mock_ssdp_scanner: - yield mock_ssdp_scanner diff --git a/tests/components/upnp/mock_upnp_device.py b/tests/components/upnp/mock_upnp_device.py deleted file mode 100644 index 230fd480cb1..00000000000 --- a/tests/components/upnp/mock_upnp_device.py +++ /dev/null @@ -1,104 +0,0 @@ -"""Mock device for testing purposes.""" - -from typing import Any, Mapping -from unittest.mock import AsyncMock, patch - -import pytest - -from homeassistant.components.upnp.const import ( - BYTES_RECEIVED, - BYTES_SENT, - PACKETS_RECEIVED, - PACKETS_SENT, - ROUTER_IP, - ROUTER_UPTIME, - TIMESTAMP, - WAN_STATUS, -) -from homeassistant.components.upnp.device import Device -from homeassistant.util import dt - -from .common import TEST_UDN - - -class MockDevice(Device): - """Mock device for Device.""" - - def __init__(self, udn: str) -> None: - """Initialize mock device.""" - igd_device = object() - mock_device_updater = AsyncMock() - super().__init__(igd_device, mock_device_updater) - self._udn = udn - self.traffic_times_polled = 0 - self.status_times_polled = 0 - - @classmethod - async def async_create_device(cls, hass, ssdp_location) -> "MockDevice": - """Return self.""" - return cls(TEST_UDN) - - @property - def udn(self) -> str: - """Get the UDN.""" - return self._udn - - @property - def manufacturer(self) -> str: - """Get manufacturer.""" - return "mock-manufacturer" - - @property - def name(self) -> str: - """Get name.""" - return "mock-name" - - @property - def model_name(self) -> str: - """Get the model name.""" - return "mock-model-name" - - @property - def device_type(self) -> str: - """Get the device type.""" - return "urn:schemas-upnp-org:device:InternetGatewayDevice:1" - - @property - def hostname(self) -> str: - """Get the hostname.""" - return "mock-hostname" - - async def async_get_traffic_data(self) -> Mapping[str, Any]: - """Get traffic data.""" - self.traffic_times_polled += 1 - return { - TIMESTAMP: dt.utcnow(), - BYTES_RECEIVED: 0, - BYTES_SENT: 0, - PACKETS_RECEIVED: 0, - PACKETS_SENT: 0, - } - - async def async_get_status(self) -> Mapping[str, Any]: - """Get connection status, uptime, and external IP.""" - self.status_times_polled += 1 - return { - WAN_STATUS: "Connected", - ROUTER_UPTIME: 0, - ROUTER_IP: "192.168.0.1", - } - - async def async_start(self) -> None: - """Start the device updater.""" - - async def async_stop(self) -> None: - """Stop the device updater.""" - - -@pytest.fixture -def mock_upnp_device(): - """Mock upnp Device.async_create_device.""" - with patch( - "homeassistant.components.upnp.Device", new=MockDevice - ) as mock_async_create_device: - yield mock_async_create_device diff --git a/tests/components/upnp/test_config_flow.py b/tests/components/upnp/test_config_flow.py index a83b9ac41dd..fa315804917 100644 --- a/tests/components/upnp/test_config_flow.py +++ b/tests/components/upnp/test_config_flow.py @@ -1,7 +1,6 @@ """Test UPnP/IGD config flow.""" from datetime import timedelta -from unittest.mock import patch import pytest @@ -15,11 +14,10 @@ from homeassistant.components.upnp.const import ( DEFAULT_SCAN_INTERVAL, DOMAIN, ) -from homeassistant.core import CoreState, HomeAssistant -from homeassistant.setup import async_setup_component +from homeassistant.core import HomeAssistant from homeassistant.util import dt -from .common import ( +from .conftest import ( TEST_DISCOVERY, TEST_FRIENDLY_NAME, TEST_HOSTNAME, @@ -28,25 +26,15 @@ from .common import ( TEST_UDN, TEST_USN, ) -from .mock_ssdp_scanner import mock_ssdp_scanner # noqa: F401 -from .mock_upnp_device import mock_upnp_device # noqa: F401 from tests.common import MockConfigEntry, async_fire_time_changed -@pytest.mark.usefixtures("mock_ssdp_scanner", "mock_upnp_device", "mock_get_source_ip") -async def test_flow_ssdp_discovery( - hass: HomeAssistant, -): +@pytest.mark.usefixtures( + "ssdp_instant_discovery", "mock_setup_entry", "mock_get_source_ip" +) +async def test_flow_ssdp(hass: HomeAssistant): """Test config flow: discovered + configured through ssdp.""" - # Ensure we have a ssdp Scanner. - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - ssdp_scanner: ssdp.Scanner = hass.data[ssdp.DOMAIN] - ssdp_scanner.cache[(TEST_UDN, TEST_ST)] = TEST_DISCOVERY - # Speed up callback in ssdp.async_register_callback. - hass.state = CoreState.not_running - # Discovered via step ssdp. result = await hass.config_entries.flow.async_init( DOMAIN, @@ -70,7 +58,7 @@ async def test_flow_ssdp_discovery( } -@pytest.mark.usefixtures("mock_ssdp_scanner", "mock_get_source_ip") +@pytest.mark.usefixtures("mock_get_source_ip") async def test_flow_ssdp_incomplete_discovery(hass: HomeAssistant): """Test config flow: incomplete discovery through ssdp.""" # Discovered via step ssdp. @@ -88,7 +76,7 @@ async def test_flow_ssdp_incomplete_discovery(hass: HomeAssistant): assert result["reason"] == "incomplete_discovery" -@pytest.mark.usefixtures("mock_ssdp_scanner", "mock_get_source_ip") +@pytest.mark.usefixtures("mock_get_source_ip") async def test_flow_ssdp_discovery_ignored(hass: HomeAssistant): """Test config flow: discovery through ssdp, but ignored, as hostname is used by existing config entry.""" # Existing entry. @@ -113,17 +101,11 @@ async def test_flow_ssdp_discovery_ignored(hass: HomeAssistant): assert result["reason"] == "discovery_ignored" -@pytest.mark.usefixtures("mock_ssdp_scanner", "mock_upnp_device", "mock_get_source_ip") +@pytest.mark.usefixtures( + "ssdp_instant_discovery", "mock_setup_entry", "mock_get_source_ip" +) async def test_flow_user(hass: HomeAssistant): """Test config flow: discovered + configured through user.""" - # Ensure we have a ssdp Scanner. - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - ssdp_scanner: ssdp.Scanner = hass.data[ssdp.DOMAIN] - ssdp_scanner.cache[(TEST_UDN, TEST_ST)] = TEST_DISCOVERY - # Speed up callback in ssdp.async_register_callback. - hass.state = CoreState.not_running - # Discovered via step user. result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -145,17 +127,11 @@ async def test_flow_user(hass: HomeAssistant): } -@pytest.mark.usefixtures("mock_ssdp_scanner", "mock_upnp_device", "mock_get_source_ip") +@pytest.mark.usefixtures( + "ssdp_instant_discovery", "mock_setup_entry", "mock_get_source_ip" +) async def test_flow_import(hass: HomeAssistant): """Test config flow: configured through configuration.yaml.""" - # Ensure we have a ssdp Scanner. - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - ssdp_scanner: ssdp.Scanner = hass.data[ssdp.DOMAIN] - ssdp_scanner.cache[(TEST_UDN, TEST_ST)] = TEST_DISCOVERY - # Speed up callback in ssdp.async_register_callback. - hass.state = CoreState.not_running - # Discovered via step import. result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT} @@ -169,7 +145,7 @@ async def test_flow_import(hass: HomeAssistant): } -@pytest.mark.usefixtures("mock_ssdp_scanner", "mock_get_source_ip") +@pytest.mark.usefixtures("ssdp_instant_discovery", "mock_get_source_ip") async def test_flow_import_already_configured(hass: HomeAssistant): """Test config flow: configured through configuration.yaml, but existing config entry.""" # Existing entry. @@ -193,37 +169,20 @@ async def test_flow_import_already_configured(hass: HomeAssistant): assert result["reason"] == "already_configured" -@pytest.mark.usefixtures("mock_ssdp_scanner", "mock_get_source_ip") +@pytest.mark.usefixtures("ssdp_no_discovery", "mock_get_source_ip") async def test_flow_import_no_devices_found(hass: HomeAssistant): """Test config flow: no devices found, configured through configuration.yaml.""" - # Ensure we have a ssdp Scanner. - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - ssdp_scanner: ssdp.Scanner = hass.data[ssdp.DOMAIN] - ssdp_scanner.cache.clear() - # Discovered via step import. - with patch( - "homeassistant.components.upnp.config_flow.SSDP_SEARCH_TIMEOUT", new=0.0 - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT} - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "no_devices_found" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "no_devices_found" -@pytest.mark.usefixtures("mock_ssdp_scanner", "mock_upnp_device", "mock_get_source_ip") +@pytest.mark.usefixtures("ssdp_instant_discovery", "mock_get_source_ip") async def test_options_flow(hass: HomeAssistant): """Test options flow.""" - # Ensure we have a ssdp Scanner. - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - ssdp_scanner: ssdp.Scanner = hass.data[ssdp.DOMAIN] - ssdp_scanner.cache[(TEST_UDN, TEST_ST)] = TEST_DISCOVERY - # Speed up callback in ssdp.async_register_callback. - hass.state = CoreState.not_running - # Set up config entry. config_entry = MockConfigEntry( domain=DOMAIN, diff --git a/tests/components/upnp/test_init.py b/tests/components/upnp/test_init.py index 6f0aa438310..6b3d2a5187f 100644 --- a/tests/components/upnp/test_init.py +++ b/tests/components/upnp/test_init.py @@ -3,25 +3,23 @@ from __future__ import annotations import pytest -from homeassistant.components import ssdp from homeassistant.components.upnp.const import ( CONFIG_ENTRY_ST, CONFIG_ENTRY_UDN, DOMAIN, ) -from homeassistant.core import CoreState, HomeAssistant +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from .common import TEST_DISCOVERY, TEST_ST, TEST_UDN -from .mock_ssdp_scanner import mock_ssdp_scanner # noqa: F401 -from .mock_upnp_device import mock_upnp_device # noqa: F401 +from .conftest import TEST_ST, TEST_UDN from tests.common import MockConfigEntry -@pytest.mark.usefixtures("mock_ssdp_scanner", "mock_upnp_device", "mock_get_source_ip") +@pytest.mark.usefixtures("ssdp_instant_discovery", "mock_get_source_ip") async def test_async_setup_entry_default(hass: HomeAssistant): """Test async_setup_entry.""" + entry = MockConfigEntry( domain=DOMAIN, data={ @@ -34,12 +32,6 @@ async def test_async_setup_entry_default(hass: HomeAssistant): await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() - # Device is discovered. - ssdp_scanner: ssdp.Scanner = hass.data[ssdp.DOMAIN] - ssdp_scanner.cache[(TEST_UDN, TEST_ST)] = TEST_DISCOVERY - # Speed up callback in ssdp.async_register_callback. - hass.state = CoreState.not_running - # Load config_entry. entry.add_to_hass(hass) assert await hass.config_entries.async_setup(entry.entry_id) is True diff --git a/tests/components/yamaha_musiccast/test_config_flow.py b/tests/components/yamaha_musiccast/test_config_flow.py index 0e52d598bf6..31d63bac158 100644 --- a/tests/components/yamaha_musiccast/test_config_flow.py +++ b/tests/components/yamaha_musiccast/test_config_flow.py @@ -14,6 +14,17 @@ from homeassistant.const import CONF_HOST from tests.common import MockConfigEntry +@pytest.fixture(autouse=True) +async def silent_ssdp_scanner(hass): + """Start SSDP component and get Scanner, prevent actual SSDP traffic.""" + with patch( + "homeassistant.components.ssdp.Scanner._async_start_ssdp_listeners" + ), patch("homeassistant.components.ssdp.Scanner._async_stop_ssdp_listeners"), patch( + "homeassistant.components.ssdp.Scanner.async_scan" + ): + yield + + @pytest.fixture(autouse=True) def mock_setup_entry(): """Mock setting up a config entry.""" diff --git a/tests/components/yeelight/__init__.py b/tests/components/yeelight/__init__.py index 4a862fa13dd..eb7ac01e3b1 100644 --- a/tests/components/yeelight/__init__.py +++ b/tests/components/yeelight/__init__.py @@ -4,7 +4,7 @@ from datetime import timedelta from ipaddress import IPv4Address from unittest.mock import AsyncMock, MagicMock, patch -from async_upnp_client.search import SSDPListener +from async_upnp_client.search import SsdpSearchListener from yeelight import BulbException, BulbType from yeelight.main import _MODEL_SPECS @@ -145,7 +145,7 @@ def _mocked_bulb(cannot_connect=False): def _patched_ssdp_listener(info, *args, **kwargs): - listener = SSDPListener(*args, **kwargs) + listener = SsdpSearchListener(*args, **kwargs) async def _async_callback(*_): if kwargs["source_ip"] == IPv4Address(FAIL_TO_BIND_IP): @@ -173,7 +173,7 @@ def _patch_discovery(no_device=False, capabilities=None): ) return patch( - "homeassistant.components.yeelight.SSDPListener", + "homeassistant.components.yeelight.SsdpSearchListener", new=_generate_fake_ssdp_listener, ) From 459bc55e3295a4ea3b0bbf15078eef57f5bb9bc0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Sep 2021 18:58:19 -0700 Subject: [PATCH 354/843] Bump HAP-python to 4.2.1 (#55804) --- homeassistant/components/homekit/manifest.json | 2 +- homeassistant/components/homekit/type_fans.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json index e40d743068c..2589a1ac6ec 100644 --- a/homeassistant/components/homekit/manifest.json +++ b/homeassistant/components/homekit/manifest.json @@ -3,7 +3,7 @@ "name": "HomeKit", "documentation": "https://www.home-assistant.io/integrations/homekit", "requirements": [ - "HAP-python==4.1.0", + "HAP-python==4.2.1", "fnvhash==0.1.0", "PyQRCode==1.2.1", "base36==0.1.1" diff --git a/homeassistant/components/homekit/type_fans.py b/homeassistant/components/homekit/type_fans.py index 85157dd9367..d25f197e0ca 100644 --- a/homeassistant/components/homekit/type_fans.py +++ b/homeassistant/components/homekit/type_fans.py @@ -219,7 +219,7 @@ class Fan(HomeAccessory): # the rotation speed is mapped to 1 otherwise the update is ignored # in order to avoid this incorrect behavior. if percentage == 0 and state == STATE_ON: - percentage = 1 + percentage = max(1, self.char_speed.properties[PROP_MIN_STEP]) if percentage is not None: self.char_speed.set_value(percentage) diff --git a/requirements_all.txt b/requirements_all.txt index 2d43b04240f..e28de73c8c9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -14,7 +14,7 @@ Adafruit-SHT31==1.0.2 # Adafruit_BBIO==1.1.1 # homeassistant.components.homekit -HAP-python==4.1.0 +HAP-python==4.2.1 # homeassistant.components.mastodon Mastodon.py==1.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b6c37dac08a..54ea4ab4448 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -7,7 +7,7 @@ AEMET-OpenData==0.2.1 # homeassistant.components.homekit -HAP-python==4.1.0 +HAP-python==4.2.1 # homeassistant.components.flick_electric PyFlick==0.0.2 From eff59e8b00a5712460170c524004db91a548bdee Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Sep 2021 19:00:26 -0700 Subject: [PATCH 355/843] Use the same server name for all HomeKit bridges (#55860) --- homeassistant/components/homekit/__init__.py | 6 ++++-- tests/components/homekit/test_homekit.py | 12 +++++++++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index b293f1f542d..7d2e60a53b9 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -519,7 +519,7 @@ class HomeKit: self.bridge = None self.driver = None - def setup(self, async_zeroconf_instance): + def setup(self, async_zeroconf_instance, uuid): """Set up bridge and accessory driver.""" persist_file = get_persist_fullpath_for_entry_id(self.hass, self._entry_id) @@ -534,6 +534,7 @@ class HomeKit: persist_file=persist_file, advertised_address=self._advertise_ip, async_zeroconf_instance=async_zeroconf_instance, + zeroconf_server=f"{uuid}-hap.local.", ) # If we do not load the mac address will be wrong @@ -713,7 +714,8 @@ class HomeKit: return self.status = STATUS_WAIT async_zc_instance = await zeroconf.async_get_async_instance(self.hass) - await self.hass.async_add_executor_job(self.setup, async_zc_instance) + uuid = await self.hass.helpers.instance_id.async_get() + await self.hass.async_add_executor_job(self.setup, async_zc_instance, uuid) self.aid_storage = AccessoryAidStorage(self.hass, self._entry_id) await self.aid_storage.async_initialize() if not await self._async_create_accessories(): diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index e9c9ad6662b..9b4ae477704 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -265,8 +265,9 @@ async def test_homekit_setup(hass, hk_driver, mock_zeroconf): hass.states.async_set("light.demo", "on") hass.states.async_set("light.demo2", "on") zeroconf_mock = MagicMock() + uuid = await hass.helpers.instance_id.async_get() with patch(f"{PATH_HOMEKIT}.HomeDriver", return_value=hk_driver) as mock_driver: - await hass.async_add_executor_job(homekit.setup, zeroconf_mock) + await hass.async_add_executor_job(homekit.setup, zeroconf_mock, uuid) path = get_persist_fullpath_for_entry_id(hass, entry.entry_id) mock_driver.assert_called_with( @@ -280,6 +281,7 @@ async def test_homekit_setup(hass, hk_driver, mock_zeroconf): persist_file=path, advertised_address=None, async_zeroconf_instance=zeroconf_mock, + zeroconf_server=f"{uuid}-hap.local.", ) assert homekit.driver.safe_mode is False @@ -307,8 +309,9 @@ async def test_homekit_setup_ip_address(hass, hk_driver, mock_zeroconf): mock_zeroconf = MagicMock() path = get_persist_fullpath_for_entry_id(hass, entry.entry_id) + uuid = await hass.helpers.instance_id.async_get() with patch(f"{PATH_HOMEKIT}.HomeDriver", return_value=hk_driver) as mock_driver: - await hass.async_add_executor_job(homekit.setup, mock_zeroconf) + await hass.async_add_executor_job(homekit.setup, mock_zeroconf, uuid) mock_driver.assert_called_with( hass, entry.entry_id, @@ -320,6 +323,7 @@ async def test_homekit_setup_ip_address(hass, hk_driver, mock_zeroconf): persist_file=path, advertised_address=None, async_zeroconf_instance=mock_zeroconf, + zeroconf_server=f"{uuid}-hap.local.", ) @@ -346,8 +350,9 @@ async def test_homekit_setup_advertise_ip(hass, hk_driver, mock_zeroconf): async_zeroconf_instance = MagicMock() path = get_persist_fullpath_for_entry_id(hass, entry.entry_id) + uuid = await hass.helpers.instance_id.async_get() with patch(f"{PATH_HOMEKIT}.HomeDriver", return_value=hk_driver) as mock_driver: - await hass.async_add_executor_job(homekit.setup, async_zeroconf_instance) + await hass.async_add_executor_job(homekit.setup, async_zeroconf_instance, uuid) mock_driver.assert_called_with( hass, entry.entry_id, @@ -359,6 +364,7 @@ async def test_homekit_setup_advertise_ip(hass, hk_driver, mock_zeroconf): persist_file=path, advertised_address="192.168.1.100", async_zeroconf_instance=async_zeroconf_instance, + zeroconf_server=f"{uuid}-hap.local.", ) From 371aa03bcab77736a4a98c1188c0a906443bf9f6 Mon Sep 17 00:00:00 2001 From: Greg Thornton Date: Sat, 11 Sep 2021 21:41:30 -0500 Subject: [PATCH 356/843] Add audio support option to HomeKit camera UI config flow (#56107) --- .../components/homekit/config_flow.py | 16 +++ homeassistant/components/homekit/strings.json | 5 +- .../components/homekit/translations/en.json | 3 +- tests/components/homekit/test_config_flow.py | 135 +++++++++++++++++- 4 files changed, 155 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/homekit/config_flow.py b/homeassistant/components/homekit/config_flow.py index 81f439c8954..79fc43fde3a 100644 --- a/homeassistant/components/homekit/config_flow.py +++ b/homeassistant/components/homekit/config_flow.py @@ -41,6 +41,7 @@ from .const import ( CONF_EXCLUDE_ACCESSORY_MODE, CONF_FILTER, CONF_HOMEKIT_MODE, + CONF_SUPPORT_AUDIO, CONF_VIDEO_CODEC, DEFAULT_AUTO_START, DEFAULT_CONFIG_FLOW_PORT, @@ -54,6 +55,7 @@ from .const import ( ) from .util import async_find_next_available_port, state_needs_accessory_mode +CONF_CAMERA_AUDIO = "camera_audio" CONF_CAMERA_COPY = "camera_copy" CONF_INCLUDE_EXCLUDE_MODE = "include_exclude_mode" @@ -362,14 +364,24 @@ class OptionsFlowHandler(config_entries.OptionsFlow): and CONF_VIDEO_CODEC in entity_config[entity_id] ): del entity_config[entity_id][CONF_VIDEO_CODEC] + if entity_id in user_input[CONF_CAMERA_AUDIO]: + entity_config.setdefault(entity_id, {})[CONF_SUPPORT_AUDIO] = True + elif ( + entity_id in entity_config + and CONF_SUPPORT_AUDIO in entity_config[entity_id] + ): + del entity_config[entity_id][CONF_SUPPORT_AUDIO] return await self.async_step_advanced() + cameras_with_audio = [] cameras_with_copy = [] entity_config = self.hk_options.setdefault(CONF_ENTITY_CONFIG, {}) for entity in self.included_cameras: hk_entity_config = entity_config.get(entity, {}) if hk_entity_config.get(CONF_VIDEO_CODEC) == VIDEO_CODEC_COPY: cameras_with_copy.append(entity) + if hk_entity_config.get(CONF_SUPPORT_AUDIO): + cameras_with_audio.append(entity) data_schema = vol.Schema( { @@ -377,6 +389,10 @@ class OptionsFlowHandler(config_entries.OptionsFlow): CONF_CAMERA_COPY, default=cameras_with_copy, ): cv.multi_select(self.included_cameras), + vol.Optional( + CONF_CAMERA_AUDIO, + default=cameras_with_audio, + ): cv.multi_select(self.included_cameras), } ) return self.async_show_form(step_id="cameras", data_schema=data_schema) diff --git a/homeassistant/components/homekit/strings.json b/homeassistant/components/homekit/strings.json index 69cff3bfcc3..ede11aef19c 100644 --- a/homeassistant/components/homekit/strings.json +++ b/homeassistant/components/homekit/strings.json @@ -23,10 +23,11 @@ }, "cameras": { "data": { - "camera_copy": "Cameras that support native H.264 streams" + "camera_copy": "Cameras that support native H.264 streams", + "camera_audio": "Cameras that support audio" }, "description": "Check all cameras that support native H.264 streams. If the camera does not output a H.264 stream, the system will transcode the video to H.264 for HomeKit. Transcoding requires a performant CPU and is unlikely to work on single board computers.", - "title": "Select camera video codec." + "title": "Camera Configuration" }, "advanced": { "data": { diff --git a/homeassistant/components/homekit/translations/en.json b/homeassistant/components/homekit/translations/en.json index 564709cb9c1..b118ec16424 100644 --- a/homeassistant/components/homekit/translations/en.json +++ b/homeassistant/components/homekit/translations/en.json @@ -29,10 +29,11 @@ }, "cameras": { "data": { + "camera_audio": "Cameras that support audio", "camera_copy": "Cameras that support native H.264 streams" }, "description": "Check all cameras that support native H.264 streams. If the camera does not output a H.264 stream, the system will transcode the video to H.264 for HomeKit. Transcoding requires a performant CPU and is unlikely to work on single board computers.", - "title": "Select camera video codec." + "title": "Camera Configuration" }, "include_exclude": { "data": { diff --git a/tests/components/homekit/test_config_flow.py b/tests/components/homekit/test_config_flow.py index fadb4572df3..8d68b8aba73 100644 --- a/tests/components/homekit/test_config_flow.py +++ b/tests/components/homekit/test_config_flow.py @@ -753,7 +753,10 @@ async def test_options_flow_include_mode_with_cameras(hass, mock_get_source_ip): ) assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM assert result2["step_id"] == "cameras" - assert result2["data_schema"]({}) == {"camera_copy": ["camera.native_h264"]} + assert result2["data_schema"]({}) == { + "camera_copy": ["camera.native_h264"], + "camera_audio": [], + } schema = result2["data_schema"].schema assert _get_schema_default(schema, "camera_copy") == ["camera.native_h264"] @@ -776,6 +779,136 @@ async def test_options_flow_include_mode_with_cameras(hass, mock_get_source_ip): } +async def test_options_flow_with_camera_audio(hass, mock_get_source_ip): + """Test config flow options with cameras that support audio.""" + + config_entry = _mock_config_entry_with_options_populated() + config_entry.add_to_hass(hass) + + hass.states.async_set("climate.old", "off") + hass.states.async_set("camera.audio", "off") + hass.states.async_set("camera.no_audio", "off") + hass.states.async_set("camera.excluded", "off") + + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init( + config_entry.entry_id, context={"show_advanced_options": False} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"domains": ["fan", "vacuum", "climate", "camera"]}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "include_exclude" + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "entities": ["camera.audio", "camera.no_audio"], + "include_exclude_mode": "include", + }, + ) + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "cameras" + + result3 = await hass.config_entries.options.async_configure( + result2["flow_id"], + user_input={"camera_audio": ["camera.audio"]}, + ) + + assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert config_entry.options == { + "auto_start": True, + "mode": "bridge", + "filter": { + "exclude_domains": [], + "exclude_entities": [], + "include_domains": ["fan", "vacuum", "climate"], + "include_entities": ["camera.audio", "camera.no_audio"], + }, + "entity_config": {"camera.audio": {"support_audio": True}}, + } + + # Now run though again and verify we can turn off audio + + result = await hass.config_entries.options.async_init( + config_entry.entry_id, context={"show_advanced_options": False} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + assert result["data_schema"]({}) == { + "domains": ["fan", "vacuum", "climate", "camera"], + "mode": "bridge", + } + schema = result["data_schema"].schema + assert _get_schema_default(schema, "domains") == [ + "fan", + "vacuum", + "climate", + "camera", + ] + assert _get_schema_default(schema, "mode") == "bridge" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"domains": ["fan", "vacuum", "climate", "camera"]}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "include_exclude" + assert result["data_schema"]({}) == { + "entities": ["camera.audio", "camera.no_audio"], + "include_exclude_mode": "include", + } + schema = result["data_schema"].schema + assert _get_schema_default(schema, "entities") == [ + "camera.audio", + "camera.no_audio", + ] + assert _get_schema_default(schema, "include_exclude_mode") == "include" + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "entities": ["climate.old", "camera.excluded"], + "include_exclude_mode": "exclude", + }, + ) + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "cameras" + assert result2["data_schema"]({}) == { + "camera_copy": [], + "camera_audio": ["camera.audio"], + } + schema = result2["data_schema"].schema + assert _get_schema_default(schema, "camera_audio") == ["camera.audio"] + + result3 = await hass.config_entries.options.async_configure( + result2["flow_id"], + user_input={"camera_audio": []}, + ) + + assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert config_entry.options == { + "auto_start": True, + "entity_config": {"camera.audio": {}}, + "filter": { + "exclude_domains": [], + "exclude_entities": ["climate.old", "camera.excluded"], + "include_domains": ["fan", "vacuum", "climate", "camera"], + "include_entities": [], + }, + "mode": "bridge", + } + + async def test_options_flow_blocked_when_from_yaml(hass, mock_get_source_ip): """Test config flow options.""" From 2b7cdb70a8f6a271b07adf9eed0a5da0c84147e3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Sep 2021 20:38:34 -0700 Subject: [PATCH 357/843] Ensure rainmachine device name is a string (#56121) --- homeassistant/components/rainmachine/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/rainmachine/__init__.py b/homeassistant/components/rainmachine/__init__.py index fac929e7e99..d5eceab05fc 100644 --- a/homeassistant/components/rainmachine/__init__.py +++ b/homeassistant/components/rainmachine/__init__.py @@ -189,7 +189,7 @@ class RainMachineEntity(CoordinatorEntity): self._attr_device_info = { "identifiers": {(DOMAIN, controller.mac)}, "connections": {(dr.CONNECTION_NETWORK_MAC, controller.mac)}, - "name": controller.name, + "name": str(controller.name), "manufacturer": "RainMachine", "model": ( f"Version {controller.hardware_version} " From 4a449902a5da8353910e4671457c29fd5225fb6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Sun, 12 Sep 2021 08:12:32 +0200 Subject: [PATCH 358/843] Surepetcare, upgrade library (#56067) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Daniel Hjelseth Høyer --- homeassistant/components/surepetcare/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/surepetcare/manifest.json b/homeassistant/components/surepetcare/manifest.json index ee97e1ac627..9fb35ac91e5 100644 --- a/homeassistant/components/surepetcare/manifest.json +++ b/homeassistant/components/surepetcare/manifest.json @@ -3,6 +3,6 @@ "name": "Sure Petcare", "documentation": "https://www.home-assistant.io/integrations/surepetcare", "codeowners": ["@benleb", "@danielhiversen"], - "requirements": ["surepy==0.7.0"], + "requirements": ["surepy==0.7.1"], "iot_class": "cloud_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index e28de73c8c9..4e9dfed5423 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2242,7 +2242,7 @@ sucks==0.9.4 sunwatcher==0.2.1 # homeassistant.components.surepetcare -surepy==0.7.0 +surepy==0.7.1 # homeassistant.components.swiss_hydrological_data swisshydrodata==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 54ea4ab4448..e3c610c16d5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1264,7 +1264,7 @@ subarulink==0.3.12 sunwatcher==0.2.1 # homeassistant.components.surepetcare -surepy==0.7.0 +surepy==0.7.1 # homeassistant.components.system_bridge systembridge==2.1.0 From ec28f7eef299dc3af786c2bae1879c515b7c434e Mon Sep 17 00:00:00 2001 From: cdheiser <10488026+cdheiser@users.noreply.github.com> Date: Sun, 12 Sep 2021 00:24:02 -0700 Subject: [PATCH 359/843] Don't return a unique_id if Lutron doesn't have a UUID for the device. (#56113) This is a workaround for https://github.com/thecynic/pylutron/issues/70 Co-authored-by: cdheiser --- homeassistant/components/lutron/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/lutron/__init__.py b/homeassistant/components/lutron/__init__.py index de8ff228bc4..c0f378d19d7 100644 --- a/homeassistant/components/lutron/__init__.py +++ b/homeassistant/components/lutron/__init__.py @@ -131,6 +131,9 @@ class LutronDevice(Entity): @property def unique_id(self): """Return a unique ID.""" + # Temporary fix for https://github.com/thecynic/pylutron/issues/70 + if self._lutron_device.uuid is None: + return None return f"{self._controller.guid}_{self._lutron_device.uuid}" From 2b019b09112cef65511b3aaa71d4fc24f18a18b8 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 12 Sep 2021 12:48:02 +0200 Subject: [PATCH 360/843] Use EntityDescription - xiaomi_aqara (#55931) * Use EntityDescription - xiaomi_aqara * Remove default values * Add missing SENSOR_TYPES --- .../components/xiaomi_aqara/sensor.py | 115 +++++++++--------- 1 file changed, 59 insertions(+), 56 deletions(-) diff --git a/homeassistant/components/xiaomi_aqara/sensor.py b/homeassistant/components/xiaomi_aqara/sensor.py index cad3afb11ba..df86ef0fe77 100644 --- a/homeassistant/components/xiaomi_aqara/sensor.py +++ b/homeassistant/components/xiaomi_aqara/sensor.py @@ -1,7 +1,10 @@ """Support for Xiaomi Aqara sensors.""" +from __future__ import annotations + +from functools import cached_property import logging -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import ( ATTR_BATTERY_LEVEL, DEVICE_CLASS_BATTERY, @@ -22,14 +25,51 @@ from .const import BATTERY_MODELS, DOMAIN, GATEWAYS_KEY, POWER_MODELS _LOGGER = logging.getLogger(__name__) -SENSOR_TYPES = { - "temperature": [TEMP_CELSIUS, None, DEVICE_CLASS_TEMPERATURE], - "humidity": [PERCENTAGE, None, DEVICE_CLASS_HUMIDITY], - "illumination": ["lm", None, DEVICE_CLASS_ILLUMINANCE], - "lux": [LIGHT_LUX, None, DEVICE_CLASS_ILLUMINANCE], - "pressure": [PRESSURE_HPA, None, DEVICE_CLASS_PRESSURE], - "bed_activity": ["μm", None, None], - "load_power": [POWER_WATT, None, DEVICE_CLASS_POWER], +SENSOR_TYPES: dict[str, SensorEntityDescription] = { + "temperature": SensorEntityDescription( + key="temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + "humidity": SensorEntityDescription( + key="humidity", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + "illumination": SensorEntityDescription( + key="illumination", + native_unit_of_measurement="lm", + device_class=DEVICE_CLASS_ILLUMINANCE, + ), + "lux": SensorEntityDescription( + key="lux", + native_unit_of_measurement=LIGHT_LUX, + device_class=DEVICE_CLASS_ILLUMINANCE, + ), + "pressure": SensorEntityDescription( + key="pressure", + native_unit_of_measurement=PRESSURE_HPA, + device_class=DEVICE_CLASS_PRESSURE, + ), + "bed_activity": SensorEntityDescription( + key="bed_activity", + native_unit_of_measurement="μm", + device_class=None, + ), + "load_power": SensorEntityDescription( + key="load_power", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + ), + "final_tilt_angle": SensorEntityDescription( + key="final_tilt_angle", + ), + "coordination": SensorEntityDescription( + key="coordination", + ), + "Battery": SensorEntityDescription( + key="Battery", + ), } @@ -116,35 +156,10 @@ class XiaomiSensor(XiaomiDevice, SensorEntity): self._data_key = data_key super().__init__(device, name, xiaomi_hub, config_entry) - @property - def icon(self): - """Return the icon to use in the frontend.""" - try: - return SENSOR_TYPES.get(self._data_key)[1] - except TypeError: - return None - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - try: - return SENSOR_TYPES.get(self._data_key)[0] - except TypeError: - return None - - @property - def device_class(self): - """Return the device class of this entity.""" - return ( - SENSOR_TYPES.get(self._data_key)[2] - if self._data_key in SENSOR_TYPES - else None - ) - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state + @cached_property + def entity_description(self) -> SensorEntityDescription: # type: ignore[override] + """Return entity_description for data_key.""" + return SENSOR_TYPES[self._data_key] def parse_data(self, data, raw_data): """Parse data sent by gateway.""" @@ -152,7 +167,7 @@ class XiaomiSensor(XiaomiDevice, SensorEntity): if value is None: return False if self._data_key in ("coordination", "status"): - self._state = value + self._attr_native_value = value return True value = float(value) if self._data_key in ("temperature", "humidity", "pressure"): @@ -166,29 +181,17 @@ class XiaomiSensor(XiaomiDevice, SensorEntity): if self._data_key == "pressure" and value == 0: return False if self._data_key in ("illumination", "lux"): - self._state = round(value) + self._attr_native_value = round(value) else: - self._state = round(value, 1) + self._attr_native_value = round(value, 1) return True class XiaomiBatterySensor(XiaomiDevice, SensorEntity): """Representation of a XiaomiSensor.""" - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return PERCENTAGE - - @property - def device_class(self): - """Return the device class of this entity.""" - return DEVICE_CLASS_BATTERY - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state + _attr_native_unit_of_measurement = PERCENTAGE + _attr_device_class = DEVICE_CLASS_BATTERY def parse_data(self, data, raw_data): """Parse data sent by gateway.""" @@ -198,7 +201,7 @@ class XiaomiBatterySensor(XiaomiDevice, SensorEntity): battery_level = int(self._extra_state_attributes.pop(ATTR_BATTERY_LEVEL)) if battery_level <= 0 or battery_level > 100: return False - self._state = battery_level + self._attr_native_value = battery_level return True def parse_voltage(self, data): From f1556ead6d16710a020dd18fd842f5dbf92d5177 Mon Sep 17 00:00:00 2001 From: Greg Thornton Date: Sun, 12 Sep 2021 10:09:09 -0500 Subject: [PATCH 361/843] Don't cache HomeKit camera stream source from entity (#56136) --- .../components/homekit/type_cameras.py | 2 -- tests/components/homekit/test_type_cameras.py | 36 +++++++++++++++++-- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index 4a8999ede08..e0c9ad7de84 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -321,8 +321,6 @@ class Camera(HomeAccessory, PyhapCamera): _LOGGER.exception( "Failed to get stream source - this could be a transient error or your camera might not be compatible with HomeKit yet" ) - if stream_source: - self.config[CONF_STREAM_SOURCE] = stream_source return stream_source async def start_stream(self, session_info, stream_config): diff --git a/tests/components/homekit/test_type_cameras.py b/tests/components/homekit/test_type_cameras.py index 991965b30b5..b128cd44d07 100644 --- a/tests/components/homekit/test_type_cameras.py +++ b/tests/components/homekit/test_type_cameras.py @@ -317,29 +317,59 @@ async def test_camera_stream_source_found(hass, run_driver, events): assert acc.category == 17 # Camera await _async_setup_endpoints(hass, acc) + working_ffmpeg = _get_working_mock_ffmpeg() + session_info = acc.sessions[MOCK_START_STREAM_SESSION_UUID] with patch( "homeassistant.components.demo.camera.DemoCamera.stream_source", return_value="rtsp://example.local", ), patch( "homeassistant.components.homekit.type_cameras.HAFFmpeg", - return_value=_get_working_mock_ffmpeg(), + return_value=working_ffmpeg, ): await _async_start_streaming(hass, acc) await _async_stop_all_streams(hass, acc) + expected_output = ( + "-map 0:v:0 -an -c:v libx264 -profile:v high -tune zerolatency -pix_fmt " + "yuv420p -r 30 -b:v 299k -bufsize 1196k -maxrate 299k -payload_type 99 -ssrc {v_ssrc} -f " + "rtp -srtp_out_suite AES_CM_128_HMAC_SHA1_80 -srtp_out_params " + "zdPmNLWeI86DtLJHvVLI6YPvqhVeeiLsNtrAgbgL " + "srtp://192.168.208.5:51246?rtcpport=51246&localrtcpport=51246&pkt_size=1316" + ) + + working_ffmpeg.open.assert_called_with( + cmd=[], + input_source="-i rtsp://example.local", + output=expected_output.format(**session_info), + stdout_pipe=False, + extra_cmd="-hide_banner -nostats", + stderr_pipe=True, + ) + await _async_setup_endpoints(hass, acc) + working_ffmpeg = _get_working_mock_ffmpeg() + session_info = acc.sessions[MOCK_START_STREAM_SESSION_UUID] with patch( "homeassistant.components.demo.camera.DemoCamera.stream_source", - return_value="rtsp://example.local", + return_value="rtsp://example2.local", ), patch( "homeassistant.components.homekit.type_cameras.HAFFmpeg", - return_value=_get_working_mock_ffmpeg(), + return_value=working_ffmpeg, ): await _async_start_streaming(hass, acc) await _async_stop_all_streams(hass, acc) + working_ffmpeg.open.assert_called_with( + cmd=[], + input_source="-i rtsp://example2.local", + output=expected_output.format(**session_info), + stdout_pipe=False, + extra_cmd="-hide_banner -nostats", + stderr_pipe=True, + ) + async def test_camera_stream_source_fails(hass, run_driver, events): """Test a camera that can stream and we cannot get the source from the entity.""" From 4e8db7173a1d2a0f0294271a1c9eed32263be757 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Sun, 12 Sep 2021 09:34:44 -0600 Subject: [PATCH 362/843] Use EntityDescription - iqvia (#55218) --- homeassistant/components/iqvia/__init__.py | 17 +-- homeassistant/components/iqvia/const.py | 11 -- homeassistant/components/iqvia/sensor.py | 123 ++++++++++++++++----- 3 files changed, 101 insertions(+), 50 deletions(-) diff --git a/homeassistant/components/iqvia/__init__.py b/homeassistant/components/iqvia/__init__.py index affe8622641..5a7fa682bc1 100644 --- a/homeassistant/components/iqvia/__init__.py +++ b/homeassistant/components/iqvia/__init__.py @@ -10,12 +10,12 @@ from typing import Any, Callable, Dict, cast from pyiqvia import Client from pyiqvia.errors import IQVIAError -from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -107,27 +107,22 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -class IQVIAEntity(CoordinatorEntity, SensorEntity): +class IQVIAEntity(CoordinatorEntity): """Define a base IQVIA entity.""" def __init__( self, coordinator: DataUpdateCoordinator, entry: ConfigEntry, - sensor_type: str, - name: str, - icon: str, + description: EntityDescription, ) -> None: """Initialize.""" super().__init__(coordinator) self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} - self._attr_icon = icon - self._attr_name = name - self._attr_unique_id = f"{entry.data[CONF_ZIP_CODE]}_{sensor_type}" - self._attr_native_unit_of_measurement = "index" + self._attr_unique_id = f"{entry.data[CONF_ZIP_CODE]}_{description.key}" self._entry = entry - self._type = sensor_type + self.entity_description = description @callback def _handle_coordinator_update(self) -> None: @@ -142,7 +137,7 @@ class IQVIAEntity(CoordinatorEntity, SensorEntity): """Register callbacks.""" await super().async_added_to_hass() - if self._type == TYPE_ALLERGY_FORECAST: + if self.entity_description.key == TYPE_ALLERGY_FORECAST: self.async_on_remove( self.hass.data[DOMAIN][DATA_COORDINATOR][self._entry.entry_id][ TYPE_ALLERGY_OUTLOOK diff --git a/homeassistant/components/iqvia/const.py b/homeassistant/components/iqvia/const.py index 10b2ae30220..cbcda26982e 100644 --- a/homeassistant/components/iqvia/const.py +++ b/homeassistant/components/iqvia/const.py @@ -21,14 +21,3 @@ TYPE_ASTHMA_TOMORROW = "asthma_index_tomorrow" TYPE_DISEASE_FORECAST = "disease_average_forecasted" TYPE_DISEASE_INDEX = "disease_index" TYPE_DISEASE_TODAY = "disease_index_today" - -SENSORS = { - TYPE_ALLERGY_FORECAST: ("Allergy Index: Forecasted Average", "mdi:flower"), - TYPE_ALLERGY_TODAY: ("Allergy Index: Today", "mdi:flower"), - TYPE_ALLERGY_TOMORROW: ("Allergy Index: Tomorrow", "mdi:flower"), - TYPE_ASTHMA_FORECAST: ("Asthma Index: Forecasted Average", "mdi:flower"), - TYPE_ASTHMA_TODAY: ("Asthma Index: Today", "mdi:flower"), - TYPE_ASTHMA_TOMORROW: ("Asthma Index: Tomorrow", "mdi:flower"), - TYPE_DISEASE_FORECAST: ("Cold & Flu: Forecasted Average", "mdi:snowflake"), - TYPE_DISEASE_TODAY: ("Cold & Flu Index: Today", "mdi:pill"), -} diff --git a/homeassistant/components/iqvia/sensor.py b/homeassistant/components/iqvia/sensor.py index 068ba522e52..adf53a9cc05 100644 --- a/homeassistant/components/iqvia/sensor.py +++ b/homeassistant/components/iqvia/sensor.py @@ -5,6 +5,11 @@ from statistics import mean import numpy as np +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_STATE from homeassistant.core import HomeAssistant, callback @@ -14,7 +19,6 @@ from . import IQVIAEntity from .const import ( DATA_COORDINATOR, DOMAIN, - SENSORS, TYPE_ALLERGY_FORECAST, TYPE_ALLERGY_INDEX, TYPE_ALLERGY_OUTLOOK, @@ -57,32 +61,89 @@ RATING_MAPPING = [ {"label": "High", "minimum": 9.7, "maximum": 12}, ] + TREND_FLAT = "Flat" TREND_INCREASING = "Increasing" TREND_SUBSIDING = "Subsiding" +FORECAST_SENSOR_DESCRIPTIONS = ( + SensorEntityDescription( + key=TYPE_ALLERGY_FORECAST, + name="Allergy Index: Forecasted Average", + icon="mdi:flower", + ), + SensorEntityDescription( + key=TYPE_ASTHMA_FORECAST, + name="Asthma Index: Forecasted Average", + icon="mdi:flower", + ), + SensorEntityDescription( + key=TYPE_DISEASE_FORECAST, + name="Cold & Flu: Forecasted Average", + icon="mdi:snowflake", + ), +) + +INDEX_SENSOR_DESCRIPTIONS = ( + SensorEntityDescription( + key=TYPE_ALLERGY_TODAY, + name="Allergy Index: Today", + icon="mdi:flower", + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_ALLERGY_TOMORROW, + name="Allergy Index: Tomorrow", + icon="mdi:flower", + ), + SensorEntityDescription( + key=TYPE_ASTHMA_TODAY, + name="Asthma Index: Today", + icon="mdi:flower", + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_ASTHMA_TOMORROW, + name="Asthma Index: Tomorrow", + icon="mdi:flower", + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_DISEASE_TODAY, + name="Cold & Flu Index: Today", + icon="mdi:pill", + state_class=STATE_CLASS_MEASUREMENT, + ), +) + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up IQVIA sensors based on a config entry.""" - sensor_class_mapping = { - TYPE_ALLERGY_FORECAST: ForecastSensor, - TYPE_ALLERGY_TODAY: IndexSensor, - TYPE_ALLERGY_TOMORROW: IndexSensor, - TYPE_ASTHMA_FORECAST: ForecastSensor, - TYPE_ASTHMA_TODAY: IndexSensor, - TYPE_ASTHMA_TOMORROW: IndexSensor, - TYPE_DISEASE_FORECAST: ForecastSensor, - TYPE_DISEASE_TODAY: IndexSensor, - } - - sensors = [] - for sensor_type, (name, icon) in SENSORS.items(): - api_category = API_CATEGORY_MAPPING.get(sensor_type, sensor_type) - coordinator = hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id][api_category] - sensor_class = sensor_class_mapping[sensor_type] - sensors.append(sensor_class(coordinator, entry, sensor_type, name, icon)) + sensors: list[ForecastSensor | IndexSensor] = [ + ForecastSensor( + hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id][ + API_CATEGORY_MAPPING.get(description.key, description.key) + ], + entry, + description, + ) + for description in FORECAST_SENSOR_DESCRIPTIONS + ] + sensors.extend( + [ + IndexSensor( + hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id][ + API_CATEGORY_MAPPING.get(description.key, description.key) + ], + entry, + description, + ) + for description in INDEX_SENSOR_DESCRIPTIONS + ] + ) async_add_entities(sensors) @@ -104,7 +165,7 @@ def calculate_trend(indices: list[float]) -> str: return TREND_FLAT -class ForecastSensor(IQVIAEntity): +class ForecastSensor(IQVIAEntity, SensorEntity): """Define sensor related to forecast data.""" @callback @@ -137,7 +198,7 @@ class ForecastSensor(IQVIAEntity): } ) - if self._type == TYPE_ALLERGY_FORECAST: + if self.entity_description.key == TYPE_ALLERGY_FORECAST: outlook_coordinator = self.hass.data[DOMAIN][DATA_COORDINATOR][ self._entry.entry_id ][TYPE_ALLERGY_OUTLOOK] @@ -153,7 +214,7 @@ class ForecastSensor(IQVIAEntity): ] = outlook_coordinator.data.get("Season") -class IndexSensor(IQVIAEntity): +class IndexSensor(IQVIAEntity, SensorEntity): """Define sensor related to indices.""" @callback @@ -163,16 +224,22 @@ class IndexSensor(IQVIAEntity): return try: - if self._type in (TYPE_ALLERGY_TODAY, TYPE_ALLERGY_TOMORROW): + if self.entity_description.key in ( + TYPE_ALLERGY_TODAY, + TYPE_ALLERGY_TOMORROW, + ): data = self.coordinator.data.get("Location") - elif self._type in (TYPE_ASTHMA_TODAY, TYPE_ASTHMA_TOMORROW): + elif self.entity_description.key in ( + TYPE_ASTHMA_TODAY, + TYPE_ASTHMA_TOMORROW, + ): data = self.coordinator.data.get("Location") - elif self._type == TYPE_DISEASE_TODAY: + elif self.entity_description.key == TYPE_DISEASE_TODAY: data = self.coordinator.data.get("Location") except KeyError: return - key = self._type.split("_")[-1].title() + key = self.entity_description.key.split("_")[-1].title() try: [period] = [p for p in data["periods"] if p["Type"] == key] @@ -194,7 +261,7 @@ class IndexSensor(IQVIAEntity): } ) - if self._type in (TYPE_ALLERGY_TODAY, TYPE_ALLERGY_TOMORROW): + if self.entity_description.key in (TYPE_ALLERGY_TODAY, TYPE_ALLERGY_TOMORROW): for idx, attrs in enumerate(period["Triggers"]): index = idx + 1 self._attr_extra_state_attributes.update( @@ -204,7 +271,7 @@ class IndexSensor(IQVIAEntity): f"{ATTR_ALLERGEN_TYPE}_{index}": attrs["PlantType"], } ) - elif self._type in (TYPE_ASTHMA_TODAY, TYPE_ASTHMA_TOMORROW): + elif self.entity_description.key in (TYPE_ASTHMA_TODAY, TYPE_ASTHMA_TOMORROW): for idx, attrs in enumerate(period["Triggers"]): index = idx + 1 self._attr_extra_state_attributes.update( @@ -213,7 +280,7 @@ class IndexSensor(IQVIAEntity): f"{ATTR_ALLERGEN_AMOUNT}_{index}": attrs["PPM"], } ) - elif self._type == TYPE_DISEASE_TODAY: + elif self.entity_description.key == TYPE_DISEASE_TODAY: for attrs in period["Triggers"]: self._attr_extra_state_attributes[ f"{attrs['Name'].lower()}_index" From 0fc89780e92eb551fc74d99cd2bad39564607adb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Sep 2021 12:06:03 -1000 Subject: [PATCH 363/843] Fix listener leak in HomeKit on reload (#56143) * Fix listener leak in HomeKit on reload * Fix mocking --- homeassistant/components/homekit/__init__.py | 13 +++---- .../components/homekit/accessories.py | 3 +- .../components/homekit/type_cameras.py | 26 +++++++++----- .../components/homekit/type_covers.py | 10 +++--- .../components/homekit/type_humidifiers.py | 10 +++--- tests/components/homekit/test_accessories.py | 2 +- tests/components/homekit/test_homekit.py | 36 +++++++++++++++++-- tests/components/homekit/test_type_cameras.py | 20 +++++++++-- 8 files changed, 87 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 7d2e60a53b9..0ce634be94e 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -554,7 +554,7 @@ class HomeKit: acc = self.driver.accessory if acc.entity_id not in entity_ids: return - acc.async_stop() + await acc.stop() if not (state := self.hass.states.get(acc.entity_id)): _LOGGER.warning( "The underlying entity %s disappeared during reset", acc.entity @@ -577,7 +577,7 @@ class HomeKit: self._name, entity_id, ) - acc = self.remove_bridge_accessory(aid) + acc = await self.async_remove_bridge_accessory(aid) if state := self.hass.states.get(acc.entity_id): new.append(state) else: @@ -670,11 +670,11 @@ class HomeKit: ) ) - def remove_bridge_accessory(self, aid): + async def async_remove_bridge_accessory(self, aid): """Try adding accessory to bridge if configured beforehand.""" acc = self.bridge.accessories.pop(aid, None) if acc: - acc.async_stop() + await acc.stop() return acc async def async_configure_accessories(self): @@ -866,11 +866,6 @@ class HomeKit: self.status = STATUS_STOPPED _LOGGER.debug("Driver stop for %s", self._name) await self.driver.async_stop() - if self.bridge: - for acc in self.bridge.accessories.values(): - acc.async_stop() - else: - self.driver.accessory.async_stop() @callback def _async_configure_linked_sensors(self, ent_reg_ent, device_lookup, state): diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index 8aa7878bfba..3b7f2c0f9eb 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -499,8 +499,7 @@ class HomeAccessory(Accessory): ) ) - @ha_callback - def async_stop(self): + async def stop(self): """Cancel any subscriptions when the bridge is stopped.""" while self._subscriptions: self._subscriptions.pop(0)() diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index e0c9ad7de84..2cdcd600932 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -244,17 +244,21 @@ class Camera(HomeAccessory, PyhapCamera): Run inside the Home Assistant event loop. """ if self._char_motion_detected: - async_track_state_change_event( - self.hass, - [self.linked_motion_sensor], - self._async_update_motion_state_event, + self._subscriptions.append( + async_track_state_change_event( + self.hass, + [self.linked_motion_sensor], + self._async_update_motion_state_event, + ) ) if self._char_doorbell_detected: - async_track_state_change_event( - self.hass, - [self.linked_doorbell_sensor], - self._async_update_doorbell_state_event, + self._subscriptions.append( + async_track_state_change_event( + self.hass, + [self.linked_doorbell_sensor], + self._async_update_doorbell_state_event, + ) ) await super().run() @@ -434,6 +438,12 @@ class Camera(HomeAccessory, PyhapCamera): self.sessions[session_id].pop(FFMPEG_WATCHER)() self.sessions[session_id].pop(FFMPEG_LOGGER).cancel() + async def stop(self): + """Stop any streams when the accessory is stopped.""" + for session_info in self.sessions.values(): + asyncio.create_task(self.stop_stream(session_info)) + await super().stop() + async def stop_stream(self, session_info): """Stop the stream for the given ``session_id``.""" session_id = session_info["id"] diff --git a/homeassistant/components/homekit/type_covers.py b/homeassistant/components/homekit/type_covers.py index 4c501208ca5..0c889d9aee4 100644 --- a/homeassistant/components/homekit/type_covers.py +++ b/homeassistant/components/homekit/type_covers.py @@ -122,10 +122,12 @@ class GarageDoorOpener(HomeAccessory): Run inside the Home Assistant event loop. """ if self.linked_obstruction_sensor: - async_track_state_change_event( - self.hass, - [self.linked_obstruction_sensor], - self._async_update_obstruction_event, + self._subscriptions.append( + async_track_state_change_event( + self.hass, + [self.linked_obstruction_sensor], + self._async_update_obstruction_event, + ) ) await super().run() diff --git a/homeassistant/components/homekit/type_humidifiers.py b/homeassistant/components/homekit/type_humidifiers.py index 6371f883b09..2f4866e395a 100644 --- a/homeassistant/components/homekit/type_humidifiers.py +++ b/homeassistant/components/homekit/type_humidifiers.py @@ -149,10 +149,12 @@ class HumidifierDehumidifier(HomeAccessory): Run inside the Home Assistant event loop. """ if self.linked_humidity_sensor: - async_track_state_change_event( - self.hass, - [self.linked_humidity_sensor], - self.async_update_current_humidity_event, + self._subscriptions.append( + async_track_state_change_event( + self.hass, + [self.linked_humidity_sensor], + self.async_update_current_humidity_event, + ) ) await super().run() diff --git a/tests/components/homekit/test_accessories.py b/tests/components/homekit/test_accessories.py index 3f08ca6fda2..60c7e5ac8e2 100644 --- a/tests/components/homekit/test_accessories.py +++ b/tests/components/homekit/test_accessories.py @@ -60,7 +60,7 @@ async def test_accessory_cancels_track_state_change_on_stop(hass, hk_driver): ): await acc.run() assert len(hass.data[TRACK_STATE_CHANGE_CALLBACKS][entity_id]) == 1 - acc.async_stop() + await acc.stop() assert entity_id not in hass.data[TRACK_STATE_CHANGE_CALLBACKS] diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index 9b4ae477704..039bd1c11c3 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -442,11 +442,12 @@ async def test_homekit_remove_accessory(hass, mock_zeroconf): homekit.driver = "driver" homekit.bridge = _mock_pyhap_bridge() acc_mock = MagicMock() + acc_mock.stop = AsyncMock() homekit.bridge.accessories = {6: acc_mock} - acc = homekit.remove_bridge_accessory(6) + acc = await homekit.async_remove_bridge_accessory(6) assert acc is acc_mock - assert acc_mock.async_stop.called + assert acc_mock.stop.called assert len(homekit.bridge.accessories) == 0 @@ -695,9 +696,11 @@ async def test_homekit_reset_accessories(hass, mock_zeroconf): acc_mock = MagicMock() acc_mock.entity_id = entity_id + acc_mock.stop = AsyncMock() aid = homekit.aid_storage.get_or_allocate_aid_for_entity_id(entity_id) homekit.bridge.accessories = {aid: acc_mock} homekit.status = STATUS_RUNNING + homekit.driver.aio_stop_event = MagicMock() await hass.services.async_call( DOMAIN, @@ -730,9 +733,12 @@ async def test_homekit_unpair(hass, device_reg, mock_zeroconf): acc_mock = MagicMock() acc_mock.entity_id = entity_id + acc_mock.stop = AsyncMock() + aid = homekit.aid_storage.get_or_allocate_aid_for_entity_id(entity_id) homekit.bridge.accessories = {aid: acc_mock} homekit.status = STATUS_RUNNING + homekit.driver.aio_stop_event = MagicMock() state = homekit.driver.state state.add_paired_client("client1", "any", b"1") @@ -769,9 +775,12 @@ async def test_homekit_unpair_missing_device_id(hass, device_reg, mock_zeroconf) acc_mock = MagicMock() acc_mock.entity_id = entity_id + acc_mock.stop = AsyncMock() + aid = homekit.aid_storage.get_or_allocate_aid_for_entity_id(entity_id) homekit.bridge.accessories = {aid: acc_mock} homekit.status = STATUS_RUNNING + homekit.driver.aio_stop_event = MagicMock() state = homekit.driver.state state.add_paired_client("client1", "any", b"1") @@ -807,6 +816,8 @@ async def test_homekit_unpair_not_homekit_device(hass, device_reg, mock_zeroconf acc_mock = MagicMock() acc_mock.entity_id = entity_id + acc_mock.stop = AsyncMock() + aid = homekit.aid_storage.get_or_allocate_aid_for_entity_id(entity_id) homekit.bridge.accessories = {aid: acc_mock} homekit.status = STATUS_RUNNING @@ -856,9 +867,12 @@ async def test_homekit_reset_accessories_not_supported(hass, mock_zeroconf): acc_mock = MagicMock() acc_mock.entity_id = entity_id + acc_mock.stop = AsyncMock() + aid = homekit.aid_storage.get_or_allocate_aid_for_entity_id(entity_id) homekit.bridge.accessories = {aid: acc_mock} homekit.status = STATUS_RUNNING + homekit.driver.aio_stop_event = MagicMock() await hass.services.async_call( DOMAIN, @@ -896,9 +910,12 @@ async def test_homekit_reset_accessories_state_missing(hass, mock_zeroconf): acc_mock = MagicMock() acc_mock.entity_id = entity_id + acc_mock.stop = AsyncMock() + aid = homekit.aid_storage.get_or_allocate_aid_for_entity_id(entity_id) homekit.bridge.accessories = {aid: acc_mock} homekit.status = STATUS_RUNNING + homekit.driver.aio_stop_event = MagicMock() await hass.services.async_call( DOMAIN, @@ -935,9 +952,12 @@ async def test_homekit_reset_accessories_not_bridged(hass, mock_zeroconf): acc_mock = MagicMock() acc_mock.entity_id = entity_id + acc_mock.stop = AsyncMock() + aid = homekit.aid_storage.get_or_allocate_aid_for_entity_id(entity_id) homekit.bridge.accessories = {aid: acc_mock} homekit.status = STATUS_RUNNING + homekit.driver.aio_stop_event = MagicMock() await hass.services.async_call( DOMAIN, @@ -974,7 +994,10 @@ async def test_homekit_reset_single_accessory(hass, mock_zeroconf): homekit.status = STATUS_RUNNING acc_mock = MagicMock() acc_mock.entity_id = entity_id + acc_mock.stop = AsyncMock() + homekit.driver.accessory = acc_mock + homekit.driver.aio_stop_event = MagicMock() await hass.services.async_call( DOMAIN, @@ -1008,7 +1031,10 @@ async def test_homekit_reset_single_accessory_unsupported(hass, mock_zeroconf): homekit.status = STATUS_RUNNING acc_mock = MagicMock() acc_mock.entity_id = entity_id + acc_mock.stop = AsyncMock() + homekit.driver.accessory = acc_mock + homekit.driver.aio_stop_event = MagicMock() await hass.services.async_call( DOMAIN, @@ -1041,7 +1067,10 @@ async def test_homekit_reset_single_accessory_state_missing(hass, mock_zeroconf) homekit.status = STATUS_RUNNING acc_mock = MagicMock() acc_mock.entity_id = entity_id + acc_mock.stop = AsyncMock() + homekit.driver.accessory = acc_mock + homekit.driver.aio_stop_event = MagicMock() await hass.services.async_call( DOMAIN, @@ -1074,7 +1103,10 @@ async def test_homekit_reset_single_accessory_no_match(hass, mock_zeroconf): homekit.status = STATUS_RUNNING acc_mock = MagicMock() acc_mock.entity_id = entity_id + acc_mock.stop = AsyncMock() + homekit.driver.accessory = acc_mock + homekit.driver.aio_stop_event = MagicMock() await hass.services.async_call( DOMAIN, diff --git a/tests/components/homekit/test_type_cameras.py b/tests/components/homekit/test_type_cameras.py index b128cd44d07..05c809910cf 100644 --- a/tests/components/homekit/test_type_cameras.py +++ b/tests/components/homekit/test_type_cameras.py @@ -1,5 +1,6 @@ """Test different accessory types: Camera.""" +import asyncio from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch from uuid import UUID @@ -45,6 +46,7 @@ PID_THAT_WILL_NEVER_BE_ALIVE = 2147483647 async def _async_start_streaming(hass, acc): """Start streaming a camera.""" acc.set_selected_stream_configuration(MOCK_START_STREAM_TLV) + await hass.async_block_till_done() await acc.run() await hass.async_block_till_done() @@ -92,6 +94,18 @@ def run_driver(hass): ) +def _mock_reader(): + """Mock ffmpeg reader.""" + + async def _readline(*args, **kwargs): + await asyncio.sleep(0.1) + + async def _get_reader(*args, **kwargs): + return AsyncMock(readline=_readline) + + return _get_reader + + def _get_exits_after_startup_mock_ffmpeg(): """Return a ffmpeg that will have an invalid pid.""" ffmpeg = MagicMock() @@ -99,7 +113,7 @@ def _get_exits_after_startup_mock_ffmpeg(): ffmpeg.open = AsyncMock(return_value=True) ffmpeg.close = AsyncMock(return_value=True) ffmpeg.kill = AsyncMock(return_value=True) - ffmpeg.get_reader = AsyncMock() + ffmpeg.get_reader = _mock_reader() return ffmpeg @@ -109,7 +123,7 @@ def _get_working_mock_ffmpeg(): ffmpeg.open = AsyncMock(return_value=True) ffmpeg.close = AsyncMock(return_value=True) ffmpeg.kill = AsyncMock(return_value=True) - ffmpeg.get_reader = AsyncMock() + ffmpeg.get_reader = _mock_reader() return ffmpeg @@ -120,7 +134,7 @@ def _get_failing_mock_ffmpeg(): ffmpeg.open = AsyncMock(return_value=False) ffmpeg.close = AsyncMock(side_effect=OSError) ffmpeg.kill = AsyncMock(side_effect=OSError) - ffmpeg.get_reader = AsyncMock() + ffmpeg.get_reader = _mock_reader() return ffmpeg From 673519f6bff1c5638aa81f0861124e4537993e25 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Sep 2021 12:07:40 -1000 Subject: [PATCH 364/843] Prefer more targeted matchers in USB discovery (#56142) - If there is a more targeted match it should win discovery --- homeassistant/components/usb/__init__.py | 15 +++++++++ tests/components/usb/test_init.py | 39 ++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index 13f18216cca..095d72f3ed4 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -161,6 +161,7 @@ class USBDiscovery: if device_tuple in self.seen: return self.seen.add(device_tuple) + matched = [] for matcher in self.usb: if "vid" in matcher and device.vid != matcher["vid"]: continue @@ -178,6 +179,20 @@ class USBDiscovery: device.description, matcher["description"] ): continue + matched.append(matcher) + + if not matched: + return + + sorted_by_most_targeted = sorted(matched, key=lambda item: -len(item)) + most_matched_fields = len(sorted_by_most_targeted[0]) + + for matcher in sorted_by_most_targeted: + # If there is a less targeted match, we only + # want the most targeted match + if len(matcher) < most_matched_fields: + break + flow: USBFlow = { "domain": matcher["domain"], "context": {"source": config_entries.SOURCE_USB}, diff --git a/tests/components/usb/test_init.py b/tests/components/usb/test_init.py index b09dad9ebe4..7d620b45984 100644 --- a/tests/components/usb/test_init.py +++ b/tests/components/usb/test_init.py @@ -176,6 +176,45 @@ async def test_discovered_by_websocket_scan_limited_by_description_matcher( assert mock_config_flow.mock_calls[0][1][0] == "test1" +async def test_most_targeted_matcher_wins(hass, hass_ws_client): + """Test that the most targeted matcher is used.""" + new_usb = [ + {"domain": "less", "vid": "3039", "pid": "3039"}, + {"domain": "more", "vid": "3039", "pid": "3039", "description": "*2652*"}, + ] + + mock_comports = [ + MagicMock( + device=slae_sh_device.device, + vid=12345, + pid=12345, + serial_number=slae_sh_device.serial_number, + manufacturer=slae_sh_device.manufacturer, + description=slae_sh_device.description, + ) + ] + + with patch("pyudev.Context", side_effect=ImportError), patch( + "homeassistant.components.usb.async_get_usb", return_value=new_usb + ), patch( + "homeassistant.components.usb.comports", return_value=mock_comports + ), patch.object( + hass.config_entries.flow, "async_init" + ) as mock_config_flow: + assert await async_setup_component(hass, "usb", {"usb": {}}) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + ws_client = await hass_ws_client(hass) + await ws_client.send_json({"id": 1, "type": "usb/scan"}) + response = await ws_client.receive_json() + assert response["success"] + await hass.async_block_till_done() + + assert len(mock_config_flow.mock_calls) == 1 + assert mock_config_flow.mock_calls[0][1][0] == "more" + + async def test_discovered_by_websocket_scan_rejected_by_description_matcher( hass, hass_ws_client ): From 990d474d02cb6dad0d52d690398ce318c3abe9b2 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Mon, 13 Sep 2021 00:08:46 +0200 Subject: [PATCH 365/843] use fixtures. (#56130) --- tests/components/template/test_cover.py | 1427 +++++++++-------------- 1 file changed, 579 insertions(+), 848 deletions(-) diff --git a/tests/components/template/test_cover.py b/tests/components/template/test_cover.py index e2b65abcf25..0f629fdd239 100644 --- a/tests/components/template/test_cover.py +++ b/tests/components/template/test_cover.py @@ -23,25 +23,18 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) -from tests.common import assert_setup_component, async_mock_service +from tests.common import assert_setup_component ENTITY_COVER = "cover.test_template_cover" -@pytest.fixture(name="calls") -def calls_fixture(hass): - """Track calls to a mock service.""" - return async_mock_service(hass, "test", "automation") - - -async def test_template_state_text(hass, calls, caplog): - """Test the state text of a template.""" - with assert_setup_component(1, "cover"): - assert await setup.async_setup_component( - hass, - "cover", +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config, states", + [ + ( { - "cover": { + DOMAIN: { "platform": "template", "covers": { "test_template_cover": { @@ -58,74 +51,42 @@ async def test_template_state_text(hass, calls, caplog): }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - hass.states.async_set("cover.test_state", STATE_OPEN) - await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") - assert state.state == STATE_OPEN - - hass.states.async_set("cover.test_state", STATE_CLOSED) - await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") - assert state.state == STATE_CLOSED - - hass.states.async_set("cover.test_state", STATE_OPENING) - await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") - assert state.state == STATE_OPENING - - hass.states.async_set("cover.test_state", STATE_CLOSING) - await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") - assert state.state == STATE_CLOSING - - # Unknown state sets position to None - "closing" takes precedence - state = hass.states.async_set("cover.test_state", "dog") - await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") - assert state.state == STATE_CLOSING - assert "Received invalid cover is_on state: dog" in caplog.text - - # Set state to open - hass.states.async_set("cover.test_state", STATE_OPEN) - await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") - assert state.state == STATE_OPEN - - # Unknown state sets position to None -> Open - state = hass.states.async_set("cover.test_state", "cat") - await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") - assert state.state == STATE_OPEN - assert "Received invalid cover is_on state: cat" in caplog.text - - # Set state to closed - hass.states.async_set("cover.test_state", STATE_CLOSED) - await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") - assert state.state == STATE_CLOSED - - # Unknown state sets position to None -> Open - state = hass.states.async_set("cover.test_state", "bear") - await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") - assert state.state == STATE_OPEN - assert "Received invalid cover is_on state: bear" in caplog.text - - -async def test_template_state_text_combined(hass, calls, caplog): - """Test the state text of a template which combines position and value templates.""" - with assert_setup_component(1, "cover"): - assert await setup.async_setup_component( - hass, - "cover", + [ + ("cover.test_state", STATE_OPEN, STATE_OPEN, {}, -1, ""), + ("cover.test_state", STATE_CLOSED, STATE_CLOSED, {}, -1, ""), + ("cover.test_state", STATE_OPENING, STATE_OPENING, {}, -1, ""), + ("cover.test_state", STATE_CLOSING, STATE_CLOSING, {}, -1, ""), + ( + "cover.test_state", + "dog", + STATE_CLOSING, + {}, + -1, + "Received invalid cover is_on state: dog", + ), + ("cover.test_state", STATE_OPEN, STATE_OPEN, {}, -1, ""), + ( + "cover.test_state", + "cat", + STATE_OPEN, + {}, + -1, + "Received invalid cover is_on state: cat", + ), + ("cover.test_state", STATE_CLOSED, STATE_CLOSED, {}, -1, ""), + ( + "cover.test_state", + "bear", + STATE_OPEN, + {}, + -1, + "Received invalid cover is_on state: bear", + ), + ], + ), + ( { - "cover": { + DOMAIN: { "platform": "template", "covers": { "test_template_cover": { @@ -143,344 +104,256 @@ async def test_template_state_text_combined(hass, calls, caplog): }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - # Test default state + [ + ("cover.test_state", STATE_OPEN, STATE_OPEN, {}, -1, ""), + ("cover.test_state", STATE_CLOSED, STATE_OPEN, {}, -1, ""), + ("cover.test_state", STATE_OPENING, STATE_OPENING, {}, -1, ""), + ("cover.test_state", STATE_CLOSING, STATE_CLOSING, {}, -1, ""), + ("cover.test", STATE_CLOSED, STATE_CLOSING, {"position": 0}, 0, ""), + ("cover.test_state", STATE_OPEN, STATE_CLOSED, {}, -1, ""), + ("cover.test", STATE_CLOSED, STATE_OPEN, {"position": 10}, 10, ""), + ( + "cover.test_state", + "dog", + STATE_OPEN, + {}, + -1, + "Received invalid cover is_on state: dog", + ), + ], + ), + ], +) +async def test_template_state_text(hass, states, start_ha, caplog): + """Test the state text of a template.""" state = hass.states.get("cover.test_template_cover") assert state.state == STATE_OPEN - # Change to "open" should be ignored - state = hass.states.async_set("cover.test_state", STATE_OPEN) - await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") - assert state.state == STATE_OPEN - - # Change to "closed" should be ignored - state = hass.states.async_set("cover.test_state", STATE_CLOSED) - await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") - assert state.state == STATE_OPEN - - # Change to "opening" should be accepted - state = hass.states.async_set("cover.test_state", STATE_OPENING) - await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") - assert state.state == STATE_OPENING - - # Change to "closing" should be accepted - state = hass.states.async_set("cover.test_state", STATE_CLOSING) - await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") - assert state.state == STATE_CLOSING - - # Set position to 0=closed - hass.states.async_set("cover.test", STATE_CLOSED, attributes={"position": 0}) - await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") - assert state.state == STATE_CLOSING - assert state.attributes["current_position"] == 0 - - # Clear "closing" state, STATE_OPEN will be ignored and state derived from position - state = hass.states.async_set("cover.test_state", STATE_OPEN) - await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") - assert state.state == STATE_CLOSED - - # Set position to 10 - hass.states.async_set("cover.test", STATE_CLOSED, attributes={"position": 10}) - await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") - assert state.state == STATE_OPEN - assert state.attributes["current_position"] == 10 - - # Unknown state should be ignored - state = hass.states.async_set("cover.test_state", "dog") - await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") - assert state.state == STATE_OPEN - assert state.attributes["current_position"] == 10 - assert "Received invalid cover is_on state: dog" in caplog.text + for entity, set_state, test_state, attr, pos, text in states: + hass.states.async_set(entity, set_state, attributes=attr) + await hass.async_block_till_done() + state = hass.states.get("cover.test_template_cover") + assert state.state == test_state + if pos >= 0: + assert state.attributes.get("current_position") == pos + assert text in caplog.text -async def test_template_state_boolean(hass, calls): +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + DOMAIN: { + "platform": "template", + "covers": { + "test_template_cover": { + "value_template": "{{ 1 == 1 }}", + "open_cover": { + "service": "cover.open_cover", + "entity_id": "cover.test_state", + }, + "close_cover": { + "service": "cover.close_cover", + "entity_id": "cover.test_state", + }, + } + }, + } + }, + ], +) +async def test_template_state_boolean(hass, start_ha): """Test the value_template attribute.""" - with assert_setup_component(1, "cover"): - assert await setup.async_setup_component( - hass, - "cover", - { - "cover": { - "platform": "template", - "covers": { - "test_template_cover": { - "value_template": "{{ 1 == 1 }}", - "open_cover": { - "service": "cover.open_cover", - "entity_id": "cover.test_state", - }, - "close_cover": { - "service": "cover.close_cover", - "entity_id": "cover.test_state", - }, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") assert state.state == STATE_OPEN -async def test_template_position(hass, calls): +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + DOMAIN: { + "platform": "template", + "covers": { + "test_template_cover": { + "position_template": "{{ states.cover.test.attributes.position }}", + "open_cover": { + "service": "cover.open_cover", + "entity_id": "cover.test", + }, + "close_cover": { + "service": "cover.close_cover", + "entity_id": "cover.test", + }, + } + }, + } + }, + ], +) +async def test_template_position(hass, start_ha): """Test the position_template attribute.""" hass.states.async_set("cover.test", STATE_OPEN) - with assert_setup_component(1, "cover"): - assert await setup.async_setup_component( - hass, - "cover", - { - "cover": { - "platform": "template", - "covers": { - "test_template_cover": { - "position_template": "{{ states.cover.test.attributes.position }}", - "open_cover": { - "service": "cover.open_cover", - "entity_id": "cover.test", - }, - "close_cover": { - "service": "cover.close_cover", - "entity_id": "cover.test", - }, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - state = hass.states.async_set("cover.test", STATE_CLOSED) - await hass.async_block_till_done() - - entity = hass.states.get("cover.test") attrs = {} - attrs["position"] = 42 - hass.states.async_set(entity.entity_id, entity.state, attributes=attrs) - await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") - assert state.attributes.get("current_position") == 42.0 - assert state.state == STATE_OPEN - - state = hass.states.async_set("cover.test", STATE_OPEN) - await hass.async_block_till_done() - entity = hass.states.get("cover.test") - attrs["position"] = 0.0 - hass.states.async_set(entity.entity_id, entity.state, attributes=attrs) - await hass.async_block_till_done() - - state = hass.states.get("cover.test_template_cover") - assert state.attributes.get("current_position") == 0.0 - assert state.state == STATE_CLOSED + for set_state, pos, test_state in [ + (STATE_CLOSED, 42, STATE_OPEN), + (STATE_OPEN, 0.0, STATE_CLOSED), + ]: + state = hass.states.async_set("cover.test", set_state) + await hass.async_block_till_done() + entity = hass.states.get("cover.test") + attrs["position"] = pos + hass.states.async_set(entity.entity_id, entity.state, attributes=attrs) + await hass.async_block_till_done() + state = hass.states.get("cover.test_template_cover") + assert state.attributes.get("current_position") == pos + assert state.state == test_state -async def test_template_tilt(hass, calls): +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + DOMAIN: { + "platform": "template", + "covers": { + "test_template_cover": { + "value_template": "{{ 1 == 1 }}", + "tilt_template": "{{ 42 }}", + "open_cover": { + "service": "cover.open_cover", + "entity_id": "cover.test_state", + }, + "close_cover": { + "service": "cover.close_cover", + "entity_id": "cover.test_state", + }, + } + }, + } + }, + ], +) +async def test_template_tilt(hass, start_ha): """Test the tilt_template attribute.""" - with assert_setup_component(1, "cover"): - assert await setup.async_setup_component( - hass, - "cover", - { - "cover": { - "platform": "template", - "covers": { - "test_template_cover": { - "value_template": "{{ 1 == 1 }}", - "tilt_template": "{{ 42 }}", - "open_cover": { - "service": "cover.open_cover", - "entity_id": "cover.test_state", - }, - "close_cover": { - "service": "cover.close_cover", - "entity_id": "cover.test_state", - }, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") assert state.attributes.get("current_tilt_position") == 42.0 -async def test_template_out_of_bounds(hass, calls): - """Test template out-of-bounds condition.""" - with assert_setup_component(1, "cover"): - assert await setup.async_setup_component( - hass, - "cover", - { - "cover": { - "platform": "template", - "covers": { - "test_template_cover": { - "position_template": "{{ -1 }}", - "tilt_template": "{{ 110 }}", - "open_cover": { - "service": "cover.open_cover", - "entity_id": "cover.test_state", - }, - "close_cover": { - "service": "cover.close_cover", - "entity_id": "cover.test_state", - }, - } +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + DOMAIN: { + "platform": "template", + "covers": { + "test_template_cover": { + "position_template": "{{ -1 }}", + "tilt_template": "{{ 110 }}", + "open_cover": { + "service": "cover.open_cover", + "entity_id": "cover.test_state", + }, + "close_cover": { + "service": "cover.close_cover", + "entity_id": "cover.test_state", + }, + } + }, + } + }, + { + DOMAIN: { + "platform": "template", + "covers": { + "test_template_cover": { + "position_template": "{{ on }}", + "tilt_template": "{% if states.cover.test_state.state %}" + "on" + "{% else %}" + "off" + "{% endif %}", + "open_cover": { + "service": "cover.open_cover", + "entity_id": "cover.test_state", + }, + "close_cover": { + "service": "cover.close_cover", + "entity_id": "cover.test_state", + }, }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + }, + } + }, + ], +) +async def test_template_out_of_bounds(hass, start_ha): + """Test template out-of-bounds condition.""" state = hass.states.get("cover.test_template_cover") assert state.attributes.get("current_tilt_position") is None assert state.attributes.get("current_position") is None -async def test_template_open_or_position(hass, caplog): - """Test that at least one of open_cover or set_position is used.""" - assert await setup.async_setup_component( - hass, - "cover", +@pytest.mark.parametrize("count,domain", [(0, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { - "cover": { + DOMAIN: { "platform": "template", "covers": {"test_template_cover": {"value_template": "{{ 1 == 1 }}"}}, } }, - ) - await hass.async_block_till_done() - + { + DOMAIN: { + "platform": "template", + "covers": { + "test_template_cover": { + "value_template": "{{ 1 == 1 }}", + "open_cover": { + "service": "cover.open_cover", + "entity_id": "cover.test_state", + }, + } + }, + } + }, + ], +) +async def test_template_open_or_position(hass, start_ha, caplog_setup_text): + """Test that at least one of open_cover or set_position is used.""" assert hass.states.async_all() == [] - assert "Invalid config for [cover.template]" in caplog.text + assert "Invalid config for [cover.template]" in caplog_setup_text -async def test_template_open_and_close(hass, calls): - """Test that if open_cover is specified, close_cover is too.""" - with assert_setup_component(0, "cover"): - assert await setup.async_setup_component( - hass, - "cover", - { - "cover": { - "platform": "template", - "covers": { - "test_template_cover": { - "value_template": "{{ 1 == 1 }}", - "open_cover": { - "service": "cover.open_cover", - "entity_id": "cover.test_state", - }, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert hass.states.async_all() == [] - - -async def test_template_non_numeric(hass, calls): - """Test that tilt_template values are numeric.""" - with assert_setup_component(1, "cover"): - assert await setup.async_setup_component( - hass, - "cover", - { - "cover": { - "platform": "template", - "covers": { - "test_template_cover": { - "position_template": "{{ on }}", - "tilt_template": "{% if states.cover.test_state.state %}" - "on" - "{% else %}" - "off" - "{% endif %}", - "open_cover": { - "service": "cover.open_cover", - "entity_id": "cover.test_state", - }, - "close_cover": { - "service": "cover.close_cover", - "entity_id": "cover.test_state", - }, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - state = hass.states.get("cover.test_template_cover") - assert state.attributes.get("current_tilt_position") is None - assert state.attributes.get("current_position") is None - - -async def test_open_action(hass, calls): +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + DOMAIN: { + "platform": "template", + "covers": { + "test_template_cover": { + "position_template": "{{ 0 }}", + "open_cover": {"service": "test.automation"}, + "close_cover": { + "service": "cover.close_cover", + "entity_id": "cover.test_state", + }, + } + }, + } + }, + ], +) +async def test_open_action(hass, start_ha, calls): """Test the open_cover command.""" - with assert_setup_component(1, "cover"): - assert await setup.async_setup_component( - hass, - "cover", - { - "cover": { - "platform": "template", - "covers": { - "test_template_cover": { - "position_template": "{{ 0 }}", - "open_cover": {"service": "test.automation"}, - "close_cover": { - "service": "cover.close_cover", - "entity_id": "cover.test_state", - }, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") assert state.state == STATE_CLOSED @@ -492,34 +365,30 @@ async def test_open_action(hass, calls): assert len(calls) == 1 -async def test_close_stop_action(hass, calls): +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + DOMAIN: { + "platform": "template", + "covers": { + "test_template_cover": { + "position_template": "{{ 100 }}", + "open_cover": { + "service": "cover.open_cover", + "entity_id": "cover.test_state", + }, + "close_cover": {"service": "test.automation"}, + "stop_cover": {"service": "test.automation"}, + } + }, + } + }, + ], +) +async def test_close_stop_action(hass, start_ha, calls): """Test the close-cover and stop_cover commands.""" - with assert_setup_component(1, "cover"): - assert await setup.async_setup_component( - hass, - "cover", - { - "cover": { - "platform": "template", - "covers": { - "test_template_cover": { - "position_template": "{{ 100 }}", - "open_cover": { - "service": "cover.open_cover", - "entity_id": "cover.test_state", - }, - "close_cover": {"service": "test.automation"}, - "stop_cover": {"service": "test.automation"}, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") assert state.state == STATE_OPEN @@ -536,14 +405,16 @@ async def test_close_stop_action(hass, calls): assert len(calls) == 2 -async def test_set_position(hass, calls): +@pytest.mark.parametrize("count,domain", [(1, "input_number")]) +@pytest.mark.parametrize( + "config", + [ + {"input_number": {"test": {"min": "0", "max": "100", "initial": "42"}}}, + ], +) +async def test_set_position(hass, start_ha, calls): """Test the set_position command.""" with assert_setup_component(1, "cover"): - assert await setup.async_setup_component( - hass, - "input_number", - {"input_number": {"test": {"min": "0", "max": "100", "initial": "42"}}}, - ) assert await setup.async_setup_component( hass, "cover", @@ -612,41 +483,48 @@ async def test_set_position(hass, calls): assert state.attributes.get("current_position") == 25.0 -async def test_set_tilt_position(hass, calls): +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + DOMAIN: { + "platform": "template", + "covers": { + "test_template_cover": { + "position_template": "{{ 100 }}", + "open_cover": { + "service": "cover.open_cover", + "entity_id": "cover.test_state", + }, + "close_cover": { + "service": "cover.close_cover", + "entity_id": "cover.test_state", + }, + "set_cover_tilt_position": {"service": "test.automation"}, + } + }, + } + }, + ], +) +@pytest.mark.parametrize( + "service,attr", + [ + ( + SERVICE_SET_COVER_TILT_POSITION, + {ATTR_ENTITY_ID: ENTITY_COVER, ATTR_TILT_POSITION: 42}, + ), + (SERVICE_OPEN_COVER_TILT, {ATTR_ENTITY_ID: ENTITY_COVER}), + (SERVICE_CLOSE_COVER_TILT, {ATTR_ENTITY_ID: ENTITY_COVER}), + ], +) +async def test_set_tilt_position(hass, service, attr, start_ha, calls): """Test the set_tilt_position command.""" - with assert_setup_component(1, "cover"): - assert await setup.async_setup_component( - hass, - "cover", - { - "cover": { - "platform": "template", - "covers": { - "test_template_cover": { - "position_template": "{{ 100 }}", - "open_cover": { - "service": "cover.open_cover", - "entity_id": "cover.test_state", - }, - "close_cover": { - "service": "cover.close_cover", - "entity_id": "cover.test_state", - }, - "set_cover_tilt_position": {"service": "test.automation"}, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - await hass.services.async_call( DOMAIN, - SERVICE_SET_COVER_TILT_POSITION, - {ATTR_ENTITY_ID: ENTITY_COVER, ATTR_TILT_POSITION: 42}, + service, + attr, blocking=True, ) await hass.async_block_till_done() @@ -654,105 +532,24 @@ async def test_set_tilt_position(hass, calls): assert len(calls) == 1 -async def test_open_tilt_action(hass, calls): - """Test the open_cover_tilt command.""" - with assert_setup_component(1, "cover"): - assert await setup.async_setup_component( - hass, - "cover", - { - "cover": { - "platform": "template", - "covers": { - "test_template_cover": { - "position_template": "{{ 100 }}", - "open_cover": { - "service": "cover.open_cover", - "entity_id": "cover.test_state", - }, - "close_cover": { - "service": "cover.close_cover", - "entity_id": "cover.test_state", - }, - "set_cover_tilt_position": {"service": "test.automation"}, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - await hass.services.async_call( - DOMAIN, SERVICE_OPEN_COVER_TILT, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True - ) - await hass.async_block_till_done() - - assert len(calls) == 1 - - -async def test_close_tilt_action(hass, calls): - """Test the close_cover_tilt command.""" - with assert_setup_component(1, "cover"): - assert await setup.async_setup_component( - hass, - "cover", - { - "cover": { - "platform": "template", - "covers": { - "test_template_cover": { - "position_template": "{{ 100 }}", - "open_cover": { - "service": "cover.open_cover", - "entity_id": "cover.test_state", - }, - "close_cover": { - "service": "cover.close_cover", - "entity_id": "cover.test_state", - }, - "set_cover_tilt_position": {"service": "test.automation"}, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - await hass.services.async_call( - DOMAIN, SERVICE_CLOSE_COVER_TILT, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True - ) - await hass.async_block_till_done() - - assert len(calls) == 1 - - -async def test_set_position_optimistic(hass, calls): +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + DOMAIN: { + "platform": "template", + "covers": { + "test_template_cover": { + "set_cover_position": {"service": "test.automation"} + } + }, + } + }, + ], +) +async def test_set_position_optimistic(hass, start_ha, calls): """Test optimistic position mode.""" - with assert_setup_component(1, "cover"): - assert await setup.async_setup_component( - hass, - "cover", - { - "cover": { - "platform": "template", - "covers": { - "test_template_cover": { - "set_cover_position": {"service": "test.automation"} - } - }, - } - }, - ) - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") assert state.attributes.get("current_position") is None @@ -766,58 +563,40 @@ async def test_set_position_optimistic(hass, calls): state = hass.states.get("cover.test_template_cover") assert state.attributes.get("current_position") == 42.0 - await hass.services.async_call( - DOMAIN, SERVICE_CLOSE_COVER, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True - ) - await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") - assert state.state == STATE_CLOSED - - await hass.services.async_call( - DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True - ) - await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") - assert state.state == STATE_OPEN - - await hass.services.async_call( - DOMAIN, SERVICE_TOGGLE, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True - ) - await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") - assert state.state == STATE_CLOSED - - await hass.services.async_call( - DOMAIN, SERVICE_TOGGLE, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True - ) - await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") - assert state.state == STATE_OPEN - - -async def test_set_tilt_position_optimistic(hass, calls): - """Test the optimistic tilt_position mode.""" - with assert_setup_component(1, "cover"): - assert await setup.async_setup_component( - hass, - "cover", - { - "cover": { - "platform": "template", - "covers": { - "test_template_cover": { - "position_template": "{{ 100 }}", - "set_cover_position": {"service": "test.automation"}, - "set_cover_tilt_position": {"service": "test.automation"}, - } - }, - } - }, + for service, test_state in [ + (SERVICE_CLOSE_COVER, STATE_CLOSED), + (SERVICE_OPEN_COVER, STATE_OPEN), + (SERVICE_TOGGLE, STATE_CLOSED), + (SERVICE_TOGGLE, STATE_OPEN), + ]: + await hass.services.async_call( + DOMAIN, service, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True ) - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get("cover.test_template_cover") + assert state.state == test_state + +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + DOMAIN: { + "platform": "template", + "covers": { + "test_template_cover": { + "position_template": "{{ 100 }}", + "set_cover_position": {"service": "test.automation"}, + "set_cover_tilt_position": {"service": "test.automation"}, + } + }, + } + }, + ], +) +async def test_set_tilt_position_optimistic(hass, start_ha, calls): + """Test the optimistic tilt_position mode.""" state = hass.states.get("cover.test_template_cover") assert state.attributes.get("current_tilt_position") is None @@ -831,68 +610,49 @@ async def test_set_tilt_position_optimistic(hass, calls): state = hass.states.get("cover.test_template_cover") assert state.attributes.get("current_tilt_position") == 42.0 - await hass.services.async_call( - DOMAIN, SERVICE_CLOSE_COVER_TILT, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True - ) - await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") - assert state.attributes.get("current_tilt_position") == 0.0 - - await hass.services.async_call( - DOMAIN, SERVICE_OPEN_COVER_TILT, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True - ) - await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") - assert state.attributes.get("current_tilt_position") == 100.0 - - await hass.services.async_call( - DOMAIN, SERVICE_TOGGLE_COVER_TILT, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True - ) - await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") - assert state.attributes.get("current_tilt_position") == 0.0 - - await hass.services.async_call( - DOMAIN, SERVICE_TOGGLE_COVER_TILT, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True - ) - await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") - assert state.attributes.get("current_tilt_position") == 100.0 - - -async def test_icon_template(hass, calls): - """Test icon template.""" - with assert_setup_component(1, "cover"): - assert await setup.async_setup_component( - hass, - "cover", - { - "cover": { - "platform": "template", - "covers": { - "test_template_cover": { - "value_template": "{{ states.cover.test_state.state }}", - "open_cover": { - "service": "cover.open_cover", - "entity_id": "cover.test_state", - }, - "close_cover": { - "service": "cover.close_cover", - "entity_id": "cover.test_state", - }, - "icon_template": "{% if states.cover.test_state.state %}" - "mdi:check" - "{% endif %}", - } - }, - } - }, + for service, pos in [ + (SERVICE_CLOSE_COVER_TILT, 0.0), + (SERVICE_OPEN_COVER_TILT, 100.0), + (SERVICE_TOGGLE_COVER_TILT, 0.0), + (SERVICE_TOGGLE_COVER_TILT, 100.0), + ]: + await hass.services.async_call( + DOMAIN, service, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True ) + await hass.async_block_till_done() + state = hass.states.get("cover.test_template_cover") + assert state.attributes.get("current_tilt_position") == pos - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + DOMAIN: { + "platform": "template", + "covers": { + "test_template_cover": { + "value_template": "{{ states.cover.test_state.state }}", + "open_cover": { + "service": "cover.open_cover", + "entity_id": "cover.test_state", + }, + "close_cover": { + "service": "cover.close_cover", + "entity_id": "cover.test_state", + }, + "icon_template": "{% if states.cover.test_state.state %}" + "mdi:check" + "{% endif %}", + } + }, + } + }, + ], +) +async def test_icon_template(hass, start_ha): + """Test icon template.""" state = hass.states.get("cover.test_template_cover") assert state.attributes.get("icon") == "" @@ -904,39 +664,35 @@ async def test_icon_template(hass, calls): assert state.attributes["icon"] == "mdi:check" -async def test_entity_picture_template(hass, calls): +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + DOMAIN: { + "platform": "template", + "covers": { + "test_template_cover": { + "value_template": "{{ states.cover.test_state.state }}", + "open_cover": { + "service": "cover.open_cover", + "entity_id": "cover.test_state", + }, + "close_cover": { + "service": "cover.close_cover", + "entity_id": "cover.test_state", + }, + "entity_picture_template": "{% if states.cover.test_state.state %}" + "/local/cover.png" + "{% endif %}", + } + }, + } + }, + ], +) +async def test_entity_picture_template(hass, start_ha): """Test icon template.""" - with assert_setup_component(1, "cover"): - assert await setup.async_setup_component( - hass, - "cover", - { - "cover": { - "platform": "template", - "covers": { - "test_template_cover": { - "value_template": "{{ states.cover.test_state.state }}", - "open_cover": { - "service": "cover.open_cover", - "entity_id": "cover.test_state", - }, - "close_cover": { - "service": "cover.close_cover", - "entity_id": "cover.test_state", - }, - "entity_picture_template": "{% if states.cover.test_state.state %}" - "/local/cover.png" - "{% endif %}", - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") assert state.attributes.get("entity_picture") == "" @@ -948,37 +704,33 @@ async def test_entity_picture_template(hass, calls): assert state.attributes["entity_picture"] == "/local/cover.png" -async def test_availability_template(hass, calls): +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + DOMAIN: { + "platform": "template", + "covers": { + "test_template_cover": { + "value_template": "open", + "open_cover": { + "service": "cover.open_cover", + "entity_id": "cover.test_state", + }, + "close_cover": { + "service": "cover.close_cover", + "entity_id": "cover.test_state", + }, + "availability_template": "{{ is_state('availability_state.state','on') }}", + } + }, + } + }, + ], +) +async def test_availability_template(hass, start_ha): """Test availability template.""" - with assert_setup_component(1, "cover"): - assert await setup.async_setup_component( - hass, - "cover", - { - "cover": { - "platform": "template", - "covers": { - "test_template_cover": { - "value_template": "open", - "open_cover": { - "service": "cover.open_cover", - "entity_id": "cover.test_state", - }, - "close_cover": { - "service": "cover.close_cover", - "entity_id": "cover.test_state", - }, - "availability_template": "{{ is_state('availability_state.state','on') }}", - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - hass.states.async_set("availability_state.state", STATE_OFF) await hass.async_block_till_done() @@ -990,13 +742,12 @@ async def test_availability_template(hass, calls): assert hass.states.get("cover.test_template_cover").state != STATE_UNAVAILABLE -async def test_availability_without_availability_template(hass, calls): - """Test that component is available if there is no.""" - assert await setup.async_setup_component( - hass, - "cover", +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { - "cover": { + DOMAIN: { "platform": "template", "covers": { "test_template_cover": { @@ -1013,23 +764,20 @@ async def test_availability_without_availability_template(hass, calls): }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_availability_without_availability_template(hass, start_ha): + """Test that component is available if there is no.""" state = hass.states.get("cover.test_template_cover") assert state.state != STATE_UNAVAILABLE -async def test_invalid_availability_template_keeps_component_available(hass, caplog): - """Test that an invalid availability keeps the device available.""" - assert await setup.async_setup_component( - hass, - "cover", +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { - "cover": { + DOMAIN: { "platform": "template", "covers": { "test_template_cover": { @@ -1047,93 +795,84 @@ async def test_invalid_availability_template_keeps_component_available(hass, cap }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_invalid_availability_template_keeps_component_available( + hass, start_ha, caplog_setup_text +): + """Test that an invalid availability keeps the device available.""" assert hass.states.get("cover.test_template_cover") != STATE_UNAVAILABLE - assert ("UndefinedError: 'x' is undefined") in caplog.text + assert ("UndefinedError: 'x' is undefined") in caplog_setup_text -async def test_device_class(hass, calls): +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + DOMAIN: { + "platform": "template", + "covers": { + "test_template_cover": { + "value_template": "{{ states.cover.test_state.state }}", + "device_class": "door", + "open_cover": { + "service": "cover.open_cover", + "entity_id": "cover.test_state", + }, + "close_cover": { + "service": "cover.close_cover", + "entity_id": "cover.test_state", + }, + } + }, + } + }, + ], +) +async def test_device_class(hass, start_ha): """Test device class.""" - with assert_setup_component(1, "cover"): - assert await setup.async_setup_component( - hass, - "cover", - { - "cover": { - "platform": "template", - "covers": { - "test_template_cover": { - "value_template": "{{ states.cover.test_state.state }}", - "device_class": "door", - "open_cover": { - "service": "cover.open_cover", - "entity_id": "cover.test_state", - }, - "close_cover": { - "service": "cover.close_cover", - "entity_id": "cover.test_state", - }, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") assert state.attributes.get("device_class") == "door" -async def test_invalid_device_class(hass, calls): +@pytest.mark.parametrize("count,domain", [(0, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + DOMAIN: { + "platform": "template", + "covers": { + "test_template_cover": { + "value_template": "{{ states.cover.test_state.state }}", + "device_class": "barnacle_bill", + "open_cover": { + "service": "cover.open_cover", + "entity_id": "cover.test_state", + }, + "close_cover": { + "service": "cover.close_cover", + "entity_id": "cover.test_state", + }, + } + }, + } + }, + ], +) +async def test_invalid_device_class(hass, start_ha): """Test device class.""" - with assert_setup_component(0, "cover"): - assert await setup.async_setup_component( - hass, - "cover", - { - "cover": { - "platform": "template", - "covers": { - "test_template_cover": { - "value_template": "{{ states.cover.test_state.state }}", - "device_class": "barnacle_bill", - "open_cover": { - "service": "cover.open_cover", - "entity_id": "cover.test_state", - }, - "close_cover": { - "service": "cover.close_cover", - "entity_id": "cover.test_state", - }, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") assert not state -async def test_unique_id(hass): - """Test unique_id option only creates one cover per id.""" - await setup.async_setup_component( - hass, - "cover", +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { - "cover": { + DOMAIN: { "platform": "template", "covers": { "test_template_cover_01": { @@ -1161,27 +900,21 @@ async def test_unique_id(hass): }, }, }, - }, + } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_unique_id(hass, start_ha): + """Test unique_id option only creates one cover per id.""" assert len(hass.states.async_all()) == 1 -async def test_state_gets_lowercased(hass): - """Test True/False is lowercased.""" - - hass.states.async_set("binary_sensor.garage_door_sensor", "off") - - await setup.async_setup_component( - hass, - "cover", +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { - "cover": { + DOMAIN: { "platform": "template", "covers": { "garage_door": { @@ -1197,12 +930,14 @@ async def test_state_gets_lowercased(hass): }, }, }, - }, + } }, - ) + ], +) +async def test_state_gets_lowercased(hass, start_ha): + """Test True/False is lowercased.""" - await hass.async_block_till_done() - await hass.async_start() + hass.states.async_set("binary_sensor.garage_door_sensor", "off") await hass.async_block_till_done() assert len(hass.states.async_all()) == 2 @@ -1213,24 +948,20 @@ async def test_state_gets_lowercased(hass): assert hass.states.get("cover.garage_door").state == STATE_CLOSED -async def test_self_referencing_icon_with_no_template_is_not_a_loop(hass, caplog): - """Test a self referencing icon with no value template is not a loop.""" - - icon_template_str = """{% if is_state('cover.office', 'open') %} - mdi:window-shutter-open - {% else %} - mdi:window-shutter - {% endif %}""" - - await setup.async_setup_component( - hass, - "cover", +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { - "cover": { + DOMAIN: { "platform": "template", "covers": { "office": { - "icon_template": icon_template_str, + "icon_template": """{% if is_state('cover.office', 'open') %} + mdi:window-shutter-open + {% else %} + mdi:window-shutter + {% endif %}""", "open_cover": { "service": "switch.turn_on", "entity_id": "switch.office_blinds_up", @@ -1247,12 +978,12 @@ async def test_self_referencing_icon_with_no_template_is_not_a_loop(hass, caplog }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_self_referencing_icon_with_no_template_is_not_a_loop( + hass, start_ha, caplog +): + """Test a self referencing icon with no value template is not a loop.""" assert len(hass.states.async_all()) == 1 assert "Template loop detected" not in caplog.text From 41b25a765c8870e50349e60bd921a64ea3dbdd72 Mon Sep 17 00:00:00 2001 From: joshs85 Date: Sun, 12 Sep 2021 21:46:43 -0400 Subject: [PATCH 366/843] Changed wording of bond state belief feature from belief to tracked state (#56147) Co-authored-by: J. Nick Koston --- homeassistant/components/bond/const.py | 8 +++--- homeassistant/components/bond/fan.py | 4 +-- homeassistant/components/bond/light.py | 8 +++--- homeassistant/components/bond/services.yaml | 32 ++++++++++----------- homeassistant/components/bond/switch.py | 10 +++++-- tests/components/bond/test_fan.py | 8 +++--- tests/components/bond/test_light.py | 28 +++++++++--------- tests/components/bond/test_switch.py | 6 ++-- 8 files changed, 55 insertions(+), 49 deletions(-) diff --git a/homeassistant/components/bond/const.py b/homeassistant/components/bond/const.py index bf1af003e96..4d886c2ee77 100644 --- a/homeassistant/components/bond/const.py +++ b/homeassistant/components/bond/const.py @@ -11,8 +11,8 @@ HUB = "hub" BPUP_SUBS = "bpup_subs" BPUP_STOP = "bpup_stop" -SERVICE_SET_FAN_SPEED_BELIEF = "set_fan_speed_belief" -SERVICE_SET_POWER_BELIEF = "set_switch_power_belief" -SERVICE_SET_LIGHT_POWER_BELIEF = "set_light_power_belief" -SERVICE_SET_LIGHT_BRIGHTNESS_BELIEF = "set_light_brightness_belief" +SERVICE_SET_FAN_SPEED_TRACKED_STATE = "set_fan_speed_tracked_state" +SERVICE_SET_POWER_TRACKED_STATE = "set_switch_power_tracked_state" +SERVICE_SET_LIGHT_POWER_TRACKED_STATE = "set_light_power_tracked_state" +SERVICE_SET_LIGHT_BRIGHTNESS_TRACKED_STATE = "set_light_brightness_tracked_state" ATTR_POWER_STATE = "power_state" diff --git a/homeassistant/components/bond/fan.py b/homeassistant/components/bond/fan.py index a5e10cd371a..b5d7059b67e 100644 --- a/homeassistant/components/bond/fan.py +++ b/homeassistant/components/bond/fan.py @@ -29,7 +29,7 @@ from homeassistant.util.percentage import ( ranged_value_to_percentage, ) -from .const import BPUP_SUBS, DOMAIN, HUB, SERVICE_SET_FAN_SPEED_BELIEF +from .const import BPUP_SUBS, DOMAIN, HUB, SERVICE_SET_FAN_SPEED_TRACKED_STATE from .entity import BondEntity from .utils import BondDevice, BondHub @@ -54,7 +54,7 @@ async def async_setup_entry( ] platform.async_register_entity_service( - SERVICE_SET_FAN_SPEED_BELIEF, + SERVICE_SET_FAN_SPEED_TRACKED_STATE, {vol.Required(ATTR_SPEED): vol.All(vol.Number(scale=0), vol.Range(0, 100))}, "async_set_speed_belief", ) diff --git a/homeassistant/components/bond/light.py b/homeassistant/components/bond/light.py index c47147a5648..82bfa24e44d 100644 --- a/homeassistant/components/bond/light.py +++ b/homeassistant/components/bond/light.py @@ -26,8 +26,8 @@ from .const import ( BPUP_SUBS, DOMAIN, HUB, - SERVICE_SET_LIGHT_BRIGHTNESS_BELIEF, - SERVICE_SET_LIGHT_POWER_BELIEF, + SERVICE_SET_LIGHT_BRIGHTNESS_TRACKED_STATE, + SERVICE_SET_LIGHT_POWER_TRACKED_STATE, ) from .entity import BondEntity from .utils import BondDevice @@ -103,7 +103,7 @@ async def async_setup_entry( ] platform.async_register_entity_service( - SERVICE_SET_LIGHT_BRIGHTNESS_BELIEF, + SERVICE_SET_LIGHT_BRIGHTNESS_TRACKED_STATE, { vol.Required(ATTR_BRIGHTNESS): vol.All( vol.Number(scale=0), vol.Range(0, 255) @@ -113,7 +113,7 @@ async def async_setup_entry( ) platform.async_register_entity_service( - SERVICE_SET_LIGHT_POWER_BELIEF, + SERVICE_SET_LIGHT_POWER_TRACKED_STATE, {vol.Required(ATTR_POWER_STATE): vol.All(cv.boolean)}, "async_set_power_belief", ) diff --git a/homeassistant/components/bond/services.yaml b/homeassistant/components/bond/services.yaml index 32a3e882739..4ad2b4f9bb3 100644 --- a/homeassistant/components/bond/services.yaml +++ b/homeassistant/components/bond/services.yaml @@ -1,11 +1,11 @@ # Describes the format for available bond services -set_fan_speed_belief: - name: Set believed fan speed - description: Sets the believed fan speed for a bond fan +set_fan_speed_tracked_state: + name: Set fan speed tracked state + description: Sets the tracked fan speed for a bond fan fields: entity_id: - description: Name(s) of entities to set the believed fan speed. + description: Name(s) of entities to set the tracked fan speed. example: "fan.living_room_fan" name: Entity required: true @@ -25,12 +25,12 @@ set_fan_speed_belief: step: 1 mode: slider -set_switch_power_belief: - name: Set believed switch power state - description: Sets the believed power state of a bond switch +set_switch_power_tracked_state: + name: Set switch power tracked state + description: Sets the tracked power state of a bond switch fields: entity_id: - description: Name(s) of entities to set the believed power state of. + description: Name(s) of entities to set the tracked power state of. example: "switch.whatever" name: Entity required: true @@ -46,12 +46,12 @@ set_switch_power_belief: selector: boolean: -set_light_power_belief: - name: Set believed light power state - description: Sets the believed light power state of a bond light +set_light_power_tracked_state: + name: Set light power tracked state + description: Sets the tracked power state of a bond light fields: entity_id: - description: Name(s) of entities to set the believed power state of. + description: Name(s) of entities to set the tracked power state of. example: "light.living_room_lights" name: Entity required: true @@ -67,12 +67,12 @@ set_light_power_belief: selector: boolean: -set_light_brightness_belief: - name: Set believed light brightness state - description: Sets the believed light brightness state of a bond light +set_light_brightness_tracked_state: + name: Set light brightness tracked state + description: Sets the tracked brightness state of a bond light fields: entity_id: - description: Name(s) of entities to set the believed power state of. + description: Name(s) of entities to set the tracked brightness state of. example: "light.living_room_lights" name: Entity required: true diff --git a/homeassistant/components/bond/switch.py b/homeassistant/components/bond/switch.py index b493ac07945..01c224d8307 100644 --- a/homeassistant/components/bond/switch.py +++ b/homeassistant/components/bond/switch.py @@ -15,7 +15,13 @@ from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ATTR_POWER_STATE, BPUP_SUBS, DOMAIN, HUB, SERVICE_SET_POWER_BELIEF +from .const import ( + ATTR_POWER_STATE, + BPUP_SUBS, + DOMAIN, + HUB, + SERVICE_SET_POWER_TRACKED_STATE, +) from .entity import BondEntity from .utils import BondHub @@ -38,7 +44,7 @@ async def async_setup_entry( ] platform.async_register_entity_service( - SERVICE_SET_POWER_BELIEF, + SERVICE_SET_POWER_TRACKED_STATE, {vol.Required(ATTR_POWER_STATE): cv.boolean}, "async_set_power_belief", ) diff --git a/tests/components/bond/test_fan.py b/tests/components/bond/test_fan.py index e975f586ff4..d24128617d2 100644 --- a/tests/components/bond/test_fan.py +++ b/tests/components/bond/test_fan.py @@ -10,7 +10,7 @@ from homeassistant import core from homeassistant.components import fan from homeassistant.components.bond.const import ( DOMAIN as BOND_DOMAIN, - SERVICE_SET_FAN_SPEED_BELIEF, + SERVICE_SET_FAN_SPEED_TRACKED_STATE, ) from homeassistant.components.fan import ( ATTR_DIRECTION, @@ -270,7 +270,7 @@ async def test_set_speed_belief_speed_zero(hass: core.HomeAssistant): with patch_bond_action() as mock_action, patch_bond_device_state(): await hass.services.async_call( BOND_DOMAIN, - SERVICE_SET_FAN_SPEED_BELIEF, + SERVICE_SET_FAN_SPEED_TRACKED_STATE, {ATTR_ENTITY_ID: "fan.name_1", ATTR_SPEED: 0}, blocking=True, ) @@ -292,7 +292,7 @@ async def test_set_speed_belief_speed_api_error(hass: core.HomeAssistant): ), patch_bond_action_returns_clientresponseerror(), patch_bond_device_state(): await hass.services.async_call( BOND_DOMAIN, - SERVICE_SET_FAN_SPEED_BELIEF, + SERVICE_SET_FAN_SPEED_TRACKED_STATE, {ATTR_ENTITY_ID: "fan.name_1", ATTR_SPEED: 100}, blocking=True, ) @@ -308,7 +308,7 @@ async def test_set_speed_belief_speed_100(hass: core.HomeAssistant): with patch_bond_action() as mock_action, patch_bond_device_state(): await hass.services.async_call( BOND_DOMAIN, - SERVICE_SET_FAN_SPEED_BELIEF, + SERVICE_SET_FAN_SPEED_TRACKED_STATE, {ATTR_ENTITY_ID: "fan.name_1", ATTR_SPEED: 100}, blocking=True, ) diff --git a/tests/components/bond/test_light.py b/tests/components/bond/test_light.py index 3b846f3d996..e0ca9a05425 100644 --- a/tests/components/bond/test_light.py +++ b/tests/components/bond/test_light.py @@ -8,8 +8,8 @@ from homeassistant import core from homeassistant.components.bond.const import ( ATTR_POWER_STATE, DOMAIN, - SERVICE_SET_LIGHT_BRIGHTNESS_BELIEF, - SERVICE_SET_LIGHT_POWER_BELIEF, + SERVICE_SET_LIGHT_BRIGHTNESS_TRACKED_STATE, + SERVICE_SET_LIGHT_POWER_TRACKED_STATE, ) from homeassistant.components.bond.light import ( SERVICE_START_DECREASING_BRIGHTNESS, @@ -296,7 +296,7 @@ async def test_light_set_brightness_belief_full(hass: core.HomeAssistant): with patch_bond_action() as mock_bond_action, patch_bond_device_state(): await hass.services.async_call( DOMAIN, - SERVICE_SET_LIGHT_BRIGHTNESS_BELIEF, + SERVICE_SET_LIGHT_BRIGHTNESS_TRACKED_STATE, {ATTR_ENTITY_ID: "light.name_1", ATTR_BRIGHTNESS: 255}, blocking=True, ) @@ -321,7 +321,7 @@ async def test_light_set_brightness_belief_api_error(hass: core.HomeAssistant): ), patch_bond_action_returns_clientresponseerror(), patch_bond_device_state(): await hass.services.async_call( DOMAIN, - SERVICE_SET_LIGHT_BRIGHTNESS_BELIEF, + SERVICE_SET_LIGHT_BRIGHTNESS_TRACKED_STATE, {ATTR_ENTITY_ID: "light.name_1", ATTR_BRIGHTNESS: 255}, blocking=True, ) @@ -340,7 +340,7 @@ async def test_fp_light_set_brightness_belief_full(hass: core.HomeAssistant): with patch_bond_action() as mock_bond_action, patch_bond_device_state(): await hass.services.async_call( DOMAIN, - SERVICE_SET_LIGHT_BRIGHTNESS_BELIEF, + SERVICE_SET_LIGHT_BRIGHTNESS_TRACKED_STATE, {ATTR_ENTITY_ID: "light.name_1", ATTR_BRIGHTNESS: 255}, blocking=True, ) @@ -365,7 +365,7 @@ async def test_fp_light_set_brightness_belief_api_error(hass: core.HomeAssistant ), patch_bond_action_returns_clientresponseerror(), patch_bond_device_state(): await hass.services.async_call( DOMAIN, - SERVICE_SET_LIGHT_BRIGHTNESS_BELIEF, + SERVICE_SET_LIGHT_BRIGHTNESS_TRACKED_STATE, {ATTR_ENTITY_ID: "light.name_1", ATTR_BRIGHTNESS: 255}, blocking=True, ) @@ -386,7 +386,7 @@ async def test_light_set_brightness_belief_brightnes_not_supported( with pytest.raises(HomeAssistantError), patch_bond_device_state(): await hass.services.async_call( DOMAIN, - SERVICE_SET_LIGHT_BRIGHTNESS_BELIEF, + SERVICE_SET_LIGHT_BRIGHTNESS_TRACKED_STATE, {ATTR_ENTITY_ID: "light.name_1", ATTR_BRIGHTNESS: 255}, blocking=True, ) @@ -405,7 +405,7 @@ async def test_light_set_brightness_belief_zero(hass: core.HomeAssistant): with patch_bond_action() as mock_bond_action, patch_bond_device_state(): await hass.services.async_call( DOMAIN, - SERVICE_SET_LIGHT_BRIGHTNESS_BELIEF, + SERVICE_SET_LIGHT_BRIGHTNESS_TRACKED_STATE, {ATTR_ENTITY_ID: "light.name_1", ATTR_BRIGHTNESS: 0}, blocking=True, ) @@ -428,7 +428,7 @@ async def test_fp_light_set_brightness_belief_zero(hass: core.HomeAssistant): with patch_bond_action() as mock_bond_action, patch_bond_device_state(): await hass.services.async_call( DOMAIN, - SERVICE_SET_LIGHT_BRIGHTNESS_BELIEF, + SERVICE_SET_LIGHT_BRIGHTNESS_TRACKED_STATE, {ATTR_ENTITY_ID: "light.name_1", ATTR_BRIGHTNESS: 0}, blocking=True, ) @@ -451,7 +451,7 @@ async def test_light_set_power_belief(hass: core.HomeAssistant): with patch_bond_action() as mock_bond_action, patch_bond_device_state(): await hass.services.async_call( DOMAIN, - SERVICE_SET_LIGHT_POWER_BELIEF, + SERVICE_SET_LIGHT_POWER_TRACKED_STATE, {ATTR_ENTITY_ID: "light.name_1", ATTR_POWER_STATE: False}, blocking=True, ) @@ -476,7 +476,7 @@ async def test_light_set_power_belief_api_error(hass: core.HomeAssistant): ), patch_bond_action_returns_clientresponseerror(), patch_bond_device_state(): await hass.services.async_call( DOMAIN, - SERVICE_SET_LIGHT_POWER_BELIEF, + SERVICE_SET_LIGHT_POWER_TRACKED_STATE, {ATTR_ENTITY_ID: "light.name_1", ATTR_POWER_STATE: False}, blocking=True, ) @@ -495,7 +495,7 @@ async def test_fp_light_set_power_belief(hass: core.HomeAssistant): with patch_bond_action() as mock_bond_action, patch_bond_device_state(): await hass.services.async_call( DOMAIN, - SERVICE_SET_LIGHT_POWER_BELIEF, + SERVICE_SET_LIGHT_POWER_TRACKED_STATE, {ATTR_ENTITY_ID: "light.name_1", ATTR_POWER_STATE: False}, blocking=True, ) @@ -520,7 +520,7 @@ async def test_fp_light_set_power_belief_api_error(hass: core.HomeAssistant): ), patch_bond_action_returns_clientresponseerror(), patch_bond_device_state(): await hass.services.async_call( DOMAIN, - SERVICE_SET_LIGHT_POWER_BELIEF, + SERVICE_SET_LIGHT_POWER_TRACKED_STATE, {ATTR_ENTITY_ID: "light.name_1", ATTR_POWER_STATE: False}, blocking=True, ) @@ -541,7 +541,7 @@ async def test_fp_light_set_brightness_belief_brightnes_not_supported( with pytest.raises(HomeAssistantError), patch_bond_device_state(): await hass.services.async_call( DOMAIN, - SERVICE_SET_LIGHT_BRIGHTNESS_BELIEF, + SERVICE_SET_LIGHT_BRIGHTNESS_TRACKED_STATE, {ATTR_ENTITY_ID: "light.name_1", ATTR_BRIGHTNESS: 255}, blocking=True, ) diff --git a/tests/components/bond/test_switch.py b/tests/components/bond/test_switch.py index f2ed6e9c3b5..619eac69e71 100644 --- a/tests/components/bond/test_switch.py +++ b/tests/components/bond/test_switch.py @@ -8,7 +8,7 @@ from homeassistant import core from homeassistant.components.bond.const import ( ATTR_POWER_STATE, DOMAIN as BOND_DOMAIN, - SERVICE_SET_POWER_BELIEF, + SERVICE_SET_POWER_TRACKED_STATE, ) from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON @@ -93,7 +93,7 @@ async def test_switch_set_power_belief(hass: core.HomeAssistant): with patch_bond_action() as mock_bond_action, patch_bond_device_state(): await hass.services.async_call( BOND_DOMAIN, - SERVICE_SET_POWER_BELIEF, + SERVICE_SET_POWER_TRACKED_STATE, {ATTR_ENTITY_ID: "switch.name_1", ATTR_POWER_STATE: False}, blocking=True, ) @@ -115,7 +115,7 @@ async def test_switch_set_power_belief_api_error(hass: core.HomeAssistant): ), patch_bond_action_returns_clientresponseerror(), patch_bond_device_state(): await hass.services.async_call( BOND_DOMAIN, - SERVICE_SET_POWER_BELIEF, + SERVICE_SET_POWER_TRACKED_STATE, {ATTR_ENTITY_ID: "switch.name_1", ATTR_POWER_STATE: False}, blocking=True, ) From 32212651fe6df34d206ef09994c9ca9a95246b28 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 12 Sep 2021 20:00:51 -0700 Subject: [PATCH 367/843] Add zeroconf discovery to Hue (#55358) * Add zeroconf discovery to Hue * Add coverage for already exists case Co-authored-by: J. Nick Koston --- homeassistant/components/hue/config_flow.py | 18 +++++++ homeassistant/components/hue/manifest.json | 1 + homeassistant/generated/zeroconf.py | 5 ++ tests/components/hue/test_config_flow.py | 54 +++++++++++++++++++++ 4 files changed, 78 insertions(+) diff --git a/homeassistant/components/hue/config_flow.py b/homeassistant/components/hue/config_flow.py index 4a7ebd01fbd..72938ebfe0a 100644 --- a/homeassistant/components/hue/config_flow.py +++ b/homeassistant/components/hue/config_flow.py @@ -207,6 +207,24 @@ class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self.bridge = bridge return await self.async_step_link() + async def async_step_zeroconf(self, discovery_info): + """Handle a discovered Hue bridge. + + This flow is triggered by the Zeroconf component. It will check if the + host is already configured and delegate to the import step if not. + """ + bridge = self._async_get_bridge( + discovery_info["host"], discovery_info["properties"]["bridgeid"] + ) + + await self.async_set_unique_id(bridge.id) + self._abort_if_unique_id_configured( + updates={CONF_HOST: bridge.host}, reload_on_update=False + ) + + self.bridge = bridge + return await self.async_step_link() + async def async_step_homekit(self, discovery_info): """Handle a discovered Hue bridge on HomeKit. diff --git a/homeassistant/components/hue/manifest.json b/homeassistant/components/hue/manifest.json index 32b3cd4ee51..67659b96275 100644 --- a/homeassistant/components/hue/manifest.json +++ b/homeassistant/components/hue/manifest.json @@ -21,6 +21,7 @@ "homekit": { "models": ["BSB002"] }, + "zeroconf": ["_hue._tcp.local."], "codeowners": ["@balloob", "@frenck"], "quality_scale": "platinum", "iot_class": "local_push" diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index fd5194bd025..cf94ff03a1c 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -131,6 +131,11 @@ ZEROCONF = { "name": "shelly*" } ], + "_hue._tcp.local.": [ + { + "domain": "hue" + } + ], "_ipp._tcp.local.": [ { "domain": "ipp" diff --git a/tests/components/hue/test_config_flow.py b/tests/components/hue/test_config_flow.py index 3deec0988fa..2c79795d48b 100644 --- a/tests/components/hue/test_config_flow.py +++ b/tests/components/hue/test_config_flow.py @@ -681,3 +681,57 @@ def _get_schema_default(schema, key_name): if schema_key == key_name: return schema_key.default() raise KeyError(f"{key_name} not found in schema") + + +async def test_bridge_zeroconf(hass): + """Test a bridge being discovered.""" + result = await hass.config_entries.flow.async_init( + const.DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data={ + "host": "192.168.1.217", + "port": 443, + "hostname": "Philips-hue.local.", + "type": "_hue._tcp.local.", + "name": "Philips Hue - ABCABC._hue._tcp.local.", + "properties": { + "_raw": {"bridgeid": b"ecb5fafffeabcabc", "modelid": b"BSB002"}, + "bridgeid": "ecb5fafffeabcabc", + "modelid": "BSB002", + }, + }, + ) + + assert result["type"] == "form" + assert result["step_id"] == "link" + + +async def test_bridge_zeroconf_already_exists(hass): + """Test a bridge being discovered by zeroconf already exists.""" + entry = MockConfigEntry( + domain="hue", + source=config_entries.SOURCE_SSDP, + data={"host": "0.0.0.0"}, + unique_id="ecb5faabcabc", + ) + entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + const.DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data={ + "host": "192.168.1.217", + "port": 443, + "hostname": "Philips-hue.local.", + "type": "_hue._tcp.local.", + "name": "Philips Hue - ABCABC._hue._tcp.local.", + "properties": { + "_raw": {"bridgeid": b"ecb5faabcabc", "modelid": b"BSB002"}, + "bridgeid": "ecb5faabcabc", + "modelid": "BSB002", + }, + }, + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + assert entry.data["host"] == "192.168.1.217" From a180c3f813ddbe21e87aeae87d3cf6ced0ba242a Mon Sep 17 00:00:00 2001 From: Sean Vig Date: Sun, 12 Sep 2021 23:45:52 -0400 Subject: [PATCH 368/843] Fix polling on online Amcrest binary sensor (#56106) --- homeassistant/components/amcrest/binary_sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/amcrest/binary_sensor.py b/homeassistant/components/amcrest/binary_sensor.py index ebe19273b82..76fdcf100ae 100644 --- a/homeassistant/components/amcrest/binary_sensor.py +++ b/homeassistant/components/amcrest/binary_sensor.py @@ -111,6 +111,7 @@ BINARY_SENSORS: tuple[AmcrestSensorEntityDescription, ...] = ( key=_ONLINE_KEY, name="Online", device_class=DEVICE_CLASS_CONNECTIVITY, + should_poll=True, ), ) BINARY_SENSOR_KEYS = [description.key for description in BINARY_SENSORS] From 1f997fcd58c3bccd96f746c198e80197fcf4237e Mon Sep 17 00:00:00 2001 From: jan iversen Date: Mon, 13 Sep 2021 06:16:48 +0200 Subject: [PATCH 369/843] Update pymodbus fixtures to use autospec (#55686) --- tests/components/modbus/conftest.py | 23 ++++++++++++++++++----- tests/components/modbus/test_init.py | 6 ++++-- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/tests/components/modbus/conftest.py b/tests/components/modbus/conftest.py index 7942a8193b3..bd2ed9f7778 100644 --- a/tests/components/modbus/conftest.py +++ b/tests/components/modbus/conftest.py @@ -39,12 +39,17 @@ def mock_pymodbus(): """Mock pymodbus.""" mock_pb = mock.MagicMock() with mock.patch( - "homeassistant.components.modbus.modbus.ModbusTcpClient", return_value=mock_pb + "homeassistant.components.modbus.modbus.ModbusTcpClient", + return_value=mock_pb, + autospec=True, ), mock.patch( "homeassistant.components.modbus.modbus.ModbusSerialClient", return_value=mock_pb, + autospec=True, ), mock.patch( - "homeassistant.components.modbus.modbus.ModbusUdpClient", return_value=mock_pb + "homeassistant.components.modbus.modbus.ModbusUdpClient", + return_value=mock_pb, + autospec=True, ): yield mock_pb @@ -96,10 +101,16 @@ async def mock_modbus( } mock_pb = mock.MagicMock() with mock.patch( - "homeassistant.components.modbus.modbus.ModbusTcpClient", return_value=mock_pb + "homeassistant.components.modbus.modbus.ModbusTcpClient", + return_value=mock_pb, + autospec=True, ): now = dt_util.utcnow() - with mock.patch("homeassistant.helpers.event.dt_util.utcnow", return_value=now): + with mock.patch( + "homeassistant.helpers.event.dt_util.utcnow", + return_value=now, + autospec=True, + ): result = await async_setup_component(hass, DOMAIN, config) assert result or not check_config_loaded await hass.async_block_till_done() @@ -131,7 +142,9 @@ async def mock_pymodbus_return(hass, register_words, mock_modbus): async def mock_do_cycle(hass, mock_pymodbus_exception, mock_pymodbus_return): """Trigger update call with time_changed event.""" now = dt_util.utcnow() + timedelta(seconds=90) - with mock.patch("homeassistant.helpers.event.dt_util.utcnow", return_value=now): + with mock.patch( + "homeassistant.helpers.event.dt_util.utcnow", return_value=now, autospec=True + ): async_fire_time_changed(hass, now) await hass.async_block_till_done() diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index 5bc94b2df41..3ae271467ca 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -602,7 +602,7 @@ async def test_pymodbus_constructor_fail(hass, caplog): ] } with mock.patch( - "homeassistant.components.modbus.modbus.ModbusTcpClient" + "homeassistant.components.modbus.modbus.ModbusTcpClient", autospec=True ) as mock_pb: caplog.set_level(logging.ERROR) mock_pb.side_effect = ModbusException("test no class") @@ -669,7 +669,9 @@ async def test_delay(hass, mock_pymodbus): # pass first scan_interval start_time = now now = now + timedelta(seconds=(test_scan_interval + 1)) - with mock.patch("homeassistant.helpers.event.dt_util.utcnow", return_value=now): + with mock.patch( + "homeassistant.helpers.event.dt_util.utcnow", return_value=now, autospec=True + ): async_fire_time_changed(hass, now) await hass.async_block_till_done() assert hass.states.get(entity_id).state == STATE_UNAVAILABLE From 7472fb20499cd1124be5b0bf831e42f4337ccd7b Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Mon, 13 Sep 2021 08:22:46 +0200 Subject: [PATCH 370/843] Switch velbus from python-velbus to velbusaio (#54032) * initial commit * use new release * Update for sensors * big update * pylint fixes, bump dependancy to 2021.8.2 * New version to try to fix the tests * Fix a lot of errors, bump version * more work * Bump version * Adde dimmer support * Make sure the counters are useable in the energy dashboard * bump version * Fix testcases * Update after review * Bump version to be able to have some decent exception catches, add the temperature device class * Readd the import of the platform from config file, but add a deprecation warning * More comments updated * Fix lefover index * Fix unique id to be backwards compatible * Fix small bug in covers * Fix testcases * Changes for theenery dashboard * Fixed services * Fix memo text * Make the interface for a service the port string instead of the device selector * Fix set_memo_text * added an async scan task, more comments * Accidently disabled some paltforms * More comments, bump version * Bump version, add extra attributes, enable mypy * Removed new features * More comments * Bump version * Update homeassistant/components/velbus/__init__.py Co-authored-by: brefra * Readd the import step Co-authored-by: brefra --- homeassistant/components/velbus/__init__.py | 147 ++++++++++-------- .../components/velbus/binary_sensor.py | 13 +- homeassistant/components/velbus/climate.py | 29 ++-- .../components/velbus/config_flow.py | 14 +- homeassistant/components/velbus/const.py | 3 + homeassistant/components/velbus/cover.py | 47 ++---- homeassistant/components/velbus/light.py | 68 ++++---- homeassistant/components/velbus/manifest.json | 2 +- homeassistant/components/velbus/sensor.py | 66 +++++--- homeassistant/components/velbus/services.yaml | 34 +++- homeassistant/components/velbus/switch.py | 28 ++-- requirements_all.txt | 6 +- requirements_test_all.txt | 6 +- tests/components/velbus/test_config_flow.py | 9 +- 14 files changed, 252 insertions(+), 220 deletions(-) diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py index b798023c465..48dce9ecf97 100644 --- a/homeassistant/components/velbus/__init__.py +++ b/homeassistant/components/velbus/__init__.py @@ -1,22 +1,28 @@ """Support for Velbus devices.""" +from __future__ import annotations + import logging -import velbus +from velbusaio.controller import Velbus import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -from .const import CONF_MEMO_TEXT, DOMAIN, SERVICE_SET_MEMO_TEXT +from .const import ( + CONF_INTERFACE, + CONF_MEMO_TEXT, + DOMAIN, + SERVICE_SCAN, + SERVICE_SET_MEMO_TEXT, + SERVICE_SYNC, +) _LOGGER = logging.getLogger(__name__) -VELBUS_MESSAGE = "velbus.message" - CONFIG_SCHEMA = vol.Schema( {DOMAIN: vol.Schema({vol.Required(CONF_PORT): cv.string})}, extra=vol.ALLOW_EXTRA ) @@ -29,6 +35,9 @@ async def async_setup(hass, config): # Import from the configuration file if needed if DOMAIN not in config: return True + + _LOGGER.warning("Loading VELBUS via configuration.yaml is deprecated") + port = config[DOMAIN].get(CONF_PORT) data = {} @@ -39,57 +48,67 @@ async def async_setup(hass, config): DOMAIN, context={"source": SOURCE_IMPORT}, data=data ) ) - return True +async def velbus_connect_task( + controller: Velbus, hass: HomeAssistant, entry_id: str +) -> None: + """Task to offload the long running connect.""" + await controller.connect() + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Establish connection with velbus.""" hass.data.setdefault(DOMAIN, {}) - def callback(): - modules = controller.get_modules() - discovery_info = {"cntrl": controller} - for platform in PLATFORMS: - discovery_info[platform] = [] - for module in modules: - for channel in range(1, module.number_of_channels() + 1): - for platform in PLATFORMS: - if platform in module.get_categories(channel): - discovery_info[platform].append( - (module.get_module_address(), channel) - ) - hass.data[DOMAIN][entry.entry_id] = discovery_info + controller = Velbus(entry.data[CONF_PORT]) + hass.data[DOMAIN][entry.entry_id] = {} + hass.data[DOMAIN][entry.entry_id]["cntrl"] = controller + hass.data[DOMAIN][entry.entry_id]["tsk"] = hass.async_create_task( + velbus_connect_task(controller, hass, entry.entry_id) + ) - for platform in PLATFORMS: - hass.add_job(hass.config_entries.async_forward_entry_setup(entry, platform)) + hass.config_entries.async_setup_platforms(entry, PLATFORMS) - try: - controller = velbus.Controller(entry.data[CONF_PORT]) - controller.scan(callback) - except velbus.util.VelbusException as err: - _LOGGER.error("An error occurred: %s", err) - raise ConfigEntryNotReady from err + if hass.services.has_service(DOMAIN, SERVICE_SCAN): + return True - def syn_clock(self, service=None): - try: - controller.sync_clock() - except velbus.util.VelbusException as err: - _LOGGER.error("An error occurred: %s", err) + def check_entry_id(interface: str): + for entry in hass.config_entries.async_entries(DOMAIN): + if "port" in entry.data and entry.data["port"] == interface: + return entry.entry_id + raise vol.Invalid( + "The interface provided is not defined as a port in a Velbus integration" + ) - hass.services.async_register(DOMAIN, "sync_clock", syn_clock, schema=vol.Schema({})) + async def scan(call): + await hass.data[DOMAIN][call.data[CONF_INTERFACE]]["cntrl"].scan() - def set_memo_text(service): + hass.services.async_register( + DOMAIN, + SERVICE_SCAN, + scan, + vol.Schema({vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id)}), + ) + + async def syn_clock(call): + await hass.data[DOMAIN][call.data[CONF_INTERFACE]]["cntrl"].sync_clock() + + hass.services.async_register( + DOMAIN, + SERVICE_SYNC, + syn_clock, + vol.Schema({vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id)}), + ) + + async def set_memo_text(call): """Handle Memo Text service call.""" - module_address = service.data[CONF_ADDRESS] - memo_text = service.data[CONF_MEMO_TEXT] + memo_text = call.data[CONF_MEMO_TEXT] memo_text.hass = hass - try: - controller.get_module(module_address).set_memo_text( - memo_text.async_render() - ) - except velbus.util.VelbusException as err: - _LOGGER.error("An error occurred while setting memo text: %s", err) + await hass.data[DOMAIN][call.data[CONF_INTERFACE]]["cntrl"].get_module( + call.data[CONF_ADDRESS] + ).set_memo_text(memo_text.async_render()) hass.services.async_register( DOMAIN, @@ -97,6 +116,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: set_memo_text, vol.Schema( { + vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id), vol.Required(CONF_ADDRESS): vol.All( vol.Coerce(int), vol.Range(min=0, max=255) ), @@ -111,35 +131,34 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): """Remove the velbus connection.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - hass.data[DOMAIN][entry.entry_id]["cntrl"].stop() + await hass.data[DOMAIN][entry.entry_id]["cntrl"].stop() hass.data[DOMAIN].pop(entry.entry_id) if not hass.data[DOMAIN]: hass.data.pop(DOMAIN) + hass.services.async_remove(DOMAIN, SERVICE_SCAN) + hass.services.async_remove(DOMAIN, SERVICE_SYNC) + hass.services.async_remove(DOMAIN, SERVICE_SET_MEMO_TEXT) return unload_ok class VelbusEntity(Entity): """Representation of a Velbus entity.""" - def __init__(self, module, channel): + def __init__(self, channel): """Initialize a Velbus entity.""" - self._module = module self._channel = channel @property def unique_id(self): """Get unique ID.""" - serial = 0 - if self._module.serial == 0: - serial = self._module.get_module_address() - else: - serial = self._module.serial - return f"{serial}-{self._channel}" + if (serial := self._channel.get_module_serial()) == 0: + serial = self._channel.get_module_address() + return f"{serial}-{self._channel.get_channel_number()}" @property def name(self): """Return the display name of this entity.""" - return self._module.get_name(self._channel) + return self._channel.get_name() @property def should_poll(self): @@ -148,26 +167,24 @@ class VelbusEntity(Entity): async def async_added_to_hass(self): """Add listener for state changes.""" - self._module.on_status_update(self._channel, self._on_update) + self._channel.on_status_update(self._on_update) - def _on_update(self, state): - self.schedule_update_ha_state() + async def _on_update(self): + self.async_write_ha_state() @property def device_info(self): """Return the device info.""" return { "identifiers": { - (DOMAIN, self._module.get_module_address(), self._module.serial) + ( + DOMAIN, + self._channel.get_module_address(), + self._channel.get_module_serial(), + ) }, - "name": "{} ({})".format( - self._module.get_module_name(), self._module.get_module_address() - ), + "name": self._channel.get_full_name(), "manufacturer": "Velleman", - "model": self._module.get_module_type_name(), - "sw_version": "{}.{}-{}".format( - self._module.memory_map_version, - self._module.build_year, - self._module.build_week, - ), + "model": self._channel.get_module_type_name(), + "sw_version": self._channel.get_module_sw_version(), } diff --git a/homeassistant/components/velbus/binary_sensor.py b/homeassistant/components/velbus/binary_sensor.py index 74263d87234..be5d8d24698 100644 --- a/homeassistant/components/velbus/binary_sensor.py +++ b/homeassistant/components/velbus/binary_sensor.py @@ -6,13 +6,12 @@ from .const import DOMAIN async def async_setup_entry(hass, entry, async_add_entities): - """Set up Velbus binary sensor based on config_entry.""" + """Set up Velbus switch based on config_entry.""" + await hass.data[DOMAIN][entry.entry_id]["tsk"] cntrl = hass.data[DOMAIN][entry.entry_id]["cntrl"] - modules_data = hass.data[DOMAIN][entry.entry_id]["binary_sensor"] entities = [] - for address, channel in modules_data: - module = cntrl.get_module(address) - entities.append(VelbusBinarySensor(module, channel)) + for channel in cntrl.get_all("binary_sensor"): + entities.append(VelbusBinarySensor(channel)) async_add_entities(entities) @@ -20,6 +19,6 @@ class VelbusBinarySensor(VelbusEntity, BinarySensorEntity): """Representation of a Velbus Binary Sensor.""" @property - def is_on(self): + def is_on(self) -> bool: """Return true if the sensor is on.""" - return self._module.is_closed(self._channel) + return self._channel.is_closed() diff --git a/homeassistant/components/velbus/climate.py b/homeassistant/components/velbus/climate.py index 6ef91d65c91..68d92bf43d0 100644 --- a/homeassistant/components/velbus/climate.py +++ b/homeassistant/components/velbus/climate.py @@ -1,14 +1,12 @@ """Support for Velbus thermostat.""" import logging -from velbus.util import VelbusException - from homeassistant.components.climate import ClimateEntity from homeassistant.components.climate.const import ( HVAC_MODE_HEAT, SUPPORT_TARGET_TEMPERATURE, ) -from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS from . import VelbusEntity from .const import DOMAIN @@ -17,13 +15,12 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass, entry, async_add_entities): - """Set up Velbus binary sensor based on config_entry.""" + """Set up Velbus switch based on config_entry.""" + await hass.data[DOMAIN][entry.entry_id]["tsk"] cntrl = hass.data[DOMAIN][entry.entry_id]["cntrl"] - modules_data = hass.data[DOMAIN][entry.entry_id]["climate"] entities = [] - for address, channel in modules_data: - module = cntrl.get_module(address) - entities.append(VelbusClimate(module, channel)) + for channel in cntrl.get_all("climate"): + entities.append(VelbusClimate(channel)) async_add_entities(entities) @@ -37,15 +34,13 @@ class VelbusClimate(VelbusEntity, ClimateEntity): @property def temperature_unit(self): - """Return the unit this state is expressed in.""" - if self._module.get_unit(self._channel) == TEMP_CELSIUS: - return TEMP_CELSIUS - return TEMP_FAHRENHEIT + """Return the unit.""" + return TEMP_CELSIUS @property def current_temperature(self): """Return the current temperature.""" - return self._module.get_state(self._channel) + return self._channel.get_state() @property def hvac_mode(self): @@ -66,18 +61,14 @@ class VelbusClimate(VelbusEntity, ClimateEntity): @property def target_temperature(self): """Return the temperature we try to reach.""" - return self._module.get_climate_target() + return self._channel.get_climate_target() def set_temperature(self, **kwargs): """Set new target temperatures.""" temp = kwargs.get(ATTR_TEMPERATURE) if temp is None: return - try: - self._module.set_temp(temp) - except VelbusException as err: - _LOGGER.error("A Velbus error occurred: %s", err) - return + self._channel.set_temp(temp) self.schedule_update_ha_state() def set_hvac_mode(self, hvac_mode): diff --git a/homeassistant/components/velbus/config_flow.py b/homeassistant/components/velbus/config_flow.py index 93dd68c9eea..3ec5af14397 100644 --- a/homeassistant/components/velbus/config_flow.py +++ b/homeassistant/components/velbus/config_flow.py @@ -1,7 +1,8 @@ """Config flow for the Velbus platform.""" from __future__ import annotations -import velbus +import velbusaio +from velbusaio.exceptions import VelbusConnectionFailed import voluptuous as vol from homeassistant import config_entries @@ -33,14 +34,15 @@ class VelbusConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Create an entry async.""" return self.async_create_entry(title=name, data={CONF_PORT: prt}) - def _test_connection(self, prt): + async def _test_connection(self, prt): """Try to connect to the velbus with the port specified.""" try: - controller = velbus.Controller(prt) - except Exception: # pylint: disable=broad-except + controller = velbusaio.controller.Velbus(prt) + await controller.connect(True) + await controller.stop() + except VelbusConnectionFailed: self._errors[CONF_PORT] = "cannot_connect" return False - controller.stop() return True def _prt_in_configuration_exists(self, prt: str) -> bool: @@ -56,7 +58,7 @@ class VelbusConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): name = slugify(user_input[CONF_NAME]) prt = user_input[CONF_PORT] if not self._prt_in_configuration_exists(prt): - if self._test_connection(prt): + if await self._test_connection(prt): return self._create_device(name, prt) else: self._errors[CONF_PORT] = "already_configured" diff --git a/homeassistant/components/velbus/const.py b/homeassistant/components/velbus/const.py index d3987295fce..69c0c926136 100644 --- a/homeassistant/components/velbus/const.py +++ b/homeassistant/components/velbus/const.py @@ -2,6 +2,9 @@ DOMAIN = "velbus" +CONF_INTERFACE = "interface" CONF_MEMO_TEXT = "memo_text" +SERVICE_SCAN = "scan" +SERVICE_SYNC = "sync_clock" SERVICE_SET_MEMO_TEXT = "set_memo_text" diff --git a/homeassistant/components/velbus/cover.py b/homeassistant/components/velbus/cover.py index efe4fdc964b..1003d341c93 100644 --- a/homeassistant/components/velbus/cover.py +++ b/homeassistant/components/velbus/cover.py @@ -1,8 +1,6 @@ """Support for Velbus covers.""" import logging -from velbus.util import VelbusException - from homeassistant.components.cover import ( ATTR_POSITION, SUPPORT_CLOSE, @@ -19,13 +17,12 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass, entry, async_add_entities): - """Set up Velbus cover based on config_entry.""" + """Set up Velbus switch based on config_entry.""" + await hass.data[DOMAIN][entry.entry_id]["tsk"] cntrl = hass.data[DOMAIN][entry.entry_id]["cntrl"] - modules_data = hass.data[DOMAIN][entry.entry_id]["cover"] entities = [] - for address, channel in modules_data: - module = cntrl.get_module(address) - entities.append(VelbusCover(module, channel)) + for channel in cntrl.get_all("cover"): + entities.append(VelbusCover(channel)) async_add_entities(entities) @@ -35,16 +32,14 @@ class VelbusCover(VelbusEntity, CoverEntity): @property def supported_features(self): """Flag supported features.""" - if self._module.support_position(): + if self._channel.support_position(): return SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_STOP | SUPPORT_SET_POSITION return SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_STOP @property def is_closed(self): """Return if the cover is closed.""" - if self._module.get_position(self._channel) == 100: - return True - return False + return self._channel.is_closed() @property def current_cover_position(self): @@ -53,33 +48,21 @@ class VelbusCover(VelbusEntity, CoverEntity): None is unknown, 0 is closed, 100 is fully open Velbus: 100 = closed, 0 = open """ - pos = self._module.get_position(self._channel) + pos = self._channel.get_position() return 100 - pos - def open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs): """Open the cover.""" - try: - self._module.open(self._channel) - except VelbusException as err: - _LOGGER.error("A Velbus error occurred: %s", err) + await self._channel.open() - def close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs): """Close the cover.""" - try: - self._module.close(self._channel) - except VelbusException as err: - _LOGGER.error("A Velbus error occurred: %s", err) + await self._channel.close() - def stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs): """Stop the cover.""" - try: - self._module.stop(self._channel) - except VelbusException as err: - _LOGGER.error("A Velbus error occurred: %s", err) + await self._channel.stop() - def set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs): """Move the cover to a specific position.""" - try: - self._module.set(self._channel, (100 - kwargs[ATTR_POSITION])) - except VelbusException as err: - _LOGGER.error("A Velbus error occurred: %s", err) + self._channel.set_position(100 - kwargs[ATTR_POSITION]) diff --git a/homeassistant/components/velbus/light.py b/homeassistant/components/velbus/light.py index 4aebbb27953..482bdb53e94 100644 --- a/homeassistant/components/velbus/light.py +++ b/homeassistant/components/velbus/light.py @@ -1,8 +1,6 @@ """Support for Velbus light.""" import logging -from velbus.util import VelbusException - from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_FLASH, @@ -22,62 +20,61 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass, entry, async_add_entities): - """Set up Velbus light based on config_entry.""" + """Set up Velbus switch based on config_entry.""" + await hass.data[DOMAIN][entry.entry_id]["tsk"] cntrl = hass.data[DOMAIN][entry.entry_id]["cntrl"] - modules_data = hass.data[DOMAIN][entry.entry_id]["light"] entities = [] - for address, channel in modules_data: - module = cntrl.get_module(address) - entities.append(VelbusLight(module, channel)) + for channel in cntrl.get_all("light"): + entities.append(VelbusLight(channel, False)) + for channel in cntrl.get_all("led"): + entities.append(VelbusLight(channel, True)) async_add_entities(entities) class VelbusLight(VelbusEntity, LightEntity): """Representation of a Velbus light.""" + def __init__(self, channel, led): + """Initialize a light Velbus entity.""" + super().__init__(channel) + self._is_led = led + @property def name(self): """Return the display name of this entity.""" - if self._module.light_is_buttonled(self._channel): - return f"LED {self._module.get_name(self._channel)}" - return self._module.get_name(self._channel) + if self._is_led: + return f"LED {self._channel.get_name()}" + return self._channel.get_name() @property def supported_features(self): """Flag supported features.""" - if self._module.light_is_buttonled(self._channel): + if self._is_led: return SUPPORT_FLASH return SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION - @property - def entity_registry_enabled_default(self): - """Disable Button LEDs by default.""" - if self._module.light_is_buttonled(self._channel): - return False - return True - @property def is_on(self): """Return true if the light is on.""" - return self._module.is_on(self._channel) + return self._channel.is_on() @property def brightness(self): """Return the brightness of the light.""" - return int((self._module.get_dimmer_state(self._channel) * 255) / 100) + return int((self._channel.get_dimmer_state() * 255) / 100) - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Instruct the Velbus light to turn on.""" - if self._module.light_is_buttonled(self._channel): + if self._is_led: if ATTR_FLASH in kwargs: if kwargs[ATTR_FLASH] == FLASH_LONG: - attr, *args = "set_led_state", self._channel, "slow" + attr, *args = "set_led_state", "slow" elif kwargs[ATTR_FLASH] == FLASH_SHORT: - attr, *args = "set_led_state", self._channel, "fast" + attr, *args = "set_led_state", "fast" else: - attr, *args = "set_led_state", self._channel, "on" + attr, *args = "set_led_state", "on" else: - attr, *args = "set_led_state", self._channel, "on" + attr, *args = "set_led_state", "on" else: if ATTR_BRIGHTNESS in kwargs: # Make sure a low but non-zero value is not rounded down to zero @@ -87,33 +84,24 @@ class VelbusLight(VelbusEntity, LightEntity): brightness = max(int((kwargs[ATTR_BRIGHTNESS] * 100) / 255), 1) attr, *args = ( "set_dimmer_state", - self._channel, brightness, kwargs.get(ATTR_TRANSITION, 0), ) else: attr, *args = ( "restore_dimmer_state", - self._channel, kwargs.get(ATTR_TRANSITION, 0), ) - try: - getattr(self._module, attr)(*args) - except VelbusException as err: - _LOGGER.error("A Velbus error occurred: %s", err) + await getattr(self._channel, attr)(*args) - def turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Instruct the velbus light to turn off.""" - if self._module.light_is_buttonled(self._channel): - attr, *args = "set_led_state", self._channel, "off" + if self._is_led: + attr, *args = "set_led_state", "off" else: attr, *args = ( "set_dimmer_state", - self._channel, 0, kwargs.get(ATTR_TRANSITION, 0), ) - try: - getattr(self._module, attr)(*args) - except VelbusException as err: - _LOGGER.error("A Velbus error occurred: %s", err) + await getattr(self._channel, attr)(*args) diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index ba99415944d..61a297d401b 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -2,7 +2,7 @@ "domain": "velbus", "name": "Velbus", "documentation": "https://www.home-assistant.io/integrations/velbus", - "requirements": ["python-velbus==2.1.2"], + "requirements": ["velbus-aio==2021.9.1"], "config_flow": true, "codeowners": ["@Cereal2nd", "@brefra"], "iot_class": "local_push" diff --git a/homeassistant/components/velbus/sensor.py b/homeassistant/components/velbus/sensor.py index 3a4aa2302f6..32f016b8ce3 100644 --- a/homeassistant/components/velbus/sensor.py +++ b/homeassistant/components/velbus/sensor.py @@ -1,30 +1,39 @@ """Support for Velbus sensors.""" -from homeassistant.components.sensor import SensorEntity -from homeassistant.const import DEVICE_CLASS_POWER, ENERGY_KILO_WATT_HOUR +from __future__ import annotations + +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + SensorEntity, +) +from homeassistant.const import ( + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_TEMPERATURE, +) from . import VelbusEntity from .const import DOMAIN async def async_setup_entry(hass, entry, async_add_entities): - """Set up Velbus sensor based on config_entry.""" + """Set up Velbus switch based on config_entry.""" + await hass.data[DOMAIN][entry.entry_id]["tsk"] cntrl = hass.data[DOMAIN][entry.entry_id]["cntrl"] - modules_data = hass.data[DOMAIN][entry.entry_id]["sensor"] entities = [] - for address, channel in modules_data: - module = cntrl.get_module(address) - entities.append(VelbusSensor(module, channel)) - if module.get_class(channel) == "counter": - entities.append(VelbusSensor(module, channel, True)) + for channel in cntrl.get_all("sensor"): + entities.append(VelbusSensor(channel)) + if channel.is_counter_channel(): + entities.append(VelbusSensor(channel, True)) async_add_entities(entities) class VelbusSensor(VelbusEntity, SensorEntity): """Representation of a sensor.""" - def __init__(self, module, channel, counter=False): + def __init__(self, channel, counter=False): """Initialize a sensor Velbus entity.""" - super().__init__(module, channel) + super().__init__(channel) self._is_counter = counter @property @@ -35,28 +44,38 @@ class VelbusSensor(VelbusEntity, SensorEntity): unique_id = f"{unique_id}-counter" return unique_id + @property + def name(self): + """Return the name for the sensor.""" + name = super().name + if self._is_counter: + name = f"{name}-counter" + return name + @property def device_class(self): """Return the device class of the sensor.""" - if self._module.get_class(self._channel) == "counter" and not self._is_counter: - if self._module.get_counter_unit(self._channel) == ENERGY_KILO_WATT_HOUR: - return DEVICE_CLASS_POWER - return None - return self._module.get_class(self._channel) + if self._is_counter: + return DEVICE_CLASS_ENERGY + if self._channel.is_counter_channel(): + return DEVICE_CLASS_POWER + if self._channel.is_temperature(): + return DEVICE_CLASS_TEMPERATURE + return None @property def native_value(self): """Return the state of the sensor.""" if self._is_counter: - return self._module.get_counter_state(self._channel) - return self._module.get_state(self._channel) + return self._channel.get_counter_state() + return self._channel.get_state() @property def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" if self._is_counter: - return self._module.get_counter_unit(self._channel) - return self._module.get_unit(self._channel) + return self._channel.get_counter_unit() + return self._channel.get_unit() @property def icon(self): @@ -64,3 +83,10 @@ class VelbusSensor(VelbusEntity, SensorEntity): if self._is_counter: return "mdi:counter" return None + + @property + def state_class(self): + """Return the state class of this device.""" + if self._is_counter: + return STATE_CLASS_TOTAL_INCREASING + return STATE_CLASS_MEASUREMENT diff --git a/homeassistant/components/velbus/services.yaml b/homeassistant/components/velbus/services.yaml index 9fed172fad4..83af09409c1 100644 --- a/homeassistant/components/velbus/services.yaml +++ b/homeassistant/components/velbus/services.yaml @@ -1,6 +1,28 @@ sync_clock: name: Sync clock description: Sync the velbus modules clock to the Home Assistant clock, this is the same as the 'sync clock' from VelbusLink + fields: + interface: + name: Interface + description: The velbus interface to send the command to, this will be the same value as used during configuration + required: true + example: "192.168.1.5:27015" + default: '' + selector: + text: + +scan: + name: Scan + description: Scan the velbus modules, this will be need if you see unknown module warnings in the logs, or when you added new modules + fields: + interface: + name: Interface + description: The velbus interface to send the command to, this will be the same value as used during configuration + required: true + example: "192.168.1.5:27015" + default: '' + selector: + text: set_memo_text: name: Set memo text @@ -8,6 +30,14 @@ set_memo_text: Set the memo text to the display of modules like VMBGPO, VMBGPOD Be sure the page(s) of the module is configured to display the memo text. fields: + interface: + name: Interface + description: The velbus interface to send the command to, this will be the same value as used during configuration + required: true + example: "192.168.1.5:27015" + default: '' + selector: + text: address: name: Address description: > @@ -16,8 +46,8 @@ set_memo_text: required: true selector: number: - min: 0 - max: 255 + min: 1 + max: 254 memo_text: name: Memo text description: > diff --git a/homeassistant/components/velbus/switch.py b/homeassistant/components/velbus/switch.py index 91746b1513e..6b9609cc857 100644 --- a/homeassistant/components/velbus/switch.py +++ b/homeassistant/components/velbus/switch.py @@ -1,7 +1,6 @@ """Support for Velbus switches.""" import logging - -from velbus.util import VelbusException +from typing import Any from homeassistant.components.switch import SwitchEntity @@ -13,12 +12,11 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass, entry, async_add_entities): """Set up Velbus switch based on config_entry.""" + await hass.data[DOMAIN][entry.entry_id]["tsk"] cntrl = hass.data[DOMAIN][entry.entry_id]["cntrl"] - modules_data = hass.data[DOMAIN][entry.entry_id]["switch"] entities = [] - for address, channel in modules_data: - module = cntrl.get_module(address) - entities.append(VelbusSwitch(module, channel)) + for channel in cntrl.get_all("switch"): + entities.append(VelbusSwitch(channel)) async_add_entities(entities) @@ -26,20 +24,14 @@ class VelbusSwitch(VelbusEntity, SwitchEntity): """Representation of a switch.""" @property - def is_on(self): + def is_on(self) -> bool: """Return true if the switch is on.""" - return self._module.is_on(self._channel) + return self._channel.is_on() - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Instruct the switch to turn on.""" - try: - self._module.turn_on(self._channel) - except VelbusException as err: - _LOGGER.error("A Velbus error occurred: %s", err) + await self._channel.turn_on() - def turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Instruct the switch to turn off.""" - try: - self._module.turn_off(self._channel) - except VelbusException as err: - _LOGGER.error("A Velbus error occurred: %s", err) + await self._channel.turn_off() diff --git a/requirements_all.txt b/requirements_all.txt index 4e9dfed5423..df48625c0bb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1924,9 +1924,6 @@ python-telnet-vlc==2.0.1 # homeassistant.components.twitch python-twitch-client==0.6.0 -# homeassistant.components.velbus -python-velbus==2.1.2 - # homeassistant.components.vlc python-vlc==1.1.2 @@ -2350,6 +2347,9 @@ uvcclient==0.11.0 # homeassistant.components.vallox vallox-websocket-api==2.8.1 +# homeassistant.components.velbus +velbus-aio==2021.9.1 + # homeassistant.components.venstar venstarcolortouch==0.14 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e3c610c16d5..be80c7a7352 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1090,9 +1090,6 @@ python-tado==0.10.0 # homeassistant.components.twitch python-twitch-client==0.6.0 -# homeassistant.components.velbus -python-velbus==2.1.2 - # homeassistant.components.awair python_awair==0.2.1 @@ -1312,6 +1309,9 @@ url-normalize==1.4.1 # homeassistant.components.uvc uvcclient==0.11.0 +# homeassistant.components.velbus +velbus-aio==2021.9.1 + # homeassistant.components.venstar venstarcolortouch==0.14 diff --git a/tests/components/velbus/test_config_flow.py b/tests/components/velbus/test_config_flow.py index f4a95f0fdf9..723b6664fd7 100644 --- a/tests/components/velbus/test_config_flow.py +++ b/tests/components/velbus/test_config_flow.py @@ -1,7 +1,8 @@ """Tests for the Velbus config flow.""" -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, patch import pytest +from velbusaio.exceptions import VelbusConnectionFailed from homeassistant import data_entry_flow from homeassistant.components.velbus import config_flow @@ -16,15 +17,15 @@ PORT_TCP = "127.0.1.0.1:3788" @pytest.fixture(name="controller_assert") def mock_controller_assert(): """Mock the velbus controller with an assert.""" - with patch("velbus.Controller", side_effect=Exception()): + with patch("velbusaio.controller.Velbus", side_effect=VelbusConnectionFailed()): yield @pytest.fixture(name="controller") def mock_controller(): """Mock a successful velbus controller.""" - controller = Mock() - with patch("velbus.Controller", return_value=controller): + controller = AsyncMock() + with patch("velbusaio.controller.Velbus", return_value=controller): yield controller From e9eb76c7dba8f4b14e993b166b8c04c9e3584110 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Mon, 13 Sep 2021 09:31:35 +0300 Subject: [PATCH 371/843] Add switch support for RPC device (#56153) * Add switch support for RPC device * Apply review comments * Apply review comments --- homeassistant/components/shelly/__init__.py | 196 ++++++++++++++---- homeassistant/components/shelly/const.py | 6 +- homeassistant/components/shelly/cover.py | 8 +- .../components/shelly/device_trigger.py | 13 +- homeassistant/components/shelly/entity.py | 91 ++++++-- homeassistant/components/shelly/light.py | 97 +++++++-- homeassistant/components/shelly/logbook.py | 6 +- homeassistant/components/shelly/switch.py | 107 ++++++++-- homeassistant/components/shelly/utils.py | 25 ++- tests/components/shelly/conftest.py | 57 ++++- .../components/shelly/test_device_trigger.py | 10 +- tests/components/shelly/test_switch.py | 74 ++++++- 12 files changed, 568 insertions(+), 122 deletions(-) diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index c0d0016392f..6cd2a101a25 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -8,6 +8,7 @@ from typing import Any, Final, cast import aioshelly from aioshelly.block_device import BlockDevice +from aioshelly.rpc_device import RpcDevice import async_timeout import voluptuous as vol @@ -31,7 +32,7 @@ from .const import ( ATTR_CLICK_TYPE, ATTR_DEVICE, BATTERY_DEVICES_WITH_PERMANENT_CONNECTION, - COAP, + BLOCK, CONF_COAP_PORT, DATA_CONFIG_ENTRY, DEFAULT_COAP_PORT, @@ -42,6 +43,8 @@ from .const import ( POLLING_TIMEOUT_SEC, REST, REST_SENSORS_UPDATE_INTERVAL, + RPC, + RPC_RECONNECT_INTERVAL, SHBTN_MODELS, SLEEP_PERIOD_MULTIPLIER, UPDATE_PERIOD_MULTIPLIER, @@ -50,10 +53,13 @@ from .utils import ( get_block_device_name, get_block_device_sleep_period, get_coap_context, + get_device_entry_gen, + get_rpc_device_name, ) -PLATFORMS: Final = ["binary_sensor", "cover", "light", "sensor", "switch"] -SLEEPING_PLATFORMS: Final = ["binary_sensor", "sensor"] +BLOCK_PLATFORMS: Final = ["binary_sensor", "cover", "light", "sensor", "switch"] +BLOCK_SLEEPING_PLATFORMS: Final = ["binary_sensor", "sensor"] +RPC_PLATFORMS: Final = ["light", "switch"] _LOGGER: Final = logging.getLogger(__name__) COAP_SCHEMA: Final = vol.Schema( @@ -89,12 +95,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) return False - if entry.data.get("gen") == 2: - return True - hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id] = {} hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][DEVICE] = None + if get_device_entry_gen(entry) == 2: + return await async_setup_rpc_entry(hass, entry) + + return await async_setup_block_entry(hass, entry) + + +async def async_setup_block_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Shelly block based device from a config entry.""" temperature_unit = "C" if hass.config.units.is_metric else "F" options = aioshelly.common.ConnectionOptions( @@ -113,7 +124,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: False, ) - dev_reg = await device_registry.async_get_registry(hass) + dev_reg = device_registry.async_get(hass) device_entry = None if entry.unique_id is not None: device_entry = dev_reg.async_get_device( @@ -135,18 +146,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: data["model"] = device.settings["device"]["type"] hass.config_entries.async_update_entry(entry, data=data) - hass.async_create_task(async_device_setup(hass, entry, device)) + hass.async_create_task(async_block_device_setup(hass, entry, device)) if sleep_period == 0: # Not a sleeping device, finish setup - _LOGGER.debug("Setting up online device %s", entry.title) + _LOGGER.debug("Setting up online block device %s", entry.title) try: async with async_timeout.timeout(AIOSHELLY_DEVICE_TIMEOUT_SEC): await device.initialize() except (asyncio.TimeoutError, OSError) as err: raise ConfigEntryNotReady from err - await async_device_setup(hass, entry, device) + await async_block_device_setup(hass, entry, device) elif sleep_period is None or device_entry is None: # Need to get sleep info or first time sleeping device setup, wait for device hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][DEVICE] = device @@ -156,34 +167,61 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: device.subscribe_updates(_async_device_online) else: # Restore sensors for sleeping device - _LOGGER.debug("Setting up offline device %s", entry.title) - await async_device_setup(hass, entry, device) + _LOGGER.debug("Setting up offline block device %s", entry.title) + await async_block_device_setup(hass, entry, device) return True -async def async_device_setup( +async def async_block_device_setup( hass: HomeAssistant, entry: ConfigEntry, device: BlockDevice ) -> None: - """Set up a device that is online.""" + """Set up a block based device that is online.""" device_wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][ - COAP - ] = ShellyDeviceWrapper(hass, entry, device) - await device_wrapper.async_setup() + BLOCK + ] = BlockDeviceWrapper(hass, entry, device) + device_wrapper.async_setup() - platforms = SLEEPING_PLATFORMS + platforms = BLOCK_SLEEPING_PLATFORMS if not entry.data.get("sleep_period"): hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][ REST ] = ShellyDeviceRestWrapper(hass, device) - platforms = PLATFORMS + platforms = BLOCK_PLATFORMS hass.config_entries.async_setup_platforms(entry, platforms) -class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator): - """Wrapper for a Shelly device with Home Assistant specific functions.""" +async def async_setup_rpc_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Shelly RPC based device from a config entry.""" + options = aioshelly.common.ConnectionOptions( + entry.data[CONF_HOST], + entry.data.get(CONF_USERNAME), + entry.data.get(CONF_PASSWORD), + ) + + _LOGGER.debug("Setting up online RPC device %s", entry.title) + try: + async with async_timeout.timeout(AIOSHELLY_DEVICE_TIMEOUT_SEC): + device = await RpcDevice.create( + aiohttp_client.async_get_clientsession(hass), options + ) + except (asyncio.TimeoutError, OSError) as err: + raise ConfigEntryNotReady from err + + device_wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][ + RPC + ] = RpcDeviceWrapper(hass, entry, device) + device_wrapper.async_setup() + + hass.config_entries.async_setup_platforms(entry, RPC_PLATFORMS) + + return True + + +class BlockDeviceWrapper(update_coordinator.DataUpdateCoordinator): + """Wrapper for a Shelly block based device with Home Assistant specific functions.""" def __init__( self, hass: HomeAssistant, entry: ConfigEntry, device: BlockDevice @@ -283,7 +321,7 @@ class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator): # Sleeping device, no point polling it, just mark it unavailable raise update_coordinator.UpdateFailed("Sleeping device did not update") - _LOGGER.debug("Polling Shelly Device - %s", self.name) + _LOGGER.debug("Polling Shelly Block Device - %s", self.name) try: async with async_timeout.timeout(POLLING_TIMEOUT_SEC): await self.device.update() @@ -300,16 +338,14 @@ class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator): """Mac address of the device.""" return cast(str, self.entry.unique_id) - async def async_setup(self) -> None: + def async_setup(self) -> None: """Set up the wrapper.""" - dev_reg = await device_registry.async_get_registry(self.hass) - sw_version = self.device.settings["fw"] if self.device.initialized else "" + dev_reg = device_registry.async_get(self.hass) + sw_version = self.device.firmware_version if self.device.initialized else "" entry = dev_reg.async_get_or_create( config_entry_id=self.entry.entry_id, name=self.name, connections={(device_registry.CONNECTION_NETWORK_MAC, self.mac)}, - # This is duplicate but otherwise via_device can't work - identifiers={(DOMAIN, self.mac)}, manufacturer="Shelly", model=aioshelly.const.MODEL_NAMES.get(self.model, self.model), sw_version=sw_version, @@ -325,7 +361,7 @@ class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator): @callback def _handle_ha_stop(self, _event: Event) -> None: """Handle Home Assistant stopping.""" - _LOGGER.debug("Stopping ShellyDeviceWrapper for %s", self.name) + _LOGGER.debug("Stopping BlockDeviceWrapper for %s", self.name) self.shutdown() @@ -369,8 +405,15 @@ class ShellyDeviceRestWrapper(update_coordinator.DataUpdateCoordinator): async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - if entry.data.get("gen") == 2: - return True + if get_device_entry_gen(entry) == 2: + unload_ok = await hass.config_entries.async_unload_platforms( + entry, RPC_PLATFORMS + ) + if unload_ok: + hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][RPC].shutdown() + hass.data[DOMAIN][DATA_CONFIG_ENTRY].pop(entry.entry_id) + + return unload_ok device = hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id].get(DEVICE) if device is not None: @@ -378,15 +421,15 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: device.shutdown() return True - platforms = SLEEPING_PLATFORMS + platforms = BLOCK_SLEEPING_PLATFORMS if not entry.data.get("sleep_period"): hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][REST] = None - platforms = PLATFORMS + platforms = BLOCK_PLATFORMS unload_ok = await hass.config_entries.async_unload_platforms(entry, platforms) if unload_ok: - hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][COAP].shutdown() + hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][BLOCK].shutdown() hass.data[DOMAIN][DATA_CONFIG_ENTRY].pop(entry.entry_id) return unload_ok @@ -394,17 +437,94 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: def get_device_wrapper( hass: HomeAssistant, device_id: str -) -> ShellyDeviceWrapper | None: +) -> BlockDeviceWrapper | RpcDeviceWrapper | None: """Get a Shelly device wrapper for the given device id.""" if not hass.data.get(DOMAIN): return None for config_entry in hass.data[DOMAIN][DATA_CONFIG_ENTRY]: - wrapper: ShellyDeviceWrapper | None = hass.data[DOMAIN][DATA_CONFIG_ENTRY][ + block_wrapper: BlockDeviceWrapper | None = hass.data[DOMAIN][DATA_CONFIG_ENTRY][ config_entry - ].get(COAP) + ].get(BLOCK) - if wrapper and wrapper.device_id == device_id: - return wrapper + if block_wrapper and block_wrapper.device_id == device_id: + return block_wrapper + + rpc_wrapper: RpcDeviceWrapper | None = hass.data[DOMAIN][DATA_CONFIG_ENTRY][ + config_entry + ].get(RPC) + + if rpc_wrapper and rpc_wrapper.device_id == device_id: + return rpc_wrapper return None + + +class RpcDeviceWrapper(update_coordinator.DataUpdateCoordinator): + """Wrapper for a Shelly RPC based device with Home Assistant specific functions.""" + + def __init__( + self, hass: HomeAssistant, entry: ConfigEntry, device: RpcDevice + ) -> None: + """Initialize the Shelly device wrapper.""" + self.device_id: str | None = None + + device_name = get_rpc_device_name(device) if device.initialized else entry.title + super().__init__( + hass, + _LOGGER, + name=device_name, + update_interval=timedelta(seconds=RPC_RECONNECT_INTERVAL), + ) + self.entry = entry + self.device = device + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop) + ) + + async def _async_update_data(self) -> None: + """Fetch data.""" + if self.device.connected: + return + + try: + _LOGGER.debug("Reconnecting to Shelly RPC Device - %s", self.name) + async with async_timeout.timeout(AIOSHELLY_DEVICE_TIMEOUT_SEC): + await self.device.initialize() + except OSError as err: + raise update_coordinator.UpdateFailed("Device disconnected") from err + + @property + def model(self) -> str: + """Model of the device.""" + return cast(str, self.entry.data["model"]) + + @property + def mac(self) -> str: + """Mac address of the device.""" + return cast(str, self.entry.unique_id) + + def async_setup(self) -> None: + """Set up the wrapper.""" + dev_reg = device_registry.async_get(self.hass) + sw_version = self.device.firmware_version if self.device.initialized else "" + entry = dev_reg.async_get_or_create( + config_entry_id=self.entry.entry_id, + name=self.name, + connections={(device_registry.CONNECTION_NETWORK_MAC, self.mac)}, + manufacturer="Shelly", + model=aioshelly.const.MODEL_NAMES.get(self.model, self.model), + sw_version=sw_version, + ) + self.device_id = entry.id + self.device.subscribe_updates(self.async_set_updated_data) + + async def shutdown(self) -> None: + """Shutdown the wrapper.""" + await self.device.shutdown() + + async def _handle_ha_stop(self, _event: Event) -> None: + """Handle Home Assistant stopping.""" + _LOGGER.debug("Stopping RpcDeviceWrapper for %s", self.name) + await self.shutdown() diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index 5646086285d..14b56d2c584 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -4,11 +4,12 @@ from __future__ import annotations import re from typing import Final -COAP: Final = "coap" +BLOCK: Final = "block" DATA_CONFIG_ENTRY: Final = "config_entry" DEVICE: Final = "device" DOMAIN: Final = "shelly" REST: Final = "rest" +RPC: Final = "rpc" CONF_COAP_PORT: Final = "coap_port" DEFAULT_COAP_PORT: Final = 5683 @@ -44,6 +45,9 @@ SLEEP_PERIOD_MULTIPLIER: Final = 1.2 # Multiplier used to calculate the "update_interval" for non-sleeping devices. UPDATE_PERIOD_MULTIPLIER: Final = 2.2 +# Reconnect interval for GEN2 devices +RPC_RECONNECT_INTERVAL = 60 + # Shelly Air - Maximum work hours before lamp replacement SHAIR_MAX_WORK_HOURS: Final = 9000 diff --git a/homeassistant/components/shelly/cover.py b/homeassistant/components/shelly/cover.py index 40441ab74d3..47166ff2dbd 100644 --- a/homeassistant/components/shelly/cover.py +++ b/homeassistant/components/shelly/cover.py @@ -18,8 +18,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ShellyDeviceWrapper -from .const import COAP, DATA_CONFIG_ENTRY, DOMAIN +from . import BlockDeviceWrapper +from .const import BLOCK, DATA_CONFIG_ENTRY, DOMAIN from .entity import ShellyBlockEntity @@ -29,7 +29,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up cover for device.""" - wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][COAP] + wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][BLOCK] blocks = [block for block in wrapper.device.blocks if block.type == "roller"] if not blocks: @@ -43,7 +43,7 @@ class ShellyCover(ShellyBlockEntity, CoverEntity): _attr_device_class = DEVICE_CLASS_SHUTTER - def __init__(self, wrapper: ShellyDeviceWrapper, block: Block) -> None: + def __init__(self, wrapper: BlockDeviceWrapper, block: Block) -> None: """Initialize light.""" super().__init__(wrapper, block) self.control_result: dict[str, Any] | None = None diff --git a/homeassistant/components/shelly/device_trigger.py b/homeassistant/components/shelly/device_trigger.py index 5d90a10dabc..552d1d62032 100644 --- a/homeassistant/components/shelly/device_trigger.py +++ b/homeassistant/components/shelly/device_trigger.py @@ -25,7 +25,7 @@ from homeassistant.const import ( from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.helpers.typing import ConfigType -from . import get_device_wrapper +from . import RpcDeviceWrapper, get_device_wrapper from .const import ( ATTR_CHANNEL, ATTR_CLICK_TYPE, @@ -55,6 +55,10 @@ async def async_validate_trigger_config( # if device is available verify parameters against device capabilities wrapper = get_device_wrapper(hass, config[CONF_DEVICE_ID]) + + if isinstance(wrapper, RpcDeviceWrapper): + return config + if not wrapper or not wrapper.device.initialized: return config @@ -76,12 +80,15 @@ async def async_get_triggers( hass: HomeAssistant, device_id: str ) -> list[dict[str, Any]]: """List device triggers for Shelly devices.""" - triggers = [] - wrapper = get_device_wrapper(hass, device_id) if not wrapper: raise InvalidDeviceAutomationConfig(f"Device not found: {device_id}") + if isinstance(wrapper, RpcDeviceWrapper): + return [] + + triggers = [] + if wrapper.model in SHBTN_MODELS: for trigger in SHBTN_INPUTS_EVENTS_TYPES: triggers.append( diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index a7b75116132..fd8dfe281ff 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -23,9 +23,13 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import StateType -from . import ShellyDeviceRestWrapper, ShellyDeviceWrapper -from .const import AIOSHELLY_DEVICE_TIMEOUT_SEC, COAP, DATA_CONFIG_ENTRY, DOMAIN, REST -from .utils import async_remove_shelly_entity, get_entity_name +from . import BlockDeviceWrapper, RpcDeviceWrapper, ShellyDeviceRestWrapper +from .const import AIOSHELLY_DEVICE_TIMEOUT_SEC, BLOCK, DATA_CONFIG_ENTRY, DOMAIN, REST +from .utils import ( + async_remove_shelly_entity, + get_block_entity_name, + get_rpc_entity_name, +) _LOGGER: Final = logging.getLogger(__name__) @@ -38,9 +42,9 @@ async def async_setup_entry_attribute_entities( sensor_class: Callable, ) -> None: """Set up entities for attributes.""" - wrapper: ShellyDeviceWrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][ + wrapper: BlockDeviceWrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][ config_entry.entry_id - ][COAP] + ][BLOCK] if wrapper.device.initialized: await async_setup_block_attribute_entities( @@ -55,7 +59,7 @@ async def async_setup_entry_attribute_entities( async def async_setup_block_attribute_entities( hass: HomeAssistant, async_add_entities: AddEntitiesCallback, - wrapper: ShellyDeviceWrapper, + wrapper: BlockDeviceWrapper, sensors: dict[tuple[str, str], BlockAttributeDescription], sensor_class: Callable, ) -> None: @@ -99,7 +103,7 @@ async def async_restore_block_attribute_entities( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - wrapper: ShellyDeviceWrapper, + wrapper: BlockDeviceWrapper, sensors: dict[tuple[str, str], BlockAttributeDescription], sensor_class: Callable, ) -> None: @@ -198,13 +202,13 @@ class RestAttributeDescription: class ShellyBlockEntity(entity.Entity): - """Helper class to represent a block.""" + """Helper class to represent a block entity.""" - def __init__(self, wrapper: ShellyDeviceWrapper, block: Block) -> None: + def __init__(self, wrapper: BlockDeviceWrapper, block: Block) -> None: """Initialize Shelly entity.""" self.wrapper = wrapper self.block = block - self._name = get_entity_name(wrapper.device, block) + self._name = get_block_entity_name(wrapper.device, block) @property def name(self) -> str: @@ -263,12 +267,67 @@ class ShellyBlockEntity(entity.Entity): return None +class ShellyRpcEntity(entity.Entity): + """Helper class to represent a rpc entity.""" + + def __init__(self, wrapper: RpcDeviceWrapper, key: str) -> None: + """Initialize Shelly entity.""" + self.wrapper = wrapper + self.key = key + self._attr_should_poll = False + self._attr_device_info = { + "connections": {(device_registry.CONNECTION_NETWORK_MAC, wrapper.mac)} + } + self._attr_unique_id = f"{wrapper.mac}-{key}" + self._attr_name = get_rpc_entity_name(wrapper.device, key) + + @property + def available(self) -> bool: + """Available.""" + return self.wrapper.device.connected + + async def async_added_to_hass(self) -> None: + """When entity is added to HASS.""" + self.async_on_remove(self.wrapper.async_add_listener(self._update_callback)) + + async def async_update(self) -> None: + """Update entity with latest info.""" + await self.wrapper.async_request_refresh() + + @callback + def _update_callback(self) -> None: + """Handle device update.""" + self.async_write_ha_state() + + async def call_rpc(self, method: str, params: Any) -> Any: + """Call RPC method.""" + _LOGGER.debug( + "Call RPC for entity %s, method: %s, params: %s", + self.name, + method, + params, + ) + try: + async with async_timeout.timeout(AIOSHELLY_DEVICE_TIMEOUT_SEC): + return await self.wrapper.device.call_rpc(method, params) + except asyncio.TimeoutError as err: + _LOGGER.error( + "Call RPC for entity %s failed, method: %s, params: %s, error: %s", + self.name, + method, + params, + repr(err), + ) + self.wrapper.last_update_success = False + return None + + class ShellyBlockAttributeEntity(ShellyBlockEntity, entity.Entity): """Helper class to represent a block attribute.""" def __init__( self, - wrapper: ShellyDeviceWrapper, + wrapper: BlockDeviceWrapper, block: Block, attribute: str, description: BlockAttributeDescription, @@ -285,7 +344,7 @@ class ShellyBlockAttributeEntity(ShellyBlockEntity, entity.Entity): self._unit: None | str | Callable[[dict], str] = unit self._unique_id: str = f"{super().unique_id}-{self.attribute}" - self._name = get_entity_name(wrapper.device, block, self.description.name) + self._name = get_block_entity_name(wrapper.device, block, self.description.name) @property def unique_id(self) -> str: @@ -346,7 +405,7 @@ class ShellyRestAttributeEntity(update_coordinator.CoordinatorEntity): def __init__( self, - wrapper: ShellyDeviceWrapper, + wrapper: BlockDeviceWrapper, attribute: str, description: RestAttributeDescription, ) -> None: @@ -355,7 +414,7 @@ class ShellyRestAttributeEntity(update_coordinator.CoordinatorEntity): self.wrapper = wrapper self.attribute = attribute self.description = description - self._name = get_entity_name(wrapper.device, None, self.description.name) + self._name = get_block_entity_name(wrapper.device, None, self.description.name) self._last_value = None @property @@ -419,7 +478,7 @@ class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity, RestoreEnti # pylint: disable=super-init-not-called def __init__( self, - wrapper: ShellyDeviceWrapper, + wrapper: BlockDeviceWrapper, block: Block | None, attribute: str, description: BlockAttributeDescription, @@ -440,7 +499,7 @@ class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity, RestoreEnti self._unit = self._unit(block.info(attribute)) self._unique_id = f"{self.wrapper.mac}-{block.description}-{attribute}" - self._name = get_entity_name( + self._name = get_block_entity_name( self.wrapper.device, block, self.description.name ) elif entry is not None: diff --git a/homeassistant/components/shelly/light.py b/homeassistant/components/shelly/light.py index 9ecc16ecc5a..bb636013361 100644 --- a/homeassistant/components/shelly/light.py +++ b/homeassistant/components/shelly/light.py @@ -33,10 +33,10 @@ from homeassistant.util.color import ( color_temperature_mired_to_kelvin, ) -from . import ShellyDeviceWrapper +from . import BlockDeviceWrapper, RpcDeviceWrapper from .const import ( AIOSHELLY_DEVICE_TIMEOUT_SEC, - COAP, + BLOCK, DATA_CONFIG_ENTRY, DOMAIN, FIRMWARE_PATTERN, @@ -46,11 +46,12 @@ from .const import ( LIGHT_TRANSITION_MIN_FIRMWARE_DATE, MAX_TRANSITION_TIME, MODELS_SUPPORTING_LIGHT_TRANSITION, + RPC, SHBLB_1_RGB_EFFECTS, STANDARD_RGB_EFFECTS, ) -from .entity import ShellyBlockEntity -from .utils import async_remove_shelly_entity +from .entity import ShellyBlockEntity, ShellyRpcEntity +from .utils import async_remove_shelly_entity, get_device_entry_gen _LOGGER: Final = logging.getLogger(__name__) @@ -61,33 +62,75 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up lights for device.""" - wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][COAP] + if get_device_entry_gen(config_entry) == 2: + return await async_setup_rpc_entry(hass, config_entry, async_add_entities) + + return await async_setup_block_entry(hass, config_entry, async_add_entities) + + +async def async_setup_block_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up entities for block device.""" + wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][BLOCK] blocks = [] + assert wrapper.device.blocks for block in wrapper.device.blocks: if block.type == "light": blocks.append(block) elif block.type == "relay": - appliance_type = wrapper.device.settings["relays"][int(block.channel)].get( + app_type = wrapper.device.settings["relays"][int(block.channel)].get( "appliance_type" ) - if appliance_type and appliance_type.lower() == "light": - blocks.append(block) - unique_id = ( - f'{wrapper.device.shelly["mac"]}-{block.type}_{block.channel}' - ) - await async_remove_shelly_entity(hass, "switch", unique_id) + if not app_type or app_type.lower() != "light": + continue + + blocks.append(block) + assert wrapper.device.shelly + unique_id = f"{wrapper.mac}-{block.type}_{block.channel}" + await async_remove_shelly_entity(hass, "switch", unique_id) if not blocks: return - async_add_entities(ShellyLight(wrapper, block) for block in blocks) + async_add_entities(BlockShellyLight(wrapper, block) for block in blocks) -class ShellyLight(ShellyBlockEntity, LightEntity): - """Switch that controls a relay block on Shelly devices.""" +async def async_setup_rpc_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up entities for RPC device.""" + wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][RPC] - def __init__(self, wrapper: ShellyDeviceWrapper, block: Block) -> None: + switch_keys = [] + for i in range(4): + key = f"switch:{i}" + if not wrapper.device.status.get(key): + continue + + con_types = wrapper.device.config["sys"]["ui_data"].get("consumption_types") + if con_types is None or con_types[i] != "lights": + continue + + switch_keys.append((key, i)) + unique_id = f"{wrapper.mac}-{key}" + await async_remove_shelly_entity(hass, "switch", unique_id) + + if not switch_keys: + return + + async_add_entities(RpcShellyLight(wrapper, key, id_) for key, id_ in switch_keys) + + +class BlockShellyLight(ShellyBlockEntity, LightEntity): + """Entity that controls a light on block based Shelly devices.""" + + def __init__(self, wrapper: BlockDeviceWrapper, block: Block) -> None: """Initialize light.""" super().__init__(wrapper, block) self.control_result: dict[str, Any] | None = None @@ -369,3 +412,25 @@ class ShellyLight(ShellyBlockEntity, LightEntity): self.control_result = None self.mode_result = None super()._update_callback() + + +class RpcShellyLight(ShellyRpcEntity, LightEntity): + """Entity that controls a light on RPC based Shelly devices.""" + + def __init__(self, wrapper: RpcDeviceWrapper, key: str, id_: int) -> None: + """Initialize light.""" + super().__init__(wrapper, key) + self._id = id_ + + @property + def is_on(self) -> bool: + """If light is on.""" + return bool(self.wrapper.device.status[self.key]["output"]) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on light.""" + await self.call_rpc("Switch.Set", {"id": self._id, "on": True}) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off light.""" + await self.call_rpc("Switch.Set", {"id": self._id, "on": False}) diff --git a/homeassistant/components/shelly/logbook.py b/homeassistant/components/shelly/logbook.py index ca4818085d0..d58691439cf 100644 --- a/homeassistant/components/shelly/logbook.py +++ b/homeassistant/components/shelly/logbook.py @@ -7,7 +7,7 @@ from homeassistant.const import ATTR_DEVICE_ID from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.typing import EventType -from . import get_device_wrapper +from . import RpcDeviceWrapper, get_device_wrapper from .const import ( ATTR_CHANNEL, ATTR_CLICK_TYPE, @@ -29,6 +29,10 @@ def async_describe_events( def async_describe_shelly_click_event(event: EventType) -> dict[str, str]: """Describe shelly.click logbook event.""" wrapper = get_device_wrapper(hass, event.data[ATTR_DEVICE_ID]) + + if isinstance(wrapper, RpcDeviceWrapper): + return {} + if wrapper and wrapper.device.initialized: device_name = get_block_device_name(wrapper.device) else: diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index b36bcd42d59..97f44d9c40e 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -10,10 +10,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ShellyDeviceWrapper -from .const import COAP, DATA_CONFIG_ENTRY, DOMAIN -from .entity import ShellyBlockEntity -from .utils import async_remove_shelly_entity +from . import BlockDeviceWrapper, RpcDeviceWrapper +from .const import BLOCK, DATA_CONFIG_ENTRY, DOMAIN, RPC +from .entity import ShellyBlockEntity, ShellyRpcEntity +from .utils import async_remove_shelly_entity, get_device_entry_gen async def async_setup_entry( @@ -22,7 +22,19 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up switches for device.""" - wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][COAP] + if get_device_entry_gen(config_entry) == 2: + return await async_setup_rpc_entry(hass, config_entry, async_add_entities) + + return await async_setup_block_entry(hass, config_entry, async_add_entities) + + +async def async_setup_block_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up entities for block device.""" + wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][BLOCK] # In roller mode the relay blocks exist but do not contain required info if ( @@ -32,32 +44,59 @@ async def async_setup_entry( return relay_blocks = [] + assert wrapper.device.blocks for block in wrapper.device.blocks: - if block.type == "relay": - appliance_type = wrapper.device.settings["relays"][int(block.channel)].get( - "appliance_type" - ) - if not appliance_type or appliance_type.lower() != "light": - relay_blocks.append(block) - unique_id = ( - f'{wrapper.device.shelly["mac"]}-{block.type}_{block.channel}' - ) - await async_remove_shelly_entity( - hass, - "light", - unique_id, - ) + if block.type != "relay": + continue + + app_type = wrapper.device.settings["relays"][int(block.channel)].get( + "appliance_type" + ) + if app_type and app_type.lower() == "light": + continue + + relay_blocks.append(block) + unique_id = f"{wrapper.mac}-{block.type}_{block.channel}" + await async_remove_shelly_entity(hass, "light", unique_id) if not relay_blocks: return - async_add_entities(RelaySwitch(wrapper, block) for block in relay_blocks) + async_add_entities(BlockRelaySwitch(wrapper, block) for block in relay_blocks) -class RelaySwitch(ShellyBlockEntity, SwitchEntity): - """Switch that controls a relay block on Shelly devices.""" +async def async_setup_rpc_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up entities for RPC device.""" + wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][RPC] - def __init__(self, wrapper: ShellyDeviceWrapper, block: Block) -> None: + switch_keys = [] + for i in range(4): + key = f"switch:{i}" + if not wrapper.device.status.get(key): + continue + + con_types = wrapper.device.config["sys"]["ui_data"].get("consumption_types") + if con_types is not None and con_types[i] == "lights": + continue + + switch_keys.append((key, i)) + unique_id = f"{wrapper.mac}-{key}" + await async_remove_shelly_entity(hass, "light", unique_id) + + if not switch_keys: + return + + async_add_entities(RpcRelaySwitch(wrapper, key, id_) for key, id_ in switch_keys) + + +class BlockRelaySwitch(ShellyBlockEntity, SwitchEntity): + """Entity that controls a relay on Block based Shelly devices.""" + + def __init__(self, wrapper: BlockDeviceWrapper, block: Block) -> None: """Initialize relay switch.""" super().__init__(wrapper, block) self.control_result: dict[str, Any] | None = None @@ -85,3 +124,25 @@ class RelaySwitch(ShellyBlockEntity, SwitchEntity): """When device updates, clear control result that overrides state.""" self.control_result = None super()._update_callback() + + +class RpcRelaySwitch(ShellyRpcEntity, SwitchEntity): + """Entity that controls a relay on RPC based Shelly devices.""" + + def __init__(self, wrapper: RpcDeviceWrapper, key: str, id_: int) -> None: + """Initialize relay switch.""" + super().__init__(wrapper, key) + self._id = id_ + + @property + def is_on(self) -> bool: + """If switch is on.""" + return bool(self.wrapper.device.status[self.key]["output"]) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on relay.""" + await self.call_rpc("Switch.Set", {"id": self._id, "on": True}) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off relay.""" + await self.call_rpc("Switch.Set", {"id": self._id, "on": False}) diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 405c34e6eb9..10046ccd4b0 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -9,6 +9,7 @@ from aioshelly.block_device import BLOCK_VALUE_UNIT, COAP, Block, BlockDevice from aioshelly.const import MODEL_NAMES from aioshelly.rpc_device import RpcDevice +from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP, TEMP_CELSIUS, TEMP_FAHRENHEIT from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import singleton @@ -81,12 +82,12 @@ def get_number_of_channels(device: BlockDevice, block: Block) -> int: return channels or 1 -def get_entity_name( +def get_block_entity_name( device: BlockDevice, block: Block | None, description: str | None = None, ) -> str: - """Naming for switch and sensors.""" + """Naming for block based switch and sensors.""" channel_name = get_device_channel_name(device, block) if description: @@ -237,3 +238,23 @@ def get_model_name(info: dict[str, Any]) -> str: return cast(str, MODEL_NAMES.get(info["model"], info["model"])) return cast(str, MODEL_NAMES.get(info["type"], info["type"])) + + +def get_rpc_entity_name( + device: RpcDevice, key: str, description: str | None = None +) -> str: + """Naming for RPC based switch and sensors.""" + entity_name: str | None = device.config[key].get("name") + + if entity_name is None: + entity_name = f"{get_rpc_device_name(device)} {key.replace(':', '_')}" + + if description: + return f"{entity_name} {description}" + + return entity_name + + +def get_device_entry_gen(entry: ConfigEntry) -> int: + """Return the device generation from config entry.""" + return entry.data.get("gen", 1) diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 71157124806..e38dd252b3a 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -3,12 +3,13 @@ from unittest.mock import AsyncMock, Mock, patch import pytest -from homeassistant.components.shelly import ShellyDeviceWrapper +from homeassistant.components.shelly import BlockDeviceWrapper, RpcDeviceWrapper from homeassistant.components.shelly.const import ( - COAP, + BLOCK, DATA_CONFIG_ENTRY, DOMAIN, EVENT_SHELLY_CLICK, + RPC, ) from homeassistant.setup import async_setup_component @@ -54,6 +55,13 @@ MOCK_BLOCKS = [ ), ] +MOCK_CONFIG = { + "switch:0": {"name": "test switch_0"}, + "sys": {"ui_data": {}}, + "wifi": { + "ap": {"ssid": "Test name"}, + }, +} MOCK_SHELLY = { "mac": "test-mac", @@ -62,6 +70,10 @@ MOCK_SHELLY = { "num_outputs": 2, } +MOCK_STATUS = { + "switch:0": {"output": True}, +} + @pytest.fixture(autouse=True) def mock_coap(): @@ -104,6 +116,7 @@ async def coap_wrapper(hass): blocks=MOCK_BLOCKS, settings=MOCK_SETTINGS, shelly=MOCK_SHELLY, + firmware_version="some fw string", update=AsyncMock(), initialized=True, ) @@ -111,9 +124,43 @@ async def coap_wrapper(hass): hass.data[DOMAIN] = {DATA_CONFIG_ENTRY: {}} hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id] = {} wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][ - COAP - ] = ShellyDeviceWrapper(hass, config_entry, device) + BLOCK + ] = BlockDeviceWrapper(hass, config_entry, device) - await wrapper.async_setup() + wrapper.async_setup() + + return wrapper + + +@pytest.fixture +async def rpc_wrapper(hass): + """Setups a coap wrapper with mocked device.""" + await async_setup_component(hass, "shelly", {}) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={"sleep_period": 0, "model": "SNSW-001P16EU", "gen": 2}, + unique_id="12345678", + ) + config_entry.add_to_hass(hass) + + device = Mock( + call_rpc=AsyncMock(), + config=MOCK_CONFIG, + shelly=MOCK_SHELLY, + status=MOCK_STATUS, + firmware_version="some fw string", + update=AsyncMock(), + initialized=True, + shutdown=AsyncMock(), + ) + + hass.data[DOMAIN] = {DATA_CONFIG_ENTRY: {}} + hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id] = {} + wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][ + RPC + ] = RpcDeviceWrapper(hass, config_entry, device) + + wrapper.async_setup() return wrapper diff --git a/tests/components/shelly/test_device_trigger.py b/tests/components/shelly/test_device_trigger.py index bedf4abc0f2..bf1529e4aaf 100644 --- a/tests/components/shelly/test_device_trigger.py +++ b/tests/components/shelly/test_device_trigger.py @@ -8,11 +8,11 @@ from homeassistant.components import automation from homeassistant.components.device_automation.exceptions import ( InvalidDeviceAutomationConfig, ) -from homeassistant.components.shelly import ShellyDeviceWrapper +from homeassistant.components.shelly import BlockDeviceWrapper from homeassistant.components.shelly.const import ( ATTR_CHANNEL, ATTR_CLICK_TYPE, - COAP, + BLOCK, CONF_SUBTYPE, DATA_CONFIG_ENTRY, DOMAIN, @@ -79,10 +79,10 @@ async def test_get_triggers_button(hass): hass.data[DOMAIN] = {DATA_CONFIG_ENTRY: {}} hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id] = {} coap_wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][ - COAP - ] = ShellyDeviceWrapper(hass, config_entry, device) + BLOCK + ] = BlockDeviceWrapper(hass, config_entry, device) - await coap_wrapper.async_setup() + coap_wrapper.async_setup() expected_triggers = [ { diff --git a/tests/components/shelly/test_switch.py b/tests/components/shelly/test_switch.py index b1dcc05bb80..fc61102507b 100644 --- a/tests/components/shelly/test_switch.py +++ b/tests/components/shelly/test_switch.py @@ -11,8 +11,8 @@ from homeassistant.const import ( RELAY_BLOCK_ID = 0 -async def test_services(hass, coap_wrapper): - """Test device turn on/off services.""" +async def test_block_device_services(hass, coap_wrapper): + """Test block device turn on/off services.""" assert coap_wrapper hass.async_create_task( @@ -37,8 +37,8 @@ async def test_services(hass, coap_wrapper): assert hass.states.get("switch.test_name_channel_1").state == STATE_OFF -async def test_update(hass, coap_wrapper, monkeypatch): - """Test device update.""" +async def test_block_device_update(hass, coap_wrapper, monkeypatch): + """Test block device update.""" assert coap_wrapper hass.async_create_task( @@ -61,8 +61,8 @@ async def test_update(hass, coap_wrapper, monkeypatch): assert hass.states.get("switch.test_name_channel_1").state == STATE_ON -async def test_no_relay_blocks(hass, coap_wrapper, monkeypatch): - """Test device without relay blocks.""" +async def test_block_device_no_relay_blocks(hass, coap_wrapper, monkeypatch): + """Test block device without relay blocks.""" assert coap_wrapper monkeypatch.setattr(coap_wrapper.device.blocks[RELAY_BLOCK_ID], "type", "roller") @@ -73,8 +73,8 @@ async def test_no_relay_blocks(hass, coap_wrapper, monkeypatch): assert hass.states.get("switch.test_name_channel_1") is None -async def test_device_mode_roller(hass, coap_wrapper, monkeypatch): - """Test switch device in roller mode.""" +async def test_block_device_mode_roller(hass, coap_wrapper, monkeypatch): + """Test block device in roller mode.""" assert coap_wrapper monkeypatch.setitem(coap_wrapper.device.settings, "mode", "roller") @@ -83,3 +83,61 @@ async def test_device_mode_roller(hass, coap_wrapper, monkeypatch): ) await hass.async_block_till_done() assert hass.states.get("switch.test_name_channel_1") is None + + +async def test_block_device_app_type_light(hass, coap_wrapper, monkeypatch): + """Test block device in app type set to light mode.""" + assert coap_wrapper + + monkeypatch.setitem( + coap_wrapper.device.settings["relays"][0], "appliance_type", "light" + ) + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(coap_wrapper.entry, SWITCH_DOMAIN) + ) + await hass.async_block_till_done() + assert hass.states.get("switch.test_name_channel_1") is None + + +async def test_rpc_device_services(hass, rpc_wrapper, monkeypatch): + """Test RPC device turn on/off services.""" + assert rpc_wrapper + + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(rpc_wrapper.entry, SWITCH_DOMAIN) + ) + await hass.async_block_till_done() + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.test_switch_0"}, + blocking=True, + ) + assert hass.states.get("switch.test_switch_0").state == STATE_ON + + monkeypatch.setitem(rpc_wrapper.device.status["switch:0"], "output", False) + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "switch.test_switch_0"}, + blocking=True, + ) + rpc_wrapper.async_set_updated_data("") + assert hass.states.get("switch.test_switch_0").state == STATE_OFF + + +async def test_rpc_device_switch_type_lights_mode(hass, rpc_wrapper, monkeypatch): + """Test RPC device with switch in consumption type lights mode.""" + assert rpc_wrapper + + monkeypatch.setitem( + rpc_wrapper.device.config["sys"]["ui_data"], + "consumption_types", + ["lights"], + ) + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(rpc_wrapper.entry, SWITCH_DOMAIN) + ) + await hass.async_block_till_done() + assert hass.states.get("switch.test_switch_0") is None From d2a9f7904a5e8b583d1ce41deab9919e118849b9 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 13 Sep 2021 10:02:24 +0200 Subject: [PATCH 372/843] Include end time of statistics data points in API response (#56063) * Include end time of statistics data points in API response * Correct typing * Update tests --- homeassistant/components/recorder/models.py | 22 ++++++++++- .../components/recorder/statistics.py | 39 ++++++++++--------- tests/components/history/test_init.py | 1 + tests/components/recorder/test_statistics.py | 5 +++ tests/components/sensor/test_recorder.py | 39 +++++++++++++++++++ 5 files changed, 87 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py index e33f2e62da2..5132dfc72bb 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import datetime import json import logging -from typing import TypedDict +from typing import TypedDict, overload from sqlalchemy import ( Boolean, @@ -391,6 +391,16 @@ class StatisticsRuns(Base): # type: ignore ) +@overload +def process_timestamp(ts: None) -> None: + ... + + +@overload +def process_timestamp(ts: datetime) -> datetime: + ... + + def process_timestamp(ts): """Process a timestamp into datetime object.""" if ts is None: @@ -401,6 +411,16 @@ def process_timestamp(ts): return dt_util.as_utc(ts) +@overload +def process_timestamp_to_utc_isoformat(ts: None) -> None: + ... + + +@overload +def process_timestamp_to_utc_isoformat(ts: datetime) -> str: + ... + + def process_timestamp_to_utc_isoformat(ts: datetime | None) -> str | None: """Process a timestamp into UTC isotime.""" if ts is None: diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index c8f4e48563c..c1b924ceeec 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -32,6 +32,7 @@ from .models import ( Statistics, StatisticsMeta, StatisticsRuns, + process_timestamp, process_timestamp_to_utc_isoformat, ) from .util import execute, retryable_database_job, session_scope @@ -437,9 +438,6 @@ def _sorted_statistics_to_dict( for stat_id in statistic_ids: result[stat_id] = [] - # Called in a tight loop so cache the function here - _process_timestamp_to_utc_isoformat = process_timestamp_to_utc_isoformat - # Append all statistic entries, and do unit conversion for meta_id, group in groupby(stats, lambda stat: stat.metadata_id): # type: ignore unit = metadata[meta_id]["unit_of_measurement"] @@ -450,21 +448,26 @@ def _sorted_statistics_to_dict( else: convert = no_conversion ent_results = result[meta_id] - ent_results.extend( - { - "statistic_id": statistic_id, - "start": _process_timestamp_to_utc_isoformat(db_state.start), - "mean": convert(db_state.mean, units), - "min": convert(db_state.min, units), - "max": convert(db_state.max, units), - "last_reset": _process_timestamp_to_utc_isoformat(db_state.last_reset), - "state": convert(db_state.state, units), - "sum": (_sum := convert(db_state.sum, units)), - "sum_increase": (inc := convert(db_state.sum_increase, units)), - "sum_decrease": None if _sum is None or inc is None else inc - _sum, - } - for db_state in group - ) + for db_state in group: + start = process_timestamp(db_state.start) + end = start + timedelta(hours=1) + ent_results.append( + { + "statistic_id": statistic_id, + "start": start.isoformat(), + "end": end.isoformat(), + "mean": convert(db_state.mean, units), + "min": convert(db_state.min, units), + "max": convert(db_state.max, units), + "last_reset": process_timestamp_to_utc_isoformat( + db_state.last_reset + ), + "state": convert(db_state.state, units), + "sum": (_sum := convert(db_state.sum, units)), + "sum_increase": (inc := convert(db_state.sum_increase, units)), + "sum_decrease": None if _sum is None or inc is None else inc - _sum, + } + ) # Filter out the empty lists if some states had 0 results. return {metadata[key]["statistic_id"]: val for key, val in result.items() if val} diff --git a/tests/components/history/test_init.py b/tests/components/history/test_init.py index 27c2024750c..b237659d528 100644 --- a/tests/components/history/test_init.py +++ b/tests/components/history/test_init.py @@ -908,6 +908,7 @@ async def test_statistics_during_period( { "statistic_id": "sensor.test", "start": now.isoformat(), + "end": (now + timedelta(hours=1)).isoformat(), "mean": approx(value), "min": approx(value), "max": approx(value), diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index 2434f8b4703..1e723e7e2ca 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -45,6 +45,7 @@ def test_compile_hourly_statistics(hass_recorder): expected_1 = { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), "mean": approx(14.915254237288135), "min": approx(10.0), "max": approx(20.0), @@ -57,6 +58,7 @@ def test_compile_hourly_statistics(hass_recorder): expected_2 = { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(four), + "end": process_timestamp_to_utc_isoformat(four + timedelta(hours=1)), "mean": approx(20.0), "min": approx(20.0), "max": approx(20.0), @@ -164,6 +166,7 @@ def test_compile_hourly_statistics_exception( expected_1 = { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(now), + "end": process_timestamp_to_utc_isoformat(now + timedelta(hours=1)), "mean": None, "min": None, "max": None, @@ -176,6 +179,7 @@ def test_compile_hourly_statistics_exception( expected_2 = { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(now + timedelta(hours=1)), + "end": process_timestamp_to_utc_isoformat(now + timedelta(hours=2)), "mean": None, "min": None, "max": None, @@ -235,6 +239,7 @@ def test_rename_entity(hass_recorder): expected_1 = { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), "mean": approx(14.915254237288135), "min": approx(10.0), "max": approx(20.0), diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 6108f4a7ef8..74adf717e5b 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -95,6 +95,7 @@ def test_compile_hourly_statistics( { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), "mean": approx(mean), "min": approx(min), "max": approx(max), @@ -159,6 +160,7 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), "mean": approx(13.050847), "min": approx(-10.0), "max": approx(30.0), @@ -173,6 +175,7 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes { "statistic_id": "sensor.test6", "start": process_timestamp_to_utc_isoformat(zero), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), "mean": approx(13.050847), "min": approx(-10.0), "max": approx(30.0), @@ -187,6 +190,7 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes { "statistic_id": "sensor.test7", "start": process_timestamp_to_utc_isoformat(zero), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), "mean": approx(13.050847), "min": approx(-10.0), "max": approx(30.0), @@ -260,6 +264,7 @@ def test_compile_hourly_sum_statistics_amount( { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), "max": None, "mean": None, "min": None, @@ -272,6 +277,7 @@ def test_compile_hourly_sum_statistics_amount( { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=2)), "max": None, "mean": None, "min": None, @@ -284,6 +290,7 @@ def test_compile_hourly_sum_statistics_amount( { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=2)), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=3)), "max": None, "mean": None, "min": None, @@ -360,6 +367,7 @@ def test_compile_hourly_sum_statistics_amount_reset_every_state_change( { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), "max": None, "mean": None, "min": None, @@ -426,6 +434,7 @@ def test_compile_hourly_sum_statistics_nan_inf_state( { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), "max": None, "mean": None, "min": None, @@ -492,6 +501,7 @@ def test_compile_hourly_sum_statistics_total_no_reset( { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), "max": None, "mean": None, "min": None, @@ -504,6 +514,7 @@ def test_compile_hourly_sum_statistics_total_no_reset( { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=2)), "max": None, "mean": None, "min": None, @@ -516,6 +527,7 @@ def test_compile_hourly_sum_statistics_total_no_reset( { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=2)), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=3)), "max": None, "mean": None, "min": None, @@ -578,6 +590,7 @@ def test_compile_hourly_sum_statistics_total_increasing( { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), "max": None, "mean": None, "min": None, @@ -590,6 +603,7 @@ def test_compile_hourly_sum_statistics_total_increasing( { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=2)), "max": None, "mean": None, "min": None, @@ -602,6 +616,7 @@ def test_compile_hourly_sum_statistics_total_increasing( { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=2)), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=3)), "max": None, "mean": None, "min": None, @@ -675,6 +690,7 @@ def test_compile_hourly_sum_statistics_total_increasing_small_dip( "last_reset": None, "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), "max": None, "mean": None, "min": None, @@ -687,6 +703,7 @@ def test_compile_hourly_sum_statistics_total_increasing_small_dip( "last_reset": None, "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=2)), "max": None, "mean": None, "min": None, @@ -699,6 +716,7 @@ def test_compile_hourly_sum_statistics_total_increasing_small_dip( "last_reset": None, "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=2)), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=3)), "max": None, "mean": None, "min": None, @@ -767,6 +785,7 @@ def test_compile_hourly_energy_statistics_unsupported(hass_recorder, caplog): { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), "max": None, "mean": None, "min": None, @@ -779,6 +798,7 @@ def test_compile_hourly_energy_statistics_unsupported(hass_recorder, caplog): { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=2)), "max": None, "mean": None, "min": None, @@ -791,6 +811,7 @@ def test_compile_hourly_energy_statistics_unsupported(hass_recorder, caplog): { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=2)), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=3)), "max": None, "mean": None, "min": None, @@ -856,6 +877,7 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), "max": None, "mean": None, "min": None, @@ -868,6 +890,7 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=2)), "max": None, "mean": None, "min": None, @@ -880,6 +903,7 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=2)), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=3)), "max": None, "mean": None, "min": None, @@ -894,6 +918,7 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): { "statistic_id": "sensor.test2", "start": process_timestamp_to_utc_isoformat(zero), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), "max": None, "mean": None, "min": None, @@ -906,6 +931,7 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): { "statistic_id": "sensor.test2", "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=2)), "max": None, "mean": None, "min": None, @@ -918,6 +944,7 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): { "statistic_id": "sensor.test2", "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=2)), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=3)), "max": None, "mean": None, "min": None, @@ -932,6 +959,7 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): { "statistic_id": "sensor.test3", "start": process_timestamp_to_utc_isoformat(zero), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), "max": None, "mean": None, "min": None, @@ -944,6 +972,7 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): { "statistic_id": "sensor.test3", "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=2)), "max": None, "mean": None, "min": None, @@ -956,6 +985,7 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): { "statistic_id": "sensor.test3", "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=2)), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=3)), "max": None, "mean": None, "min": None, @@ -1011,6 +1041,7 @@ def test_compile_hourly_statistics_unchanged( { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(four), + "end": process_timestamp_to_utc_isoformat(four + timedelta(hours=1)), "mean": approx(value), "min": approx(value), "max": approx(value), @@ -1045,6 +1076,7 @@ def test_compile_hourly_statistics_partially_unavailable(hass_recorder, caplog): { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), "mean": approx(21.1864406779661), "min": approx(10.0), "max": approx(25.0), @@ -1104,6 +1136,7 @@ def test_compile_hourly_statistics_unavailable( { "statistic_id": "sensor.test2", "start": process_timestamp_to_utc_isoformat(four), + "end": process_timestamp_to_utc_isoformat(four + timedelta(hours=1)), "mean": approx(value), "min": approx(value), "max": approx(value), @@ -1256,6 +1289,7 @@ def test_compile_hourly_statistics_changing_units_1( { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), "mean": approx(mean), "min": approx(min), "max": approx(max), @@ -1284,6 +1318,7 @@ def test_compile_hourly_statistics_changing_units_1( { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), "mean": approx(mean), "min": approx(min), "max": approx(max), @@ -1391,6 +1426,7 @@ def test_compile_hourly_statistics_changing_units_3( { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), "mean": approx(mean), "min": approx(min), "max": approx(max), @@ -1417,6 +1453,7 @@ def test_compile_hourly_statistics_changing_units_3( { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), "mean": approx(mean), "min": approx(min), "max": approx(max), @@ -1497,6 +1534,7 @@ def test_compile_hourly_statistics_changing_statistics( { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), "mean": approx(mean), "min": approx(min), "max": approx(max), @@ -1509,6 +1547,7 @@ def test_compile_hourly_statistics_changing_statistics( { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=2)), "mean": None, "min": None, "max": None, From d899d15a1e9e363715ffe80d0a8723a195b5c8bf Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 13 Sep 2021 13:44:22 +0200 Subject: [PATCH 373/843] Add statistics validation (#56020) * Add statistics validation * Remove redundant None-check * Move validate_statistics WS API to recorder * Apply suggestion from code review --- homeassistant/components/recorder/__init__.py | 3 +- .../components/recorder/statistics.py | 23 ++ .../components/recorder/websocket_api.py | 30 +++ homeassistant/components/sensor/recorder.py | 54 ++++ .../components/recorder/test_websocket_api.py | 242 ++++++++++++++++++ 5 files changed, 351 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/recorder/websocket_api.py create mode 100644 tests/components/recorder/test_websocket_api.py diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 17215eb9845..d045726bc22 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -49,7 +49,7 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass import homeassistant.util.dt as dt_util -from . import history, migration, purge, statistics +from . import history, migration, purge, statistics, websocket_api from .const import CONF_DB_INTEGRITY_CHECK, DATA_INSTANCE, DOMAIN, SQLITE_URL_PREFIX from .models import ( Base, @@ -264,6 +264,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: _async_register_services(hass, instance) history.async_setup(hass) statistics.async_setup(hass) + websocket_api.async_setup(hass) await async_process_integration_platforms(hass, DOMAIN, _process_recorder_platform) return await instance.async_db_ready diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index c1b924ceeec..6ed612c60ad 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections import defaultdict +import dataclasses from datetime import datetime, timedelta from itertools import groupby import logging @@ -91,6 +92,18 @@ UNIT_CONVERSIONS = { _LOGGER = logging.getLogger(__name__) +@dataclasses.dataclass +class ValidationIssue: + """Error or warning message.""" + + type: str + data: dict[str, str | None] | None = None + + def as_dict(self) -> dict: + """Return dictionary version.""" + return dataclasses.asdict(self) + + def async_setup(hass: HomeAssistant) -> None: """Set up the history hooks.""" hass.data[STATISTICS_BAKERY] = baked.bakery() @@ -471,3 +484,13 @@ def _sorted_statistics_to_dict( # Filter out the empty lists if some states had 0 results. return {metadata[key]["statistic_id"]: val for key, val in result.items() if val} + + +def validate_statistics(hass: HomeAssistant) -> dict[str, list[ValidationIssue]]: + """Validate statistics.""" + platform_validation: dict[str, list[ValidationIssue]] = {} + for platform in hass.data[DOMAIN].values(): + if not hasattr(platform, "validate_statistics"): + continue + platform_validation.update(platform.validate_statistics(hass)) + return platform_validation diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py new file mode 100644 index 00000000000..c5a332547cb --- /dev/null +++ b/homeassistant/components/recorder/websocket_api.py @@ -0,0 +1,30 @@ +"""The Energy websocket API.""" +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.core import HomeAssistant, callback + +from .statistics import validate_statistics + + +@callback +def async_setup(hass: HomeAssistant) -> None: + """Set up the recorder websocket API.""" + websocket_api.async_register_command(hass, ws_validate_statistics) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "recorder/validate_statistics", + } +) +@websocket_api.async_response +async def ws_validate_statistics( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict +) -> None: + """Fetch a list of available statistic_id.""" + statistic_ids = await hass.async_add_executor_job( + validate_statistics, + hass, + ) + connection.send_result(msg["id"], statistic_ids) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 7e3fb5ddd9f..02269439cb8 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -1,6 +1,7 @@ """Statistics helper for sensor.""" from __future__ import annotations +from collections import defaultdict import datetime import itertools import logging @@ -543,3 +544,56 @@ def list_statistic_ids(hass: HomeAssistant, statistic_type: str | None = None) - statistic_ids[entity_id] = statistics_unit return statistic_ids + + +def validate_statistics( + hass: HomeAssistant, +) -> dict[str, list[statistics.ValidationIssue]]: + """Validate statistics.""" + validation_result = defaultdict(list) + + entities = _get_entities(hass) + + for ( + entity_id, + _state_class, + device_class, + ) in entities: + state = hass.states.get(entity_id) + assert state is not None + + metadata = statistics.get_metadata(hass, entity_id) + if not metadata: + continue + + state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + metadata_unit = metadata["unit_of_measurement"] + + if device_class not in UNIT_CONVERSIONS: + + if state_unit != metadata_unit: + validation_result[entity_id].append( + statistics.ValidationIssue( + "units_changed", + { + "statistic_id": entity_id, + "state_unit": state_unit, + "metadata_unit": metadata_unit, + }, + ) + ) + continue + + if state_unit not in UNIT_CONVERSIONS[device_class]: + validation_result[entity_id].append( + statistics.ValidationIssue( + "unsupported_unit", + { + "statistic_id": entity_id, + "device_class": device_class, + "state_unit": state_unit, + }, + ) + ) + + return validation_result diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py new file mode 100644 index 00000000000..51334432121 --- /dev/null +++ b/tests/components/recorder/test_websocket_api.py @@ -0,0 +1,242 @@ +"""The tests for sensor recorder platform.""" +# pylint: disable=protected-access,invalid-name +from datetime import timedelta + +import pytest + +from homeassistant.components.recorder.const import DATA_INSTANCE +from homeassistant.components.recorder.models import StatisticsMeta +from homeassistant.components.recorder.util import session_scope +from homeassistant.setup import async_setup_component +import homeassistant.util.dt as dt_util +from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM + +from tests.common import init_recorder_component + +BATTERY_SENSOR_ATTRIBUTES = { + "device_class": "battery", + "state_class": "measurement", + "unit_of_measurement": "%", +} +POWER_SENSOR_ATTRIBUTES = { + "device_class": "power", + "state_class": "measurement", + "unit_of_measurement": "kW", +} +NONE_SENSOR_ATTRIBUTES = { + "state_class": "measurement", +} +PRESSURE_SENSOR_ATTRIBUTES = { + "device_class": "pressure", + "state_class": "measurement", + "unit_of_measurement": "hPa", +} +TEMPERATURE_SENSOR_ATTRIBUTES = { + "device_class": "temperature", + "state_class": "measurement", + "unit_of_measurement": "°C", +} + + +@pytest.mark.parametrize( + "units, attributes, unit", + [ + (IMPERIAL_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W"), + (METRIC_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W"), + (IMPERIAL_SYSTEM, TEMPERATURE_SENSOR_ATTRIBUTES, "°F"), + (METRIC_SYSTEM, TEMPERATURE_SENSOR_ATTRIBUTES, "°C"), + (IMPERIAL_SYSTEM, PRESSURE_SENSOR_ATTRIBUTES, "psi"), + (METRIC_SYSTEM, PRESSURE_SENSOR_ATTRIBUTES, "Pa"), + ], +) +async def test_validate_statistics_supported_device_class( + hass, hass_ws_client, units, attributes, unit +): + """Test list_statistic_ids.""" + id = 1 + + def next_id(): + nonlocal id + id += 1 + return id + + async def assert_validation_result(client, expected_result): + await client.send_json( + {"id": next_id(), "type": "recorder/validate_statistics"} + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == expected_result + + now = dt_util.utcnow() + + hass.config.units = units + await hass.async_add_executor_job(init_recorder_component, hass) + await async_setup_component(hass, "sensor", {}) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + client = await hass_ws_client() + + # No statistics, no state - empty response + await assert_validation_result(client, {}) + + # No statistics, valid state - empty response + hass.states.async_set( + "sensor.test", 10, attributes={**attributes, **{"unit_of_measurement": unit}} + ) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + await assert_validation_result(client, {}) + + # No statistics, invalid state - empty response + hass.states.async_set( + "sensor.test", 11, attributes={**attributes, **{"unit_of_measurement": "dogs"}} + ) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + await assert_validation_result(client, {}) + + # Statistics has run, invalid state - expect error + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + hass.data[DATA_INSTANCE].do_adhoc_statistics(start=now) + hass.states.async_set( + "sensor.test", 12, attributes={**attributes, **{"unit_of_measurement": "dogs"}} + ) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + expected = { + "sensor.test": [ + { + "data": { + "device_class": attributes["device_class"], + "state_unit": "dogs", + "statistic_id": "sensor.test", + }, + "type": "unsupported_unit", + } + ], + } + await assert_validation_result(client, expected) + + # Valid state - empty response + hass.states.async_set( + "sensor.test", 13, attributes={**attributes, **{"unit_of_measurement": unit}} + ) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + await assert_validation_result(client, {}) + + # Valid state, statistic runs again - empty response + hass.data[DATA_INSTANCE].do_adhoc_statistics(start=now) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + await assert_validation_result(client, {}) + + # Remove the state - empty response + hass.states.async_remove("sensor.test") + await assert_validation_result(client, {}) + + +@pytest.mark.parametrize( + "attributes", + [BATTERY_SENSOR_ATTRIBUTES, NONE_SENSOR_ATTRIBUTES], +) +async def test_validate_statistics_unsupported_device_class( + hass, hass_ws_client, attributes +): + """Test list_statistic_ids.""" + id = 1 + + def next_id(): + nonlocal id + id += 1 + return id + + async def assert_validation_result(client, expected_result): + await client.send_json( + {"id": next_id(), "type": "recorder/validate_statistics"} + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == expected_result + + async def assert_statistic_ids(expected_result): + with session_scope(hass=hass) as session: + db_states = list(session.query(StatisticsMeta)) + assert len(db_states) == len(expected_result) + for i in range(len(db_states)): + assert db_states[i].statistic_id == expected_result[i]["statistic_id"] + assert ( + db_states[i].unit_of_measurement + == expected_result[i]["unit_of_measurement"] + ) + + now = dt_util.utcnow() + + await hass.async_add_executor_job(init_recorder_component, hass) + await async_setup_component(hass, "sensor", {}) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + client = await hass_ws_client() + rec = hass.data[DATA_INSTANCE] + + # No statistics, no state - empty response + await assert_validation_result(client, {}) + + # No statistics, original unit - empty response + hass.states.async_set("sensor.test", 10, attributes=attributes) + await assert_validation_result(client, {}) + + # No statistics, changed unit - empty response + hass.states.async_set( + "sensor.test", 11, attributes={**attributes, **{"unit_of_measurement": "dogs"}} + ) + await assert_validation_result(client, {}) + + # Run statistics, no statistics will be generated because of conflicting units + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + rec.do_adhoc_statistics(start=now) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + await assert_statistic_ids([]) + + # No statistics, changed unit - empty response + hass.states.async_set( + "sensor.test", 12, attributes={**attributes, **{"unit_of_measurement": "dogs"}} + ) + await assert_validation_result(client, {}) + + # Run statistics one hour later, only the "dogs" state will be considered + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + rec.do_adhoc_statistics(start=now + timedelta(hours=1)) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + await assert_statistic_ids( + [{"statistic_id": "sensor.test", "unit_of_measurement": "dogs"}] + ) + await assert_validation_result(client, {}) + + # Change back to original unit - expect error + hass.states.async_set("sensor.test", 13, attributes=attributes) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + expected = { + "sensor.test": [ + { + "data": { + "metadata_unit": "dogs", + "state_unit": attributes.get("unit_of_measurement"), + "statistic_id": "sensor.test", + }, + "type": "units_changed", + } + ], + } + await assert_validation_result(client, expected) + + # Changed unit - empty response + hass.states.async_set( + "sensor.test", 14, attributes={**attributes, **{"unit_of_measurement": "dogs"}} + ) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + await assert_validation_result(client, {}) + + # Valid state, statistic runs again - empty response + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + hass.data[DATA_INSTANCE].do_adhoc_statistics(start=now) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + await assert_validation_result(client, {}) + + # Remove the state - empty response + hass.states.async_remove("sensor.test") + await assert_validation_result(client, {}) From 5ccc3c17d9d8641edfee3efa266806a3a4025bcd Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 13 Sep 2021 13:46:21 +0200 Subject: [PATCH 374/843] Use list comprehension in onewire entity descriptions (#56168) * Use list comprehension in onewire binary sensors * Use list comprehension in onewire switches --- .../components/onewire/binary_sensor.py | 74 ++------ homeassistant/components/onewire/const.py | 3 + homeassistant/components/onewire/switch.py | 168 +++++------------- 3 files changed, 65 insertions(+), 180 deletions(-) diff --git a/homeassistant/components/onewire/binary_sensor.py b/homeassistant/components/onewire/binary_sensor.py index ff2ee55d0bd..0b78988f7e1 100644 --- a/homeassistant/components/onewire/binary_sensor.py +++ b/homeassistant/components/onewire/binary_sensor.py @@ -20,7 +20,13 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import CONF_TYPE_OWSERVER, DOMAIN, READ_MODE_BOOL +from .const import ( + CONF_TYPE_OWSERVER, + DEVICE_KEYS_0_7, + DEVICE_KEYS_A_B, + DOMAIN, + READ_MODE_BOOL, +) from .onewire_entities import OneWireEntityDescription, OneWireProxyEntity from .onewirehub import OneWireHub @@ -33,69 +39,23 @@ class OneWireBinarySensorEntityDescription( DEVICE_BINARY_SENSORS: dict[str, tuple[OneWireBinarySensorEntityDescription, ...]] = { - "12": ( + "12": tuple( OneWireBinarySensorEntityDescription( - key="sensed.A", + key=f"sensed.{id}", entity_registry_enabled_default=False, - name="Sensed A", + name=f"Sensed {id}", read_mode=READ_MODE_BOOL, - ), - OneWireBinarySensorEntityDescription( - key="sensed.B", - entity_registry_enabled_default=False, - name="Sensed B", - read_mode=READ_MODE_BOOL, - ), + ) + for id in DEVICE_KEYS_A_B ), - "29": ( + "29": tuple( OneWireBinarySensorEntityDescription( - key="sensed.0", + key=f"sensed.{id}", entity_registry_enabled_default=False, - name="Sensed 0", + name=f"Sensed {id}", read_mode=READ_MODE_BOOL, - ), - OneWireBinarySensorEntityDescription( - key="sensed.1", - entity_registry_enabled_default=False, - name="Sensed 1", - read_mode=READ_MODE_BOOL, - ), - OneWireBinarySensorEntityDescription( - key="sensed.2", - entity_registry_enabled_default=False, - name="Sensed 2", - read_mode=READ_MODE_BOOL, - ), - OneWireBinarySensorEntityDescription( - key="sensed.3", - entity_registry_enabled_default=False, - name="Sensed 3", - read_mode=READ_MODE_BOOL, - ), - OneWireBinarySensorEntityDescription( - key="sensed.4", - entity_registry_enabled_default=False, - name="Sensed 4", - read_mode=READ_MODE_BOOL, - ), - OneWireBinarySensorEntityDescription( - key="sensed.5", - entity_registry_enabled_default=False, - name="Sensed 5", - read_mode=READ_MODE_BOOL, - ), - OneWireBinarySensorEntityDescription( - key="sensed.6", - entity_registry_enabled_default=False, - name="Sensed 6", - read_mode=READ_MODE_BOOL, - ), - OneWireBinarySensorEntityDescription( - key="sensed.7", - entity_registry_enabled_default=False, - name="Sensed 7", - read_mode=READ_MODE_BOOL, - ), + ) + for id in DEVICE_KEYS_0_7 ), } diff --git a/homeassistant/components/onewire/const.py b/homeassistant/components/onewire/const.py index 4d758146aff..54bfc686459 100644 --- a/homeassistant/components/onewire/const.py +++ b/homeassistant/components/onewire/const.py @@ -17,6 +17,9 @@ DEFAULT_SYSBUS_MOUNT_DIR = "/sys/bus/w1/devices/" DOMAIN = "onewire" +DEVICE_KEYS_0_7 = range(8) +DEVICE_KEYS_A_B = ("A", "B") + PRESSURE_CBAR = "cbar" READ_MODE_BOOL = "bool" diff --git a/homeassistant/components/onewire/switch.py b/homeassistant/components/onewire/switch.py index 678f930901f..e8e790feab4 100644 --- a/homeassistant/components/onewire/switch.py +++ b/homeassistant/components/onewire/switch.py @@ -19,7 +19,13 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import CONF_TYPE_OWSERVER, DOMAIN, READ_MODE_BOOL +from .const import ( + CONF_TYPE_OWSERVER, + DEVICE_KEYS_0_7, + DEVICE_KEYS_A_B, + DOMAIN, + READ_MODE_BOOL, +) from .onewire_entities import OneWireEntityDescription, OneWireProxyEntity from .onewirehub import OneWireHub @@ -38,129 +44,45 @@ DEVICE_SWITCHES: dict[str, tuple[OneWireEntityDescription, ...]] = { read_mode=READ_MODE_BOOL, ), ), - "12": ( - OneWireSwitchEntityDescription( - key="PIO.A", - entity_registry_enabled_default=False, - name="PIO A", - read_mode=READ_MODE_BOOL, - ), - OneWireSwitchEntityDescription( - key="PIO.B", - entity_registry_enabled_default=False, - name="PIO B", - read_mode=READ_MODE_BOOL, - ), - OneWireSwitchEntityDescription( - key="latch.A", - entity_registry_enabled_default=False, - name="Latch A", - read_mode=READ_MODE_BOOL, - ), - OneWireSwitchEntityDescription( - key="latch.B", - entity_registry_enabled_default=False, - name="Latch B", - read_mode=READ_MODE_BOOL, - ), + "12": tuple( + [ + OneWireSwitchEntityDescription( + key=f"PIO.{id}", + entity_registry_enabled_default=False, + name=f"PIO {id}", + read_mode=READ_MODE_BOOL, + ) + for id in DEVICE_KEYS_A_B + ] + + [ + OneWireSwitchEntityDescription( + key=f"latch.{id}", + entity_registry_enabled_default=False, + name=f"Latch {id}", + read_mode=READ_MODE_BOOL, + ) + for id in DEVICE_KEYS_A_B + ] ), - "29": ( - OneWireSwitchEntityDescription( - key="PIO.0", - entity_registry_enabled_default=False, - name="PIO 0", - read_mode=READ_MODE_BOOL, - ), - OneWireSwitchEntityDescription( - key="PIO.1", - entity_registry_enabled_default=False, - name="PIO 1", - read_mode=READ_MODE_BOOL, - ), - OneWireSwitchEntityDescription( - key="PIO.2", - entity_registry_enabled_default=False, - name="PIO 2", - read_mode=READ_MODE_BOOL, - ), - OneWireSwitchEntityDescription( - key="PIO.3", - entity_registry_enabled_default=False, - name="PIO 3", - read_mode=READ_MODE_BOOL, - ), - OneWireSwitchEntityDescription( - key="PIO.4", - entity_registry_enabled_default=False, - name="PIO 4", - read_mode=READ_MODE_BOOL, - ), - OneWireSwitchEntityDescription( - key="PIO.5", - entity_registry_enabled_default=False, - name="PIO 5", - read_mode=READ_MODE_BOOL, - ), - OneWireSwitchEntityDescription( - key="PIO.6", - entity_registry_enabled_default=False, - name="PIO 6", - read_mode=READ_MODE_BOOL, - ), - OneWireSwitchEntityDescription( - key="PIO.7", - entity_registry_enabled_default=False, - name="PIO 7", - read_mode=READ_MODE_BOOL, - ), - OneWireSwitchEntityDescription( - key="latch.0", - entity_registry_enabled_default=False, - name="Latch 0", - read_mode=READ_MODE_BOOL, - ), - OneWireSwitchEntityDescription( - key="latch.1", - entity_registry_enabled_default=False, - name="Latch 1", - read_mode=READ_MODE_BOOL, - ), - OneWireSwitchEntityDescription( - key="latch.2", - entity_registry_enabled_default=False, - name="Latch 2", - read_mode=READ_MODE_BOOL, - ), - OneWireSwitchEntityDescription( - key="latch.3", - entity_registry_enabled_default=False, - name="Latch 3", - read_mode=READ_MODE_BOOL, - ), - OneWireSwitchEntityDescription( - key="latch.4", - entity_registry_enabled_default=False, - name="Latch 4", - read_mode=READ_MODE_BOOL, - ), - OneWireSwitchEntityDescription( - key="latch.5", - entity_registry_enabled_default=False, - name="Latch 5", - read_mode=READ_MODE_BOOL, - ), - OneWireSwitchEntityDescription( - key="latch.6", - entity_registry_enabled_default=False, - name="Latch 6", - read_mode=READ_MODE_BOOL, - ), - OneWireSwitchEntityDescription( - key="latch.7", - entity_registry_enabled_default=False, - name="Latch 7", - read_mode=READ_MODE_BOOL, - ), + "29": tuple( + [ + OneWireSwitchEntityDescription( + key=f"PIO.{id}", + entity_registry_enabled_default=False, + name=f"PIO {id}", + read_mode=READ_MODE_BOOL, + ) + for id in DEVICE_KEYS_0_7 + ] + + [ + OneWireSwitchEntityDescription( + key=f"latch.{id}", + entity_registry_enabled_default=False, + name=f"Latch {id}", + read_mode=READ_MODE_BOOL, + ) + for id in DEVICE_KEYS_0_7 + ] ), } From ee616ed992f0672d17c08de1f793af6695f9de40 Mon Sep 17 00:00:00 2001 From: Vilppu Vuorinen Date: Mon, 13 Sep 2021 15:14:47 +0300 Subject: [PATCH 375/843] Support hvac mode in melcloud climate.set_temperature service (#56082) Setting the HVAC_MODE via the set_temperature service has not been possible before this change. --- homeassistant/components/melcloud/climate.py | 34 ++++++++++++++------ 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/melcloud/climate.py b/homeassistant/components/melcloud/climate.py index 98aeaf73be1..d4eeb9354c8 100644 --- a/homeassistant/components/melcloud/climate.py +++ b/homeassistant/components/melcloud/climate.py @@ -16,6 +16,7 @@ import voluptuous as vol from homeassistant.components.climate import ClimateEntity from homeassistant.components.climate.const import ( + ATTR_HVAC_MODE, DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP, HVAC_MODE_COOL, @@ -29,7 +30,7 @@ from homeassistant.components.climate.const import ( SUPPORT_TARGET_TEMPERATURE, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import TEMP_CELSIUS +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform @@ -169,20 +170,25 @@ class AtaDeviceClimate(MelCloudClimate): return HVAC_MODE_OFF return ATA_HVAC_MODE_LOOKUP.get(mode) - async def async_set_hvac_mode(self, hvac_mode: str) -> None: - """Set new target hvac mode.""" + def _apply_set_hvac_mode(self, hvac_mode: str, set_dict: dict[str, Any]) -> None: + """Apply hvac mode changes to a dict used to call _device.set.""" if hvac_mode == HVAC_MODE_OFF: - await self._device.set({"power": False}) + set_dict["power"] = False return operation_mode = ATA_HVAC_MODE_REVERSE_LOOKUP.get(hvac_mode) if operation_mode is None: raise ValueError(f"Invalid hvac_mode [{hvac_mode}]") - props = {"operation_mode": operation_mode} + set_dict["operation_mode"] = operation_mode if self.hvac_mode == HVAC_MODE_OFF: - props["power"] = True - await self._device.set(props) + set_dict["power"] = True + + async def async_set_hvac_mode(self, hvac_mode: str) -> None: + """Set new target hvac mode.""" + set_dict = {} + self._apply_set_hvac_mode(hvac_mode, set_dict) + await self._device.set(set_dict) @property def hvac_modes(self) -> list[str]: @@ -203,9 +209,17 @@ class AtaDeviceClimate(MelCloudClimate): async def async_set_temperature(self, **kwargs) -> None: """Set new target temperature.""" - await self._device.set( - {"target_temperature": kwargs.get("temperature", self.target_temperature)} - ) + set_dict = {} + if ATTR_HVAC_MODE in kwargs: + self._apply_set_hvac_mode( + kwargs.get(ATTR_HVAC_MODE, self.hvac_mode), set_dict + ) + + if ATTR_TEMPERATURE in kwargs: + set_dict["target_temperature"] = kwargs.get(ATTR_TEMPERATURE) + + if set_dict: + await self._device.set(set_dict) @property def fan_mode(self) -> str | None: From e638e5bb4214fef83b64fd42fa02d956b521711d Mon Sep 17 00:00:00 2001 From: Brian Egge Date: Mon, 13 Sep 2021 09:28:37 -0400 Subject: [PATCH 376/843] Add component for binary sensor groups (#55365) * Add component for binary sensor groups https://github.com/home-assistant/home-assistant.io/pull/19239 * Accidental push over prior commit * Add test for any case * Add unavailable attribute and tests for unique_id * Added tests for attributes link to documentation: https://github.com/home-assistant/home-assistant.io/pull/19297 --- homeassistant/components/group/__init__.py | 2 +- .../components/group/binary_sensor.py | 133 +++++++++++++++ tests/components/group/test_binary_sensor.py | 151 ++++++++++++++++++ 3 files changed, 285 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/group/binary_sensor.py create mode 100644 tests/components/group/test_binary_sensor.py diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index 096108b460e..dad8f943328 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -56,7 +56,7 @@ ATTR_ALL = "all" SERVICE_SET = "set" SERVICE_REMOVE = "remove" -PLATFORMS = ["light", "cover", "notify"] +PLATFORMS = ["light", "cover", "notify", "binary_sensor"] REG_KEY = f"{DOMAIN}_registry" diff --git a/homeassistant/components/group/binary_sensor.py b/homeassistant/components/group/binary_sensor.py new file mode 100644 index 00000000000..93d4e1e066e --- /dev/null +++ b/homeassistant/components/group/binary_sensor.py @@ -0,0 +1,133 @@ +"""This platform allows several binary sensor to be grouped into one binary sensor.""" +from __future__ import annotations + +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASSES_SCHEMA, + DOMAIN as BINARY_SENSOR_DOMAIN, + PLATFORM_SCHEMA, + BinarySensorEntity, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_DEVICE_CLASS, + CONF_ENTITIES, + CONF_NAME, + CONF_UNIQUE_ID, + STATE_ON, + STATE_UNAVAILABLE, +) +from homeassistant.core import CoreState, Event, HomeAssistant +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.helpers.typing import ConfigType + +from . import GroupEntity + +DEFAULT_NAME = "Binary Sensor Group" + +CONF_ALL = "all" +REG_KEY = f"{BINARY_SENSOR_DOMAIN}_registry" + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_ENTITIES): cv.entities_domain(BINARY_SENSOR_DOMAIN), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_ALL): cv.boolean, + } +) + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: dict[str, Any] | None = None, +) -> None: + """Set up the Group Binary Sensor platform.""" + async_add_entities( + [ + BinarySensorGroup( + config.get(CONF_UNIQUE_ID), + config[CONF_NAME], + config.get(CONF_DEVICE_CLASS), + config[CONF_ENTITIES], + config.get(CONF_ALL), + ) + ] + ) + + +class BinarySensorGroup(GroupEntity, BinarySensorEntity): + """Representation of a BinarySensorGroup.""" + + _attr_assumed_state: bool = True + + def __init__( + self, + unique_id: str | None, + name: str, + device_class: str | None, + entity_ids: list[str], + mode: str | None, + ) -> None: + """Initialize a BinarySensorGroup entity.""" + super().__init__() + self._entity_ids = entity_ids + self._attr_name = name + self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entity_ids} + self._attr_unique_id = unique_id + self._device_class = device_class + self._state: str | None = None + self.mode = any + if mode: + self.mode = all + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + + async def async_state_changed_listener(event: Event) -> None: + """Handle child updates.""" + self.async_set_context(event.context) + await self.async_defer_or_update_ha_state() + + self.async_on_remove( + async_track_state_change_event( + self.hass, self._entity_ids, async_state_changed_listener + ) + ) + + if self.hass.state == CoreState.running: + await self.async_update() + return + + await super().async_added_to_hass() + + async def async_update(self) -> None: + """Query all members and determine the binary sensor group state.""" + all_states = [self.hass.states.get(x) for x in self._entity_ids] + filtered_states: list[str] = [x.state for x in all_states if x is not None] + self._attr_available = any( + state != STATE_UNAVAILABLE for state in filtered_states + ) + if STATE_UNAVAILABLE in filtered_states: + self._attr_is_on = None + else: + states = list(map(lambda x: x == STATE_ON, filtered_states)) + state = self.mode(states) + self._attr_is_on = state + self.async_write_ha_state() + + @property + def device_class(self) -> str | None: + """Return the sensor class of the binary sensor.""" + return self._device_class diff --git a/tests/components/group/test_binary_sensor.py b/tests/components/group/test_binary_sensor.py new file mode 100644 index 00000000000..7bf62a16a42 --- /dev/null +++ b/tests/components/group/test_binary_sensor.py @@ -0,0 +1,151 @@ +"""The tests for the Group Binary Sensor platform.""" +from os import path + +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.group import DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNAVAILABLE +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component + + +async def test_default_state(hass): + """Test binary_sensor group default state.""" + hass.states.async_set("binary_sensor.kitchen", "on") + hass.states.async_set("binary_sensor.bedroom", "on") + await async_setup_component( + hass, + BINARY_SENSOR_DOMAIN, + { + BINARY_SENSOR_DOMAIN: { + "platform": DOMAIN, + "entities": ["binary_sensor.kitchen", "binary_sensor.bedroom"], + "name": "Bedroom Group", + "unique_id": "unique_identifier", + "device_class": "presence", + } + }, + ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.bedroom_group") + assert state is not None + assert state.state == STATE_ON + assert state.attributes.get(ATTR_ENTITY_ID) == [ + "binary_sensor.kitchen", + "binary_sensor.bedroom", + ] + + entity_registry = er.async_get(hass) + entry = entity_registry.async_get("binary_sensor.bedroom_group") + assert entry + assert entry.unique_id == "unique_identifier" + assert entry.original_name == "Bedroom Group" + assert entry.device_class == "presence" + + +async def test_state_reporting_all(hass): + """Test the state reporting.""" + await async_setup_component( + hass, + BINARY_SENSOR_DOMAIN, + { + BINARY_SENSOR_DOMAIN: { + "platform": DOMAIN, + "entities": ["binary_sensor.test1", "binary_sensor.test2"], + "name": "Binary Sensor Group", + "device_class": "presence", + "all": "true", + } + }, + ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + hass.states.async_set("binary_sensor.test1", STATE_ON) + hass.states.async_set("binary_sensor.test2", STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_OFF + + hass.states.async_set("binary_sensor.test1", STATE_ON) + hass.states.async_set("binary_sensor.test2", STATE_OFF) + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_OFF + + hass.states.async_set("binary_sensor.test1", STATE_OFF) + hass.states.async_set("binary_sensor.test2", STATE_OFF) + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_OFF + + hass.states.async_set("binary_sensor.test1", STATE_ON) + hass.states.async_set("binary_sensor.test2", STATE_ON) + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_ON + + hass.states.async_set("binary_sensor.test1", STATE_UNAVAILABLE) + hass.states.async_set("binary_sensor.test2", STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert ( + hass.states.get("binary_sensor.binary_sensor_group").state == STATE_UNAVAILABLE + ) + + +async def test_state_reporting_any(hass): + """Test the state reporting.""" + await async_setup_component( + hass, + BINARY_SENSOR_DOMAIN, + { + BINARY_SENSOR_DOMAIN: { + "platform": DOMAIN, + "entities": ["binary_sensor.test1", "binary_sensor.test2"], + "name": "Binary Sensor Group", + "device_class": "presence", + "all": "false", + "unique_id": "unique_identifier", + } + }, + ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + # binary sensors have state off if unavailable + hass.states.async_set("binary_sensor.test1", STATE_ON) + hass.states.async_set("binary_sensor.test2", STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_OFF + + hass.states.async_set("binary_sensor.test1", STATE_ON) + hass.states.async_set("binary_sensor.test2", STATE_OFF) + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_ON + + hass.states.async_set("binary_sensor.test1", STATE_OFF) + hass.states.async_set("binary_sensor.test2", STATE_OFF) + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_OFF + + hass.states.async_set("binary_sensor.test1", STATE_ON) + hass.states.async_set("binary_sensor.test2", STATE_ON) + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_ON + + # binary sensors have state off if unavailable + hass.states.async_set("binary_sensor.test1", STATE_UNAVAILABLE) + hass.states.async_set("binary_sensor.test2", STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert ( + hass.states.get("binary_sensor.binary_sensor_group").state == STATE_UNAVAILABLE + ) + + entity_registry = er.async_get(hass) + entry = entity_registry.async_get("binary_sensor.binary_sensor_group") + assert entry + assert entry.unique_id == "unique_identifier" + + +def _get_fixtures_base_path(): + return path.dirname(path.dirname(path.dirname(__file__))) From 37f263e2aca7666bf2a0902a00910a7fc59f85fd Mon Sep 17 00:00:00 2001 From: jjlawren Date: Mon, 13 Sep 2021 08:46:54 -0500 Subject: [PATCH 377/843] Bump plexapi to 4.7.1 (#56163) --- homeassistant/components/plex/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/plex/manifest.json b/homeassistant/components/plex/manifest.json index 40d8ecc675e..27461d0d8ad 100644 --- a/homeassistant/components/plex/manifest.json +++ b/homeassistant/components/plex/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/plex", "requirements": [ - "plexapi==4.7.0", + "plexapi==4.7.1", "plexauth==0.0.6", "plexwebsocket==0.0.13" ], diff --git a/requirements_all.txt b/requirements_all.txt index df48625c0bb..f50e5e0384d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1200,7 +1200,7 @@ pillow==8.2.0 pizzapi==0.0.3 # homeassistant.components.plex -plexapi==4.7.0 +plexapi==4.7.1 # homeassistant.components.plex plexauth==0.0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index be80c7a7352..34c36b36251 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -678,7 +678,7 @@ pilight==0.1.1 pillow==8.2.0 # homeassistant.components.plex -plexapi==4.7.0 +plexapi==4.7.1 # homeassistant.components.plex plexauth==0.0.6 From 5f86388f1c4b24bdacf09ce7539ad81cc8c908f5 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 13 Sep 2021 18:18:21 +0200 Subject: [PATCH 378/843] Netgear config flow (#54479) * Original work from Quentame * Small adjustments * Add properties and method_version * fix unknown name * add consider_home functionality * fix typo * fix key * swao setup order * use formatted mac * add tracked_list option * add options flow * add config flow * add config flow * clean up registries * only remove if no other integration has that device * tracked_list formatting * convert tracked list * add import * move imports * use new tracked list on update * use update_device instead of remove * add strings * initialize already known devices * Update router.py * Update router.py * Update router.py * small fixes * styling * fix typing * fix spelling * Update router.py * get model of router * add router device info * fix api * add listeners * update router device info * remove method version option * Update __init__.py * fix styling * ignore typing * remove typing * fix mypy config * Update mypy.ini * add options flow tests * Update .coveragerc * fix styling * Update homeassistant/components/netgear/__init__.py Co-authored-by: J. Nick Koston * Update homeassistant/components/netgear/__init__.py Co-authored-by: J. Nick Koston * Update homeassistant/components/netgear/__init__.py Co-authored-by: J. Nick Koston * Update homeassistant/components/netgear/config_flow.py Co-authored-by: J. Nick Koston * Update homeassistant/components/netgear/router.py Co-authored-by: J. Nick Koston * add ConfigEntryNotReady * Update router.py * use entry.async_on_unload * Update homeassistant/components/netgear/device_tracker.py Co-authored-by: J. Nick Koston * use cv.ensure_list_csv * add hostname property * Update device_tracker.py * fix typo * fix isort * add myself to codeowners * clean config flow * further clean config flow * deprecate old netgear discovery * split out _async_remove_untracked_registries * Update homeassistant/components/netgear/config_flow.py Co-authored-by: J. Nick Koston * Update homeassistant/components/netgear/config_flow.py Co-authored-by: J. Nick Koston * cleanup * fix rename * fix typo * remove URL option * fixes * add sensor platform * fixes * fix removing multiple entities * remove extra attributes * initialize sensors correctly * extra sensors disabled by default * fix styling and unused imports * fix tests * Update .coveragerc * fix requirements * remove tracked list * remove tracked registry editing * fix styling * fix discovery test * simplify unload * Update homeassistant/components/netgear/router.py Co-authored-by: J. Nick Koston * add typing Co-authored-by: J. Nick Koston * add typing Co-authored-by: J. Nick Koston * add typing Co-authored-by: J. Nick Koston * condense NetgearSensorEntities Co-authored-by: J. Nick Koston * Update homeassistant/components/netgear/router.py Co-authored-by: J. Nick Koston * Update homeassistant/components/netgear/router.py Co-authored-by: J. Nick Koston * Update homeassistant/components/netgear/router.py Co-authored-by: J. Nick Koston * Update homeassistant/components/netgear/router.py Co-authored-by: J. Nick Koston * add typing * styling * add typing * use ForwardRefrence for typing * Update homeassistant/components/netgear/device_tracker.py Co-authored-by: J. Nick Koston * add typing * Apply suggestions from code review Thanks! Co-authored-by: Martin Hjelmare * process review comments * fix styling * fix devicename not available on all models * ensure DeviceName is not needed * Update homeassistant/components/netgear/config_flow.py Co-authored-by: Martin Hjelmare * Update homeassistant/components/netgear/config_flow.py Co-authored-by: Martin Hjelmare * Update __init__.py * fix styling Co-authored-by: J. Nick Koston Co-authored-by: Martin Hjelmare --- .coveragerc | 3 + CODEOWNERS | 1 + .../components/discovery/__init__.py | 2 +- homeassistant/components/netgear/__init__.py | 60 +++- .../components/netgear/config_flow.py | 184 +++++++++++ homeassistant/components/netgear/const.py | 60 ++++ .../components/netgear/device_tracker.py | 195 +++++------- homeassistant/components/netgear/errors.py | 10 + .../components/netgear/manifest.json | 13 +- homeassistant/components/netgear/router.py | 292 ++++++++++++++++++ homeassistant/components/netgear/sensor.py | 83 +++++ homeassistant/components/netgear/strings.json | 34 ++ .../components/netgear/translations/en.json | 34 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/ssdp.py | 6 + mypy.ini | 3 + requirements_all.txt | 2 +- requirements_test_all.txt | 3 + script/hassfest/mypy_config.py | 1 + tests/components/discovery/test_init.py | 6 +- tests/components/netgear/__init__.py | 1 + tests/components/netgear/conftest.py | 14 + tests/components/netgear/test_config_flow.py | 284 +++++++++++++++++ 23 files changed, 1167 insertions(+), 125 deletions(-) create mode 100644 homeassistant/components/netgear/config_flow.py create mode 100644 homeassistant/components/netgear/const.py create mode 100644 homeassistant/components/netgear/errors.py create mode 100644 homeassistant/components/netgear/router.py create mode 100644 homeassistant/components/netgear/sensor.py create mode 100644 homeassistant/components/netgear/strings.json create mode 100644 homeassistant/components/netgear/translations/en.json create mode 100644 tests/components/netgear/__init__.py create mode 100644 tests/components/netgear/conftest.py create mode 100644 tests/components/netgear/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index a9fc9c433b8..6ac8779ad75 100644 --- a/.coveragerc +++ b/.coveragerc @@ -699,7 +699,10 @@ omit = homeassistant/components/nello/lock.py homeassistant/components/nest/legacy/* homeassistant/components/netdata/sensor.py + homeassistant/components/netgear/__init__.py homeassistant/components/netgear/device_tracker.py + homeassistant/components/netgear/router.py + homeassistant/components/netgear/sensor.py homeassistant/components/netgear_lte/* homeassistant/components/netio/switch.py homeassistant/components/neurio_energy/sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 54ce1818ce4..8127c30d357 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -335,6 +335,7 @@ homeassistant/components/ness_alarm/* @nickw444 homeassistant/components/nest/* @allenporter homeassistant/components/netatmo/* @cgtobi homeassistant/components/netdata/* @fabaff +homeassistant/components/netgear/* @hacf-fr @Quentame @starkillerOG homeassistant/components/nexia/* @bdraco homeassistant/components/nextbus/* @vividboarder homeassistant/components/nextcloud/* @meichthys diff --git a/homeassistant/components/discovery/__init__.py b/homeassistant/components/discovery/__init__.py index 8bf31a94aef..99106ef63a8 100644 --- a/homeassistant/components/discovery/__init__.py +++ b/homeassistant/components/discovery/__init__.py @@ -46,7 +46,6 @@ CONFIG_ENTRY_HANDLERS = { # These have no config flows SERVICE_HANDLERS = { - SERVICE_NETGEAR: ("device_tracker", None), SERVICE_ENIGMA2: ("media_player", "enigma2"), SERVICE_SABNZBD: ("sabnzbd", None), "yamaha": ("media_player", "yamaha"), @@ -76,6 +75,7 @@ MIGRATED_SERVICE_HANDLERS = [ "kodi", SERVICE_KONNECTED, SERVICE_MOBILE_APP, + SERVICE_NETGEAR, SERVICE_OCTOPRINT, "philips_hue", SERVICE_SAMSUNG_PRINTER, diff --git a/homeassistant/components/netgear/__init__.py b/homeassistant/components/netgear/__init__.py index 1b55d01b463..395773c5fe3 100644 --- a/homeassistant/components/netgear/__init__.py +++ b/homeassistant/components/netgear/__init__.py @@ -1 +1,59 @@ -"""The netgear component.""" +"""Support for Netgear routers.""" +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.typing import HomeAssistantType + +from .const import DOMAIN, PLATFORMS +from .errors import CannotLoginException +from .router import NetgearRouter + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: + """Set up Netgear component.""" + router = NetgearRouter(hass, entry) + try: + await router.async_setup() + except CannotLoginException as ex: + raise ConfigEntryNotReady from ex + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.unique_id] = router + + entry.async_on_unload(entry.add_update_listener(update_listener)) + + device_registry = await dr.async_get_registry(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, entry.unique_id)}, + manufacturer="Netgear", + name=router.device_name, + model=router.model, + sw_version=router.firmware_version, + ) + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + if unload_ok: + hass.data[DOMAIN].pop(entry.unique_id) + if not hass.data[DOMAIN]: + hass.data.pop(DOMAIN) + + return unload_ok + + +async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/netgear/config_flow.py b/homeassistant/components/netgear/config_flow.py new file mode 100644 index 00000000000..18813ac27cd --- /dev/null +++ b/homeassistant/components/netgear/config_flow.py @@ -0,0 +1,184 @@ +"""Config flow to configure the Netgear integration.""" +from urllib.parse import urlparse + +from pynetgear import DEFAULT_HOST, DEFAULT_PORT, DEFAULT_USER +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components import ssdp +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, +) +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult + +from .const import CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME, DEFAULT_NAME, DOMAIN +from .errors import CannotLoginException +from .router import get_api + + +def _discovery_schema_with_defaults(discovery_info): + return vol.Schema(_ordered_shared_schema(discovery_info)) + + +def _user_schema_with_defaults(user_input): + user_schema = { + vol.Optional(CONF_HOST, default=user_input.get(CONF_HOST, "")): str, + vol.Optional(CONF_PORT, default=user_input.get(CONF_PORT, DEFAULT_PORT)): int, + vol.Optional(CONF_SSL, default=user_input.get(CONF_SSL, False)): bool, + } + user_schema.update(_ordered_shared_schema(user_input)) + + return vol.Schema(user_schema) + + +def _ordered_shared_schema(schema_input): + return { + vol.Optional(CONF_USERNAME, default=schema_input.get(CONF_USERNAME, "")): str, + vol.Required(CONF_PASSWORD, default=schema_input.get(CONF_PASSWORD, "")): str, + } + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Options for the component.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Init object.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Manage the options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + settings_schema = vol.Schema( + { + vol.Optional( + CONF_CONSIDER_HOME, + default=self.config_entry.options.get( + CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME.total_seconds() + ), + ): int, + } + ) + + return self.async_show_form(step_id="init", data_schema=settings_schema) + + +class NetgearFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow.""" + + VERSION = 1 + + def __init__(self): + """Initialize the netgear config flow.""" + self.placeholders = { + CONF_HOST: DEFAULT_HOST, + CONF_PORT: DEFAULT_PORT, + CONF_USERNAME: DEFAULT_USER, + CONF_SSL: False, + } + self.discovered = False + + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> OptionsFlowHandler: + """Get the options flow.""" + return OptionsFlowHandler(config_entry) + + async def _show_setup_form(self, user_input=None, errors=None): + """Show the setup form to the user.""" + if not user_input: + user_input = {} + + if self.discovered: + data_schema = _discovery_schema_with_defaults(user_input) + else: + data_schema = _user_schema_with_defaults(user_input) + + return self.async_show_form( + step_id="user", + data_schema=data_schema, + errors=errors or {}, + description_placeholders=self.placeholders, + ) + + async def async_step_import(self, user_input=None): + """Import a config entry.""" + return await self.async_step_user(user_input) + + async def async_step_ssdp(self, discovery_info: dict) -> FlowResult: + """Initialize flow from ssdp.""" + updated_data = {} + + device_url = urlparse(discovery_info[ssdp.ATTR_SSDP_LOCATION]) + if device_url.hostname: + updated_data[CONF_HOST] = device_url.hostname + if device_url.port: + updated_data[CONF_PORT] = device_url.port + if device_url.scheme == "https": + updated_data[CONF_SSL] = True + else: + updated_data[CONF_SSL] = False + + await self.async_set_unique_id(discovery_info[ssdp.ATTR_UPNP_SERIAL]) + self._abort_if_unique_id_configured(updates=updated_data) + self.placeholders.update(updated_data) + self.discovered = True + + return await self.async_step_user() + + async def async_step_user(self, user_input=None): + """Handle a flow initiated by the user.""" + errors = {} + + if user_input is None: + return await self._show_setup_form() + + host = user_input.get(CONF_HOST, self.placeholders[CONF_HOST]) + port = user_input.get(CONF_PORT, self.placeholders[CONF_PORT]) + ssl = user_input.get(CONF_SSL, self.placeholders[CONF_SSL]) + username = user_input.get(CONF_USERNAME, self.placeholders[CONF_USERNAME]) + password = user_input[CONF_PASSWORD] + if not username: + username = self.placeholders[CONF_USERNAME] + + # Open connection and check authentication + try: + api = await self.hass.async_add_executor_job( + get_api, password, host, username, port, ssl + ) + except CannotLoginException: + errors["base"] = "config" + + if errors: + return await self._show_setup_form(user_input, errors) + + # Check if already configured + info = await self.hass.async_add_executor_job(api.get_info) + await self.async_set_unique_id(info["SerialNumber"], raise_on_progress=False) + self._abort_if_unique_id_configured() + + config_data = { + CONF_USERNAME: username, + CONF_PASSWORD: password, + CONF_HOST: host, + CONF_PORT: port, + CONF_SSL: ssl, + } + + if info.get("ModelName") is not None and info.get("DeviceName") is not None: + name = f"{info['ModelName']} - {info['DeviceName']}" + else: + name = info.get("ModelName", DEFAULT_NAME) + + return self.async_create_entry( + title=name, + data=config_data, + ) diff --git a/homeassistant/components/netgear/const.py b/homeassistant/components/netgear/const.py new file mode 100644 index 00000000000..8b520485e1e --- /dev/null +++ b/homeassistant/components/netgear/const.py @@ -0,0 +1,60 @@ +"""Netgear component constants.""" +from datetime import timedelta + +DOMAIN = "netgear" + +PLATFORMS = ["device_tracker", "sensor"] + +CONF_CONSIDER_HOME = "consider_home" + +DEFAULT_CONSIDER_HOME = timedelta(seconds=180) +DEFAULT_NAME = "Netgear router" + +# update method V2 models +MODELS_V2 = ["Orbi"] + +# Icons +DEVICE_ICONS = { + 0: "mdi:access-point-network", # Router (Orbi ...) + 1: "mdi:book-open-variant", # Amazon Kindle + 2: "mdi:android", # Android Device + 3: "mdi:cellphone-android", # Android Phone + 4: "mdi:tablet-android", # Android Tablet + 5: "mdi:router-wireless", # Apple Airport Express + 6: "mdi:disc-player", # Blu-ray Player + 7: "mdi:router-network", # Bridge + 8: "mdi:play-network", # Cable STB + 9: "mdi:camera", # Camera + 10: "mdi:router-network", # Router + 11: "mdi:play-network", # DVR + 12: "mdi:gamepad-variant", # Gaming Console + 13: "mdi:desktop-mac", # iMac + 14: "mdi:tablet-ipad", # iPad + 15: "mdi:tablet-ipad", # iPad Mini + 16: "mdi:cellphone-iphone", # iPhone 5/5S/5C + 17: "mdi:cellphone-iphone", # iPhone + 18: "mdi:ipod", # iPod Touch + 19: "mdi:linux", # Linux PC + 20: "mdi:apple-finder", # Mac Mini + 21: "mdi:desktop-tower", # Mac Pro + 22: "mdi:laptop-mac", # MacBook + 23: "mdi:play-network", # Media Device + 24: "mdi:network", # Network Device + 25: "mdi:play-network", # Other STB + 26: "mdi:power-plug", # Powerline + 27: "mdi:printer", # Printer + 28: "mdi:access-point", # Repeater + 29: "mdi:play-network", # Satellite STB + 30: "mdi:scanner", # Scanner + 31: "mdi:play-network", # SlingBox + 32: "mdi:cellphone", # Smart Phone + 33: "mdi:nas", # Storage (NAS) + 34: "mdi:switch", # Switch + 35: "mdi:television", # TV + 36: "mdi:tablet", # Tablet + 37: "mdi:desktop-classic", # UNIX PC + 38: "mdi:desktop-tower-monitor", # Windows PC + 39: "mdi:laptop-windows", # Surface + 40: "mdi:access-point-network", # Wifi Extender + 41: "mdi:apple-airplay", # Apple TV +} diff --git a/homeassistant/components/netgear/device_tracker.py b/homeassistant/components/netgear/device_tracker.py index 504faef70eb..f568a506552 100644 --- a/homeassistant/components/netgear/device_tracker.py +++ b/homeassistant/components/netgear/device_tracker.py @@ -1,15 +1,14 @@ """Support for Netgear routers.""" import logging -from pprint import pformat -from pynetgear import Netgear import voluptuous as vol from homeassistant.components.device_tracker import ( - DOMAIN, PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, - DeviceScanner, + SOURCE_TYPE_ROUTER, ) +from homeassistant.components.device_tracker.config_entry import ScannerEntity +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_DEVICES, CONF_EXCLUDE, @@ -19,7 +18,13 @@ from homeassistant.const import ( CONF_SSL, CONF_USERNAME, ) +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import HomeAssistantType + +from .const import DEVICE_ICONS, DOMAIN +from .router import NetgearDeviceEntity, NetgearRouter, async_setup_netgear_entry _LOGGER = logging.getLogger(__name__) @@ -27,9 +32,9 @@ CONF_APS = "accesspoints" PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( { - vol.Optional(CONF_HOST, default=""): cv.string, - vol.Optional(CONF_SSL, default=False): cv.boolean, - vol.Optional(CONF_USERNAME, default=""): cv.string, + vol.Optional(CONF_HOST): cv.string, + vol.Optional(CONF_SSL): cv.boolean, + vol.Optional(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, vol.Optional(CONF_PORT): cv.port, vol.Optional(CONF_DEVICES, default=[]): vol.All(cv.ensure_list, [cv.string]), @@ -39,132 +44,88 @@ PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( ) -def get_scanner(hass, config): - """Validate the configuration and returns a Netgear scanner.""" - info = config[DOMAIN] - host = info[CONF_HOST] - ssl = info[CONF_SSL] - username = info[CONF_USERNAME] - password = info[CONF_PASSWORD] - port = info.get(CONF_PORT) - devices = info[CONF_DEVICES] - excluded_devices = info[CONF_EXCLUDE] - accesspoints = info[CONF_APS] +async def async_get_scanner(hass, config): + """Import Netgear configuration from YAML.""" + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, + ) + ) - api = Netgear(password, host, username, port, ssl) - scanner = NetgearDeviceScanner(api, devices, excluded_devices, accesspoints) + _LOGGER.warning( + "Your Netgear configuration has been imported into the UI, " + "please remove it from configuration.yaml. " + "Loading Netgear via platform setup is now deprecated" + ) - _LOGGER.debug("Logging in") - - results = scanner.get_attached_devices() - - if results is not None: - scanner.last_results = results - else: - _LOGGER.error("Failed to Login") - return None - - return scanner + return None -class NetgearDeviceScanner(DeviceScanner): - """Queries a Netgear wireless router using the SOAP-API.""" +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up device tracker for Netgear component.""" - def __init__( - self, - api, - devices, - excluded_devices, - accesspoints, - ): - """Initialize the scanner.""" - self.tracked_devices = devices - self.excluded_devices = excluded_devices - self.tracked_accesspoints = accesspoints - self.last_results = [] - self._api = api + def generate_classes(router: NetgearRouter, device: dict): + return [NetgearScannerEntity(router, device)] - def scan_devices(self): - """Scan for new devices and return a list with found device IDs.""" - self._update_info() + async_setup_netgear_entry(hass, entry, async_add_entities, generate_classes) - devices = [] - for dev in self.last_results: - tracked = ( - not self.tracked_devices - or dev.mac in self.tracked_devices - or dev.name in self.tracked_devices - ) - tracked = tracked and ( - not self.excluded_devices - or not ( - dev.mac in self.excluded_devices - or dev.name in self.excluded_devices - ) - ) - if tracked: - devices.append(dev.mac) - if ( - self.tracked_accesspoints - and dev.conn_ap_mac in self.tracked_accesspoints - ): - devices.append(f"{dev.mac}_{dev.conn_ap_mac}") +class NetgearScannerEntity(NetgearDeviceEntity, ScannerEntity): + """Representation of a device connected to a Netgear router.""" - return devices + def __init__(self, router: NetgearRouter, device: dict) -> None: + """Initialize a Netgear device.""" + super().__init__(router, device) + self._hostname = self.get_hostname() + self._icon = DEVICE_ICONS.get(device["device_type"], "mdi:help-network") - def get_device_name(self, device): - """Return the name of the given device or the MAC if we don't know.""" - parts = device.split("_") - mac = parts[0] - ap_mac = None - if len(parts) > 1: - ap_mac = parts[1] + def get_hostname(self): + """Return the hostname of the given device or None if we don't know.""" + hostname = self._device["name"] + if hostname == "--": + return None - name = None - for dev in self.last_results: - if dev.mac == mac: - name = dev.name - break + return hostname - if not name or name == "--": - name = mac + @callback + def async_update_device(self) -> None: + """Update the Netgear device.""" + self._device = self._router.devices[self._mac] + self._active = self._device["active"] + self._icon = DEVICE_ICONS.get(self._device["device_type"], "mdi:help-network") - if ap_mac: - ap_name = "Router" - for dev in self.last_results: - if dev.mac == ap_mac: - ap_name = dev.name - break + self.async_write_ha_state() - return f"{name} on {ap_name}" + @property + def is_connected(self): + """Return true if the device is connected to the router.""" + return self._active - return name + @property + def source_type(self) -> str: + """Return the source type.""" + return SOURCE_TYPE_ROUTER - def _update_info(self): - """Retrieve latest information from the Netgear router. + @property + def ip_address(self) -> str: + """Return the IP address.""" + return self._device["ip"] - Returns boolean if scanning successful. - """ - _LOGGER.debug("Scanning") + @property + def mac_address(self) -> str: + """Return the mac address.""" + return self._mac - results = self.get_attached_devices() + @property + def hostname(self) -> str: + """Return the hostname.""" + return self._hostname - if _LOGGER.isEnabledFor(logging.DEBUG): - _LOGGER.debug("Scan result: \n%s", pformat(results)) - - if results is None: - _LOGGER.warning("Error scanning devices") - - self.last_results = results or [] - - def get_attached_devices(self): - """List attached devices with pynetgear. - - The v2 method takes more time and is more heavy on the router - so we only use it if we need connected AP info. - """ - if self.tracked_accesspoints: - return self._api.get_attached_devices_2() - - return self._api.get_attached_devices() + @property + def icon(self) -> str: + """Return the icon.""" + return self._icon diff --git a/homeassistant/components/netgear/errors.py b/homeassistant/components/netgear/errors.py new file mode 100644 index 00000000000..2ac1ed18224 --- /dev/null +++ b/homeassistant/components/netgear/errors.py @@ -0,0 +1,10 @@ +"""Errors for the Netgear component.""" +from homeassistant.exceptions import HomeAssistantError + + +class NetgearException(HomeAssistantError): + """Base class for Netgear exceptions.""" + + +class CannotLoginException(NetgearException): + """Unable to login to the router.""" diff --git a/homeassistant/components/netgear/manifest.json b/homeassistant/components/netgear/manifest.json index 713101f657f..aa4c57ecdde 100644 --- a/homeassistant/components/netgear/manifest.json +++ b/homeassistant/components/netgear/manifest.json @@ -2,7 +2,14 @@ "domain": "netgear", "name": "NETGEAR", "documentation": "https://www.home-assistant.io/integrations/netgear", - "requirements": ["pynetgear==0.6.1"], - "codeowners": [], - "iot_class": "local_polling" + "requirements": ["pynetgear==0.7.0"], + "codeowners": ["@hacf-fr", "@Quentame", "@starkillerOG"], + "iot_class": "local_polling", + "config_flow": true, + "ssdp": [ + { + "manufacturer": "NETGEAR, Inc.", + "deviceType": "urn:schemas-upnp-org:device:InternetGatewayDevice:1" + } + ] } diff --git a/homeassistant/components/netgear/router.py b/homeassistant/components/netgear/router.py new file mode 100644 index 00000000000..a500bffb966 --- /dev/null +++ b/homeassistant/components/netgear/router.py @@ -0,0 +1,292 @@ +"""Represent the Netgear router and its devices.""" +from abc import abstractmethod +from datetime import timedelta +import logging +from typing import Callable + +from pynetgear import Netgear + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, +) +from homeassistant.core import callback +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.util import dt as dt_util + +from .const import ( + CONF_CONSIDER_HOME, + DEFAULT_CONSIDER_HOME, + DEFAULT_NAME, + DOMAIN, + MODELS_V2, +) +from .errors import CannotLoginException + +SCAN_INTERVAL = timedelta(seconds=30) + +_LOGGER = logging.getLogger(__name__) + + +def get_api( + password: str, + host: str = None, + username: str = None, + port: int = None, + ssl: bool = False, +) -> Netgear: + """Get the Netgear API and login to it.""" + api: Netgear = Netgear(password, host, username, port, ssl) + + if not api.login(): + raise CannotLoginException + + return api + + +@callback +def async_setup_netgear_entry( + hass: HomeAssistantType, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + entity_class_generator: Callable[["NetgearRouter", dict], list], +) -> None: + """Set up device tracker for Netgear component.""" + router = hass.data[DOMAIN][entry.unique_id] + tracked = set() + + @callback + def _async_router_updated(): + """Update the values of the router.""" + async_add_new_entities( + router, async_add_entities, tracked, entity_class_generator + ) + + entry.async_on_unload( + async_dispatcher_connect(hass, router.signal_device_new, _async_router_updated) + ) + + _async_router_updated() + + +@callback +def async_add_new_entities(router, async_add_entities, tracked, entity_class_generator): + """Add new tracker entities from the router.""" + new_tracked = [] + + for mac, device in router.devices.items(): + if mac in tracked: + continue + + new_tracked.extend(entity_class_generator(router, device)) + tracked.add(mac) + + if new_tracked: + async_add_entities(new_tracked, True) + + +class NetgearRouter: + """Representation of a Netgear router.""" + + def __init__(self, hass: HomeAssistantType, entry: ConfigEntry) -> None: + """Initialize a Netgear router.""" + self.hass = hass + self.entry = entry + self.entry_id = entry.entry_id + self.unique_id = entry.unique_id + self._host = entry.data.get(CONF_HOST) + self._port = entry.data.get(CONF_PORT) + self._ssl = entry.data.get(CONF_SSL) + self._username = entry.data.get(CONF_USERNAME) + self._password = entry.data[CONF_PASSWORD] + + self._info = None + self.model = None + self.device_name = None + self.firmware_version = None + + self._method_version = 1 + consider_home_int = entry.options.get( + CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME.total_seconds() + ) + self._consider_home = timedelta(seconds=consider_home_int) + + self._api: Netgear = None + self._attrs = {} + + self.devices = {} + + def _setup(self) -> None: + """Set up a Netgear router sync portion.""" + self._api = get_api( + self._password, + self._host, + self._username, + self._port, + self._ssl, + ) + + self._info = self._api.get_info() + self.device_name = self._info.get("DeviceName", DEFAULT_NAME) + self.model = self._info.get("ModelName") + self.firmware_version = self._info.get("Firmwareversion") + + if self.model in MODELS_V2: + self._method_version = 2 + + async def async_setup(self) -> None: + """Set up a Netgear router.""" + await self.hass.async_add_executor_job(self._setup) + + # set already known devices to away instead of unavailable + device_registry = dr.async_get(self.hass) + devices = dr.async_entries_for_config_entry(device_registry, self.entry_id) + for device_entry in devices: + if device_entry.via_device_id is None: + continue # do not add the router itself + + device_mac = dict(device_entry.connections).get(dr.CONNECTION_NETWORK_MAC) + self.devices[device_mac] = { + "mac": device_mac, + "name": device_entry.name, + "active": False, + "last_seen": dt_util.utcnow() - timedelta(days=365), + "device_model": None, + "device_type": None, + "type": None, + "link_rate": None, + "signal": None, + "ip": None, + } + + await self.async_update_device_trackers() + self.entry.async_on_unload( + async_track_time_interval( + self.hass, self.async_update_device_trackers, SCAN_INTERVAL + ) + ) + + async_dispatcher_send(self.hass, self.signal_device_new) + + async def async_get_attached_devices(self) -> list: + """Get the devices connected to the router.""" + if self._method_version == 1: + return await self.hass.async_add_executor_job( + self._api.get_attached_devices + ) + + return await self.hass.async_add_executor_job(self._api.get_attached_devices_2) + + async def async_update_device_trackers(self, now=None) -> None: + """Update Netgear devices.""" + new_device = False + ntg_devices = await self.async_get_attached_devices() + now = dt_util.utcnow() + + for ntg_device in ntg_devices: + device_mac = format_mac(ntg_device.mac) + + if self._method_version == 2 and not ntg_device.link_rate: + continue + + if not self.devices.get(device_mac): + new_device = True + + # ntg_device is a namedtuple from the collections module that needs conversion to a dict through ._asdict method + self.devices[device_mac] = ntg_device._asdict() + self.devices[device_mac]["mac"] = device_mac + self.devices[device_mac]["last_seen"] = now + + for device in self.devices.values(): + device["active"] = now - device["last_seen"] <= self._consider_home + + async_dispatcher_send(self.hass, self.signal_device_update) + + if new_device: + _LOGGER.debug("Netgear tracker: new device found") + async_dispatcher_send(self.hass, self.signal_device_new) + + @property + def signal_device_new(self) -> str: + """Event specific per Netgear entry to signal new device.""" + return f"{DOMAIN}-{self._host}-device-new" + + @property + def signal_device_update(self) -> str: + """Event specific per Netgear entry to signal updates in devices.""" + return f"{DOMAIN}-{self._host}-device-update" + + +class NetgearDeviceEntity(Entity): + """Base class for a device connected to a Netgear router.""" + + def __init__(self, router: NetgearRouter, device: dict) -> None: + """Initialize a Netgear device.""" + self._router = router + self._device = device + self._mac = device["mac"] + self._name = self.get_device_name() + self._device_name = self._name + self._unique_id = self._mac + self._active = device["active"] + + def get_device_name(self): + """Return the name of the given device or the MAC if we don't know.""" + name = self._device["name"] + if not name or name == "--": + name = self._mac + + return name + + @abstractmethod + @callback + def async_update_device(self) -> None: + """Update the Netgear device.""" + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return self._unique_id + + @property + def name(self) -> str: + """Return the name.""" + return self._name + + @property + def device_info(self): + """Return the device information.""" + return { + "connections": {(CONNECTION_NETWORK_MAC, self._mac)}, + "name": self._device_name, + "model": self._device["device_model"], + "via_device": (DOMAIN, self._router.unique_id), + } + + @property + def should_poll(self) -> bool: + """No polling needed.""" + return False + + async def async_added_to_hass(self): + """Register state update callback.""" + self.async_on_remove( + async_dispatcher_connect( + self.hass, + self._router.signal_device_update, + self.async_update_device, + ) + ) diff --git a/homeassistant/components/netgear/sensor.py b/homeassistant/components/netgear/sensor.py new file mode 100644 index 00000000000..62867383d6e --- /dev/null +++ b/homeassistant/components/netgear/sensor.py @@ -0,0 +1,83 @@ +"""Support for Netgear routers.""" +import logging + +from homeassistant.components.sensor import ( + DEVICE_CLASS_SIGNAL_STRENGTH, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE +from homeassistant.core import callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import HomeAssistantType + +from .router import NetgearDeviceEntity, NetgearRouter, async_setup_netgear_entry + +_LOGGER = logging.getLogger(__name__) + + +SENSOR_TYPES = { + "type": SensorEntityDescription( + key="type", + name="link type", + native_unit_of_measurement=None, + device_class=None, + ), + "link_rate": SensorEntityDescription( + key="link_rate", + name="link rate", + native_unit_of_measurement="Mbps", + device_class=None, + ), + "signal": SensorEntityDescription( + key="signal", + name="signal strength", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + ), +} + + +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up device tracker for Netgear component.""" + + def generate_sensor_classes(router: NetgearRouter, device: dict): + return [ + NetgearSensorEntity(router, device, attribute) + for attribute in ("type", "link_rate", "signal") + ] + + async_setup_netgear_entry(hass, entry, async_add_entities, generate_sensor_classes) + + +class NetgearSensorEntity(NetgearDeviceEntity, SensorEntity): + """Representation of a device connected to a Netgear router.""" + + _attr_entity_registry_enabled_default = False + + def __init__(self, router: NetgearRouter, device: dict, attribute: str) -> None: + """Initialize a Netgear device.""" + super().__init__(router, device) + self._attribute = attribute + self.entity_description = SENSOR_TYPES[self._attribute] + self._name = f"{self.get_device_name()} {self.entity_description.name}" + self._unique_id = f"{self._mac}-{self._attribute}" + self._state = self._device[self._attribute] + + @property + def native_value(self): + """Return the state of the sensor.""" + return self._state + + @callback + def async_update_device(self) -> None: + """Update the Netgear device.""" + self._device = self._router.devices[self._mac] + self._active = self._device["active"] + if self._device[self._attribute] is not None: + self._state = self._device[self._attribute] + + self.async_write_ha_state() diff --git a/homeassistant/components/netgear/strings.json b/homeassistant/components/netgear/strings.json new file mode 100644 index 00000000000..9fdd548d992 --- /dev/null +++ b/homeassistant/components/netgear/strings.json @@ -0,0 +1,34 @@ +{ + "config": { + "step": { + "user": { + "title": "Netgear", + "description": "Default host: {host}\n Default port: {port}\n Default username: {username}", + "data": { + "host": "[%key:common::config_flow::data::host%] (Optional)", + "port": "[%key:common::config_flow::data::port%] (Optional)", + "ssl": "[%key:common::config_flow::data::ssl%]", + "username": "[%key:common::config_flow::data::username%] (Optional)", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "config": "Connection or login error: please check your configuration" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "options": { + "step": { + "init": { + "title": "Netgear", + "description": "Specify optional settings", + "data": { + "consider_home": "Consider home time (seconds)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/netgear/translations/en.json b/homeassistant/components/netgear/translations/en.json new file mode 100644 index 00000000000..b3c14648fb1 --- /dev/null +++ b/homeassistant/components/netgear/translations/en.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Host already configured" + }, + "error": { + "config": "Connection or login error: please check your configuration" + }, + "step": { + "user": { + "data": { + "host": "Host (Optional)", + "password": "Password", + "port": "Port (Optional)", + "ssl": "Use SSL (Optional)", + "username": "Username (Optional)" + }, + "description": "Default host: {host}\n Default port: {port}\n Default username: {username}", + "title": "Netgear" + } + } + }, + "options": { + "step": { + "init": { + "title": "Netgear", + "description": "Specify optional settings", + "data": { + "consider_home": "Consider home time (seconds)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 57f152bb5a2..f6fac775b2d 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -181,6 +181,7 @@ FLOWS = [ "neato", "nest", "netatmo", + "netgear", "nexia", "nfandroidtv", "nightscout", diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index 1638d932e89..e5e823b404a 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -151,6 +151,12 @@ SSDP = { "manufacturer": "konnected.io" } ], + "netgear": [ + { + "deviceType": "urn:schemas-upnp-org:device:InternetGatewayDevice:1", + "manufacturer": "NETGEAR, Inc." + } + ], "roku": [ { "deviceType": "urn:roku-com:device:player:1-0", diff --git a/mypy.ini b/mypy.ini index f048a3d473f..7c195414135 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1500,6 +1500,9 @@ ignore_errors = true [mypy-homeassistant.components.nest.legacy.*] ignore_errors = true +[mypy-homeassistant.components.netgear.*] +ignore_errors = true + [mypy-homeassistant.components.nightscout.*] ignore_errors = true diff --git a/requirements_all.txt b/requirements_all.txt index f50e5e0384d..84acb3f0b8f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1641,7 +1641,7 @@ pynanoleaf==0.1.0 pynello==2.0.3 # homeassistant.components.netgear -pynetgear==0.6.1 +pynetgear==0.7.0 # homeassistant.components.netio pynetio==0.1.9.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 34c36b36251..47ff575035b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -950,6 +950,9 @@ pymysensors==0.21.0 # homeassistant.components.nanoleaf pynanoleaf==0.1.0 +# homeassistant.components.netgear +pynetgear==0.7.0 + # homeassistant.components.nuki pynuki==1.4.1 diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index a66e880544c..f799b3fdb20 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -85,6 +85,7 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.mullvad.*", "homeassistant.components.ness_alarm.*", "homeassistant.components.nest.legacy.*", + "homeassistant.components.netgear.*", "homeassistant.components.nightscout.*", "homeassistant.components.nilu.*", "homeassistant.components.nsw_fuel_station.*", diff --git a/tests/components/discovery/test_init.py b/tests/components/discovery/test_init.py index 8be837bb16e..2b004135286 100644 --- a/tests/components/discovery/test_init.py +++ b/tests/components/discovery/test_init.py @@ -16,8 +16,10 @@ from tests.common import async_fire_time_changed, mock_coro SERVICE = "yamaha" SERVICE_COMPONENT = "media_player" -SERVICE_NO_PLATFORM = "netgear_router" -SERVICE_NO_PLATFORM_COMPONENT = "device_tracker" +# sabnzbd is the last no platform integration to be migrated +# drop these tests once it is migrated +SERVICE_NO_PLATFORM = "sabnzbd" +SERVICE_NO_PLATFORM_COMPONENT = "sabnzbd" SERVICE_INFO = {"key": "value"} # Can be anything UNKNOWN_SERVICE = "this_service_will_never_be_supported" diff --git a/tests/components/netgear/__init__.py b/tests/components/netgear/__init__.py new file mode 100644 index 00000000000..7ef2f96cced --- /dev/null +++ b/tests/components/netgear/__init__.py @@ -0,0 +1 @@ +"""Tests for the Netgear component.""" diff --git a/tests/components/netgear/conftest.py b/tests/components/netgear/conftest.py new file mode 100644 index 00000000000..f60b9be62a5 --- /dev/null +++ b/tests/components/netgear/conftest.py @@ -0,0 +1,14 @@ +"""Configure Netgear tests.""" +from unittest.mock import patch + +import pytest + + +@pytest.fixture(name="bypass_setup", autouse=True) +def bypass_setup_fixture(): + """Mock component setup.""" + with patch( + "homeassistant.components.netgear.device_tracker.async_get_scanner", + return_value=None, + ): + yield diff --git a/tests/components/netgear/test_config_flow.py b/tests/components/netgear/test_config_flow.py new file mode 100644 index 00000000000..de4f4fba510 --- /dev/null +++ b/tests/components/netgear/test_config_flow.py @@ -0,0 +1,284 @@ +"""Tests for the Netgear config flow.""" +import logging +from unittest.mock import Mock, patch + +from pynetgear import DEFAULT_HOST, DEFAULT_PORT, DEFAULT_USER +import pytest + +from homeassistant import data_entry_flow +from homeassistant.components import ssdp +from homeassistant.components.netgear.const import CONF_CONSIDER_HOME, DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_SSDP, SOURCE_USER +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, +) + +from tests.common import MockConfigEntry + +_LOGGER = logging.getLogger(__name__) + +URL = "http://routerlogin.net" +SERIAL = "5ER1AL0000001" + +ROUTER_INFOS = { + "Description": "Netgear Smart Wizard 3.0, specification 1.6 version", + "SignalStrength": "-4", + "SmartAgentversion": "3.0", + "FirewallVersion": "net-wall 2.0", + "VPNVersion": None, + "OthersoftwareVersion": "N/A", + "Hardwareversion": "N/A", + "Otherhardwareversion": "N/A", + "FirstUseDate": "Sunday, 30 Sep 2007 01:10:03", + "DeviceMode": "0", + "ModelName": "RBR20", + "SerialNumber": SERIAL, + "Firmwareversion": "V2.3.5.26", + "DeviceName": "Desk", + "DeviceNameUserSet": "true", + "FirmwareDLmethod": "HTTPS", + "FirmwareLastUpdate": "2019_10.5_18:42:58", + "FirmwareLastChecked": "2020_5.3_1:33:0", + "DeviceModeCapability": "0;1", +} +TITLE = f"{ROUTER_INFOS['ModelName']} - {ROUTER_INFOS['DeviceName']}" + +HOST = "10.0.0.1" +SERIAL_2 = "5ER1AL0000002" +PORT = 80 +SSL = False +USERNAME = "Home_Assistant" +PASSWORD = "password" +SSDP_URL = f"http://{HOST}:{PORT}/rootDesc.xml" +SSDP_URL_SLL = f"https://{HOST}:{PORT}/rootDesc.xml" + + +@pytest.fixture(name="service") +def mock_controller_service(): + """Mock a successful service.""" + with patch( + "homeassistant.components.netgear.async_setup_entry", return_value=True + ), patch("homeassistant.components.netgear.router.Netgear") as service_mock: + service_mock.return_value.get_info = Mock(return_value=ROUTER_INFOS) + yield service_mock + + +@pytest.fixture(name="service_failed") +def mock_controller_service_failed(): + """Mock a failed service.""" + with patch("homeassistant.components.netgear.router.Netgear") as service_mock: + service_mock.return_value.login = Mock(return_value=None) + service_mock.return_value.get_info = Mock(return_value=None) + yield service_mock + + +async def test_user(hass, service): + """Test user step.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + # Have to provide all config + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_SSL: SSL, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["result"].unique_id == SERIAL + assert result["title"] == TITLE + assert result["data"].get(CONF_HOST) == HOST + assert result["data"].get(CONF_PORT) == PORT + assert result["data"].get(CONF_SSL) == SSL + assert result["data"].get(CONF_USERNAME) == USERNAME + assert result["data"][CONF_PASSWORD] == PASSWORD + + +async def test_import_required(hass, service): + """Test import step, with required config only.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data={CONF_PASSWORD: PASSWORD} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["result"].unique_id == SERIAL + assert result["title"] == TITLE + assert result["data"].get(CONF_HOST) == DEFAULT_HOST + assert result["data"].get(CONF_PORT) == DEFAULT_PORT + assert result["data"].get(CONF_SSL) is False + assert result["data"].get(CONF_USERNAME) == DEFAULT_USER + assert result["data"][CONF_PASSWORD] == PASSWORD + + +async def test_import_required_login_failed(hass, service_failed): + """Test import step, with required config only, while wrong password or connection issue.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data={CONF_PASSWORD: PASSWORD} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "config"} + + +async def test_import_all(hass, service): + """Test import step, with all config provided.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_SSL: SSL, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["result"].unique_id == SERIAL + assert result["title"] == TITLE + assert result["data"].get(CONF_HOST) == HOST + assert result["data"].get(CONF_PORT) == PORT + assert result["data"].get(CONF_SSL) == SSL + assert result["data"].get(CONF_USERNAME) == USERNAME + assert result["data"][CONF_PASSWORD] == PASSWORD + + +async def test_import_all_connection_failed(hass, service_failed): + """Test import step, with all config provided, while wrong host.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_SSL: SSL, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "config"} + + +async def test_abort_if_already_setup(hass, service): + """Test we abort if the router is already setup.""" + MockConfigEntry( + domain=DOMAIN, + data={CONF_PASSWORD: PASSWORD}, + unique_id=SERIAL, + ).add_to_hass(hass) + + # Should fail, same SERIAL (import) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_PASSWORD: PASSWORD}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + # Should fail, same SERIAL (flow) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: PASSWORD}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_ssdp_already_configured(hass): + """Test ssdp abort when the router is already configured.""" + MockConfigEntry( + domain=DOMAIN, + data={CONF_PASSWORD: PASSWORD}, + unique_id=SERIAL, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_SSDP}, + data={ + ssdp.ATTR_SSDP_LOCATION: SSDP_URL_SLL, + ssdp.ATTR_UPNP_MODEL_NUMBER: "RBR20", + ssdp.ATTR_UPNP_PRESENTATION_URL: URL, + ssdp.ATTR_UPNP_SERIAL: SERIAL, + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_ssdp(hass, service): + """Test ssdp step.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_SSDP}, + data={ + ssdp.ATTR_SSDP_LOCATION: SSDP_URL, + ssdp.ATTR_UPNP_MODEL_NUMBER: "RBR20", + ssdp.ATTR_UPNP_PRESENTATION_URL: URL, + ssdp.ATTR_UPNP_SERIAL: SERIAL, + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PASSWORD: PASSWORD} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["result"].unique_id == SERIAL + assert result["title"] == TITLE + assert result["data"].get(CONF_HOST) == HOST + assert result["data"].get(CONF_PORT) == PORT + assert result["data"].get(CONF_SSL) == SSL + assert result["data"].get(CONF_USERNAME) == DEFAULT_USER + assert result["data"][CONF_PASSWORD] == PASSWORD + + +async def test_options_flow(hass, service): + """Test specifying non default settings using options flow.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_PASSWORD: PASSWORD}, + unique_id=SERIAL, + title=TITLE, + ) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_CONSIDER_HOME: 1800, + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert config_entry.options == { + CONF_CONSIDER_HOME: 1800, + } From 86d24bec75ffadf230fee12ada400c5307d93818 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 13 Sep 2021 19:29:38 +0200 Subject: [PATCH 379/843] Update icons for MDI 6 (#56170) --- homeassistant/components/coinbase/sensor.py | 2 +- homeassistant/components/ebox/sensor.py | 2 +- homeassistant/components/fido/sensor.py | 4 ++-- homeassistant/components/habitica/sensor.py | 2 +- homeassistant/components/homematic/entity.py | 2 +- homeassistant/components/icloud/device_tracker.py | 8 ++++---- homeassistant/components/jewish_calendar/sensor.py | 2 +- 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/coinbase/sensor.py b/homeassistant/components/coinbase/sensor.py index d5abb7d66f5..f37af04065e 100644 --- a/homeassistant/components/coinbase/sensor.py +++ b/homeassistant/components/coinbase/sensor.py @@ -31,7 +31,7 @@ CURRENCY_ICONS = { "USD": "mdi:currency-usd", } -DEFAULT_COIN_ICON = "mdi:currency-usd-circle" +DEFAULT_COIN_ICON = "mdi:cash" ATTRIBUTION = "Data provided by coinbase.com" diff --git a/homeassistant/components/ebox/sensor.py b/homeassistant/components/ebox/sensor.py index 3c43dd36130..7ec453b7c3a 100644 --- a/homeassistant/components/ebox/sensor.py +++ b/homeassistant/components/ebox/sensor.py @@ -52,7 +52,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="balance", name="Balance", native_unit_of_measurement=PRICE, - icon="mdi:cash-usd", + icon="mdi:cash", ), SensorEntityDescription( key="limit", diff --git a/homeassistant/components/fido/sensor.py b/homeassistant/components/fido/sensor.py index 0e61b580902..6964abd9b3d 100644 --- a/homeassistant/components/fido/sensor.py +++ b/homeassistant/components/fido/sensor.py @@ -44,13 +44,13 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="fido_dollar", name="Fido dollar", native_unit_of_measurement=PRICE, - icon="mdi:cash-usd", + icon="mdi:cash", ), SensorEntityDescription( key="balance", name="Balance", native_unit_of_measurement=PRICE, - icon="mdi:cash-usd", + icon="mdi:cash", ), SensorEntityDescription( key="data_used", diff --git a/homeassistant/components/habitica/sensor.py b/homeassistant/components/habitica/sensor.py index eb42426e8ea..ae27e0a51fc 100644 --- a/homeassistant/components/habitica/sensor.py +++ b/homeassistant/components/habitica/sensor.py @@ -26,7 +26,7 @@ SENSORS_TYPES = { "exp": ST("EXP", "mdi:star", "EXP", ["stats", "exp"]), "toNextLevel": ST("Next Lvl", "mdi:star", "EXP", ["stats", "toNextLevel"]), "lvl": ST("Lvl", "mdi:arrow-up-bold-circle-outline", "Lvl", ["stats", "lvl"]), - "gp": ST("Gold", "mdi:currency-usd-circle", "Gold", ["stats", "gp"]), + "gp": ST("Gold", "mdi:circle-multiple", "Gold", ["stats", "gp"]), "class": ST("Class", "mdi:sword", "", ["stats", "class"]), } diff --git a/homeassistant/components/homematic/entity.py b/homeassistant/components/homematic/entity.py index 50b9bcb2bfc..2fb23f707e3 100644 --- a/homeassistant/components/homematic/entity.py +++ b/homeassistant/components/homematic/entity.py @@ -238,7 +238,7 @@ class HMHub(Entity): @property def icon(self): """Return the icon to use in the frontend, if any.""" - return "mdi:gradient" + return "mdi:gradient-vertical" def _update_hub(self, now): """Retrieve latest state.""" diff --git a/homeassistant/components/icloud/device_tracker.py b/homeassistant/components/icloud/device_tracker.py index 2f53e782750..233df6a7556 100644 --- a/homeassistant/components/icloud/device_tracker.py +++ b/homeassistant/components/icloud/device_tracker.py @@ -134,13 +134,13 @@ class IcloudTrackerEntity(TrackerEntity): def icon_for_icloud_device(icloud_device: IcloudDevice) -> str: - """Return a battery icon valid identifier.""" + """Return an icon for the device.""" switcher = { - "iPad": "mdi:tablet-ipad", - "iPhone": "mdi:cellphone-iphone", + "iPad": "mdi:tablet", + "iPhone": "mdi:cellphone", "iPod": "mdi:ipod", "iMac": "mdi:desktop-mac", - "MacBookPro": "mdi:laptop-mac", + "MacBookPro": "mdi:laptop", } return switcher.get(icloud_device.device_class, "mdi:cellphone-link") diff --git a/homeassistant/components/jewish_calendar/sensor.py b/homeassistant/components/jewish_calendar/sensor.py index 824eba46973..6a57eb0eeda 100644 --- a/homeassistant/components/jewish_calendar/sensor.py +++ b/homeassistant/components/jewish_calendar/sensor.py @@ -22,7 +22,7 @@ DATA_SENSORS = ( SensorEntityDescription( key="date", name="Date", - icon="mdi:judaism", + icon="mdi:star-david", ), SensorEntityDescription( key="weekly_portion", From 17efafb2ea737a9bbb849afe294f0a955c3a309e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 13 Sep 2021 19:40:24 +0200 Subject: [PATCH 380/843] Do not set assumed state for binary sensor groups (#56190) --- homeassistant/components/group/binary_sensor.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/group/binary_sensor.py b/homeassistant/components/group/binary_sensor.py index 93d4e1e066e..24d6cb86aa1 100644 --- a/homeassistant/components/group/binary_sensor.py +++ b/homeassistant/components/group/binary_sensor.py @@ -70,8 +70,6 @@ async def async_setup_platform( class BinarySensorGroup(GroupEntity, BinarySensorEntity): """Representation of a BinarySensorGroup.""" - _attr_assumed_state: bool = True - def __init__( self, unique_id: str | None, From 20d96ef4efad960a5b2729142eb09235527d44ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Mon, 13 Sep 2021 21:11:10 +0200 Subject: [PATCH 381/843] Update docker base image to 2021.09.0 (#56191) --- build.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/build.json b/build.json index bdb59943d72..1b9c72e8675 100644 --- a/build.json +++ b/build.json @@ -2,11 +2,11 @@ "image": "homeassistant/{arch}-homeassistant", "shadow_repository": "ghcr.io/home-assistant", "build_from": { - "aarch64": "ghcr.io/home-assistant/aarch64-homeassistant-base:2021.08.0", - "armhf": "ghcr.io/home-assistant/armhf-homeassistant-base:2021.08.0", - "armv7": "ghcr.io/home-assistant/armv7-homeassistant-base:2021.08.0", - "amd64": "ghcr.io/home-assistant/amd64-homeassistant-base:2021.08.0", - "i386": "ghcr.io/home-assistant/i386-homeassistant-base:2021.08.0" + "aarch64": "ghcr.io/home-assistant/aarch64-homeassistant-base:2021.09.0", + "armhf": "ghcr.io/home-assistant/armhf-homeassistant-base:2021.09.0", + "armv7": "ghcr.io/home-assistant/armv7-homeassistant-base:2021.09.0", + "amd64": "ghcr.io/home-assistant/amd64-homeassistant-base:2021.09.0", + "i386": "ghcr.io/home-assistant/i386-homeassistant-base:2021.09.0" }, "labels": { "io.hass.type": "core", @@ -19,4 +19,4 @@ "org.opencontainers.image.licenses": "Apache License 2.0" }, "version_tag": true -} +} \ No newline at end of file From d661a76462e2be967aaab6a6f269ecb09802ebfa Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 13 Sep 2021 21:55:33 +0200 Subject: [PATCH 382/843] Use entity description and set state class to all System Monitor sensors (#56140) --- .../components/systemmonitor/sensor.py | 281 ++++++++++++------ 1 file changed, 187 insertions(+), 94 deletions(-) diff --git a/homeassistant/components/systemmonitor/sensor.py b/homeassistant/components/systemmonitor/sensor.py index 687e9e8e521..9360f2a3168 100644 --- a/homeassistant/components/systemmonitor/sensor.py +++ b/homeassistant/components/systemmonitor/sensor.py @@ -14,7 +14,13 @@ from typing import Any, cast import psutil import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + STATE_CLASS_TOTAL, + STATE_CLASS_TOTAL_INCREASING, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( CONF_RESOURCES, CONF_SCAN_INTERVAL, @@ -60,62 +66,180 @@ SENSOR_TYPE_MANDATORY_ARG = 4 SIGNAL_SYSTEMMONITOR_UPDATE = "systemmonitor_update" -# Schema: [name, unit of measurement, icon, device class, flag if mandatory arg] -SENSOR_TYPES: dict[str, tuple[str, str | None, str | None, str | None, bool]] = { - "disk_free": ("Disk free", DATA_GIBIBYTES, "mdi:harddisk", None, False), - "disk_use": ("Disk use", DATA_GIBIBYTES, "mdi:harddisk", None, False), - "disk_use_percent": ( - "Disk use (percent)", - PERCENTAGE, - "mdi:harddisk", - None, - False, + +@dataclass +class SysMonitorSensorEntityDescription(SensorEntityDescription): + """Description for System Monitor sensor entities.""" + + mandatory_arg: bool = False + + +SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription] = { + "disk_free": SysMonitorSensorEntityDescription( + key="disk_free", + name="Disk free", + native_unit_of_measurement=DATA_GIBIBYTES, + icon="mdi:harddisk", + state_class=STATE_CLASS_TOTAL, ), - "ipv4_address": ("IPv4 address", "", "mdi:server-network", None, True), - "ipv6_address": ("IPv6 address", "", "mdi:server-network", None, True), - "last_boot": ("Last boot", None, None, DEVICE_CLASS_TIMESTAMP, False), - "load_15m": ("Load (15m)", " ", CPU_ICON, None, False), - "load_1m": ("Load (1m)", " ", CPU_ICON, None, False), - "load_5m": ("Load (5m)", " ", CPU_ICON, None, False), - "memory_free": ("Memory free", DATA_MEBIBYTES, "mdi:memory", None, False), - "memory_use": ("Memory use", DATA_MEBIBYTES, "mdi:memory", None, False), - "memory_use_percent": ( - "Memory use (percent)", - PERCENTAGE, - "mdi:memory", - None, - False, + "disk_use": SysMonitorSensorEntityDescription( + key="disk_use", + name="Disk use", + native_unit_of_measurement=DATA_GIBIBYTES, + icon="mdi:harddisk", + state_class=STATE_CLASS_TOTAL, ), - "network_in": ("Network in", DATA_MEBIBYTES, "mdi:server-network", None, True), - "network_out": ("Network out", DATA_MEBIBYTES, "mdi:server-network", None, True), - "packets_in": ("Packets in", " ", "mdi:server-network", None, True), - "packets_out": ("Packets out", " ", "mdi:server-network", None, True), - "throughput_network_in": ( - "Network throughput in", - DATA_RATE_MEGABYTES_PER_SECOND, - "mdi:server-network", - None, - True, + "disk_use_percent": SysMonitorSensorEntityDescription( + key="disk_use_percent", + name="Disk use (percent)", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:harddisk", + state_class=STATE_CLASS_TOTAL, ), - "throughput_network_out": ( - "Network throughput out", - DATA_RATE_MEGABYTES_PER_SECOND, - "mdi:server-network", - None, - True, + "ipv4_address": SysMonitorSensorEntityDescription( + key="ipv4_address", + name="IPv4 address", + icon="mdi:server-network", + mandatory_arg=True, ), - "process": ("Process", " ", CPU_ICON, None, True), - "processor_use": ("Processor use (percent)", PERCENTAGE, CPU_ICON, None, False), - "processor_temperature": ( - "Processor temperature", - TEMP_CELSIUS, - None, - DEVICE_CLASS_TEMPERATURE, - False, + "ipv6_address": SysMonitorSensorEntityDescription( + key="ipv6_address", + name="IPv6 address", + icon="mdi:server-network", + mandatory_arg=True, + ), + "last_boot": SysMonitorSensorEntityDescription( + key="last_boot", + name="Last boot", + device_class=DEVICE_CLASS_TIMESTAMP, + ), + "load_15m": SysMonitorSensorEntityDescription( + key="load_15m", + name="Load (15m)", + icon=CPU_ICON, + state_class=STATE_CLASS_TOTAL, + ), + "load_1m": SysMonitorSensorEntityDescription( + key="load_1m", + name="Load (1m)", + icon=CPU_ICON, + state_class=STATE_CLASS_TOTAL, + ), + "load_5m": SysMonitorSensorEntityDescription( + key="load_5m", + name="Load (5m)", + icon=CPU_ICON, + state_class=STATE_CLASS_TOTAL, + ), + "memory_free": SysMonitorSensorEntityDescription( + key="memory_free", + name="Memory free", + native_unit_of_measurement=DATA_MEBIBYTES, + icon="mdi:memory", + state_class=STATE_CLASS_TOTAL, + ), + "memory_use": SysMonitorSensorEntityDescription( + key="memory_use", + name="Memory use", + native_unit_of_measurement=DATA_MEBIBYTES, + icon="mdi:memory", + state_class=STATE_CLASS_TOTAL, + ), + "memory_use_percent": SysMonitorSensorEntityDescription( + key="memory_use_percent", + name="Memory use (percent)", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:memory", + state_class=STATE_CLASS_TOTAL, + ), + "network_in": SysMonitorSensorEntityDescription( + key="network_in", + name="Network in", + native_unit_of_measurement=DATA_MEBIBYTES, + icon="mdi:server-network", + state_class=STATE_CLASS_TOTAL_INCREASING, + mandatory_arg=True, + ), + "network_out": SysMonitorSensorEntityDescription( + key="network_out", + name="Network out", + native_unit_of_measurement=DATA_MEBIBYTES, + icon="mdi:server-network", + state_class=STATE_CLASS_TOTAL_INCREASING, + mandatory_arg=True, + ), + "packets_in": SysMonitorSensorEntityDescription( + key="packets_in", + name="Packets in", + icon="mdi:server-network", + state_class=STATE_CLASS_TOTAL_INCREASING, + mandatory_arg=True, + ), + "packets_out": SysMonitorSensorEntityDescription( + key="packets_out", + name="Packets out", + icon="mdi:server-network", + state_class=STATE_CLASS_TOTAL_INCREASING, + mandatory_arg=True, + ), + "throughput_network_in": SysMonitorSensorEntityDescription( + key="throughput_network_in", + name="Network throughput in", + native_unit_of_measurement=DATA_RATE_MEGABYTES_PER_SECOND, + icon="mdi:server-network", + state_class=STATE_CLASS_TOTAL, + mandatory_arg=True, + ), + "throughput_network_out": SysMonitorSensorEntityDescription( + key="throughput_network_out", + name="Network throughput out", + native_unit_of_measurement=DATA_RATE_MEGABYTES_PER_SECOND, + icon="mdi:server-network", + state_class=STATE_CLASS_TOTAL, + mandatory_arg=True, + ), + "process": SysMonitorSensorEntityDescription( + key="process", + name="Process", + icon=CPU_ICON, + state_class=STATE_CLASS_TOTAL, + mandatory_arg=True, + ), + "processor_use": SysMonitorSensorEntityDescription( + key="processor_use", + name="Processor use", + native_unit_of_measurement=PERCENTAGE, + icon=CPU_ICON, + state_class=STATE_CLASS_TOTAL, + ), + "processor_temperature": SysMonitorSensorEntityDescription( + key="processor_temperature", + name="Processor temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_TOTAL, + ), + "swap_free": SysMonitorSensorEntityDescription( + key="swap_free", + name="Swap free", + native_unit_of_measurement=DATA_MEBIBYTES, + icon="mdi:harddisk", + state_class=STATE_CLASS_TOTAL, + ), + "swap_use": SysMonitorSensorEntityDescription( + key="swap_use", + name="Swap use", + native_unit_of_measurement=DATA_MEBIBYTES, + icon="mdi:harddisk", + state_class=STATE_CLASS_TOTAL, + ), + "swap_use_percent": SysMonitorSensorEntityDescription( + key="swap_use_percent", + name="Swap use (percent)", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:harddisk", + state_class=STATE_CLASS_TOTAL, ), - "swap_free": ("Swap free", DATA_MEBIBYTES, "mdi:harddisk", None, False), - "swap_use": ("Swap use", DATA_MEBIBYTES, "mdi:harddisk", None, False), - "swap_use_percent": ("Swap use (percent)", PERCENTAGE, "mdi:harddisk", None, False), } @@ -125,7 +249,7 @@ def check_required_arg(value: Any) -> Any: sensor_type = sensor[CONF_TYPE] sensor_arg = sensor.get(CONF_ARG) - if sensor_arg is None and SENSOR_TYPES[sensor_type][SENSOR_TYPE_MANDATORY_ARG]: + if sensor_arg is None and SENSOR_TYPES[sensor_type].mandatory_arg: raise vol.RequiredFieldInvalid( f"Mandatory 'arg' is missing for sensor type '{sensor_type}'." ) @@ -230,7 +354,9 @@ async def async_setup_platform( sensor_registry[(type_, argument)] = SensorData( argument, None, None, None, None ) - entities.append(SystemMonitorSensor(sensor_registry, type_, argument)) + entities.append( + SystemMonitorSensor(sensor_registry, SENSOR_TYPES[type_], argument) + ) scan_interval = config.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) await async_setup_sensor_registry_updates(hass, sensor_registry, scan_interval) @@ -297,68 +423,35 @@ async def async_setup_sensor_registry_updates( class SystemMonitorSensor(SensorEntity): """Implementation of a system monitor sensor.""" + should_poll = False + def __init__( self, sensor_registry: dict[tuple[str, str], SensorData], - sensor_type: str, + sensor_description: SysMonitorSensorEntityDescription, argument: str = "", ) -> None: """Initialize the sensor.""" - self._type: str = sensor_type - self._name: str = f"{self.sensor_type[SENSOR_TYPE_NAME]} {argument}".rstrip() - self._unique_id: str = slugify(f"{sensor_type}_{argument}") + self.entity_description = sensor_description + self._attr_name: str = f"{sensor_description.name} {argument}".rstrip() + self._attr_unique_id: str = slugify(f"{sensor_description.key}_{argument}") self._sensor_registry = sensor_registry self._argument: str = argument - @property - def name(self) -> str: - """Return the name of the sensor.""" - return self._name - - @property - def unique_id(self) -> str: - """Return the unique ID.""" - return self._unique_id - - @property - def device_class(self) -> str | None: - """Return the class of this sensor.""" - return self.sensor_type[SENSOR_TYPE_DEVICE_CLASS] # type: ignore[no-any-return] - - @property - def icon(self) -> str | None: - """Icon to use in the frontend, if any.""" - return self.sensor_type[SENSOR_TYPE_ICON] # type: ignore[no-any-return] - @property def native_value(self) -> str | None: """Return the state of the device.""" return self.data.state - @property - def native_unit_of_measurement(self) -> str | None: - """Return the unit of measurement of this entity, if any.""" - return self.sensor_type[SENSOR_TYPE_UOM] # type: ignore[no-any-return] - @property def available(self) -> bool: """Return True if entity is available.""" return self.data.last_exception is None - @property - def should_poll(self) -> bool: - """Entity does not poll.""" - return False - - @property - def sensor_type(self) -> list: - """Return sensor type data for the sensor.""" - return SENSOR_TYPES[self._type] # type: ignore - @property def data(self) -> SensorData: """Return registry entry for the data.""" - return self._sensor_registry[(self._type, self._argument)] + return self._sensor_registry[(self.entity_description.key, self._argument)] async def async_added_to_hass(self) -> None: """When entity is added to hass.""" From f9de8fb49ab4d31b0f1547d1dcba95e2a3a341ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Mon, 13 Sep 2021 21:57:06 +0200 Subject: [PATCH 383/843] Surepetcare config flow (#56127) Co-authored-by: J. Nick Koston --- .../components/surepetcare/__init__.py | 50 ++++-- .../components/surepetcare/binary_sensor.py | 8 +- .../components/surepetcare/config_flow.py | 85 +++++++++++ .../components/surepetcare/manifest.json | 14 +- .../components/surepetcare/sensor.py | 6 +- .../components/surepetcare/strings.json | 20 +++ homeassistant/generated/config_flows.py | 1 + tests/components/surepetcare/conftest.py | 1 + .../surepetcare/test_config_flow.py | 144 ++++++++++++++++++ 9 files changed, 303 insertions(+), 26 deletions(-) create mode 100644 homeassistant/components/surepetcare/config_flow.py create mode 100644 homeassistant/components/surepetcare/strings.json create mode 100644 tests/components/surepetcare/test_config_flow.py diff --git a/homeassistant/components/surepetcare/__init__.py b/homeassistant/components/surepetcare/__init__.py index f04af0dd795..5462cfc954c 100644 --- a/homeassistant/components/surepetcare/__init__.py +++ b/homeassistant/components/surepetcare/__init__.py @@ -9,7 +9,14 @@ from surepy.enums import LockState from surepy.exceptions import SurePetcareAuthenticationError, SurePetcareError import voluptuous as vol -from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME +from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_PASSWORD, + CONF_SCAN_INTERVAL, + CONF_TOKEN, + CONF_USERNAME, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -61,14 +68,28 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Sure Petcare integration.""" - conf = config[DOMAIN] + if DOMAIN not in config: + return True + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=config[DOMAIN], + ) + ) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Sure Petcare from a config entry.""" hass.data.setdefault(DOMAIN, {}) try: surepy = Surepy( - conf[CONF_USERNAME], - conf[CONF_PASSWORD], - auth_token=None, + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + auth_token=entry.data[CONF_TOKEN], api_timeout=SURE_API_TIMEOUT, session=async_get_clientsession(hass), ) @@ -94,14 +115,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: update_interval=SCAN_INTERVAL, ) - hass.data[DOMAIN] = coordinator - await coordinator.async_refresh() + hass.data[DOMAIN][entry.entry_id] = coordinator + await coordinator.async_config_entry_first_refresh() - # load platforms - for platform in PLATFORMS: - hass.async_create_task( - hass.helpers.discovery.async_load_platform(platform, DOMAIN, {}, config) - ) + hass.config_entries.async_setup_platforms(entry, PLATFORMS) lock_states = { LockState.UNLOCKED.name.lower(): surepy.sac.unlock, @@ -138,3 +155,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/surepetcare/binary_sensor.py b/homeassistant/components/surepetcare/binary_sensor.py index 0c223ae3ac1..b61eae12a7e 100644 --- a/homeassistant/components/surepetcare/binary_sensor.py +++ b/homeassistant/components/surepetcare/binary_sensor.py @@ -23,16 +23,12 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -async def async_setup_platform( - hass, config, async_add_entities, discovery_info=None -) -> None: +async def async_setup_entry(hass, entry, async_add_entities) -> None: """Set up Sure PetCare Flaps binary sensors based on a config entry.""" - if discovery_info is None: - return entities: list[SurepyEntity | Pet | Hub | DeviceConnectivity] = [] - coordinator: DataUpdateCoordinator = hass.data[DOMAIN] + coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] for surepy_entity in coordinator.data.values(): diff --git a/homeassistant/components/surepetcare/config_flow.py b/homeassistant/components/surepetcare/config_flow.py new file mode 100644 index 00000000000..e2e5f07f05e --- /dev/null +++ b/homeassistant/components/surepetcare/config_flow.py @@ -0,0 +1,85 @@ +"""Config flow for Sure Petcare integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from surepy import Surepy +from surepy.exceptions import SurePetcareAuthenticationError, SurePetcareError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN, SURE_API_TIMEOUT + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: + """Validate the user input allows us to connect.""" + surepy = Surepy( + data[CONF_USERNAME], + data[CONF_PASSWORD], + auth_token=None, + api_timeout=SURE_API_TIMEOUT, + session=async_get_clientsession(hass), + ) + + token = await surepy.sac.get_token() + + return {CONF_TOKEN: token} + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Sure Petcare.""" + + VERSION = 1 + + async def async_step_import(self, import_info): + """Set the config entry up from yaml.""" + return await self.async_step_user(import_info) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA + ) + + errors = {} + + try: + info = await validate_input(self.hass, user_input) + except SurePetcareAuthenticationError: + errors["base"] = "invalid_auth" + except SurePetcareError: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(user_input[CONF_USERNAME].lower()) + self._abort_if_unique_id_configured() + + user_input[CONF_TOKEN] = info[CONF_TOKEN] + return self.async_create_entry( + title="Sure Petcare", + data=user_input, + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/surepetcare/manifest.json b/homeassistant/components/surepetcare/manifest.json index 9fb35ac91e5..466f73644b6 100644 --- a/homeassistant/components/surepetcare/manifest.json +++ b/homeassistant/components/surepetcare/manifest.json @@ -2,7 +2,13 @@ "domain": "surepetcare", "name": "Sure Petcare", "documentation": "https://www.home-assistant.io/integrations/surepetcare", - "codeowners": ["@benleb", "@danielhiversen"], - "requirements": ["surepy==0.7.1"], - "iot_class": "cloud_polling" -} + "codeowners": [ + "@benleb", + "@danielhiversen" + ], + "requirements": [ + "surepy==0.7.1" + ], + "iot_class": "cloud_polling", + "config_flow": true +} \ No newline at end of file diff --git a/homeassistant/components/surepetcare/sensor.py b/homeassistant/components/surepetcare/sensor.py index 53d5d985e41..0122dc2905d 100644 --- a/homeassistant/components/surepetcare/sensor.py +++ b/homeassistant/components/surepetcare/sensor.py @@ -19,14 +19,12 @@ from .const import DOMAIN, SURE_BATT_VOLTAGE_DIFF, SURE_BATT_VOLTAGE_LOW _LOGGER = logging.getLogger(__name__) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_entry(hass, entry, async_add_entities): """Set up Sure PetCare Flaps sensors.""" - if discovery_info is None: - return entities: list[SurepyEntity] = [] - coordinator: DataUpdateCoordinator = hass.data[DOMAIN] + coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] for surepy_entity in coordinator.data.values(): diff --git a/homeassistant/components/surepetcare/strings.json b/homeassistant/components/surepetcare/strings.json new file mode 100644 index 00000000000..f5e2f6f173b --- /dev/null +++ b/homeassistant/components/surepetcare/strings.json @@ -0,0 +1,20 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index f6fac775b2d..738a2d1172a 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -265,6 +265,7 @@ FLOWS = [ "srp_energy", "starline", "subaru", + "surepetcare", "switcher_kis", "syncthing", "syncthru", diff --git a/tests/components/surepetcare/conftest.py b/tests/components/surepetcare/conftest.py index cecdaababa9..dd1cd19aa0e 100644 --- a/tests/components/surepetcare/conftest.py +++ b/tests/components/surepetcare/conftest.py @@ -19,4 +19,5 @@ async def surepetcare(): client = mock_client_class.return_value client.resources = {} client.call = _mock_call + client.get_token.return_value = "token" yield client diff --git a/tests/components/surepetcare/test_config_flow.py b/tests/components/surepetcare/test_config_flow.py new file mode 100644 index 00000000000..d397c9b121a --- /dev/null +++ b/tests/components/surepetcare/test_config_flow.py @@ -0,0 +1,144 @@ +"""Test the Sure Petcare config flow.""" +from unittest.mock import NonCallableMagicMock, patch + +from surepy.exceptions import SurePetcareAuthenticationError, SurePetcareError + +from homeassistant import config_entries, setup +from homeassistant.components.surepetcare.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + +from tests.common import MockConfigEntry + + +async def test_form(hass: HomeAssistant, surepetcare: NonCallableMagicMock) -> None: + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + with patch( + "homeassistant.components.surepetcare.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "Sure Petcare" + assert result2["data"] == { + "username": "test-username", + "password": "test-password", + "token": "token", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_auth(hass: HomeAssistant) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "surepy.client.SureAPIClient.get_token", + side_effect=SurePetcareAuthenticationError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_cannot_connect(hass: HomeAssistant) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "surepy.client.SureAPIClient.get_token", + side_effect=SurePetcareError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_unknown_error(hass: HomeAssistant) -> None: + """Test we handle unknown error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "surepy.client.SureAPIClient.get_token", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "unknown"} + + +async def test_flow_entry_already_exists( + hass, surepetcare: NonCallableMagicMock +) -> None: + """Test user input for config_entry that already exists.""" + first_entry = MockConfigEntry( + domain="surepetcare", + data={ + "username": "test-username", + "password": "test-password", + }, + unique_id="test-username", + ) + first_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.surepetcare.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={ + "username": "test-username", + "password": "test-password", + }, + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" From f023ec24d70f1e54229bf1170fb792f27cd3500d Mon Sep 17 00:00:00 2001 From: Martin Ilievski Date: Mon, 13 Sep 2021 22:00:10 +0200 Subject: [PATCH 384/843] Bump pykodi to 0.2.6 (#56148) --- homeassistant/components/kodi/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/kodi/manifest.json b/homeassistant/components/kodi/manifest.json index 78d0c6e5998..f88a893c7fa 100644 --- a/homeassistant/components/kodi/manifest.json +++ b/homeassistant/components/kodi/manifest.json @@ -2,7 +2,7 @@ "domain": "kodi", "name": "Kodi", "documentation": "https://www.home-assistant.io/integrations/kodi", - "requirements": ["pykodi==0.2.5"], + "requirements": ["pykodi==0.2.6"], "codeowners": ["@OnFreund", "@cgtobi"], "zeroconf": ["_xbmc-jsonrpc-h._tcp.local."], "config_flow": true, diff --git a/requirements_all.txt b/requirements_all.txt index 84acb3f0b8f..02f095c90ec 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1551,7 +1551,7 @@ pykira==0.1.1 pykmtronic==0.3.0 # homeassistant.components.kodi -pykodi==0.2.5 +pykodi==0.2.6 # homeassistant.components.kraken pykrakenapi==0.1.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 47ff575035b..f08ecf68af3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -891,7 +891,7 @@ pykira==0.1.1 pykmtronic==0.3.0 # homeassistant.components.kodi -pykodi==0.2.5 +pykodi==0.2.6 # homeassistant.components.kraken pykrakenapi==0.1.8 From c869b78ac1e94032602125b2b9d9c5890f50b1d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Mon, 13 Sep 2021 21:02:34 +0100 Subject: [PATCH 385/843] Add Whirlpool integration (#48346) * Add Whirlpool integration * Apply suggestions from code review Co-authored-by: Paulus Schoutsen * Apply suggestions from code review * Fix lint * Fix lint and tests * Apply suggestions from code review Co-authored-by: J. Nick Koston * Use dict lookups * Lint * Apply code changes from PR review * Do real integration setup in tests * Apply suggestions from review & fix test * Replace get with array operator * Add suggestions from code review * Rename test var Co-authored-by: Paulus Schoutsen Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + .../components/whirlpool/__init__.py | 45 +++ homeassistant/components/whirlpool/climate.py | 189 +++++++++ .../components/whirlpool/config_flow.py | 76 ++++ homeassistant/components/whirlpool/const.py | 4 + .../components/whirlpool/manifest.json | 13 + .../components/whirlpool/strings.json | 17 + .../components/whirlpool/translations/en.json | 22 ++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/whirlpool/__init__.py | 23 ++ tests/components/whirlpool/conftest.py | 41 ++ tests/components/whirlpool/test_climate.py | 364 ++++++++++++++++++ .../components/whirlpool/test_config_flow.py | 122 ++++++ tests/components/whirlpool/test_init.py | 49 +++ 16 files changed, 973 insertions(+) create mode 100644 homeassistant/components/whirlpool/__init__.py create mode 100644 homeassistant/components/whirlpool/climate.py create mode 100644 homeassistant/components/whirlpool/config_flow.py create mode 100644 homeassistant/components/whirlpool/const.py create mode 100644 homeassistant/components/whirlpool/manifest.json create mode 100644 homeassistant/components/whirlpool/strings.json create mode 100644 homeassistant/components/whirlpool/translations/en.json create mode 100644 tests/components/whirlpool/__init__.py create mode 100644 tests/components/whirlpool/conftest.py create mode 100644 tests/components/whirlpool/test_climate.py create mode 100644 tests/components/whirlpool/test_config_flow.py create mode 100644 tests/components/whirlpool/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index 8127c30d357..d47b267daaa 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -576,6 +576,7 @@ homeassistant/components/weather/* @fabaff homeassistant/components/webostv/* @bendavid @thecode homeassistant/components/websocket_api/* @home-assistant/core homeassistant/components/wemo/* @esev +homeassistant/components/whirlpool/* @abmantis homeassistant/components/wiffi/* @mampfes homeassistant/components/wilight/* @leofig-rj homeassistant/components/wirelesstag/* @sergeymaysak diff --git a/homeassistant/components/whirlpool/__init__.py b/homeassistant/components/whirlpool/__init__.py new file mode 100644 index 00000000000..2c51ee07cc4 --- /dev/null +++ b/homeassistant/components/whirlpool/__init__.py @@ -0,0 +1,45 @@ +"""The Whirlpool Sixth Sense integration.""" +import logging + +import aiohttp +from whirlpool.auth import Auth + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import AUTH_INSTANCE_KEY, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = ["climate"] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up Whirlpool Sixth Sense from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + + auth = Auth(entry.data["username"], entry.data["password"]) + try: + await auth.do_auth(store=False) + except aiohttp.ClientError as ex: + raise ConfigEntryNotReady("Cannot connect") from ex + + if not auth.is_access_token_valid(): + _LOGGER.error("Authentication failed") + return False + + hass.data[DOMAIN][entry.entry_id] = {AUTH_INSTANCE_KEY: auth} + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/whirlpool/climate.py b/homeassistant/components/whirlpool/climate.py new file mode 100644 index 00000000000..811b05435bd --- /dev/null +++ b/homeassistant/components/whirlpool/climate.py @@ -0,0 +1,189 @@ +"""Platform for climate integration.""" +import asyncio +import logging + +import aiohttp +from whirlpool.aircon import Aircon, FanSpeed as AirconFanSpeed, Mode as AirconMode +from whirlpool.auth import Auth + +from homeassistant.components.climate import ClimateEntity +from homeassistant.components.climate.const import ( + FAN_AUTO, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + FAN_OFF, + HVAC_MODE_COOL, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + SUPPORT_FAN_MODE, + SUPPORT_SWING_MODE, + SUPPORT_TARGET_TEMPERATURE, + SWING_HORIZONTAL, + SWING_OFF, +) +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS + +from .const import AUTH_INSTANCE_KEY, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +AIRCON_MODE_MAP = { + AirconMode.Cool: HVAC_MODE_COOL, + AirconMode.Heat: HVAC_MODE_HEAT, + AirconMode.Fan: HVAC_MODE_FAN_ONLY, +} + +HVAC_MODE_TO_AIRCON_MODE = {v: k for k, v in AIRCON_MODE_MAP.items()} + +AIRCON_FANSPEED_MAP = { + AirconFanSpeed.Off: FAN_OFF, + AirconFanSpeed.Auto: FAN_AUTO, + AirconFanSpeed.Low: FAN_LOW, + AirconFanSpeed.Medium: FAN_MEDIUM, + AirconFanSpeed.High: FAN_HIGH, +} + +FAN_MODE_TO_AIRCON_FANSPEED = {v: k for k, v in AIRCON_FANSPEED_MAP.items()} + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up entry.""" + auth: Auth = hass.data[DOMAIN][config_entry.entry_id][AUTH_INSTANCE_KEY] + said_list = auth.get_said_list() + if not said_list: + _LOGGER.debug("No appliances found") + return + + # the whirlpool library needs to be updated to be able to support more + # than one device, so we use only the first one for now + aircon = AirConEntity(said_list[0], auth) + async_add_entities([aircon], True) + + +class AirConEntity(ClimateEntity): + """Representation of an air conditioner.""" + + _attr_fan_modes = [FAN_AUTO, FAN_HIGH, FAN_MEDIUM, FAN_LOW, FAN_OFF] + _attr_hvac_modes = [ + HVAC_MODE_COOL, + HVAC_MODE_HEAT, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_OFF, + ] + _attr_max_temp = 30 + _attr_min_temp = 16 + _attr_supported_features = ( + SUPPORT_TARGET_TEMPERATURE | SUPPORT_FAN_MODE | SUPPORT_SWING_MODE + ) + _attr_swing_modes = [SWING_HORIZONTAL, SWING_OFF] + _attr_target_temperature_step = 1 + _attr_temperature_unit = TEMP_CELSIUS + _attr_should_poll = False + + def __init__(self, said, auth: Auth): + """Initialize the entity.""" + self._aircon = Aircon(auth, said, self.async_write_ha_state) + + self._attr_name = said + self._attr_unique_id = said + + async def async_added_to_hass(self) -> None: + """Connect aircon to the cloud.""" + await self._aircon.connect() + + try: + name = await self._aircon.fetch_name() + if name is not None: + self._attr_name = name + except (asyncio.TimeoutError, aiohttp.ClientError): + _LOGGER.exception("Failed to get name") + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._aircon.get_online() + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._aircon.get_current_temp() + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._aircon.get_temp() + + async def async_set_temperature(self, **kwargs): + """Set new target temperature.""" + await self._aircon.set_temp(kwargs.get(ATTR_TEMPERATURE)) + + @property + def current_humidity(self): + """Return the current humidity.""" + return self._aircon.get_current_humidity() + + @property + def target_humidity(self): + """Return the humidity we try to reach.""" + return self._aircon.get_humidity() + + async def async_set_humidity(self, humidity: int) -> None: + """Set new target humidity.""" + await self._aircon.set_humidity(humidity) + + @property + def hvac_mode(self): + """Return current operation ie. heat, cool, fan.""" + if not self._aircon.get_power_on(): + return HVAC_MODE_OFF + + mode: AirconMode = self._aircon.get_mode() + return AIRCON_MODE_MAP.get(mode, None) + + async def async_set_hvac_mode(self, hvac_mode): + """Set HVAC mode.""" + if hvac_mode == HVAC_MODE_OFF: + await self._aircon.set_power_on(False) + return + + mode = HVAC_MODE_TO_AIRCON_MODE.get(hvac_mode) + if not mode: + _LOGGER.warning("Unexpected hvac mode: %s", hvac_mode) + return + + await self._aircon.set_mode(mode) + if not self._aircon.get_power_on(): + await self._aircon.set_power_on(True) + + @property + def fan_mode(self): + """Return the fan setting.""" + fanspeed = self._aircon.get_fanspeed() + return AIRCON_FANSPEED_MAP.get(fanspeed, FAN_OFF) + + async def async_set_fan_mode(self, fan_mode): + """Set fan mode.""" + fanspeed = FAN_MODE_TO_AIRCON_FANSPEED.get(fan_mode) + if not fanspeed: + return + await self._aircon.set_fanspeed(fanspeed) + + @property + def swing_mode(self): + """Return the swing setting.""" + return SWING_HORIZONTAL if self._aircon.get_h_louver_swing() else SWING_OFF + + async def async_set_swing_mode(self, swing_mode): + """Set new target temperature.""" + await self._aircon.set_h_louver_swing(swing_mode == SWING_HORIZONTAL) + + async def async_turn_on(self): + """Turn device on.""" + await self._aircon.set_power_on(True) + + async def async_turn_off(self): + """Turn device off.""" + await self._aircon.set_power_on(False) diff --git a/homeassistant/components/whirlpool/config_flow.py b/homeassistant/components/whirlpool/config_flow.py new file mode 100644 index 00000000000..d5fdfd90568 --- /dev/null +++ b/homeassistant/components/whirlpool/config_flow.py @@ -0,0 +1,76 @@ +"""Config flow for Whirlpool Sixth Sense integration.""" +import asyncio +import logging + +import aiohttp +import voluptuous as vol +from whirlpool.auth import Auth + +from homeassistant import config_entries, core, exceptions +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from .const import DOMAIN # pylint: disable=unused-import + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema({CONF_USERNAME: str, CONF_PASSWORD: str}) + + +async def validate_input(hass: core.HomeAssistant, data): + """Validate the user input allows us to connect. + + Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + """ + auth = Auth(data[CONF_USERNAME], data[CONF_PASSWORD]) + try: + await auth.do_auth() + except (asyncio.TimeoutError, aiohttp.ClientConnectionError) as exc: + raise CannotConnect from exc + + if not auth.is_access_token_valid(): + raise InvalidAuth + + return {"title": data[CONF_USERNAME]} + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Whirlpool Sixth Sense.""" + + VERSION = 1 + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA + ) + + errors = {} + + try: + info = await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id( + user_input[CONF_USERNAME].lower(), raise_on_progress=False + ) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=info["title"], data=user_input) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(exceptions.HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/whirlpool/const.py b/homeassistant/components/whirlpool/const.py new file mode 100644 index 00000000000..16ba293e3b2 --- /dev/null +++ b/homeassistant/components/whirlpool/const.py @@ -0,0 +1,4 @@ +"""Constants for the Whirlpool Sixth Sense integration.""" + +DOMAIN = "whirlpool" +AUTH_INSTANCE_KEY = "auth" diff --git a/homeassistant/components/whirlpool/manifest.json b/homeassistant/components/whirlpool/manifest.json new file mode 100644 index 00000000000..9df10f32931 --- /dev/null +++ b/homeassistant/components/whirlpool/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "whirlpool", + "name": "Whirlpool Sixth Sense", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/whirlpool", + "requirements": [ + "whirlpool-sixth-sense==0.15.1" + ], + "codeowners": [ + "@abmantis" + ], + "iot_class": "cloud_push" +} diff --git a/homeassistant/components/whirlpool/strings.json b/homeassistant/components/whirlpool/strings.json new file mode 100644 index 00000000000..4925d73e4c4 --- /dev/null +++ b/homeassistant/components/whirlpool/strings.json @@ -0,0 +1,17 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/whirlpool/translations/en.json b/homeassistant/components/whirlpool/translations/en.json new file mode 100644 index 00000000000..407d41d6736 --- /dev/null +++ b/homeassistant/components/whirlpool/translations/en.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "host": "Host", + "password": "Password", + "username": "Username" + } + } + } + }, + "title": "Whirlpool Sixth Sense" +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 738a2d1172a..80baa455f9b 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -302,6 +302,7 @@ FLOWS = [ "wallbox", "waze_travel_time", "wemo", + "whirlpool", "wiffi", "wilight", "withings", diff --git a/requirements_all.txt b/requirements_all.txt index 02f095c90ec..47068a6f72e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2390,6 +2390,9 @@ waterfurnace==1.1.0 # homeassistant.components.cisco_webex_teams webexteamssdk==1.1.1 +# homeassistant.components.whirlpool +whirlpool-sixth-sense==0.15.1 + # homeassistant.components.wiffi wiffi==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f08ecf68af3..cd63782d34b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1337,6 +1337,9 @@ wallbox==0.4.4 # homeassistant.components.folder_watcher watchdog==2.1.4 +# homeassistant.components.whirlpool +whirlpool-sixth-sense==0.15.1 + # homeassistant.components.wiffi wiffi==1.0.1 diff --git a/tests/components/whirlpool/__init__.py b/tests/components/whirlpool/__init__.py new file mode 100644 index 00000000000..3f50518b4ad --- /dev/null +++ b/tests/components/whirlpool/__init__.py @@ -0,0 +1,23 @@ +"""Tests for the Whirlpool Sixth Sense integration.""" +from homeassistant.components.whirlpool.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def init_integration(hass: HomeAssistant) -> MockConfigEntry: + """Set up the Whirlpool integration in Home Assistant.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_USERNAME: "nobody", + CONF_PASSWORD: "qwerty", + }, + ) + + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry diff --git a/tests/components/whirlpool/conftest.py b/tests/components/whirlpool/conftest.py new file mode 100644 index 00000000000..e3919c118e2 --- /dev/null +++ b/tests/components/whirlpool/conftest.py @@ -0,0 +1,41 @@ +"""Fixtures for the Whirlpool Sixth Sense integration tests.""" +from unittest import mock +from unittest.mock import AsyncMock + +import pytest +import whirlpool + +MOCK_SAID = "said1" + + +@pytest.fixture(name="mock_auth_api") +def fixture_mock_auth_api(): + """Set up air conditioner Auth fixture.""" + with mock.patch("homeassistant.components.whirlpool.Auth") as mock_auth: + mock_auth.return_value.do_auth = AsyncMock() + mock_auth.return_value.is_access_token_valid.return_value = True + mock_auth.return_value.get_said_list.return_value = [MOCK_SAID] + yield mock_auth + + +@pytest.fixture(name="mock_aircon_api", autouse=True) +def fixture_mock_aircon_api(mock_auth_api): + """Set up air conditioner API fixture.""" + with mock.patch( + "homeassistant.components.whirlpool.climate.Aircon" + ) as mock_aircon_api: + mock_aircon_api.return_value.connect = AsyncMock() + mock_aircon_api.return_value.fetch_name = AsyncMock(return_value="TestZone") + mock_aircon_api.return_value.said = MOCK_SAID + mock_aircon_api.return_value.get_online.return_value = True + mock_aircon_api.return_value.get_power_on.return_value = True + mock_aircon_api.return_value.get_mode.return_value = whirlpool.aircon.Mode.Cool + mock_aircon_api.return_value.get_fanspeed.return_value = ( + whirlpool.aircon.FanSpeed.Auto + ) + mock_aircon_api.return_value.get_current_temp.return_value = 15 + mock_aircon_api.return_value.get_temp.return_value = 20 + mock_aircon_api.return_value.get_current_humidity.return_value = 80 + mock_aircon_api.return_value.get_humidity.return_value = 50 + mock_aircon_api.return_value.get_h_louver_swing.return_value = True + yield mock_aircon_api diff --git a/tests/components/whirlpool/test_climate.py b/tests/components/whirlpool/test_climate.py new file mode 100644 index 00000000000..77009607947 --- /dev/null +++ b/tests/components/whirlpool/test_climate.py @@ -0,0 +1,364 @@ +"""Test the Whirlpool Sixth Sense climate domain.""" +from unittest.mock import AsyncMock, MagicMock + +import aiohttp +import whirlpool + +from homeassistant.components.climate.const import ( + ATTR_CURRENT_HUMIDITY, + ATTR_CURRENT_TEMPERATURE, + ATTR_FAN_MODE, + ATTR_FAN_MODES, + ATTR_HVAC_MODE, + ATTR_HVAC_MODES, + ATTR_MAX_TEMP, + ATTR_MIN_TEMP, + ATTR_SWING_MODE, + ATTR_SWING_MODES, + ATTR_TARGET_TEMP_STEP, + DOMAIN as CLIMATE_DOMAIN, + FAN_AUTO, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + FAN_MIDDLE, + FAN_OFF, + HVAC_MODE_AUTO, + HVAC_MODE_COOL, + HVAC_MODE_DRY, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + SERVICE_SET_FAN_MODE, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_SWING_MODE, + SERVICE_SET_TEMPERATURE, + SUPPORT_FAN_MODE, + SUPPORT_SWING_MODE, + SUPPORT_TARGET_TEMPERATURE, + SWING_HORIZONTAL, + SWING_OFF, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_FRIENDLY_NAME, + ATTR_SUPPORTED_FEATURES, + ATTR_TEMPERATURE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_UNAVAILABLE, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + + +async def update_ac_state(hass: HomeAssistant, mock_aircon_api: MagicMock): + """Simulate an update trigger from the API.""" + update_ha_state_cb = mock_aircon_api.call_args.args[2] + update_ha_state_cb() + await hass.async_block_till_done() + return hass.states.get("climate.said1") + + +async def test_no_appliances(hass: HomeAssistant, mock_auth_api: MagicMock): + """Test the setup of the climate entities when there are no appliances available.""" + mock_auth_api.return_value.get_said_list.return_value = [] + await init_integration(hass) + assert len(hass.states.async_all()) == 0 + + +async def test_name_fallback_on_exception( + hass: HomeAssistant, mock_aircon_api: MagicMock +): + """Test name property.""" + mock_aircon_api.return_value.fetch_name = AsyncMock( + side_effect=aiohttp.ClientError() + ) + + await init_integration(hass) + state = hass.states.get("climate.said1") + assert state.attributes[ATTR_FRIENDLY_NAME] == "said1" + + +async def test_static_attributes(hass: HomeAssistant, mock_aircon_api: MagicMock): + """Test static climate attributes.""" + await init_integration(hass) + + entry = er.async_get(hass).async_get("climate.said1") + assert entry + assert entry.unique_id == "said1" + + state = hass.states.get("climate.said1") + assert state is not None + assert state.state != STATE_UNAVAILABLE + assert state.state == HVAC_MODE_COOL + + attributes = state.attributes + assert attributes[ATTR_FRIENDLY_NAME] == "TestZone" + + assert ( + attributes[ATTR_SUPPORTED_FEATURES] + == SUPPORT_TARGET_TEMPERATURE | SUPPORT_FAN_MODE | SUPPORT_SWING_MODE + ) + assert attributes[ATTR_HVAC_MODES] == [ + HVAC_MODE_COOL, + HVAC_MODE_HEAT, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_OFF, + ] + assert attributes[ATTR_FAN_MODES] == [ + FAN_AUTO, + FAN_HIGH, + FAN_MEDIUM, + FAN_LOW, + FAN_OFF, + ] + assert attributes[ATTR_SWING_MODES] == [SWING_HORIZONTAL, SWING_OFF] + assert attributes[ATTR_TARGET_TEMP_STEP] == 1 + assert attributes[ATTR_MIN_TEMP] == 16 + assert attributes[ATTR_MAX_TEMP] == 30 + + +async def test_dynamic_attributes(hass: HomeAssistant, mock_aircon_api: MagicMock): + """Test dynamic attributes.""" + await init_integration(hass) + + state = hass.states.get("climate.said1") + assert state is not None + assert state.state == HVAC_MODE_COOL + + mock_aircon_api.return_value.get_power_on.return_value = False + state = await update_ac_state(hass, mock_aircon_api) + assert state.state == HVAC_MODE_OFF + + mock_aircon_api.return_value.get_online.return_value = False + state = await update_ac_state(hass, mock_aircon_api) + assert state.state == STATE_UNAVAILABLE + + mock_aircon_api.return_value.get_power_on.return_value = True + mock_aircon_api.return_value.get_online.return_value = True + state = await update_ac_state(hass, mock_aircon_api) + assert state.state == HVAC_MODE_COOL + + mock_aircon_api.return_value.get_mode.return_value = whirlpool.aircon.Mode.Heat + state = await update_ac_state(hass, mock_aircon_api) + assert state.state == HVAC_MODE_HEAT + + mock_aircon_api.return_value.get_mode.return_value = whirlpool.aircon.Mode.Fan + state = await update_ac_state(hass, mock_aircon_api) + assert state.state == HVAC_MODE_FAN_ONLY + + mock_aircon_api.return_value.get_fanspeed.return_value = ( + whirlpool.aircon.FanSpeed.Auto + ) + state = await update_ac_state(hass, mock_aircon_api) + assert state.attributes[ATTR_FAN_MODE] == HVAC_MODE_AUTO + + mock_aircon_api.return_value.get_fanspeed.return_value = ( + whirlpool.aircon.FanSpeed.Low + ) + state = await update_ac_state(hass, mock_aircon_api) + assert state.attributes[ATTR_FAN_MODE] == FAN_LOW + + mock_aircon_api.return_value.get_fanspeed.return_value = ( + whirlpool.aircon.FanSpeed.Medium + ) + state = await update_ac_state(hass, mock_aircon_api) + assert state.attributes[ATTR_FAN_MODE] == FAN_MEDIUM + + mock_aircon_api.return_value.get_fanspeed.return_value = ( + whirlpool.aircon.FanSpeed.High + ) + state = await update_ac_state(hass, mock_aircon_api) + assert state.attributes[ATTR_FAN_MODE] == FAN_HIGH + + mock_aircon_api.return_value.get_fanspeed.return_value = ( + whirlpool.aircon.FanSpeed.Off + ) + state = await update_ac_state(hass, mock_aircon_api) + assert state.attributes[ATTR_FAN_MODE] == FAN_OFF + + mock_aircon_api.return_value.get_current_temp.return_value = 15 + mock_aircon_api.return_value.get_temp.return_value = 20 + mock_aircon_api.return_value.get_current_humidity.return_value = 80 + mock_aircon_api.return_value.get_h_louver_swing.return_value = True + attributes = (await update_ac_state(hass, mock_aircon_api)).attributes + assert attributes[ATTR_CURRENT_TEMPERATURE] == 15 + assert attributes[ATTR_TEMPERATURE] == 20 + assert attributes[ATTR_CURRENT_HUMIDITY] == 80 + assert attributes[ATTR_SWING_MODE] == SWING_HORIZONTAL + + mock_aircon_api.return_value.get_current_temp.return_value = 16 + mock_aircon_api.return_value.get_temp.return_value = 21 + mock_aircon_api.return_value.get_current_humidity.return_value = 70 + mock_aircon_api.return_value.get_h_louver_swing.return_value = False + attributes = (await update_ac_state(hass, mock_aircon_api)).attributes + assert attributes[ATTR_CURRENT_TEMPERATURE] == 16 + assert attributes[ATTR_TEMPERATURE] == 21 + assert attributes[ATTR_CURRENT_HUMIDITY] == 70 + assert attributes[ATTR_SWING_MODE] == SWING_OFF + + +async def test_service_calls(hass: HomeAssistant, mock_aircon_api: MagicMock): + """Test controlling the entity through service calls.""" + await init_integration(hass) + mock_aircon_api.return_value.set_power_on = AsyncMock() + mock_aircon_api.return_value.set_mode = AsyncMock() + mock_aircon_api.return_value.set_temp = AsyncMock() + mock_aircon_api.return_value.set_humidity = AsyncMock() + mock_aircon_api.return_value.set_mode = AsyncMock() + mock_aircon_api.return_value.set_fanspeed = AsyncMock() + mock_aircon_api.return_value.set_h_louver_swing = AsyncMock() + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "climate.said1"}, + blocking=True, + ) + mock_aircon_api.return_value.set_power_on.assert_called_once_with(False) + + mock_aircon_api.return_value.set_power_on.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "climate.said1"}, + blocking=True, + ) + mock_aircon_api.return_value.set_power_on.assert_called_once_with(True) + + mock_aircon_api.return_value.set_power_on.reset_mock() + mock_aircon_api.return_value.get_power_on.return_value = False + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.said1", ATTR_HVAC_MODE: HVAC_MODE_COOL}, + blocking=True, + ) + mock_aircon_api.return_value.set_power_on.assert_called_once_with(True) + + mock_aircon_api.return_value.set_temp.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: "climate.said1", ATTR_TEMPERATURE: 15}, + blocking=True, + ) + mock_aircon_api.return_value.set_temp.assert_called_once_with(15) + + mock_aircon_api.return_value.set_mode.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.said1", ATTR_HVAC_MODE: HVAC_MODE_COOL}, + blocking=True, + ) + mock_aircon_api.return_value.set_mode.assert_called_once_with( + whirlpool.aircon.Mode.Cool + ) + + mock_aircon_api.return_value.set_mode.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.said1", ATTR_HVAC_MODE: HVAC_MODE_HEAT}, + blocking=True, + ) + mock_aircon_api.return_value.set_mode.assert_called_once_with( + whirlpool.aircon.Mode.Heat + ) + + mock_aircon_api.return_value.set_mode.reset_mock() + # HVAC_MODE_DRY should be ignored + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.said1", ATTR_HVAC_MODE: HVAC_MODE_DRY}, + blocking=True, + ) + mock_aircon_api.return_value.set_mode.assert_not_called() + + mock_aircon_api.return_value.set_mode.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.said1", ATTR_HVAC_MODE: HVAC_MODE_FAN_ONLY}, + blocking=True, + ) + mock_aircon_api.return_value.set_mode.assert_called_once_with( + whirlpool.aircon.Mode.Fan + ) + + mock_aircon_api.return_value.set_fanspeed.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: "climate.said1", ATTR_FAN_MODE: FAN_AUTO}, + blocking=True, + ) + mock_aircon_api.return_value.set_fanspeed.assert_called_once_with( + whirlpool.aircon.FanSpeed.Auto + ) + + mock_aircon_api.return_value.set_fanspeed.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: "climate.said1", ATTR_FAN_MODE: FAN_LOW}, + blocking=True, + ) + mock_aircon_api.return_value.set_fanspeed.assert_called_once_with( + whirlpool.aircon.FanSpeed.Low + ) + + mock_aircon_api.return_value.set_fanspeed.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: "climate.said1", ATTR_FAN_MODE: FAN_MEDIUM}, + blocking=True, + ) + mock_aircon_api.return_value.set_fanspeed.assert_called_once_with( + whirlpool.aircon.FanSpeed.Medium + ) + + mock_aircon_api.return_value.set_fanspeed.reset_mock() + # FAN_MIDDLE should be ignored + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: "climate.said1", ATTR_FAN_MODE: FAN_MIDDLE}, + blocking=True, + ) + mock_aircon_api.return_value.set_fanspeed.assert_not_called() + + mock_aircon_api.return_value.set_fanspeed.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: "climate.said1", ATTR_FAN_MODE: FAN_HIGH}, + blocking=True, + ) + mock_aircon_api.return_value.set_fanspeed.assert_called_once_with( + whirlpool.aircon.FanSpeed.High + ) + + mock_aircon_api.return_value.set_h_louver_swing.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_SWING_MODE, + {ATTR_ENTITY_ID: "climate.said1", ATTR_SWING_MODE: SWING_HORIZONTAL}, + blocking=True, + ) + mock_aircon_api.return_value.set_h_louver_swing.assert_called_with(True) + + mock_aircon_api.return_value.set_h_louver_swing.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_SWING_MODE, + {ATTR_ENTITY_ID: "climate.said1", ATTR_SWING_MODE: SWING_OFF}, + blocking=True, + ) + mock_aircon_api.return_value.set_h_louver_swing.assert_called_with(False) diff --git a/tests/components/whirlpool/test_config_flow.py b/tests/components/whirlpool/test_config_flow.py new file mode 100644 index 00000000000..6746e406a85 --- /dev/null +++ b/tests/components/whirlpool/test_config_flow.py @@ -0,0 +1,122 @@ +"""Test the Whirlpool Sixth Sense config flow.""" +import asyncio +from unittest.mock import patch + +import aiohttp + +from homeassistant import config_entries +from homeassistant.components.whirlpool.const import DOMAIN + + +async def test_form(hass): + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == config_entries.SOURCE_USER + + with patch("homeassistant.components.whirlpool.config_flow.Auth.do_auth"), patch( + "homeassistant.components.whirlpool.config_flow.Auth.is_access_token_valid", + return_value=True, + ), patch( + "homeassistant.components.whirlpool.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == "test-username" + assert result2["data"] == { + "username": "test-username", + "password": "test-password", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_auth(hass): + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + with patch("homeassistant.components.whirlpool.config_flow.Auth.do_auth"), patch( + "homeassistant.components.whirlpool.config_flow.Auth.is_access_token_valid", + return_value=False, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_cannot_connect(hass): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + with patch( + "homeassistant.components.whirlpool.config_flow.Auth.do_auth", + side_effect=aiohttp.ClientConnectionError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_auth_timeout(hass): + """Test we handle auth timeout error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + with patch( + "homeassistant.components.whirlpool.config_flow.Auth.do_auth", + side_effect=asyncio.TimeoutError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_generic_auth_exception(hass): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + with patch( + "homeassistant.components.whirlpool.config_flow.Auth.do_auth", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + assert result2["type"] == "form" + assert result2["errors"] == {"base": "unknown"} diff --git a/tests/components/whirlpool/test_init.py b/tests/components/whirlpool/test_init.py new file mode 100644 index 00000000000..00fc27ddc63 --- /dev/null +++ b/tests/components/whirlpool/test_init.py @@ -0,0 +1,49 @@ +"""Test the Whirlpool Sixth Sense init.""" +from unittest.mock import AsyncMock, MagicMock + +import aiohttp + +from homeassistant.components.whirlpool.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.components.whirlpool import init_integration + + +async def test_setup(hass: HomeAssistant): + """Test setup.""" + entry = await init_integration(hass) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state is ConfigEntryState.LOADED + + +async def test_setup_http_exception(hass: HomeAssistant, mock_auth_api: MagicMock): + """Test setup with an http exception.""" + mock_auth_api.return_value.do_auth = AsyncMock( + side_effect=aiohttp.ClientConnectionError() + ) + entry = await init_integration(hass) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_auth_failed(hass: HomeAssistant, mock_auth_api: MagicMock): + """Test setup with failed auth.""" + mock_auth_api.return_value.do_auth = AsyncMock() + mock_auth_api.return_value.is_access_token_valid.return_value = False + entry = await init_integration(hass) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_unload_entry(hass: HomeAssistant): + """Test successful unload of entry.""" + entry = await init_integration(hass) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED + assert not hass.data.get(DOMAIN) From 8d87f4148b2ba5141fc4ac189b98eab833ea2fe3 Mon Sep 17 00:00:00 2001 From: Brian Egge Date: Mon, 13 Sep 2021 16:27:06 -0400 Subject: [PATCH 386/843] Fix generic thermostat switch state initialization (#56073) --- .../components/generic_thermostat/climate.py | 27 +++++--- .../generic_thermostat/test_climate.py | 68 ++++++++++++++++++- 2 files changed, 83 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/generic_thermostat/climate.py b/homeassistant/components/generic_thermostat/climate.py index a659d13cb7e..c48deba12d8 100644 --- a/homeassistant/components/generic_thermostat/climate.py +++ b/homeassistant/components/generic_thermostat/climate.py @@ -227,6 +227,12 @@ class GenericThermostat(ClimateEntity, RestoreEntity): ): self._async_update_temp(sensor_state) self.async_write_ha_state() + switch_state = self.hass.states.get(self.heater_entity_id) + if switch_state and switch_state.state not in ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + self.hass.create_task(self._check_switch_initial_state()) if self.hass.state == CoreState.running: _async_startup() @@ -270,14 +276,6 @@ class GenericThermostat(ClimateEntity, RestoreEntity): if not self._hvac_mode: self._hvac_mode = HVAC_MODE_OFF - # Prevent the device from keep running if HVAC_MODE_OFF - if self._hvac_mode == HVAC_MODE_OFF and self._is_device_active: - await self._async_heater_turn_off() - _LOGGER.warning( - "The climate mode is OFF, but the switch device is ON. Turning off device %s", - self.heater_entity_id, - ) - @property def should_poll(self): """Return the polling state.""" @@ -401,12 +399,24 @@ class GenericThermostat(ClimateEntity, RestoreEntity): await self._async_control_heating() self.async_write_ha_state() + async def _check_switch_initial_state(self): + """Prevent the device from keep running if HVAC_MODE_OFF.""" + if self._hvac_mode == HVAC_MODE_OFF and self._is_device_active: + _LOGGER.warning( + "The climate mode is OFF, but the switch device is ON. Turning off device %s", + self.heater_entity_id, + ) + await self._async_heater_turn_off() + @callback def _async_switch_changed(self, event): """Handle heater switch state changes.""" new_state = event.data.get("new_state") + old_state = event.data.get("old_state") if new_state is None: return + if old_state is None: + self.hass.create_task(self._check_switch_initial_state()) self.async_write_ha_state() @callback @@ -426,7 +436,6 @@ class GenericThermostat(ClimateEntity, RestoreEntity): if not self._active and None not in ( self._cur_temp, self._target_temp, - self._is_device_active, ): self._active = True _LOGGER.info( diff --git a/tests/components/generic_thermostat/test_climate.py b/tests/components/generic_thermostat/test_climate.py index 7363ee8a32a..4015887efbb 100644 --- a/tests/components/generic_thermostat/test_climate.py +++ b/tests/components/generic_thermostat/test_climate.py @@ -42,6 +42,7 @@ from homeassistant.util.unit_system import METRIC_SYSTEM from tests.common import ( assert_setup_component, async_fire_time_changed, + async_mock_service, mock_restore_cache, ) from tests.components.climate import common @@ -1189,14 +1190,15 @@ async def test_custom_setup_params(hass): assert state.attributes.get("temperature") == TARGET_TEMP -async def test_restore_state(hass): +@pytest.mark.parametrize("hvac_mode", [HVAC_MODE_OFF, HVAC_MODE_HEAT, HVAC_MODE_COOL]) +async def test_restore_state(hass, hvac_mode): """Ensure states are restored on startup.""" mock_restore_cache( hass, ( State( "climate.test_thermostat", - HVAC_MODE_OFF, + hvac_mode, {ATTR_TEMPERATURE: "20", ATTR_PRESET_MODE: PRESET_AWAY}, ), ), @@ -1221,7 +1223,7 @@ async def test_restore_state(hass): state = hass.states.get("climate.test_thermostat") assert state.attributes[ATTR_TEMPERATURE] == 20 assert state.attributes[ATTR_PRESET_MODE] == PRESET_AWAY - assert state.state == HVAC_MODE_OFF + assert state.state == hvac_mode async def test_no_restore_state(hass): @@ -1347,6 +1349,66 @@ async def test_restore_will_turn_off_(hass): assert hass.states.get(heater_switch).state == STATE_ON +async def test_restore_will_turn_off_when_loaded_second(hass): + """Ensure that restored state is coherent with real situation. + + Switch is not available until after component is loaded + """ + heater_switch = "input_boolean.test" + mock_restore_cache( + hass, + ( + State( + "climate.test_thermostat", + HVAC_MODE_HEAT, + {ATTR_TEMPERATURE: "18", ATTR_PRESET_MODE: PRESET_NONE}, + ), + State(heater_switch, STATE_ON, {}), + ), + ) + + hass.state = CoreState.starting + + await hass.async_block_till_done() + assert hass.states.get(heater_switch) is None + + _setup_sensor(hass, 16) + + await async_setup_component( + hass, + DOMAIN, + { + "climate": { + "platform": "generic_thermostat", + "name": "test_thermostat", + "heater": heater_switch, + "target_sensor": ENT_SENSOR, + "target_temp": 20, + "initial_hvac_mode": HVAC_MODE_OFF, + } + }, + ) + await hass.async_block_till_done() + state = hass.states.get("climate.test_thermostat") + assert state.attributes[ATTR_TEMPERATURE] == 20 + assert state.state == HVAC_MODE_OFF + + calls_on = async_mock_service(hass, ha.DOMAIN, SERVICE_TURN_ON) + calls_off = async_mock_service(hass, ha.DOMAIN, SERVICE_TURN_OFF) + + assert await async_setup_component( + hass, input_boolean.DOMAIN, {"input_boolean": {"test": None}} + ) + await hass.async_block_till_done() + # heater must be switched off + assert len(calls_on) == 0 + assert len(calls_off) == 1 + call = calls_off[0] + assert call.domain == HASS_DOMAIN + assert call.service == SERVICE_TURN_OFF + assert call.data["entity_id"] == "input_boolean.test" + + async def test_restore_state_uncoherence_case(hass): """ Test restore from a strange state. From 9bb9f0e0705fd1451849241d021e9f6b11b6a451 Mon Sep 17 00:00:00 2001 From: tube0013 Date: Mon, 13 Sep 2021 17:22:55 -0400 Subject: [PATCH 387/843] Add description to match TubesZB Coordinators for USB Discovery (#56201) --- homeassistant/components/zha/manifest.json | 1 + homeassistant/generated/usb.py | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 19ffff2f12b..53d0605e45c 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -16,6 +16,7 @@ ], "usb": [ {"vid":"10C4","pid":"EA60","description":"*2652*","known_devices":["slae.sh cc2652rb stick"]}, + {"vid":"10C4","pid":"EA60","description":"*tubeszb*","known_devices":["TubesZB Coordinator"]}, {"vid":"1CF1","pid":"0030","description":"*conbee*","known_devices":["Conbee II"]}, {"vid":"10C4","pid":"8A2A","description":"*zigbee*","known_devices":["Nortek HUSBZB-1"]} ], diff --git a/homeassistant/generated/usb.py b/homeassistant/generated/usb.py index 844c09fea40..f118aa2b0cb 100644 --- a/homeassistant/generated/usb.py +++ b/homeassistant/generated/usb.py @@ -12,6 +12,12 @@ USB = [ "pid": "EA60", "description": "*2652*" }, + { + "domain": "zha", + "vid": "10C4", + "pid": "EA60", + "description": "*tubeszb*" + }, { "domain": "zha", "vid": "1CF1", From 14aa9c91eb25846a1b41ac0bd3eaaebf9a245144 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Mon, 13 Sep 2021 20:22:54 -0400 Subject: [PATCH 388/843] Add Config Flow to Modem Caller ID integration (#46677) * Add phone_modem integration * Use original domain * Add init tests for Modem Caller ID * Clean up tests * Clean up tests * apply suggestions * Fix tests * Make only one instance possible * Allow more than 1 device and remove hangup service * simplify already configured * Update sensor.py * Update config_flow.py * Fix manifest * More cleanup * Fix tests * Ue target * Clean up sensor.py * Minor tweaks * Close modem on restart and unload * Update requirements * fix tests * Bump phone_modem * rework * add typing * use async_setup_platform * typing * tweak * cleanup * fix init * preserve original name * remove callback line * use list of serial devices on host * tweak * rework * Rework for usb dicsovery * Update requirements_test_all.txt * Update config_flow.py * tweaks * tweak * move api out of try statement * suggested tweaks * clean up * typing * tweak * tweak * async name the service --- CODEOWNERS | 1 + .../components/modem_callerid/__init__.py | 38 +++- .../components/modem_callerid/config_flow.py | 142 ++++++++++++ .../components/modem_callerid/const.py | 27 +++ .../components/modem_callerid/manifest.json | 11 +- .../components/modem_callerid/sensor.py | 187 ++++++++-------- .../components/modem_callerid/services.yaml | 7 + .../components/modem_callerid/strings.json | 26 +++ .../modem_callerid/translations/en.json | 26 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/usb.py | 5 + requirements_all.txt | 6 +- requirements_test_all.txt | 3 + tests/components/modem_callerid/__init__.py | 25 +++ .../modem_callerid/test_config_flow.py | 204 ++++++++++++++++++ tests/components/modem_callerid/test_init.py | 63 ++++++ 16 files changed, 673 insertions(+), 99 deletions(-) create mode 100644 homeassistant/components/modem_callerid/config_flow.py create mode 100644 homeassistant/components/modem_callerid/const.py create mode 100644 homeassistant/components/modem_callerid/services.yaml create mode 100644 homeassistant/components/modem_callerid/strings.json create mode 100644 homeassistant/components/modem_callerid/translations/en.json create mode 100644 tests/components/modem_callerid/__init__.py create mode 100644 tests/components/modem_callerid/test_config_flow.py create mode 100644 tests/components/modem_callerid/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index d47b267daaa..c05b11a5b02 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -312,6 +312,7 @@ homeassistant/components/minecraft_server/* @elmurato homeassistant/components/minio/* @tkislan homeassistant/components/mobile_app/* @robbiet480 homeassistant/components/modbus/* @adamchengtkc @janiversen @vzahradnik +homeassistant/components/modem_callerid/* @tkdrob homeassistant/components/modern_forms/* @wonderslug homeassistant/components/monoprice/* @etsinko @OnFreund homeassistant/components/moon/* @fabaff diff --git a/homeassistant/components/modem_callerid/__init__.py b/homeassistant/components/modem_callerid/__init__.py index 0ce41b0ea03..afa79f1d210 100644 --- a/homeassistant/components/modem_callerid/__init__.py +++ b/homeassistant/components/modem_callerid/__init__.py @@ -1 +1,37 @@ -"""The modem_callerid component.""" +"""The Modem Caller ID integration.""" +from phone_modem import PhoneModem + +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_DEVICE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DATA_KEY_API, DOMAIN, EXCEPTIONS + +PLATFORMS = [SENSOR_DOMAIN] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Modem Caller ID from a config entry.""" + device = entry.data[CONF_DEVICE] + api = PhoneModem(device) + try: + await api.initialize(device) + except EXCEPTIONS as ex: + raise ConfigEntryNotReady(f"Unable to open port: {device}") from ex + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {DATA_KEY_API: api} + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + api = hass.data[DOMAIN].pop(entry.entry_id)[DATA_KEY_API] + await api.close() + + return unload_ok diff --git a/homeassistant/components/modem_callerid/config_flow.py b/homeassistant/components/modem_callerid/config_flow.py new file mode 100644 index 00000000000..fbb68381c41 --- /dev/null +++ b/homeassistant/components/modem_callerid/config_flow.py @@ -0,0 +1,142 @@ +"""Config flow for Modem Caller ID integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from phone_modem import DEFAULT_PORT, PhoneModem +import serial.tools.list_ports +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components import usb +from homeassistant.const import CONF_DEVICE, CONF_NAME +from homeassistant.data_entry_flow import FlowResult + +from .const import DEFAULT_NAME, DOMAIN, EXCEPTIONS + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema({"name": str, "device": str}) + + +def _generate_unique_id(port: Any) -> str: + """Generate unique id from usb attributes.""" + return f"{port.vid}:{port.pid}_{port.serial_number}_{port.manufacturer}_{port.description}" + + +class PhoneModemFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Phone Modem.""" + + def __init__(self) -> None: + """Set up flow instance.""" + self._device: str | None = None + + async def async_step_usb(self, discovery_info: dict[str, str]) -> FlowResult: + """Handle USB Discovery.""" + device = discovery_info["device"] + + dev_path = await self.hass.async_add_executor_job(usb.get_serial_by_id, device) + unique_id = f"{discovery_info['vid']}:{discovery_info['pid']}_{discovery_info['serial_number']}_{discovery_info['manufacturer']}_{discovery_info['description']}" + if ( + await self.validate_device_errors(dev_path=dev_path, unique_id=unique_id) + is None + ): + self._device = dev_path + return await self.async_step_usb_confirm() + return self.async_abort(reason="cannot_connect") + + async def async_step_usb_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle USB Discovery confirmation.""" + if user_input is not None: + return self.async_create_entry( + title=user_input.get(CONF_NAME, DEFAULT_NAME), + data={CONF_DEVICE: self._device}, + ) + self._set_confirm_only() + return self.async_show_form(step_id="usb_confirm") + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by the user.""" + if self._async_in_progress(): + return self.async_abort(reason="already_in_progress") + ports = await self.hass.async_add_executor_job(serial.tools.list_ports.comports) + existing_devices = [ + entry.data[CONF_DEVICE] for entry in self._async_current_entries() + ] + unused_ports = [ + usb.human_readable_device_name( + port.device, + port.serial_number, + port.manufacturer, + port.description, + port.vid, + port.pid, + ) + for port in ports + if port.device not in existing_devices + ] + if not unused_ports: + return self.async_abort(reason="no_devices_found") + + if user_input is not None: + port = ports[unused_ports.index(str(user_input.get(CONF_DEVICE)))] + dev_path = await self.hass.async_add_executor_job( + usb.get_serial_by_id, port.device + ) + errors: dict | None = await self.validate_device_errors( + dev_path=dev_path, unique_id=_generate_unique_id(port) + ) + if errors is None: + return self.async_create_entry( + title=user_input.get(CONF_NAME, DEFAULT_NAME), + data={CONF_DEVICE: dev_path}, + ) + user_input = user_input or {} + schema = vol.Schema({vol.Required(CONF_DEVICE): vol.In(unused_ports)}) + return self.async_show_form( + step_id="user", data_schema=schema, errors=errors or {} + ) + + async def async_step_import(self, config: dict[str, Any]) -> FlowResult: + """Import a config entry from configuration.yaml.""" + if self._async_current_entries(): + _LOGGER.warning( + "Loading Modem_callerid via platform setup is deprecated; Please remove it from your configuration" + ) + if CONF_DEVICE not in config: + config[CONF_DEVICE] = DEFAULT_PORT + ports = await self.hass.async_add_executor_job(serial.tools.list_ports.comports) + for port in ports: + if port.device == config[CONF_DEVICE]: + if ( + await self.validate_device_errors( + dev_path=port.device, + unique_id=_generate_unique_id(port), + ) + is None + ): + return self.async_create_entry( + title=config.get(CONF_NAME, DEFAULT_NAME), + data={CONF_DEVICE: port.device}, + ) + return self.async_abort(reason="cannot_connect") + + async def validate_device_errors( + self, dev_path: str, unique_id: str + ) -> dict[str, str] | None: + """Handle common flow input validation.""" + self._async_abort_entries_match({CONF_DEVICE: dev_path}) + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured(updates={CONF_DEVICE: dev_path}) + try: + api = PhoneModem() + await api.test(dev_path) + except EXCEPTIONS: + return {"base": "cannot_connect"} + else: + return None diff --git a/homeassistant/components/modem_callerid/const.py b/homeassistant/components/modem_callerid/const.py new file mode 100644 index 00000000000..b05623f8d8b --- /dev/null +++ b/homeassistant/components/modem_callerid/const.py @@ -0,0 +1,27 @@ +"""Constants for the Modem Caller ID integration.""" +from typing import Final + +from phone_modem import exceptions +from serial import SerialException + +DATA_KEY_API = "api" +DATA_KEY_COORDINATOR = "coordinator" +DEFAULT_NAME = "Phone Modem" +DOMAIN = "modem_callerid" +ICON = "mdi:phone-classic" +SERVICE_REJECT_CALL = "reject_call" + +EXCEPTIONS: Final = ( + FileNotFoundError, + exceptions.SerialError, + exceptions.ResponseError, + SerialException, +) + + +class CID: + """CID Attributes.""" + + CID_TIME = "cid_time" + CID_NUMBER = "cid_number" + CID_NAME = "cid_name" diff --git a/homeassistant/components/modem_callerid/manifest.json b/homeassistant/components/modem_callerid/manifest.json index a3bb7b676f0..4f4264d7688 100644 --- a/homeassistant/components/modem_callerid/manifest.json +++ b/homeassistant/components/modem_callerid/manifest.json @@ -1,8 +1,11 @@ { "domain": "modem_callerid", - "name": "Modem Caller ID", + "name": "Phone Modem", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/modem_callerid", - "requirements": ["basicmodem==0.7"], - "codeowners": [], - "iot_class": "local_polling" + "requirements": ["phone_modem==0.1.1"], + "codeowners": ["@tkdrob"], + "dependencies": ["usb"], + "iot_class": "local_polling", + "usb": [{"vid":"0572","pid":"1340"}] } diff --git a/homeassistant/components/modem_callerid/sensor.py b/homeassistant/components/modem_callerid/sensor.py index afbc09eb45c..6c08ea8d6cf 100644 --- a/homeassistant/components/modem_callerid/sensor.py +++ b/homeassistant/components/modem_callerid/sensor.py @@ -1,121 +1,126 @@ """A sensor for incoming calls using a USB modem that supports caller ID.""" -import logging +from __future__ import annotations -from basicmodem.basicmodem import BasicModem as bm +from phone_modem import DEFAULT_PORT, PhoneModem import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_DEVICE, CONF_NAME, EVENT_HOMEASSISTANT_STOP, STATE_IDLE, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv, entity_platform +from homeassistant.helpers.typing import DiscoveryInfoType -_LOGGER = logging.getLogger(__name__) -DEFAULT_NAME = "Modem CallerID" -ICON = "mdi:phone-classic" -DEFAULT_DEVICE = "/dev/ttyACM0" +from .const import CID, DATA_KEY_API, DEFAULT_NAME, DOMAIN, ICON, SERVICE_REJECT_CALL -STATE_RING = "ring" -STATE_CALLERID = "callerid" - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_DEVICE, default=DEFAULT_DEVICE): cv.string, - } +# Deprecated in Home Assistant 2021.10 +PLATFORM_SCHEMA = cv.deprecated( + vol.All( + PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_DEVICE, default=DEFAULT_PORT): cv.string, + } + ) + ) ) -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up modem caller ID sensor platform.""" +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigEntry, + async_add_entities: entity_platform.AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up the Modem Caller ID component.""" + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + ) - name = config.get(CONF_NAME) - port = config.get(CONF_DEVICE) - modem = bm(port) - if modem.state == modem.STATE_FAILED: - _LOGGER.error("Unable to initialize modem") - return +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: entity_platform.AddEntitiesCallback, +) -> None: + """Set up the Modem Caller ID sensor.""" + api = hass.data[DOMAIN][entry.entry_id][DATA_KEY_API] + async_add_entities( + [ + ModemCalleridSensor( + api, + entry.title, + entry.data[CONF_DEVICE], + entry.entry_id, + ) + ] + ) - add_entities([ModemCalleridSensor(hass, name, port, modem)]) + async def _async_on_hass_stop(self) -> None: + """HA is shutting down, close modem port.""" + if hass.data[DOMAIN][entry.entry_id][DATA_KEY_API]: + await hass.data[DOMAIN][entry.entry_id][DATA_KEY_API].close() + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_on_hass_stop) + ) + + platform = entity_platform.async_get_current_platform() + + platform.async_register_entity_service(SERVICE_REJECT_CALL, {}, "async_reject_call") class ModemCalleridSensor(SensorEntity): """Implementation of USB modem caller ID sensor.""" - def __init__(self, hass, name, port, modem): + _attr_icon = ICON + _attr_should_poll = False + + def __init__( + self, api: PhoneModem, name: str, device: str, server_unique_id: str + ) -> None: """Initialize the sensor.""" - self._attributes = {"cid_time": 0, "cid_number": "", "cid_name": ""} - self._name = name - self.port = port - self.modem = modem - self._state = STATE_IDLE - modem.registercallback(self._incomingcallcallback) - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, self._stop_modem) + self.device = device + self.api = api + self._attr_name = name + self._attr_unique_id = server_unique_id + self._attr_native_value = STATE_IDLE + self._attr_extra_state_attributes = { + CID.CID_TIME: 0, + CID.CID_NUMBER: "", + CID.CID_NAME: "", + } - def set_state(self, state): - """Set the state.""" - self._state = state + async def async_added_to_hass(self) -> None: + """Call when the modem sensor is added to Home Assistant.""" + self.api.registercallback(self._async_incoming_call) + await super().async_added_to_hass() - def set_attributes(self, attributes): - """Set the state attributes.""" - self._attributes = attributes - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def icon(self): - """Return icon.""" - return ICON - - @property - def native_value(self): - """Return the state of the device.""" - return self._state - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return self._attributes - - def _stop_modem(self, event): - """HA is shutting down, close modem port.""" - if self.modem: - self.modem.close() - self.modem = None - - def _incomingcallcallback(self, newstate): + @callback + def _async_incoming_call(self, new_state) -> None: """Handle new states.""" - if newstate == self.modem.STATE_RING: - if self.state == self.modem.STATE_IDLE: - att = { - "cid_time": self.modem.get_cidtime, - "cid_number": "", - "cid_name": "", + if new_state == PhoneModem.STATE_RING: + if self.native_value == PhoneModem.STATE_IDLE: + self._attr_extra_state_attributes = { + CID.CID_NUMBER: "", + CID.CID_NAME: "", } - self.set_attributes(att) - self._state = STATE_RING - self.schedule_update_ha_state() - elif newstate == self.modem.STATE_CALLERID: - att = { - "cid_time": self.modem.get_cidtime, - "cid_number": self.modem.get_cidnumber, - "cid_name": self.modem.get_cidname, + elif new_state == PhoneModem.STATE_CALLERID: + self._attr_extra_state_attributes = { + CID.CID_NUMBER: self.api.cid_number, + CID.CID_NAME: self.api.cid_name, } - self.set_attributes(att) - self._state = STATE_CALLERID - self.schedule_update_ha_state() - elif newstate == self.modem.STATE_IDLE: - self._state = STATE_IDLE - self.schedule_update_ha_state() + self._attr_extra_state_attributes[CID.CID_TIME] = self.api.cid_time + self._attr_native_value = self.api.state + self.async_write_ha_state() + + async def async_reject_call(self) -> None: + """Reject Incoming Call.""" + await self.api.reject_call(self.device) diff --git a/homeassistant/components/modem_callerid/services.yaml b/homeassistant/components/modem_callerid/services.yaml new file mode 100644 index 00000000000..7ec8aaf3f94 --- /dev/null +++ b/homeassistant/components/modem_callerid/services.yaml @@ -0,0 +1,7 @@ +reject_call: + name: Reject Call + description: Reject incoming call. + target: + entity: + integration: modem_callerid + domain: sensor diff --git a/homeassistant/components/modem_callerid/strings.json b/homeassistant/components/modem_callerid/strings.json new file mode 100644 index 00000000000..17359128528 --- /dev/null +++ b/homeassistant/components/modem_callerid/strings.json @@ -0,0 +1,26 @@ +{ + "config": { + "step": { + "user": { + "title": "Phone Modem", + "description": "This is an integration for landline calls using a CX93001 voice modem. This can retrieve caller ID information with an option to reject an incoming call.", + "data": { + "name": "[%key:common::config_flow::data::name%]", + "port": "[%key:common::config_flow::data::port%]" + } + }, + "usb_confirm": { + "title": "Phone Modem", + "description": "This is an integration for landline calls using a CX93001 voice modem. This can retrieve caller ID information with an option to reject an incoming call." + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "no_devices_found": "No remaining devices found" + } + } + } \ No newline at end of file diff --git a/homeassistant/components/modem_callerid/translations/en.json b/homeassistant/components/modem_callerid/translations/en.json new file mode 100644 index 00000000000..207f9ab7a17 --- /dev/null +++ b/homeassistant/components/modem_callerid/translations/en.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "already_in_progress": "Configuration flow is already in progress", + "no_devices_found": "No remaining devices found" + }, + "error": { + "cannot_connect": "Failed to connect" + }, + "step": { + "user": { + "data": { + "name": "Name", + "port": "Port" + }, + "title": "Phone Modem", + "description": "This is an integration for landline calls using a CX93001 voice modem. This can retrieve caller id information with an option to reject an incoming call." + }, + "usb_confirm": { + "title": "Phone Modem", + "description": "This is an integration for landline calls using a CX93001 voice modem. This can retrieve caller ID information with an option to reject an incoming call." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 80baa455f9b..1ad789c33d7 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -167,6 +167,7 @@ FLOWS = [ "mill", "minecraft_server", "mobile_app", + "modem_callerid", "modern_forms", "monoprice", "motion_blinds", diff --git a/homeassistant/generated/usb.py b/homeassistant/generated/usb.py index f118aa2b0cb..4b1abcfd557 100644 --- a/homeassistant/generated/usb.py +++ b/homeassistant/generated/usb.py @@ -6,6 +6,11 @@ To update, run python3 -m script.hassfest # fmt: off USB = [ + { + "domain": "modem_callerid", + "vid": "0572", + "pid": "1340" + }, { "domain": "zha", "vid": "10C4", diff --git a/requirements_all.txt b/requirements_all.txt index 47068a6f72e..c25c16ff202 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -356,9 +356,6 @@ baidu-aip==1.6.6 # homeassistant.components.homekit base36==0.1.1 -# homeassistant.components.modem_callerid -basicmodem==0.7 - # homeassistant.components.linux_battery batinfo==0.4.2 @@ -1169,6 +1166,9 @@ pencompy==0.0.3 # homeassistant.components.unifi_direct pexpect==4.6.0 +# homeassistant.components.modem_callerid +phone_modem==0.1.1 + # homeassistant.components.onewire pi1wire==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cd63782d34b..dd397d5ddba 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -662,6 +662,9 @@ pdunehd==1.3.2 # homeassistant.components.unifi_direct pexpect==4.6.0 +# homeassistant.components.modem_callerid +phone_modem==0.1.1 + # homeassistant.components.onewire pi1wire==0.1.0 diff --git a/tests/components/modem_callerid/__init__.py b/tests/components/modem_callerid/__init__.py new file mode 100644 index 00000000000..2ff0e87c9cd --- /dev/null +++ b/tests/components/modem_callerid/__init__.py @@ -0,0 +1,25 @@ +"""Tests for the Modem Caller ID integration.""" + +from unittest.mock import patch + +from phone_modem import DEFAULT_PORT + +from homeassistant.const import CONF_DEVICE + +CONF_DATA = {CONF_DEVICE: DEFAULT_PORT} + +IMPORT_DATA = {"sensor": {"platform": "modem_callerid"}} + + +def _patch_init_modem(mocked_modem): + return patch( + "homeassistant.components.modem_callerid.PhoneModem", + return_value=mocked_modem, + ) + + +def _patch_config_flow_modem(mocked_modem): + return patch( + "homeassistant.components.modem_callerid.config_flow.PhoneModem", + return_value=mocked_modem, + ) diff --git a/tests/components/modem_callerid/test_config_flow.py b/tests/components/modem_callerid/test_config_flow.py new file mode 100644 index 00000000000..5a2e4e5fd6d --- /dev/null +++ b/tests/components/modem_callerid/test_config_flow.py @@ -0,0 +1,204 @@ +"""Test Modem Caller ID config flow.""" +from unittest.mock import AsyncMock, MagicMock, patch + +import phone_modem +import serial.tools.list_ports + +from homeassistant.components import usb +from homeassistant.components.modem_callerid.const import DEFAULT_NAME, DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USB, SOURCE_USER +from homeassistant.const import CONF_DEVICE, CONF_SOURCE +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + +from . import CONF_DATA, IMPORT_DATA, _patch_config_flow_modem + +DISCOVERY_INFO = { + "device": phone_modem.DEFAULT_PORT, + "pid": "1340", + "vid": "0572", + "serial_number": "1234", + "description": "modem", + "manufacturer": "Connexant", +} + + +def _patch_setup(): + return patch( + "homeassistant.components.modem_callerid.async_setup_entry", + return_value=True, + ) + + +def com_port(): + """Mock of a serial port.""" + port = serial.tools.list_ports_common.ListPortInfo(phone_modem.DEFAULT_PORT) + port.serial_number = "1234" + port.manufacturer = "Virtual serial port" + port.device = phone_modem.DEFAULT_PORT + port.description = "Some serial port" + + return port + + +@patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()])) +async def test_flow_usb(hass: HomeAssistant): + """Test usb discovery flow.""" + port = com_port() + with _patch_config_flow_modem(AsyncMock()), _patch_setup(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USB}, + data=DISCOVERY_INFO, + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "usb_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_DEVICE: phone_modem.DEFAULT_PORT}, + ) + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["data"] == {CONF_DEVICE: port.device} + + +@patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()])) +async def test_flow_usb_cannot_connect(hass: HomeAssistant): + """Test usb flow connection error.""" + with _patch_config_flow_modem(AsyncMock()) as modemmock: + modemmock.side_effect = phone_modem.exceptions.SerialError + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USB}, data=DISCOVERY_INFO + ) + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "cannot_connect" + + +@patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()])) +async def test_flow_user(hass: HomeAssistant): + """Test user initialized flow.""" + port = com_port() + port_select = usb.human_readable_device_name( + port.device, + port.serial_number, + port.manufacturer, + port.description, + port.vid, + port.pid, + ) + mocked_modem = AsyncMock() + with _patch_config_flow_modem(mocked_modem), _patch_setup(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data={CONF_DEVICE: port_select}, + ) + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["data"] == {CONF_DEVICE: port.device} + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data={CONF_DEVICE: port_select}, + ) + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "no_devices_found" + + +@patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()])) +async def test_flow_user_error(hass: HomeAssistant): + """Test user initialized flow with unreachable device.""" + port = com_port() + port_select = usb.human_readable_device_name( + port.device, + port.serial_number, + port.manufacturer, + port.description, + port.vid, + port.pid, + ) + with _patch_config_flow_modem(AsyncMock()) as modemmock: + modemmock.side_effect = phone_modem.exceptions.SerialError + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data={CONF_DEVICE: port_select} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + modemmock.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_DEVICE: port_select}, + ) + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["data"] == {CONF_DEVICE: port.device} + + +@patch("serial.tools.list_ports.comports", MagicMock()) +async def test_flow_user_no_port_list(hass: HomeAssistant): + """Test user with no list of ports.""" + with _patch_config_flow_modem(AsyncMock()): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data={CONF_DEVICE: phone_modem.DEFAULT_PORT}, + ) + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "no_devices_found" + + +async def test_abort_user_with_existing_flow(hass: HomeAssistant): + """Test user flow is aborted when another discovery has happened.""" + with _patch_config_flow_modem(AsyncMock()): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USB}, + data=DISCOVERY_INFO, + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "usb_confirm" + + result2 = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data={}, + ) + + assert result2["type"] == RESULT_TYPE_ABORT + assert result2["reason"] == "already_in_progress" + + +@patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()])) +async def test_flow_import(hass: HomeAssistant): + """Test import step.""" + with _patch_config_flow_modem(AsyncMock()): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_IMPORT}, data=IMPORT_DATA + ) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == DEFAULT_NAME + assert result["data"] == CONF_DATA + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_IMPORT}, data=IMPORT_DATA + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_flow_import_cannot_connect(hass: HomeAssistant): + """Test import connection error.""" + with _patch_config_flow_modem(AsyncMock()) as modemmock: + modemmock.side_effect = phone_modem.exceptions.SerialError + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_IMPORT}, data=IMPORT_DATA + ) + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "cannot_connect" diff --git a/tests/components/modem_callerid/test_init.py b/tests/components/modem_callerid/test_init.py new file mode 100644 index 00000000000..b288fb7dc9f --- /dev/null +++ b/tests/components/modem_callerid/test_init.py @@ -0,0 +1,63 @@ +"""Test Modem Caller ID integration.""" +from unittest.mock import AsyncMock, patch + +from phone_modem import exceptions + +from homeassistant.components.modem_callerid.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import CONF_DATA, _patch_init_modem + +from tests.common import MockConfigEntry + + +async def test_setup_config(hass: HomeAssistant): + """Test Modem Caller ID setup.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=CONF_DATA, + ) + entry.add_to_hass(hass) + mocked_modem = AsyncMock() + with _patch_init_modem(mocked_modem): + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state == ConfigEntryState.LOADED + + +async def test_async_setup_entry_not_ready(hass: HomeAssistant): + """Test that it throws ConfigEntryNotReady when exception occurs during setup.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=CONF_DATA, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.modem_callerid.PhoneModem", + side_effect=exceptions.SerialError(), + ): + await hass.config_entries.async_setup(entry.entry_id) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state == ConfigEntryState.SETUP_ERROR + assert not hass.data.get(DOMAIN) + + +async def test_unload_config_entry(hass: HomeAssistant): + """Test unload.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=CONF_DATA, + ) + entry.add_to_hass(hass) + mocked_modem = AsyncMock() + with _patch_init_modem(mocked_modem): + await hass.config_entries.async_setup(entry.entry_id) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED + assert not hass.data.get(DOMAIN) From fe1311ba3473acaaa7bc267d0af848fb12ab3cab Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Mon, 13 Sep 2021 21:27:29 -0400 Subject: [PATCH 389/843] Bump up zha dependencies (#56206) --- homeassistant/components/zha/manifest.json | 4 ++-- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 53d0605e45c..fbaa0f84568 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -7,9 +7,9 @@ "bellows==0.27.0", "pyserial==3.5", "pyserial-asyncio==0.5", - "zha-quirks==0.0.60", + "zha-quirks==0.0.61", "zigpy-deconz==0.13.0", - "zigpy==0.37.1", + "zigpy==0.38.0", "zigpy-xbee==0.14.0", "zigpy-zigate==0.7.3", "zigpy-znp==0.5.4" diff --git a/requirements_all.txt b/requirements_all.txt index c25c16ff202..ed0d0547fa6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2456,7 +2456,7 @@ zengge==0.2 zeroconf==0.36.2 # homeassistant.components.zha -zha-quirks==0.0.60 +zha-quirks==0.0.61 # homeassistant.components.zhong_hong zhong_hong_hvac==1.0.9 @@ -2477,7 +2477,7 @@ zigpy-zigate==0.7.3 zigpy-znp==0.5.4 # homeassistant.components.zha -zigpy==0.37.1 +zigpy==0.38.0 # homeassistant.components.zoneminder zm-py==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dd397d5ddba..97e3c63a95e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1385,7 +1385,7 @@ youless-api==0.12 zeroconf==0.36.2 # homeassistant.components.zha -zha-quirks==0.0.60 +zha-quirks==0.0.61 # homeassistant.components.zha zigpy-deconz==0.13.0 @@ -1400,7 +1400,7 @@ zigpy-zigate==0.7.3 zigpy-znp==0.5.4 # homeassistant.components.zha -zigpy==0.37.1 +zigpy==0.38.0 # homeassistant.components.zwave_js zwave-js-server-python==0.30.0 From dba2998e8c9928aa7847a99be800358b5161f826 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Tue, 14 Sep 2021 08:44:20 +0200 Subject: [PATCH 390/843] Clean up Surepetcare binary sensor (#56070) --- .../components/surepetcare/binary_sensor.py | 33 ++++++++----------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/surepetcare/binary_sensor.py b/homeassistant/components/surepetcare/binary_sensor.py index b61eae12a7e..2f411e8c2a9 100644 --- a/homeassistant/components/surepetcare/binary_sensor.py +++ b/homeassistant/components/surepetcare/binary_sensor.py @@ -55,14 +55,13 @@ class SurePetcareBinarySensor(CoordinatorEntity, BinarySensorEntity): self, _id: int, coordinator: DataUpdateCoordinator, - device_class: str, ) -> None: """Initialize a Sure Petcare binary sensor.""" super().__init__(coordinator) self._id = _id - surepy_entity: SurepyEntity = coordinator.data[self._id] + surepy_entity: SurepyEntity = coordinator.data[_id] # cover special case where a device has no name set if surepy_entity.name: @@ -70,29 +69,26 @@ class SurePetcareBinarySensor(CoordinatorEntity, BinarySensorEntity): else: name = f"Unnamed {surepy_entity.type.name.capitalize()}" - self._attr_device_class = device_class self._attr_name = f"{surepy_entity.type.name.capitalize()} {name.capitalize()}" - self._attr_unique_id = f"{surepy_entity.household_id}-{self._id}" - self._update_attr() + self._attr_unique_id = f"{surepy_entity.household_id}-{_id}" + self._update_attr(coordinator.data[_id]) @abstractmethod @callback - def _update_attr(self) -> None: + def _update_attr(self, surepy_entity) -> None: """Update the state and attributes.""" @callback def _handle_coordinator_update(self) -> None: """Get the latest data and update the state.""" - self._update_attr() + self._update_attr(self.coordinator.data[self._id]) self.async_write_ha_state() class Hub(SurePetcareBinarySensor): """Sure Petcare Hub.""" - def __init__(self, _id: int, coordinator: DataUpdateCoordinator) -> None: - """Initialize a Sure Petcare Hub.""" - super().__init__(_id, coordinator, DEVICE_CLASS_CONNECTIVITY) + _attr_device_class = DEVICE_CLASS_CONNECTIVITY @property def available(self) -> bool: @@ -100,9 +96,8 @@ class Hub(SurePetcareBinarySensor): return super().available and bool(self._attr_is_on) @callback - def _update_attr(self) -> None: + def _update_attr(self, surepy_entity) -> None: """Get the latest data and update the state.""" - surepy_entity = self.coordinator.data[self._id] state = surepy_entity.raw_data()["status"] self._attr_is_on = self._attr_available = bool(state["online"]) if surepy_entity.raw_data(): @@ -120,14 +115,11 @@ class Hub(SurePetcareBinarySensor): class Pet(SurePetcareBinarySensor): """Sure Petcare Pet.""" - def __init__(self, _id: int, coordinator: DataUpdateCoordinator) -> None: - """Initialize a Sure Petcare Pet.""" - super().__init__(_id, coordinator, DEVICE_CLASS_PRESENCE) + _attr_device_class = DEVICE_CLASS_PRESENCE @callback - def _update_attr(self) -> None: + def _update_attr(self, surepy_entity) -> None: """Get the latest data and update the state.""" - surepy_entity = self.coordinator.data[self._id] state = surepy_entity.location try: self._attr_is_on = bool(Location(state.where) == Location.INSIDE) @@ -146,21 +138,22 @@ class Pet(SurePetcareBinarySensor): class DeviceConnectivity(SurePetcareBinarySensor): """Sure Petcare Device.""" + _attr_device_class = DEVICE_CLASS_CONNECTIVITY + def __init__( self, _id: int, coordinator: DataUpdateCoordinator, ) -> None: """Initialize a Sure Petcare Device.""" - super().__init__(_id, coordinator, DEVICE_CLASS_CONNECTIVITY) + super().__init__(_id, coordinator) self._attr_name = f"{self.name}_connectivity" self._attr_unique_id = ( f"{self.coordinator.data[self._id].household_id}-{self._id}-connectivity" ) @callback - def _update_attr(self): - surepy_entity = self.coordinator.data[self._id] + def _update_attr(self, surepy_entity): state = surepy_entity.raw_data()["status"] self._attr_is_on = bool(state) if state: From 31623368c85b758f6ec2486d91268f6b007bbf06 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 Sep 2021 08:50:29 +0200 Subject: [PATCH 391/843] Bump codecov/codecov-action from 2.0.3 to 2.1.0 (#56210) Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 2.0.3 to 2.1.0. - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/master/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/v2.0.3...v2.1.0) --- updated-dependencies: - dependency-name: codecov/codecov-action dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 27ab710e1ff..3cecb157d07 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -740,4 +740,4 @@ jobs: coverage report --fail-under=94 coverage xml - name: Upload coverage to Codecov - uses: codecov/codecov-action@v2.0.3 + uses: codecov/codecov-action@v2.1.0 From aaa62dadec4ccc019feec797346d412a0a565ace Mon Sep 17 00:00:00 2001 From: jan iversen Date: Tue, 14 Sep 2021 09:42:50 +0200 Subject: [PATCH 392/843] Add service to stop/restart modbus (#55599) * Add service to stop/restart modbus. Co-authored-by: Martin Hjelmare --- homeassistant/components/modbus/__init__.py | 11 ++- .../components/modbus/base_platform.py | 37 +++++++-- homeassistant/components/modbus/const.py | 6 ++ homeassistant/components/modbus/cover.py | 1 + homeassistant/components/modbus/modbus.py | 77 +++++++++++++------ homeassistant/components/modbus/services.yaml | 22 ++++++ tests/components/modbus/test_init.py | 54 +++++++++++++ 7 files changed, 180 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index df1fc2f6995..830dadfcdb8 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -369,6 +369,11 @@ SERVICE_WRITE_COIL_SCHEMA = vol.Schema( ), } ) +SERVICE_STOP_START_SCHEMA = vol.Schema( + { + vol.Required(ATTR_HUB): cv.string, + } +) def get_hub(hass: HomeAssistant, name: str) -> ModbusHub: @@ -379,5 +384,9 @@ def get_hub(hass: HomeAssistant, name: str) -> ModbusHub: async def async_setup(hass, config): """Set up Modbus component.""" return await async_modbus_setup( - hass, config, SERVICE_WRITE_REGISTER_SCHEMA, SERVICE_WRITE_COIL_SCHEMA + hass, + config, + SERVICE_WRITE_REGISTER_SCHEMA, + SERVICE_WRITE_COIL_SCHEMA, + SERVICE_STOP_START_SCHEMA, ) diff --git a/homeassistant/components/modbus/base_platform.py b/homeassistant/components/modbus/base_platform.py index efcb70b5b16..3167313fae8 100644 --- a/homeassistant/components/modbus/base_platform.py +++ b/homeassistant/components/modbus/base_platform.py @@ -5,7 +5,7 @@ from abc import abstractmethod from datetime import timedelta import logging import struct -from typing import Any +from typing import Any, Callable from homeassistant.const import ( CONF_ADDRESS, @@ -21,6 +21,8 @@ from homeassistant.const import ( CONF_STRUCTURE, STATE_ON, ) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity, ToggleEntity from homeassistant.helpers.event import async_call_later, async_track_time_interval from homeassistant.helpers.restore_state import RestoreEntity @@ -50,6 +52,8 @@ from .const import ( CONF_VERIFY, CONF_WRITE_TYPE, DATA_TYPE_STRING, + SIGNAL_START_ENTITY, + SIGNAL_STOP_ENTITY, ) from .modbus import ModbusHub @@ -73,6 +77,7 @@ class BasePlatform(Entity): self._value = None self._scan_interval = int(entry[CONF_SCAN_INTERVAL]) self._call_active = False + self._cancel_timer: Callable[[], None] | None = None self._attr_name = entry[CONF_NAME] self._attr_should_poll = False @@ -86,13 +91,35 @@ class BasePlatform(Entity): async def async_update(self, now=None): """Virtual function to be overwritten.""" - async def async_base_added_to_hass(self): - """Handle entity which will be added.""" + @callback + def async_remote_start(self) -> None: + """Remote start entity.""" + if self._cancel_timer: + self._cancel_timer() + self._cancel_timer = None if self._scan_interval > 0: - cancel_func = async_track_time_interval( + self._cancel_timer = async_track_time_interval( self.hass, self.async_update, timedelta(seconds=self._scan_interval) ) - self._hub.entity_timers.append(cancel_func) + self._attr_available = True + self.async_write_ha_state() + + @callback + def async_remote_stop(self) -> None: + """Remote stop entity.""" + if self._cancel_timer: + self._cancel_timer() + self._cancel_timer = None + self._attr_available = False + self.async_write_ha_state() + + async def async_base_added_to_hass(self): + """Handle entity which will be added.""" + self.async_remote_start() + async_dispatcher_connect(self.hass, SIGNAL_STOP_ENTITY, self.async_remote_stop) + async_dispatcher_connect( + self.hass, SIGNAL_START_ENTITY, self.async_remote_start + ) class BaseStructPlatform(BasePlatform, RestoreEntity): diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py index 3bcd85053d2..f2c3b7dd19c 100644 --- a/homeassistant/components/modbus/const.py +++ b/homeassistant/components/modbus/const.py @@ -106,6 +106,12 @@ CALL_TYPE_X_REGISTER_HOLDINGS = "holdings" # service calls SERVICE_WRITE_COIL = "write_coil" SERVICE_WRITE_REGISTER = "write_register" +SERVICE_STOP = "stop" +SERVICE_RESTART = "restart" + +# dispatcher signals +SIGNAL_STOP_ENTITY = "modbus.stop" +SIGNAL_START_ENTITY = "modbus.start" # integration names DEFAULT_HUB = "modbus_hub" diff --git a/homeassistant/components/modbus/cover.py b/homeassistant/components/modbus/cover.py index 5fa77eb1cb8..ca4f25ca1cc 100644 --- a/homeassistant/components/modbus/cover.py +++ b/homeassistant/components/modbus/cover.py @@ -74,6 +74,7 @@ class ModbusCover(BasePlatform, CoverEntity, RestoreEntity): self._status_register_type = config[CONF_STATUS_REGISTER_TYPE] self._attr_supported_features = SUPPORT_OPEN | SUPPORT_CLOSE + self._attr_is_closed = False # If we read cover status from coil, and not from optional status register, # we interpret boolean value False as closed cover, and value True as open cover. diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 42505215622..fb9af241048 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -20,8 +20,9 @@ from homeassistant.const import ( CONF_TYPE, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import CALLBACK_TYPE, callback +from homeassistant.core import callback from homeassistant.helpers.discovery import async_load_platform +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later from .const import ( @@ -51,8 +52,12 @@ from .const import ( PLATFORMS, RTUOVERTCP, SERIAL, + SERVICE_RESTART, + SERVICE_STOP, SERVICE_WRITE_COIL, SERVICE_WRITE_REGISTER, + SIGNAL_START_ENTITY, + SIGNAL_STOP_ENTITY, TCP, UDP, ) @@ -107,7 +112,11 @@ PYMODBUS_CALL = [ async def async_modbus_setup( - hass, config, service_write_register_schema, service_write_coil_schema + hass, + config, + service_write_register_schema, + service_write_coil_schema, + service_stop_start_schema, ): """Set up Modbus component.""" @@ -131,9 +140,9 @@ async def async_modbus_setup( async def async_stop_modbus(event): """Stop Modbus service.""" + async_dispatcher_send(hass, SIGNAL_STOP_ENTITY) for client in hub_collect.values(): await client.async_close() - del client hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop_modbus) @@ -142,15 +151,15 @@ async def async_modbus_setup( unit = int(float(service.data[ATTR_UNIT])) address = int(float(service.data[ATTR_ADDRESS])) value = service.data[ATTR_VALUE] - client_name = ( + hub = hub_collect[ service.data[ATTR_HUB] if ATTR_HUB in service.data else DEFAULT_HUB - ) + ] if isinstance(value, list): - await hub_collect[client_name].async_pymodbus_call( + await hub.async_pymodbus_call( unit, address, [int(float(i)) for i in value], CALL_TYPE_WRITE_REGISTERS ) else: - await hub_collect[client_name].async_pymodbus_call( + await hub.async_pymodbus_call( unit, address, int(float(value)), CALL_TYPE_WRITE_REGISTER ) @@ -166,35 +175,48 @@ async def async_modbus_setup( unit = service.data[ATTR_UNIT] address = service.data[ATTR_ADDRESS] state = service.data[ATTR_STATE] - client_name = ( + hub = hub_collect[ service.data[ATTR_HUB] if ATTR_HUB in service.data else DEFAULT_HUB - ) + ] if isinstance(state, list): - await hub_collect[client_name].async_pymodbus_call( - unit, address, state, CALL_TYPE_WRITE_COILS - ) + await hub.async_pymodbus_call(unit, address, state, CALL_TYPE_WRITE_COILS) else: - await hub_collect[client_name].async_pymodbus_call( - unit, address, state, CALL_TYPE_WRITE_COIL - ) + await hub.async_pymodbus_call(unit, address, state, CALL_TYPE_WRITE_COIL) hass.services.async_register( DOMAIN, SERVICE_WRITE_COIL, async_write_coil, schema=service_write_coil_schema ) + + async def async_stop_hub(service): + """Stop Modbus hub.""" + async_dispatcher_send(hass, SIGNAL_STOP_ENTITY) + hub = hub_collect[service.data[ATTR_HUB]] + await hub.async_close() + + hass.services.async_register( + DOMAIN, SERVICE_STOP, async_stop_hub, schema=service_stop_start_schema + ) + + async def async_restart_hub(service): + """Restart Modbus hub.""" + async_dispatcher_send(hass, SIGNAL_START_ENTITY) + hub = hub_collect[service.data[ATTR_HUB]] + await hub.async_restart() + + hass.services.async_register( + DOMAIN, SERVICE_RESTART, async_restart_hub, schema=service_stop_start_schema + ) return True class ModbusHub: """Thread safe wrapper class for pymodbus.""" - name: str - def __init__(self, hass, client_config): """Initialize the Modbus hub.""" # generic configuration self._client = None - self.entity_timers: list[CALLBACK_TYPE] = [] self._async_cancel_listener = None self._in_error = False self._lock = asyncio.Lock() @@ -284,29 +306,40 @@ class ModbusHub: self._async_cancel_listener = None self._config_delay = 0 + async def async_restart(self): + """Reconnect client.""" + if self._client: + await self.async_close() + + await self.async_setup() + async def async_close(self): """Disconnect client.""" if self._async_cancel_listener: self._async_cancel_listener() self._async_cancel_listener = None - for call in self.entity_timers: - call() - self.entity_timers = [] async with self._lock: if self._client: try: self._client.close() except ModbusException as exception_error: self._log_error(str(exception_error)) + del self._client self._client = None + message = f"modbus {self.name} communication closed" + _LOGGER.warning(message) def _pymodbus_connect(self): """Connect client.""" try: - return self._client.connect() + self._client.connect() except ModbusException as exception_error: self._log_error(str(exception_error), error_state=False) return False + else: + message = f"modbus {self.name} communication open" + _LOGGER.warning(message) + return True def _pymodbus_call(self, unit, address, value, use_call): """Call sync. pymodbus.""" diff --git a/homeassistant/components/modbus/services.yaml b/homeassistant/components/modbus/services.yaml index 855303aef07..835927e4627 100644 --- a/homeassistant/components/modbus/services.yaml +++ b/homeassistant/components/modbus/services.yaml @@ -66,3 +66,25 @@ write_register: default: "modbus_hub" selector: text: +stop: + name: Stop + description: Stop modbus hub. + fields: + hub: + name: Hub + description: Modbus hub name. + example: "hub1" + default: "modbus_hub" + selector: + text: +restart: + name: Restart + description: Restart modbus hub (if running stop then start). + fields: + hub: + name: Hub + description: Modbus hub name. + example: "hub1" + default: "modbus_hub" + selector: + text: diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index 3ae271467ca..ba99df19b4d 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -53,6 +53,8 @@ from homeassistant.components.modbus.const import ( MODBUS_DOMAIN as DOMAIN, RTUOVERTCP, SERIAL, + SERVICE_RESTART, + SERVICE_STOP, SERVICE_WRITE_COIL, SERVICE_WRITE_REGISTER, TCP, @@ -82,6 +84,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, STATE_ON, STATE_UNAVAILABLE, + STATE_UNKNOWN, ) from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -715,3 +718,54 @@ async def test_shutdown(hass, caplog, mock_pymodbus, mock_modbus_with_pymodbus): await hass.async_block_till_done() assert mock_pymodbus.close.called assert caplog.text == "" + + +@pytest.mark.parametrize( + "do_config", + [ + { + CONF_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 51, + } + ] + }, + ], +) +async def test_stop_restart(hass, caplog, mock_modbus): + """Run test for service stop.""" + + entity_id = f"{SENSOR_DOMAIN}.{TEST_ENTITY_NAME}" + assert hass.states.get(entity_id).state == STATE_UNKNOWN + hass.states.async_set(entity_id, 17) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == "17" + + mock_modbus.reset_mock() + caplog.clear() + data = { + ATTR_HUB: TEST_MODBUS_NAME, + } + await hass.services.async_call(DOMAIN, SERVICE_STOP, data, blocking=True) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + assert mock_modbus.close.called + assert f"modbus {TEST_MODBUS_NAME} communication closed" in caplog.text + + mock_modbus.reset_mock() + caplog.clear() + await hass.services.async_call(DOMAIN, SERVICE_RESTART, data, blocking=True) + await hass.async_block_till_done() + assert not mock_modbus.close.called + assert mock_modbus.connect.called + assert f"modbus {TEST_MODBUS_NAME} communication open" in caplog.text + + mock_modbus.reset_mock() + caplog.clear() + await hass.services.async_call(DOMAIN, SERVICE_RESTART, data, blocking=True) + await hass.async_block_till_done() + assert mock_modbus.close.called + assert mock_modbus.connect.called + assert f"modbus {TEST_MODBUS_NAME} communication closed" in caplog.text + assert f"modbus {TEST_MODBUS_NAME} communication open" in caplog.text From bac55b78fe6a8d609f2249e491169ab2a8c8ef3d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 14 Sep 2021 16:56:36 +0200 Subject: [PATCH 393/843] Enforce device class for gas and energy sensors used by energy dashboard (#56218) * Enforce device class for gas and energy sensors used by energy dashboard * Adjust tests --- homeassistant/components/energy/validate.py | 51 +++++++--- tests/components/energy/test_validate.py | 104 +++++++++++++++++--- 2 files changed, 128 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/energy/validate.py b/homeassistant/components/energy/validate.py index 9674b32df9b..2326851491c 100644 --- a/homeassistant/components/energy/validate.py +++ b/homeassistant/components/energy/validate.py @@ -1,12 +1,13 @@ """Validate the energy preferences provide valid data.""" from __future__ import annotations -from collections.abc import Sequence +from collections.abc import Mapping, Sequence import dataclasses from typing import Any from homeassistant.components import recorder, sensor from homeassistant.const import ( + ATTR_DEVICE_CLASS, ENERGY_KILO_WATT_HOUR, ENERGY_WATT_HOUR, STATE_UNAVAILABLE, @@ -19,14 +20,16 @@ from homeassistant.core import HomeAssistant, callback, valid_entity_id from . import data from .const import DOMAIN -ENERGY_USAGE_UNITS = (ENERGY_KILO_WATT_HOUR, ENERGY_WATT_HOUR) +ENERGY_USAGE_DEVICE_CLASSES = (sensor.DEVICE_CLASS_ENERGY,) +ENERGY_USAGE_UNITS = { + sensor.DEVICE_CLASS_ENERGY: (ENERGY_KILO_WATT_HOUR, ENERGY_WATT_HOUR) +} ENERGY_UNIT_ERROR = "entity_unexpected_unit_energy" -GAS_USAGE_UNITS = ( - ENERGY_WATT_HOUR, - ENERGY_KILO_WATT_HOUR, - VOLUME_CUBIC_METERS, - VOLUME_CUBIC_FEET, -) +GAS_USAGE_DEVICE_CLASSES = (sensor.DEVICE_CLASS_ENERGY, sensor.DEVICE_CLASS_GAS) +GAS_USAGE_UNITS = { + sensor.DEVICE_CLASS_ENERGY: (ENERGY_WATT_HOUR, ENERGY_KILO_WATT_HOUR), + sensor.DEVICE_CLASS_GAS: (VOLUME_CUBIC_METERS, VOLUME_CUBIC_FEET), +} GAS_UNIT_ERROR = "entity_unexpected_unit_gas" @@ -59,7 +62,8 @@ class EnergyPreferencesValidation: def _async_validate_usage_stat( hass: HomeAssistant, stat_value: str, - allowed_units: Sequence[str], + allowed_device_classes: Sequence[str], + allowed_units: Mapping[str, Sequence[str]], unit_error: str, result: list[ValidationIssue], ) -> None: @@ -106,19 +110,29 @@ def _async_validate_usage_stat( ValidationIssue("entity_negative_state", stat_value, current_value) ) - unit = state.attributes.get("unit_of_measurement") + device_class = state.attributes.get(ATTR_DEVICE_CLASS) + if device_class not in allowed_device_classes: + result.append( + ValidationIssue( + "entity_unexpected_device_class", + stat_value, + device_class, + ) + ) + else: + unit = state.attributes.get("unit_of_measurement") - if unit not in allowed_units: - result.append(ValidationIssue(unit_error, stat_value, unit)) + if device_class and unit not in allowed_units.get(device_class, []): + result.append(ValidationIssue(unit_error, stat_value, unit)) - state_class = state.attributes.get("state_class") + state_class = state.attributes.get(sensor.ATTR_STATE_CLASS) - supported_state_classes = [ + allowed_state_classes = [ sensor.STATE_CLASS_MEASUREMENT, sensor.STATE_CLASS_TOTAL, sensor.STATE_CLASS_TOTAL_INCREASING, ] - if state_class not in supported_state_classes: + if state_class not in allowed_state_classes: result.append( ValidationIssue( "entity_unexpected_state_class_total_increasing", @@ -236,6 +250,7 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: _async_validate_usage_stat( hass, flow["stat_energy_from"], + ENERGY_USAGE_DEVICE_CLASSES, ENERGY_USAGE_UNITS, ENERGY_UNIT_ERROR, source_result, @@ -258,6 +273,7 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: _async_validate_usage_stat( hass, flow["stat_energy_to"], + ENERGY_USAGE_DEVICE_CLASSES, ENERGY_USAGE_UNITS, ENERGY_UNIT_ERROR, source_result, @@ -282,6 +298,7 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: _async_validate_usage_stat( hass, source["stat_energy_from"], + GAS_USAGE_DEVICE_CLASSES, GAS_USAGE_UNITS, GAS_UNIT_ERROR, source_result, @@ -304,6 +321,7 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: _async_validate_usage_stat( hass, source["stat_energy_from"], + ENERGY_USAGE_DEVICE_CLASSES, ENERGY_USAGE_UNITS, ENERGY_UNIT_ERROR, source_result, @@ -313,6 +331,7 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: _async_validate_usage_stat( hass, source["stat_energy_from"], + ENERGY_USAGE_DEVICE_CLASSES, ENERGY_USAGE_UNITS, ENERGY_UNIT_ERROR, source_result, @@ -320,6 +339,7 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: _async_validate_usage_stat( hass, source["stat_energy_to"], + ENERGY_USAGE_DEVICE_CLASSES, ENERGY_USAGE_UNITS, ENERGY_UNIT_ERROR, source_result, @@ -331,6 +351,7 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: _async_validate_usage_stat( hass, device["stat_consumption"], + ENERGY_USAGE_DEVICE_CLASSES, ENERGY_USAGE_UNITS, ENERGY_UNIT_ERROR, device_result, diff --git a/tests/components/energy/test_validate.py b/tests/components/energy/test_validate.py index e893c71d1f2..76b9201a001 100644 --- a/tests/components/energy/test_validate.py +++ b/tests/components/energy/test_validate.py @@ -45,7 +45,11 @@ async def test_validation(hass, mock_energy_manager): hass.states.async_set( f"sensor.{key}", "123", - {"unit_of_measurement": "kWh", "state_class": "total_increasing"}, + { + "device_class": "energy", + "unit_of_measurement": "kWh", + "state_class": "total_increasing", + }, ) await mock_energy_manager.async_update( @@ -142,7 +146,11 @@ async def test_validation_device_consumption_entity_unexpected_unit( hass.states.async_set( "sensor.unexpected_unit", "10.10", - {"unit_of_measurement": "beers", "state_class": "total_increasing"}, + { + "device_class": "energy", + "unit_of_measurement": "beers", + "state_class": "total_increasing", + }, ) assert (await validate.async_validate(hass)).as_dict() == { @@ -194,7 +202,11 @@ async def test_validation_solar(hass, mock_energy_manager): hass.states.async_set( "sensor.solar_production", "10.10", - {"unit_of_measurement": "beers", "state_class": "total_increasing"}, + { + "device_class": "energy", + "unit_of_measurement": "beers", + "state_class": "total_increasing", + }, ) assert (await validate.async_validate(hass)).as_dict() == { @@ -227,12 +239,20 @@ async def test_validation_battery(hass, mock_energy_manager): hass.states.async_set( "sensor.battery_import", "10.10", - {"unit_of_measurement": "beers", "state_class": "total_increasing"}, + { + "device_class": "energy", + "unit_of_measurement": "beers", + "state_class": "total_increasing", + }, ) hass.states.async_set( "sensor.battery_export", "10.10", - {"unit_of_measurement": "beers", "state_class": "total_increasing"}, + { + "device_class": "energy", + "unit_of_measurement": "beers", + "state_class": "total_increasing", + }, ) assert (await validate.async_validate(hass)).as_dict() == { @@ -282,12 +302,20 @@ async def test_validation_grid(hass, mock_energy_manager, mock_is_entity_recorde hass.states.async_set( "sensor.grid_consumption_1", "10.10", - {"unit_of_measurement": "beers", "state_class": "total_increasing"}, + { + "device_class": "energy", + "unit_of_measurement": "beers", + "state_class": "total_increasing", + }, ) hass.states.async_set( "sensor.grid_production_1", "10.10", - {"unit_of_measurement": "beers", "state_class": "total_increasing"}, + { + "device_class": "energy", + "unit_of_measurement": "beers", + "state_class": "total_increasing", + }, ) assert (await validate.async_validate(hass)).as_dict() == { @@ -324,12 +352,20 @@ async def test_validation_grid_price_not_exist(hass, mock_energy_manager): hass.states.async_set( "sensor.grid_consumption_1", "10.10", - {"unit_of_measurement": "kWh", "state_class": "total_increasing"}, + { + "device_class": "energy", + "unit_of_measurement": "kWh", + "state_class": "total_increasing", + }, ) hass.states.async_set( "sensor.grid_production_1", "10.10", - {"unit_of_measurement": "kWh", "state_class": "total_increasing"}, + { + "device_class": "energy", + "unit_of_measurement": "kWh", + "state_class": "total_increasing", + }, ) await mock_energy_manager.async_update( { @@ -402,7 +438,11 @@ async def test_validation_grid_price_errors( hass.states.async_set( "sensor.grid_consumption_1", "10.10", - {"unit_of_measurement": "kWh", "state_class": "total_increasing"}, + { + "device_class": "energy", + "unit_of_measurement": "kWh", + "state_class": "total_increasing", + }, ) hass.states.async_set( "sensor.grid_price_1", @@ -454,18 +494,50 @@ async def test_validation_gas(hass, mock_energy_manager, mock_is_entity_recorded "stat_energy_from": "sensor.gas_consumption_2", "stat_cost": "sensor.gas_cost_2", }, + { + "type": "gas", + "stat_energy_from": "sensor.gas_consumption_3", + "stat_cost": "sensor.gas_cost_2", + }, + { + "type": "gas", + "stat_energy_from": "sensor.gas_consumption_4", + "stat_cost": "sensor.gas_cost_2", + }, ] } ) hass.states.async_set( "sensor.gas_consumption_1", "10.10", - {"unit_of_measurement": "beers", "state_class": "total_increasing"}, + { + "device_class": "energy", + "unit_of_measurement": "beers", + "state_class": "total_increasing", + }, ) hass.states.async_set( "sensor.gas_consumption_2", "10.10", - {"unit_of_measurement": "kWh", "state_class": "total_increasing"}, + { + "device_class": "energy", + "unit_of_measurement": "kWh", + "state_class": "total_increasing", + }, + ) + hass.states.async_set( + "sensor.gas_consumption_3", + "10.10", + { + "device_class": "gas", + "unit_of_measurement": "m³", + "state_class": "total_increasing", + }, + ) + hass.states.async_set( + "sensor.gas_consumption_4", + "10.10", + {"unit_of_measurement": "beers", "state_class": "total_increasing"}, ) hass.states.async_set( "sensor.gas_cost_2", @@ -488,6 +560,14 @@ async def test_validation_gas(hass, mock_energy_manager, mock_is_entity_recorded }, ], [], + [], + [ + { + "type": "entity_unexpected_device_class", + "identifier": "sensor.gas_consumption_4", + "value": None, + }, + ], ], "device_consumption": [], } From 2a51bb5bba0105a7b4eaf3d6f81c02e65dcb4aa2 Mon Sep 17 00:00:00 2001 From: Ricardo Steijn <61013287+RicArch97@users.noreply.github.com> Date: Tue, 14 Sep 2021 21:46:52 +0200 Subject: [PATCH 394/843] Add Crownstone integration (#50677) --- .coveragerc | 7 + .strict-typing | 1 + CODEOWNERS | 1 + .../components/crownstone/__init__.py | 23 + .../components/crownstone/config_flow.py | 299 ++++++++++ homeassistant/components/crownstone/const.py | 45 ++ .../components/crownstone/devices.py | 43 ++ .../components/crownstone/entry_manager.py | 190 +++++++ .../components/crownstone/helpers.py | 59 ++ homeassistant/components/crownstone/light.py | 204 +++++++ .../components/crownstone/listeners.py | 147 +++++ .../components/crownstone/manifest.json | 15 + .../components/crownstone/strings.json | 75 +++ .../crownstone/translations/en.json | 75 +++ homeassistant/generated/config_flows.py | 1 + mypy.ini | 11 + requirements_all.txt | 10 + requirements_test_all.txt | 10 + tests/components/crownstone/__init__.py | 1 + .../components/crownstone/test_config_flow.py | 531 ++++++++++++++++++ 20 files changed, 1748 insertions(+) create mode 100644 homeassistant/components/crownstone/__init__.py create mode 100644 homeassistant/components/crownstone/config_flow.py create mode 100644 homeassistant/components/crownstone/const.py create mode 100644 homeassistant/components/crownstone/devices.py create mode 100644 homeassistant/components/crownstone/entry_manager.py create mode 100644 homeassistant/components/crownstone/helpers.py create mode 100644 homeassistant/components/crownstone/light.py create mode 100644 homeassistant/components/crownstone/listeners.py create mode 100644 homeassistant/components/crownstone/manifest.json create mode 100644 homeassistant/components/crownstone/strings.json create mode 100644 homeassistant/components/crownstone/translations/en.json create mode 100644 tests/components/crownstone/__init__.py create mode 100644 tests/components/crownstone/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 6ac8779ad75..119f0345f3f 100644 --- a/.coveragerc +++ b/.coveragerc @@ -171,6 +171,13 @@ omit = homeassistant/components/coolmaster/const.py homeassistant/components/cppm_tracker/device_tracker.py homeassistant/components/cpuspeed/sensor.py + homeassistant/components/crownstone/__init__.py + homeassistant/components/crownstone/const.py + homeassistant/components/crownstone/listeners.py + homeassistant/components/crownstone/helpers.py + homeassistant/components/crownstone/devices.py + homeassistant/components/crownstone/entry_manager.py + homeassistant/components/crownstone/light.py homeassistant/components/cups/sensor.py homeassistant/components/currencylayer/sensor.py homeassistant/components/daikin/* diff --git a/.strict-typing b/.strict-typing index 68c8f62daf6..b664fc3b886 100644 --- a/.strict-typing +++ b/.strict-typing @@ -27,6 +27,7 @@ homeassistant.components.calendar.* homeassistant.components.camera.* homeassistant.components.canary.* homeassistant.components.cover.* +homeassistant.components.crownstone.* homeassistant.components.device_automation.* homeassistant.components.device_tracker.* homeassistant.components.devolo_home_control.* diff --git a/CODEOWNERS b/CODEOWNERS index c05b11a5b02..9982fc8de7b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -104,6 +104,7 @@ homeassistant/components/coronavirus/* @home-assistant/core homeassistant/components/counter/* @fabaff homeassistant/components/cover/* @home-assistant/core homeassistant/components/cpuspeed/* @fabaff +homeassistant/components/crownstone/* @Crownstone @RicArch97 homeassistant/components/cups/* @fabaff homeassistant/components/daikin/* @fredrike homeassistant/components/darksky/* @fabaff diff --git a/homeassistant/components/crownstone/__init__.py b/homeassistant/components/crownstone/__init__.py new file mode 100644 index 00000000000..bd4aae79665 --- /dev/null +++ b/homeassistant/components/crownstone/__init__.py @@ -0,0 +1,23 @@ +"""Integration for Crownstone.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .entry_manager import CrownstoneEntryManager + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Initiate setup for a Crownstone config entry.""" + manager = CrownstoneEntryManager(hass, entry) + + return await manager.async_setup() + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok: bool = await hass.data[DOMAIN][entry.entry_id].async_unload() + if len(hass.data[DOMAIN]) == 0: + hass.data.pop(DOMAIN) + return unload_ok diff --git a/homeassistant/components/crownstone/config_flow.py b/homeassistant/components/crownstone/config_flow.py new file mode 100644 index 00000000000..72edeef7910 --- /dev/null +++ b/homeassistant/components/crownstone/config_flow.py @@ -0,0 +1,299 @@ +"""Flow handler for Crownstone.""" +from __future__ import annotations + +from typing import Any + +from crownstone_cloud import CrownstoneCloud +from crownstone_cloud.exceptions import ( + CrownstoneAuthenticationError, + CrownstoneUnknownError, +) +import serial.tools.list_ports +from serial.tools.list_ports_common import ListPortInfo +import voluptuous as vol + +from homeassistant.components import usb +from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import aiohttp_client + +from .const import ( + CONF_USB_MANUAL_PATH, + CONF_USB_PATH, + CONF_USB_SPHERE, + CONF_USB_SPHERE_OPTION, + CONF_USE_USB_OPTION, + DOMAIN, + DONT_USE_USB, + MANUAL_PATH, + REFRESH_LIST, +) +from .entry_manager import CrownstoneEntryManager +from .helpers import list_ports_as_str + + +class CrownstoneConfigFlowHandler(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Crownstone.""" + + VERSION = 1 + cloud: CrownstoneCloud + + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> CrownstoneOptionsFlowHandler: + """Return the Crownstone options.""" + return CrownstoneOptionsFlowHandler(config_entry) + + def __init__(self) -> None: + """Initialize the flow.""" + self.login_info: dict[str, Any] = {} + self.usb_path: str | None = None + self.usb_sphere_id: str | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is None: + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + {vol.Required(CONF_EMAIL): str, vol.Required(CONF_PASSWORD): str} + ), + ) + + self.cloud = CrownstoneCloud( + email=user_input[CONF_EMAIL], + password=user_input[CONF_PASSWORD], + clientsession=aiohttp_client.async_get_clientsession(self.hass), + ) + # Login & sync all user data + try: + await self.cloud.async_initialize() + except CrownstoneAuthenticationError as auth_error: + if auth_error.type == "LOGIN_FAILED": + errors["base"] = "invalid_auth" + elif auth_error.type == "LOGIN_FAILED_EMAIL_NOT_VERIFIED": + errors["base"] = "account_not_verified" + except CrownstoneUnknownError: + errors["base"] = "unknown_error" + + # show form again, with the errors + if errors: + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + {vol.Required(CONF_EMAIL): str, vol.Required(CONF_PASSWORD): str} + ), + errors=errors, + ) + + await self.async_set_unique_id(self.cloud.cloud_data.user_id) + self._abort_if_unique_id_configured() + + self.login_info = user_input + return await self.async_step_usb_config() + + async def async_step_usb_config( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Set up a Crownstone USB dongle.""" + list_of_ports = await self.hass.async_add_executor_job( + serial.tools.list_ports.comports + ) + ports_as_string = list_ports_as_str(list_of_ports) + + if user_input is not None: + selection = user_input[CONF_USB_PATH] + + if selection == DONT_USE_USB: + return self.async_create_new_entry() + if selection == MANUAL_PATH: + return await self.async_step_usb_manual_config() + if selection != REFRESH_LIST: + selected_port: ListPortInfo = list_of_ports[ + (ports_as_string.index(selection) - 1) + ] + self.usb_path = await self.hass.async_add_executor_job( + usb.get_serial_by_id, selected_port.device + ) + return await self.async_step_usb_sphere_config() + + return self.async_show_form( + step_id="usb_config", + data_schema=vol.Schema( + {vol.Required(CONF_USB_PATH): vol.In(ports_as_string)} + ), + ) + + async def async_step_usb_manual_config( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manually enter Crownstone USB dongle path.""" + if user_input is None: + return self.async_show_form( + step_id="usb_manual_config", + data_schema=vol.Schema({vol.Required(CONF_USB_MANUAL_PATH): str}), + ) + + self.usb_path = user_input[CONF_USB_MANUAL_PATH] + return await self.async_step_usb_sphere_config() + + async def async_step_usb_sphere_config( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Select a Crownstone sphere that the USB operates in.""" + spheres = {sphere.name: sphere.cloud_id for sphere in self.cloud.cloud_data} + # no need to select if there's only 1 option + sphere_id: str | None = None + if len(spheres) == 1: + sphere_id = next(iter(spheres.values())) + + if user_input is None and sphere_id is None: + return self.async_show_form( + step_id="usb_sphere_config", + data_schema=vol.Schema({CONF_USB_SPHERE: vol.In(spheres.keys())}), + ) + + if sphere_id: + self.usb_sphere_id = sphere_id + elif user_input: + self.usb_sphere_id = spheres[user_input[CONF_USB_SPHERE]] + + return self.async_create_new_entry() + + def async_create_new_entry(self) -> FlowResult: + """Create a new entry.""" + return self.async_create_entry( + title=f"Account: {self.login_info[CONF_EMAIL]}", + data={ + CONF_EMAIL: self.login_info[CONF_EMAIL], + CONF_PASSWORD: self.login_info[CONF_PASSWORD], + }, + options={CONF_USB_PATH: self.usb_path, CONF_USB_SPHERE: self.usb_sphere_id}, + ) + + +class CrownstoneOptionsFlowHandler(OptionsFlow): + """Handle Crownstone options.""" + + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize Crownstone options.""" + self.entry = config_entry + self.updated_options = config_entry.options.copy() + self.spheres: dict[str, str] = {} + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage Crownstone options.""" + manager: CrownstoneEntryManager = self.hass.data[DOMAIN][self.entry.entry_id] + + spheres = {sphere.name: sphere.cloud_id for sphere in manager.cloud.cloud_data} + usb_path = self.entry.options.get(CONF_USB_PATH) + usb_sphere = self.entry.options.get(CONF_USB_SPHERE) + + options_schema = vol.Schema( + {vol.Optional(CONF_USE_USB_OPTION, default=usb_path is not None): bool} + ) + if usb_path is not None and len(spheres) > 1: + options_schema = options_schema.extend( + { + vol.Optional( + CONF_USB_SPHERE_OPTION, + default=manager.cloud.cloud_data.spheres[usb_sphere].name, + ): vol.In(spheres.keys()) + } + ) + + if user_input is not None: + if user_input[CONF_USE_USB_OPTION] and usb_path is None: + self.spheres = spheres + return await self.async_step_usb_config_option() + if not user_input[CONF_USE_USB_OPTION] and usb_path is not None: + self.updated_options[CONF_USB_PATH] = None + self.updated_options[CONF_USB_SPHERE] = None + elif ( + CONF_USB_SPHERE_OPTION in user_input + and spheres[user_input[CONF_USB_SPHERE_OPTION]] != usb_sphere + ): + sphere_id = spheres[user_input[CONF_USB_SPHERE_OPTION]] + user_input[CONF_USB_SPHERE_OPTION] = sphere_id + self.updated_options[CONF_USB_SPHERE] = sphere_id + + return self.async_create_entry(title="", data=self.updated_options) + + return self.async_show_form(step_id="init", data_schema=options_schema) + + async def async_step_usb_config_option( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Set up a Crownstone USB dongle.""" + list_of_ports = await self.hass.async_add_executor_job( + serial.tools.list_ports.comports + ) + ports_as_string = list_ports_as_str(list_of_ports, False) + + if user_input is not None: + selection = user_input[CONF_USB_PATH] + + if selection == MANUAL_PATH: + return await self.async_step_usb_manual_config_option() + if selection != REFRESH_LIST: + selected_port: ListPortInfo = list_of_ports[ + ports_as_string.index(selection) + ] + usb_path = await self.hass.async_add_executor_job( + usb.get_serial_by_id, selected_port.device + ) + self.updated_options[CONF_USB_PATH] = usb_path + return await self.async_step_usb_sphere_config_option() + + return self.async_show_form( + step_id="usb_config_option", + data_schema=vol.Schema( + {vol.Required(CONF_USB_PATH): vol.In(ports_as_string)} + ), + ) + + async def async_step_usb_manual_config_option( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manually enter Crownstone USB dongle path.""" + if user_input is None: + return self.async_show_form( + step_id="usb_manual_config_option", + data_schema=vol.Schema({vol.Required(CONF_USB_MANUAL_PATH): str}), + ) + + self.updated_options[CONF_USB_PATH] = user_input[CONF_USB_MANUAL_PATH] + return await self.async_step_usb_sphere_config_option() + + async def async_step_usb_sphere_config_option( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Select a Crownstone sphere that the USB operates in.""" + # no need to select if there's only 1 option + sphere_id: str | None = None + if len(self.spheres) == 1: + sphere_id = next(iter(self.spheres.values())) + + if user_input is None and sphere_id is None: + return self.async_show_form( + step_id="usb_sphere_config_option", + data_schema=vol.Schema({CONF_USB_SPHERE: vol.In(self.spheres.keys())}), + ) + + if sphere_id: + self.updated_options[CONF_USB_SPHERE] = sphere_id + elif user_input: + self.updated_options[CONF_USB_SPHERE] = self.spheres[ + user_input[CONF_USB_SPHERE] + ] + + return self.async_create_entry(title="", data=self.updated_options) diff --git a/homeassistant/components/crownstone/const.py b/homeassistant/components/crownstone/const.py new file mode 100644 index 00000000000..2238701dcaf --- /dev/null +++ b/homeassistant/components/crownstone/const.py @@ -0,0 +1,45 @@ +"""Constants for the crownstone integration.""" +from __future__ import annotations + +from typing import Final + +# Platforms +DOMAIN: Final = "crownstone" +PLATFORMS: Final[list[str]] = ["light"] + +# Listeners +SSE_LISTENERS: Final = "sse_listeners" +UART_LISTENERS: Final = "uart_listeners" + +# Unique ID suffixes +CROWNSTONE_SUFFIX: Final = "crownstone" + +# Signals (within integration) +SIG_CROWNSTONE_STATE_UPDATE: Final = "crownstone.crownstone_state_update" +SIG_CROWNSTONE_UPDATE: Final = "crownstone.crownstone_update" +SIG_UART_STATE_CHANGE: Final = "crownstone.uart_state_change" + +# Abilities state +ABILITY_STATE: Final[dict[bool, str]] = {True: "Enabled", False: "Disabled"} + +# Config flow +CONF_USB_PATH: Final = "usb_path" +CONF_USB_MANUAL_PATH: Final = "usb_manual_path" +CONF_USB_SPHERE: Final = "usb_sphere" +# Options flow +CONF_USE_USB_OPTION: Final = "use_usb_option" +CONF_USB_SPHERE_OPTION: Final = "usb_sphere_option" +# USB config list entries +DONT_USE_USB: Final = "Don't use USB" +REFRESH_LIST: Final = "Refresh list" +MANUAL_PATH: Final = "Enter manually" + +# Crownstone entity +CROWNSTONE_INCLUDE_TYPES: Final[dict[str, str]] = { + "PLUG": "Plug", + "BUILTIN": "Built-in", + "BUILTIN_ONE": "Built-in One", +} + +# Crownstone USB Dongle +CROWNSTONE_USB: Final = "CROWNSTONE_USB" diff --git a/homeassistant/components/crownstone/devices.py b/homeassistant/components/crownstone/devices.py new file mode 100644 index 00000000000..49965bc8fcd --- /dev/null +++ b/homeassistant/components/crownstone/devices.py @@ -0,0 +1,43 @@ +"""Base classes for Crownstone devices.""" +from __future__ import annotations + +from crownstone_cloud.cloud_models.crownstones import Crownstone + +from homeassistant.const import ( + ATTR_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_NAME, + ATTR_SW_VERSION, +) +from homeassistant.helpers.entity import DeviceInfo + +from .const import CROWNSTONE_INCLUDE_TYPES, DOMAIN + + +class CrownstoneDevice: + """Representation of a Crownstone device.""" + + def __init__(self, device: Crownstone) -> None: + """Initialize the device.""" + self.device = device + + @property + def cloud_id(self) -> str: + """ + Return the unique identifier for this device. + + Used as device ID and to generate unique entity ID's. + """ + return str(self.device.cloud_id) + + @property + def device_info(self) -> DeviceInfo: + """Return device info.""" + return { + ATTR_IDENTIFIERS: {(DOMAIN, self.cloud_id)}, + ATTR_NAME: self.device.name, + ATTR_MANUFACTURER: "Crownstone", + ATTR_MODEL: CROWNSTONE_INCLUDE_TYPES[self.device.type], + ATTR_SW_VERSION: self.device.sw_version, + } diff --git a/homeassistant/components/crownstone/entry_manager.py b/homeassistant/components/crownstone/entry_manager.py new file mode 100644 index 00000000000..b01316a771a --- /dev/null +++ b/homeassistant/components/crownstone/entry_manager.py @@ -0,0 +1,190 @@ +"""Manager to set up IO with Crownstone devices for a config entry.""" +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +from crownstone_cloud import CrownstoneCloud +from crownstone_cloud.exceptions import ( + CrownstoneAuthenticationError, + CrownstoneUnknownError, +) +from crownstone_sse import CrownstoneSSEAsync +from crownstone_uart import CrownstoneUart, UartEventBus +from crownstone_uart.Exceptions import UartException + +from homeassistant.components import persistent_notification +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import aiohttp_client + +from .const import ( + CONF_USB_PATH, + CONF_USB_SPHERE, + DOMAIN, + PLATFORMS, + SSE_LISTENERS, + UART_LISTENERS, +) +from .helpers import get_port +from .listeners import setup_sse_listeners, setup_uart_listeners + +_LOGGER = logging.getLogger(__name__) + + +class CrownstoneEntryManager: + """Manage a Crownstone config entry.""" + + uart: CrownstoneUart | None = None + cloud: CrownstoneCloud + sse: CrownstoneSSEAsync + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Initialize the hub.""" + self.hass = hass + self.config_entry = config_entry + self.listeners: dict[str, Any] = {} + self.usb_sphere_id: str | None = None + + async def async_setup(self) -> bool: + """ + Set up a Crownstone config entry. + + Returns True if the setup was successful. + """ + email = self.config_entry.data[CONF_EMAIL] + password = self.config_entry.data[CONF_PASSWORD] + + self.cloud = CrownstoneCloud( + email=email, + password=password, + clientsession=aiohttp_client.async_get_clientsession(self.hass), + ) + # Login & sync all user data + try: + await self.cloud.async_initialize() + except CrownstoneAuthenticationError as auth_err: + _LOGGER.error( + "Auth error during login with type: %s and message: %s", + auth_err.type, + auth_err.message, + ) + return False + except CrownstoneUnknownError as unknown_err: + _LOGGER.error("Unknown error during login") + raise ConfigEntryNotReady from unknown_err + + # A new clientsession is created because the default one does not cleanup on unload + self.sse = CrownstoneSSEAsync( + email=email, + password=password, + access_token=self.cloud.access_token, + websession=aiohttp_client.async_create_clientsession(self.hass), + ) + # Listen for events in the background, without task tracking + asyncio.create_task(self.async_process_events(self.sse)) + setup_sse_listeners(self) + + # Set up a Crownstone USB only if path exists + if self.config_entry.options[CONF_USB_PATH] is not None: + await self.async_setup_usb() + + # Save the sphere where the USB is located + # Makes HA aware of the Crownstone environment HA is placed in, a user can have multiple + self.usb_sphere_id = self.config_entry.options[CONF_USB_SPHERE] + + self.hass.data.setdefault(DOMAIN, {})[self.config_entry.entry_id] = self + self.hass.config_entries.async_setup_platforms(self.config_entry, PLATFORMS) + + # HA specific listeners + self.config_entry.async_on_unload( + self.config_entry.add_update_listener(_async_update_listener) + ) + self.config_entry.async_on_unload( + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.on_shutdown) + ) + + return True + + async def async_process_events(self, sse_client: CrownstoneSSEAsync) -> None: + """Asynchronous iteration of Crownstone SSE events.""" + async with sse_client as client: + async for event in client: + if event is not None: + # Make SSE updates, like ability change, available to the user + self.hass.bus.async_fire(f"{DOMAIN}_{event.type}", event.data) + + async def async_setup_usb(self) -> None: + """Attempt setup of a Crownstone usb dongle.""" + # Trace by-id symlink back to the serial port + serial_port = await self.hass.async_add_executor_job( + get_port, self.config_entry.options[CONF_USB_PATH] + ) + if serial_port is None: + return + + self.uart = CrownstoneUart() + # UartException is raised when serial controller fails to open + try: + await self.uart.initialize_usb(serial_port) + except UartException: + self.uart = None + # Set entry options for usb to null + updated_options = self.config_entry.options.copy() + updated_options[CONF_USB_PATH] = None + updated_options[CONF_USB_SPHERE] = None + # Ensure that the user can configure an USB again from options + self.hass.config_entries.async_update_entry( + self.config_entry, options=updated_options + ) + # Show notification to ensure the user knows the cloud is now used + persistent_notification.async_create( + self.hass, + f"Setup of Crownstone USB dongle was unsuccessful on port {serial_port}.\n \ + Crownstone Cloud will be used to switch Crownstones.\n \ + Please check if your port is correct and set up the USB again from integration options.", + "Crownstone", + "crownstone_usb_dongle_setup", + ) + return + + setup_uart_listeners(self) + + async def async_unload(self) -> bool: + """Unload the current config entry.""" + # Authentication failed + if self.cloud.cloud_data is None: + return True + + self.sse.close_client() + for sse_unsub in self.listeners[SSE_LISTENERS]: + sse_unsub() + + if self.uart: + self.uart.stop() + for subscription_id in self.listeners[UART_LISTENERS]: + UartEventBus.unsubscribe(subscription_id) + + unload_ok = await self.hass.config_entries.async_unload_platforms( + self.config_entry, PLATFORMS + ) + + if unload_ok: + self.hass.data[DOMAIN].pop(self.config_entry.entry_id) + + return unload_ok + + @callback + def on_shutdown(self, _: Event) -> None: + """Close all IO connections.""" + self.sse.close_client() + if self.uart: + self.uart.stop() + + +async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/crownstone/helpers.py b/homeassistant/components/crownstone/helpers.py new file mode 100644 index 00000000000..ad12c28d464 --- /dev/null +++ b/homeassistant/components/crownstone/helpers.py @@ -0,0 +1,59 @@ +"""Helper functions for the Crownstone integration.""" +from __future__ import annotations + +import os + +from serial.tools.list_ports_common import ListPortInfo + +from homeassistant.components import usb + +from .const import DONT_USE_USB, MANUAL_PATH, REFRESH_LIST + + +def list_ports_as_str( + serial_ports: list[ListPortInfo], no_usb_option: bool = True +) -> list[str]: + """ + Represent currently available serial ports as string. + + Adds option to not use usb on top of the list, + option to use manual path or refresh list at the end. + """ + ports_as_string: list[str] = [] + + if no_usb_option: + ports_as_string.append(DONT_USE_USB) + + for port in serial_ports: + ports_as_string.append( + usb.human_readable_device_name( + port.device, + port.serial_number, + port.manufacturer, + port.description, + f"{hex(port.vid)[2:]:0>4}".upper(), + f"{hex(port.pid)[2:]:0>4}".upper(), + ) + ) + ports_as_string.append(MANUAL_PATH) + ports_as_string.append(REFRESH_LIST) + + return ports_as_string + + +def get_port(dev_path: str) -> str | None: + """Get the port that the by-id link points to.""" + # not a by-id link, but just given path + by_id = "/dev/serial/by-id" + if by_id not in dev_path: + return dev_path + + try: + return f"/dev/{os.path.basename(os.readlink(dev_path))}" + except FileNotFoundError: + return None + + +def map_from_to(val: int, in_min: int, in_max: int, out_min: int, out_max: int) -> int: + """Map a value from a range to another.""" + return int((val - in_min) * (out_max - out_min) / (in_max - in_min) + out_min) diff --git a/homeassistant/components/crownstone/light.py b/homeassistant/components/crownstone/light.py new file mode 100644 index 00000000000..b2d4d8411b7 --- /dev/null +++ b/homeassistant/components/crownstone/light.py @@ -0,0 +1,204 @@ +"""Support for Crownstone devices.""" +from __future__ import annotations + +from collections.abc import Mapping +from functools import partial +import logging +from typing import TYPE_CHECKING, Any + +from crownstone_cloud.cloud_models.crownstones import Crownstone +from crownstone_cloud.const import ( + DIMMING_ABILITY, + SWITCHCRAFT_ABILITY, + TAP_TO_TOGGLE_ABILITY, +) +from crownstone_cloud.exceptions import CrownstoneAbilityError +from crownstone_uart import CrownstoneUart + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + SUPPORT_BRIGHTNESS, + LightEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ( + ABILITY_STATE, + CROWNSTONE_INCLUDE_TYPES, + CROWNSTONE_SUFFIX, + DOMAIN, + SIG_CROWNSTONE_STATE_UPDATE, + SIG_UART_STATE_CHANGE, +) +from .devices import CrownstoneDevice +from .helpers import map_from_to + +if TYPE_CHECKING: + from .entry_manager import CrownstoneEntryManager + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up crownstones from a config entry.""" + manager: CrownstoneEntryManager = hass.data[DOMAIN][config_entry.entry_id] + + entities: list[CrownstoneEntity] = [] + + # Add Crownstone entities that support switching/dimming + for sphere in manager.cloud.cloud_data: + for crownstone in sphere.crownstones: + if crownstone.type in CROWNSTONE_INCLUDE_TYPES: + # Crownstone can communicate with Crownstone USB + if manager.uart and sphere.cloud_id == manager.usb_sphere_id: + entities.append(CrownstoneEntity(crownstone, manager.uart)) + # Crownstone can't communicate with Crownstone USB + else: + entities.append(CrownstoneEntity(crownstone)) + + async_add_entities(entities) + + +def crownstone_state_to_hass(value: int) -> int: + """Crownstone 0..100 to hass 0..255.""" + return map_from_to(value, 0, 100, 0, 255) + + +def hass_to_crownstone_state(value: int) -> int: + """Hass 0..255 to Crownstone 0..100.""" + return map_from_to(value, 0, 255, 0, 100) + + +class CrownstoneEntity(CrownstoneDevice, LightEntity): + """ + Representation of a crownstone. + + Light platform is used to support dimming. + """ + + _attr_should_poll = False + _attr_icon = "mdi:power-socket-de" + + def __init__(self, crownstone_data: Crownstone, usb: CrownstoneUart = None) -> None: + """Initialize the crownstone.""" + super().__init__(crownstone_data) + self.usb = usb + # Entity class attributes + self._attr_name = str(self.device.name) + self._attr_unique_id = f"{self.cloud_id}-{CROWNSTONE_SUFFIX}" + + @property + def usb_available(self) -> bool: + """Return if this entity can use a usb dongle.""" + return self.usb is not None and self.usb.is_ready() + + @property + def brightness(self) -> int | None: + """Return the brightness if dimming enabled.""" + return crownstone_state_to_hass(self.device.state) + + @property + def is_on(self) -> bool: + """Return if the device is on.""" + return crownstone_state_to_hass(self.device.state) > 0 + + @property + def supported_features(self) -> int: + """Return the supported features of this Crownstone.""" + if self.device.abilities.get(DIMMING_ABILITY).is_enabled: + return SUPPORT_BRIGHTNESS + return 0 + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """State attributes for Crownstone devices.""" + attributes: dict[str, Any] = {} + # switch method + if self.usb_available: + attributes["switch_method"] = "Crownstone USB Dongle" + else: + attributes["switch_method"] = "Crownstone Cloud" + + # crownstone abilities + attributes["dimming"] = ABILITY_STATE.get( + self.device.abilities.get(DIMMING_ABILITY).is_enabled + ) + attributes["tap_to_toggle"] = ABILITY_STATE.get( + self.device.abilities.get(TAP_TO_TOGGLE_ABILITY).is_enabled + ) + attributes["switchcraft"] = ABILITY_STATE.get( + self.device.abilities.get(SWITCHCRAFT_ABILITY).is_enabled + ) + + return attributes + + async def async_added_to_hass(self) -> None: + """Set up a listener when this entity is added to HA.""" + # new state received + self.async_on_remove( + async_dispatcher_connect( + self.hass, SIG_CROWNSTONE_STATE_UPDATE, self.async_write_ha_state + ) + ) + # updates state attributes when usb connects/disconnects + self.async_on_remove( + async_dispatcher_connect( + self.hass, SIG_UART_STATE_CHANGE, self.async_write_ha_state + ) + ) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on this light via dongle or cloud.""" + if ATTR_BRIGHTNESS in kwargs: + if self.usb_available: + await self.hass.async_add_executor_job( + partial( + self.usb.dim_crownstone, + self.device.unique_id, + hass_to_crownstone_state(kwargs[ATTR_BRIGHTNESS]), + ) + ) + else: + try: + await self.device.async_set_brightness( + hass_to_crownstone_state(kwargs[ATTR_BRIGHTNESS]) + ) + except CrownstoneAbilityError as ability_error: + _LOGGER.error(ability_error) + return + + # assume brightness is set on device + self.device.state = hass_to_crownstone_state(kwargs[ATTR_BRIGHTNESS]) + self.async_write_ha_state() + + elif self.usb_available: + await self.hass.async_add_executor_job( + partial(self.usb.switch_crownstone, self.device.unique_id, on=True) + ) + self.device.state = 100 + self.async_write_ha_state() + + else: + await self.device.async_turn_on() + self.device.state = 100 + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off this device via dongle or cloud.""" + if self.usb_available: + await self.hass.async_add_executor_job( + partial(self.usb.switch_crownstone, self.device.unique_id, on=False) + ) + + else: + await self.device.async_turn_off() + + self.device.state = 0 + self.async_write_ha_state() diff --git a/homeassistant/components/crownstone/listeners.py b/homeassistant/components/crownstone/listeners.py new file mode 100644 index 00000000000..ae316bc0029 --- /dev/null +++ b/homeassistant/components/crownstone/listeners.py @@ -0,0 +1,147 @@ +""" +Listeners for updating data in the Crownstone integration. + +For data updates, Cloud Push is used in form of an SSE server that sends out events. +For fast device switching Local Push is used in form of a USB dongle that hooks into a BLE mesh. +""" +from __future__ import annotations + +from functools import partial +from typing import TYPE_CHECKING, cast + +from crownstone_core.packets.serviceDataParsers.containers.AdvExternalCrownstoneState import ( + AdvExternalCrownstoneState, +) +from crownstone_core.packets.serviceDataParsers.containers.elements.AdvTypes import ( + AdvType, +) +from crownstone_core.protocol.SwitchState import SwitchState +from crownstone_sse.const import ( + EVENT_ABILITY_CHANGE, + EVENT_ABILITY_CHANGE_DIMMING, + EVENT_SWITCH_STATE_UPDATE, +) +from crownstone_sse.events import AbilityChangeEvent, SwitchStateUpdateEvent +from crownstone_uart import UartEventBus, UartTopics +from crownstone_uart.topics.SystemTopics import SystemTopics + +from homeassistant.core import Event, callback +from homeassistant.helpers.dispatcher import async_dispatcher_send, dispatcher_send + +from .const import ( + DOMAIN, + SIG_CROWNSTONE_STATE_UPDATE, + SIG_UART_STATE_CHANGE, + SSE_LISTENERS, + UART_LISTENERS, +) + +if TYPE_CHECKING: + from .entry_manager import CrownstoneEntryManager + + +@callback +def async_update_crwn_state_sse( + manager: CrownstoneEntryManager, ha_event: Event +) -> None: + """Update the state of a Crownstone when switched externally.""" + switch_event = SwitchStateUpdateEvent(ha_event.data) + try: + updated_crownstone = manager.cloud.get_crownstone_by_id(switch_event.cloud_id) + except KeyError: + return + + # only update on change. + if updated_crownstone.state != switch_event.switch_state: + updated_crownstone.state = switch_event.switch_state + async_dispatcher_send(manager.hass, SIG_CROWNSTONE_STATE_UPDATE) + + +@callback +def async_update_crwn_ability(manager: CrownstoneEntryManager, ha_event: Event) -> None: + """Update the ability information of a Crownstone.""" + ability_event = AbilityChangeEvent(ha_event.data) + try: + updated_crownstone = manager.cloud.get_crownstone_by_id(ability_event.cloud_id) + except KeyError: + return + + ability_type = ability_event.ability_type + ability_enabled = ability_event.ability_enabled + # only update on a change in state + if updated_crownstone.abilities[ability_type].is_enabled == ability_enabled: + return + + # write the change to the crownstone entity. + updated_crownstone.abilities[ability_type].is_enabled = ability_enabled + + if ability_event.sub_type == EVENT_ABILITY_CHANGE_DIMMING: + # reload the config entry because dimming is part of supported features + manager.hass.async_create_task( + manager.hass.config_entries.async_reload(manager.config_entry.entry_id) + ) + else: + async_dispatcher_send(manager.hass, SIG_CROWNSTONE_STATE_UPDATE) + + +def update_uart_state(manager: CrownstoneEntryManager, _: bool | None) -> None: + """Update the uart ready state for entities that use USB.""" + # update availability of power usage entities. + dispatcher_send(manager.hass, SIG_UART_STATE_CHANGE) + + +def update_crwn_state_uart( + manager: CrownstoneEntryManager, data: AdvExternalCrownstoneState +) -> None: + """Update the state of a Crownstone when switched externally.""" + if data.type != AdvType.EXTERNAL_STATE: + return + try: + updated_crownstone = manager.cloud.get_crownstone_by_uid( + data.crownstoneId, manager.usb_sphere_id + ) + except KeyError: + return + + if data.switchState is None: + return + # update on change + updated_state = cast(SwitchState, data.switchState) + if updated_crownstone.state != updated_state.intensity: + updated_crownstone.state = updated_state.intensity + + dispatcher_send(manager.hass, SIG_CROWNSTONE_STATE_UPDATE) + + +def setup_sse_listeners(manager: CrownstoneEntryManager) -> None: + """Set up SSE listeners.""" + # save unsub function for when entry removed + manager.listeners[SSE_LISTENERS] = [ + manager.hass.bus.async_listen( + f"{DOMAIN}_{EVENT_SWITCH_STATE_UPDATE}", + partial(async_update_crwn_state_sse, manager), + ), + manager.hass.bus.async_listen( + f"{DOMAIN}_{EVENT_ABILITY_CHANGE}", + partial(async_update_crwn_ability, manager), + ), + ] + + +def setup_uart_listeners(manager: CrownstoneEntryManager) -> None: + """Set up UART listeners.""" + # save subscription id to unsub + manager.listeners[UART_LISTENERS] = [ + UartEventBus.subscribe( + SystemTopics.connectionEstablished, + partial(update_uart_state, manager), + ), + UartEventBus.subscribe( + SystemTopics.connectionClosed, + partial(update_uart_state, manager), + ), + UartEventBus.subscribe( + UartTopics.newDataAvailable, + partial(update_crwn_state_uart, manager), + ), + ] diff --git a/homeassistant/components/crownstone/manifest.json b/homeassistant/components/crownstone/manifest.json new file mode 100644 index 00000000000..a7caa6a8d7f --- /dev/null +++ b/homeassistant/components/crownstone/manifest.json @@ -0,0 +1,15 @@ +{ + "domain": "crownstone", + "name": "Crownstone", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/crownstone", + "requirements": [ + "crownstone-cloud==1.4.7", + "crownstone-sse==2.0.2", + "crownstone-uart==2.1.0", + "pyserial==3.5" + ], + "codeowners": ["@Crownstone", "@RicArch97"], + "after_dependencies": ["usb"], + "iot_class": "cloud_push" +} diff --git a/homeassistant/components/crownstone/strings.json b/homeassistant/components/crownstone/strings.json new file mode 100644 index 00000000000..7437d458ea7 --- /dev/null +++ b/homeassistant/components/crownstone/strings.json @@ -0,0 +1,75 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "usb_setup_complete": "Crownstone USB setup complete.", + "usb_setup_unsuccessful": "Crownstone USB setup was unsuccessful." + }, + "error": { + "account_not_verified": "Account not verified. Please activate your account through the activation email from Crownstone.", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "title": "Crownstone account" + }, + "usb_config": { + "data": { + "usb_path": "[%key:common::config_flow::data::usb_path%]" + }, + "title": "Crownstone USB dongle configuration", + "description": "Select the serial port of the Crownstone USB dongle, or select 'Don't use USB' if you don't want to setup a USB dongle.\n\nLook for a device with VID 10C4 and PID EA60." + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "[%key:common::config_flow::data::usb_path%]" + }, + "title": "Crownstone USB dongle manual path", + "description": "Manually enter the path of a Crownstone USB dongle." + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "title": "Crownstone USB Sphere", + "description": "Select a Crownstone Sphere where the USB is located." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "use_usb_option": "Use a Crownstone USB dongle for local data transmission", + "usb_sphere_option": "Crownstone Sphere where the USB is located" + } + }, + "usb_config_option": { + "data": { + "usb_path": "[%key:common::config_flow::data::usb_path%]" + }, + "title": "Crownstone USB dongle configuration", + "description": "Select the serial port of the Crownstone USB dongle.\n\nLook for a device with VID 10C4 and PID EA60." + }, + "usb_manual_config_option": { + "data": { + "usb_manual_path": "[%key:common::config_flow::data::usb_path%]" + }, + "title": "Crownstone USB dongle manual path", + "description": "Manually enter the path of a Crownstone USB dongle." + }, + "usb_sphere_config_option": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "title": "Crownstone USB Sphere", + "description": "Select a Crownstone Sphere where the USB is located." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/crownstone/translations/en.json b/homeassistant/components/crownstone/translations/en.json new file mode 100644 index 00000000000..e8b552ba53c --- /dev/null +++ b/homeassistant/components/crownstone/translations/en.json @@ -0,0 +1,75 @@ +{ + "config": { + "abort": { + "already_configured": "Account is already configured", + "usb_setup_complete": "Crownstone USB setup complete.", + "usb_setup_unsuccessful": "Crownstone USB setup was unsuccessful." + }, + "error": { + "account_not_verified": "Account not verified. Please activate your account through the activation email from Crownstone.", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "usb_config": { + "data": { + "usb_path": "USB Device Path" + }, + "description": "Select the serial port of the Crownstone USB dongle, or select 'Don't use USB' if you don't want to setup a USB dongle.\n\nLook for a device with VID 10C4 and PID EA60.", + "title": "Crownstone USB dongle configuration" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "USB Device Path" + }, + "description": "Manually enter the path of a Crownstone USB dongle.", + "title": "Crownstone USB dongle manual path" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "Select a Crownstone Sphere where the USB is located.", + "title": "Crownstone USB Sphere" + }, + "user": { + "data": { + "email": "Email", + "password": "Password" + }, + "title": "Crownstone account" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "usb_sphere_option": "Crownstone Sphere where the USB is located", + "use_usb_option": "Use a Crownstone USB dongle for local data transmission" + } + }, + "usb_config_option": { + "data": { + "usb_path": "USB Device Path" + }, + "description": "Select the serial port of the Crownstone USB dongle.\n\nLook for a device with VID 10C4 and PID EA60.", + "title": "Crownstone USB dongle configuration" + }, + "usb_manual_config_option": { + "data": { + "usb_manual_path": "USB Device Path" + }, + "description": "Manually enter the path of a Crownstone USB dongle.", + "title": "Crownstone USB dongle manual path" + }, + "usb_sphere_config_option": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "Select a Crownstone Sphere where the USB is located.", + "title": "Crownstone USB Sphere" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 1ad789c33d7..6265360700a 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -52,6 +52,7 @@ FLOWS = [ "control4", "coolmaster", "coronavirus", + "crownstone", "daikin", "deconz", "denonavr", diff --git a/mypy.ini b/mypy.ini index 7c195414135..6a27e352595 100644 --- a/mypy.ini +++ b/mypy.ini @@ -308,6 +308,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.crownstone.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.device_automation.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index ed0d0547fa6..5b4bc9e0f73 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -486,6 +486,15 @@ coronavirus==1.1.1 # homeassistant.components.utility_meter croniter==1.0.6 +# homeassistant.components.crownstone +crownstone-cloud==1.4.7 + +# homeassistant.components.crownstone +crownstone-sse==2.0.2 + +# homeassistant.components.crownstone +crownstone-uart==2.1.0 + # homeassistant.components.datadog datadog==0.15.0 @@ -1761,6 +1770,7 @@ pysensibo==1.0.3 pyserial-asyncio==0.5 # homeassistant.components.acer_projector +# homeassistant.components.crownstone # homeassistant.components.usb # homeassistant.components.zha pyserial==3.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 97e3c63a95e..e2925d55244 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -285,6 +285,15 @@ coronavirus==1.1.1 # homeassistant.components.utility_meter croniter==1.0.6 +# homeassistant.components.crownstone +crownstone-cloud==1.4.7 + +# homeassistant.components.crownstone +crownstone-sse==2.0.2 + +# homeassistant.components.crownstone +crownstone-uart==2.1.0 + # homeassistant.components.datadog datadog==0.15.0 @@ -1026,6 +1035,7 @@ pyruckus==0.12 pyserial-asyncio==0.5 # homeassistant.components.acer_projector +# homeassistant.components.crownstone # homeassistant.components.usb # homeassistant.components.zha pyserial==3.5 diff --git a/tests/components/crownstone/__init__.py b/tests/components/crownstone/__init__.py new file mode 100644 index 00000000000..de960619d1d --- /dev/null +++ b/tests/components/crownstone/__init__.py @@ -0,0 +1 @@ +"""Tests for the Crownstone integration.""" diff --git a/tests/components/crownstone/test_config_flow.py b/tests/components/crownstone/test_config_flow.py new file mode 100644 index 00000000000..227657a65a2 --- /dev/null +++ b/tests/components/crownstone/test_config_flow.py @@ -0,0 +1,531 @@ +"""Tests for the Crownstone integration.""" +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +from crownstone_cloud.cloud_models.spheres import Spheres +from crownstone_cloud.exceptions import ( + CrownstoneAuthenticationError, + CrownstoneUnknownError, +) +import pytest +from serial.tools.list_ports_common import ListPortInfo + +from homeassistant import data_entry_flow +from homeassistant.components import usb +from homeassistant.components.crownstone.const import ( + CONF_USB_MANUAL_PATH, + CONF_USB_PATH, + CONF_USB_SPHERE, + CONF_USB_SPHERE_OPTION, + CONF_USE_USB_OPTION, + DOMAIN, + DONT_USE_USB, + MANUAL_PATH, +) +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture(name="crownstone_setup", autouse=True) +def crownstone_setup(): + """Mock Crownstone entry setup.""" + with patch( + "homeassistant.components.crownstone.async_setup_entry", return_value=True + ): + yield + + +def get_mocked_crownstone_cloud(spheres: dict[str, MagicMock] | None = None): + """Return a mocked Crownstone Cloud instance.""" + mock_cloud = MagicMock() + mock_cloud.async_initialize = AsyncMock() + mock_cloud.cloud_data = Spheres(MagicMock(), "account_id") + mock_cloud.cloud_data.spheres = spheres + + return mock_cloud + + +def create_mocked_spheres(amount: int) -> dict[str, MagicMock]: + """Return a dict with mocked sphere instances.""" + spheres: dict[str, MagicMock] = {} + for i in range(amount): + spheres[f"sphere_id_{i}"] = MagicMock() + spheres[f"sphere_id_{i}"].name = f"sphere_name_{i}" + spheres[f"sphere_id_{i}"].cloud_id = f"sphere_id_{i}" + + return spheres + + +def get_mocked_com_port(): + """Mock of a serial port.""" + port = ListPortInfo("/dev/ttyUSB1234") + port.device = "/dev/ttyUSB1234" + port.serial_number = "1234567" + port.manufacturer = "crownstone" + port.description = "crownstone dongle - crownstone dongle" + port.vid = 1234 + port.pid = 5678 + + return port + + +def create_mocked_entry_data_conf(email: str, password: str): + """Set a result for the entry data for comparison.""" + mock_data: dict[str, str | None] = {} + mock_data[CONF_EMAIL] = email + mock_data[CONF_PASSWORD] = password + + return mock_data + + +def create_mocked_entry_options_conf(usb_path: str | None, usb_sphere: str | None): + """Set a result for the entry options for comparison.""" + mock_options: dict[str, str | None] = {} + mock_options[CONF_USB_PATH] = usb_path + mock_options[CONF_USB_SPHERE] = usb_sphere + + return mock_options + + +async def start_config_flow(hass: HomeAssistant, mocked_cloud: MagicMock): + """Patch Crownstone Cloud and start the flow.""" + mocked_login_input = { + CONF_EMAIL: "example@homeassistant.com", + CONF_PASSWORD: "homeassistantisawesome", + } + + with patch( + "homeassistant.components.crownstone.config_flow.CrownstoneCloud", + return_value=mocked_cloud, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"}, data=mocked_login_input + ) + + return result + + +async def start_options_flow( + hass: HomeAssistant, entry_id: str, mocked_cloud: MagicMock +): + """Patch CrownstoneEntryManager and start the flow.""" + mocked_manager = MagicMock() + mocked_manager.cloud = mocked_cloud + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry_id] = mocked_manager + + return await hass.config_entries.options.async_init(entry_id) + + +async def test_no_user_input(hass: HomeAssistant): + """Test the flow done in the correct way.""" + # test if a form is returned if no input is provided + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"} + ) + # show the login form + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + +async def test_abort_if_configured(hass: HomeAssistant): + """Test flow with correct login input and abort if sphere already configured.""" + # create mock entry conf + configured_entry_data = create_mocked_entry_data_conf( + email="example@homeassistant.com", + password="homeassistantisawesome", + ) + configured_entry_options = create_mocked_entry_options_conf( + usb_path="/dev/serial/by-id/crownstone-usb", + usb_sphere="sphere_id", + ) + + # create mocked entry + MockConfigEntry( + domain=DOMAIN, + data=configured_entry_data, + options=configured_entry_options, + unique_id="account_id", + ).add_to_hass(hass) + + result = await start_config_flow(hass, get_mocked_crownstone_cloud()) + + # test if we abort if we try to configure the same entry + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_authentication_errors(hass: HomeAssistant): + """Test flow with wrong auth errors.""" + cloud = get_mocked_crownstone_cloud() + # side effect: auth error login failed + cloud.async_initialize.side_effect = CrownstoneAuthenticationError( + exception_type="LOGIN_FAILED" + ) + + result = await start_config_flow(hass, cloud) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "invalid_auth"} + + # side effect: auth error account not verified + cloud.async_initialize.side_effect = CrownstoneAuthenticationError( + exception_type="LOGIN_FAILED_EMAIL_NOT_VERIFIED" + ) + + result = await start_config_flow(hass, cloud) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "account_not_verified"} + + +async def test_unknown_error(hass: HomeAssistant): + """Test flow with unknown error.""" + cloud = get_mocked_crownstone_cloud() + # side effect: unknown error + cloud.async_initialize.side_effect = CrownstoneUnknownError + + result = await start_config_flow(hass, cloud) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "unknown_error"} + + +async def test_successful_login_no_usb(hass: HomeAssistant): + """Test a successful login without configuring a USB.""" + entry_data_without_usb = create_mocked_entry_data_conf( + email="example@homeassistant.com", + password="homeassistantisawesome", + ) + entry_options_without_usb = create_mocked_entry_options_conf( + usb_path=None, + usb_sphere=None, + ) + + result = await start_config_flow(hass, get_mocked_crownstone_cloud()) + # should show usb form + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "usb_config" + + # don't setup USB dongle, create entry + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_USB_PATH: DONT_USE_USB} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"] == entry_data_without_usb + assert result["options"] == entry_options_without_usb + + +@patch( + "serial.tools.list_ports.comports", MagicMock(return_value=[get_mocked_com_port()]) +) +@patch( + "homeassistant.components.usb.get_serial_by_id", + return_value="/dev/serial/by-id/crownstone-usb", +) +async def test_successful_login_with_usb(serial_mock: MagicMock, hass: HomeAssistant): + """Test flow with correct login and usb configuration.""" + entry_data_with_usb = create_mocked_entry_data_conf( + email="example@homeassistant.com", + password="homeassistantisawesome", + ) + entry_options_with_usb = create_mocked_entry_options_conf( + usb_path="/dev/serial/by-id/crownstone-usb", + usb_sphere="sphere_id_1", + ) + + result = await start_config_flow( + hass, get_mocked_crownstone_cloud(create_mocked_spheres(2)) + ) + # should show usb form + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "usb_config" + + # create a mocked port + port = get_mocked_com_port() + port_select = usb.human_readable_device_name( + port.device, + port.serial_number, + port.manufacturer, + port.description, + f"{hex(port.vid)[2:]:0>4}".upper(), + f"{hex(port.pid)[2:]:0>4}".upper(), + ) + + # select a port from the list + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_USB_PATH: port_select} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "usb_sphere_config" + assert serial_mock.call_count == 1 + + # select a sphere + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_USB_SPHERE: "sphere_name_1"} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"] == entry_data_with_usb + assert result["options"] == entry_options_with_usb + + +@patch( + "serial.tools.list_ports.comports", MagicMock(return_value=[get_mocked_com_port()]) +) +async def test_successful_login_with_manual_usb_path(hass: HomeAssistant): + """Test flow with correct login and usb configuration.""" + entry_data_with_manual_usb = create_mocked_entry_data_conf( + email="example@homeassistant.com", + password="homeassistantisawesome", + ) + entry_options_with_manual_usb = create_mocked_entry_options_conf( + usb_path="/dev/crownstone-usb", + usb_sphere="sphere_id_0", + ) + + result = await start_config_flow( + hass, get_mocked_crownstone_cloud(create_mocked_spheres(1)) + ) + # should show usb form + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "usb_config" + + # select manual from the list + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_USB_PATH: MANUAL_PATH} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "usb_manual_config" + + # enter USB path + path = "/dev/crownstone-usb" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_USB_MANUAL_PATH: path} + ) + + # since we only have 1 sphere here, test that it's automatically selected and + # creating entry without asking for user input + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"] == entry_data_with_manual_usb + assert result["options"] == entry_options_with_manual_usb + + +@patch( + "serial.tools.list_ports.comports", MagicMock(return_value=[get_mocked_com_port()]) +) +@patch( + "homeassistant.components.usb.get_serial_by_id", + return_value="/dev/serial/by-id/crownstone-usb", +) +async def test_options_flow_setup_usb(serial_mock: MagicMock, hass: HomeAssistant): + """Test options flow init.""" + configured_entry_data = create_mocked_entry_data_conf( + email="example@homeassistant.com", + password="homeassistantisawesome", + ) + configured_entry_options = create_mocked_entry_options_conf( + usb_path=None, + usb_sphere=None, + ) + + # create mocked entry + entry = MockConfigEntry( + domain=DOMAIN, + data=configured_entry_data, + options=configured_entry_options, + unique_id="account_id", + ) + entry.add_to_hass(hass) + + result = await start_options_flow( + hass, entry.entry_id, get_mocked_crownstone_cloud(create_mocked_spheres(2)) + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + schema = result["data_schema"].schema + for schema_key in schema: + if schema_key == CONF_USE_USB_OPTION: + assert not schema_key.default() + + # USB is not set up, so this should not be in the options + assert CONF_USB_SPHERE_OPTION not in schema + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_USE_USB_OPTION: True} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "usb_config_option" + + # create a mocked port + port = get_mocked_com_port() + port_select = usb.human_readable_device_name( + port.device, + port.serial_number, + port.manufacturer, + port.description, + f"{hex(port.vid)[2:]:0>4}".upper(), + f"{hex(port.pid)[2:]:0>4}".upper(), + ) + + # select a port from the list + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_USB_PATH: port_select} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "usb_sphere_config_option" + assert serial_mock.call_count == 1 + + # select a sphere + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_USB_SPHERE: "sphere_name_1"} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"] == create_mocked_entry_options_conf( + usb_path="/dev/serial/by-id/crownstone-usb", usb_sphere="sphere_id_1" + ) + + +async def test_options_flow_remove_usb(hass: HomeAssistant): + """Test selecting to set up an USB dongle.""" + configured_entry_data = create_mocked_entry_data_conf( + email="example@homeassistant.com", + password="homeassistantisawesome", + ) + configured_entry_options = create_mocked_entry_options_conf( + usb_path="/dev/serial/by-id/crownstone-usb", + usb_sphere="sphere_id_0", + ) + + # create mocked entry + entry = MockConfigEntry( + domain=DOMAIN, + data=configured_entry_data, + options=configured_entry_options, + unique_id="account_id", + ) + entry.add_to_hass(hass) + + result = await start_options_flow( + hass, entry.entry_id, get_mocked_crownstone_cloud(create_mocked_spheres(2)) + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + schema = result["data_schema"].schema + for schema_key in schema: + if schema_key == CONF_USE_USB_OPTION: + assert schema_key.default() + if schema_key == CONF_USB_SPHERE_OPTION: + assert schema_key.default() == "sphere_name_0" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_USE_USB_OPTION: False, + CONF_USB_SPHERE_OPTION: "sphere_name_0", + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"] == create_mocked_entry_options_conf( + usb_path=None, usb_sphere=None + ) + + +@patch( + "serial.tools.list_ports.comports", MagicMock(return_value=[get_mocked_com_port()]) +) +async def test_options_flow_manual_usb_path(hass: HomeAssistant): + """Test flow with correct login and usb configuration.""" + configured_entry_data = create_mocked_entry_data_conf( + email="example@homeassistant.com", + password="homeassistantisawesome", + ) + configured_entry_options = create_mocked_entry_options_conf( + usb_path=None, + usb_sphere=None, + ) + + # create mocked entry + entry = MockConfigEntry( + domain=DOMAIN, + data=configured_entry_data, + options=configured_entry_options, + unique_id="account_id", + ) + entry.add_to_hass(hass) + + result = await start_options_flow( + hass, entry.entry_id, get_mocked_crownstone_cloud(create_mocked_spheres(1)) + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_USE_USB_OPTION: True} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "usb_config_option" + + # select manual from the list + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_USB_PATH: MANUAL_PATH} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "usb_manual_config_option" + + # enter USB path + path = "/dev/crownstone-usb" + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_USB_MANUAL_PATH: path} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"] == create_mocked_entry_options_conf( + usb_path=path, usb_sphere="sphere_id_0" + ) + + +async def test_options_flow_change_usb_sphere(hass: HomeAssistant): + """Test changing the usb sphere in the options.""" + configured_entry_data = create_mocked_entry_data_conf( + email="example@homeassistant.com", + password="homeassistantisawesome", + ) + configured_entry_options = create_mocked_entry_options_conf( + usb_path="/dev/serial/by-id/crownstone-usb", + usb_sphere="sphere_id_0", + ) + + # create mocked entry + entry = MockConfigEntry( + domain=DOMAIN, + data=configured_entry_data, + options=configured_entry_options, + unique_id="account_id", + ) + entry.add_to_hass(hass) + + result = await start_options_flow( + hass, entry.entry_id, get_mocked_crownstone_cloud(create_mocked_spheres(3)) + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_USE_USB_OPTION: True, CONF_USB_SPHERE_OPTION: "sphere_name_2"}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"] == create_mocked_entry_options_conf( + usb_path="/dev/serial/by-id/crownstone-usb", usb_sphere="sphere_id_2" + ) From 0364922d80cfd2cd27c391b30fc714893cc8ac62 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Tue, 14 Sep 2021 14:04:55 -0600 Subject: [PATCH 395/843] Add long-term statistics for AirNow sensors (#56230) --- homeassistant/components/airnow/sensor.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/airnow/sensor.py b/homeassistant/components/airnow/sensor.py index ed879d32d4a..b0d8c69cff2 100644 --- a/homeassistant/components/airnow/sensor.py +++ b/homeassistant/components/airnow/sensor.py @@ -1,7 +1,11 @@ """Support for the AirNow sensor service.""" from __future__ import annotations -from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( ATTR_ATTRIBUTION, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, @@ -31,18 +35,21 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( icon="mdi:blur", name=ATTR_API_AQI, native_unit_of_measurement="aqi", + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_API_PM25, icon="mdi:blur", name=ATTR_API_PM25, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_API_O3, icon="mdi:blur", name=ATTR_API_O3, native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + state_class=STATE_CLASS_MEASUREMENT, ), ) From 2b51896d7a433427165ec2486470dfb145591805 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 14 Sep 2021 22:06:06 +0200 Subject: [PATCH 396/843] Use EntityDescription - vicare (#55932) * Use EntityDescription - vicare binary_sensor * Use EntityDescription - vicare sensor * Fix typing * Remove default values * Fix pylint --- homeassistant/components/vicare/__init__.py | 14 + .../components/vicare/binary_sensor.py | 108 ++-- homeassistant/components/vicare/sensor.py | 469 +++++++++--------- 3 files changed, 319 insertions(+), 272 deletions(-) diff --git a/homeassistant/components/vicare/__init__.py b/homeassistant/components/vicare/__init__.py index f3ffd7e1db6..b811b9bbfb5 100644 --- a/homeassistant/components/vicare/__init__.py +++ b/homeassistant/components/vicare/__init__.py @@ -1,6 +1,10 @@ """The ViCare integration.""" +from __future__ import annotations + +from dataclasses import dataclass import enum import logging +from typing import Callable, Generic, TypeVar from PyViCare.PyViCareDevice import Device from PyViCare.PyViCareFuelCell import FuelCell @@ -33,6 +37,16 @@ CONF_HEATING_TYPE = "heating_type" DEFAULT_HEATING_TYPE = "generic" +ApiT = TypeVar("ApiT", bound=Device) + + +@dataclass() +class ViCareRequiredKeysMixin(Generic[ApiT]): + """Mixin for required keys.""" + + value_getter: Callable[[ApiT], bool] + + class HeatingType(enum.Enum): """Possible options for heating type.""" diff --git a/homeassistant/components/vicare/binary_sensor.py b/homeassistant/components/vicare/binary_sensor.py index 0c98d22e9ae..9897b38ccf5 100644 --- a/homeassistant/components/vicare/binary_sensor.py +++ b/homeassistant/components/vicare/binary_sensor.py @@ -1,28 +1,35 @@ """Viessmann ViCare sensor device.""" +from __future__ import annotations + from contextlib import suppress +from dataclasses import dataclass import logging +from typing import Union from PyViCare.PyViCare import PyViCareNotSupportedFeatureError, PyViCareRateLimitError +from PyViCare.PyViCareDevice import Device +from PyViCare.PyViCareGazBoiler import GazBoiler +from PyViCare.PyViCareHeatPump import HeatPump import requests from homeassistant.components.binary_sensor import ( DEVICE_CLASS_POWER, BinarySensorEntity, + BinarySensorEntityDescription, ) -from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME from . import ( DOMAIN as VICARE_DOMAIN, VICARE_API, VICARE_HEATING_TYPE, VICARE_NAME, + ApiT, HeatingType, + ViCareRequiredKeysMixin, ) _LOGGER = logging.getLogger(__name__) -CONF_GETTER = "getter" - SENSOR_CIRCULATION_PUMP_ACTIVE = "circulationpump_active" # gas sensors @@ -31,33 +38,46 @@ SENSOR_BURNER_ACTIVE = "burner_active" # heatpump sensors SENSOR_COMPRESSOR_ACTIVE = "compressor_active" -SENSOR_TYPES = { - SENSOR_CIRCULATION_PUMP_ACTIVE: { - CONF_NAME: "Circulation pump active", - CONF_DEVICE_CLASS: DEVICE_CLASS_POWER, - CONF_GETTER: lambda api: api.getCirculationPumpActive(), - }, - # gas sensors - SENSOR_BURNER_ACTIVE: { - CONF_NAME: "Burner active", - CONF_DEVICE_CLASS: DEVICE_CLASS_POWER, - CONF_GETTER: lambda api: api.getBurnerActive(), - }, - # heatpump sensors - SENSOR_COMPRESSOR_ACTIVE: { - CONF_NAME: "Compressor active", - CONF_DEVICE_CLASS: DEVICE_CLASS_POWER, - CONF_GETTER: lambda api: api.getCompressorActive(), - }, -} + +@dataclass +class ViCareBinarySensorEntityDescription( + BinarySensorEntityDescription, ViCareRequiredKeysMixin[ApiT] +): + """Describes ViCare binary sensor entity.""" + + +SENSOR_TYPES_GENERIC: tuple[ViCareBinarySensorEntityDescription[Device]] = ( + ViCareBinarySensorEntityDescription[Device]( + key=SENSOR_CIRCULATION_PUMP_ACTIVE, + name="Circulation pump active", + device_class=DEVICE_CLASS_POWER, + value_getter=lambda api: api.getCirculationPumpActive(), + ), +) + +SENSOR_TYPES_GAS: tuple[ViCareBinarySensorEntityDescription[GazBoiler]] = ( + ViCareBinarySensorEntityDescription[GazBoiler]( + key=SENSOR_BURNER_ACTIVE, + name="Burner active", + device_class=DEVICE_CLASS_POWER, + value_getter=lambda api: api.getBurnerActive(), + ), +) + +SENSOR_TYPES_HEATPUMP: tuple[ViCareBinarySensorEntityDescription[HeatPump]] = ( + ViCareBinarySensorEntityDescription[HeatPump]( + key=SENSOR_COMPRESSOR_ACTIVE, + name="Compressor active", + device_class=DEVICE_CLASS_POWER, + value_getter=lambda api: api.getCompressorActive(), + ), +) SENSORS_GENERIC = [SENSOR_CIRCULATION_PUMP_ACTIVE] SENSORS_BY_HEATINGTYPE = { HeatingType.gas: [SENSOR_BURNER_ACTIVE], - HeatingType.heatpump: [ - SENSOR_COMPRESSOR_ACTIVE, - ], + HeatingType.heatpump: [SENSOR_COMPRESSOR_ACTIVE], HeatingType.fuelcell: [SENSOR_BURNER_ACTIVE], } @@ -78,22 +98,34 @@ def setup_platform(hass, config, add_entities, discovery_info=None): add_entities( [ ViCareBinarySensor( - hass.data[VICARE_DOMAIN][VICARE_NAME], vicare_api, sensor + hass.data[VICARE_DOMAIN][VICARE_NAME], vicare_api, description ) - for sensor in sensors + for description in ( + *SENSOR_TYPES_GENERIC, + *SENSOR_TYPES_GAS, + *SENSOR_TYPES_HEATPUMP, + ) + if description.key in sensors ] ) +DescriptionT = Union[ + ViCareBinarySensorEntityDescription[Device], + ViCareBinarySensorEntityDescription[GazBoiler], + ViCareBinarySensorEntityDescription[HeatPump], +] + + class ViCareBinarySensor(BinarySensorEntity): """Representation of a ViCare sensor.""" - def __init__(self, name, api, sensor_type): + entity_description: DescriptionT + + def __init__(self, name, api, description: DescriptionT): """Initialize the sensor.""" - self._sensor = SENSOR_TYPES[sensor_type] - self._name = f"{name} {self._sensor[CONF_NAME]}" + self._attr_name = f"{name} {description.name}" self._api = api - self._sensor_type = sensor_type self._state = None @property @@ -104,28 +136,18 @@ class ViCareBinarySensor(BinarySensorEntity): @property def unique_id(self): """Return a unique ID.""" - return f"{self._api.service.id}-{self._sensor_type}" - - @property - def name(self): - """Return the name of the sensor.""" - return self._name + return f"{self._api.service.id}-{self.entity_description.key}" @property def is_on(self): """Return the state of the sensor.""" return self._state - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return self._sensor[CONF_DEVICE_CLASS] - def update(self): """Update state of sensor.""" try: with suppress(PyViCareNotSupportedFeatureError): - self._state = self._sensor[CONF_GETTER](self._api) + self._state = self.entity_description.value_getter(self._api) except requests.exceptions.ConnectionError: _LOGGER.error("Unable to retrieve data from ViCare server") except ValueError: diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index e96b3b8120a..c7e318a6c16 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -1,16 +1,20 @@ """Viessmann ViCare sensor device.""" +from __future__ import annotations + from contextlib import suppress +from dataclasses import dataclass import logging +from typing import Union from PyViCare.PyViCare import PyViCareNotSupportedFeatureError, PyViCareRateLimitError +from PyViCare.PyViCareDevice import Device +from PyViCare.PyViCareFuelCell import FuelCell +from PyViCare.PyViCareGazBoiler import GazBoiler +from PyViCare.PyViCareHeatPump import HeatPump import requests -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import ( - CONF_DEVICE_CLASS, - CONF_ICON, - CONF_NAME, - CONF_UNIT_OF_MEASUREMENT, DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, DEVICE_CLASS_TEMPERATURE, @@ -26,13 +30,13 @@ from . import ( VICARE_API, VICARE_HEATING_TYPE, VICARE_NAME, + ApiT, HeatingType, + ViCareRequiredKeysMixin, ) _LOGGER = logging.getLogger(__name__) -CONF_GETTER = "getter" - SENSOR_TYPE_TEMPERATURE = "temperature" SENSOR_OUTSIDE_TEMPERATURE = "outside_temperature" @@ -70,200 +74,212 @@ SENSOR_POWER_PRODUCTION_THIS_WEEK = "power_production_this_week" SENSOR_POWER_PRODUCTION_THIS_MONTH = "power_production_this_month" SENSOR_POWER_PRODUCTION_THIS_YEAR = "power_production_this_year" -SENSOR_TYPES = { - SENSOR_OUTSIDE_TEMPERATURE: { - CONF_NAME: "Outside Temperature", - CONF_ICON: None, - CONF_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, - CONF_GETTER: lambda api: api.getOutsideTemperature(), - CONF_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - }, - SENSOR_SUPPLY_TEMPERATURE: { - CONF_NAME: "Supply Temperature", - CONF_ICON: None, - CONF_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, - CONF_GETTER: lambda api: api.getSupplyTemperature(), - CONF_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - }, - # gas sensors - SENSOR_BOILER_TEMPERATURE: { - CONF_NAME: "Boiler Temperature", - CONF_ICON: None, - CONF_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, - CONF_GETTER: lambda api: api.getBoilerTemperature(), - CONF_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - }, - SENSOR_BURNER_MODULATION: { - CONF_NAME: "Burner modulation", - CONF_ICON: "mdi:percent", - CONF_UNIT_OF_MEASUREMENT: PERCENTAGE, - CONF_GETTER: lambda api: api.getBurnerModulation(), - CONF_DEVICE_CLASS: None, - }, - SENSOR_DHW_GAS_CONSUMPTION_TODAY: { - CONF_NAME: "Hot water gas consumption today", - CONF_ICON: "mdi:power", - CONF_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, - CONF_GETTER: lambda api: api.getGasConsumptionDomesticHotWaterToday(), - CONF_DEVICE_CLASS: None, - }, - SENSOR_DHW_GAS_CONSUMPTION_THIS_WEEK: { - CONF_NAME: "Hot water gas consumption this week", - CONF_ICON: "mdi:power", - CONF_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, - CONF_GETTER: lambda api: api.getGasConsumptionDomesticHotWaterThisWeek(), - CONF_DEVICE_CLASS: None, - }, - SENSOR_DHW_GAS_CONSUMPTION_THIS_MONTH: { - CONF_NAME: "Hot water gas consumption this month", - CONF_ICON: "mdi:power", - CONF_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, - CONF_GETTER: lambda api: api.getGasConsumptionDomesticHotWaterThisMonth(), - CONF_DEVICE_CLASS: None, - }, - SENSOR_DHW_GAS_CONSUMPTION_THIS_YEAR: { - CONF_NAME: "Hot water gas consumption this year", - CONF_ICON: "mdi:power", - CONF_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, - CONF_GETTER: lambda api: api.getGasConsumptionDomesticHotWaterThisYear(), - CONF_DEVICE_CLASS: None, - }, - SENSOR_GAS_CONSUMPTION_TODAY: { - CONF_NAME: "Heating gas consumption today", - CONF_ICON: "mdi:power", - CONF_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, - CONF_GETTER: lambda api: api.getGasConsumptionHeatingToday(), - CONF_DEVICE_CLASS: None, - }, - SENSOR_GAS_CONSUMPTION_THIS_WEEK: { - CONF_NAME: "Heating gas consumption this week", - CONF_ICON: "mdi:power", - CONF_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, - CONF_GETTER: lambda api: api.getGasConsumptionHeatingThisWeek(), - CONF_DEVICE_CLASS: None, - }, - SENSOR_GAS_CONSUMPTION_THIS_MONTH: { - CONF_NAME: "Heating gas consumption this month", - CONF_ICON: "mdi:power", - CONF_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, - CONF_GETTER: lambda api: api.getGasConsumptionHeatingThisMonth(), - CONF_DEVICE_CLASS: None, - }, - SENSOR_GAS_CONSUMPTION_THIS_YEAR: { - CONF_NAME: "Heating gas consumption this year", - CONF_ICON: "mdi:power", - CONF_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, - CONF_GETTER: lambda api: api.getGasConsumptionHeatingThisYear(), - CONF_DEVICE_CLASS: None, - }, - SENSOR_BURNER_STARTS: { - CONF_NAME: "Burner Starts", - CONF_ICON: "mdi:counter", - CONF_UNIT_OF_MEASUREMENT: None, - CONF_GETTER: lambda api: api.getBurnerStarts(), - CONF_DEVICE_CLASS: None, - }, - SENSOR_BURNER_HOURS: { - CONF_NAME: "Burner Hours", - CONF_ICON: "mdi:counter", - CONF_UNIT_OF_MEASUREMENT: TIME_HOURS, - CONF_GETTER: lambda api: api.getBurnerHours(), - CONF_DEVICE_CLASS: None, - }, - # heatpump sensors - SENSOR_COMPRESSOR_STARTS: { - CONF_NAME: "Compressor Starts", - CONF_ICON: "mdi:counter", - CONF_UNIT_OF_MEASUREMENT: None, - CONF_GETTER: lambda api: api.getCompressorStarts(), - CONF_DEVICE_CLASS: None, - }, - SENSOR_COMPRESSOR_HOURS: { - CONF_NAME: "Compressor Hours", - CONF_ICON: "mdi:counter", - CONF_UNIT_OF_MEASUREMENT: TIME_HOURS, - CONF_GETTER: lambda api: api.getCompressorHours(), - CONF_DEVICE_CLASS: None, - }, - SENSOR_COMPRESSOR_HOURS_LOADCLASS1: { - CONF_NAME: "Compressor Hours Load Class 1", - CONF_ICON: "mdi:counter", - CONF_UNIT_OF_MEASUREMENT: TIME_HOURS, - CONF_GETTER: lambda api: api.getCompressorHoursLoadClass1(), - CONF_DEVICE_CLASS: None, - }, - SENSOR_COMPRESSOR_HOURS_LOADCLASS2: { - CONF_NAME: "Compressor Hours Load Class 2", - CONF_ICON: "mdi:counter", - CONF_UNIT_OF_MEASUREMENT: TIME_HOURS, - CONF_GETTER: lambda api: api.getCompressorHoursLoadClass2(), - CONF_DEVICE_CLASS: None, - }, - SENSOR_COMPRESSOR_HOURS_LOADCLASS3: { - CONF_NAME: "Compressor Hours Load Class 3", - CONF_ICON: "mdi:counter", - CONF_UNIT_OF_MEASUREMENT: TIME_HOURS, - CONF_GETTER: lambda api: api.getCompressorHoursLoadClass3(), - CONF_DEVICE_CLASS: None, - }, - SENSOR_COMPRESSOR_HOURS_LOADCLASS4: { - CONF_NAME: "Compressor Hours Load Class 4", - CONF_ICON: "mdi:counter", - CONF_UNIT_OF_MEASUREMENT: TIME_HOURS, - CONF_GETTER: lambda api: api.getCompressorHoursLoadClass4(), - CONF_DEVICE_CLASS: None, - }, - SENSOR_COMPRESSOR_HOURS_LOADCLASS5: { - CONF_NAME: "Compressor Hours Load Class 5", - CONF_ICON: "mdi:counter", - CONF_UNIT_OF_MEASUREMENT: TIME_HOURS, - CONF_GETTER: lambda api: api.getCompressorHoursLoadClass5(), - CONF_DEVICE_CLASS: None, - }, - SENSOR_RETURN_TEMPERATURE: { - CONF_NAME: "Return Temperature", - CONF_ICON: None, - CONF_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, - CONF_GETTER: lambda api: api.getReturnTemperature(), - CONF_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - }, - # fuelcell sensors - SENSOR_POWER_PRODUCTION_CURRENT: { - CONF_NAME: "Power production current", - CONF_ICON: None, - CONF_UNIT_OF_MEASUREMENT: POWER_WATT, - CONF_GETTER: lambda api: api.getPowerProductionCurrent(), - CONF_DEVICE_CLASS: DEVICE_CLASS_POWER, - }, - SENSOR_POWER_PRODUCTION_TODAY: { - CONF_NAME: "Power production today", - CONF_ICON: None, - CONF_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, - CONF_GETTER: lambda api: api.getPowerProductionToday(), - CONF_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - }, - SENSOR_POWER_PRODUCTION_THIS_WEEK: { - CONF_NAME: "Power production this week", - CONF_ICON: None, - CONF_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, - CONF_GETTER: lambda api: api.getPowerProductionThisWeek(), - CONF_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - }, - SENSOR_POWER_PRODUCTION_THIS_MONTH: { - CONF_NAME: "Power production this month", - CONF_ICON: None, - CONF_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, - CONF_GETTER: lambda api: api.getPowerProductionThisMonth(), - CONF_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - }, - SENSOR_POWER_PRODUCTION_THIS_YEAR: { - CONF_NAME: "Power production this year", - CONF_ICON: None, - CONF_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, - CONF_GETTER: lambda api: api.getPowerProductionThisYear(), - CONF_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - }, -} + +@dataclass +class ViCareSensorEntityDescription( + SensorEntityDescription, ViCareRequiredKeysMixin[ApiT] +): + """Describes ViCare sensor entity.""" + + +SENSOR_TYPES_GENERIC: tuple[ViCareSensorEntityDescription[Device], ...] = ( + ViCareSensorEntityDescription[Device]( + key=SENSOR_OUTSIDE_TEMPERATURE, + name="Outside Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + value_getter=lambda api: api.getOutsideTemperature(), + device_class=DEVICE_CLASS_TEMPERATURE, + ), + ViCareSensorEntityDescription[Device]( + key=SENSOR_SUPPLY_TEMPERATURE, + name="Supply Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + value_getter=lambda api: api.getSupplyTemperature(), + device_class=DEVICE_CLASS_TEMPERATURE, + ), +) + +SENSOR_TYPES_GAS: tuple[ViCareSensorEntityDescription[GazBoiler], ...] = ( + ViCareSensorEntityDescription[GazBoiler]( + key=SENSOR_BOILER_TEMPERATURE, + name="Boiler Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + value_getter=lambda api: api.getBoilerTemperature(), + device_class=DEVICE_CLASS_TEMPERATURE, + ), + ViCareSensorEntityDescription[GazBoiler]( + key=SENSOR_BURNER_MODULATION, + name="Burner modulation", + icon="mdi:percent", + native_unit_of_measurement=PERCENTAGE, + value_getter=lambda api: api.getBurnerModulation(), + ), + ViCareSensorEntityDescription[GazBoiler]( + key=SENSOR_DHW_GAS_CONSUMPTION_TODAY, + name="Hot water gas consumption today", + icon="mdi:power", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + value_getter=lambda api: api.getGasConsumptionDomesticHotWaterToday(), + ), + ViCareSensorEntityDescription[GazBoiler]( + key=SENSOR_DHW_GAS_CONSUMPTION_THIS_WEEK, + name="Hot water gas consumption this week", + icon="mdi:power", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + value_getter=lambda api: api.getGasConsumptionDomesticHotWaterThisWeek(), + ), + ViCareSensorEntityDescription[GazBoiler]( + key=SENSOR_DHW_GAS_CONSUMPTION_THIS_MONTH, + name="Hot water gas consumption this month", + icon="mdi:power", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + value_getter=lambda api: api.getGasConsumptionDomesticHotWaterThisMonth(), + ), + ViCareSensorEntityDescription[GazBoiler]( + key=SENSOR_DHW_GAS_CONSUMPTION_THIS_YEAR, + name="Hot water gas consumption this year", + icon="mdi:power", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + value_getter=lambda api: api.getGasConsumptionDomesticHotWaterThisYear(), + ), + ViCareSensorEntityDescription[GazBoiler]( + key=SENSOR_GAS_CONSUMPTION_TODAY, + name="Heating gas consumption today", + icon="mdi:power", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + value_getter=lambda api: api.getGasConsumptionHeatingToday(), + ), + ViCareSensorEntityDescription[GazBoiler]( + key=SENSOR_GAS_CONSUMPTION_THIS_WEEK, + name="Heating gas consumption this week", + icon="mdi:power", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + value_getter=lambda api: api.getGasConsumptionHeatingThisWeek(), + ), + ViCareSensorEntityDescription[GazBoiler]( + key=SENSOR_GAS_CONSUMPTION_THIS_MONTH, + name="Heating gas consumption this month", + icon="mdi:power", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + value_getter=lambda api: api.getGasConsumptionHeatingThisMonth(), + ), + ViCareSensorEntityDescription[GazBoiler]( + key=SENSOR_GAS_CONSUMPTION_THIS_YEAR, + name="Heating gas consumption this year", + icon="mdi:power", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + value_getter=lambda api: api.getGasConsumptionHeatingThisYear(), + ), + ViCareSensorEntityDescription[GazBoiler]( + key=SENSOR_BURNER_STARTS, + name="Burner Starts", + icon="mdi:counter", + value_getter=lambda api: api.getBurnerStarts(), + ), + ViCareSensorEntityDescription[GazBoiler]( + key=SENSOR_BURNER_HOURS, + name="Burner Hours", + icon="mdi:counter", + native_unit_of_measurement=TIME_HOURS, + value_getter=lambda api: api.getBurnerHours(), + ), +) + +SENSOR_TYPES_HEATPUMP: tuple[ViCareSensorEntityDescription[HeatPump], ...] = ( + ViCareSensorEntityDescription[HeatPump]( + key=SENSOR_COMPRESSOR_STARTS, + name="Compressor Starts", + icon="mdi:counter", + value_getter=lambda api: api.getCompressorStarts(), + ), + ViCareSensorEntityDescription[HeatPump]( + key=SENSOR_COMPRESSOR_HOURS, + name="Compressor Hours", + icon="mdi:counter", + native_unit_of_measurement=TIME_HOURS, + value_getter=lambda api: api.getCompressorHours(), + ), + ViCareSensorEntityDescription[HeatPump]( + key=SENSOR_COMPRESSOR_HOURS_LOADCLASS1, + name="Compressor Hours Load Class 1", + icon="mdi:counter", + native_unit_of_measurement=TIME_HOURS, + value_getter=lambda api: api.getCompressorHoursLoadClass1(), + ), + ViCareSensorEntityDescription[HeatPump]( + key=SENSOR_COMPRESSOR_HOURS_LOADCLASS2, + name="Compressor Hours Load Class 2", + icon="mdi:counter", + native_unit_of_measurement=TIME_HOURS, + value_getter=lambda api: api.getCompressorHoursLoadClass2(), + ), + ViCareSensorEntityDescription[HeatPump]( + key=SENSOR_COMPRESSOR_HOURS_LOADCLASS3, + name="Compressor Hours Load Class 3", + icon="mdi:counter", + native_unit_of_measurement=TIME_HOURS, + value_getter=lambda api: api.getCompressorHoursLoadClass3(), + ), + ViCareSensorEntityDescription[HeatPump]( + key=SENSOR_COMPRESSOR_HOURS_LOADCLASS4, + name="Compressor Hours Load Class 4", + icon="mdi:counter", + native_unit_of_measurement=TIME_HOURS, + value_getter=lambda api: api.getCompressorHoursLoadClass4(), + ), + ViCareSensorEntityDescription[HeatPump]( + key=SENSOR_COMPRESSOR_HOURS_LOADCLASS5, + name="Compressor Hours Load Class 5", + icon="mdi:counter", + native_unit_of_measurement=TIME_HOURS, + value_getter=lambda api: api.getCompressorHoursLoadClass5(), + ), + ViCareSensorEntityDescription[HeatPump]( + key=SENSOR_RETURN_TEMPERATURE, + name="Return Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + value_getter=lambda api: api.getReturnTemperature(), + device_class=DEVICE_CLASS_TEMPERATURE, + ), +) + +SENSOR_TYPES_FUELCELL: tuple[ViCareSensorEntityDescription[FuelCell], ...] = ( + ViCareSensorEntityDescription[FuelCell]( + key=SENSOR_POWER_PRODUCTION_CURRENT, + name="Power production current", + native_unit_of_measurement=POWER_WATT, + value_getter=lambda api: api.getPowerProductionCurrent(), + device_class=DEVICE_CLASS_POWER, + ), + ViCareSensorEntityDescription[FuelCell]( + key=SENSOR_POWER_PRODUCTION_TODAY, + name="Power production today", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + value_getter=lambda api: api.getPowerProductionToday(), + device_class=DEVICE_CLASS_ENERGY, + ), + ViCareSensorEntityDescription[FuelCell]( + key=SENSOR_POWER_PRODUCTION_THIS_WEEK, + name="Power production this week", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + value_getter=lambda api: api.getPowerProductionThisWeek(), + device_class=DEVICE_CLASS_ENERGY, + ), + ViCareSensorEntityDescription[FuelCell]( + key=SENSOR_POWER_PRODUCTION_THIS_MONTH, + name="Power production this month", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + value_getter=lambda api: api.getPowerProductionThisMonth(), + device_class=DEVICE_CLASS_ENERGY, + ), + ViCareSensorEntityDescription[FuelCell]( + key=SENSOR_POWER_PRODUCTION_THIS_YEAR, + name="Power production this year", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + value_getter=lambda api: api.getPowerProductionThisYear(), + device_class=DEVICE_CLASS_ENERGY, + ), +) SENSORS_GENERIC = [SENSOR_OUTSIDE_TEMPERATURE, SENSOR_SUPPLY_TEMPERATURE] @@ -331,21 +347,36 @@ def setup_platform(hass, config, add_entities, discovery_info=None): add_entities( [ - ViCareSensor(hass.data[VICARE_DOMAIN][VICARE_NAME], vicare_api, sensor) - for sensor in sensors + ViCareSensor(hass.data[VICARE_DOMAIN][VICARE_NAME], vicare_api, description) + for description in ( + *SENSOR_TYPES_GENERIC, + *SENSOR_TYPES_GAS, + *SENSOR_TYPES_HEATPUMP, + *SENSOR_TYPES_FUELCELL, + ) + if description.key in sensors ] ) +DescriptionT = Union[ + ViCareSensorEntityDescription[Device], + ViCareSensorEntityDescription[GazBoiler], + ViCareSensorEntityDescription[HeatPump], + ViCareSensorEntityDescription[FuelCell], +] + + class ViCareSensor(SensorEntity): """Representation of a ViCare sensor.""" - def __init__(self, name, api, sensor_type): + entity_description: DescriptionT + + def __init__(self, name, api, description: DescriptionT): """Initialize the sensor.""" - self._sensor = SENSOR_TYPES[sensor_type] - self._name = f"{name} {self._sensor[CONF_NAME]}" + self.entity_description = description + self._attr_name = f"{name} {description.name}" self._api = api - self._sensor_type = sensor_type self._state = None @property @@ -356,38 +387,18 @@ class ViCareSensor(SensorEntity): @property def unique_id(self): """Return a unique ID.""" - return f"{self._api.service.id}-{self._sensor_type}" - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return self._sensor[CONF_ICON] + return f"{self._api.service.id}-{self.entity_description.key}" @property def native_value(self): """Return the state of the sensor.""" return self._state - @property - def native_unit_of_measurement(self): - """Return the unit of measurement.""" - return self._sensor[CONF_UNIT_OF_MEASUREMENT] - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return self._sensor[CONF_DEVICE_CLASS] - def update(self): """Update state of sensor.""" try: with suppress(PyViCareNotSupportedFeatureError): - self._state = self._sensor[CONF_GETTER](self._api) + self._state = self.entity_description.value_getter(self._api) except requests.exceptions.ConnectionError: _LOGGER.error("Unable to retrieve data from ViCare server") except ValueError: From 4b2ff0a0bab2122ab2cba89f9258de18b720bfac Mon Sep 17 00:00:00 2001 From: jan iversen Date: Tue, 14 Sep 2021 22:06:29 +0200 Subject: [PATCH 397/843] Update template/alarm_control_panel.py to use pytest (#56229) --- .../template/test_alarm_control_panel.py | 830 +++++++----------- 1 file changed, 328 insertions(+), 502 deletions(-) diff --git a/tests/components/template/test_alarm_control_panel.py b/tests/components/template/test_alarm_control_panel.py index abb2e7b4765..fabf626afd3 100644 --- a/tests/components/template/test_alarm_control_panel.py +++ b/tests/components/template/test_alarm_control_panel.py @@ -1,5 +1,6 @@ """The tests for the Template alarm control panel platform.""" -from homeassistant import setup +import pytest + from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, @@ -10,21 +11,80 @@ from homeassistant.const import ( STATE_ALARM_TRIGGERED, ) -from tests.common import async_mock_service from tests.components.alarm_control_panel import common +TEMPLATE_NAME = "alarm_control_panel.test_template_panel" +PANEL_NAME = "alarm_control_panel.test" -async def test_template_state_text(hass): + +@pytest.mark.parametrize("count,domain", [(1, "alarm_control_panel")]) +@pytest.mark.parametrize( + "config", + [ + { + "alarm_control_panel": { + "platform": "template", + "panels": { + "test_template_panel": { + "value_template": "{{ states('alarm_control_panel.test') }}", + "arm_away": { + "service": "alarm_control_panel.alarm_arm_away", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "arm_home": { + "service": "alarm_control_panel.alarm_arm_home", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "arm_night": { + "service": "alarm_control_panel.alarm_arm_night", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "disarm": { + "service": "alarm_control_panel.alarm_disarm", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + } + }, + } + }, + ], +) +async def test_template_state_text(hass, start_ha): """Test the state text of a template.""" - await setup.async_setup_component( - hass, - "alarm_control_panel", + + for set_state in [ + STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMING, + STATE_ALARM_DISARMED, + STATE_ALARM_PENDING, + STATE_ALARM_TRIGGERED, + ]: + hass.states.async_set(PANEL_NAME, set_state) + await hass.async_block_till_done() + state = hass.states.get(TEMPLATE_NAME) + assert state.state == set_state + + hass.states.async_set(PANEL_NAME, "invalid_state") + await hass.async_block_till_done() + state = hass.states.get(TEMPLATE_NAME) + assert state.state == "unknown" + + +@pytest.mark.parametrize("count,domain", [(1, "alarm_control_panel")]) +@pytest.mark.parametrize( + "config", + [ { "alarm_control_panel": { "platform": "template", "panels": { "test_template_panel": { - "value_template": "{{ states('alarm_control_panel.test') }}", "arm_away": { "service": "alarm_control_panel.alarm_arm_away", "entity_id": "alarm_control_panel.test", @@ -49,140 +109,30 @@ async def test_template_state_text(hass): }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - hass.states.async_set("alarm_control_panel.test", STATE_ALARM_ARMED_HOME) - await hass.async_block_till_done() - - state = hass.states.get("alarm_control_panel.test_template_panel") - assert state.state == STATE_ALARM_ARMED_HOME - - hass.states.async_set("alarm_control_panel.test", STATE_ALARM_ARMED_AWAY) - await hass.async_block_till_done() - - state = hass.states.get("alarm_control_panel.test_template_panel") - assert state.state == STATE_ALARM_ARMED_AWAY - - hass.states.async_set("alarm_control_panel.test", STATE_ALARM_ARMED_NIGHT) - await hass.async_block_till_done() - - state = hass.states.get("alarm_control_panel.test_template_panel") - assert state.state == STATE_ALARM_ARMED_NIGHT - - hass.states.async_set("alarm_control_panel.test", STATE_ALARM_ARMING) - await hass.async_block_till_done() - - state = hass.states.get("alarm_control_panel.test_template_panel") - assert state.state == STATE_ALARM_ARMING - - hass.states.async_set("alarm_control_panel.test", STATE_ALARM_DISARMED) - await hass.async_block_till_done() - - state = hass.states.get("alarm_control_panel.test_template_panel") - assert state.state == STATE_ALARM_DISARMED - - hass.states.async_set("alarm_control_panel.test", STATE_ALARM_PENDING) - await hass.async_block_till_done() - - state = hass.states.get("alarm_control_panel.test_template_panel") - assert state.state == STATE_ALARM_PENDING - - hass.states.async_set("alarm_control_panel.test", STATE_ALARM_TRIGGERED) - await hass.async_block_till_done() - - state = hass.states.get("alarm_control_panel.test_template_panel") - assert state.state == STATE_ALARM_TRIGGERED - - hass.states.async_set("alarm_control_panel.test", "invalid_state") - await hass.async_block_till_done() - - state = hass.states.get("alarm_control_panel.test_template_panel") - assert state.state == "unknown" - - -async def test_optimistic_states(hass): + ], +) +async def test_optimistic_states(hass, start_ha): """Test the optimistic state.""" - await setup.async_setup_component( - hass, - "alarm_control_panel", - { - "alarm_control_panel": { - "platform": "template", - "panels": { - "test_template_panel": { - "arm_away": { - "service": "alarm_control_panel.alarm_arm_away", - "entity_id": "alarm_control_panel.test", - "data": {"code": "1234"}, - }, - "arm_home": { - "service": "alarm_control_panel.alarm_arm_home", - "entity_id": "alarm_control_panel.test", - "data": {"code": "1234"}, - }, - "arm_night": { - "service": "alarm_control_panel.alarm_arm_night", - "entity_id": "alarm_control_panel.test", - "data": {"code": "1234"}, - }, - "disarm": { - "service": "alarm_control_panel.alarm_disarm", - "entity_id": "alarm_control_panel.test", - "data": {"code": "1234"}, - }, - } - }, - } - }, - ) - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - state = hass.states.get("alarm_control_panel.test_template_panel") + state = hass.states.get(TEMPLATE_NAME) await hass.async_block_till_done() assert state.state == "unknown" - await common.async_alarm_arm_away( - hass, entity_id="alarm_control_panel.test_template_panel" - ) - await hass.async_block_till_done() - state = hass.states.get("alarm_control_panel.test_template_panel") - await hass.async_block_till_done() - assert state.state == STATE_ALARM_ARMED_AWAY - - await common.async_alarm_arm_home( - hass, entity_id="alarm_control_panel.test_template_panel" - ) - state = hass.states.get("alarm_control_panel.test_template_panel") - await hass.async_block_till_done() - assert state.state == STATE_ALARM_ARMED_HOME - - await common.async_alarm_arm_night( - hass, entity_id="alarm_control_panel.test_template_panel" - ) - state = hass.states.get("alarm_control_panel.test_template_panel") - await hass.async_block_till_done() - assert state.state == STATE_ALARM_ARMED_NIGHT - - await common.async_alarm_disarm( - hass, entity_id="alarm_control_panel.test_template_panel" - ) - state = hass.states.get("alarm_control_panel.test_template_panel") - await hass.async_block_till_done() - assert state.state == STATE_ALARM_DISARMED + for func, set_state in [ + (common.async_alarm_arm_away, STATE_ALARM_ARMED_AWAY), + (common.async_alarm_arm_home, STATE_ALARM_ARMED_HOME), + (common.async_alarm_arm_night, STATE_ALARM_ARMED_NIGHT), + (common.async_alarm_disarm, STATE_ALARM_DISARMED), + ]: + await func(hass, entity_id=TEMPLATE_NAME) + await hass.async_block_till_done() + assert hass.states.get(TEMPLATE_NAME).state == set_state -async def test_no_action_scripts(hass): - """Test no action scripts per state.""" - await setup.async_setup_component( - hass, - "alarm_control_panel", +@pytest.mark.parametrize("count,domain", [(1, "alarm_control_panel")]) +@pytest.mark.parametrize( + "config", + [ { "alarm_control_panel": { "platform": "template", @@ -193,180 +143,121 @@ async def test_no_action_scripts(hass): }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_no_action_scripts(hass, start_ha): + """Test no action scripts per state.""" hass.states.async_set("alarm_control_panel.test", STATE_ALARM_ARMED_AWAY) await hass.async_block_till_done() - await common.async_alarm_arm_away( - hass, entity_id="alarm_control_panel.test_template_panel" - ) - await hass.async_block_till_done() - state = hass.states.get("alarm_control_panel.test_template_panel") - await hass.async_block_till_done() - assert state.state == STATE_ALARM_ARMED_AWAY - - await common.async_alarm_arm_home( - hass, entity_id="alarm_control_panel.test_template_panel" - ) - await hass.async_block_till_done() - state = hass.states.get("alarm_control_panel.test_template_panel") - await hass.async_block_till_done() - assert state.state == STATE_ALARM_ARMED_AWAY - - await common.async_alarm_arm_night( - hass, entity_id="alarm_control_panel.test_template_panel" - ) - await hass.async_block_till_done() - state = hass.states.get("alarm_control_panel.test_template_panel") - await hass.async_block_till_done() - assert state.state == STATE_ALARM_ARMED_AWAY - - await common.async_alarm_disarm( - hass, entity_id="alarm_control_panel.test_template_panel" - ) - await hass.async_block_till_done() - state = hass.states.get("alarm_control_panel.test_template_panel") - await hass.async_block_till_done() - assert state.state == STATE_ALARM_ARMED_AWAY + for func, set_state in [ + (common.async_alarm_arm_away, STATE_ALARM_ARMED_AWAY), + (common.async_alarm_arm_home, STATE_ALARM_ARMED_AWAY), + (common.async_alarm_arm_night, STATE_ALARM_ARMED_AWAY), + (common.async_alarm_disarm, STATE_ALARM_ARMED_AWAY), + ]: + await func(hass, entity_id=TEMPLATE_NAME) + await hass.async_block_till_done() + assert hass.states.get(TEMPLATE_NAME).state == set_state -async def test_template_syntax_error(hass, caplog): +@pytest.mark.parametrize("count,domain", [(0, "alarm_control_panel")]) +@pytest.mark.parametrize( + "config,msg", + [ + ( + { + "alarm_control_panel": { + "platform": "template", + "panels": { + "test_template_panel": { + "value_template": "{% if blah %}", + "arm_away": { + "service": "alarm_control_panel.alarm_arm_away", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "arm_home": { + "service": "alarm_control_panel.alarm_arm_home", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "arm_night": { + "service": "alarm_control_panel.alarm_arm_night", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "disarm": { + "service": "alarm_control_panel.alarm_disarm", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + } + }, + } + }, + "invalid template", + ), + ( + { + "alarm_control_panel": { + "platform": "template", + "panels": { + "bad name here": { + "value_template": "{{ disarmed }}", + "arm_away": { + "service": "alarm_control_panel.alarm_arm_away", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "arm_home": { + "service": "alarm_control_panel.alarm_arm_home", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "arm_night": { + "service": "alarm_control_panel.alarm_arm_night", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "disarm": { + "service": "alarm_control_panel.alarm_disarm", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + } + }, + } + }, + "invalid slug bad name", + ), + ( + { + "alarm_control_panel": { + "platform": "template", + "wibble": {"test_panel": "Invalid"}, + } + }, + "[wibble] is an invalid option", + ), + ( + { + "alarm_control_panel": {"platform": "template"}, + }, + "required key not provided @ data['panels']", + ), + ], +) +async def test_template_syntax_error(hass, msg, start_ha, caplog_setup_text): """Test templating syntax error.""" - await setup.async_setup_component( - hass, - "alarm_control_panel", - { - "alarm_control_panel": { - "platform": "template", - "panels": { - "test_template_panel": { - "value_template": "{% if blah %}", - "arm_away": { - "service": "alarm_control_panel.alarm_arm_away", - "entity_id": "alarm_control_panel.test", - "data": {"code": "1234"}, - }, - "arm_home": { - "service": "alarm_control_panel.alarm_arm_home", - "entity_id": "alarm_control_panel.test", - "data": {"code": "1234"}, - }, - "arm_night": { - "service": "alarm_control_panel.alarm_arm_night", - "entity_id": "alarm_control_panel.test", - "data": {"code": "1234"}, - }, - "disarm": { - "service": "alarm_control_panel.alarm_disarm", - "entity_id": "alarm_control_panel.test", - "data": {"code": "1234"}, - }, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - assert len(hass.states.async_all()) == 0 - assert ("invalid template") in caplog.text + assert (msg) in caplog_setup_text -async def test_invalid_name_does_not_create(hass, caplog): - """Test invalid name.""" - await setup.async_setup_component( - hass, - "alarm_control_panel", - { - "alarm_control_panel": { - "platform": "template", - "panels": { - "bad name here": { - "value_template": "{{ disarmed }}", - "arm_away": { - "service": "alarm_control_panel.alarm_arm_away", - "entity_id": "alarm_control_panel.test", - "data": {"code": "1234"}, - }, - "arm_home": { - "service": "alarm_control_panel.alarm_arm_home", - "entity_id": "alarm_control_panel.test", - "data": {"code": "1234"}, - }, - "arm_night": { - "service": "alarm_control_panel.alarm_arm_night", - "entity_id": "alarm_control_panel.test", - "data": {"code": "1234"}, - }, - "disarm": { - "service": "alarm_control_panel.alarm_disarm", - "entity_id": "alarm_control_panel.test", - "data": {"code": "1234"}, - }, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert len(hass.states.async_all()) == 0 - assert ("invalid slug bad name") in caplog.text - - -async def test_invalid_panel_does_not_create(hass, caplog): - """Test invalid alarm control panel.""" - await setup.async_setup_component( - hass, - "alarm_control_panel", - { - "alarm_control_panel": { - "platform": "template", - "wibble": {"test_panel": "Invalid"}, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert len(hass.states.async_all()) == 0 - assert ("[wibble] is an invalid option") in caplog.text - - -async def test_no_panels_does_not_create(hass, caplog): - """Test if there are no panels -> no creation.""" - await setup.async_setup_component( - hass, - "alarm_control_panel", - {"alarm_control_panel": {"platform": "template"}}, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert len(hass.states.async_all()) == 0 - assert ("required key not provided @ data['panels']") in caplog.text - - -async def test_name(hass): - """Test the accessibility of the name attribute.""" - await setup.async_setup_component( - hass, - "alarm_control_panel", +@pytest.mark.parametrize("count,domain", [(1, "alarm_control_panel")]) +@pytest.mark.parametrize( + "config", + [ { "alarm_control_panel": { "platform": "template", @@ -398,211 +289,148 @@ async def test_name(hass): }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - state = hass.states.get("alarm_control_panel.test_template_panel") + ], +) +async def test_name(hass, start_ha): + """Test the accessibility of the name attribute.""" + state = hass.states.get(TEMPLATE_NAME) assert state is not None - assert state.attributes.get("friendly_name") == "Template Alarm Panel" -async def test_arm_home_action(hass): +@pytest.mark.parametrize("count,domain", [(1, "alarm_control_panel")]) +@pytest.mark.parametrize( + "config,func", + [ + ( + { + "alarm_control_panel": { + "platform": "template", + "panels": { + "test_template_panel": { + "value_template": "{{ states('alarm_control_panel.test') }}", + "arm_away": { + "service": "alarm_control_panel.alarm_arm_home", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "arm_home": {"service": "test.automation"}, + "arm_night": { + "service": "alarm_control_panel.alarm_arm_home", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "disarm": { + "service": "alarm_control_panel.alarm_disarm", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + } + }, + } + }, + common.async_alarm_arm_home, + ), + ( + { + "alarm_control_panel": { + "platform": "template", + "panels": { + "test_template_panel": { + "value_template": "{{ states('alarm_control_panel.test') }}", + "arm_home": { + "service": "alarm_control_panel.alarm_arm_home", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "arm_away": {"service": "test.automation"}, + "arm_night": { + "service": "alarm_control_panel.alarm_arm_home", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "disarm": { + "service": "alarm_control_panel.alarm_disarm", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + } + }, + }, + }, + common.async_alarm_arm_away, + ), + ( + { + "alarm_control_panel": { + "platform": "template", + "panels": { + "test_template_panel": { + "value_template": "{{ states('alarm_control_panel.test') }}", + "arm_home": { + "service": "alarm_control_panel.alarm_arm_home", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "arm_night": {"service": "test.automation"}, + "arm_away": { + "service": "alarm_control_panel.alarm_arm_home", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "disarm": { + "service": "alarm_control_panel.alarm_disarm", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + } + }, + } + }, + common.async_alarm_arm_night, + ), + ( + { + "alarm_control_panel": { + "platform": "template", + "panels": { + "test_template_panel": { + "value_template": "{{ states('alarm_control_panel.test') }}", + "arm_home": { + "service": "alarm_control_panel.alarm_arm_home", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "disarm": {"service": "test.automation"}, + "arm_away": { + "service": "alarm_control_panel.alarm_arm_home", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "arm_night": { + "service": "alarm_control_panel.alarm_disarm", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + } + }, + } + }, + common.async_alarm_disarm, + ), + ], +) +async def test_arm_home_action(hass, func, start_ha, calls): """Test arm home action.""" - await setup.async_setup_component( - hass, - "alarm_control_panel", - { - "alarm_control_panel": { - "platform": "template", - "panels": { - "test_template_panel": { - "value_template": "{{ states('alarm_control_panel.test') }}", - "arm_away": { - "service": "alarm_control_panel.alarm_arm_home", - "entity_id": "alarm_control_panel.test", - "data": {"code": "1234"}, - }, - "arm_home": {"service": "test.automation"}, - "arm_night": { - "service": "alarm_control_panel.alarm_arm_home", - "entity_id": "alarm_control_panel.test", - "data": {"code": "1234"}, - }, - "disarm": { - "service": "alarm_control_panel.alarm_disarm", - "entity_id": "alarm_control_panel.test", - "data": {"code": "1234"}, - }, - } - }, - } - }, - ) - + await func(hass, entity_id=TEMPLATE_NAME) await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - service_calls = async_mock_service(hass, "test", "automation") - - await common.async_alarm_arm_home( - hass, entity_id="alarm_control_panel.test_template_panel" - ) - await hass.async_block_till_done() - - assert len(service_calls) == 1 + assert len(calls) == 1 -async def test_arm_away_action(hass): - """Test arm away action.""" - await setup.async_setup_component( - hass, - "alarm_control_panel", - { - "alarm_control_panel": { - "platform": "template", - "panels": { - "test_template_panel": { - "value_template": "{{ states('alarm_control_panel.test') }}", - "arm_home": { - "service": "alarm_control_panel.alarm_arm_home", - "entity_id": "alarm_control_panel.test", - "data": {"code": "1234"}, - }, - "arm_away": {"service": "test.automation"}, - "arm_night": { - "service": "alarm_control_panel.alarm_arm_home", - "entity_id": "alarm_control_panel.test", - "data": {"code": "1234"}, - }, - "disarm": { - "service": "alarm_control_panel.alarm_disarm", - "entity_id": "alarm_control_panel.test", - "data": {"code": "1234"}, - }, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - service_calls = async_mock_service(hass, "test", "automation") - - await common.async_alarm_arm_away( - hass, entity_id="alarm_control_panel.test_template_panel" - ) - await hass.async_block_till_done() - - assert len(service_calls) == 1 - - -async def test_arm_night_action(hass): - """Test arm night action.""" - await setup.async_setup_component( - hass, - "alarm_control_panel", - { - "alarm_control_panel": { - "platform": "template", - "panels": { - "test_template_panel": { - "value_template": "{{ states('alarm_control_panel.test') }}", - "arm_home": { - "service": "alarm_control_panel.alarm_arm_home", - "entity_id": "alarm_control_panel.test", - "data": {"code": "1234"}, - }, - "arm_night": {"service": "test.automation"}, - "arm_away": { - "service": "alarm_control_panel.alarm_arm_home", - "entity_id": "alarm_control_panel.test", - "data": {"code": "1234"}, - }, - "disarm": { - "service": "alarm_control_panel.alarm_disarm", - "entity_id": "alarm_control_panel.test", - "data": {"code": "1234"}, - }, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - service_calls = async_mock_service(hass, "test", "automation") - - await common.async_alarm_arm_night( - hass, entity_id="alarm_control_panel.test_template_panel" - ) - await hass.async_block_till_done() - - assert len(service_calls) == 1 - - -async def test_disarm_action(hass): - """Test disarm action.""" - await setup.async_setup_component( - hass, - "alarm_control_panel", - { - "alarm_control_panel": { - "platform": "template", - "panels": { - "test_template_panel": { - "value_template": "{{ states('alarm_control_panel.test') }}", - "arm_home": { - "service": "alarm_control_panel.alarm_arm_home", - "entity_id": "alarm_control_panel.test", - "data": {"code": "1234"}, - }, - "disarm": {"service": "test.automation"}, - "arm_away": { - "service": "alarm_control_panel.alarm_arm_home", - "entity_id": "alarm_control_panel.test", - "data": {"code": "1234"}, - }, - "arm_night": { - "service": "alarm_control_panel.alarm_disarm", - "entity_id": "alarm_control_panel.test", - "data": {"code": "1234"}, - }, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - service_calls = async_mock_service(hass, "test", "automation") - - await common.async_alarm_disarm( - hass, entity_id="alarm_control_panel.test_template_panel" - ) - await hass.async_block_till_done() - - assert len(service_calls) == 1 - - -async def test_unique_id(hass): - """Test unique_id option only creates one alarm control panel per id.""" - await setup.async_setup_component( - hass, - "alarm_control_panel", +@pytest.mark.parametrize("count,domain", [(1, "alarm_control_panel")]) +@pytest.mark.parametrize( + "config", + [ { "alarm_control_panel": { "platform": "template", @@ -618,10 +446,8 @@ async def test_unique_id(hass): }, }, }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_unique_id(hass, start_ha): + """Test unique_id option only creates one alarm control panel per id.""" assert len(hass.states.async_all()) == 1 From 2c348dd2d7eb15b8cace195e2bba1a334c47911f Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Tue, 14 Sep 2021 14:06:40 -0600 Subject: [PATCH 398/843] Add long-term statistics for RainMachine sensors (#55418) * Add long-term statistics for RainMachine sensors * Code review --- homeassistant/components/rainmachine/sensor.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/rainmachine/sensor.py b/homeassistant/components/rainmachine/sensor.py index f990dd5c672..57b51472a6f 100644 --- a/homeassistant/components/rainmachine/sensor.py +++ b/homeassistant/components/rainmachine/sensor.py @@ -4,7 +4,12 @@ from __future__ import annotations from dataclasses import dataclass from functools import partial -from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( DEVICE_CLASS_TEMPERATURE, @@ -45,6 +50,7 @@ SENSOR_DESCRIPTIONS = ( icon="mdi:water-pump", native_unit_of_measurement=f"clicks/{VOLUME_CUBIC_METERS}", entity_registry_enabled_default=False, + state_class=STATE_CLASS_MEASUREMENT, api_category=DATA_PROVISION_SETTINGS, ), RainMachineSensorEntityDescription( @@ -53,6 +59,7 @@ SENSOR_DESCRIPTIONS = ( icon="mdi:water-pump", native_unit_of_measurement="liter", entity_registry_enabled_default=False, + state_class=STATE_CLASS_TOTAL_INCREASING, api_category=DATA_PROVISION_SETTINGS, ), RainMachineSensorEntityDescription( @@ -69,6 +76,7 @@ SENSOR_DESCRIPTIONS = ( icon="mdi:water-pump", native_unit_of_measurement="clicks", entity_registry_enabled_default=False, + state_class=STATE_CLASS_MEASUREMENT, api_category=DATA_PROVISION_SETTINGS, ), RainMachineSensorEntityDescription( @@ -77,6 +85,7 @@ SENSOR_DESCRIPTIONS = ( icon="mdi:thermometer", native_unit_of_measurement=TEMP_CELSIUS, device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, api_category=DATA_RESTRICTIONS_UNIVERSAL, ), ) From 96a9af8cc427a49540d9c375652dec3ca6b252b5 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Tue, 14 Sep 2021 22:06:55 +0200 Subject: [PATCH 399/843] Update template/test_weather.py to use pytest (#56223) --- tests/components/template/test_weather.py | 73 ++++++++++------------- 1 file changed, 30 insertions(+), 43 deletions(-) diff --git a/tests/components/template/test_weather.py b/tests/components/template/test_weather.py index 649a54aa3aa..c112473ecd6 100644 --- a/tests/components/template/test_weather.py +++ b/tests/components/template/test_weather.py @@ -1,4 +1,6 @@ """The tests for the Template Weather platform.""" +import pytest + from homeassistant.components.weather import ( ATTR_WEATHER_ATTRIBUTION, ATTR_WEATHER_HUMIDITY, @@ -10,14 +12,12 @@ from homeassistant.components.weather import ( ATTR_WEATHER_WIND_SPEED, DOMAIN, ) -from homeassistant.setup import async_setup_component -async def test_template_state_text(hass): - """Test the state text of a template.""" - await async_setup_component( - hass, - DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "weather": [ {"weather": {"platform": "demo"}}, @@ -37,40 +37,27 @@ async def test_template_state_text(hass): }, ] }, - ) - await hass.async_block_till_done() - - await hass.async_start() - await hass.async_block_till_done() - - hass.states.async_set("sensor.attribution", "The custom attribution") - await hass.async_block_till_done() - hass.states.async_set("sensor.temperature", 22.3) - await hass.async_block_till_done() - hass.states.async_set("sensor.humidity", 60) - await hass.async_block_till_done() - hass.states.async_set("sensor.pressure", 1000) - await hass.async_block_till_done() - hass.states.async_set("sensor.windspeed", 20) - await hass.async_block_till_done() - hass.states.async_set("sensor.windbearing", 180) - await hass.async_block_till_done() - hass.states.async_set("sensor.ozone", 25) - await hass.async_block_till_done() - hass.states.async_set("sensor.visibility", 4.6) - await hass.async_block_till_done() - - state = hass.states.get("weather.test") - assert state is not None - - assert state.state == "sunny" - - data = state.attributes - assert data.get(ATTR_WEATHER_ATTRIBUTION) == "The custom attribution" - assert data.get(ATTR_WEATHER_TEMPERATURE) == 22.3 - assert data.get(ATTR_WEATHER_HUMIDITY) == 60 - assert data.get(ATTR_WEATHER_PRESSURE) == 1000 - assert data.get(ATTR_WEATHER_WIND_SPEED) == 20 - assert data.get(ATTR_WEATHER_WIND_BEARING) == 180 - assert data.get(ATTR_WEATHER_OZONE) == 25 - assert data.get(ATTR_WEATHER_VISIBILITY) == 4.6 + ], +) +async def test_template_state_text(hass, start_ha): + """Test the state text of a template.""" + for attr, v_attr, value in [ + ( + "sensor.attribution", + ATTR_WEATHER_ATTRIBUTION, + "The custom attribution", + ), + ("sensor.temperature", ATTR_WEATHER_TEMPERATURE, 22.3), + ("sensor.humidity", ATTR_WEATHER_HUMIDITY, 60), + ("sensor.pressure", ATTR_WEATHER_PRESSURE, 1000), + ("sensor.windspeed", ATTR_WEATHER_WIND_SPEED, 20), + ("sensor.windbearing", ATTR_WEATHER_WIND_BEARING, 180), + ("sensor.ozone", ATTR_WEATHER_OZONE, 25), + ("sensor.visibility", ATTR_WEATHER_VISIBILITY, 4.6), + ]: + hass.states.async_set(attr, value) + await hass.async_block_till_done() + state = hass.states.get("weather.test") + assert state is not None + assert state.state == "sunny" + assert state.attributes.get(v_attr) == value From 441e99b4393630813c04556919e53c4c600adfe4 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Tue, 14 Sep 2021 14:08:54 -0600 Subject: [PATCH 400/843] Add long-term statistics for AirVisual sensors (#55415) --- homeassistant/components/airvisual/sensor.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/airvisual/sensor.py b/homeassistant/components/airvisual/sensor.py index 72f94875ea8..486ef072f24 100644 --- a/homeassistant/components/airvisual/sensor.py +++ b/homeassistant/components/airvisual/sensor.py @@ -1,7 +1,11 @@ """Support for AirVisual air quality sensors.""" from __future__ import annotations -from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_LATITUDE, @@ -76,6 +80,7 @@ GEOGRAPHY_SENSOR_DESCRIPTIONS = ( name="Air Quality Index", device_class=DEVICE_CLASS_AQI, native_unit_of_measurement="AQI", + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=SENSOR_KIND_POLLUTANT, @@ -92,6 +97,7 @@ NODE_PRO_SENSOR_DESCRIPTIONS = ( name="Air Quality Index", device_class=DEVICE_CLASS_AQI, native_unit_of_measurement="AQI", + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=SENSOR_KIND_BATTERY_LEVEL, @@ -104,6 +110,7 @@ NODE_PRO_SENSOR_DESCRIPTIONS = ( name="C02", device_class=DEVICE_CLASS_CO2, native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=SENSOR_KIND_HUMIDITY, @@ -116,30 +123,35 @@ NODE_PRO_SENSOR_DESCRIPTIONS = ( name="PM 0.1", device_class=DEVICE_CLASS_PM1, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=SENSOR_KIND_PM_1_0, name="PM 1.0", device_class=DEVICE_CLASS_PM10, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=SENSOR_KIND_PM_2_5, name="PM 2.5", device_class=DEVICE_CLASS_PM25, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=SENSOR_KIND_TEMPERATURE, name="Temperature", device_class=DEVICE_CLASS_TEMPERATURE, native_unit_of_measurement=TEMP_CELSIUS, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=SENSOR_KIND_VOC, name="VOC", device_class=DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + state_class=STATE_CLASS_MEASUREMENT, ), ) From bbc75b5c007594c4dc5602e6f5bced6d3db2e253 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Tue, 14 Sep 2021 14:09:45 -0600 Subject: [PATCH 401/843] Add long-term statistics for Flu Near You sensors (#55416) --- homeassistant/components/flunearyou/sensor.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/flunearyou/sensor.py b/homeassistant/components/flunearyou/sensor.py index 98d11b0dc45..72c0a7b1118 100644 --- a/homeassistant/components/flunearyou/sensor.py +++ b/homeassistant/components/flunearyou/sensor.py @@ -1,7 +1,11 @@ """Support for user- and CDC-based flu info sensors from Flu Near You.""" from __future__ import annotations -from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ATTRIBUTION, @@ -57,42 +61,49 @@ USER_SENSOR_DESCRIPTIONS = ( name="Avian Flu Symptoms", icon="mdi:alert", native_unit_of_measurement="reports", + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=SENSOR_TYPE_USER_DENGUE, name="Dengue Fever Symptoms", icon="mdi:alert", native_unit_of_measurement="reports", + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=SENSOR_TYPE_USER_FLU, name="Flu Symptoms", icon="mdi:alert", native_unit_of_measurement="reports", + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=SENSOR_TYPE_USER_LEPTO, name="Leptospirosis Symptoms", icon="mdi:alert", native_unit_of_measurement="reports", + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=SENSOR_TYPE_USER_NO_SYMPTOMS, name="No Symptoms", icon="mdi:alert", native_unit_of_measurement="reports", + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=SENSOR_TYPE_USER_SYMPTOMS, name="Flu-like Symptoms", icon="mdi:alert", native_unit_of_measurement="reports", + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=SENSOR_TYPE_USER_TOTAL, name="Total Symptoms", icon="mdi:alert", native_unit_of_measurement="reports", + state_class=STATE_CLASS_MEASUREMENT, ), ) From 692f61110937d129aafe3750d5598973f154250b Mon Sep 17 00:00:00 2001 From: jan iversen Date: Tue, 14 Sep 2021 22:51:46 +0200 Subject: [PATCH 402/843] Update template/test_fan.py to use pytest (#56215) --- tests/components/template/test_fan.py | 1212 +++++++++---------------- 1 file changed, 428 insertions(+), 784 deletions(-) diff --git a/tests/components/template/test_fan.py b/tests/components/template/test_fan.py index b5820cd5c76..82f89be9b0a 100644 --- a/tests/components/template/test_fan.py +++ b/tests/components/template/test_fan.py @@ -11,6 +11,7 @@ from homeassistant.components.fan import ( ATTR_SPEED, DIRECTION_FORWARD, DIRECTION_REVERSE, + DOMAIN, SPEED_HIGH, SPEED_LOW, SPEED_MEDIUM, @@ -18,7 +19,7 @@ from homeassistant.components.fan import ( ) from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE -from tests.common import assert_setup_component, async_mock_service +from tests.common import assert_setup_component from tests.components.fan import common _TEST_FAN = "fan.test_fan" @@ -38,229 +39,161 @@ _OSC_INPUT = "input_select.osc" _DIRECTION_INPUT_SELECT = "input_select.direction" -@pytest.fixture -def calls(hass): - """Track calls to a mock service.""" - return async_mock_service(hass, "test", "automation") - - -# Configuration tests # -async def test_missing_optional_config(hass, calls): - """Test: missing optional template is ok.""" - with assert_setup_component(1, "fan"): - assert await setup.async_setup_component( - hass, - "fan", - { - "fan": { - "platform": "template", - "fans": { - "test_fan": { - "value_template": "{{ 'on' }}", - "turn_on": {"service": "script.fan_on"}, - "turn_off": {"service": "script.fan_off"}, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - _verify(hass, STATE_ON, None, None, None, None, None) - - -async def test_missing_value_template_config(hass, calls): - """Test: missing 'value_template' will fail.""" - with assert_setup_component(0, "fan"): - assert await setup.async_setup_component( - hass, - "fan", - { - "fan": { - "platform": "template", - "fans": { - "test_fan": { - "turn_on": {"service": "script.fan_on"}, - "turn_off": {"service": "script.fan_off"}, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert hass.states.async_all() == [] - - -async def test_missing_turn_on_config(hass, calls): - """Test: missing 'turn_on' will fail.""" - with assert_setup_component(0, "fan"): - assert await setup.async_setup_component( - hass, - "fan", - { - "fan": { - "platform": "template", - "fans": { - "test_fan": { - "value_template": "{{ 'on' }}", - "turn_off": {"service": "script.fan_off"}, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert hass.states.async_all() == [] - - -async def test_missing_turn_off_config(hass, calls): - """Test: missing 'turn_off' will fail.""" - with assert_setup_component(0, "fan"): - assert await setup.async_setup_component( - hass, - "fan", - { - "fan": { - "platform": "template", - "fans": { - "test_fan": { - "value_template": "{{ 'on' }}", - "turn_on": {"service": "script.fan_on"}, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert hass.states.async_all() == [] - - -async def test_invalid_config(hass, calls): - """Test: missing 'turn_off' will fail.""" - with assert_setup_component(0, "fan"): - assert await setup.async_setup_component( - hass, - "fan", - { +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + DOMAIN: { "platform": "template", "fans": { "test_fan": { "value_template": "{{ 'on' }}", "turn_on": {"service": "script.fan_on"}, + "turn_off": {"service": "script.fan_off"}, } }, - }, - ) + } + }, + ], +) +async def test_missing_optional_config(hass, start_ha): + """Test: missing optional template is ok.""" + _verify(hass, STATE_ON, None, None, None, None, None) - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() +@pytest.mark.parametrize("count,domain", [(0, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + DOMAIN: { + "platform": "template", + "fans": { + "platform": "template", + "fans": { + "test_fan": { + "turn_on": {"service": "script.fan_on"}, + "turn_off": {"service": "script.fan_off"}, + } + }, + }, + } + }, + { + DOMAIN: { + "platform": "template", + "fans": { + "platform": "template", + "fans": { + "test_fan": { + "value_template": "{{ 'on' }}", + "turn_off": {"service": "script.fan_off"}, + } + }, + }, + } + }, + { + DOMAIN: { + "platform": "template", + "fans": { + "platform": "template", + "fans": { + "test_fan": { + "value_template": "{{ 'on' }}", + "turn_on": {"service": "script.fan_on"}, + } + }, + }, + } + }, + { + DOMAIN: { + "platform": "template", + "fans": { + "platform": "template", + "fans": { + "test_fan": { + "value_template": "{{ 'on' }}", + "turn_on": {"service": "script.fan_on"}, + } + }, + }, + } + }, + ], +) +async def test_wrong_template_config(hass, start_ha): + """Test: missing 'value_template' will fail.""" assert hass.states.async_all() == [] -# End of configuration tests # - - -# Template tests # -async def test_templates_with_entities(hass, calls): - """Test tempalates with values from other entities.""" - value_template = """ +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + DOMAIN: { + "platform": "template", + "fans": { + "test_fan": { + "value_template": """ {% if is_state('input_boolean.state', 'True') %} {{ 'on' }} {% else %} {{ 'off' }} {% endif %} - """ - - with assert_setup_component(1, "fan"): - assert await setup.async_setup_component( - hass, - "fan", - { - "fan": { - "platform": "template", - "fans": { - "test_fan": { - "value_template": value_template, - "percentage_template": "{{ states('input_number.percentage') }}", - "speed_template": "{{ states('input_select.speed') }}", - "preset_mode_template": "{{ states('input_select.preset_mode') }}", - "oscillating_template": "{{ states('input_select.osc') }}", - "direction_template": "{{ states('input_select.direction') }}", - "speed_count": "3", - "set_percentage": { - "service": "script.fans_set_speed", - "data_template": {"percentage": "{{ percentage }}"}, - }, - "turn_on": {"service": "script.fan_on"}, - "turn_off": {"service": "script.fan_off"}, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + """, + "percentage_template": "{{ states('input_number.percentage') }}", + "speed_template": "{{ states('input_select.speed') }}", + "preset_mode_template": "{{ states('input_select.preset_mode') }}", + "oscillating_template": "{{ states('input_select.osc') }}", + "direction_template": "{{ states('input_select.direction') }}", + "speed_count": "3", + "set_percentage": { + "service": "script.fans_set_speed", + "data_template": {"percentage": "{{ percentage }}"}, + }, + "turn_on": {"service": "script.fan_on"}, + "turn_off": {"service": "script.fan_off"}, + } + }, + } + }, + ], +) +async def test_templates_with_entities(hass, start_ha): + """Test tempalates with values from other entities.""" _verify(hass, STATE_OFF, None, 0, None, None, None) hass.states.async_set(_STATE_INPUT_BOOLEAN, True) hass.states.async_set(_SPEED_INPUT_SELECT, SPEED_MEDIUM) hass.states.async_set(_OSC_INPUT, "True") - hass.states.async_set(_DIRECTION_INPUT_SELECT, DIRECTION_FORWARD) - await hass.async_block_till_done() - _verify(hass, STATE_ON, SPEED_MEDIUM, 66, True, DIRECTION_FORWARD, None) - - hass.states.async_set(_PERCENTAGE_INPUT_NUMBER, 33) - await hass.async_block_till_done() - _verify(hass, STATE_ON, SPEED_LOW, 33, True, DIRECTION_FORWARD, None) - - hass.states.async_set(_PERCENTAGE_INPUT_NUMBER, 66) - await hass.async_block_till_done() - _verify(hass, STATE_ON, SPEED_MEDIUM, 66, True, DIRECTION_FORWARD, None) - - hass.states.async_set(_PERCENTAGE_INPUT_NUMBER, 100) - await hass.async_block_till_done() - _verify(hass, STATE_ON, SPEED_HIGH, 100, True, DIRECTION_FORWARD, None) - - hass.states.async_set(_PERCENTAGE_INPUT_NUMBER, "dog") - await hass.async_block_till_done() - _verify(hass, STATE_ON, None, 0, True, DIRECTION_FORWARD, None) + for set_state, set_value, speed, value in [ + (_DIRECTION_INPUT_SELECT, DIRECTION_FORWARD, SPEED_MEDIUM, 66), + (_PERCENTAGE_INPUT_NUMBER, 33, SPEED_LOW, 33), + (_PERCENTAGE_INPUT_NUMBER, 66, SPEED_MEDIUM, 66), + (_PERCENTAGE_INPUT_NUMBER, 100, SPEED_HIGH, 100), + (_PERCENTAGE_INPUT_NUMBER, "dog", None, 0), + ]: + hass.states.async_set(set_state, set_value) + await hass.async_block_till_done() + _verify(hass, STATE_ON, speed, value, True, DIRECTION_FORWARD, None) hass.states.async_set(_STATE_INPUT_BOOLEAN, False) await hass.async_block_till_done() _verify(hass, STATE_OFF, None, 0, True, DIRECTION_FORWARD, None) -async def test_templates_with_entities_and_invalid_percentage(hass, calls): - """Test templates with values from other entities.""" - hass.states.async_set("sensor.percentage", "0") - - with assert_setup_component(1, "fan"): - assert await setup.async_setup_component( - hass, - "fan", +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config,entity,tests", + [ + ( { - "fan": { + DOMAIN: { "platform": "template", "fans": { "test_fan": { @@ -272,50 +205,19 @@ async def test_templates_with_entities_and_invalid_percentage(hass, calls): }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - _verify(hass, STATE_ON, SPEED_OFF, 0, None, None, None) - - hass.states.async_set("sensor.percentage", "33") - await hass.async_block_till_done() - - _verify(hass, STATE_ON, SPEED_LOW, 33, None, None, None) - - hass.states.async_set("sensor.percentage", "invalid") - await hass.async_block_till_done() - - _verify(hass, STATE_ON, None, 0, None, None, None) - - hass.states.async_set("sensor.percentage", "5000") - await hass.async_block_till_done() - - _verify(hass, STATE_ON, None, 0, None, None, None) - - hass.states.async_set("sensor.percentage", "100") - await hass.async_block_till_done() - - _verify(hass, STATE_ON, SPEED_HIGH, 100, None, None, None) - - hass.states.async_set("sensor.percentage", "0") - await hass.async_block_till_done() - - _verify(hass, STATE_ON, SPEED_OFF, 0, None, None, None) - - -async def test_templates_with_entities_and_preset_modes(hass, calls): - """Test templates with values from other entities.""" - hass.states.async_set("sensor.preset_mode", "0") - - with assert_setup_component(1, "fan"): - assert await setup.async_setup_component( - hass, - "fan", + "sensor.percentage", + [ + ("0", 0, SPEED_OFF, None), + ("33", 33, SPEED_LOW, None), + ("invalid", 0, None, None), + ("5000", 0, None, None), + ("100", 100, SPEED_HIGH, None), + ("0", 0, SPEED_OFF, None), + ], + ), + ( { - "fan": { + DOMAIN: { "platform": "template", "fans": { "test_fan": { @@ -328,43 +230,62 @@ async def test_templates_with_entities_and_preset_modes(hass, calls): }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - _verify(hass, STATE_ON, None, None, None, None, None) - - hass.states.async_set("sensor.preset_mode", "invalid") - await hass.async_block_till_done() - - _verify(hass, STATE_ON, None, None, None, None, None) - - hass.states.async_set("sensor.preset_mode", "auto") - await hass.async_block_till_done() - - _verify(hass, STATE_ON, "auto", None, None, None, "auto") - - hass.states.async_set("sensor.preset_mode", "smart") - await hass.async_block_till_done() - - _verify(hass, STATE_ON, "smart", None, None, None, "smart") - - hass.states.async_set("sensor.preset_mode", "invalid") - await hass.async_block_till_done() - _verify(hass, STATE_ON, None, None, None, None, None) + "sensor.preset_mode", + [ + ("0", None, None, None), + ("invalid", None, None, None), + ("auto", None, "auto", "auto"), + ("smart", None, "smart", "smart"), + ("invalid", None, None, None), + ], + ), + ], +) +async def test_templates_with_entities2(hass, entity, tests, start_ha): + """Test templates with values from other entities.""" + for set_percentage, test_percentage, speed, test_type in tests: + hass.states.async_set(entity, set_percentage) + await hass.async_block_till_done() + _verify(hass, STATE_ON, speed, test_percentage, None, None, test_type) -async def test_template_with_unavailable_entities(hass, calls): - """Test unavailability with value_template.""" +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + DOMAIN: { + "platform": "template", + "fans": { + "test_fan": { + "availability_template": "{{ is_state('availability_boolean.state', 'on') }}", + "value_template": "{{ 'on' }}", + "speed_template": "{{ 'medium' }}", + "oscillating_template": "{{ 1 == 1 }}", + "direction_template": "{{ 'forward' }}", + "turn_on": {"service": "script.fan_on"}, + "turn_off": {"service": "script.fan_off"}, + } + }, + } + }, + ], +) +async def test_availability_template_with_entities(hass, start_ha): + """Test availability tempalates with values from other entities.""" + for state, test_assert in [(STATE_ON, True), (STATE_OFF, False)]: + hass.states.async_set(_STATE_AVAILABILITY_BOOLEAN, state) + await hass.async_block_till_done() + assert (hass.states.get(_TEST_FAN).state != STATE_UNAVAILABLE) == test_assert - with assert_setup_component(1, "fan"): - assert await setup.async_setup_component( - hass, - "fan", + +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config, states", + [ + ( { - "fan": { + DOMAIN: { "platform": "template", "fans": { "test_fan": { @@ -375,23 +296,11 @@ async def test_template_with_unavailable_entities(hass, calls): }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - assert hass.states.get(_TEST_FAN).state == STATE_OFF - - -async def test_template_with_unavailable_parameters(hass, calls): - """Test unavailability of speed, direction and oscillating parameters.""" - - with assert_setup_component(1, "fan"): - assert await setup.async_setup_component( - hass, - "fan", + [STATE_OFF, None, None, None, None], + ), + ( { - "fan": { + DOMAIN: { "platform": "template", "fans": { "test_fan": { @@ -405,67 +314,11 @@ async def test_template_with_unavailable_parameters(hass, calls): }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - _verify(hass, STATE_ON, None, 0, None, None, None) - - -async def test_availability_template_with_entities(hass, calls): - """Test availability tempalates with values from other entities.""" - - with assert_setup_component(1, "fan"): - assert await setup.async_setup_component( - hass, - "fan", + [STATE_ON, None, 0, None, None], + ), + ( { - "fan": { - "platform": "template", - "fans": { - "test_fan": { - "availability_template": "{{ is_state('availability_boolean.state', 'on') }}", - "value_template": "{{ 'on' }}", - "speed_template": "{{ 'medium' }}", - "oscillating_template": "{{ 1 == 1 }}", - "direction_template": "{{ 'forward' }}", - "turn_on": {"service": "script.fan_on"}, - "turn_off": {"service": "script.fan_off"}, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - # When template returns true.. - hass.states.async_set(_STATE_AVAILABILITY_BOOLEAN, STATE_ON) - await hass.async_block_till_done() - - # Device State should not be unavailable - assert hass.states.get(_TEST_FAN).state != STATE_UNAVAILABLE - - # When Availability template returns false - hass.states.async_set(_STATE_AVAILABILITY_BOOLEAN, STATE_OFF) - await hass.async_block_till_done() - - # device state should be unavailable - assert hass.states.get(_TEST_FAN).state == STATE_UNAVAILABLE - - -async def test_templates_with_valid_values(hass, calls): - """Test templates with valid values.""" - with assert_setup_component(1, "fan"): - assert await setup.async_setup_component( - hass, - "fan", - { - "fan": { + DOMAIN: { "platform": "template", "fans": { "test_fan": { @@ -479,23 +332,11 @@ async def test_templates_with_valid_values(hass, calls): }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - _verify(hass, STATE_ON, SPEED_MEDIUM, 66, True, DIRECTION_FORWARD, None) - - -async def test_templates_invalid_values(hass, calls): - """Test templates with invalid values.""" - with assert_setup_component(1, "fan"): - assert await setup.async_setup_component( - hass, - "fan", + [STATE_ON, SPEED_MEDIUM, 66, True, DIRECTION_FORWARD], + ), + ( { - "fan": { + DOMAIN: { "platform": "template", "fans": { "test_fan": { @@ -509,437 +350,249 @@ async def test_templates_invalid_values(hass, calls): }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - _verify(hass, STATE_OFF, None, 0, None, None, None) + [STATE_OFF, None, 0, None, None], + ), + ], +) +async def test_template_with_unavailable_entities(hass, states, start_ha): + """Test unavailability with value_template.""" + _verify(hass, states[0], states[1], states[2], states[3], states[4], None) -async def test_invalid_availability_template_keeps_component_available(hass, caplog): +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + DOMAIN: { + "platform": "template", + "fans": { + "test_fan": { + "value_template": "{{ 'on' }}", + "availability_template": "{{ x - 12 }}", + "speed_template": "{{ states('input_select.speed') }}", + "preset_mode_template": "{{ states('input_select.preset_mode') }}", + "oscillating_template": "{{ states('input_select.osc') }}", + "direction_template": "{{ states('input_select.direction') }}", + "turn_on": {"service": "script.fan_on"}, + "turn_off": {"service": "script.fan_off"}, + } + }, + } + }, + ], +) +async def test_invalid_availability_template_keeps_component_available( + hass, start_ha, caplog_setup_text +): """Test that an invalid availability keeps the device available.""" - - with assert_setup_component(1, "fan"): - assert await setup.async_setup_component( - hass, - "fan", - { - "fan": { - "platform": "template", - "fans": { - "test_fan": { - "value_template": "{{ 'on' }}", - "availability_template": "{{ x - 12 }}", - "speed_template": "{{ states('input_select.speed') }}", - "preset_mode_template": "{{ states('input_select.preset_mode') }}", - "oscillating_template": "{{ states('input_select.osc') }}", - "direction_template": "{{ states('input_select.direction') }}", - "turn_on": {"service": "script.fan_on"}, - "turn_off": {"service": "script.fan_off"}, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - assert hass.states.get("fan.test_fan").state != STATE_UNAVAILABLE - - assert "TemplateError" in caplog.text - assert "x" in caplog.text + assert "TemplateError" in caplog_setup_text + assert "x" in caplog_setup_text -# End of template tests # - - -# Function tests # -async def test_on_off(hass, calls): +async def test_on_off(hass): """Test turn on and turn off.""" await _register_components(hass) - # Turn on fan - await common.async_turn_on(hass, _TEST_FAN) - - # verify - assert hass.states.get(_STATE_INPUT_BOOLEAN).state == STATE_ON - _verify(hass, STATE_ON, None, 0, None, None, None) - - # Turn off fan - await common.async_turn_off(hass, _TEST_FAN) - - # verify - assert hass.states.get(_STATE_INPUT_BOOLEAN).state == STATE_OFF - _verify(hass, STATE_OFF, None, 0, None, None, None) + for func, state in [ + (common.async_turn_on, STATE_ON), + (common.async_turn_off, STATE_OFF), + ]: + await func(hass, _TEST_FAN) + assert hass.states.get(_STATE_INPUT_BOOLEAN).state == state + _verify(hass, state, None, 0, None, None, None) -async def test_on_with_speed(hass, calls): - """Test turn on with speed.""" - await _register_components(hass) - - # Turn on fan with high speed - await common.async_turn_on(hass, _TEST_FAN, SPEED_HIGH) - - # verify - assert hass.states.get(_STATE_INPUT_BOOLEAN).state == STATE_ON - assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == 100 - _verify(hass, STATE_ON, SPEED_HIGH, 100, None, None, None) - - -async def test_set_speed(hass, calls): +async def test_set_speed(hass): """Test set valid speed.""" await _register_components(hass, preset_modes=["auto", "smart"]) - # Turn on fan await common.async_turn_on(hass, _TEST_FAN) - - # Set fan's speed to high - await common.async_set_speed(hass, _TEST_FAN, SPEED_HIGH) - - # verify - assert hass.states.get(_SPEED_INPUT_SELECT).state == SPEED_HIGH - _verify(hass, STATE_ON, SPEED_HIGH, 100, None, None, None) - - # Set fan's speed to medium - await common.async_set_speed(hass, _TEST_FAN, SPEED_MEDIUM) - - # verify - assert hass.states.get(_SPEED_INPUT_SELECT).state == SPEED_MEDIUM - _verify(hass, STATE_ON, SPEED_MEDIUM, 66, None, None, None) - - # Set fan's speed to off - await common.async_set_speed(hass, _TEST_FAN, SPEED_OFF) - - # verify - assert hass.states.get(_SPEED_INPUT_SELECT).state == SPEED_OFF - _verify(hass, STATE_OFF, SPEED_OFF, 0, None, None, None) + for cmd, t_state, type, state, value in [ + (SPEED_HIGH, SPEED_HIGH, SPEED_HIGH, STATE_ON, 100), + (SPEED_MEDIUM, SPEED_MEDIUM, SPEED_MEDIUM, STATE_ON, 66), + (SPEED_OFF, SPEED_OFF, SPEED_OFF, STATE_OFF, 0), + (SPEED_MEDIUM, SPEED_MEDIUM, SPEED_MEDIUM, STATE_ON, 66), + ("invalid", SPEED_MEDIUM, SPEED_MEDIUM, STATE_ON, 66), + ]: + await common.async_set_speed(hass, _TEST_FAN, cmd) + assert hass.states.get(_SPEED_INPUT_SELECT).state == t_state + _verify(hass, state, type, value, None, None, None) -async def test_set_percentage(hass, calls): - """Test set valid speed percentage.""" - await _register_components(hass) - - # Turn on fan - await common.async_turn_on(hass, _TEST_FAN) - - # Set fan's percentage speed to 100 - await common.async_set_percentage(hass, _TEST_FAN, 100) - - # verify - assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == 100 - - _verify(hass, STATE_ON, SPEED_HIGH, 100, None, None, None) - - # Set fan's percentage speed to 66 - await common.async_set_percentage(hass, _TEST_FAN, 66) - assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == 66 - - _verify(hass, STATE_ON, SPEED_MEDIUM, 66, None, None, None) - - # Set fan's percentage speed to 0 - await common.async_set_percentage(hass, _TEST_FAN, 0) - assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == 0 - - _verify(hass, STATE_OFF, SPEED_OFF, 0, None, None, None) - - # Set fan's percentage speed to 50 - await common.async_turn_on(hass, _TEST_FAN, percentage=50) - assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == 50 - - _verify(hass, STATE_ON, SPEED_MEDIUM, 50, None, None, None) - - -async def test_increase_decrease_speed(hass, calls): - """Test set valid increase and decrease speed.""" - await _register_components(hass, speed_count=3) - - # Turn on fan - await common.async_turn_on(hass, _TEST_FAN) - - # Set fan's percentage speed to 100 - await common.async_set_percentage(hass, _TEST_FAN, 100) - - # verify - assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == 100 - - _verify(hass, STATE_ON, SPEED_HIGH, 100, None, None, None) - - # Set fan's percentage speed to 66 - await common.async_decrease_speed(hass, _TEST_FAN) - assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == 66 - - _verify(hass, STATE_ON, SPEED_MEDIUM, 66, None, None, None) - - # Set fan's percentage speed to 33 - await common.async_decrease_speed(hass, _TEST_FAN) - assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == 33 - - _verify(hass, STATE_ON, SPEED_LOW, 33, None, None, None) - - # Set fan's percentage speed to 0 - await common.async_decrease_speed(hass, _TEST_FAN) - assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == 0 - - _verify(hass, STATE_OFF, SPEED_OFF, 0, None, None, None) - - # Set fan's percentage speed to 33 - await common.async_increase_speed(hass, _TEST_FAN) - assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == 33 - - _verify(hass, STATE_ON, SPEED_LOW, 33, None, None, None) - - -async def test_increase_decrease_speed_default_speed_count(hass, calls): - """Test set valid increase and decrease speed.""" - await _register_components( - hass, - ) - - # Turn on fan - await common.async_turn_on(hass, _TEST_FAN) - - # Set fan's percentage speed to 100 - await common.async_set_percentage(hass, _TEST_FAN, 100) - - # verify - assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == 100 - - _verify(hass, STATE_ON, SPEED_HIGH, 100, None, None, None) - - # Set fan's percentage speed to 99 - await common.async_decrease_speed(hass, _TEST_FAN) - assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == 99 - - _verify(hass, STATE_ON, SPEED_HIGH, 99, None, None, None) - - # Set fan's percentage speed to 98 - await common.async_decrease_speed(hass, _TEST_FAN) - assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == 98 - - _verify(hass, STATE_ON, SPEED_HIGH, 98, None, None, None) - - for _ in range(32): - await common.async_decrease_speed(hass, _TEST_FAN) - assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == 66 - - _verify(hass, STATE_ON, SPEED_MEDIUM, 66, None, None, None) - - -async def test_set_invalid_speed_from_initial_stage(hass, calls): - """Test set invalid speed when fan is in initial state.""" - await _register_components(hass) - - # Turn on fan - await common.async_turn_on(hass, _TEST_FAN) - - # Set fan's speed to 'invalid' - await common.async_set_speed(hass, _TEST_FAN, "invalid") - - # verify speed is unchanged - assert hass.states.get(_SPEED_INPUT_SELECT).state == "" - _verify(hass, STATE_ON, None, 0, None, None, None) - - -async def test_set_invalid_speed(hass, calls): +async def test_set_invalid_speed(hass): """Test set invalid speed when fan has valid speed.""" await _register_components(hass) - # Turn on fan await common.async_turn_on(hass, _TEST_FAN) - - # Set fan's speed to high - await common.async_set_speed(hass, _TEST_FAN, SPEED_HIGH) - - # verify - assert hass.states.get(_SPEED_INPUT_SELECT).state == SPEED_HIGH - _verify(hass, STATE_ON, SPEED_HIGH, 100, None, None, None) - - # Set fan's speed to 'invalid' - await common.async_set_speed(hass, _TEST_FAN, "invalid") - - # verify speed is unchanged - assert hass.states.get(_SPEED_INPUT_SELECT).state == SPEED_HIGH - _verify(hass, STATE_ON, SPEED_HIGH, 100, None, None, None) + for extra in [SPEED_HIGH, "invalid"]: + await common.async_set_speed(hass, _TEST_FAN, extra) + assert hass.states.get(_SPEED_INPUT_SELECT).state == SPEED_HIGH + _verify(hass, STATE_ON, SPEED_HIGH, 100, None, None, None) -async def test_custom_speed_list(hass, calls): +async def test_custom_speed_list(hass): """Test set custom speed list.""" await _register_components(hass, ["1", "2", "3"]) - # Turn on fan await common.async_turn_on(hass, _TEST_FAN) - - # Set fan's speed to '1' - await common.async_set_speed(hass, _TEST_FAN, "1") - - # verify - assert hass.states.get(_SPEED_INPUT_SELECT).state == "1" - _verify(hass, STATE_ON, "1", 33, None, None, None) - - # Set fan's speed to 'medium' which is invalid - await common.async_set_speed(hass, _TEST_FAN, SPEED_MEDIUM) - - # verify that speed is unchanged - assert hass.states.get(_SPEED_INPUT_SELECT).state == "1" - _verify(hass, STATE_ON, "1", 33, None, None, None) - - -async def test_preset_modes(hass, calls): - """Test preset_modes.""" - await _register_components( - hass, ["off", "low", "medium", "high", "auto", "smart"], ["auto", "smart"] - ) - - # Turn on fan - await common.async_turn_on(hass, _TEST_FAN) - - # Set fan's preset_mode to "auto" - await common.async_set_preset_mode(hass, _TEST_FAN, "auto") - - # verify - assert hass.states.get(_PRESET_MODE_INPUT_SELECT).state == "auto" - - # Set fan's preset_mode to "smart" - await common.async_set_preset_mode(hass, _TEST_FAN, "smart") - - # Verify fan's preset_mode is "smart" - assert hass.states.get(_PRESET_MODE_INPUT_SELECT).state == "smart" - - # Set fan's preset_mode to "invalid" - await common.async_set_preset_mode(hass, _TEST_FAN, "invalid") - - # Verify fan's preset_mode is still "smart" - assert hass.states.get(_PRESET_MODE_INPUT_SELECT).state == "smart" - - # Set fan's preset_mode to "auto" - await common.async_turn_on(hass, _TEST_FAN, preset_mode="auto") - - # verify - assert hass.states.get(_PRESET_MODE_INPUT_SELECT).state == "auto" - - -async def test_set_osc(hass, calls): - """Test set oscillating.""" - await _register_components(hass) - - # Turn on fan - await common.async_turn_on(hass, _TEST_FAN) - - # Set fan's osc to True - await common.async_oscillate(hass, _TEST_FAN, True) - - # verify - assert hass.states.get(_OSC_INPUT).state == "True" - _verify(hass, STATE_ON, None, 0, True, None, None) - - # Set fan's osc to False - await common.async_oscillate(hass, _TEST_FAN, False) - - # verify - assert hass.states.get(_OSC_INPUT).state == "False" - _verify(hass, STATE_ON, None, 0, False, None, None) - - -async def test_set_invalid_osc_from_initial_state(hass, calls): - """Test set invalid oscillating when fan is in initial state.""" - await _register_components(hass) - - # Turn on fan - await common.async_turn_on(hass, _TEST_FAN) - - # Set fan's osc to 'invalid' - with pytest.raises(vol.Invalid): - await common.async_oscillate(hass, _TEST_FAN, "invalid") - - # verify - assert hass.states.get(_OSC_INPUT).state == "" - _verify(hass, STATE_ON, None, 0, None, None, None) - - -async def test_set_invalid_osc(hass, calls): - """Test set invalid oscillating when fan has valid osc.""" - await _register_components(hass) - - # Turn on fan - await common.async_turn_on(hass, _TEST_FAN) - - # Set fan's osc to True - await common.async_oscillate(hass, _TEST_FAN, True) - - # verify - assert hass.states.get(_OSC_INPUT).state == "True" - _verify(hass, STATE_ON, None, 0, True, None, None) - - # Set fan's osc to None - with pytest.raises(vol.Invalid): - await common.async_oscillate(hass, _TEST_FAN, None) - - # verify osc is unchanged - assert hass.states.get(_OSC_INPUT).state == "True" - _verify(hass, STATE_ON, None, 0, True, None, None) - - -async def test_set_direction(hass, calls): - """Test set valid direction.""" - await _register_components(hass) - - # Turn on fan - await common.async_turn_on(hass, _TEST_FAN) - - # Set fan's direction to forward - await common.async_set_direction(hass, _TEST_FAN, DIRECTION_FORWARD) - - # verify - assert hass.states.get(_DIRECTION_INPUT_SELECT).state == DIRECTION_FORWARD - _verify(hass, STATE_ON, None, 0, None, DIRECTION_FORWARD, None) - - # Set fan's direction to reverse - await common.async_set_direction(hass, _TEST_FAN, DIRECTION_REVERSE) - - # verify - assert hass.states.get(_DIRECTION_INPUT_SELECT).state == DIRECTION_REVERSE - _verify(hass, STATE_ON, None, 0, None, DIRECTION_REVERSE, None) + for extra in ["1", SPEED_MEDIUM]: + await common.async_set_speed(hass, _TEST_FAN, extra) + assert hass.states.get(_SPEED_INPUT_SELECT).state == "1" + _verify(hass, STATE_ON, "1", 33, None, None, None) async def test_set_invalid_direction_from_initial_stage(hass, calls): """Test set invalid direction when fan is in initial state.""" await _register_components(hass) - # Turn on fan await common.async_turn_on(hass, _TEST_FAN) - # Set fan's direction to 'invalid' await common.async_set_direction(hass, _TEST_FAN, "invalid") - - # verify direction is unchanged assert hass.states.get(_DIRECTION_INPUT_SELECT).state == "" _verify(hass, STATE_ON, None, 0, None, None, None) -async def test_set_invalid_direction(hass, calls): +async def test_set_osc(hass): + """Test set oscillating.""" + await _register_components(hass) + + await common.async_turn_on(hass, _TEST_FAN) + for state in [True, False]: + await common.async_oscillate(hass, _TEST_FAN, state) + assert hass.states.get(_OSC_INPUT).state == str(state) + _verify(hass, STATE_ON, None, 0, state, None, None) + + +async def test_set_direction(hass): + """Test set valid direction.""" + await _register_components(hass) + + await common.async_turn_on(hass, _TEST_FAN) + for cmd in [DIRECTION_FORWARD, DIRECTION_REVERSE]: + await common.async_set_direction(hass, _TEST_FAN, cmd) + assert hass.states.get(_DIRECTION_INPUT_SELECT).state == cmd + _verify(hass, STATE_ON, None, 0, None, cmd, None) + + +async def test_set_invalid_direction(hass): """Test set invalid direction when fan has valid direction.""" await _register_components(hass) - # Turn on fan await common.async_turn_on(hass, _TEST_FAN) + for cmd in [DIRECTION_FORWARD, "invalid"]: + await common.async_set_direction(hass, _TEST_FAN, cmd) + assert hass.states.get(_DIRECTION_INPUT_SELECT).state == DIRECTION_FORWARD + _verify(hass, STATE_ON, None, 0, None, DIRECTION_FORWARD, None) - # Set fan's direction to forward - await common.async_set_direction(hass, _TEST_FAN, DIRECTION_FORWARD) - # verify - assert hass.states.get(_DIRECTION_INPUT_SELECT).state == DIRECTION_FORWARD - _verify(hass, STATE_ON, None, 0, None, DIRECTION_FORWARD, None) +async def test_on_with_speed(hass): + """Test turn on with speed.""" + await _register_components(hass) - # Set fan's direction to 'invalid' - await common.async_set_direction(hass, _TEST_FAN, "invalid") + await common.async_turn_on(hass, _TEST_FAN, SPEED_HIGH) + assert hass.states.get(_STATE_INPUT_BOOLEAN).state == STATE_ON + assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == 100 + _verify(hass, STATE_ON, SPEED_HIGH, 100, None, None, None) - # verify direction is unchanged - assert hass.states.get(_DIRECTION_INPUT_SELECT).state == DIRECTION_FORWARD - _verify(hass, STATE_ON, None, 0, None, DIRECTION_FORWARD, None) + +async def test_preset_modes(hass): + """Test preset_modes.""" + await _register_components( + hass, ["off", "low", "medium", "high", "auto", "smart"], ["auto", "smart"] + ) + + await common.async_turn_on(hass, _TEST_FAN) + for extra, state in [ + ("auto", "auto"), + ("smart", "smart"), + ("invalid", "smart"), + ]: + await common.async_set_preset_mode(hass, _TEST_FAN, extra) + assert hass.states.get(_PRESET_MODE_INPUT_SELECT).state == state + + await common.async_turn_on(hass, _TEST_FAN, preset_mode="auto") + assert hass.states.get(_PRESET_MODE_INPUT_SELECT).state == "auto" + + +async def test_set_percentage(hass): + """Test set valid speed percentage.""" + await _register_components(hass) + + await common.async_turn_on(hass, _TEST_FAN) + for type, state, value in [ + (SPEED_HIGH, STATE_ON, 100), + (SPEED_MEDIUM, STATE_ON, 66), + (SPEED_OFF, STATE_OFF, 0), + ]: + await common.async_set_percentage(hass, _TEST_FAN, value) + assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == value + _verify(hass, state, type, value, None, None, None) + + await common.async_turn_on(hass, _TEST_FAN, percentage=50) + assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == 50 + _verify(hass, STATE_ON, SPEED_MEDIUM, 50, None, None, None) + + +async def test_increase_decrease_speed(hass): + """Test set valid increase and decrease speed.""" + await _register_components(hass, speed_count=3) + + await common.async_turn_on(hass, _TEST_FAN) + for func, extra, state, type, value in [ + (common.async_set_percentage, 100, STATE_ON, SPEED_HIGH, 100), + (common.async_decrease_speed, None, STATE_ON, SPEED_MEDIUM, 66), + (common.async_decrease_speed, None, STATE_ON, SPEED_LOW, 33), + (common.async_decrease_speed, None, STATE_OFF, SPEED_OFF, 0), + (common.async_increase_speed, None, STATE_ON, SPEED_LOW, 33), + ]: + await func(hass, _TEST_FAN, extra) + assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == value + _verify(hass, state, type, value, None, None, None) + + +async def test_increase_decrease_speed_default_speed_count(hass): + """Test set valid increase and decrease speed.""" + await _register_components(hass) + + await common.async_turn_on(hass, _TEST_FAN) + for func, extra, state, type, value in [ + (common.async_set_percentage, 100, STATE_ON, SPEED_HIGH, 100), + (common.async_decrease_speed, None, STATE_ON, SPEED_HIGH, 99), + (common.async_decrease_speed, None, STATE_ON, SPEED_HIGH, 98), + (common.async_decrease_speed, 31, STATE_ON, SPEED_HIGH, 67), + (common.async_decrease_speed, None, STATE_ON, SPEED_MEDIUM, 66), + ]: + await func(hass, _TEST_FAN, extra) + assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == value + _verify(hass, state, type, value, None, None, None) + + +async def test_set_invalid_osc_from_initial_state(hass): + """Test set invalid oscillating when fan is in initial state.""" + await _register_components(hass) + + await common.async_turn_on(hass, _TEST_FAN) + with pytest.raises(vol.Invalid): + await common.async_oscillate(hass, _TEST_FAN, "invalid") + assert hass.states.get(_OSC_INPUT).state == "" + _verify(hass, STATE_ON, None, 0, None, None, None) + + +async def test_set_invalid_osc(hass): + """Test set invalid oscillating when fan has valid osc.""" + await _register_components(hass) + + await common.async_turn_on(hass, _TEST_FAN) + await common.async_oscillate(hass, _TEST_FAN, True) + assert hass.states.get(_OSC_INPUT).state == "True" + _verify(hass, STATE_ON, None, 0, True, None, None) + + with pytest.raises(vol.Invalid): + await common.async_oscillate(hass, _TEST_FAN, None) + assert hass.states.get(_OSC_INPUT).state == "True" + _verify(hass, STATE_ON, None, 0, True, None, None) def _verify( @@ -1103,13 +756,12 @@ async def _register_components( await hass.async_block_till_done() -async def test_unique_id(hass): - """Test unique_id option only creates one fan per id.""" - await setup.async_setup_component( - hass, - "fan", +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { - "fan": { + DOMAIN: { "platform": "template", "fans": { "test_template_fan_01": { @@ -1137,14 +789,12 @@ async def test_unique_id(hass): }, }, }, - }, + } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_unique_id(hass, start_ha): + """Test unique_id option only creates one fan per id.""" assert len(hass.states.async_all()) == 1 @@ -1221,13 +871,12 @@ async def test_implemented_percentage(hass, speed_count, percentage_step): assert attributes["percentage_step"] == percentage_step -async def test_implemented_preset_mode(hass): - """Test a fan that implements preset_mode.""" - await setup.async_setup_component( - hass, - "fan", +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { - "fan": { + DOMAIN: { "platform": "template", "fans": { "mechanical_ventilation": { @@ -1276,14 +925,12 @@ async def test_implemented_preset_mode(hass): ], }, }, - }, + } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_implemented_preset_mode(hass, start_ha): + """Test a fan that implements preset_mode.""" assert len(hass.states.async_all()) == 1 state = hass.states.get("fan.mechanical_ventilation") @@ -1291,13 +938,12 @@ async def test_implemented_preset_mode(hass): assert attributes["percentage"] is None -async def test_implemented_speed(hass): - """Test a fan that implements speed.""" - await setup.async_setup_component( - hass, - "fan", +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { - "fan": { + DOMAIN: { "platform": "template", "fans": { "mechanical_ventilation": { @@ -1346,14 +992,12 @@ async def test_implemented_speed(hass): ], }, }, - }, + } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_implemented_speed(hass, start_ha): + """Test a fan that implements speed.""" assert len(hass.states.async_all()) == 1 state = hass.states.get("fan.mechanical_ventilation") From 98cf34c7c3662fa2ba9b09258ee620d5f69b0a38 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Sep 2021 13:02:37 -1000 Subject: [PATCH 403/843] Bump zeroconf to 0.36.3 (#56233) --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 6ed4c8d09dd..2fce7a3d25a 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -2,7 +2,7 @@ "domain": "zeroconf", "name": "Zero-configuration networking (zeroconf)", "documentation": "https://www.home-assistant.io/integrations/zeroconf", - "requirements": ["zeroconf==0.36.2"], + "requirements": ["zeroconf==0.36.3"], "dependencies": ["network", "api"], "codeowners": ["@bdraco"], "quality_scale": "internal", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 546a56aa736..ca024f0368b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ sqlalchemy==1.4.23 voluptuous-serialize==2.4.0 voluptuous==0.12.1 yarl==1.6.3 -zeroconf==0.36.2 +zeroconf==0.36.3 pycryptodome>=3.6.6 diff --git a/requirements_all.txt b/requirements_all.txt index 5b4bc9e0f73..88f50e4f680 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2463,7 +2463,7 @@ youtube_dl==2021.04.26 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.36.2 +zeroconf==0.36.3 # homeassistant.components.zha zha-quirks==0.0.61 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e2925d55244..2d3c44228b8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1392,7 +1392,7 @@ yeelight==0.7.4 youless-api==0.12 # homeassistant.components.zeroconf -zeroconf==0.36.2 +zeroconf==0.36.3 # homeassistant.components.zha zha-quirks==0.0.61 From 0a426b5686c1fa075dd21cae8ced461d7cac1369 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 14 Sep 2021 21:23:19 -0700 Subject: [PATCH 404/843] Bump aiohue to 2.6.2 (#56234) --- homeassistant/components/hue/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hue/manifest.json b/homeassistant/components/hue/manifest.json index 67659b96275..954ad1f7a7b 100644 --- a/homeassistant/components/hue/manifest.json +++ b/homeassistant/components/hue/manifest.json @@ -3,7 +3,7 @@ "name": "Philips Hue", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/hue", - "requirements": ["aiohue==2.6.1"], + "requirements": ["aiohue==2.6.2"], "ssdp": [ { "manufacturer": "Royal Philips Electronics", diff --git a/requirements_all.txt b/requirements_all.txt index 88f50e4f680..4d718f912f8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -189,7 +189,7 @@ aiohomekit==0.6.2 aiohttp_cors==0.7.0 # homeassistant.components.hue -aiohue==2.6.1 +aiohue==2.6.2 # homeassistant.components.imap aioimaplib==0.9.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2d3c44228b8..c3adc943744 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -125,7 +125,7 @@ aiohomekit==0.6.2 aiohttp_cors==0.7.0 # homeassistant.components.hue -aiohue==2.6.1 +aiohue==2.6.2 # homeassistant.components.apache_kafka aiokafka==0.6.0 From 30c25d44485cb3002d171f43049a5fa168bd2d40 Mon Sep 17 00:00:00 2001 From: Steven Looman Date: Wed, 15 Sep 2021 06:57:02 +0200 Subject: [PATCH 405/843] Clean up upnp YAML config (#56200) * Put back local_ip option to config schema + deprecate config schema * More cleanup * Remove log Co-authored-by: Martin Hjelmare --- homeassistant/components/upnp/__init__.py | 26 ++++++++++++++--------- homeassistant/components/upnp/const.py | 2 +- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py index 3251b8c69fb..14a2c39d3d9 100644 --- a/homeassistant/components/upnp/__init__.py +++ b/homeassistant/components/upnp/__init__.py @@ -5,6 +5,7 @@ import asyncio from collections.abc import Mapping from dataclasses import dataclass from datetime import timedelta +from ipaddress import ip_address from typing import Any import voluptuous as vol @@ -18,6 +19,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -25,13 +27,13 @@ from homeassistant.helpers.update_coordinator import ( ) from .const import ( + CONF_LOCAL_IP, CONFIG_ENTRY_HOSTNAME, CONFIG_ENTRY_SCAN_INTERVAL, CONFIG_ENTRY_ST, CONFIG_ENTRY_UDN, DEFAULT_SCAN_INTERVAL, DOMAIN, - DOMAIN_CONFIG, DOMAIN_DEVICES, LOGGER, ) @@ -43,22 +45,26 @@ NOTIFICATION_TITLE = "UPnP/IGD Setup" PLATFORMS = ["binary_sensor", "sensor"] CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - {}, - ) - }, + vol.All( + cv.deprecated(DOMAIN), + { + DOMAIN: vol.Schema( + vol.All( + cv.deprecated(CONF_LOCAL_IP), + { + vol.Optional(CONF_LOCAL_IP): vol.All(ip_address, cv.string), + }, + ) + ) + }, + ), extra=vol.ALLOW_EXTRA, ) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up UPnP component.""" - LOGGER.debug("async_setup, config: %s", config) - conf_default = CONFIG_SCHEMA({DOMAIN: {}})[DOMAIN] - conf = config.get(DOMAIN, conf_default) hass.data[DOMAIN] = { - DOMAIN_CONFIG: conf, DOMAIN_DEVICES: {}, } diff --git a/homeassistant/components/upnp/const.py b/homeassistant/components/upnp/const.py index 5eeb73abc4a..71c72acd594 100644 --- a/homeassistant/components/upnp/const.py +++ b/homeassistant/components/upnp/const.py @@ -6,8 +6,8 @@ from homeassistant.const import TIME_SECONDS LOGGER = logging.getLogger(__package__) +CONF_LOCAL_IP = "local_ip" DOMAIN = "upnp" -DOMAIN_CONFIG = "config" DOMAIN_DEVICES = "devices" BYTES_RECEIVED = "bytes_received" BYTES_SENT = "bytes_sent" From 0d842a8f01c2d112fc0e025d32951c2e966b8d17 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 15 Sep 2021 07:54:56 +0200 Subject: [PATCH 406/843] Adjust charging_power unit (#56167) --- homeassistant/components/renault/sensor.py | 21 ++++++++++++++++++--- tests/components/renault/const.py | 10 ++++++---- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/renault/sensor.py b/homeassistant/components/renault/sensor.py index b2161fc9adf..4cb0d723234 100644 --- a/homeassistant/components/renault/sensor.py +++ b/homeassistant/components/renault/sensor.py @@ -20,9 +20,11 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( DEVICE_CLASS_BATTERY, + DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, DEVICE_CLASS_TEMPERATURE, + ELECTRIC_CURRENT_AMPERE, ENERGY_KILO_WATT_HOUR, LENGTH_KILOMETERS, PERCENTAGE, @@ -39,6 +41,7 @@ from .const import DEVICE_CLASS_CHARGE_STATE, DEVICE_CLASS_PLUG_STATE, DOMAIN from .renault_coordinator import T from .renault_entities import RenaultDataEntity, RenaultEntityDescription from .renault_hub import RenaultHub +from .renault_vehicle import RenaultVehicleProxy @dataclass @@ -56,6 +59,7 @@ class RenaultSensorEntityDescription( """Class describing Renault sensor entities.""" icon_lambda: Callable[[RenaultSensor[T]], str] | None = None + condition_lambda: Callable[[RenaultVehicleProxy], bool] | None = None requires_fuel: bool = False value_lambda: Callable[[RenaultSensor[T]], StateType] | None = None @@ -73,6 +77,7 @@ async def async_setup_entry( for description in SENSOR_TYPES if description.coordinator in vehicle.coordinators and (not description.requires_fuel or vehicle.details.uses_fuel()) + and (not description.condition_lambda or description.condition_lambda(vehicle)) ] async_add_entities(entities) @@ -106,9 +111,7 @@ class RenaultSensor(RenaultDataEntity[T], SensorEntity): def _get_charging_power(entity: RenaultSensor[T]) -> StateType: """Return the charging_power of this entity.""" - if entity.vehicle.details.reports_charging_power_in_watts(): - return cast(float, entity.data) / 1000 - return entity.data + return cast(float, entity.data) / 1000 def _get_charge_state_formatted(entity: RenaultSensor[T]) -> str | None: @@ -177,6 +180,18 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription, ...] = ( ), RenaultSensorEntityDescription( key="charging_power", + condition_lambda=lambda a: not a.details.reports_charging_power_in_watts(), + coordinator="battery", + data_key="chargingInstantaneousPower", + device_class=DEVICE_CLASS_CURRENT, + entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], + name="Charging Power", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + state_class=STATE_CLASS_MEASUREMENT, + ), + RenaultSensorEntityDescription( + key="charging_power", + condition_lambda=lambda a: a.details.reports_charging_power_in_watts(), coordinator="battery", data_key="chargingInstantaneousPower", device_class=DEVICE_CLASS_POWER, diff --git a/tests/components/renault/const.py b/tests/components/renault/const.py index 4ffc08587e3..f9fb765dab3 100644 --- a/tests/components/renault/const.py +++ b/tests/components/renault/const.py @@ -29,9 +29,11 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_USERNAME, DEVICE_CLASS_BATTERY, + DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, DEVICE_CLASS_TEMPERATURE, + ELECTRIC_CURRENT_AMPERE, ENERGY_KILO_WATT_HOUR, LENGTH_KILOMETERS, PERCENTAGE, @@ -313,10 +315,10 @@ MOCK_VEHICLES = { "entity_id": "sensor.charging_power", "unique_id": "vf1aaaaa555777999_charging_power", "result": STATE_UNKNOWN, - ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, + ATTR_DEVICE_CLASS: DEVICE_CLASS_CURRENT, ATTR_LAST_UPDATE: "2020-11-17T08:06:48+00:00", ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - ATTR_UNIT_OF_MEASUREMENT: POWER_KILO_WATT, + ATTR_UNIT_OF_MEASUREMENT: ELECTRIC_CURRENT_AMPERE, }, { "entity_id": "sensor.charging_remaining_time", @@ -450,10 +452,10 @@ MOCK_VEHICLES = { "entity_id": "sensor.charging_power", "unique_id": "vf1aaaaa555777123_charging_power", "result": "27.0", - ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, + ATTR_DEVICE_CLASS: DEVICE_CLASS_CURRENT, ATTR_LAST_UPDATE: "2020-01-12T21:40:16+00:00", ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - ATTR_UNIT_OF_MEASUREMENT: POWER_KILO_WATT, + ATTR_UNIT_OF_MEASUREMENT: ELECTRIC_CURRENT_AMPERE, }, { "entity_id": "sensor.charging_remaining_time", From ddfe94187ef566d163c5fe2e01a8bfa4c7486dbb Mon Sep 17 00:00:00 2001 From: Shulyaka Date: Wed, 15 Sep 2021 08:55:43 +0300 Subject: [PATCH 407/843] generic_hygrostat: enable tests (#56193) --- .coveragerc | 1 - 1 file changed, 1 deletion(-) diff --git a/.coveragerc b/.coveragerc index 119f0345f3f..e5573b40cd6 100644 --- a/.coveragerc +++ b/.coveragerc @@ -375,7 +375,6 @@ omit = homeassistant/components/garages_amsterdam/sensor.py homeassistant/components/gc100/* homeassistant/components/geniushub/* - homeassistant/components/generic_hygrostat/* homeassistant/components/github/sensor.py homeassistant/components/gitlab_ci/sensor.py homeassistant/components/gitter/sensor.py From 53d5a59257409602b6a50c4d53cb881d900d9c79 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Wed, 15 Sep 2021 07:58:04 +0200 Subject: [PATCH 408/843] Activate mypy for directv (#55963) * Activate mypy for directv. * Activate mypy for directv. --- homeassistant/components/directv/const.py | 3 ++- homeassistant/components/directv/media_player.py | 3 +-- homeassistant/components/directv/remote.py | 2 +- mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - 5 files changed, 4 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/directv/const.py b/homeassistant/components/directv/const.py index 853386fd1d8..b840b7bd2dc 100644 --- a/homeassistant/components/directv/const.py +++ b/homeassistant/components/directv/const.py @@ -1,4 +1,5 @@ """Constants for the DirecTV integration.""" +from typing import Final DOMAIN = "directv" @@ -7,7 +8,7 @@ ATTR_MEDIA_CURRENTLY_RECORDING = "media_currently_recording" ATTR_MEDIA_RATING = "media_rating" ATTR_MEDIA_RECORDED = "media_recorded" ATTR_MEDIA_START_TIME = "media_start_time" -ATTR_VIA_DEVICE = "via_device" +ATTR_VIA_DEVICE: Final = "via_device" CONF_RECEIVER_ID = "receiver_id" diff --git a/homeassistant/components/directv/media_player.py b/homeassistant/components/directv/media_player.py index 1a7d07c5ebd..0f2dcabd552 100644 --- a/homeassistant/components/directv/media_player.py +++ b/homeassistant/components/directv/media_player.py @@ -67,7 +67,7 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback, -) -> bool: +) -> None: """Set up the DirecTV config entry.""" dtv = hass.data[DOMAIN][entry.entry_id] entities = [] @@ -98,7 +98,6 @@ class DIRECTVMediaPlayer(DIRECTVEntity, MediaPlayerEntity): self._attr_name = name self._attr_device_class = DEVICE_CLASS_RECEIVER self._attr_available = False - self._attr_assumed_state = None self._is_recorded = None self._is_standby = True diff --git a/homeassistant/components/directv/remote.py b/homeassistant/components/directv/remote.py index 52e94bc2608..c8c84a7f0cc 100644 --- a/homeassistant/components/directv/remote.py +++ b/homeassistant/components/directv/remote.py @@ -25,7 +25,7 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback, -) -> bool: +) -> None: """Load DirecTV remote based on a config entry.""" dtv = hass.data[DOMAIN][entry.entry_id] entities = [] diff --git a/mypy.ini b/mypy.ini index 6a27e352595..22c50199acd 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1331,9 +1331,6 @@ ignore_errors = true [mypy-homeassistant.components.dhcp.*] ignore_errors = true -[mypy-homeassistant.components.directv.*] -ignore_errors = true - [mypy-homeassistant.components.doorbird.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index f799b3fdb20..53513a9c37e 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -25,7 +25,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.demo.*", "homeassistant.components.denonavr.*", "homeassistant.components.dhcp.*", - "homeassistant.components.directv.*", "homeassistant.components.doorbird.*", "homeassistant.components.enphase_envoy.*", "homeassistant.components.entur_public_transport.*", From 0619069ae60932a2f912ded4187216ddb7ba0b08 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Wed, 15 Sep 2021 03:07:31 -0500 Subject: [PATCH 409/843] Avoid a zeroconf race condition (#56240) --- homeassistant/components/sonos/speaker.py | 36 ++++++++++------------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index 30d107bdd8d..bf0a8c589ee 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -485,6 +485,15 @@ class SonosSpeaker: # # Speaker availability methods # + @callback + def _async_reset_seen_timer(self): + """Reset the _seen_timer scheduler.""" + if self._seen_timer: + self._seen_timer() + self._seen_timer = self.hass.helpers.event.async_call_later( + SEEN_EXPIRE_TIME.total_seconds(), self.async_unseen + ) + async def async_seen(self, soco: SoCo | None = None) -> None: """Record that this speaker was seen right now.""" if soco is not None: @@ -492,12 +501,7 @@ class SonosSpeaker: was_available = self.available - if self._seen_timer: - self._seen_timer() - - self._seen_timer = self.hass.helpers.event.async_call_later( - SEEN_EXPIRE_TIME.total_seconds(), self.async_unseen - ) + self._async_reset_seen_timer() if was_available: self.async_write_entity_states() @@ -512,8 +516,6 @@ class SonosSpeaker: if self._is_ready and not self.subscriptions_failed: done = await self.async_subscribe() if not done: - assert self._seen_timer is not None - self._seen_timer() await self.async_unseen() self.async_write_entity_states() @@ -522,10 +524,6 @@ class SonosSpeaker: self, callback_timestamp: datetime.datetime | None = None ) -> None: """Make this player unavailable when it was not seen recently.""" - if self._seen_timer: - self._seen_timer() - self._seen_timer = None - if callback_timestamp: # Called by a _seen_timer timeout, check mDNS one more time # This should not be checked in an "active" unseen scenario @@ -534,9 +532,7 @@ class SonosSpeaker: aiozeroconf = await zeroconf.async_get_async_instance(self.hass) if await aiozeroconf.async_get_service_info(MDNS_SERVICE, zcname): # We can still see the speaker via zeroconf check again later. - self._seen_timer = self.hass.helpers.event.async_call_later( - SEEN_EXPIRE_TIME.total_seconds(), self.async_unseen - ) + self._async_reset_seen_timer() return _LOGGER.debug( @@ -546,6 +542,10 @@ class SonosSpeaker: self._share_link_plugin = None + if self._seen_timer: + self._seen_timer() + self._seen_timer = None + if self._poll_timer: self._poll_timer() self._poll_timer = None @@ -565,11 +565,7 @@ class SonosSpeaker: await self.async_unsubscribe() self.soco = soco await self.async_subscribe() - if self._seen_timer: - self._seen_timer() - self._seen_timer = self.hass.helpers.event.async_call_later( - SEEN_EXPIRE_TIME.total_seconds(), self.async_unseen - ) + self._async_reset_seen_timer() self.async_write_entity_states() # From 19054e1ffed705a72659aa3ee2f7b35c8e6eae80 Mon Sep 17 00:00:00 2001 From: muppet3000 Date: Wed, 15 Sep 2021 09:08:15 +0100 Subject: [PATCH 410/843] Bump growattServer to 1.1.0 (#56084) --- homeassistant/components/growatt_server/config_flow.py | 2 +- homeassistant/components/growatt_server/manifest.json | 2 +- homeassistant/components/growatt_server/sensor.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/growatt_server/test_config_flow.py | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/growatt_server/config_flow.py b/homeassistant/components/growatt_server/config_flow.py index d6b2c7db9fe..c4a97a81f0a 100644 --- a/homeassistant/components/growatt_server/config_flow.py +++ b/homeassistant/components/growatt_server/config_flow.py @@ -47,7 +47,7 @@ class GrowattServerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if not login_response["success"] and login_response["errCode"] == "102": return self._async_show_user_form({"base": "invalid_auth"}) - self.user_id = login_response["userId"] + self.user_id = login_response["user"]["id"] self.data = user_input return await self.async_step_plant() diff --git a/homeassistant/components/growatt_server/manifest.json b/homeassistant/components/growatt_server/manifest.json index ab2d07c147b..79472359ab9 100644 --- a/homeassistant/components/growatt_server/manifest.json +++ b/homeassistant/components/growatt_server/manifest.json @@ -3,7 +3,7 @@ "name": "Growatt", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/growatt_server/", - "requirements": ["growattServer==1.0.1"], + "requirements": ["growattServer==1.1.0"], "codeowners": ["@indykoning", "@muppet3000", "@JasperPlant"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/growatt_server/sensor.py b/homeassistant/components/growatt_server/sensor.py index 894d096fcd0..fa1d8b644d5 100644 --- a/homeassistant/components/growatt_server/sensor.py +++ b/homeassistant/components/growatt_server/sensor.py @@ -879,7 +879,7 @@ def get_device_list(api, config): if not login_response["success"] and login_response["errCode"] == "102": _LOGGER.error("Username, Password or URL may be incorrect!") return - user_id = login_response["userId"] + user_id = login_response["user"]["id"] if plant_id == DEFAULT_PLANT_ID: plant_info = api.plant_list(user_id) plant_id = plant_info["data"][0]["plantId"] diff --git a/requirements_all.txt b/requirements_all.txt index 4d718f912f8..1f8831d3f5d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -745,7 +745,7 @@ greeneye_monitor==2.1 greenwavereality==0.5.1 # homeassistant.components.growatt_server -growattServer==1.0.1 +growattServer==1.1.0 # homeassistant.components.gstreamer gstreamer-player==1.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c3adc943744..7916a6a4273 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -432,7 +432,7 @@ googlemaps==2.5.1 greeclimate==0.11.8 # homeassistant.components.growatt_server -growattServer==1.0.1 +growattServer==1.1.0 # homeassistant.components.profiler guppy3==3.1.0 diff --git a/tests/components/growatt_server/test_config_flow.py b/tests/components/growatt_server/test_config_flow.py index 096052fd6cf..db46ed36911 100644 --- a/tests/components/growatt_server/test_config_flow.py +++ b/tests/components/growatt_server/test_config_flow.py @@ -40,7 +40,7 @@ GROWATT_PLANT_LIST_RESPONSE = { }, "success": True, } -GROWATT_LOGIN_RESPONSE = {"userId": 123456, "userLevel": 1, "success": True} +GROWATT_LOGIN_RESPONSE = {"user": {"id": 123456}, "userLevel": 1, "success": True} async def test_show_authenticate_form(hass): From c5544550b4863d14d86641dc5488cdee06e80cf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Wed, 15 Sep 2021 10:43:39 +0200 Subject: [PATCH 411/843] Deprecate Surepetcare yaml config (#56209) --- .../components/surepetcare/__init__.py | 47 ++++++++++--------- 1 file changed, 26 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/surepetcare/__init__.py b/homeassistant/components/surepetcare/__init__.py index 5462cfc954c..59b91dabb2a 100644 --- a/homeassistant/components/surepetcare/__init__.py +++ b/homeassistant/components/surepetcare/__init__.py @@ -40,28 +40,33 @@ PLATFORMS = ["binary_sensor", "sensor"] SCAN_INTERVAL = timedelta(minutes=3) CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - vol.All( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_FEEDERS): vol.All( - cv.ensure_list, [cv.positive_int] - ), - vol.Optional(CONF_FLAPS): vol.All( - cv.ensure_list, [cv.positive_int] - ), - vol.Optional(CONF_PETS): vol.All(cv.ensure_list, [cv.positive_int]), - vol.Optional(CONF_SCAN_INTERVAL): cv.time_period, - }, - cv.deprecated(CONF_FEEDERS), - cv.deprecated(CONF_FLAPS), - cv.deprecated(CONF_PETS), - cv.deprecated(CONF_SCAN_INTERVAL), + vol.All( + cv.deprecated(DOMAIN), + { + DOMAIN: vol.Schema( + vol.All( + { + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_FEEDERS): vol.All( + cv.ensure_list, [cv.positive_int] + ), + vol.Optional(CONF_FLAPS): vol.All( + cv.ensure_list, [cv.positive_int] + ), + vol.Optional(CONF_PETS): vol.All( + cv.ensure_list, [cv.positive_int] + ), + vol.Optional(CONF_SCAN_INTERVAL): cv.time_period, + }, + cv.deprecated(CONF_FEEDERS), + cv.deprecated(CONF_FLAPS), + cv.deprecated(CONF_PETS), + cv.deprecated(CONF_SCAN_INTERVAL), + ) ) - ) - }, + }, + ), extra=vol.ALLOW_EXTRA, ) From ecd827722b60855a7253cd8093b6c34ae6436b3a Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Wed, 15 Sep 2021 13:05:12 -0600 Subject: [PATCH 412/843] Bump pyopenuv to 2.2.1 (#56270) --- homeassistant/components/openuv/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/openuv/manifest.json b/homeassistant/components/openuv/manifest.json index 24af3f3a3af..207bd307d21 100644 --- a/homeassistant/components/openuv/manifest.json +++ b/homeassistant/components/openuv/manifest.json @@ -3,7 +3,7 @@ "name": "OpenUV", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/openuv", - "requirements": ["pyopenuv==2.2.0"], + "requirements": ["pyopenuv==2.2.1"], "codeowners": ["@bachya"], "iot_class": "cloud_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index 1f8831d3f5d..ff33c806aca 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1677,7 +1677,7 @@ pyobihai==1.3.1 pyombi==0.1.10 # homeassistant.components.openuv -pyopenuv==2.2.0 +pyopenuv==2.2.1 # homeassistant.components.opnsense pyopnsense==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7916a6a4273..bbaf253e270 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -981,7 +981,7 @@ pynx584==0.5 pynzbgetapi==0.2.0 # homeassistant.components.openuv -pyopenuv==2.2.0 +pyopenuv==2.2.1 # homeassistant.components.opnsense pyopnsense==0.2.0 From 4eb656d5d9541722b7bcb9a7ec50f790ee4d2bc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Wed, 15 Sep 2021 22:18:35 +0200 Subject: [PATCH 413/843] Fix Surepetcare string reference (#56262) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Daniel Hjelseth Høyer --- homeassistant/components/surepetcare/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/surepetcare/strings.json b/homeassistant/components/surepetcare/strings.json index f5e2f6f173b..f3d4d11008f 100644 --- a/homeassistant/components/surepetcare/strings.json +++ b/homeassistant/components/surepetcare/strings.json @@ -14,7 +14,7 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" } } } From 69ff7a968a3ac86a152ae6c06b3ae97541617162 Mon Sep 17 00:00:00 2001 From: Sean Vig Date: Thu, 16 Sep 2021 00:53:40 -0400 Subject: [PATCH 414/843] Bump amcrest version to 1.9.2 (#56281) --- homeassistant/components/amcrest/camera.py | 4 ++-- homeassistant/components/amcrest/manifest.json | 2 +- requirements_all.txt | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/amcrest/camera.py b/homeassistant/components/amcrest/camera.py index 12fec04ce9c..f118bd0da77 100644 --- a/homeassistant/components/amcrest/camera.py +++ b/homeassistant/components/amcrest/camera.py @@ -375,8 +375,8 @@ class AmcrestCam(Camera): if self._brand is None: resp = self._api.vendor_information.strip() _LOGGER.debug("Assigned brand=%s", resp) - if resp.startswith("vendor="): - self._brand = resp.split("=")[-1] + if resp: + self._brand = resp else: self._brand = "unknown" if self._model is None: diff --git a/homeassistant/components/amcrest/manifest.json b/homeassistant/components/amcrest/manifest.json index 725ff96b3ad..6035c62ff0e 100644 --- a/homeassistant/components/amcrest/manifest.json +++ b/homeassistant/components/amcrest/manifest.json @@ -2,7 +2,7 @@ "domain": "amcrest", "name": "Amcrest", "documentation": "https://www.home-assistant.io/integrations/amcrest", - "requirements": ["amcrest==1.8.1"], + "requirements": ["amcrest==1.9.2"], "dependencies": ["ffmpeg"], "codeowners": ["@flacjacket"], "iot_class": "local_polling" diff --git a/requirements_all.txt b/requirements_all.txt index ff33c806aca..1a75da74585 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -276,7 +276,7 @@ ambee==0.3.0 ambiclimate==0.2.1 # homeassistant.components.amcrest -amcrest==1.8.1 +amcrest==1.9.2 # homeassistant.components.androidtv androidtv[async]==0.0.60 From 0656407561dd93a5d90749f9a1b8379a1c2ddacb Mon Sep 17 00:00:00 2001 From: Malachi Soord Date: Thu, 16 Sep 2021 07:00:25 +0200 Subject: [PATCH 415/843] Upgrade pylast from 4.2.0 to 4.2.1 (#56015) * Upgrade pylast from 4.2.0 to 4.2.1 * Fix test * Use MockNetwork * Tidy * Fix lint --- homeassistant/components/lastfm/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/lastfm/test_sensor.py | 12 ++++++++++-- 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/lastfm/manifest.json b/homeassistant/components/lastfm/manifest.json index 9b4b0e5cdfc..f850b39a620 100644 --- a/homeassistant/components/lastfm/manifest.json +++ b/homeassistant/components/lastfm/manifest.json @@ -2,7 +2,7 @@ "domain": "lastfm", "name": "Last.fm", "documentation": "https://www.home-assistant.io/integrations/lastfm", - "requirements": ["pylast==4.2.0"], + "requirements": ["pylast==4.2.1"], "codeowners": [], "iot_class": "cloud_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index 1a75da74585..f65f5dc9734 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1575,7 +1575,7 @@ pykwb==0.0.8 pylacrosse==0.4 # homeassistant.components.lastfm -pylast==4.2.0 +pylast==4.2.1 # homeassistant.components.launch_library pylaunches==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bbaf253e270..e5f8275c01e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -912,7 +912,7 @@ pykrakenapi==0.1.8 pykulersky==0.5.2 # homeassistant.components.lastfm -pylast==4.2.0 +pylast==4.2.1 # homeassistant.components.forked_daapd pylibrespot-java==0.1.0 diff --git a/tests/components/lastfm/test_sensor.py b/tests/components/lastfm/test_sensor.py index af7a177edb8..cbb37f94dc4 100644 --- a/tests/components/lastfm/test_sensor.py +++ b/tests/components/lastfm/test_sensor.py @@ -9,8 +9,16 @@ from homeassistant.components.lastfm.sensor import STATE_NOT_SCROBBLING from homeassistant.setup import async_setup_component +class MockNetwork: + """Mock _Network object for pylast.""" + + def __init__(self, username: str): + """Initialize the mock.""" + self.username = username + + class MockUser: - """Mock user object for pylast.""" + """Mock User object for pylast.""" def __init__(self, now_playing_result): """Initialize the mock.""" @@ -67,7 +75,7 @@ async def test_update_playing(hass, lastfm_network): """Test update when song playing.""" lastfm_network.return_value.get_user.return_value = MockUser( - Track("artist", "title", None) + Track("artist", "title", MockNetwork("test")) ) assert await async_setup_component( From 8c5efafdd8d69ac8b9cd5217c188ee0a99b99dfe Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 16 Sep 2021 10:57:15 +0200 Subject: [PATCH 416/843] Add 5-minute statistics for sensors (#56006) * Add 5-minute statistics for sensors * Address pylint issues * Black * Apply suggestion from code review * Apply suggestions from code review * Improve tests --- homeassistant/components/history/__init__.py | 2 + homeassistant/components/recorder/__init__.py | 17 +- .../components/recorder/migration.py | 14 +- homeassistant/components/recorder/models.py | 61 +- .../components/recorder/statistics.py | 197 +++++- homeassistant/components/recorder/util.py | 8 +- tests/components/history/test_init.py | 13 +- tests/components/recorder/conftest.py | 6 +- tests/components/recorder/test_init.py | 30 +- tests/components/recorder/test_statistics.py | 64 +- tests/components/sensor/test_recorder.py | 662 +++++++++++++----- tests/conftest.py | 4 +- 12 files changed, 781 insertions(+), 297 deletions(-) diff --git a/homeassistant/components/history/__init__.py b/homeassistant/components/history/__init__.py index e05b6466a24..4f50a5e66be 100644 --- a/homeassistant/components/history/__init__.py +++ b/homeassistant/components/history/__init__.py @@ -124,6 +124,7 @@ class LazyState(history_models.LazyState): vol.Required("start_time"): str, vol.Optional("end_time"): str, vol.Optional("statistic_ids"): [str], + vol.Required("period"): vol.Any("hour", "5minute"), } ) @websocket_api.async_response @@ -157,6 +158,7 @@ async def ws_get_statistics_during_period( start_time, end_time, msg.get("statistic_ids"), + msg.get("period"), ) connection.send_result(msg["id"], statistics) diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index d045726bc22..bbf67b23c52 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -565,7 +565,7 @@ class Recorder(threading.Thread): self.queue.put(PerodicCleanupTask()) @callback - def async_hourly_statistics(self, now): + def async_periodic_statistics(self, now): """Trigger the hourly statistics run.""" start = statistics.get_start_time() self.queue.put(StatisticsTask(start)) @@ -582,9 +582,9 @@ class Recorder(threading.Thread): self.hass, self.async_nightly_tasks, hour=4, minute=12, second=0 ) - # Compile hourly statistics every hour at *:12 + # Compile short term statistics every 5 minutes async_track_time_change( - self.hass, self.async_hourly_statistics, minute=12, second=0 + self.hass, self.async_periodic_statistics, minute=range(0, 60, 5), second=10 ) def run(self): @@ -995,20 +995,21 @@ class Recorder(threading.Thread): def _schedule_compile_missing_statistics(self, session: Session) -> None: """Add tasks for missing statistics runs.""" now = dt_util.utcnow() - last_hour = now.replace(minute=0, second=0, microsecond=0) + last_period_minutes = now.minute - now.minute % 5 + last_period = now.replace(minute=last_period_minutes, second=0, microsecond=0) start = now - timedelta(days=self.keep_days) start = start.replace(minute=0, second=0, microsecond=0) # Find the newest statistics run, if any if last_run := session.query(func.max(StatisticsRuns.start)).scalar(): - start = max(start, process_timestamp(last_run) + timedelta(hours=1)) + start = max(start, process_timestamp(last_run) + timedelta(minutes=5)) # Add tasks - while start < last_hour: - end = start + timedelta(hours=1) + while start < last_period: + end = start + timedelta(minutes=5) _LOGGER.debug("Compiling missing statistics for %s-%s", start, end) self.queue.put(StatisticsTask(start)) - start = start + timedelta(hours=1) + start = end def _end_session(self): """End the recorder session.""" diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 74d95bb6c9c..c7ccefeca02 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -1,6 +1,5 @@ """Schema migration helpers.""" import contextlib -from datetime import timedelta import logging import sqlalchemy @@ -13,8 +12,6 @@ from sqlalchemy.exc import ( ) from sqlalchemy.schema import AddConstraint, DropConstraint -import homeassistant.util.dt as dt_util - from .models import ( SCHEMA_VERSION, TABLE_STATES, @@ -24,6 +21,7 @@ from .models import ( StatisticsMeta, StatisticsRuns, ) +from .statistics import get_start_time from .util import session_scope _LOGGER = logging.getLogger(__name__) @@ -483,10 +481,7 @@ def _apply_update(engine, session, new_version, old_version): # noqa: C901 elif new_version == 19: # This adds the statistic runs table, insert a fake run to prevent duplicating # statistics. - now = dt_util.utcnow() - start = now.replace(minute=0, second=0, microsecond=0) - start = start - timedelta(hours=1) - session.add(StatisticsRuns(start=start)) + session.add(StatisticsRuns(start=get_start_time())) elif new_version == 20: # This changed the precision of statistics from float to double if engine.dialect.name in ["mysql", "oracle", "postgresql"]: @@ -537,10 +532,7 @@ def _inspect_schema_version(engine, session): for index in indexes: if index["column_names"] == ["time_fired"]: # Schema addition from version 1 detected. New DB. - now = dt_util.utcnow() - start = now.replace(minute=0, second=0, microsecond=0) - start = start - timedelta(hours=1) - session.add(StatisticsRuns(start=start)) + session.add(StatisticsRuns(start=get_start_time())) session.add(SchemaChanges(schema_version=SCHEMA_VERSION)) return SCHEMA_VERSION diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py index 5132dfc72bb..1c5c9fa90f0 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -1,7 +1,7 @@ """Models for SQLAlchemy.""" from __future__ import annotations -from datetime import datetime +from datetime import datetime, timedelta import json import logging from typing import TypedDict, overload @@ -20,6 +20,7 @@ from sqlalchemy import ( distinct, ) from sqlalchemy.dialects import mysql, oracle, postgresql +from sqlalchemy.ext.declarative import declared_attr from sqlalchemy.orm import declarative_base, relationship from sqlalchemy.orm.session import Session @@ -52,6 +53,7 @@ TABLE_SCHEMA_CHANGES = "schema_changes" TABLE_STATISTICS = "statistics" TABLE_STATISTICS_META = "statistics_meta" TABLE_STATISTICS_RUNS = "statistics_runs" +TABLE_STATISTICS_SHORT_TERM = "statistics_short_term" ALL_TABLES = [ TABLE_STATES, @@ -61,6 +63,7 @@ ALL_TABLES = [ TABLE_STATISTICS, TABLE_STATISTICS_META, TABLE_STATISTICS_RUNS, + TABLE_STATISTICS_SHORT_TERM, ] DATETIME_TYPE = DateTime(timezone=True).with_variant( @@ -232,21 +235,21 @@ class StatisticData(TypedDict, total=False): sum_increase: float -class Statistics(Base): # type: ignore - """Statistics.""" +class StatisticsBase: + """Statistics base class.""" - __table_args__ = ( - # Used for fetching statistics for a certain entity at a specific time - Index("ix_statistics_statistic_id_start", "metadata_id", "start"), - ) - __tablename__ = TABLE_STATISTICS id = Column(Integer, primary_key=True) created = Column(DATETIME_TYPE, default=dt_util.utcnow) - metadata_id = Column( - Integer, - ForeignKey(f"{TABLE_STATISTICS_META}.id", ondelete="CASCADE"), - index=True, - ) + + @declared_attr + def metadata_id(self): + """Define the metadata_id column for sub classes.""" + return Column( + Integer, + ForeignKey(f"{TABLE_STATISTICS_META}.id", ondelete="CASCADE"), + index=True, + ) + start = Column(DATETIME_TYPE, index=True) mean = Column(DOUBLE_TYPE) min = Column(DOUBLE_TYPE) @@ -256,16 +259,40 @@ class Statistics(Base): # type: ignore sum = Column(DOUBLE_TYPE) sum_increase = Column(DOUBLE_TYPE) - @staticmethod - def from_stats(metadata_id: str, start: datetime, stats: StatisticData): + @classmethod + def from_stats(cls, metadata_id: str, start: datetime, stats: StatisticData): """Create object from a statistics.""" - return Statistics( + return cls( # type: ignore metadata_id=metadata_id, start=start, **stats, ) +class Statistics(Base, StatisticsBase): # type: ignore + """Long term statistics.""" + + duration = timedelta(hours=1) + + __table_args__ = ( + # Used for fetching statistics for a certain entity at a specific time + Index("ix_statistics_statistic_id_start", "metadata_id", "start"), + ) + __tablename__ = TABLE_STATISTICS + + +class StatisticsShortTerm(Base, StatisticsBase): # type: ignore + """Short term statistics.""" + + duration = timedelta(minutes=5) + + __table_args__ = ( + # Used for fetching statistics for a certain entity at a specific time + Index("ix_statistics_short_term_statistic_id_start", "metadata_id", "start"), + ) + __tablename__ = TABLE_STATISTICS_SHORT_TERM + + class StatisticMetaData(TypedDict, total=False): """Statistic meta data class.""" @@ -401,7 +428,7 @@ def process_timestamp(ts: datetime) -> datetime: ... -def process_timestamp(ts): +def process_timestamp(ts: datetime | None) -> datetime | None: """Process a timestamp into datetime object.""" if ts is None: return None diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 6ed612c60ad..afdaacca380 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -2,13 +2,14 @@ from __future__ import annotations from collections import defaultdict +from collections.abc import Iterable import dataclasses from datetime import datetime, timedelta from itertools import groupby import logging from typing import TYPE_CHECKING, Any, Callable -from sqlalchemy import bindparam +from sqlalchemy import bindparam, func from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext import baked from sqlalchemy.orm.scoping import scoped_session @@ -33,6 +34,7 @@ from .models import ( Statistics, StatisticsMeta, StatisticsRuns, + StatisticsShortTerm, process_timestamp, process_timestamp_to_utc_isoformat, ) @@ -53,6 +55,40 @@ QUERY_STATISTICS = [ Statistics.sum_increase, ] +QUERY_STATISTICS_SHORT_TERM = [ + StatisticsShortTerm.metadata_id, + StatisticsShortTerm.start, + StatisticsShortTerm.mean, + StatisticsShortTerm.min, + StatisticsShortTerm.max, + StatisticsShortTerm.last_reset, + StatisticsShortTerm.state, + StatisticsShortTerm.sum, + StatisticsShortTerm.sum_increase, +] + +QUERY_STATISTICS_SUMMARY_MEAN = [ + StatisticsShortTerm.metadata_id, + func.avg(StatisticsShortTerm.mean), + func.min(StatisticsShortTerm.min), + func.max(StatisticsShortTerm.max), +] + +QUERY_STATISTICS_SUMMARY_SUM = [ + StatisticsShortTerm.metadata_id, + StatisticsShortTerm.start, + StatisticsShortTerm.last_reset, + StatisticsShortTerm.state, + StatisticsShortTerm.sum, + StatisticsShortTerm.sum_increase, + func.row_number() + .over( + partition_by=StatisticsShortTerm.metadata_id, + order_by=StatisticsShortTerm.start.desc(), + ) + .label("rownum"), +] + QUERY_STATISTIC_META = [ StatisticsMeta.id, StatisticsMeta.statistic_id, @@ -67,7 +103,9 @@ QUERY_STATISTIC_META_ID = [ ] STATISTICS_BAKERY = "recorder_statistics_bakery" -STATISTICS_META_BAKERY = "recorder_statistics_bakery" +STATISTICS_META_BAKERY = "recorder_statistics_meta_bakery" +STATISTICS_SHORT_TERM_BAKERY = "recorder_statistics_short_term_bakery" + # Convert pressure and temperature statistics from the native unit used for statistics # to the units configured by the user @@ -108,6 +146,7 @@ def async_setup(hass: HomeAssistant) -> None: """Set up the history hooks.""" hass.data[STATISTICS_BAKERY] = baked.bakery() hass.data[STATISTICS_META_BAKERY] = baked.bakery() + hass.data[STATISTICS_SHORT_TERM_BAKERY] = baked.bakery() def entity_id_changed(event: Event) -> None: """Handle entity_id changed.""" @@ -137,9 +176,11 @@ def async_setup(hass: HomeAssistant) -> None: def get_start_time() -> datetime: """Return start time.""" - last_hour = dt_util.utcnow() - timedelta(hours=1) - start = last_hour.replace(minute=0, second=0, microsecond=0) - return start + now = dt_util.utcnow() + current_period_minutes = now.minute - now.minute % 5 + current_period = now.replace(minute=current_period_minutes, second=0, microsecond=0) + last_period = current_period - timedelta(minutes=5) + return last_period def _get_metadata_ids( @@ -204,11 +245,76 @@ def _update_or_add_metadata( return metadata_id +def compile_hourly_statistics( + instance: Recorder, session: scoped_session, start: datetime +) -> None: + """Compile hourly statistics.""" + start_time = start.replace(minute=0) + end_time = start_time + timedelta(hours=1) + # Get last hour's average, min, max + summary = {} + baked_query = instance.hass.data[STATISTICS_SHORT_TERM_BAKERY]( + lambda session: session.query(*QUERY_STATISTICS_SUMMARY_MEAN) + ) + + baked_query += lambda q: q.filter( + StatisticsShortTerm.start >= bindparam("start_time") + ) + baked_query += lambda q: q.filter(StatisticsShortTerm.start < bindparam("end_time")) + baked_query += lambda q: q.group_by(StatisticsShortTerm.metadata_id) + baked_query += lambda q: q.order_by(StatisticsShortTerm.metadata_id) + + stats = execute( + baked_query(session).params(start_time=start_time, end_time=end_time) + ) + + if stats: + for stat in stats: + metadata_id, _mean, _min, _max = stat + summary[metadata_id] = { + "metadata_id": metadata_id, + "mean": _mean, + "min": _min, + "max": _max, + } + + # Get last hour's sum + subquery = ( + session.query(*QUERY_STATISTICS_SUMMARY_SUM) + .filter(StatisticsShortTerm.start >= bindparam("start_time")) + .filter(StatisticsShortTerm.start < bindparam("end_time")) + .subquery() + ) + query = ( + session.query(subquery) + .filter(subquery.c.rownum == 1) + .order_by(subquery.c.metadata_id) + ) + stats = execute(query.params(start_time=start_time, end_time=end_time)) + + if stats: + for stat in stats: + metadata_id, start, last_reset, state, _sum, sum_increase, _ = stat + summary[metadata_id] = { + **summary.get(metadata_id, {}), + **{ + "metadata_id": metadata_id, + "last_reset": process_timestamp(last_reset), + "state": state, + "sum": _sum, + "sum_increase": sum_increase, + }, + } + + for stat in summary.values(): + session.add(Statistics.from_stats(stat.pop("metadata_id"), start_time, stat)) + + @retryable_database_job("statistics") def compile_statistics(instance: Recorder, start: datetime) -> bool: """Compile statistics.""" start = dt_util.as_utc(start) - end = start + timedelta(hours=1) + end = start + timedelta(minutes=5) with session_scope(session=instance.get_session()) as session: # type: ignore if session.query(StatisticsRuns).filter_by(start=start).first(): @@ -232,13 +338,20 @@ def compile_statistics(instance: Recorder, start: datetime) -> bool: instance.hass, session, entity_id, stat["meta"] ) try: - session.add(Statistics.from_stats(metadata_id, start, stat["stat"])) + session.add( + StatisticsShortTerm.from_stats(metadata_id, start, stat["stat"]) + ) except SQLAlchemyError: _LOGGER.exception( "Unexpected exception when inserting statistics %s:%s ", metadata_id, stat, ) + + if start.minute == 55: + # A full hour is ready, summarize it + compile_hourly_statistics(instance, session, start) + session.add(StatisticsRuns(start=start)) return True @@ -354,11 +467,36 @@ def list_statistic_ids( ] +def _statistics_during_period_query( + hass: HomeAssistant, + end_time: datetime | None, + statistic_ids: list[str] | None, + bakery: Any, + base_query: Iterable, + table: type[Statistics | StatisticsShortTerm], +) -> Callable: + baked_query = hass.data[bakery](lambda session: session.query(*base_query)) + + baked_query += lambda q: q.filter(table.start >= bindparam("start_time")) + + if end_time is not None: + baked_query += lambda q: q.filter(table.start < bindparam("end_time")) + + if statistic_ids is not None: + baked_query += lambda q: q.filter( + table.metadata_id.in_(bindparam("metadata_ids")) + ) + + baked_query += lambda q: q.order_by(table.metadata_id, table.start) + return baked_query # type: ignore[no-any-return] + + def statistics_during_period( hass: HomeAssistant, start_time: datetime, end_time: datetime | None = None, statistic_ids: list[str] | None = None, + period: str = "hour", ) -> dict[str, list[dict[str, str]]]: """Return states changes during UTC period start_time - end_time.""" metadata = None @@ -367,23 +505,22 @@ def statistics_during_period( if not metadata: return {} - baked_query = hass.data[STATISTICS_BAKERY]( - lambda session: session.query(*QUERY_STATISTICS) - ) - - baked_query += lambda q: q.filter(Statistics.start >= bindparam("start_time")) - - if end_time is not None: - baked_query += lambda q: q.filter(Statistics.start < bindparam("end_time")) - metadata_ids = None if statistic_ids is not None: - baked_query += lambda q: q.filter( - Statistics.metadata_id.in_(bindparam("metadata_ids")) - ) metadata_ids = list(metadata.keys()) - baked_query += lambda q: q.order_by(Statistics.metadata_id, Statistics.start) + if period == "hour": + bakery = STATISTICS_BAKERY + base_query = QUERY_STATISTICS + table = Statistics + else: + bakery = STATISTICS_SHORT_TERM_BAKERY + base_query = QUERY_STATISTICS_SHORT_TERM + table = StatisticsShortTerm + + baked_query = _statistics_during_period_query( + hass, end_time, statistic_ids, bakery, base_query, table + ) stats = execute( baked_query(session).params( @@ -392,7 +529,9 @@ def statistics_during_period( ) if not stats: return {} - return _sorted_statistics_to_dict(hass, stats, statistic_ids, metadata, True) + return _sorted_statistics_to_dict( + hass, stats, statistic_ids, metadata, True, table.duration + ) def get_last_statistics( @@ -405,15 +544,15 @@ def get_last_statistics( if not metadata: return {} - baked_query = hass.data[STATISTICS_BAKERY]( - lambda session: session.query(*QUERY_STATISTICS) + baked_query = hass.data[STATISTICS_SHORT_TERM_BAKERY]( + lambda session: session.query(*QUERY_STATISTICS_SHORT_TERM) ) baked_query += lambda q: q.filter_by(metadata_id=bindparam("metadata_id")) metadata_id = next(iter(metadata.keys())) baked_query += lambda q: q.order_by( - Statistics.metadata_id, Statistics.start.desc() + StatisticsShortTerm.metadata_id, StatisticsShortTerm.start.desc() ) baked_query += lambda q: q.limit(bindparam("number_of_stats")) @@ -427,7 +566,12 @@ def get_last_statistics( return {} return _sorted_statistics_to_dict( - hass, stats, statistic_ids, metadata, convert_units + hass, + stats, + statistic_ids, + metadata, + convert_units, + StatisticsShortTerm.duration, ) @@ -437,6 +581,7 @@ def _sorted_statistics_to_dict( statistic_ids: list[str] | None, metadata: dict[str, StatisticMetaData], convert_units: bool, + duration: timedelta, ) -> dict[str, list[dict]]: """Convert SQL results into JSON friendly data structure.""" result: dict = defaultdict(list) @@ -463,7 +608,7 @@ def _sorted_statistics_to_dict( ent_results = result[meta_id] for db_state in group: start = process_timestamp(db_state.start) - end = start + timedelta(hours=1) + end = start + duration ent_results.append( { "statistic_id": statistic_id, diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index f492b754125..a3ca0514b4c 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -25,6 +25,7 @@ from .models import ( TABLE_STATISTICS, TABLE_STATISTICS_META, TABLE_STATISTICS_RUNS, + TABLE_STATISTICS_SHORT_TERM, RecorderRuns, process_timestamp, ) @@ -185,7 +186,12 @@ def basic_sanity_check(cursor): for table in ALL_TABLES: # The statistics tables may not be present in old databases - if table in [TABLE_STATISTICS, TABLE_STATISTICS_META, TABLE_STATISTICS_RUNS]: + if table in [ + TABLE_STATISTICS, + TABLE_STATISTICS_META, + TABLE_STATISTICS_RUNS, + TABLE_STATISTICS_SHORT_TERM, + ]: continue if table in (TABLE_RECORDER_RUNS, TABLE_SCHEMA_CHANGES): cursor.execute(f"SELECT * FROM {table};") # nosec # not injection diff --git a/tests/components/history/test_init.py b/tests/components/history/test_init.py index b237659d528..661d703725d 100644 --- a/tests/components/history/test_init.py +++ b/tests/components/history/test_init.py @@ -875,7 +875,7 @@ async def test_statistics_during_period( await hass.async_add_executor_job(trigger_db_commit, hass) await hass.async_block_till_done() - hass.data[recorder.DATA_INSTANCE].do_adhoc_statistics(period="hourly", start=now) + hass.data[recorder.DATA_INSTANCE].do_adhoc_statistics(start=now) await hass.async_add_executor_job(hass.data[recorder.DATA_INSTANCE].block_till_done) client = await hass_ws_client() @@ -886,19 +886,20 @@ async def test_statistics_during_period( "start_time": now.isoformat(), "end_time": now.isoformat(), "statistic_ids": ["sensor.test"], + "period": "hour", } ) response = await client.receive_json() assert response["success"] assert response["result"] == {} - client = await hass_ws_client() await client.send_json( { - "id": 1, + "id": 2, "type": "history/statistics_during_period", "start_time": now.isoformat(), "statistic_ids": ["sensor.test"], + "period": "5minute", } ) response = await client.receive_json() @@ -908,7 +909,7 @@ async def test_statistics_during_period( { "statistic_id": "sensor.test", "start": now.isoformat(), - "end": (now + timedelta(hours=1)).isoformat(), + "end": (now + timedelta(minutes=5)).isoformat(), "mean": approx(value), "min": approx(value), "max": approx(value), @@ -938,6 +939,7 @@ async def test_statistics_during_period_bad_start_time(hass, hass_ws_client): "id": 1, "type": "history/statistics_during_period", "start_time": "cats", + "period": "5minute", } ) response = await client.receive_json() @@ -964,6 +966,7 @@ async def test_statistics_during_period_bad_end_time(hass, hass_ws_client): "type": "history/statistics_during_period", "start_time": now.isoformat(), "end_time": "dogs", + "period": "5minute", } ) response = await client.receive_json() @@ -1011,7 +1014,7 @@ async def test_list_statistic_ids(hass, hass_ws_client, units, attributes, unit) {"statistic_id": "sensor.test", "unit_of_measurement": unit} ] - hass.data[recorder.DATA_INSTANCE].do_adhoc_statistics(period="hourly", start=now) + hass.data[recorder.DATA_INSTANCE].do_adhoc_statistics(start=now) await hass.async_add_executor_job(hass.data[recorder.DATA_INSTANCE].block_till_done) # Remove the state, statistics will now be fetched from the database hass.states.async_remove("sensor.test") diff --git a/tests/components/recorder/conftest.py b/tests/components/recorder/conftest.py index 2a29513a88e..e7786307b69 100644 --- a/tests/components/recorder/conftest.py +++ b/tests/components/recorder/conftest.py @@ -30,9 +30,11 @@ async def async_setup_recorder_instance( hass: HomeAssistant, config: ConfigType | None = None ) -> Recorder: """Setup and return recorder instance.""" # noqa: D401 - stats = recorder.Recorder.async_hourly_statistics if enable_statistics else None + stats = ( + recorder.Recorder.async_periodic_statistics if enable_statistics else None + ) with patch( - "homeassistant.components.recorder.Recorder.async_hourly_statistics", + "homeassistant.components.recorder.Recorder.async_periodic_statistics", side_effect=stats, autospec=True, ): diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index fa0e8b7349b..e41a0da34ba 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -700,41 +700,41 @@ def test_auto_statistics(hass_recorder): tz = dt_util.get_time_zone("Europe/Copenhagen") dt_util.set_default_time_zone(tz) - # Statistics is scheduled to happen at *:12am every hour. Exercise this behavior by + # Statistics is scheduled to happen every 5 minutes. Exercise this behavior by # firing time changed events and advancing the clock around this time. Pick an # arbitrary year in the future to avoid boundary conditions relative to the current # date. # - # The clock is started at 4:15am then advanced forward below + # The clock is started at 4:16am then advanced forward below now = dt_util.utcnow() - test_time = datetime(now.year + 2, 1, 1, 4, 15, 0, tzinfo=tz) + test_time = datetime(now.year + 2, 1, 1, 4, 16, 0, tzinfo=tz) run_tasks_at_time(hass, test_time) with patch( "homeassistant.components.recorder.statistics.compile_statistics", return_value=True, ) as compile_statistics: - # Advance one hour, and the statistics task should run - test_time = test_time + timedelta(hours=1) + # Advance 5 minutes, and the statistics task should run + test_time = test_time + timedelta(minutes=5) run_tasks_at_time(hass, test_time) assert len(compile_statistics.mock_calls) == 1 compile_statistics.reset_mock() - # Advance one hour, and the statistics task should run again - test_time = test_time + timedelta(hours=1) + # Advance 5 minutes, and the statistics task should run again + test_time = test_time + timedelta(minutes=5) run_tasks_at_time(hass, test_time) assert len(compile_statistics.mock_calls) == 1 compile_statistics.reset_mock() - # Advance less than one full hour. The task should not run. - test_time = test_time + timedelta(minutes=50) + # Advance less than 5 minutes. The task should not run. + test_time = test_time + timedelta(minutes=3) run_tasks_at_time(hass, test_time) assert len(compile_statistics.mock_calls) == 0 - # Advance to the next hour, and the statistics task should run again - test_time = test_time + timedelta(hours=1) + # Advance 5 minutes, and the statistics task should run again + test_time = test_time + timedelta(minutes=5) run_tasks_at_time(hass, test_time) assert len(compile_statistics.mock_calls) == 1 @@ -754,8 +754,8 @@ def test_statistics_runs_initiated(hass_recorder): assert len(statistics_runs) == 1 last_run = process_timestamp(statistics_runs[0].start) assert process_timestamp(last_run) == now.replace( - minute=0, second=0, microsecond=0 - ) - timedelta(hours=1) + minute=now.minute - now.minute % 5, second=0, microsecond=0 + ) - timedelta(minutes=5) def test_compile_missing_statistics(tmpdir): @@ -776,7 +776,7 @@ def test_compile_missing_statistics(tmpdir): statistics_runs = list(session.query(StatisticsRuns)) assert len(statistics_runs) == 1 last_run = process_timestamp(statistics_runs[0].start) - assert last_run == now - timedelta(hours=1) + assert last_run == now - timedelta(minutes=5) wait_recording_done(hass) wait_recording_done(hass) @@ -795,7 +795,7 @@ def test_compile_missing_statistics(tmpdir): with session_scope(hass=hass) as session: statistics_runs = list(session.query(StatisticsRuns)) - assert len(statistics_runs) == 2 + assert len(statistics_runs) == 13 # 12 5-minute runs last_run = process_timestamp(statistics_runs[1].start) assert last_run == now diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index 1e723e7e2ca..d82f74c155a 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -9,7 +9,7 @@ from pytest import approx from homeassistant.components.recorder import history from homeassistant.components.recorder.const import DATA_INSTANCE from homeassistant.components.recorder.models import ( - Statistics, + StatisticsShortTerm, process_timestamp_to_utc_isoformat, ) from homeassistant.components.recorder.statistics import ( @@ -34,18 +34,18 @@ def test_compile_hourly_statistics(hass_recorder): assert dict(states) == dict(hist) for kwargs in ({}, {"statistic_ids": ["sensor.test1"]}): - stats = statistics_during_period(hass, zero, **kwargs) + stats = statistics_during_period(hass, zero, period="5minute", **kwargs) assert stats == {} stats = get_last_statistics(hass, 0, "sensor.test1", True) assert stats == {} - recorder.do_adhoc_statistics(period="hourly", start=zero) - recorder.do_adhoc_statistics(period="hourly", start=four) + recorder.do_adhoc_statistics(start=zero) + recorder.do_adhoc_statistics(start=four) wait_recording_done(hass) expected_1 = { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero), - "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(minutes=5)), "mean": approx(14.915254237288135), "min": approx(10.0), "max": approx(20.0), @@ -58,7 +58,7 @@ def test_compile_hourly_statistics(hass_recorder): expected_2 = { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(four), - "end": process_timestamp_to_utc_isoformat(four + timedelta(hours=1)), + "end": process_timestamp_to_utc_isoformat(four + timedelta(minutes=5)), "mean": approx(20.0), "min": approx(20.0), "max": approx(20.0), @@ -78,13 +78,17 @@ def test_compile_hourly_statistics(hass_recorder): ] # Test statistics_during_period - stats = statistics_during_period(hass, zero) + stats = statistics_during_period(hass, zero, period="5minute") assert stats == {"sensor.test1": expected_stats1, "sensor.test2": expected_stats2} - stats = statistics_during_period(hass, zero, statistic_ids=["sensor.test2"]) + stats = statistics_during_period( + hass, zero, statistic_ids=["sensor.test2"], period="5minute" + ) assert stats == {"sensor.test2": expected_stats2} - stats = statistics_during_period(hass, zero, statistic_ids=["sensor.test3"]) + stats = statistics_during_period( + hass, zero, statistic_ids=["sensor.test3"], period="5minute" + ) assert stats == {} # Test get_last_statistics @@ -130,7 +134,7 @@ def mock_sensor_statistics(): def mock_from_stats(): """Mock out Statistics.from_stats.""" counter = 0 - real_from_stats = Statistics.from_stats + real_from_stats = StatisticsShortTerm.from_stats def from_stats(metadata_id, start, stats): nonlocal counter @@ -140,17 +144,17 @@ def mock_from_stats(): return real_from_stats(metadata_id, start, stats) with patch( - "homeassistant.components.recorder.statistics.Statistics.from_stats", + "homeassistant.components.recorder.statistics.StatisticsShortTerm.from_stats", side_effect=from_stats, autospec=True, ): yield -def test_compile_hourly_statistics_exception( +def test_compile_periodic_statistics_exception( hass_recorder, mock_sensor_statistics, mock_from_stats ): - """Test exception handling when compiling hourly statistics.""" + """Test exception handling when compiling periodic statistics.""" def mock_from_stats(): raise ValueError @@ -160,13 +164,13 @@ def test_compile_hourly_statistics_exception( setup_component(hass, "sensor", {}) now = dt_util.utcnow() - recorder.do_adhoc_statistics(period="hourly", start=now) - recorder.do_adhoc_statistics(period="hourly", start=now + timedelta(hours=1)) + recorder.do_adhoc_statistics(start=now) + recorder.do_adhoc_statistics(start=now + timedelta(minutes=5)) wait_recording_done(hass) expected_1 = { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(now), - "end": process_timestamp_to_utc_isoformat(now + timedelta(hours=1)), + "end": process_timestamp_to_utc_isoformat(now + timedelta(minutes=5)), "mean": None, "min": None, "max": None, @@ -178,8 +182,8 @@ def test_compile_hourly_statistics_exception( } expected_2 = { "statistic_id": "sensor.test1", - "start": process_timestamp_to_utc_isoformat(now + timedelta(hours=1)), - "end": process_timestamp_to_utc_isoformat(now + timedelta(hours=2)), + "start": process_timestamp_to_utc_isoformat(now + timedelta(minutes=5)), + "end": process_timestamp_to_utc_isoformat(now + timedelta(minutes=10)), "mean": None, "min": None, "max": None, @@ -201,7 +205,7 @@ def test_compile_hourly_statistics_exception( {**expected_2, "statistic_id": "sensor.test3"}, ] - stats = statistics_during_period(hass, now) + stats = statistics_during_period(hass, now, period="5minute") assert stats == { "sensor.test1": expected_stats1, "sensor.test2": expected_stats2, @@ -229,17 +233,17 @@ def test_rename_entity(hass_recorder): assert dict(states) == dict(hist) for kwargs in ({}, {"statistic_ids": ["sensor.test1"]}): - stats = statistics_during_period(hass, zero, **kwargs) + stats = statistics_during_period(hass, zero, period="5minute", **kwargs) assert stats == {} stats = get_last_statistics(hass, 0, "sensor.test1", True) assert stats == {} - recorder.do_adhoc_statistics(period="hourly", start=zero) + recorder.do_adhoc_statistics(start=zero) wait_recording_done(hass) expected_1 = { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero), - "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(minutes=5)), "mean": approx(14.915254237288135), "min": approx(10.0), "max": approx(20.0), @@ -259,13 +263,13 @@ def test_rename_entity(hass_recorder): {**expected_1, "statistic_id": "sensor.test99"}, ] - stats = statistics_during_period(hass, zero) + stats = statistics_during_period(hass, zero, period="5minute") assert stats == {"sensor.test1": expected_stats1, "sensor.test2": expected_stats2} entity_reg.async_update_entity(reg_entry.entity_id, new_entity_id="sensor.test99") hass.block_till_done() - stats = statistics_during_period(hass, zero) + stats = statistics_during_period(hass, zero, period="5minute") assert stats == {"sensor.test99": expected_stats99, "sensor.test2": expected_stats2} @@ -285,7 +289,7 @@ def test_statistics_duplicated(hass_recorder, caplog): with patch( "homeassistant.components.sensor.recorder.compile_statistics" ) as compile_statistics: - recorder.do_adhoc_statistics(period="hourly", start=zero) + recorder.do_adhoc_statistics(start=zero) wait_recording_done(hass) assert compile_statistics.called compile_statistics.reset_mock() @@ -293,7 +297,7 @@ def test_statistics_duplicated(hass_recorder, caplog): assert "Statistics already compiled" not in caplog.text caplog.clear() - recorder.do_adhoc_statistics(period="hourly", start=zero) + recorder.do_adhoc_statistics(start=zero) wait_recording_done(hass) assert not compile_statistics.called compile_statistics.reset_mock() @@ -332,10 +336,10 @@ def record_states(hass): return hass.states.get(entity_id) zero = dt_util.utcnow() - one = zero + timedelta(minutes=1) - two = one + timedelta(minutes=15) - three = two + timedelta(minutes=30) - four = three + timedelta(minutes=15) + one = zero + timedelta(seconds=1 * 5) + two = one + timedelta(seconds=15 * 5) + three = two + timedelta(seconds=30 * 5) + four = three + timedelta(seconds=15 * 5) states = {mp: [], sns1: [], sns2: [], sns3: [], sns4: []} with patch("homeassistant.components.recorder.dt_util.utcnow", return_value=one): diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 74adf717e5b..c1278202443 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -2,6 +2,7 @@ # pylint: disable=protected-access,invalid-name from datetime import timedelta import math +from statistics import mean from unittest.mock import patch import pytest @@ -83,19 +84,19 @@ def test_compile_hourly_statistics( hist = history.get_significant_states(hass, zero, four) assert dict(states) == dict(hist) - recorder.do_adhoc_statistics(period="hourly", start=zero) + recorder.do_adhoc_statistics(start=zero) wait_recording_done(hass) statistic_ids = list_statistic_ids(hass) assert statistic_ids == [ {"statistic_id": "sensor.test1", "unit_of_measurement": native_unit} ] - stats = statistics_during_period(hass, zero) + stats = statistics_during_period(hass, zero, period="5minute") assert stats == { "sensor.test1": [ { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero), - "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(minutes=5)), "mean": approx(mean), "min": approx(min), "max": approx(max), @@ -146,7 +147,7 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes hist = history.get_significant_states(hass, zero, four) assert dict(states) == dict(hist) - recorder.do_adhoc_statistics(period="hourly", start=zero) + recorder.do_adhoc_statistics(start=zero) wait_recording_done(hass) statistic_ids = list_statistic_ids(hass) assert statistic_ids == [ @@ -154,13 +155,13 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes {"statistic_id": "sensor.test6", "unit_of_measurement": "°C"}, {"statistic_id": "sensor.test7", "unit_of_measurement": "°C"}, ] - stats = statistics_during_period(hass, zero) + stats = statistics_during_period(hass, zero, period="5minute") assert stats == { "sensor.test1": [ { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero), - "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(minutes=5)), "mean": approx(13.050847), "min": approx(-10.0), "max": approx(30.0), @@ -175,7 +176,7 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes { "statistic_id": "sensor.test6", "start": process_timestamp_to_utc_isoformat(zero), - "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(minutes=5)), "mean": approx(13.050847), "min": approx(-10.0), "max": approx(30.0), @@ -190,7 +191,7 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes { "statistic_id": "sensor.test7", "start": process_timestamp_to_utc_isoformat(zero), - "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(minutes=5)), "mean": approx(13.050847), "min": approx(-10.0), "max": approx(30.0), @@ -227,7 +228,10 @@ def test_compile_hourly_sum_statistics_amount( hass_recorder, caplog, units, state_class, device_class, unit, display_unit, factor ): """Test compiling hourly statistics.""" - zero = dt_util.utcnow() + period0 = dt_util.utcnow() + period0_end = period1 = period0 + timedelta(minutes=5) + period1_end = period2 = period0 + timedelta(minutes=10) + period2_end = period0 + timedelta(minutes=15) hass = hass_recorder() hass.config.units = units recorder = hass.data[DATA_INSTANCE] @@ -241,34 +245,34 @@ def test_compile_hourly_sum_statistics_amount( seq = [10, 15, 20, 10, 30, 40, 50, 60, 70] four, eight, states = record_meter_states( - hass, zero, "sensor.test1", attributes, seq + hass, period0, "sensor.test1", attributes, seq ) hist = history.get_significant_states( - hass, zero - timedelta.resolution, eight + timedelta.resolution + hass, period0 - timedelta.resolution, eight + timedelta.resolution ) assert dict(states)["sensor.test1"] == dict(hist)["sensor.test1"] - recorder.do_adhoc_statistics(period="hourly", start=zero) + recorder.do_adhoc_statistics(start=period0) wait_recording_done(hass) - recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=1)) + recorder.do_adhoc_statistics(start=period1) wait_recording_done(hass) - recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=2)) + recorder.do_adhoc_statistics(start=period2) wait_recording_done(hass) statistic_ids = list_statistic_ids(hass) assert statistic_ids == [ {"statistic_id": "sensor.test1", "unit_of_measurement": display_unit} ] - stats = statistics_during_period(hass, zero) + stats = statistics_during_period(hass, period0, period="5minute") assert stats == { "sensor.test1": [ { "statistic_id": "sensor.test1", - "start": process_timestamp_to_utc_isoformat(zero), - "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), + "start": process_timestamp_to_utc_isoformat(period0), + "end": process_timestamp_to_utc_isoformat(period0_end), "max": None, "mean": None, "min": None, - "last_reset": process_timestamp_to_utc_isoformat(zero), + "last_reset": process_timestamp_to_utc_isoformat(period0), "state": approx(factor * seq[2]), "sum": approx(factor * 10.0), "sum_decrease": approx(factor * 0.0), @@ -276,8 +280,8 @@ def test_compile_hourly_sum_statistics_amount( }, { "statistic_id": "sensor.test1", - "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), - "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=2)), + "start": process_timestamp_to_utc_isoformat(period1), + "end": process_timestamp_to_utc_isoformat(period1_end), "max": None, "mean": None, "min": None, @@ -289,8 +293,8 @@ def test_compile_hourly_sum_statistics_amount( }, { "statistic_id": "sensor.test1", - "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=2)), - "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=3)), + "start": process_timestamp_to_utc_isoformat(period2), + "end": process_timestamp_to_utc_isoformat(period2_end), "max": None, "mean": None, "min": None, @@ -341,7 +345,7 @@ def test_compile_hourly_sum_statistics_amount_reset_every_state_change( states = {"sensor.test1": []} one = zero for i in range(len(seq)): - one = one + timedelta(minutes=1) + one = one + timedelta(seconds=5) _states = record_meter_state( hass, one, "sensor.test1", attributes, seq[i : i + 1] ) @@ -355,19 +359,19 @@ def test_compile_hourly_sum_statistics_amount_reset_every_state_change( ) assert dict(states)["sensor.test1"] == dict(hist)["sensor.test1"] - recorder.do_adhoc_statistics(period="hourly", start=zero) + recorder.do_adhoc_statistics(start=zero) wait_recording_done(hass) statistic_ids = list_statistic_ids(hass) assert statistic_ids == [ {"statistic_id": "sensor.test1", "unit_of_measurement": native_unit} ] - stats = statistics_during_period(hass, zero) + stats = statistics_during_period(hass, zero, period="5minute") assert stats == { "sensor.test1": [ { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero), - "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(minutes=5)), "max": None, "mean": None, "min": None, @@ -408,7 +412,7 @@ def test_compile_hourly_sum_statistics_nan_inf_state( states = {"sensor.test1": []} one = zero for i in range(len(seq)): - one = one + timedelta(minutes=1) + one = one + timedelta(seconds=5) _states = record_meter_state( hass, one, "sensor.test1", attributes, seq[i : i + 1] ) @@ -422,19 +426,19 @@ def test_compile_hourly_sum_statistics_nan_inf_state( ) assert dict(states)["sensor.test1"] == dict(hist)["sensor.test1"] - recorder.do_adhoc_statistics(period="hourly", start=zero) + recorder.do_adhoc_statistics(start=zero) wait_recording_done(hass) statistic_ids = list_statistic_ids(hass) assert statistic_ids == [ {"statistic_id": "sensor.test1", "unit_of_measurement": native_unit} ] - stats = statistics_during_period(hass, zero) + stats = statistics_during_period(hass, zero, period="5minute") assert stats == { "sensor.test1": [ { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero), - "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(minutes=5)), "max": None, "mean": None, "min": None, @@ -466,7 +470,10 @@ def test_compile_hourly_sum_statistics_total_no_reset( hass_recorder, caplog, device_class, unit, native_unit, factor ): """Test compiling hourly statistics.""" - zero = dt_util.utcnow() + period0 = dt_util.utcnow() + period0_end = period1 = period0 + timedelta(minutes=5) + period1_end = period2 = period0 + timedelta(minutes=10) + period2_end = period0 + timedelta(minutes=15) hass = hass_recorder() recorder = hass.data[DATA_INSTANCE] setup_component(hass, "sensor", {}) @@ -478,30 +485,30 @@ def test_compile_hourly_sum_statistics_total_no_reset( seq = [10, 15, 20, 10, 30, 40, 50, 60, 70] four, eight, states = record_meter_states( - hass, zero, "sensor.test1", attributes, seq + hass, period0, "sensor.test1", attributes, seq ) hist = history.get_significant_states( - hass, zero - timedelta.resolution, eight + timedelta.resolution + hass, period0 - timedelta.resolution, eight + timedelta.resolution ) assert dict(states)["sensor.test1"] == dict(hist)["sensor.test1"] - recorder.do_adhoc_statistics(period="hourly", start=zero) + recorder.do_adhoc_statistics(start=period0) wait_recording_done(hass) - recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=1)) + recorder.do_adhoc_statistics(start=period1) wait_recording_done(hass) - recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=2)) + recorder.do_adhoc_statistics(start=period2) wait_recording_done(hass) statistic_ids = list_statistic_ids(hass) assert statistic_ids == [ {"statistic_id": "sensor.test1", "unit_of_measurement": native_unit} ] - stats = statistics_during_period(hass, zero) + stats = statistics_during_period(hass, period0, period="5minute") assert stats == { "sensor.test1": [ { "statistic_id": "sensor.test1", - "start": process_timestamp_to_utc_isoformat(zero), - "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), + "start": process_timestamp_to_utc_isoformat(period0), + "end": process_timestamp_to_utc_isoformat(period0_end), "max": None, "mean": None, "min": None, @@ -513,8 +520,8 @@ def test_compile_hourly_sum_statistics_total_no_reset( }, { "statistic_id": "sensor.test1", - "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), - "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=2)), + "start": process_timestamp_to_utc_isoformat(period1), + "end": process_timestamp_to_utc_isoformat(period1_end), "max": None, "mean": None, "min": None, @@ -526,8 +533,8 @@ def test_compile_hourly_sum_statistics_total_no_reset( }, { "statistic_id": "sensor.test1", - "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=2)), - "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=3)), + "start": process_timestamp_to_utc_isoformat(period2), + "end": process_timestamp_to_utc_isoformat(period2_end), "max": None, "mean": None, "min": None, @@ -555,7 +562,10 @@ def test_compile_hourly_sum_statistics_total_increasing( hass_recorder, caplog, device_class, unit, native_unit, factor ): """Test compiling hourly statistics.""" - zero = dt_util.utcnow() + period0 = dt_util.utcnow() + period0_end = period1 = period0 + timedelta(minutes=5) + period1_end = period2 = period0 + timedelta(minutes=10) + period2_end = period0 + timedelta(minutes=15) hass = hass_recorder() recorder = hass.data[DATA_INSTANCE] setup_component(hass, "sensor", {}) @@ -567,30 +577,30 @@ def test_compile_hourly_sum_statistics_total_increasing( seq = [10, 15, 20, 10, 30, 40, 50, 60, 70] four, eight, states = record_meter_states( - hass, zero, "sensor.test1", attributes, seq + hass, period0, "sensor.test1", attributes, seq ) hist = history.get_significant_states( - hass, zero - timedelta.resolution, eight + timedelta.resolution + hass, period0 - timedelta.resolution, eight + timedelta.resolution ) assert dict(states)["sensor.test1"] == dict(hist)["sensor.test1"] - recorder.do_adhoc_statistics(period="hourly", start=zero) + recorder.do_adhoc_statistics(start=period0) wait_recording_done(hass) - recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=1)) + recorder.do_adhoc_statistics(start=period1) wait_recording_done(hass) - recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=2)) + recorder.do_adhoc_statistics(start=period2) wait_recording_done(hass) statistic_ids = list_statistic_ids(hass) assert statistic_ids == [ {"statistic_id": "sensor.test1", "unit_of_measurement": native_unit} ] - stats = statistics_during_period(hass, zero) + stats = statistics_during_period(hass, period0, period="5minute") assert stats == { "sensor.test1": [ { "statistic_id": "sensor.test1", - "start": process_timestamp_to_utc_isoformat(zero), - "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), + "start": process_timestamp_to_utc_isoformat(period0), + "end": process_timestamp_to_utc_isoformat(period0_end), "max": None, "mean": None, "min": None, @@ -602,8 +612,8 @@ def test_compile_hourly_sum_statistics_total_increasing( }, { "statistic_id": "sensor.test1", - "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), - "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=2)), + "start": process_timestamp_to_utc_isoformat(period1), + "end": process_timestamp_to_utc_isoformat(period1_end), "max": None, "mean": None, "min": None, @@ -615,8 +625,8 @@ def test_compile_hourly_sum_statistics_total_increasing( }, { "statistic_id": "sensor.test1", - "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=2)), - "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=3)), + "start": process_timestamp_to_utc_isoformat(period2), + "end": process_timestamp_to_utc_isoformat(period2_end), "max": None, "mean": None, "min": None, @@ -642,7 +652,10 @@ def test_compile_hourly_sum_statistics_total_increasing_small_dip( hass_recorder, caplog, device_class, unit, native_unit, factor ): """Test small dips in sensor readings do not trigger a reset.""" - zero = dt_util.utcnow() + period0 = dt_util.utcnow() + period0_end = period1 = period0 + timedelta(minutes=5) + period1_end = period2 = period0 + timedelta(minutes=10) + period2_end = period0 + timedelta(minutes=15) hass = hass_recorder() recorder = hass.data[DATA_INSTANCE] setup_component(hass, "sensor", {}) @@ -654,16 +667,16 @@ def test_compile_hourly_sum_statistics_total_increasing_small_dip( seq = [10, 15, 20, 19, 30, 40, 39, 60, 70] four, eight, states = record_meter_states( - hass, zero, "sensor.test1", attributes, seq + hass, period0, "sensor.test1", attributes, seq ) hist = history.get_significant_states( - hass, zero - timedelta.resolution, eight + timedelta.resolution + hass, period0 - timedelta.resolution, eight + timedelta.resolution ) assert dict(states)["sensor.test1"] == dict(hist)["sensor.test1"] - recorder.do_adhoc_statistics(period="hourly", start=zero) + recorder.do_adhoc_statistics(start=period0) wait_recording_done(hass) - recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=1)) + recorder.do_adhoc_statistics(start=period1) wait_recording_done(hass) assert ( "Entity sensor.test1 has state class total_increasing, but its state is not " @@ -671,7 +684,7 @@ def test_compile_hourly_sum_statistics_total_increasing_small_dip( "home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A" "+recorder%22" ) not in caplog.text - recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=2)) + recorder.do_adhoc_statistics(start=period2) wait_recording_done(hass) assert ( "Entity sensor.test1 has state class total_increasing, but its state is not " @@ -683,14 +696,14 @@ def test_compile_hourly_sum_statistics_total_increasing_small_dip( assert statistic_ids == [ {"statistic_id": "sensor.test1", "unit_of_measurement": native_unit} ] - stats = statistics_during_period(hass, zero) + stats = statistics_during_period(hass, period0, period="5minute") assert stats == { "sensor.test1": [ { "last_reset": None, "statistic_id": "sensor.test1", - "start": process_timestamp_to_utc_isoformat(zero), - "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), + "start": process_timestamp_to_utc_isoformat(period0), + "end": process_timestamp_to_utc_isoformat(period0_end), "max": None, "mean": None, "min": None, @@ -702,8 +715,8 @@ def test_compile_hourly_sum_statistics_total_increasing_small_dip( { "last_reset": None, "statistic_id": "sensor.test1", - "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), - "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=2)), + "start": process_timestamp_to_utc_isoformat(period1), + "end": process_timestamp_to_utc_isoformat(period1_end), "max": None, "mean": None, "min": None, @@ -715,8 +728,8 @@ def test_compile_hourly_sum_statistics_total_increasing_small_dip( { "last_reset": None, "statistic_id": "sensor.test1", - "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=2)), - "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=3)), + "start": process_timestamp_to_utc_isoformat(period2), + "end": process_timestamp_to_utc_isoformat(period2_end), "max": None, "mean": None, "min": None, @@ -732,7 +745,10 @@ def test_compile_hourly_sum_statistics_total_increasing_small_dip( def test_compile_hourly_energy_statistics_unsupported(hass_recorder, caplog): """Test compiling hourly statistics.""" - zero = dt_util.utcnow() + period0 = dt_util.utcnow() + period0_end = period1 = period0 + timedelta(minutes=5) + period1_end = period2 = period0 + timedelta(minutes=10) + period2_end = period0 + timedelta(minutes=15) hass = hass_recorder() recorder = hass.data[DATA_INSTANCE] setup_component(hass, "sensor", {}) @@ -755,41 +771,41 @@ def test_compile_hourly_energy_statistics_unsupported(hass_recorder, caplog): seq4 = [0, 0, 5, 10, 30, 50, 60, 80, 90] four, eight, states = record_meter_states( - hass, zero, "sensor.test1", sns1_attr, seq1 + hass, period0, "sensor.test1", sns1_attr, seq1 ) - _, _, _states = record_meter_states(hass, zero, "sensor.test2", sns2_attr, seq2) + _, _, _states = record_meter_states(hass, period0, "sensor.test2", sns2_attr, seq2) states = {**states, **_states} - _, _, _states = record_meter_states(hass, zero, "sensor.test3", sns3_attr, seq3) + _, _, _states = record_meter_states(hass, period0, "sensor.test3", sns3_attr, seq3) states = {**states, **_states} - _, _, _states = record_meter_states(hass, zero, "sensor.test4", sns4_attr, seq4) + _, _, _states = record_meter_states(hass, period0, "sensor.test4", sns4_attr, seq4) states = {**states, **_states} hist = history.get_significant_states( - hass, zero - timedelta.resolution, eight + timedelta.resolution + hass, period0 - timedelta.resolution, eight + timedelta.resolution ) assert dict(states)["sensor.test1"] == dict(hist)["sensor.test1"] - recorder.do_adhoc_statistics(period="hourly", start=zero) + recorder.do_adhoc_statistics(start=period0) wait_recording_done(hass) - recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=1)) + recorder.do_adhoc_statistics(start=period1) wait_recording_done(hass) - recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=2)) + recorder.do_adhoc_statistics(start=period2) wait_recording_done(hass) statistic_ids = list_statistic_ids(hass) assert statistic_ids == [ {"statistic_id": "sensor.test1", "unit_of_measurement": "kWh"} ] - stats = statistics_during_period(hass, zero) + stats = statistics_during_period(hass, period0, period="5minute") assert stats == { "sensor.test1": [ { "statistic_id": "sensor.test1", - "start": process_timestamp_to_utc_isoformat(zero), - "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), + "start": process_timestamp_to_utc_isoformat(period0), + "end": process_timestamp_to_utc_isoformat(period0_end), "max": None, "mean": None, "min": None, - "last_reset": process_timestamp_to_utc_isoformat(zero), + "last_reset": process_timestamp_to_utc_isoformat(period0), "state": approx(20.0), "sum": approx(10.0), "sum_decrease": approx(0.0), @@ -797,8 +813,8 @@ def test_compile_hourly_energy_statistics_unsupported(hass_recorder, caplog): }, { "statistic_id": "sensor.test1", - "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), - "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=2)), + "start": process_timestamp_to_utc_isoformat(period1), + "end": process_timestamp_to_utc_isoformat(period1_end), "max": None, "mean": None, "min": None, @@ -810,8 +826,8 @@ def test_compile_hourly_energy_statistics_unsupported(hass_recorder, caplog): }, { "statistic_id": "sensor.test1", - "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=2)), - "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=3)), + "start": process_timestamp_to_utc_isoformat(period2), + "end": process_timestamp_to_utc_isoformat(period2_end), "max": None, "mean": None, "min": None, @@ -828,7 +844,10 @@ def test_compile_hourly_energy_statistics_unsupported(hass_recorder, caplog): def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): """Test compiling multiple hourly statistics.""" - zero = dt_util.utcnow() + period0 = dt_util.utcnow() + period0_end = period1 = period0 + timedelta(minutes=5) + period1_end = period2 = period0 + timedelta(minutes=10) + period2_end = period0 + timedelta(minutes=15) hass = hass_recorder() recorder = hass.data[DATA_INSTANCE] setup_component(hass, "sensor", {}) @@ -846,24 +865,24 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): seq4 = [0, 0, 5, 10, 30, 50, 60, 80, 90] four, eight, states = record_meter_states( - hass, zero, "sensor.test1", sns1_attr, seq1 + hass, period0, "sensor.test1", sns1_attr, seq1 ) - _, _, _states = record_meter_states(hass, zero, "sensor.test2", sns2_attr, seq2) + _, _, _states = record_meter_states(hass, period0, "sensor.test2", sns2_attr, seq2) states = {**states, **_states} - _, _, _states = record_meter_states(hass, zero, "sensor.test3", sns3_attr, seq3) + _, _, _states = record_meter_states(hass, period0, "sensor.test3", sns3_attr, seq3) states = {**states, **_states} - _, _, _states = record_meter_states(hass, zero, "sensor.test4", sns4_attr, seq4) + _, _, _states = record_meter_states(hass, period0, "sensor.test4", sns4_attr, seq4) states = {**states, **_states} hist = history.get_significant_states( - hass, zero - timedelta.resolution, eight + timedelta.resolution + hass, period0 - timedelta.resolution, eight + timedelta.resolution ) assert dict(states)["sensor.test1"] == dict(hist)["sensor.test1"] - recorder.do_adhoc_statistics(period="hourly", start=zero) + recorder.do_adhoc_statistics(start=period0) wait_recording_done(hass) - recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=1)) + recorder.do_adhoc_statistics(start=period1) wait_recording_done(hass) - recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=2)) + recorder.do_adhoc_statistics(start=period2) wait_recording_done(hass) statistic_ids = list_statistic_ids(hass) assert statistic_ids == [ @@ -871,17 +890,17 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): {"statistic_id": "sensor.test2", "unit_of_measurement": "kWh"}, {"statistic_id": "sensor.test3", "unit_of_measurement": "kWh"}, ] - stats = statistics_during_period(hass, zero) + stats = statistics_during_period(hass, period0, period="5minute") assert stats == { "sensor.test1": [ { "statistic_id": "sensor.test1", - "start": process_timestamp_to_utc_isoformat(zero), - "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), + "start": process_timestamp_to_utc_isoformat(period0), + "end": process_timestamp_to_utc_isoformat(period0_end), "max": None, "mean": None, "min": None, - "last_reset": process_timestamp_to_utc_isoformat(zero), + "last_reset": process_timestamp_to_utc_isoformat(period0), "state": approx(20.0), "sum": approx(10.0), "sum_decrease": approx(0.0), @@ -889,8 +908,8 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): }, { "statistic_id": "sensor.test1", - "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), - "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=2)), + "start": process_timestamp_to_utc_isoformat(period1), + "end": process_timestamp_to_utc_isoformat(period1_end), "max": None, "mean": None, "min": None, @@ -902,8 +921,8 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): }, { "statistic_id": "sensor.test1", - "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=2)), - "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=3)), + "start": process_timestamp_to_utc_isoformat(period2), + "end": process_timestamp_to_utc_isoformat(period2_end), "max": None, "mean": None, "min": None, @@ -917,12 +936,12 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "sensor.test2": [ { "statistic_id": "sensor.test2", - "start": process_timestamp_to_utc_isoformat(zero), - "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), + "start": process_timestamp_to_utc_isoformat(period0), + "end": process_timestamp_to_utc_isoformat(period0_end), "max": None, "mean": None, "min": None, - "last_reset": process_timestamp_to_utc_isoformat(zero), + "last_reset": process_timestamp_to_utc_isoformat(period0), "state": approx(130.0), "sum": approx(20.0), "sum_decrease": approx(0.0), @@ -930,8 +949,8 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): }, { "statistic_id": "sensor.test2", - "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), - "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=2)), + "start": process_timestamp_to_utc_isoformat(period1), + "end": process_timestamp_to_utc_isoformat(period1_end), "max": None, "mean": None, "min": None, @@ -943,8 +962,8 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): }, { "statistic_id": "sensor.test2", - "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=2)), - "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=3)), + "start": process_timestamp_to_utc_isoformat(period2), + "end": process_timestamp_to_utc_isoformat(period2_end), "max": None, "mean": None, "min": None, @@ -958,12 +977,12 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "sensor.test3": [ { "statistic_id": "sensor.test3", - "start": process_timestamp_to_utc_isoformat(zero), - "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), + "start": process_timestamp_to_utc_isoformat(period0), + "end": process_timestamp_to_utc_isoformat(period0_end), "max": None, "mean": None, "min": None, - "last_reset": process_timestamp_to_utc_isoformat(zero), + "last_reset": process_timestamp_to_utc_isoformat(period0), "state": approx(5.0 / 1000), "sum": approx(5.0 / 1000), "sum_decrease": approx(0.0 / 1000), @@ -971,8 +990,8 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): }, { "statistic_id": "sensor.test3", - "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), - "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=2)), + "start": process_timestamp_to_utc_isoformat(period1), + "end": process_timestamp_to_utc_isoformat(period1_end), "max": None, "mean": None, "min": None, @@ -984,8 +1003,8 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): }, { "statistic_id": "sensor.test3", - "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=2)), - "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=3)), + "start": process_timestamp_to_utc_isoformat(period2), + "end": process_timestamp_to_utc_isoformat(period2_end), "max": None, "mean": None, "min": None, @@ -1033,15 +1052,15 @@ def test_compile_hourly_statistics_unchanged( hist = history.get_significant_states(hass, zero, four) assert dict(states) == dict(hist) - recorder.do_adhoc_statistics(period="hourly", start=four) + recorder.do_adhoc_statistics(start=four) wait_recording_done(hass) - stats = statistics_during_period(hass, four) + stats = statistics_during_period(hass, four, period="5minute") assert stats == { "sensor.test1": [ { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(four), - "end": process_timestamp_to_utc_isoformat(four + timedelta(hours=1)), + "end": process_timestamp_to_utc_isoformat(four + timedelta(minutes=5)), "mean": approx(value), "min": approx(value), "max": approx(value), @@ -1068,15 +1087,15 @@ def test_compile_hourly_statistics_partially_unavailable(hass_recorder, caplog): hist = history.get_significant_states(hass, zero, four) assert dict(states) == dict(hist) - recorder.do_adhoc_statistics(period="hourly", start=zero) + recorder.do_adhoc_statistics(start=zero) wait_recording_done(hass) - stats = statistics_during_period(hass, zero) + stats = statistics_during_period(hass, zero, period="5minute") assert stats == { "sensor.test1": [ { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero), - "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(minutes=5)), "mean": approx(21.1864406779661), "min": approx(10.0), "max": approx(25.0), @@ -1128,15 +1147,15 @@ def test_compile_hourly_statistics_unavailable( hist = history.get_significant_states(hass, zero, four) assert dict(states) == dict(hist) - recorder.do_adhoc_statistics(period="hourly", start=four) + recorder.do_adhoc_statistics(start=four) wait_recording_done(hass) - stats = statistics_during_period(hass, four) + stats = statistics_during_period(hass, four, period="5minute") assert stats == { "sensor.test2": [ { "statistic_id": "sensor.test2", "start": process_timestamp_to_utc_isoformat(four), - "end": process_timestamp_to_utc_isoformat(four + timedelta(hours=1)), + "end": process_timestamp_to_utc_isoformat(four + timedelta(minutes=5)), "mean": approx(value), "min": approx(value), "max": approx(value), @@ -1161,7 +1180,7 @@ def test_compile_hourly_statistics_fails(hass_recorder, caplog): "homeassistant.components.sensor.recorder.compile_statistics", side_effect=Exception, ): - recorder.do_adhoc_statistics(period="hourly", start=zero) + recorder.do_adhoc_statistics(start=zero) wait_recording_done(hass) assert "Error while processing event StatisticsTask" in caplog.text @@ -1266,30 +1285,30 @@ def test_compile_hourly_statistics_changing_units_1( four, states = record_states(hass, zero, "sensor.test1", attributes) attributes["unit_of_measurement"] = "cats" four, _states = record_states( - hass, zero + timedelta(hours=1), "sensor.test1", attributes + hass, zero + timedelta(minutes=5), "sensor.test1", attributes ) states["sensor.test1"] += _states["sensor.test1"] four, _states = record_states( - hass, zero + timedelta(hours=2), "sensor.test1", attributes + hass, zero + timedelta(minutes=10), "sensor.test1", attributes ) states["sensor.test1"] += _states["sensor.test1"] hist = history.get_significant_states(hass, zero, four) assert dict(states) == dict(hist) - recorder.do_adhoc_statistics(period="hourly", start=zero) + recorder.do_adhoc_statistics(start=zero) wait_recording_done(hass) assert "does not match the unit of already compiled" not in caplog.text statistic_ids = list_statistic_ids(hass) assert statistic_ids == [ {"statistic_id": "sensor.test1", "unit_of_measurement": native_unit} ] - stats = statistics_during_period(hass, zero) + stats = statistics_during_period(hass, zero, period="5minute") assert stats == { "sensor.test1": [ { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero), - "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(minutes=5)), "mean": approx(mean), "min": approx(min), "max": approx(max), @@ -1302,7 +1321,7 @@ def test_compile_hourly_statistics_changing_units_1( ] } - recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=2)) + recorder.do_adhoc_statistics(start=zero + timedelta(minutes=10)) wait_recording_done(hass) assert ( "The unit of sensor.test1 (cats) does not match the unit of already compiled " @@ -1312,13 +1331,13 @@ def test_compile_hourly_statistics_changing_units_1( assert statistic_ids == [ {"statistic_id": "sensor.test1", "unit_of_measurement": native_unit} ] - stats = statistics_during_period(hass, zero) + stats = statistics_during_period(hass, zero, period="5minute") assert stats == { "sensor.test1": [ { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero), - "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(minutes=5)), "mean": approx(mean), "min": approx(min), "max": approx(max), @@ -1358,13 +1377,13 @@ def test_compile_hourly_statistics_changing_units_2( four, states = record_states(hass, zero, "sensor.test1", attributes) attributes["unit_of_measurement"] = "cats" four, _states = record_states( - hass, zero + timedelta(hours=1), "sensor.test1", attributes + hass, zero + timedelta(minutes=5), "sensor.test1", attributes ) states["sensor.test1"] += _states["sensor.test1"] hist = history.get_significant_states(hass, zero, four) assert dict(states) == dict(hist) - recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(minutes=30)) + recorder.do_adhoc_statistics(start=zero + timedelta(seconds=30 * 5)) wait_recording_done(hass) assert "The unit of sensor.test1 is changing" in caplog.text assert "and matches the unit of already compiled statistics" not in caplog.text @@ -1372,7 +1391,7 @@ def test_compile_hourly_statistics_changing_units_2( assert statistic_ids == [ {"statistic_id": "sensor.test1", "unit_of_measurement": "cats"} ] - stats = statistics_during_period(hass, zero) + stats = statistics_during_period(hass, zero, period="5minute") assert stats == {} assert "Error while processing event StatisticsTask" not in caplog.text @@ -1402,31 +1421,31 @@ def test_compile_hourly_statistics_changing_units_3( } four, states = record_states(hass, zero, "sensor.test1", attributes) four, _states = record_states( - hass, zero + timedelta(hours=1), "sensor.test1", attributes + hass, zero + timedelta(minutes=5), "sensor.test1", attributes ) states["sensor.test1"] += _states["sensor.test1"] attributes["unit_of_measurement"] = "cats" four, _states = record_states( - hass, zero + timedelta(hours=2), "sensor.test1", attributes + hass, zero + timedelta(minutes=10), "sensor.test1", attributes ) states["sensor.test1"] += _states["sensor.test1"] hist = history.get_significant_states(hass, zero, four) assert dict(states) == dict(hist) - recorder.do_adhoc_statistics(period="hourly", start=zero) + recorder.do_adhoc_statistics(start=zero) wait_recording_done(hass) assert "does not match the unit of already compiled" not in caplog.text statistic_ids = list_statistic_ids(hass) assert statistic_ids == [ {"statistic_id": "sensor.test1", "unit_of_measurement": native_unit} ] - stats = statistics_during_period(hass, zero) + stats = statistics_during_period(hass, zero, period="5minute") assert stats == { "sensor.test1": [ { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero), - "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(minutes=5)), "mean": approx(mean), "min": approx(min), "max": approx(max), @@ -1439,7 +1458,7 @@ def test_compile_hourly_statistics_changing_units_3( ] } - recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=2)) + recorder.do_adhoc_statistics(start=zero + timedelta(minutes=10)) wait_recording_done(hass) assert "The unit of sensor.test1 is changing" in caplog.text assert f"matches the unit of already compiled statistics ({unit})" in caplog.text @@ -1447,13 +1466,13 @@ def test_compile_hourly_statistics_changing_units_3( assert statistic_ids == [ {"statistic_id": "sensor.test1", "unit_of_measurement": native_unit} ] - stats = statistics_during_period(hass, zero) + stats = statistics_during_period(hass, zero, period="5minute") assert stats == { "sensor.test1": [ { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero), - "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(minutes=5)), "mean": approx(mean), "min": approx(min), "max": approx(max), @@ -1478,7 +1497,9 @@ def test_compile_hourly_statistics_changing_statistics( hass_recorder, caplog, device_class, unit, native_unit, mean, min, max ): """Test compiling hourly statistics where units change during an hour.""" - zero = dt_util.utcnow() + period0 = dt_util.utcnow() + period0_end = period1 = period0 + timedelta(minutes=5) + period1_end = period0 + timedelta(minutes=10) hass = hass_recorder() recorder = hass.data[DATA_INSTANCE] setup_component(hass, "sensor", {}) @@ -1492,8 +1513,8 @@ def test_compile_hourly_statistics_changing_statistics( "state_class": "total_increasing", "unit_of_measurement": unit, } - four, states = record_states(hass, zero, "sensor.test1", attributes_1) - recorder.do_adhoc_statistics(period="hourly", start=zero) + four, states = record_states(hass, period0, "sensor.test1", attributes_1) + recorder.do_adhoc_statistics(start=period0) wait_recording_done(hass) statistic_ids = list_statistic_ids(hass) assert statistic_ids == [ @@ -1508,14 +1529,12 @@ def test_compile_hourly_statistics_changing_statistics( } # Add more states, with changed state class - four, _states = record_states( - hass, zero + timedelta(hours=1), "sensor.test1", attributes_2 - ) + four, _states = record_states(hass, period1, "sensor.test1", attributes_2) states["sensor.test1"] += _states["sensor.test1"] - hist = history.get_significant_states(hass, zero, four) + hist = history.get_significant_states(hass, period0, four) assert dict(states) == dict(hist) - recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=1)) + recorder.do_adhoc_statistics(start=period1) wait_recording_done(hass) statistic_ids = list_statistic_ids(hass) assert statistic_ids == [ @@ -1528,13 +1547,13 @@ def test_compile_hourly_statistics_changing_statistics( "statistic_id": "sensor.test1", "unit_of_measurement": None, } - stats = statistics_during_period(hass, zero) + stats = statistics_during_period(hass, period0, period="5minute") assert stats == { "sensor.test1": [ { "statistic_id": "sensor.test1", - "start": process_timestamp_to_utc_isoformat(zero), - "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), + "start": process_timestamp_to_utc_isoformat(period0), + "end": process_timestamp_to_utc_isoformat(period0_end), "mean": approx(mean), "min": approx(min), "max": approx(max), @@ -1546,8 +1565,8 @@ def test_compile_hourly_statistics_changing_statistics( }, { "statistic_id": "sensor.test1", - "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), - "end": process_timestamp_to_utc_isoformat(zero + timedelta(hours=2)), + "start": process_timestamp_to_utc_isoformat(period1), + "end": process_timestamp_to_utc_isoformat(period1_end), "mean": None, "min": None, "max": None, @@ -1563,12 +1582,288 @@ def test_compile_hourly_statistics_changing_statistics( assert "Error while processing event StatisticsTask" not in caplog.text -def record_states(hass, zero, entity_id, attributes): +def test_compile_statistics_hourly_summary(hass_recorder, caplog): + """Test compiling hourly statistics.""" + zero = dt_util.utcnow() + zero = zero.replace(minute=0, second=0, microsecond=0) + # Travel to the future, recorder gets confused otherwise because states are added + # before the start of the recorder_run + zero += timedelta(hours=1) + hass = hass_recorder() + recorder = hass.data[DATA_INSTANCE] + setup_component(hass, "sensor", {}) + attributes = { + "device_class": None, + "state_class": "measurement", + "unit_of_measurement": "%", + } + + sum_attributes = { + "device_class": None, + "state_class": "total", + "unit_of_measurement": "EUR", + } + + def _weighted_average(seq, i, last_state): + total = 0 + duration = 0 + durations = [50, 200, 45] + if i > 0: + total += last_state * 5 + duration += 5 + for j, dur in enumerate(durations): + total += seq[j] * dur + duration += dur + return total / duration + + def _min(seq, last_state): + if last_state is None: + return min(seq) + return min([*seq, last_state]) + + def _max(seq, last_state): + if last_state is None: + return max(seq) + return max([*seq, last_state]) + + def _sum(seq, last_state, last_sum): + if last_state is None: + return seq[-1] - seq[0] + return last_sum[-1] + seq[-1] - last_state + + # Generate states for two hours + states = { + "sensor.test1": [], + "sensor.test2": [], + "sensor.test3": [], + "sensor.test4": [], + } + expected_minima = {"sensor.test1": [], "sensor.test2": [], "sensor.test3": []} + expected_maxima = {"sensor.test1": [], "sensor.test2": [], "sensor.test3": []} + expected_averages = {"sensor.test1": [], "sensor.test2": [], "sensor.test3": []} + expected_states = {"sensor.test4": []} + expected_sums = {"sensor.test4": []} + expected_decreases = {"sensor.test4": []} + expected_increases = {"sensor.test4": []} + last_states = { + "sensor.test1": None, + "sensor.test2": None, + "sensor.test3": None, + "sensor.test4": None, + } + start = zero + for i in range(24): + seq = [-10, 15, 30] + # test1 has same value in every period + four, _states = record_states(hass, start, "sensor.test1", attributes, seq) + states["sensor.test1"] += _states["sensor.test1"] + last_state = last_states["sensor.test1"] + expected_minima["sensor.test1"].append(_min(seq, last_state)) + expected_maxima["sensor.test1"].append(_max(seq, last_state)) + expected_averages["sensor.test1"].append(_weighted_average(seq, i, last_state)) + last_states["sensor.test1"] = seq[-1] + # test2 values change: min/max at the last state + seq = [-10 * (i + 1), 15 * (i + 1), 30 * (i + 1)] + four, _states = record_states(hass, start, "sensor.test2", attributes, seq) + states["sensor.test2"] += _states["sensor.test2"] + last_state = last_states["sensor.test2"] + expected_minima["sensor.test2"].append(_min(seq, last_state)) + expected_maxima["sensor.test2"].append(_max(seq, last_state)) + expected_averages["sensor.test2"].append(_weighted_average(seq, i, last_state)) + last_states["sensor.test2"] = seq[-1] + # test3 values change: min/max at the first state + seq = [-10 * (23 - i + 1), 15 * (23 - i + 1), 30 * (23 - i + 1)] + four, _states = record_states(hass, start, "sensor.test3", attributes, seq) + states["sensor.test3"] += _states["sensor.test3"] + last_state = last_states["sensor.test3"] + expected_minima["sensor.test3"].append(_min(seq, last_state)) + expected_maxima["sensor.test3"].append(_max(seq, last_state)) + expected_averages["sensor.test3"].append(_weighted_average(seq, i, last_state)) + last_states["sensor.test3"] = seq[-1] + # test4 values grow + seq = [i, i + 0.5, i + 0.75] + start_meter = start + for j in range(len(seq)): + _states = record_meter_state( + hass, start_meter, "sensor.test4", sum_attributes, seq[j : j + 1] + ) + start_meter = start + timedelta(minutes=1) + states["sensor.test4"] += _states["sensor.test4"] + last_state = last_states["sensor.test4"] + expected_states["sensor.test4"].append(seq[-1]) + expected_sums["sensor.test4"].append( + _sum(seq, last_state, expected_sums["sensor.test4"]) + ) + expected_decreases["sensor.test4"].append(0) + expected_increases["sensor.test4"].append( + _sum(seq, last_state, expected_increases["sensor.test4"]) + ) + last_states["sensor.test4"] = seq[-1] + + start += timedelta(minutes=5) + hist = history.get_significant_states( + hass, zero - timedelta.resolution, four, significant_changes_only=False + ) + assert dict(states) == dict(hist) + wait_recording_done(hass) + + # Generate 5-minute statistics for two hours + start = zero + for i in range(24): + recorder.do_adhoc_statistics(start=start) + wait_recording_done(hass) + start += timedelta(minutes=5) + + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + {"statistic_id": "sensor.test1", "unit_of_measurement": "%"}, + {"statistic_id": "sensor.test2", "unit_of_measurement": "%"}, + {"statistic_id": "sensor.test3", "unit_of_measurement": "%"}, + {"statistic_id": "sensor.test4", "unit_of_measurement": "EUR"}, + ] + + stats = statistics_during_period(hass, zero, period="5minute") + expected_stats = { + "sensor.test1": [], + "sensor.test2": [], + "sensor.test3": [], + "sensor.test4": [], + } + start = zero + end = zero + timedelta(minutes=5) + for i in range(24): + for entity_id in [ + "sensor.test1", + "sensor.test2", + "sensor.test3", + "sensor.test4", + ]: + expected_average = ( + expected_averages[entity_id][i] + if entity_id in expected_averages + else None + ) + expected_minimum = ( + expected_minima[entity_id][i] if entity_id in expected_minima else None + ) + expected_maximum = ( + expected_maxima[entity_id][i] if entity_id in expected_maxima else None + ) + expected_state = ( + expected_states[entity_id][i] if entity_id in expected_states else None + ) + expected_sum = ( + expected_sums[entity_id][i] if entity_id in expected_sums else None + ) + expected_decrease = ( + expected_decreases[entity_id][i] + if entity_id in expected_decreases + else None + ) + expected_increase = ( + expected_increases[entity_id][i] + if entity_id in expected_increases + else None + ) + expected_stats[entity_id].append( + { + "statistic_id": entity_id, + "start": process_timestamp_to_utc_isoformat(start), + "end": process_timestamp_to_utc_isoformat(end), + "mean": approx(expected_average), + "min": approx(expected_minimum), + "max": approx(expected_maximum), + "last_reset": None, + "state": expected_state, + "sum": expected_sum, + "sum_decrease": expected_decrease, + "sum_increase": expected_increase, + } + ) + start += timedelta(minutes=5) + end += timedelta(minutes=5) + assert stats == expected_stats + + stats = statistics_during_period(hass, zero, period="hour") + expected_stats = { + "sensor.test1": [], + "sensor.test2": [], + "sensor.test3": [], + "sensor.test4": [], + } + start = zero + end = zero + timedelta(hours=1) + for i in range(2): + for entity_id in [ + "sensor.test1", + "sensor.test2", + "sensor.test3", + "sensor.test4", + ]: + expected_average = ( + mean(expected_averages[entity_id][i * 12 : (i + 1) * 12]) + if entity_id in expected_averages + else None + ) + expected_minimum = ( + min(expected_minima[entity_id][i * 12 : (i + 1) * 12]) + if entity_id in expected_minima + else None + ) + expected_maximum = ( + max(expected_maxima[entity_id][i * 12 : (i + 1) * 12]) + if entity_id in expected_maxima + else None + ) + expected_state = ( + expected_states[entity_id][(i + 1) * 12 - 1] + if entity_id in expected_states + else None + ) + expected_sum = ( + expected_sums[entity_id][(i + 1) * 12 - 1] + if entity_id in expected_sums + else None + ) + expected_decrease = ( + expected_decreases[entity_id][(i + 1) * 12 - 1] + if entity_id in expected_decreases + else None + ) + expected_increase = ( + expected_increases[entity_id][(i + 1) * 12 - 1] + if entity_id in expected_increases + else None + ) + expected_stats[entity_id].append( + { + "statistic_id": entity_id, + "start": process_timestamp_to_utc_isoformat(start), + "end": process_timestamp_to_utc_isoformat(end), + "mean": approx(expected_average), + "min": approx(expected_minimum), + "max": approx(expected_maximum), + "last_reset": None, + "state": expected_state, + "sum": expected_sum, + "sum_decrease": expected_decrease, + "sum_increase": expected_increase, + } + ) + start += timedelta(hours=1) + end += timedelta(hours=1) + assert stats == expected_stats + assert "Error while processing event StatisticsTask" not in caplog.text + + +def record_states(hass, zero, entity_id, attributes, seq=None): """Record some test states. We inject a bunch of state updates for measurement sensors. """ attributes = dict(attributes) + if seq is None: + seq = [-10, 15, 30] def set_state(entity_id, state, **kwargs): """Set the state.""" @@ -1576,20 +1871,26 @@ def record_states(hass, zero, entity_id, attributes): wait_recording_done(hass) return hass.states.get(entity_id) - one = zero + timedelta(minutes=1) - two = one + timedelta(minutes=10) - three = two + timedelta(minutes=40) - four = three + timedelta(minutes=10) + one = zero + timedelta(seconds=1 * 5) + two = one + timedelta(seconds=10 * 5) + three = two + timedelta(seconds=40 * 5) + four = three + timedelta(seconds=10 * 5) states = {entity_id: []} with patch("homeassistant.components.recorder.dt_util.utcnow", return_value=one): - states[entity_id].append(set_state(entity_id, "-10", attributes=attributes)) + states[entity_id].append( + set_state(entity_id, str(seq[0]), attributes=attributes) + ) with patch("homeassistant.components.recorder.dt_util.utcnow", return_value=two): - states[entity_id].append(set_state(entity_id, "15", attributes=attributes)) + states[entity_id].append( + set_state(entity_id, str(seq[1]), attributes=attributes) + ) with patch("homeassistant.components.recorder.dt_util.utcnow", return_value=three): - states[entity_id].append(set_state(entity_id, "30", attributes=attributes)) + states[entity_id].append( + set_state(entity_id, str(seq[2]), attributes=attributes) + ) return four, states @@ -1606,14 +1907,14 @@ def record_meter_states(hass, zero, entity_id, _attributes, seq): wait_recording_done(hass) return hass.states.get(entity_id) - one = zero + timedelta(minutes=15) - two = one + timedelta(minutes=30) - three = two + timedelta(minutes=15) - four = three + timedelta(minutes=15) - five = four + timedelta(minutes=30) - six = five + timedelta(minutes=15) - seven = six + timedelta(minutes=15) - eight = seven + timedelta(minutes=30) + one = zero + timedelta(seconds=15 * 5) # 00:01:15 + two = one + timedelta(seconds=30 * 5) # 00:03:45 + three = two + timedelta(seconds=15 * 5) # 00:05:00 + four = three + timedelta(seconds=15 * 5) # 00:06:15 + five = four + timedelta(seconds=30 * 5) # 00:08:45 + six = five + timedelta(seconds=15 * 5) # 00:10:00 + seven = six + timedelta(seconds=15 * 5) # 00:11:45 + eight = seven + timedelta(seconds=30 * 5) # 00:13:45 attributes = dict(_attributes) if "last_reset" in _attributes: @@ -1667,7 +1968,8 @@ def record_meter_state(hass, zero, entity_id, _attributes, seq): return hass.states.get(entity_id) attributes = dict(_attributes) - attributes["last_reset"] = zero.isoformat() + if "last_reset" in _attributes: + attributes["last_reset"] = zero.isoformat() states = {entity_id: []} with patch("homeassistant.components.recorder.dt_util.utcnow", return_value=zero): @@ -1688,10 +1990,10 @@ def record_states_partially_unavailable(hass, zero, entity_id, attributes): wait_recording_done(hass) return hass.states.get(entity_id) - one = zero + timedelta(minutes=1) - two = one + timedelta(minutes=15) - three = two + timedelta(minutes=30) - four = three + timedelta(minutes=15) + one = zero + timedelta(seconds=1 * 5) + two = one + timedelta(seconds=15 * 5) + three = two + timedelta(seconds=30 * 5) + four = three + timedelta(seconds=15 * 5) states = {entity_id: []} with patch("homeassistant.components.recorder.dt_util.utcnow", return_value=one): diff --git a/tests/conftest.py b/tests/conftest.py index 0b3db0bf832..9ee6bbc680b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -636,9 +636,9 @@ def enable_statistics(): def hass_recorder(enable_statistics, hass_storage): """Home Assistant fixture with in-memory recorder.""" hass = get_test_home_assistant() - stats = recorder.Recorder.async_hourly_statistics if enable_statistics else None + stats = recorder.Recorder.async_periodic_statistics if enable_statistics else None with patch( - "homeassistant.components.recorder.Recorder.async_hourly_statistics", + "homeassistant.components.recorder.Recorder.async_periodic_statistics", side_effect=stats, autospec=True, ): From c668dcb1acd73940bc021b65ae2cafd419d21a19 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 16 Sep 2021 11:07:14 +0200 Subject: [PATCH 417/843] Allow smaller step size for input number (#56211) * Allow smaller step size for input number * Tweak * Tweak --- homeassistant/components/input_number/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/input_number/__init__.py b/homeassistant/components/input_number/__init__.py index 9c0cc202cfa..89a487d630f 100644 --- a/homeassistant/components/input_number/__init__.py +++ b/homeassistant/components/input_number/__init__.py @@ -66,7 +66,7 @@ CREATE_FIELDS = { vol.Required(CONF_MIN): vol.Coerce(float), vol.Required(CONF_MAX): vol.Coerce(float), vol.Optional(CONF_INITIAL): vol.Coerce(float), - vol.Optional(CONF_STEP, default=1): vol.All(vol.Coerce(float), vol.Range(min=1e-3)), + vol.Optional(CONF_STEP, default=1): vol.All(vol.Coerce(float), vol.Range(min=1e-9)), vol.Optional(CONF_ICON): cv.icon, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, vol.Optional(CONF_MODE, default=MODE_SLIDER): vol.In([MODE_BOX, MODE_SLIDER]), @@ -77,7 +77,7 @@ UPDATE_FIELDS = { vol.Optional(CONF_MIN): vol.Coerce(float), vol.Optional(CONF_MAX): vol.Coerce(float), vol.Optional(CONF_INITIAL): vol.Coerce(float), - vol.Optional(CONF_STEP): vol.All(vol.Coerce(float), vol.Range(min=1e-3)), + vol.Optional(CONF_STEP): vol.All(vol.Coerce(float), vol.Range(min=1e-9)), vol.Optional(CONF_ICON): cv.icon, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, vol.Optional(CONF_MODE): vol.In([MODE_BOX, MODE_SLIDER]), @@ -93,7 +93,7 @@ CONFIG_SCHEMA = vol.Schema( vol.Required(CONF_MAX): vol.Coerce(float), vol.Optional(CONF_INITIAL): vol.Coerce(float), vol.Optional(CONF_STEP, default=1): vol.All( - vol.Coerce(float), vol.Range(min=1e-3) + vol.Coerce(float), vol.Range(min=1e-9) ), vol.Optional(CONF_ICON): cv.icon, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, From 0438c9308ca6810f066353147a930ad5b50e4b35 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Thu, 16 Sep 2021 06:31:36 -0500 Subject: [PATCH 418/843] Delay startup for `cert_expiry` to allow for self checks (#56266) * Delay startup of cert_expiry * Update tests --- .../components/cert_expiry/__init__.py | 11 +++-- tests/components/cert_expiry/test_init.py | 2 + tests/components/cert_expiry/test_sensors.py | 43 +++++-------------- 3 files changed, 21 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/cert_expiry/__init__.py b/homeassistant/components/cert_expiry/__init__.py index babf81048df..22339b9f4c4 100644 --- a/homeassistant/components/cert_expiry/__init__.py +++ b/homeassistant/components/cert_expiry/__init__.py @@ -6,7 +6,7 @@ import logging from typing import Optional from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STARTED from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -27,7 +27,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: port = entry.data[CONF_PORT] coordinator = CertExpiryDataUpdateCoordinator(hass, host, port) - await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = coordinator @@ -35,7 +34,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if entry.unique_id is None: hass.config_entries.async_update_entry(entry, unique_id=f"{host}:{port}") - hass.config_entries.async_setup_platforms(entry, PLATFORMS) + async def async_finish_startup(_): + await coordinator.async_refresh() + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, async_finish_startup) + ) return True diff --git a/tests/components/cert_expiry/test_init.py b/tests/components/cert_expiry/test_init.py index dbe5e74d891..25771c39250 100644 --- a/tests/components/cert_expiry/test_init.py +++ b/tests/components/cert_expiry/test_init.py @@ -9,6 +9,7 @@ from homeassistant.const import ( CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STARTED, STATE_UNAVAILABLE, ) from homeassistant.setup import async_setup_component @@ -87,6 +88,7 @@ async def test_unload_config_entry(mock_now, hass): return_value=timestamp, ): assert await async_setup_component(hass, DOMAIN, {}) is True + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() assert entry.state is ConfigEntryState.LOADED diff --git a/tests/components/cert_expiry/test_sensors.py b/tests/components/cert_expiry/test_sensors.py index 099fe78ca39..0ca228dc344 100644 --- a/tests/components/cert_expiry/test_sensors.py +++ b/tests/components/cert_expiry/test_sensors.py @@ -5,8 +5,13 @@ import ssl from unittest.mock import patch from homeassistant.components.cert_expiry.const import DOMAIN -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_HOST, CONF_PORT, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.const import ( + CONF_HOST, + CONF_PORT, + EVENT_HOMEASSISTANT_STARTED, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) from homeassistant.util.dt import utcnow from .const import HOST, PORT @@ -32,6 +37,7 @@ async def test_async_setup_entry(mock_now, hass): ): entry.add_to_hass(hass) assert await hass.config_entries.async_setup(entry.entry_id) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() state = hass.states.get("sensor.cert_expiry_timestamp_example_com") @@ -56,6 +62,7 @@ async def test_async_setup_entry_bad_cert(hass): ): entry.add_to_hass(hass) assert await hass.config_entries.async_setup(entry.entry_id) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() state = hass.states.get("sensor.cert_expiry_timestamp_example_com") @@ -65,36 +72,6 @@ async def test_async_setup_entry_bad_cert(hass): assert not state.attributes.get("is_valid") -async def test_async_setup_entry_host_unavailable(hass): - """Test async_setup_entry when host is unavailable.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_HOST: HOST, CONF_PORT: PORT}, - unique_id=f"{HOST}:{PORT}", - ) - - with patch( - "homeassistant.components.cert_expiry.helper.get_cert", - side_effect=socket.gaierror, - ): - entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(entry.entry_id) is False - await hass.async_block_till_done() - - assert entry.state is ConfigEntryState.SETUP_RETRY - - next_update = utcnow() + timedelta(seconds=45) - async_fire_time_changed(hass, next_update) - with patch( - "homeassistant.components.cert_expiry.helper.get_cert", - side_effect=socket.gaierror, - ): - await hass.async_block_till_done() - - state = hass.states.get("sensor.cert_expiry_timestamp_example_com") - assert state is None - - async def test_update_sensor(hass): """Test async_update for sensor.""" entry = MockConfigEntry( @@ -112,6 +89,7 @@ async def test_update_sensor(hass): ): entry.add_to_hass(hass) assert await hass.config_entries.async_setup(entry.entry_id) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() state = hass.states.get("sensor.cert_expiry_timestamp_example_com") @@ -154,6 +132,7 @@ async def test_update_sensor_network_errors(hass): ): entry.add_to_hass(hass) assert await hass.config_entries.async_setup(entry.entry_id) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() state = hass.states.get("sensor.cert_expiry_timestamp_example_com") From 28b4b5407b5ecefeaaf6b08a068974a8289f9931 Mon Sep 17 00:00:00 2001 From: Paul Owen Date: Thu, 16 Sep 2021 12:57:42 +0100 Subject: [PATCH 419/843] Fix return value of preset_mode in hive climate (#56247) --- homeassistant/components/hive/climate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/hive/climate.py b/homeassistant/components/hive/climate.py index 7639a07c82a..80a6bb0941e 100644 --- a/homeassistant/components/hive/climate.py +++ b/homeassistant/components/hive/climate.py @@ -194,7 +194,7 @@ class HiveClimateEntity(HiveEntity, ClimateEntity): """Return the current preset mode, e.g., home, away, temp.""" if self.device["status"]["boost"] == "ON": return PRESET_BOOST - return None + return PRESET_NONE @property def preset_modes(self): From 1609d069bb394f6e73c902274a726b0dcdadf428 Mon Sep 17 00:00:00 2001 From: Chris Browet Date: Thu, 16 Sep 2021 16:07:53 +0200 Subject: [PATCH 420/843] Fix Meteoalarm expired alerts (#56255) --- .../components/meteoalarm/binary_sensor.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/meteoalarm/binary_sensor.py b/homeassistant/components/meteoalarm/binary_sensor.py index 6d237c696f6..ce0fa97ecb9 100644 --- a/homeassistant/components/meteoalarm/binary_sensor.py +++ b/homeassistant/components/meteoalarm/binary_sensor.py @@ -12,6 +12,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME import homeassistant.helpers.config_validation as cv +import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -85,10 +86,14 @@ class MeteoAlertBinarySensor(BinarySensorEntity): def update(self): """Update device state.""" + self._attributes = {} + self._state = False + alert = self._api.get_alert() if alert: - self._attributes = alert - self._state = True - else: - self._attributes = {} - self._state = False + expiration_date = dt_util.parse_datetime(alert["expires"]) + now = dt_util.utcnow() + + if expiration_date > now: + self._attributes = alert + self._state = True From 8418d4ade284d5ee26c4e93ba9b889919adec2f0 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Thu, 16 Sep 2021 18:06:58 +0300 Subject: [PATCH 421/843] Address Switcher late review comments (#56264) * Address Switcher late review comments * Rename wrapper to coordinator --- .../components/switcher_kis/__init__.py | 37 +++++++++++-------- .../components/switcher_kis/sensor.py | 27 +++++++------- .../components/switcher_kis/switch.py | 29 +++++++-------- .../switcher_kis/test_config_flow.py | 6 ++- 4 files changed, 54 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/switcher_kis/__init__.py b/homeassistant/components/switcher_kis/__init__.py index 6a23f1bb453..7be726388f0 100644 --- a/homeassistant/components/switcher_kis/__init__.py +++ b/homeassistant/components/switcher_kis/__init__.py @@ -1,6 +1,7 @@ """The Switcher integration.""" from __future__ import annotations +import asyncio from datetime import timedelta import logging @@ -75,10 +76,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Existing device update device data if device.device_id in hass.data[DOMAIN][DATA_DEVICE]: - wrapper: SwitcherDeviceWrapper = hass.data[DOMAIN][DATA_DEVICE][ + coordinator: SwitcherDataUpdateCoordinator = hass.data[DOMAIN][DATA_DEVICE][ device.device_id ] - wrapper.async_set_updated_data(device) + coordinator.async_set_updated_data(device) return # New device - create device @@ -90,15 +91,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: device.device_type.hex_rep, ) - wrapper = hass.data[DOMAIN][DATA_DEVICE][ + coordinator = hass.data[DOMAIN][DATA_DEVICE][ device.device_id - ] = SwitcherDeviceWrapper(hass, entry, device) - hass.async_create_task(wrapper.async_setup()) + ] = SwitcherDataUpdateCoordinator(hass, entry, device) + coordinator.async_setup() async def platforms_setup_task() -> None: # Must be ready before dispatcher is called - for platform in PLATFORMS: - await hass.config_entries.async_forward_entry_setup(entry, platform) + await asyncio.gather( + *( + hass.config_entries.async_forward_entry_setup(entry, platform) + for platform in PLATFORMS + ) + ) discovery_task = hass.data[DOMAIN].pop(DATA_DISCOVERY, None) if discovery_task is not None: @@ -114,25 +119,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def stop_bridge(event: Event) -> None: await async_stop_bridge(hass) - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_bridge) + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_bridge) + ) return True -class SwitcherDeviceWrapper(update_coordinator.DataUpdateCoordinator): - """Wrapper for a Switcher device with Home Assistant specific functions.""" +class SwitcherDataUpdateCoordinator(update_coordinator.DataUpdateCoordinator): + """Switcher device data update coordinator.""" def __init__( self, hass: HomeAssistant, entry: ConfigEntry, device: SwitcherBase ) -> None: - """Initialize the Switcher device wrapper.""" + """Initialize the Switcher device coordinator.""" super().__init__( hass, _LOGGER, name=device.name, update_interval=timedelta(seconds=MAX_UPDATE_INTERVAL_SEC), ) - self.hass = hass self.entry = entry self.data = device @@ -157,9 +163,10 @@ class SwitcherDeviceWrapper(update_coordinator.DataUpdateCoordinator): """Switcher device mac address.""" return self.data.mac_address # type: ignore[no-any-return] - async def async_setup(self) -> None: - """Set up the wrapper.""" - dev_reg = await device_registry.async_get_registry(self.hass) + @callback + def async_setup(self) -> None: + """Set up the coordinator.""" + dev_reg = device_registry.async_get(self.hass) dev_reg.async_get_or_create( config_entry_id=self.entry.entry_id, connections={(device_registry.CONNECTION_NETWORK_MAC, self.mac_address)}, diff --git a/homeassistant/components/switcher_kis/sensor.py b/homeassistant/components/switcher_kis/sensor.py index e070bd52d0d..d694bfee380 100644 --- a/homeassistant/components/switcher_kis/sensor.py +++ b/homeassistant/components/switcher_kis/sensor.py @@ -20,7 +20,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import SwitcherDeviceWrapper +from . import SwitcherDataUpdateCoordinator from .const import SIGNAL_DEVICE_ADD @@ -75,16 +75,16 @@ async def async_setup_entry( """Set up Switcher sensor from config entry.""" @callback - def async_add_sensors(wrapper: SwitcherDeviceWrapper) -> None: + def async_add_sensors(coordinator: SwitcherDataUpdateCoordinator) -> None: """Add sensors from Switcher device.""" - if wrapper.data.device_type.category == DeviceCategory.POWER_PLUG: + if coordinator.data.device_type.category == DeviceCategory.POWER_PLUG: async_add_entities( - SwitcherSensorEntity(wrapper, attribute, info) + SwitcherSensorEntity(coordinator, attribute, info) for attribute, info in POWER_PLUG_SENSORS.items() ) - elif wrapper.data.device_type.category == DeviceCategory.WATER_HEATER: + elif coordinator.data.device_type.category == DeviceCategory.WATER_HEATER: async_add_entities( - SwitcherSensorEntity(wrapper, attribute, info) + SwitcherSensorEntity(coordinator, attribute, info) for attribute, info in WATER_HEATER_SENSORS.items() ) @@ -98,30 +98,31 @@ class SwitcherSensorEntity(CoordinatorEntity, SensorEntity): def __init__( self, - wrapper: SwitcherDeviceWrapper, + coordinator: SwitcherDataUpdateCoordinator, attribute: str, description: AttributeDescription, ) -> None: """Initialize the entity.""" - super().__init__(wrapper) - self.wrapper = wrapper + super().__init__(coordinator) self.attribute = attribute # Entity class attributes - self._attr_name = f"{wrapper.name} {description.name}" + self._attr_name = f"{coordinator.name} {description.name}" self._attr_icon = description.icon self._attr_native_unit_of_measurement = description.unit self._attr_device_class = description.device_class self._attr_entity_registry_enabled_default = description.default_enabled - self._attr_unique_id = f"{wrapper.device_id}-{wrapper.mac_address}-{attribute}" + self._attr_unique_id = ( + f"{coordinator.device_id}-{coordinator.mac_address}-{attribute}" + ) self._attr_device_info = { "connections": { - (device_registry.CONNECTION_NETWORK_MAC, wrapper.mac_address) + (device_registry.CONNECTION_NETWORK_MAC, coordinator.mac_address) } } @property def native_value(self) -> StateType: """Return value of sensor.""" - return getattr(self.wrapper.data, self.attribute) # type: ignore[no-any-return] + return getattr(self.coordinator.data, self.attribute) # type: ignore[no-any-return] diff --git a/homeassistant/components/switcher_kis/switch.py b/homeassistant/components/switcher_kis/switch.py index 0eeeb881f45..8b93e422e2a 100644 --- a/homeassistant/components/switcher_kis/switch.py +++ b/homeassistant/components/switcher_kis/switch.py @@ -26,7 +26,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import SwitcherDeviceWrapper +from . import SwitcherDataUpdateCoordinator from .const import ( CONF_AUTO_OFF, CONF_TIMER_MINUTES, @@ -69,12 +69,12 @@ async def async_setup_entry( ) @callback - def async_add_switch(wrapper: SwitcherDeviceWrapper) -> None: + def async_add_switch(coordinator: SwitcherDataUpdateCoordinator) -> None: """Add switch from Switcher device.""" - if wrapper.data.device_type.category == DeviceCategory.POWER_PLUG: - async_add_entities([SwitcherPowerPlugSwitchEntity(wrapper)]) - elif wrapper.data.device_type.category == DeviceCategory.WATER_HEATER: - async_add_entities([SwitcherWaterHeaterSwitchEntity(wrapper)]) + if coordinator.data.device_type.category == DeviceCategory.POWER_PLUG: + async_add_entities([SwitcherPowerPlugSwitchEntity(coordinator)]) + elif coordinator.data.device_type.category == DeviceCategory.WATER_HEATER: + async_add_entities([SwitcherWaterHeaterSwitchEntity(coordinator)]) config_entry.async_on_unload( async_dispatcher_connect(hass, SIGNAL_DEVICE_ADD, async_add_switch) @@ -84,18 +84,17 @@ async def async_setup_entry( class SwitcherBaseSwitchEntity(CoordinatorEntity, SwitchEntity): """Representation of a Switcher switch entity.""" - def __init__(self, wrapper: SwitcherDeviceWrapper) -> None: + def __init__(self, coordinator: SwitcherDataUpdateCoordinator) -> None: """Initialize the entity.""" - super().__init__(wrapper) - self.wrapper = wrapper + super().__init__(coordinator) self.control_result: bool | None = None # Entity class attributes - self._attr_name = wrapper.name - self._attr_unique_id = f"{wrapper.device_id}-{wrapper.mac_address}" + self._attr_name = coordinator.name + self._attr_unique_id = f"{coordinator.device_id}-{coordinator.mac_address}" self._attr_device_info = { "connections": { - (device_registry.CONNECTION_NETWORK_MAC, wrapper.mac_address) + (device_registry.CONNECTION_NETWORK_MAC, coordinator.mac_address) } } @@ -113,7 +112,7 @@ class SwitcherBaseSwitchEntity(CoordinatorEntity, SwitchEntity): try: async with SwitcherApi( - self.wrapper.data.ip_address, self.wrapper.device_id + self.coordinator.data.ip_address, self.coordinator.data.device_id ) as swapi: response = await getattr(swapi, api)(*args) except (asyncio.TimeoutError, OSError, RuntimeError) as err: @@ -127,7 +126,7 @@ class SwitcherBaseSwitchEntity(CoordinatorEntity, SwitchEntity): args, response or error, ) - self.wrapper.last_update_success = False + self.coordinator.last_update_success = False @property def is_on(self) -> bool: @@ -135,7 +134,7 @@ class SwitcherBaseSwitchEntity(CoordinatorEntity, SwitchEntity): if self.control_result is not None: return self.control_result - return bool(self.wrapper.data.device_state == DeviceState.ON) + return bool(self.coordinator.data.device_state == DeviceState.ON) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" diff --git a/tests/components/switcher_kis/test_config_flow.py b/tests/components/switcher_kis/test_config_flow.py index 07a2396a0d9..2029e4a8ef3 100644 --- a/tests/components/switcher_kis/test_config_flow.py +++ b/tests/components/switcher_kis/test_config_flow.py @@ -44,10 +44,11 @@ async def test_import(hass): ) async def test_user_setup(hass, mock_bridge): """Test we can finish a config flow.""" - with patch("homeassistant.components.switcher_kis.utils.asyncio.sleep"): + with patch("homeassistant.components.switcher_kis.utils.DISCOVERY_TIME_SEC", 0): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + await hass.async_block_till_done() assert mock_bridge.is_running is False assert len(hass.data[DOMAIN][DATA_DISCOVERY].result()) == 2 @@ -68,10 +69,11 @@ async def test_user_setup(hass, mock_bridge): async def test_user_setup_abort_no_devices_found(hass, mock_bridge): """Test we abort a config flow if no devices found.""" - with patch("homeassistant.components.switcher_kis.utils.asyncio.sleep"): + with patch("homeassistant.components.switcher_kis.utils.DISCOVERY_TIME_SEC", 0): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + await hass.async_block_till_done() assert mock_bridge.is_running is False assert len(hass.data[DOMAIN][DATA_DISCOVERY].result()) == 0 From 41bf1eb610aa8475fc0cdf604c37b0ecd9c1dd22 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 16 Sep 2021 18:19:41 +0200 Subject: [PATCH 422/843] Fetch the data a second time when -9999 error occurs in Xiaomi Miio integration (#56288) --- homeassistant/components/xiaomi_miio/__init__.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/xiaomi_miio/__init__.py b/homeassistant/components/xiaomi_miio/__init__.py index de28ae78701..a1ea7565dab 100644 --- a/homeassistant/components/xiaomi_miio/__init__.py +++ b/homeassistant/components/xiaomi_miio/__init__.py @@ -175,12 +175,23 @@ async def async_create_miio_device_and_coordinator( async def async_update_data(): """Fetch data from the device using async_add_executor_job.""" - try: + + async def _async_fetch_data(): + """Fetch data from the device.""" async with async_timeout.timeout(10): state = await hass.async_add_executor_job(device.status) _LOGGER.debug("Got new state: %s", state) return state + try: + return await _async_fetch_data() + except DeviceException as ex: + if getattr(ex, "code", None) != -9999: + raise UpdateFailed(ex) from ex + _LOGGER.info("Got exception while fetching the state, trying again: %s", ex) + # Try to fetch the data a second time after error code -9999 + try: + return await _async_fetch_data() except DeviceException as ex: raise UpdateFailed(ex) from ex From 15a7fe219df2f2dc68bd5be70db1cc2220adb0ac Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 16 Sep 2021 19:01:02 +0200 Subject: [PATCH 423/843] Bump pychromecast to 9.2.1 (#56296) --- homeassistant/components/cast/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/cast/manifest.json b/homeassistant/components/cast/manifest.json index 78f7bcf485c..092d122d5cf 100644 --- a/homeassistant/components/cast/manifest.json +++ b/homeassistant/components/cast/manifest.json @@ -3,7 +3,7 @@ "name": "Google Cast", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/cast", - "requirements": ["pychromecast==9.2.0"], + "requirements": ["pychromecast==9.2.1"], "after_dependencies": [ "cloud", "http", diff --git a/requirements_all.txt b/requirements_all.txt index f65f5dc9734..74681a2f5fa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1382,7 +1382,7 @@ pycfdns==1.2.1 pychannels==1.0.0 # homeassistant.components.cast -pychromecast==9.2.0 +pychromecast==9.2.1 # homeassistant.components.pocketcasts pycketcasts==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e5f8275c01e..1a6bd584a13 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -800,7 +800,7 @@ pybotvac==0.0.22 pycfdns==1.2.1 # homeassistant.components.cast -pychromecast==9.2.0 +pychromecast==9.2.1 # homeassistant.components.climacell pyclimacell==0.18.2 From 94f06f86cf426bd5469ee19f615c317dfcb045a8 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Thu, 16 Sep 2021 19:05:08 +0200 Subject: [PATCH 424/843] Activate mypy for gpmdp. (#55967) --- homeassistant/components/gpmdp/media_player.py | 5 ++++- mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/gpmdp/media_player.py b/homeassistant/components/gpmdp/media_player.py index 5680eb75500..0a26a514323 100644 --- a/homeassistant/components/gpmdp/media_player.py +++ b/homeassistant/components/gpmdp/media_player.py @@ -1,8 +1,11 @@ """Support for Google Play Music Desktop Player.""" +from __future__ import annotations + import json import logging import socket import time +from typing import Any import voluptuous as vol from websocket import _exceptions, create_connection @@ -28,7 +31,7 @@ from homeassistant.const import ( import homeassistant.helpers.config_validation as cv from homeassistant.util.json import load_json, save_json -_CONFIGURING = {} +_CONFIGURING: dict[str, Any] = {} _LOGGER = logging.getLogger(__name__) DEFAULT_HOST = "localhost" diff --git a/mypy.ini b/mypy.ini index 22c50199acd..e8524d236b3 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1370,9 +1370,6 @@ ignore_errors = true [mypy-homeassistant.components.google_assistant.*] ignore_errors = true -[mypy-homeassistant.components.gpmdp.*] -ignore_errors = true - [mypy-homeassistant.components.gree.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 53513a9c37e..fc6b12ac70e 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -38,7 +38,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.geniushub.*", "homeassistant.components.glances.*", "homeassistant.components.google_assistant.*", - "homeassistant.components.gpmdp.*", "homeassistant.components.gree.*", "homeassistant.components.growatt_server.*", "homeassistant.components.habitica.*", From 70eb519f768011f7e83b2dd728b404922d764e2d Mon Sep 17 00:00:00 2001 From: jan iversen Date: Thu, 16 Sep 2021 20:05:00 +0200 Subject: [PATCH 425/843] Update template/test_light.py to use pytest (#56300) --- tests/components/template/conftest.py | 14 +- tests/components/template/test_light.py | 1598 +++++++++-------------- 2 files changed, 658 insertions(+), 954 deletions(-) diff --git a/tests/components/template/conftest.py b/tests/components/template/conftest.py index 5ccc9e6479a..410ce21e4c6 100644 --- a/tests/components/template/conftest.py +++ b/tests/components/template/conftest.py @@ -1,4 +1,6 @@ """template conftest.""" +import json + import pytest from homeassistant.setup import async_setup_component @@ -13,8 +15,18 @@ def calls(hass): @pytest.fixture -async def start_ha(hass, count, domain, config, caplog): +def config_addon(): + """Add entra configuration items.""" + return None + + +@pytest.fixture +async def start_ha(hass, count, domain, config_addon, config, caplog): """Do setup of integration.""" + if config_addon: + for key, value in config_addon.items(): + config = config.replace(key, value) + config = json.loads(config) with assert_setup_component(count, domain): assert await async_setup_component( hass, diff --git a/tests/components/template/test_light.py b/tests/components/template/test_light.py index b2eb5f06417..bbf866c78a3 100644 --- a/tests/components/template/test_light.py +++ b/tests/components/template/test_light.py @@ -3,7 +3,6 @@ import logging import pytest -from homeassistant import setup import homeassistant.components.light as light from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -23,320 +22,234 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) -from tests.common import assert_setup_component, async_mock_service - _LOGGER = logging.getLogger(__name__) # Represent for light's availability _STATE_AVAILABILITY_BOOLEAN = "availability_boolean.state" -@pytest.fixture(name="calls") -def fixture_calls(hass): - """Track calls to a mock service.""" - return async_mock_service(hass, "test", "automation") - - -async def test_template_state_invalid(hass): - """Test template state with render error.""" - with assert_setup_component(1, light.DOMAIN): - assert await setup.async_setup_component( - hass, - light.DOMAIN, - { - "light": { - "platform": "template", - "lights": { - "test_template_light": { - "value_template": "{{states.test['big.fat...']}}", - "turn_on": { - "service": "light.turn_on", - "entity_id": "light.test_state", - }, - "turn_off": { - "service": "light.turn_off", - "entity_id": "light.test_state", - }, - "set_level": { - "service": "light.turn_on", - "data_template": { - "entity_id": "light.test_state", - "brightness": "{{brightness}}", - }, - }, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - state = hass.states.get("light.test_template_light") - assert state.state == STATE_OFF - - -async def test_template_state_text(hass): - """Test the state text of a template.""" - with assert_setup_component(1, light.DOMAIN): - assert await setup.async_setup_component( - hass, - light.DOMAIN, - { - "light": { - "platform": "template", - "lights": { - "test_template_light": { - "value_template": "{{ states.light.test_state.state }}", - "turn_on": { - "service": "light.turn_on", - "entity_id": "light.test_state", - }, - "turn_off": { - "service": "light.turn_off", - "entity_id": "light.test_state", - }, - "set_level": { - "service": "light.turn_on", - "data_template": { - "entity_id": "light.test_state", - "brightness": "{{brightness}}", - }, - }, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - state = hass.states.async_set("light.test_state", STATE_ON) - await hass.async_block_till_done() - - state = hass.states.get("light.test_template_light") - assert state.state == STATE_ON - - state = hass.states.async_set("light.test_state", STATE_OFF) - await hass.async_block_till_done() - - state = hass.states.get("light.test_template_light") - assert state.state == STATE_OFF - - +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) @pytest.mark.parametrize( - "expected_state,template", - [(STATE_ON, "{{ 1 == 1 }}"), (STATE_OFF, "{{ 1 == 2 }}")], -) -async def test_template_state_boolean(hass, expected_state, template): - """Test the setting of the state with boolean on.""" - with assert_setup_component(1, light.DOMAIN): - assert await setup.async_setup_component( - hass, - light.DOMAIN, - { - "light": { - "platform": "template", - "lights": { - "test_template_light": { - "value_template": template, - "turn_on": { - "service": "light.turn_on", - "entity_id": "light.test_state", - }, - "turn_off": { - "service": "light.turn_off", - "entity_id": "light.test_state", - }, - "set_level": { - "service": "light.turn_on", - "data_template": { - "entity_id": "light.test_state", - "brightness": "{{brightness}}", - }, - }, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - state = hass.states.get("light.test_template_light") - assert state.state == expected_state - - -async def test_template_syntax_error(hass): - """Test templating syntax error.""" - with assert_setup_component(0, light.DOMAIN): - assert await setup.async_setup_component( - hass, - light.DOMAIN, - { - "light": { - "platform": "template", - "lights": { - "test_template_light": { - "value_template": "{%- if false -%}", - "turn_on": { - "service": "light.turn_on", - "entity_id": "light.test_state", - }, - "turn_off": { - "service": "light.turn_off", - "entity_id": "light.test_state", - }, - "set_level": { - "service": "light.turn_on", - "data_template": { - "entity_id": "light.test_state", - "brightness": "{{brightness}}", - }, - }, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert hass.states.async_all() == [] - - -async def test_invalid_name_does_not_create(hass): - """Test invalid name.""" - with assert_setup_component(0, light.DOMAIN): - assert await setup.async_setup_component( - hass, - light.DOMAIN, - { - "light": { - "platform": "template", - "lights": { - "bad name here": { - "value_template": "{{ 1== 1}}", - "turn_on": { - "service": "light.turn_on", - "entity_id": "light.test_state", - }, - "turn_off": { - "service": "light.turn_off", - "entity_id": "light.test_state", - }, - "set_level": { - "service": "light.turn_on", - "data_template": { - "entity_id": "light.test_state", - "brightness": "{{brightness}}", - }, - }, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert hass.states.async_all() == [] - - -async def test_invalid_light_does_not_create(hass): - """Test invalid light.""" - with assert_setup_component(0, light.DOMAIN): - assert await setup.async_setup_component( - hass, - light.DOMAIN, - { - "light": { - "platform": "template", - "switches": {"test_template_light": "Invalid"}, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert hass.states.async_all() == [] - - -async def test_no_lights_does_not_create(hass): - """Test if there are no lights no creation.""" - with assert_setup_component(0, light.DOMAIN): - assert await setup.async_setup_component( - hass, "light", {"light": {"platform": "template"}} - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert hass.states.async_all() == [] - - -@pytest.mark.parametrize( - "missing_key, count", [("value_template", 1), ("turn_on", 0), ("turn_off", 0)] -) -async def test_missing_key(hass, missing_key, count): - """Test missing template.""" - light_config = { - "light": { - "platform": "template", - "lights": { - "light_one": { - "value_template": "{{ 1== 1}}", - "turn_on": { - "service": "light.turn_on", - "entity_id": "light.test_state", - }, - "turn_off": { - "service": "light.turn_off", - "entity_id": "light.test_state", - }, - "set_level": { - "service": "light.turn_on", - "data_template": { + "config", + [ + { + "light": { + "platform": "template", + "lights": { + "test_template_light": { + "value_template": "{{states.test['big.fat...']}}", + "turn_on": { + "service": "light.turn_on", "entity_id": "light.test_state", - "brightness": "{{brightness}}", }, - }, + "turn_off": { + "service": "light.turn_off", + "entity_id": "light.test_state", + }, + "set_level": { + "service": "light.turn_on", + "data_template": { + "entity_id": "light.test_state", + "brightness": "{{brightness}}", + }, + }, + } + }, + } + }, + ], +) +async def test_template_state_invalid(hass, start_ha): + """Test template state with render error.""" + assert hass.states.get("light.test_template_light").state == STATE_OFF + + +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "light": { + "platform": "template", + "lights": { + "test_template_light": { + "value_template": "{{ states.light.test_state.state }}", + "turn_on": { + "service": "light.turn_on", + "entity_id": "light.test_state", + }, + "turn_off": { + "service": "light.turn_off", + "entity_id": "light.test_state", + }, + "set_level": { + "service": "light.turn_on", + "data_template": { + "entity_id": "light.test_state", + "brightness": "{{brightness}}", + }, + }, + } + }, + } + }, + ], +) +async def test_template_state_text(hass, start_ha): + """Test the state text of a template.""" + for set_state in [STATE_ON, STATE_OFF]: + hass.states.async_set("light.test_state", set_state) + await hass.async_block_till_done() + assert hass.states.get("light.test_template_light").state == set_state + + +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) +@pytest.mark.parametrize( + "config_addon,expected_state", + [ + ({"replace1": '"{{ 1 == 1 }}"'}, STATE_ON), + ({"replace1": '"{{ 1 == 2 }}"'}, STATE_OFF), + ], +) +@pytest.mark.parametrize( + "config", + [ + """{ + "light": { + "platform": "template", + "lights": { + "test_template_light": { + "value_template": replace1, + "turn_on": { + "service": "light.turn_on", + "entity_id": "light.test_state" + }, + "turn_off": { + "service": "light.turn_off", + "entity_id": "light.test_state" + }, + "set_level": { + "service": "light.turn_on", + "data_template": { + "entity_id": "light.test_state", + "brightness": "{{brightness}}" + } + } + } } - }, - } - } + } + }""", + ], +) +async def test_templatex_state_boolean(hass, expected_state, start_ha): + """Test the setting of the state with boolean on.""" + assert hass.states.get("light.test_template_light").state == expected_state - del light_config["light"]["lights"]["light_one"][missing_key] - with assert_setup_component(count, light.DOMAIN): - assert await setup.async_setup_component(hass, "light", light_config) - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() +@pytest.mark.parametrize("count,domain", [(0, light.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "light": { + "platform": "template", + "lights": { + "test_template_light": { + "value_template": "{%- if false -%}", + "turn_on": { + "service": "light.turn_on", + "entity_id": "light.test_state", + }, + "turn_off": { + "service": "light.turn_off", + "entity_id": "light.test_state", + }, + "set_level": { + "service": "light.turn_on", + "data_template": { + "entity_id": "light.test_state", + "brightness": "{{brightness}}", + }, + }, + } + }, + } + }, + { + "light": { + "platform": "template", + "lights": { + "bad name here": { + "value_template": "{{ 1== 1}}", + "turn_on": { + "service": "light.turn_on", + "entity_id": "light.test_state", + }, + "turn_off": { + "service": "light.turn_off", + "entity_id": "light.test_state", + }, + "set_level": { + "service": "light.turn_on", + "data_template": { + "entity_id": "light.test_state", + "brightness": "{{brightness}}", + }, + }, + } + }, + } + }, + { + "light": { + "platform": "template", + "switches": {"test_template_light": "Invalid"}, + } + }, + ], +) +async def test_template_syntax_error(hass, start_ha): + """Test templating syntax error.""" + assert hass.states.async_all() == [] + + +SET_VAL1 = '"value_template": "{{ 1== 1}}",' +SET_VAL2 = '"turn_on": {"service": "light.turn_on","entity_id": "light.test_state"},' +SET_VAL3 = '"turn_off": {"service": "light.turn_off","entity_id": "light.test_state"},' + + +@pytest.mark.parametrize("domain", [light.DOMAIN]) +@pytest.mark.parametrize( + "config_addon, count", + [ + ({"replace2": f"{SET_VAL2}{SET_VAL3}"}, 1), + ({"replace2": f"{SET_VAL1}{SET_VAL2}"}, 0), + ({"replace2": f"{SET_VAL2}{SET_VAL3}"}, 1), + ], +) +@pytest.mark.parametrize( + "config", + [ + """{"light": {"platform": "template", "lights": { + "light_one": { + replace2 + "set_level": {"service": "light.turn_on", + "data_template": {"entity_id": "light.test_state","brightness": "{{brightness}}" + }}}}}}""" + ], +) +async def test_missing_key(hass, count, start_ha): + """Test missing template.""" if count: assert hass.states.async_all() != [] else: assert hass.states.async_all() == [] -async def test_on_action(hass, calls): - """Test on action.""" - assert await setup.async_setup_component( - hass, - light.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "light": { "platform": "template", @@ -359,12 +272,10 @@ async def test_on_action(hass, calls): }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_on_action(hass, start_ha, calls): + """Test on action.""" hass.states.async_set("light.test_state", STATE_OFF) await hass.async_block_till_done() @@ -381,11 +292,10 @@ async def test_on_action(hass, calls): assert len(calls) == 1 -async def test_on_action_with_transition(hass, calls): - """Test on action with transition.""" - assert await setup.async_setup_component( - hass, - light.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "light": { "platform": "template", @@ -415,12 +325,10 @@ async def test_on_action_with_transition(hass, calls): }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_on_action_with_transition(hass, start_ha, calls): + """Test on action with transition.""" hass.states.async_set("light.test_state", STATE_OFF) await hass.async_block_till_done() @@ -438,11 +346,10 @@ async def test_on_action_with_transition(hass, calls): assert calls[0].data["transition"] == 5 -async def test_on_action_optimistic(hass, calls): - """Test on action with optimistic state.""" - assert await setup.async_setup_component( - hass, - light.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "light": { "platform": "template", @@ -464,12 +371,10 @@ async def test_on_action_optimistic(hass, calls): }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_on_action_optimistic(hass, start_ha, calls): + """Test on action with optimistic state.""" hass.states.async_set("light.test_state", STATE_OFF) await hass.async_block_till_done() @@ -488,11 +393,10 @@ async def test_on_action_optimistic(hass, calls): assert state.state == STATE_ON -async def test_off_action(hass, calls): - """Test off action.""" - assert await setup.async_setup_component( - hass, - light.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "light": { "platform": "template", @@ -517,12 +421,10 @@ async def test_off_action(hass, calls): }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_off_action(hass, start_ha, calls): + """Test off action.""" hass.states.async_set("light.test_state", STATE_ON) await hass.async_block_till_done() @@ -539,11 +441,10 @@ async def test_off_action(hass, calls): assert len(calls) == 1 -async def test_off_action_with_transition(hass, calls): - """Test off action with transition.""" - assert await setup.async_setup_component( - hass, - light.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "light": { "platform": "template", @@ -573,12 +474,10 @@ async def test_off_action_with_transition(hass, calls): }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_off_action_with_transition(hass, start_ha, calls): + """Test off action with transition.""" hass.states.async_set("light.test_state", STATE_ON) await hass.async_block_till_done() @@ -596,11 +495,10 @@ async def test_off_action_with_transition(hass, calls): assert calls[0].data["transition"] == 2 -async def test_off_action_optimistic(hass, calls): - """Test off action with optimistic state.""" - assert await setup.async_setup_component( - hass, - light.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "light": { "platform": "template", @@ -622,12 +520,10 @@ async def test_off_action_optimistic(hass, calls): }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_off_action_optimistic(hass, start_ha, calls): + """Test off action with optimistic state.""" state = hass.states.get("light.test_template_light") assert state.state == STATE_OFF @@ -643,11 +539,10 @@ async def test_off_action_optimistic(hass, calls): assert state.state == STATE_OFF -async def test_white_value_action_no_template(hass, calls): - """Test setting white value with optimistic template.""" - assert await setup.async_setup_component( - hass, - light.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "light": { "platform": "template", @@ -673,11 +568,10 @@ async def test_white_value_action_no_template(hass, calls): }, } }, - ) - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_white_value_action_no_template(hass, start_ha, calls): + """Test setting white value with optimistic template.""" state = hass.states.get("light.test_template_light") assert state.attributes.get("white_value") is None @@ -697,63 +591,43 @@ async def test_white_value_action_no_template(hass, calls): @pytest.mark.parametrize( - "expected_white_value,template", + "expected_white_value,config_addon", [ - (255, "{{255}}"), - (None, "{{256}}"), - (None, "{{x - 12}}"), - (None, "{{ none }}"), - (None, ""), + (255, {"replace3": "{{255}}"}), + (None, {"replace3": "{{256}}"}), + (None, {"replace3": "{{x - 12}}"}), + (None, {"replace3": "{{ none }}"}), + (None, {"replace3": ""}), ], ) -async def test_white_value_template(hass, expected_white_value, template): +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + """{ + "light": {"platform": "template","lights": { + "test_template_light": { + "value_template": "{{ 1 == 1 }}", + "turn_on": {"service": "light.turn_on","entity_id": "light.test_state"}, + "turn_off": {"service": "light.turn_off","entity_id": "light.test_state"}, + "set_white_value": {"service": "light.turn_on", + "data_template": {"entity_id": "light.test_state", + "white_value": "{{white_value}}"}}, + "white_value_template": "replace3" + }}}}""", + ], +) +async def test_white_value_template(hass, expected_white_value, start_ha): """Test the template for the white value.""" - with assert_setup_component(1, light.DOMAIN): - assert await setup.async_setup_component( - hass, - light.DOMAIN, - { - "light": { - "platform": "template", - "lights": { - "test_template_light": { - "value_template": "{{ 1 == 1 }}", - "turn_on": { - "service": "light.turn_on", - "entity_id": "light.test_state", - }, - "turn_off": { - "service": "light.turn_off", - "entity_id": "light.test_state", - }, - "set_white_value": { - "service": "light.turn_on", - "data_template": { - "entity_id": "light.test_state", - "white_value": "{{white_value}}", - }, - }, - "white_value_template": template, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - state = hass.states.get("light.test_template_light") assert state is not None assert state.attributes.get("white_value") == expected_white_value -async def test_level_action_no_template(hass, calls): - """Test setting brightness with optimistic template.""" - assert await setup.async_setup_component( - hass, - light.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "light": { "platform": "template", @@ -779,11 +653,10 @@ async def test_level_action_no_template(hass, calls): }, } }, - ) - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_level_action_no_template(hass, start_ha, calls): + """Test setting brightness with optimistic template.""" state = hass.states.get("light.test_template_light") assert state.attributes.get("brightness") is None @@ -803,77 +676,54 @@ async def test_level_action_no_template(hass, calls): assert state.attributes.get("brightness") == 124 +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) @pytest.mark.parametrize( - "expected_level,template", + "expected_level,config_addon", [ - (255, "{{255}}"), - (None, "{{256}}"), - (None, "{{x - 12}}"), - (None, "{{ none }}"), - (None, ""), + (255, {"replace4": '"{{255}}"'}), + (None, {"replace4": '"{{256}}"'}), + (None, {"replace4": '"{{x - 12}}"'}), + (None, {"replace4": '"{{ none }}"'}), + (None, {"replace4": '""'}), ], ) -async def test_level_template(hass, expected_level, template): +@pytest.mark.parametrize( + "config", + [ + """{"light": {"platform": "template", "lights": { + "test_template_light": { + "value_template": "{{ 1 == 1 }}", + "turn_on": {"service": "light.turn_on","entity_id": "light.test_state"}, + "turn_off": {"service": "light.turn_off","entity_id": "light.test_state"}, + "set_level": {"service": "light.turn_on","data_template": { + "entity_id": "light.test_state","brightness": "{{brightness}}"}}, + "level_template": replace4 + }}}}""", + ], +) +async def test_level_template(hass, expected_level, start_ha): """Test the template for the level.""" - with assert_setup_component(1, light.DOMAIN): - assert await setup.async_setup_component( - hass, - light.DOMAIN, - { - "light": { - "platform": "template", - "lights": { - "test_template_light": { - "value_template": "{{ 1 == 1 }}", - "turn_on": { - "service": "light.turn_on", - "entity_id": "light.test_state", - }, - "turn_off": { - "service": "light.turn_off", - "entity_id": "light.test_state", - }, - "set_level": { - "service": "light.turn_on", - "data_template": { - "entity_id": "light.test_state", - "brightness": "{{brightness}}", - }, - }, - "level_template": template, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - state = hass.states.get("light.test_template_light") assert state is not None assert state.attributes.get("brightness") == expected_level +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) @pytest.mark.parametrize( - "expected_temp,template", + "expected_temp,config_addon", [ - (500, "{{500}}"), - (None, "{{501}}"), - (None, "{{x - 12}}"), - (None, "None"), - (None, "{{ none }}"), - (None, ""), + (500, {"replace5": '"{{500}}"'}), + (None, {"replace5": '"{{501}}"'}), + (None, {"replace5": '"{{x - 12}}"'}), + (None, {"replace5": '"None"'}), + (None, {"replace5": '"{{ none }}"'}), + (None, {"replace5": '""'}), ], ) -async def test_temperature_template(hass, expected_temp, template): - """Test the template for the temperature.""" - with assert_setup_component(1, light.DOMAIN): - assert await setup.async_setup_component( - hass, - light.DOMAIN, - { +@pytest.mark.parametrize( + "config", + [ + """{ "light": { "platform": "template", "lights": { @@ -881,40 +731,37 @@ async def test_temperature_template(hass, expected_temp, template): "value_template": "{{ 1 == 1 }}", "turn_on": { "service": "light.turn_on", - "entity_id": "light.test_state", + "entity_id": "light.test_state" }, "turn_off": { "service": "light.turn_off", - "entity_id": "light.test_state", + "entity_id": "light.test_state" }, "set_temperature": { "service": "light.turn_on", "data_template": { "entity_id": "light.test_state", - "color_temp": "{{color_temp}}", - }, + "color_temp": "{{color_temp}}" + } }, - "temperature_template": template, + "temperature_template": replace5 } - }, + } } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + }""" + ], +) +async def test_temperature_template(hass, expected_temp, start_ha): + """Test the template for the temperature.""" state = hass.states.get("light.test_template_light") assert state is not None assert state.attributes.get("color_temp") == expected_temp -async def test_temperature_action_no_template(hass, calls): - """Test setting temperature with optimistic template.""" - assert await setup.async_setup_component( - hass, - light.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "light": { "platform": "template", @@ -940,11 +787,10 @@ async def test_temperature_action_no_template(hass, calls): }, } }, - ) - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_temperature_action_no_template(hass, start_ha, calls): + """Test setting temperature with optimistic template.""" state = hass.states.get("light.test_template_light") assert state.attributes.get("color_template") is None @@ -964,43 +810,40 @@ async def test_temperature_action_no_template(hass, calls): assert state.attributes.get("color_temp") == 345 -async def test_friendly_name(hass): +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "light": { + "platform": "template", + "lights": { + "test_template_light": { + "friendly_name": "Template light", + "value_template": "{{ 1 == 1 }}", + "turn_on": { + "service": "light.turn_on", + "entity_id": "light.test_state", + }, + "turn_off": { + "service": "light.turn_off", + "entity_id": "light.test_state", + }, + "set_level": { + "service": "light.turn_on", + "data_template": { + "entity_id": "light.test_state", + "brightness": "{{brightness}}", + }, + }, + } + }, + } + }, + ], +) +async def test_friendly_name(hass, start_ha): """Test the accessibility of the friendly_name attribute.""" - with assert_setup_component(1, light.DOMAIN): - assert await setup.async_setup_component( - hass, - light.DOMAIN, - { - "light": { - "platform": "template", - "lights": { - "test_template_light": { - "friendly_name": "Template light", - "value_template": "{{ 1 == 1 }}", - "turn_on": { - "service": "light.turn_on", - "entity_id": "light.test_state", - }, - "turn_off": { - "service": "light.turn_off", - "entity_id": "light.test_state", - }, - "set_level": { - "service": "light.turn_on", - "data_template": { - "entity_id": "light.test_state", - "brightness": "{{brightness}}", - }, - }, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() state = hass.states.get("light.test_template_light") assert state is not None @@ -1008,47 +851,43 @@ async def test_friendly_name(hass): assert state.attributes.get("friendly_name") == "Template light" -async def test_icon_template(hass): +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "light": { + "platform": "template", + "lights": { + "test_template_light": { + "friendly_name": "Template light", + "value_template": "{{ 1 == 1 }}", + "turn_on": { + "service": "light.turn_on", + "entity_id": "light.test_state", + }, + "turn_off": { + "service": "light.turn_off", + "entity_id": "light.test_state", + }, + "set_level": { + "service": "light.turn_on", + "data_template": { + "entity_id": "light.test_state", + "brightness": "{{brightness}}", + }, + }, + "icon_template": "{% if states.light.test_state.state %}" + "mdi:check" + "{% endif %}", + } + }, + } + }, + ], +) +async def test_icon_template(hass, start_ha): """Test icon template.""" - with assert_setup_component(1, light.DOMAIN): - assert await setup.async_setup_component( - hass, - light.DOMAIN, - { - "light": { - "platform": "template", - "lights": { - "test_template_light": { - "friendly_name": "Template light", - "value_template": "{{ 1 == 1 }}", - "turn_on": { - "service": "light.turn_on", - "entity_id": "light.test_state", - }, - "turn_off": { - "service": "light.turn_off", - "entity_id": "light.test_state", - }, - "set_level": { - "service": "light.turn_on", - "data_template": { - "entity_id": "light.test_state", - "brightness": "{{brightness}}", - }, - }, - "icon_template": "{% if states.light.test_state.state %}" - "mdi:check" - "{% endif %}", - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - state = hass.states.get("light.test_template_light") assert state.attributes.get("icon") == "" @@ -1060,47 +899,43 @@ async def test_icon_template(hass): assert state.attributes["icon"] == "mdi:check" -async def test_entity_picture_template(hass): +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "light": { + "platform": "template", + "lights": { + "test_template_light": { + "friendly_name": "Template light", + "value_template": "{{ 1 == 1 }}", + "turn_on": { + "service": "light.turn_on", + "entity_id": "light.test_state", + }, + "turn_off": { + "service": "light.turn_off", + "entity_id": "light.test_state", + }, + "set_level": { + "service": "light.turn_on", + "data_template": { + "entity_id": "light.test_state", + "brightness": "{{brightness}}", + }, + }, + "entity_picture_template": "{% if states.light.test_state.state %}" + "/local/light.png" + "{% endif %}", + } + }, + } + }, + ], +) +async def test_entity_picture_template(hass, start_ha): """Test entity_picture template.""" - with assert_setup_component(1, light.DOMAIN): - assert await setup.async_setup_component( - hass, - light.DOMAIN, - { - "light": { - "platform": "template", - "lights": { - "test_template_light": { - "friendly_name": "Template light", - "value_template": "{{ 1 == 1 }}", - "turn_on": { - "service": "light.turn_on", - "entity_id": "light.test_state", - }, - "turn_off": { - "service": "light.turn_off", - "entity_id": "light.test_state", - }, - "set_level": { - "service": "light.turn_on", - "data_template": { - "entity_id": "light.test_state", - "brightness": "{{brightness}}", - }, - }, - "entity_picture_template": "{% if states.light.test_state.state %}" - "/local/light.png" - "{% endif %}", - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - state = hass.states.get("light.test_template_light") assert state.attributes.get("entity_picture") == "" @@ -1112,11 +947,10 @@ async def test_entity_picture_template(hass): assert state.attributes["entity_picture"] == "/local/light.png" -async def test_color_action_no_template(hass, calls): - """Test setting color with optimistic template.""" - assert await setup.async_setup_component( - hass, - light.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "light": { "platform": "template", @@ -1153,11 +987,10 @@ async def test_color_action_no_template(hass, calls): }, } }, - ) - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_color_action_no_template(hass, start_ha, calls): + """Test setting color with optimistic template.""" state = hass.states.get("light.test_template_light") assert state.attributes.get("hs_color") is None @@ -1183,66 +1016,44 @@ async def test_color_action_no_template(hass, calls): assert calls[1].data["s"] == 50 +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) @pytest.mark.parametrize( - "expected_hs,template", + "expected_hs,config_addon", [ - ((360, 100), "{{(360, 100)}}"), - ((359.9, 99.9), "{{(359.9, 99.9)}}"), - (None, "{{(361, 100)}}"), - (None, "{{(360, 101)}}"), - (None, "{{x - 12}}"), - (None, ""), - (None, "{{ none }}"), + ((360, 100), {"replace6": '"{{(360, 100)}}"'}), + ((359.9, 99.9), {"replace6": '"{{(359.9, 99.9)}}"'}), + (None, {"replace6": '"{{(361, 100)}}"'}), + (None, {"replace6": '"{{(360, 101)}}"'}), + (None, {"replace6": '"{{x - 12}}"'}), + (None, {"replace6": '""'}), + (None, {"replace6": '"{{ none }}"'}), ], ) -async def test_color_template(hass, expected_hs, template): +@pytest.mark.parametrize( + "config", + [ + """{"light": {"platform": "template","lights": {"test_template_light": { + "value_template": "{{ 1 == 1 }}", + "turn_on": {"service": "light.turn_on","entity_id": "light.test_state"}, + "turn_off": {"service": "light.turn_off","entity_id": "light.test_state"}, + "set_color": [{"service": "input_number.set_value", + "data_template": {"entity_id": "input_number.h","color_temp": "{{h}}" + }}], + "color_template": replace6 + }}}}""" + ], +) +async def test_color_template(hass, expected_hs, start_ha): """Test the template for the color.""" - with assert_setup_component(1, light.DOMAIN): - assert await setup.async_setup_component( - hass, - light.DOMAIN, - { - "light": { - "platform": "template", - "lights": { - "test_template_light": { - "value_template": "{{ 1 == 1 }}", - "turn_on": { - "service": "light.turn_on", - "entity_id": "light.test_state", - }, - "turn_off": { - "service": "light.turn_off", - "entity_id": "light.test_state", - }, - "set_color": [ - { - "service": "input_number.set_value", - "data_template": { - "entity_id": "input_number.h", - "color_temp": "{{h}}", - }, - } - ], - "color_template": template, - } - }, - } - }, - ) - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() state = hass.states.get("light.test_template_light") assert state is not None assert state.attributes.get("hs_color") == expected_hs -async def test_effect_action_valid_effect(hass, calls): - """Test setting valid effect with template.""" - assert await setup.async_setup_component( - hass, - light.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "light": { "platform": "template", @@ -1274,12 +1085,10 @@ async def test_effect_action_valid_effect(hass, calls): }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_effect_action_valid_effect(hass, start_ha, calls): + """Test setting valid effect with template.""" state = hass.states.get("light.test_template_light") assert state is not None @@ -1298,11 +1107,10 @@ async def test_effect_action_valid_effect(hass, calls): assert state.attributes.get("effect") == "Disco" -async def test_effect_action_invalid_effect(hass, calls): - """Test setting invalid effect with template.""" - assert await setup.async_setup_component( - hass, - light.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "light": { "platform": "template", @@ -1334,12 +1142,10 @@ async def test_effect_action_invalid_effect(hass, calls): }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_effect_action_invalid_effect(hass, start_ha, calls): + """Test setting invalid effect with template.""" state = hass.states.get("light.test_template_light") assert state is not None @@ -1358,284 +1164,176 @@ async def test_effect_action_invalid_effect(hass, calls): assert state.attributes.get("effect") is None +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) @pytest.mark.parametrize( - "expected_effect_list,template", + "expected_effect_list,config_addon", [ ( ["Strobe color", "Police", "Christmas", "RGB", "Random Loop"], - "{{ ['Strobe color', 'Police', 'Christmas', 'RGB', 'Random Loop'] }}", + { + "replace7": "\"{{ ['Strobe color', 'Police', 'Christmas', 'RGB', 'Random Loop'] }}\"" + }, ), ( ["Police", "RGB", "Random Loop"], - "{{ ['Police', 'RGB', 'Random Loop'] }}", + {"replace7": "\"{{ ['Police', 'RGB', 'Random Loop'] }}\""}, ), - (None, "{{ [] }}"), - (None, "{{ '[]' }}"), - (None, "{{ 124 }}"), - (None, "{{ '124' }}"), - (None, "{{ none }}"), - (None, ""), + (None, {"replace7": '"{{ [] }}"'}), + (None, {"replace7": "\"{{ '[]' }}\""}), + (None, {"replace7": '"{{ 124 }}"'}), + (None, {"replace7": "\"{{ '124' }}\""}), + (None, {"replace7": '"{{ none }}"'}), + (None, {"replace7": '""'}), ], ) -async def test_effect_list_template(hass, expected_effect_list, template): +@pytest.mark.parametrize( + "config", + [ + """{"light": {"platform": "template","lights": {"test_template_light": { + "value_template": "{{ 1 == 1 }}", + "turn_on": {"service": "light.turn_on","entity_id": "light.test_state"}, + "turn_off": {"service": "light.turn_off","entity_id": "light.test_state"}, + "set_effect": {"service": "test.automation", + "data_template": {"entity_id": "test.test_state","effect": "{{effect}}"}}, + "effect_template": "{{ None }}", + "effect_list_template": replace7 + }}}}""", + ], +) +async def test_effect_list_template(hass, expected_effect_list, start_ha): """Test the template for the effect list.""" - with assert_setup_component(1, light.DOMAIN): - assert await setup.async_setup_component( - hass, - light.DOMAIN, - { - "light": { - "platform": "template", - "lights": { - "test_template_light": { - "value_template": "{{ 1 == 1 }}", - "turn_on": { - "service": "light.turn_on", - "entity_id": "light.test_state", - }, - "turn_off": { - "service": "light.turn_off", - "entity_id": "light.test_state", - }, - "set_effect": { - "service": "test.automation", - "data_template": { - "entity_id": "test.test_state", - "effect": "{{effect}}", - }, - }, - "effect_list_template": template, - "effect_template": "{{ None }}", - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - state = hass.states.get("light.test_template_light") assert state is not None assert state.attributes.get("effect_list") == expected_effect_list +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) @pytest.mark.parametrize( - "expected_effect,template", + "expected_effect,config_addon", [ - (None, "Disco"), - (None, "None"), - (None, "{{ None }}"), - ("Police", "Police"), - ("Strobe color", "{{ 'Strobe color' }}"), + (None, {"replace8": '"Disco"'}), + (None, {"replace8": '"None"'}), + (None, {"replace8": '"{{ None }}"'}), + ("Police", {"replace8": '"Police"'}), + ("Strobe color", {"replace8": "\"{{ 'Strobe color' }}\""}), ], ) -async def test_effect_template(hass, expected_effect, template): +@pytest.mark.parametrize( + "config", + [ + """{"light": {"platform": "template","lights": {"test_template_light": { + "value_template": "{{ 1 == 1 }}", + "turn_on": {"service": "light.turn_on","entity_id": "light.test_state"}, + "turn_off": {"service": "light.turn_off","entity_id": "light.test_state"}, + "set_effect": {"service": "test.automation","data_template": { + "entity_id": "test.test_state","effect": "{{effect}}"}}, + "effect_list_template": "{{ ['Strobe color', 'Police', 'Christmas', 'RGB', 'Random Loop'] }}", + "effect_template": replace8 + }}}}""", + ], +) +async def test_effect_template(hass, expected_effect, start_ha): """Test the template for the effect.""" - with assert_setup_component(1, light.DOMAIN): - assert await setup.async_setup_component( - hass, - light.DOMAIN, - { - "light": { - "platform": "template", - "lights": { - "test_template_light": { - "value_template": "{{ 1 == 1 }}", - "turn_on": { - "service": "light.turn_on", - "entity_id": "light.test_state", - }, - "turn_off": { - "service": "light.turn_off", - "entity_id": "light.test_state", - }, - "set_effect": { - "service": "test.automation", - "data_template": { - "entity_id": "test.test_state", - "effect": "{{effect}}", - }, - }, - "effect_list_template": "{{ ['Strobe color', 'Police', 'Christmas', 'RGB', 'Random Loop'] }}", - "effect_template": template, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - state = hass.states.get("light.test_template_light") assert state is not None assert state.attributes.get("effect") == expected_effect +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) @pytest.mark.parametrize( - "expected_min_mireds,template", + "expected_min_mireds,config_addon", [ - (118, "{{118}}"), - (153, "{{x - 12}}"), - (153, "None"), - (153, "{{ none }}"), - (153, ""), - (153, "{{ 'a' }}"), + (118, {"replace9": '"{{118}}"'}), + (153, {"replace9": '"{{x - 12}}"'}), + (153, {"replace9": '"None"'}), + (153, {"replace9": '"{{ none }}"'}), + (153, {"replace9": '""'}), + (153, {"replace9": "\"{{ 'a' }}\""}), ], ) -async def test_min_mireds_template(hass, expected_min_mireds, template): +@pytest.mark.parametrize( + "config", + [ + """{"light": {"platform": "template","lights": {"test_template_light": { + "value_template": "{{ 1 == 1 }}", + "turn_on": {"service": "light.turn_on","entity_id": "light.test_state"}, + "turn_off": {"service": "light.turn_off","entity_id": "light.test_state"}, + "set_temperature": {"service": "light.turn_on","data_template": { + "entity_id": "light.test_state","color_temp": "{{color_temp}}"}}, + "temperature_template": "{{200}}", + "min_mireds_template": replace9 + }}}}""", + ], +) +async def test_min_mireds_template(hass, expected_min_mireds, start_ha): """Test the template for the min mireds.""" - with assert_setup_component(1, light.DOMAIN): - assert await setup.async_setup_component( - hass, - "light", - { - "light": { - "platform": "template", - "lights": { - "test_template_light": { - "value_template": "{{ 1 == 1 }}", - "turn_on": { - "service": "light.turn_on", - "entity_id": "light.test_state", - }, - "turn_off": { - "service": "light.turn_off", - "entity_id": "light.test_state", - }, - "set_temperature": { - "service": "light.turn_on", - "data_template": { - "entity_id": "light.test_state", - "color_temp": "{{color_temp}}", - }, - }, - "temperature_template": "{{200}}", - "min_mireds_template": template, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - state = hass.states.get("light.test_template_light") assert state is not None assert state.attributes.get("min_mireds") == expected_min_mireds +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) @pytest.mark.parametrize( - "expected_max_mireds,template", + "expected_max_mireds,config_addon", [ - (488, "{{488}}"), - (500, "{{x - 12}}"), - (500, "None"), - (500, "{{ none }}"), - (500, ""), - (500, "{{ 'a' }}"), + (488, {"template1": '"{{488}}"'}), + (500, {"template1": '"{{x - 12}}"'}), + (500, {"template1": '"None"'}), + (500, {"template1": '"{{ none }}"'}), + (500, {"template1": '""'}), + (500, {"template1": "\"{{ 'a' }}\""}), ], ) -async def test_max_mireds_template(hass, expected_max_mireds, template): +@pytest.mark.parametrize( + "config", + [ + """{"light": {"platform": "template","lights": {"test_template_light": { + "value_template": "{{ 1 == 1 }}", + "turn_on": {"service": "light.turn_on","entity_id": "light.test_state"}, + "turn_off": {"service": "light.turn_off","entity_id": "light.test_state"}, + "set_temperature": {"service": "light.turn_on","data_template": { + "entity_id": "light.test_state","color_temp": "{{color_temp}}"}}, + "temperature_template": "{{200}}", + "max_mireds_template": template1 + }}}}""", + ], +) +async def test_max_mireds_template(hass, expected_max_mireds, start_ha): """Test the template for the max mireds.""" - with assert_setup_component(1, light.DOMAIN): - assert await setup.async_setup_component( - hass, - "light", - { - "light": { - "platform": "template", - "lights": { - "test_template_light": { - "value_template": "{{ 1 == 1 }}", - "turn_on": { - "service": "light.turn_on", - "entity_id": "light.test_state", - }, - "turn_off": { - "service": "light.turn_off", - "entity_id": "light.test_state", - }, - "set_temperature": { - "service": "light.turn_on", - "data_template": { - "entity_id": "light.test_state", - "color_temp": "{{color_temp}}", - }, - }, - "temperature_template": "{{200}}", - "max_mireds_template": template, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - state = hass.states.get("light.test_template_light") assert state is not None assert state.attributes.get("max_mireds") == expected_max_mireds +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) @pytest.mark.parametrize( - "expected_supports_transition,template", + "expected_supports_transition,config_addon", [ - (True, "{{true}}"), - (True, "{{1 == 1}}"), - (False, "{{false}}"), - (False, "{{ none }}"), - (False, ""), - (False, "None"), + (True, {"template2": '"{{true}}"'}), + (True, {"template2": '"{{1 == 1}}"'}), + (False, {"template2": '"{{false}}"'}), + (False, {"template2": '"{{ none }}"'}), + (False, {"template2": '""'}), + (False, {"template2": '"None"'}), + ], +) +@pytest.mark.parametrize( + "config", + [ + """{"light": {"platform": "template","lights": {"test_template_light": { + "value_template": "{{ 1 == 1 }}", + "turn_on": {"service": "light.turn_on","entity_id": "light.test_state"}, + "turn_off": {"service": "light.turn_off","entity_id": "light.test_state"}, + "set_temperature": {"service": "light.turn_on","data_template": { + "entity_id": "light.test_state","color_temp": "{{color_temp}}"}}, + "supports_transition_template": template2 + }}}}""", ], ) async def test_supports_transition_template( - hass, expected_supports_transition, template + hass, expected_supports_transition, start_ha ): """Test the template for the supports transition.""" - with assert_setup_component(1, light.DOMAIN): - assert await setup.async_setup_component( - hass, - "light", - { - "light": { - "platform": "template", - "lights": { - "test_template_light": { - "value_template": "{{ 1 == 1 }}", - "turn_on": { - "service": "light.turn_on", - "entity_id": "light.test_state", - }, - "turn_off": { - "service": "light.turn_off", - "entity_id": "light.test_state", - }, - "set_temperature": { - "service": "light.turn_on", - "data_template": { - "entity_id": "light.test_state", - "color_temp": "{{color_temp}}", - }, - }, - "supports_transition_template": template, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - state = hass.states.get("light.test_template_light") expected_value = 1 @@ -1649,11 +1347,10 @@ async def test_supports_transition_template( ) != expected_value -async def test_available_template_with_entities(hass): - """Test availability templates with values from other entities.""" - await setup.async_setup_component( - hass, - light.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "light": { "platform": "template", @@ -1679,11 +1376,10 @@ async def test_available_template_with_entities(hass): }, } }, - ) - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_available_template_with_entities(hass, start_ha): + """Test availability templates with values from other entities.""" # When template returns true.. hass.states.async_set(_STATE_AVAILABILITY_BOOLEAN, STATE_ON) await hass.async_block_till_done() @@ -1699,11 +1395,10 @@ async def test_available_template_with_entities(hass): assert hass.states.get("light.test_template_light").state == STATE_UNAVAILABLE -async def test_invalid_availability_template_keeps_component_available(hass, caplog): - """Test that an invalid availability keeps the device available.""" - await setup.async_setup_component( - hass, - light.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "light": { "platform": "template", @@ -1729,21 +1424,20 @@ async def test_invalid_availability_template_keeps_component_available(hass, cap }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_invalid_availability_template_keeps_component_available( + hass, start_ha, caplog_setup_text +): + """Test that an invalid availability keeps the device available.""" assert hass.states.get("light.test_template_light").state != STATE_UNAVAILABLE - assert ("UndefinedError: 'x' is undefined") in caplog.text + assert ("UndefinedError: 'x' is undefined") in caplog_setup_text -async def test_unique_id(hass): - """Test unique_id option only creates one light per id.""" - await setup.async_setup_component( - hass, - light.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "light": { "platform": "template", @@ -1771,12 +1465,10 @@ async def test_unique_id(hass): }, }, }, - }, + } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_unique_id(hass, start_ha): + """Test unique_id option only creates one light per id.""" assert len(hass.states.async_all()) == 1 From 8341ae12d38c252b00ab8c27bd8ec967a8787c43 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 16 Sep 2021 23:29:41 +0200 Subject: [PATCH 426/843] Mock out zeroconf in homekit_controller tests (#56307) --- tests/components/homekit_controller/conftest.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/components/homekit_controller/conftest.py b/tests/components/homekit_controller/conftest.py index 4e095b1d2d9..dc27162bc57 100644 --- a/tests/components/homekit_controller/conftest.py +++ b/tests/components/homekit_controller/conftest.py @@ -27,3 +27,8 @@ def controller(hass): instance = FakeController() with unittest.mock.patch("aiohomekit.Controller", return_value=instance): yield instance + + +@pytest.fixture(autouse=True) +def homekit_mock_zeroconf(mock_zeroconf): + """Mock zeroconf in all homekit tests.""" From 175f207d28f0d063da3d9be6c55d758723909d57 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Fri, 17 Sep 2021 06:50:46 +0200 Subject: [PATCH 427/843] Avoid sending Standby when already off (#56306) --- homeassistant/components/philips_js/media_player.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/philips_js/media_player.py b/homeassistant/components/philips_js/media_player.py index e4512fc52f0..4499fb61e2a 100644 --- a/homeassistant/components/philips_js/media_player.py +++ b/homeassistant/components/philips_js/media_player.py @@ -213,9 +213,12 @@ class PhilipsTVMediaPlayer(CoordinatorEntity, MediaPlayerEntity): async def async_turn_off(self): """Turn off the device.""" - await self._tv.sendKey("Standby") - self._state = STATE_OFF - await self._async_update_soon() + if self._state == STATE_ON: + await self._tv.sendKey("Standby") + self._state = STATE_OFF + await self._async_update_soon() + else: + _LOGGER.debug("Ignoring turn off when already in expected state") async def async_volume_up(self): """Send volume up command.""" From 6d99a7a73032789be3bcce137ced028f173be7a8 Mon Sep 17 00:00:00 2001 From: Sean Vig Date: Fri, 17 Sep 2021 01:48:17 -0400 Subject: [PATCH 428/843] Add unique id to amcrest sensors (#55243) * Add unique id to amcrest sensors * Change 'unique_id' to 'serial_number' on api wrapper * Update unique id's with channel value that can be used in future changes and remove unrelated camera changes --- homeassistant/components/amcrest/binary_sensor.py | 15 +++++++++++++++ homeassistant/components/amcrest/sensor.py | 6 ++++++ 2 files changed, 21 insertions(+) diff --git a/homeassistant/components/amcrest/binary_sensor.py b/homeassistant/components/amcrest/binary_sensor.py index 76fdcf100ae..48bc6727585 100644 --- a/homeassistant/components/amcrest/binary_sensor.py +++ b/homeassistant/components/amcrest/binary_sensor.py @@ -169,6 +169,7 @@ class AmcrestBinarySensor(BinarySensorEntity): """Initialize entity.""" self._signal_name = name self._api = device.api + self._channel = 0 # Used in unique id, reserved for future use self.entity_description: AmcrestSensorEntityDescription = entity_description self._attr_name = f"{name} {entity_description.name}" @@ -192,6 +193,9 @@ class AmcrestBinarySensor(BinarySensorEntity): if not (self._api.available or self.is_on): return _LOGGER.debug(_UPDATE_MSG, self.name) + + self._update_unique_id() + if self._api.available: # Send a command to the camera to test if we can still communicate with it. # Override of Http.command() in __init__.py will set self._api.available @@ -205,6 +209,8 @@ class AmcrestBinarySensor(BinarySensorEntity): return _LOGGER.debug(_UPDATE_MSG, self.name) + self._update_unique_id() + event_code = self.entity_description.event_code if event_code is None: _LOGGER.error("Binary sensor %s event code not set", self.name) @@ -215,6 +221,15 @@ class AmcrestBinarySensor(BinarySensorEntity): except AmcrestError as error: log_update_error(_LOGGER, "update", self.name, "binary sensor", error) + def _update_unique_id(self) -> None: + """Set the unique id.""" + if self._attr_unique_id is None: + serial_number = self._api.serial_number + if serial_number: + self._attr_unique_id = ( + f"{serial_number}-{self.entity_description.key}-{self._channel}" + ) + async def async_on_demand_update(self) -> None: """Update state.""" if self.entity_description.key == _ONLINE_KEY: diff --git a/homeassistant/components/amcrest/sensor.py b/homeassistant/components/amcrest/sensor.py index e6040cfa728..87bb1d5c758 100644 --- a/homeassistant/components/amcrest/sensor.py +++ b/homeassistant/components/amcrest/sensor.py @@ -77,6 +77,7 @@ class AmcrestSensor(SensorEntity): self.entity_description = description self._signal_name = name self._api = device.api + self._channel = 0 # Used in unique id, reserved for future use self._unsub_dispatcher: Callable[[], None] | None = None self._attr_name = f"{name} {description.name}" @@ -94,6 +95,11 @@ class AmcrestSensor(SensorEntity): _LOGGER.debug("Updating %s sensor", self.name) sensor_type = self.entity_description.key + if self._attr_unique_id is None: + serial_number = self._api.serial_number + if serial_number: + self._attr_unique_id = f"{serial_number}-{sensor_type}-{self._channel}" + try: if sensor_type == SENSOR_PTZ_PRESET: self._attr_native_value = self._api.ptz_presets_count From 8814c535048e7cbecd5856020e4da204a78affc0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Sep 2021 21:04:54 -1000 Subject: [PATCH 429/843] Bump zeroconf to 0.36.4 (#56314) --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 2fce7a3d25a..172acde2daf 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -2,7 +2,7 @@ "domain": "zeroconf", "name": "Zero-configuration networking (zeroconf)", "documentation": "https://www.home-assistant.io/integrations/zeroconf", - "requirements": ["zeroconf==0.36.3"], + "requirements": ["zeroconf==0.36.4"], "dependencies": ["network", "api"], "codeowners": ["@bdraco"], "quality_scale": "internal", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ca024f0368b..8f23aef2eb0 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ sqlalchemy==1.4.23 voluptuous-serialize==2.4.0 voluptuous==0.12.1 yarl==1.6.3 -zeroconf==0.36.3 +zeroconf==0.36.4 pycryptodome>=3.6.6 diff --git a/requirements_all.txt b/requirements_all.txt index 74681a2f5fa..16c75dc1add 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2463,7 +2463,7 @@ youtube_dl==2021.04.26 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.36.3 +zeroconf==0.36.4 # homeassistant.components.zha zha-quirks==0.0.61 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1a6bd584a13..1fc41dc7335 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1392,7 +1392,7 @@ yeelight==0.7.4 youless-api==0.12 # homeassistant.components.zeroconf -zeroconf==0.36.3 +zeroconf==0.36.4 # homeassistant.components.zha zha-quirks==0.0.61 From fce7f0873ea0a6cb423b4585489ad4df27c45200 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 17 Sep 2021 11:19:19 +0200 Subject: [PATCH 430/843] Prevent 3rd party lib from opening sockets in sia tests (#56325) --- tests/components/sia/test_config_flow.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/components/sia/test_config_flow.py b/tests/components/sia/test_config_flow.py index 9679f4949e8..a91822c7846 100644 --- a/tests/components/sia/test_config_flow.py +++ b/tests/components/sia/test_config_flow.py @@ -209,6 +209,13 @@ async def test_abort_form(hass): assert get_abort["reason"] == "already_configured" +@pytest.fixture(autouse=True) +def mock_sia(): + """Mock SIAClient.""" + with patch("homeassistant.components.sia.hub.SIAClient", autospec=True): + yield + + @pytest.mark.parametrize( "field, value, error", [ From bce4c5eb1149721a4abc2b53f9d9d599b1c01c02 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 17 Sep 2021 11:19:32 +0200 Subject: [PATCH 431/843] Prevent 3rd party lib from opening sockets in zeroconf tests (#56324) --- tests/components/zeroconf/conftest.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/components/zeroconf/conftest.py b/tests/components/zeroconf/conftest.py index 5ccd617f84f..cbe2ec8dc26 100644 --- a/tests/components/zeroconf/conftest.py +++ b/tests/components/zeroconf/conftest.py @@ -4,8 +4,14 @@ from unittest.mock import AsyncMock, patch import pytest +@pytest.fixture(autouse=True) +def zc_mock_get_source_ip(mock_get_source_ip): + """Enable the mock_get_source_ip fixture for all zeroconf tests.""" + return mock_get_source_ip + + @pytest.fixture -def mock_async_zeroconf(): +def mock_async_zeroconf(mock_zeroconf): """Mock AsyncZeroconf.""" with patch("homeassistant.components.zeroconf.HaAsyncZeroconf") as mock_aiozc: zc = mock_aiozc.return_value From a793fd4134cb25c701331762c2404838ab6793b7 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 17 Sep 2021 11:41:19 +0200 Subject: [PATCH 432/843] Prevent 3rd party lib from opening sockets in ping tests (#56329) --- tests/components/ping/test_binary_sensor.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/components/ping/test_binary_sensor.py b/tests/components/ping/test_binary_sensor.py index a9af91c9f6f..3ffb2bb95d5 100644 --- a/tests/components/ping/test_binary_sensor.py +++ b/tests/components/ping/test_binary_sensor.py @@ -2,12 +2,21 @@ from os import path from unittest.mock import patch +import pytest + from homeassistant import config as hass_config, setup from homeassistant.components.ping import DOMAIN from homeassistant.const import SERVICE_RELOAD -async def test_reload(hass): +@pytest.fixture +def mock_ping(): + """Mock icmplib.ping.""" + with patch("homeassistant.components.ping.icmp_ping"): + yield + + +async def test_reload(hass, mock_ping): """Verify we can reload trend sensors.""" await setup.async_setup_component( From e0a232aa36f918ca1026a1be3771d0b1dbfbb34b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 17 Sep 2021 12:50:11 +0200 Subject: [PATCH 433/843] Prevent 3rd party lib from opening sockets in wallbox tests (#56308) --- tests/components/wallbox/test_config_flow.py | 39 +------------------- 1 file changed, 1 insertion(+), 38 deletions(-) diff --git a/tests/components/wallbox/test_config_flow.py b/tests/components/wallbox/test_config_flow.py index 6b5a05a3486..fba322182c9 100644 --- a/tests/components/wallbox/test_config_flow.py +++ b/tests/components/wallbox/test_config_flow.py @@ -1,11 +1,10 @@ """Test the Wallbox config flow.""" import json -from unittest.mock import patch import requests_mock from homeassistant import config_entries, data_entry_flow -from homeassistant.components.wallbox import InvalidAuth, config_flow +from homeassistant.components.wallbox import config_flow from homeassistant.components.wallbox.const import DOMAIN from homeassistant.core import HomeAssistant @@ -24,29 +23,6 @@ async def test_show_set_form(hass: HomeAssistant) -> None: assert result["step_id"] == "user" -async def test_form_invalid_auth(hass): - """Test we handle invalid auth.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "homeassistant.components.wallbox.config_flow.WallboxHub.async_authenticate", - side_effect=InvalidAuth, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "station": "12345", - "username": "test-username", - "password": "test-password", - }, - ) - - assert result2["type"] == "form" - assert result2["errors"] == {"base": "invalid_auth"} - - async def test_form_cannot_authenticate(hass): """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( @@ -59,19 +35,6 @@ async def test_form_cannot_authenticate(hass): text='{"jwt":"fakekeyhere","user_id":12345,"ttl":145656758,"error":false,"status":200}', status_code=403, ) - mock_request.get( - "https://api.wall-box.com/chargers/status/12345", - text='{"Temperature": 100, "Location": "Toronto", "Datetime": "2020-07-23", "Units": "Celsius"}', - status_code=403, - ) - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "station": "12345", - "username": "test-username", - "password": "test-password", - }, - ) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { From 327bf24940e2fcfb14372b126ea2b93eb6569ed9 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 17 Sep 2021 12:50:59 +0200 Subject: [PATCH 434/843] Prevent 3rd party lib from opening sockets in cloud tests (#56328) --- tests/components/cloud/test_google_config.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/components/cloud/test_google_config.py b/tests/components/cloud/test_google_config.py index f2528de221d..a80ccaccd6c 100644 --- a/tests/components/cloud/test_google_config.py +++ b/tests/components/cloud/test_google_config.py @@ -133,7 +133,9 @@ async def test_google_entity_registry_sync(hass, mock_cloud_login, cloud_prefs): with patch.object( config, "async_schedule_google_sync_all" - ) as mock_sync, patch.object(ga_helpers, "SYNC_DELAY", 0): + ) as mock_sync, patch.object(config, "async_sync_entities_all"), patch.object( + ga_helpers, "SYNC_DELAY", 0 + ): # Created entity hass.bus.async_fire( EVENT_ENTITY_REGISTRY_UPDATED, From 55a77b2ba26f2e4ebd1a7d4fae904e50c6a943d4 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 17 Sep 2021 12:51:25 +0200 Subject: [PATCH 435/843] Prevent 3rd party lib from opening sockets in ps4 tests (#56330) --- tests/components/ps4/conftest.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/tests/components/ps4/conftest.py b/tests/components/ps4/conftest.py index 155f1c6d5dd..5bb27012b18 100644 --- a/tests/components/ps4/conftest.py +++ b/tests/components/ps4/conftest.py @@ -1,6 +1,7 @@ """Test configuration for PS4.""" -from unittest.mock import patch +from unittest.mock import MagicMock, patch +from pyps4_2ndscreen.ddp import DEFAULT_UDP_PORT, DDPProtocol import pytest @@ -25,6 +26,19 @@ def patch_get_status(): yield mock_get_status +@pytest.fixture +def mock_ddp_endpoint(): + """Mock pyps4_2ndscreen.ddp.async_create_ddp_endpoint.""" + protocol = DDPProtocol() + protocol._local_port = DEFAULT_UDP_PORT + protocol._transport = MagicMock() + with patch( + "homeassistant.components.ps4.async_create_ddp_endpoint", + return_value=(None, protocol), + ): + yield + + @pytest.fixture(autouse=True) -def patch_io(patch_load_json, patch_save_json, patch_get_status): +def patch_io(patch_load_json, patch_save_json, patch_get_status, mock_ddp_endpoint): """Prevent PS4 doing I/O.""" From 797b68b42dfb4e341792d25fcda91b5be6be012f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 17 Sep 2021 12:51:40 +0200 Subject: [PATCH 436/843] Prevent 3rd party lib from opening sockets in rfxtrx tests (#56331) --- tests/components/rfxtrx/test_config_flow.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/tests/components/rfxtrx/test_config_flow.py b/tests/components/rfxtrx/test_config_flow.py index 2b55db0b889..07c316618e3 100644 --- a/tests/components/rfxtrx/test_config_flow.py +++ b/tests/components/rfxtrx/test_config_flow.py @@ -1,4 +1,4 @@ -"""Test the Tado config flow.""" +"""Test the Rfxtrx config flow.""" import os from unittest.mock import MagicMock, patch, sentinel @@ -32,11 +32,8 @@ def com_port(): return port -@patch( - "homeassistant.components.rfxtrx.rfxtrxmod.PyNetworkTransport.connect", - return_value=None, -) -async def test_setup_network(connect_mock, hass): +@patch("homeassistant.components.rfxtrx.rfxtrxmod.PyNetworkTransport", autospec=True) +async def test_setup_network(transport_mock, hass): """Test we can setup network.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -170,10 +167,11 @@ async def test_setup_serial_manual(com_mock, connect_mock, hass): @patch( - "homeassistant.components.rfxtrx.rfxtrxmod.PyNetworkTransport.connect", + "homeassistant.components.rfxtrx.rfxtrxmod.PyNetworkTransport", + autospec=True, side_effect=OSError, ) -async def test_setup_network_fail(connect_mock, hass): +async def test_setup_network_fail(transport_mock, hass): """Test we can setup network.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} From 5249c89c3f7513660f2540c3ae532a4894709fa7 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Fri, 17 Sep 2021 15:53:39 +0300 Subject: [PATCH 437/843] Add Shelly RPC sensor and binary sensor platforms (#56253) --- homeassistant/components/shelly/__init__.py | 4 +- .../components/shelly/binary_sensor.py | 84 ++++++++++--- homeassistant/components/shelly/const.py | 3 + homeassistant/components/shelly/entity.py | 118 +++++++++++++++++- homeassistant/components/shelly/light.py | 25 ++-- homeassistant/components/shelly/sensor.py | 103 +++++++++++++-- homeassistant/components/shelly/switch.py | 25 ++-- homeassistant/components/shelly/utils.py | 70 +++++++++-- 8 files changed, 362 insertions(+), 70 deletions(-) diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 6cd2a101a25..34a09338c81 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -59,7 +59,7 @@ from .utils import ( BLOCK_PLATFORMS: Final = ["binary_sensor", "cover", "light", "sensor", "switch"] BLOCK_SLEEPING_PLATFORMS: Final = ["binary_sensor", "sensor"] -RPC_PLATFORMS: Final = ["light", "switch"] +RPC_PLATFORMS: Final = ["binary_sensor", "light", "sensor", "switch"] _LOGGER: Final = logging.getLogger(__name__) COAP_SCHEMA: Final = vol.Schema( @@ -410,7 +410,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry, RPC_PLATFORMS ) if unload_ok: - hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][RPC].shutdown() + await hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][RPC].shutdown() hass.data[DOMAIN][DATA_CONFIG_ENTRY].pop(entry.entry_id) return unload_ok diff --git a/homeassistant/components/shelly/binary_sensor.py b/homeassistant/components/shelly/binary_sensor.py index 02183c3628e..16ffe8b4ee5 100644 --- a/homeassistant/components/shelly/binary_sensor.py +++ b/homeassistant/components/shelly/binary_sensor.py @@ -24,13 +24,20 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .entity import ( BlockAttributeDescription, RestAttributeDescription, + RpcAttributeDescription, ShellyBlockAttributeEntity, ShellyRestAttributeEntity, + ShellyRpcAttributeEntity, ShellySleepingBlockAttributeEntity, async_setup_entry_attribute_entities, async_setup_entry_rest, + async_setup_entry_rpc, +) +from .utils import ( + get_device_entry_gen, + is_block_momentary_input, + is_rpc_momentary_input, ) -from .utils import is_momentary_input SENSORS: Final = { ("device", "overtemp"): BlockAttributeDescription( @@ -69,19 +76,19 @@ SENSORS: Final = { name="Input", device_class=DEVICE_CLASS_POWER, default_enabled=False, - removal_condition=is_momentary_input, + removal_condition=is_block_momentary_input, ), ("relay", "input"): BlockAttributeDescription( name="Input", device_class=DEVICE_CLASS_POWER, default_enabled=False, - removal_condition=is_momentary_input, + removal_condition=is_block_momentary_input, ), ("device", "input"): BlockAttributeDescription( name="Input", device_class=DEVICE_CLASS_POWER, default_enabled=False, - removal_condition=is_momentary_input, + removal_condition=is_block_momentary_input, ), ("sensor", "extInput"): BlockAttributeDescription( name="External Input", @@ -112,6 +119,41 @@ REST_SENSORS: Final = { ), } +RPC_SENSORS: Final = { + "input": RpcAttributeDescription( + key="input", + name="Input", + value=lambda status, _: status["state"], + device_class=DEVICE_CLASS_POWER, + default_enabled=False, + removal_condition=is_rpc_momentary_input, + ), + "cloud": RpcAttributeDescription( + key="cloud", + name="Cloud", + value=lambda status, _: status["connected"], + device_class=DEVICE_CLASS_CONNECTIVITY, + default_enabled=False, + ), + "fwupdate": RpcAttributeDescription( + key="sys", + name="Firmware Update", + device_class=DEVICE_CLASS_UPDATE, + value=lambda status, _: status["available_updates"], + default_enabled=False, + extra_state_attributes=lambda status: { + "latest_stable_version": status["available_updates"].get( + "stable", + {"version": ""}, + )["version"], + "beta_version": status["available_updates"].get( + "beta", + {"version": ""}, + )["version"], + }, + ), +} + async def async_setup_entry( hass: HomeAssistant, @@ -119,29 +161,34 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up sensors for device.""" + if get_device_entry_gen(config_entry) == 2: + return await async_setup_entry_rpc( + hass, config_entry, async_add_entities, RPC_SENSORS, RpcBinarySensor + ) + if config_entry.data["sleep_period"]: await async_setup_entry_attribute_entities( hass, config_entry, async_add_entities, SENSORS, - ShellySleepingBinarySensor, + BlockSleepingBinarySensor, ) else: await async_setup_entry_attribute_entities( - hass, config_entry, async_add_entities, SENSORS, ShellyBinarySensor + hass, config_entry, async_add_entities, SENSORS, BlockBinarySensor ) await async_setup_entry_rest( hass, config_entry, async_add_entities, REST_SENSORS, - ShellyRestBinarySensor, + RestBinarySensor, ) -class ShellyBinarySensor(ShellyBlockAttributeEntity, BinarySensorEntity): - """Shelly binary sensor entity.""" +class BlockBinarySensor(ShellyBlockAttributeEntity, BinarySensorEntity): + """Represent a block binary sensor entity.""" @property def is_on(self) -> bool: @@ -149,8 +196,8 @@ class ShellyBinarySensor(ShellyBlockAttributeEntity, BinarySensorEntity): return bool(self.attribute_value) -class ShellyRestBinarySensor(ShellyRestAttributeEntity, BinarySensorEntity): - """Shelly REST binary sensor entity.""" +class RestBinarySensor(ShellyRestAttributeEntity, BinarySensorEntity): + """Represent a REST binary sensor entity.""" @property def is_on(self) -> bool: @@ -158,10 +205,17 @@ class ShellyRestBinarySensor(ShellyRestAttributeEntity, BinarySensorEntity): return bool(self.attribute_value) -class ShellySleepingBinarySensor( - ShellySleepingBlockAttributeEntity, BinarySensorEntity -): - """Represent a shelly sleeping binary sensor.""" +class RpcBinarySensor(ShellyRpcAttributeEntity, BinarySensorEntity): + """Represent a RPC binary sensor entity.""" + + @property + def is_on(self) -> bool: + """Return true if RPC sensor state is on.""" + return bool(self.attribute_value) + + +class BlockSleepingBinarySensor(ShellySleepingBlockAttributeEntity, BinarySensorEntity): + """Represent a block sleeping binary sensor.""" @property def is_on(self) -> bool: diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index 14b56d2c584..917c10ff57c 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -113,3 +113,6 @@ KELVIN_MIN_VALUE_WHITE: Final = 2700 KELVIN_MIN_VALUE_COLOR: Final = 3000 UPTIME_DEVIATION: Final = 5 + +# Max RPC switch/input key instances +MAX_RPC_KEY_INSTANCES = 4 diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index fd8dfe281ff..13fd3aade3b 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -24,11 +24,19 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import StateType from . import BlockDeviceWrapper, RpcDeviceWrapper, ShellyDeviceRestWrapper -from .const import AIOSHELLY_DEVICE_TIMEOUT_SEC, BLOCK, DATA_CONFIG_ENTRY, DOMAIN, REST +from .const import ( + AIOSHELLY_DEVICE_TIMEOUT_SEC, + BLOCK, + DATA_CONFIG_ENTRY, + DOMAIN, + REST, + RPC, +) from .utils import ( async_remove_shelly_entity, get_block_entity_name, get_rpc_entity_name, + get_rpc_key_instances, ) _LOGGER: Final = logging.getLogger(__name__) @@ -139,6 +147,45 @@ async def async_restore_block_attribute_entities( async_add_entities(entities) +async def async_setup_entry_rpc( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + sensors: dict[str, RpcAttributeDescription], + sensor_class: Callable, +) -> None: + """Set up entities for REST sensors.""" + wrapper: RpcDeviceWrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][ + config_entry.entry_id + ][RPC] + + entities = [] + for sensor_id in sensors: + description = sensors[sensor_id] + key_instances = get_rpc_key_instances(wrapper.device.status, description.key) + + for key in key_instances: + # Filter and remove entities that according to settings should not create an entity + if description.removal_condition and description.removal_condition( + wrapper.device.config, key + ): + domain = sensor_class.__module__.split(".")[-1] + unique_id = f"{wrapper.mac}-{key}-{sensor_id}" + await async_remove_shelly_entity(hass, domain, unique_id) + else: + entities.append((key, sensor_id, description)) + + if not entities: + return + + async_add_entities( + [ + sensor_class(wrapper, key, sensor_id, description) + for key, sensor_id, description in entities + ] + ) + + async def async_setup_entry_rest( hass: HomeAssistant, config_entry: ConfigEntry, @@ -187,6 +234,23 @@ class BlockAttributeDescription: extra_state_attributes: Callable[[Block], dict | None] | None = None +@dataclass +class RpcAttributeDescription: + """Class to describe a RPC sensor.""" + + key: str + name: str + icon: str | None = None + unit: str | None = None + value: Callable[[dict, Any], Any] | None = None + device_class: str | None = None + state_class: str | None = None + default_enabled: bool = True + available: Callable[[dict], bool] | None = None + removal_condition: Callable[[dict, str], bool] | None = None + extra_state_attributes: Callable[[dict], dict | None] | None = None + + @dataclass class RestAttributeDescription: """Class to describe a REST sensor.""" @@ -472,6 +536,58 @@ class ShellyRestAttributeEntity(update_coordinator.CoordinatorEntity): return self.description.extra_state_attributes(self.wrapper.device.status) +class ShellyRpcAttributeEntity(ShellyRpcEntity, entity.Entity): + """Helper class to represent a rpc attribute.""" + + def __init__( + self, + wrapper: RpcDeviceWrapper, + key: str, + attribute: str, + description: RpcAttributeDescription, + ) -> None: + """Initialize sensor.""" + super().__init__(wrapper, key) + self.attribute = attribute + self.description = description + + self._attr_unique_id = f"{super().unique_id}-{attribute}" + self._attr_name = get_rpc_entity_name(wrapper.device, key, description.name) + self._attr_entity_registry_enabled_default = description.default_enabled + self._attr_device_class = description.device_class + self._attr_icon = description.icon + self._last_value = None + + @property + def attribute_value(self) -> StateType: + """Value of sensor.""" + if callable(self.description.value): + self._last_value = self.description.value( + self.wrapper.device.status[self.key], self._last_value + ) + return self._last_value + + @property + def available(self) -> bool: + """Available.""" + available = super().available + + if not available or not self.description.available: + return available + + return self.description.available(self.wrapper.device.status[self.key]) + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """Return the state attributes.""" + if self.description.extra_state_attributes is None: + return None + + return self.description.extra_state_attributes( + self.wrapper.device.status[self.key] + ) + + class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity, RestoreEntity): """Represent a shelly sleeping block attribute entity.""" diff --git a/homeassistant/components/shelly/light.py b/homeassistant/components/shelly/light.py index bb636013361..6a1035816a5 100644 --- a/homeassistant/components/shelly/light.py +++ b/homeassistant/components/shelly/light.py @@ -51,7 +51,7 @@ from .const import ( STANDARD_RGB_EFFECTS, ) from .entity import ShellyBlockEntity, ShellyRpcEntity -from .utils import async_remove_shelly_entity, get_device_entry_gen +from .utils import async_remove_shelly_entity, get_device_entry_gen, get_rpc_key_ids _LOGGER: Final = logging.getLogger(__name__) @@ -106,25 +106,22 @@ async def async_setup_rpc_entry( ) -> None: """Set up entities for RPC device.""" wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][RPC] + switch_key_ids = get_rpc_key_ids(wrapper.device.status, "switch") - switch_keys = [] - for i in range(4): - key = f"switch:{i}" - if not wrapper.device.status.get(key): - continue - + switch_ids = [] + for id_ in switch_key_ids: con_types = wrapper.device.config["sys"]["ui_data"].get("consumption_types") - if con_types is None or con_types[i] != "lights": + if con_types is None or con_types[id_] != "lights": continue - switch_keys.append((key, i)) - unique_id = f"{wrapper.mac}-{key}" + switch_ids.append(id_) + unique_id = f"{wrapper.mac}-switch:{id_}" await async_remove_shelly_entity(hass, "switch", unique_id) - if not switch_keys: + if not switch_ids: return - async_add_entities(RpcShellyLight(wrapper, key, id_) for key, id_ in switch_keys) + async_add_entities(RpcShellyLight(wrapper, id_) for id_ in switch_ids) class BlockShellyLight(ShellyBlockEntity, LightEntity): @@ -417,9 +414,9 @@ class BlockShellyLight(ShellyBlockEntity, LightEntity): class RpcShellyLight(ShellyRpcEntity, LightEntity): """Entity that controls a light on RPC based Shelly devices.""" - def __init__(self, wrapper: RpcDeviceWrapper, key: str, id_: int) -> None: + def __init__(self, wrapper: RpcDeviceWrapper, id_: int) -> None: """Initialize light.""" - super().__init__(wrapper, key) + super().__init__(wrapper, f"switch:{id_}") self._id = id_ @property diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 7ffaae82daa..8a1ac340d31 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -16,6 +16,7 @@ from homeassistant.const import ( PERCENTAGE, POWER_WATT, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -25,13 +26,16 @@ from .const import SHAIR_MAX_WORK_HOURS from .entity import ( BlockAttributeDescription, RestAttributeDescription, + RpcAttributeDescription, ShellyBlockAttributeEntity, ShellyRestAttributeEntity, + ShellyRpcAttributeEntity, ShellySleepingBlockAttributeEntity, async_setup_entry_attribute_entities, async_setup_entry_rest, + async_setup_entry_rpc, ) -from .utils import get_device_uptime, temperature_unit +from .utils import get_device_entry_gen, get_device_uptime, temperature_unit SENSORS: Final = { ("device", "battery"): BlockAttributeDescription( @@ -220,7 +224,60 @@ REST_SENSORS: Final = { ), "uptime": RestAttributeDescription( name="Uptime", - value=get_device_uptime, + value=lambda status, last: get_device_uptime(status["uptime"], last), + device_class=sensor.DEVICE_CLASS_TIMESTAMP, + default_enabled=False, + ), +} + + +RPC_SENSORS: Final = { + "power": RpcAttributeDescription( + key="switch", + name="Power", + unit=POWER_WATT, + value=lambda status, _: round(float(status["apower"]), 1), + device_class=sensor.DEVICE_CLASS_POWER, + state_class=sensor.STATE_CLASS_MEASUREMENT, + ), + "voltage": RpcAttributeDescription( + key="switch", + name="Voltage", + unit=ELECTRIC_POTENTIAL_VOLT, + value=lambda status, _: round(float(status["voltage"]), 1), + device_class=sensor.DEVICE_CLASS_VOLTAGE, + state_class=sensor.STATE_CLASS_MEASUREMENT, + ), + "energy": RpcAttributeDescription( + key="switch", + name="Energy", + unit=ENERGY_KILO_WATT_HOUR, + value=lambda status, _: round(status["aenergy"]["total"] / 1000, 2), + device_class=sensor.DEVICE_CLASS_ENERGY, + state_class=sensor.STATE_CLASS_TOTAL_INCREASING, + ), + "temperature": RpcAttributeDescription( + key="switch", + name="Temperature", + unit=TEMP_CELSIUS, + value=lambda status, _: round(status["temperature"]["tC"], 1), + device_class=sensor.DEVICE_CLASS_TEMPERATURE, + state_class=sensor.STATE_CLASS_MEASUREMENT, + default_enabled=False, + ), + "rssi": RpcAttributeDescription( + key="wifi", + name="RSSI", + unit=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + value=lambda status, _: status["rssi"], + device_class=sensor.DEVICE_CLASS_SIGNAL_STRENGTH, + state_class=sensor.STATE_CLASS_MEASUREMENT, + default_enabled=False, + ), + "uptime": RpcAttributeDescription( + key="sys", + name="Uptime", + value=lambda status, last: get_device_uptime(status["uptime"], last), device_class=sensor.DEVICE_CLASS_TIMESTAMP, default_enabled=False, ), @@ -233,21 +290,26 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up sensors for device.""" + if get_device_entry_gen(config_entry) == 2: + return await async_setup_entry_rpc( + hass, config_entry, async_add_entities, RPC_SENSORS, RpcSensor + ) + if config_entry.data["sleep_period"]: await async_setup_entry_attribute_entities( - hass, config_entry, async_add_entities, SENSORS, ShellySleepingSensor + hass, config_entry, async_add_entities, SENSORS, BlockSleepingSensor ) else: await async_setup_entry_attribute_entities( - hass, config_entry, async_add_entities, SENSORS, ShellySensor + hass, config_entry, async_add_entities, SENSORS, BlockSensor ) await async_setup_entry_rest( - hass, config_entry, async_add_entities, REST_SENSORS, ShellyRestSensor + hass, config_entry, async_add_entities, REST_SENSORS, RestSensor ) -class ShellySensor(ShellyBlockAttributeEntity, SensorEntity): - """Represent a shelly sensor.""" +class BlockSensor(ShellyBlockAttributeEntity, SensorEntity): + """Represent a block sensor.""" @property def native_value(self) -> StateType: @@ -265,8 +327,8 @@ class ShellySensor(ShellyBlockAttributeEntity, SensorEntity): return cast(str, self._unit) -class ShellyRestSensor(ShellyRestAttributeEntity, SensorEntity): - """Represent a shelly REST sensor.""" +class RestSensor(ShellyRestAttributeEntity, SensorEntity): + """Represent a REST sensor.""" @property def native_value(self) -> StateType: @@ -284,8 +346,27 @@ class ShellyRestSensor(ShellyRestAttributeEntity, SensorEntity): return self.description.unit -class ShellySleepingSensor(ShellySleepingBlockAttributeEntity, SensorEntity): - """Represent a shelly sleeping sensor.""" +class RpcSensor(ShellyRpcAttributeEntity, SensorEntity): + """Represent a RPC sensor.""" + + @property + def native_value(self) -> StateType: + """Return value of sensor.""" + return self.attribute_value + + @property + def state_class(self) -> str | None: + """State class of sensor.""" + return self.description.state_class + + @property + def native_unit_of_measurement(self) -> str | None: + """Return unit of sensor.""" + return self.description.unit + + +class BlockSleepingSensor(ShellySleepingBlockAttributeEntity, SensorEntity): + """Represent a block sleeping sensor.""" @property def native_value(self) -> StateType: diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index 97f44d9c40e..d6e8fa11798 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -13,7 +13,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import BlockDeviceWrapper, RpcDeviceWrapper from .const import BLOCK, DATA_CONFIG_ENTRY, DOMAIN, RPC from .entity import ShellyBlockEntity, ShellyRpcEntity -from .utils import async_remove_shelly_entity, get_device_entry_gen +from .utils import async_remove_shelly_entity, get_device_entry_gen, get_rpc_key_ids async def async_setup_entry( @@ -72,25 +72,22 @@ async def async_setup_rpc_entry( ) -> None: """Set up entities for RPC device.""" wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][RPC] + switch_key_ids = get_rpc_key_ids(wrapper.device.status, "switch") - switch_keys = [] - for i in range(4): - key = f"switch:{i}" - if not wrapper.device.status.get(key): - continue - + switch_ids = [] + for id_ in switch_key_ids: con_types = wrapper.device.config["sys"]["ui_data"].get("consumption_types") - if con_types is not None and con_types[i] == "lights": + if con_types is not None and con_types[id_] == "lights": continue - switch_keys.append((key, i)) - unique_id = f"{wrapper.mac}-{key}" + switch_ids.append(id_) + unique_id = f"{wrapper.mac}-switch:{id_}" await async_remove_shelly_entity(hass, "light", unique_id) - if not switch_keys: + if not switch_ids: return - async_add_entities(RpcRelaySwitch(wrapper, key, id_) for key, id_ in switch_keys) + async_add_entities(RpcRelaySwitch(wrapper, id_) for id_ in switch_ids) class BlockRelaySwitch(ShellyBlockEntity, SwitchEntity): @@ -129,9 +126,9 @@ class BlockRelaySwitch(ShellyBlockEntity, SwitchEntity): class RpcRelaySwitch(ShellyRpcEntity, SwitchEntity): """Entity that controls a relay on RPC based Shelly devices.""" - def __init__(self, wrapper: RpcDeviceWrapper, key: str, id_: int) -> None: + def __init__(self, wrapper: RpcDeviceWrapper, id_: int) -> None: """Initialize relay switch.""" - super().__init__(wrapper, key) + super().__init__(wrapper, f"switch:{id_}") self._id = id_ @property diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 10046ccd4b0..13b34ef5aea 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -21,6 +21,7 @@ from .const import ( CONF_COAP_PORT, DEFAULT_COAP_PORT, DOMAIN, + MAX_RPC_KEY_INSTANCES, SHBTN_INPUTS_EVENTS_TYPES, SHBTN_MODELS, SHIX3_1_INPUTS_EVENTS_TYPES, @@ -88,7 +89,7 @@ def get_block_entity_name( description: str | None = None, ) -> str: """Naming for block based switch and sensors.""" - channel_name = get_device_channel_name(device, block) + channel_name = get_block_channel_name(device, block) if description: return f"{channel_name} {description}" @@ -96,7 +97,7 @@ def get_block_entity_name( return channel_name -def get_device_channel_name(device: BlockDevice, block: Block | None) -> str: +def get_block_channel_name(device: BlockDevice, block: Block | None) -> str: """Get name based on device and channel name.""" entity_name = get_block_device_name(device) @@ -125,8 +126,8 @@ def get_device_channel_name(device: BlockDevice, block: Block | None) -> str: return f"{entity_name} channel {chr(int(block.channel)+base)}" -def is_momentary_input(settings: dict[str, Any], block: Block) -> bool: - """Return true if input button settings is set to a momentary type.""" +def is_block_momentary_input(settings: dict[str, Any], block: Block) -> bool: + """Return true if block input button settings is set to a momentary type.""" # Shelly Button type is fixed to momentary and no btn_type if settings["device"]["type"] in SHBTN_MODELS: return True @@ -147,9 +148,9 @@ def is_momentary_input(settings: dict[str, Any], block: Block) -> bool: return button_type in ["momentary", "momentary_on_release"] -def get_device_uptime(status: dict[str, Any], last_uptime: str | None) -> str: +def get_device_uptime(uptime: float, last_uptime: str | None) -> str: """Return device uptime string, tolerate up to 5 seconds deviation.""" - delta_uptime = utcnow() - timedelta(seconds=status["uptime"]) + delta_uptime = utcnow() - timedelta(seconds=uptime) if ( not last_uptime @@ -166,7 +167,7 @@ def get_input_triggers(device: BlockDevice, block: Block) -> list[tuple[str, str if "inputEvent" not in block.sensor_ids or "inputEventCnt" not in block.sensor_ids: return [] - if not is_momentary_input(device.settings, block): + if not is_block_momentary_input(device.settings, block): return [] triggers = [] @@ -240,21 +241,64 @@ def get_model_name(info: dict[str, Any]) -> str: return cast(str, MODEL_NAMES.get(info["type"], info["type"])) +def get_rpc_channel_name(device: RpcDevice, key: str) -> str: + """Get name based on device and channel name.""" + key = key.replace("input", "switch") + device_name = get_rpc_device_name(device) + entity_name: str | None = device.config[key].get("name", device_name) + + if entity_name is None: + return f"{device_name} {key.replace(':', '_')}" + + return entity_name + + def get_rpc_entity_name( device: RpcDevice, key: str, description: str | None = None ) -> str: """Naming for RPC based switch and sensors.""" - entity_name: str | None = device.config[key].get("name") - - if entity_name is None: - entity_name = f"{get_rpc_device_name(device)} {key.replace(':', '_')}" + channel_name = get_rpc_channel_name(device, key) if description: - return f"{entity_name} {description}" + return f"{channel_name} {description}" - return entity_name + return channel_name def get_device_entry_gen(entry: ConfigEntry) -> int: """Return the device generation from config entry.""" return entry.data.get("gen", 1) + + +def get_rpc_key_instances(keys_dict: dict[str, Any], key: str) -> list[str]: + """Return list of key instances for RPC device from a dict.""" + if key in keys_dict: + return [key] + + keys_list: list[str] = [] + for i in range(MAX_RPC_KEY_INSTANCES): + key_inst = f"{key}:{i}" + if key_inst not in keys_dict: + return keys_list + + keys_list.append(key_inst) + + return keys_list + + +def get_rpc_key_ids(keys_dict: dict[str, Any], key: str) -> list[int]: + """Return list of key ids for RPC device from a dict.""" + key_ids: list[int] = [] + for i in range(MAX_RPC_KEY_INSTANCES): + key_inst = f"{key}:{i}" + if key_inst not in keys_dict: + return key_ids + + key_ids.append(i) + + return key_ids + + +def is_rpc_momentary_input(config: dict[str, Any], key: str) -> bool: + """Return true if rpc input button settings is set to a momentary type.""" + return cast(bool, config[key]["type"] == "button") From ecf4a7813a7d868578a74fd8a61d18b0cf8164bf Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 17 Sep 2021 15:27:26 +0200 Subject: [PATCH 438/843] Prevent 3rd party lib from opening sockets in wilight tests (#56310) --- tests/components/wilight/test_init.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/components/wilight/test_init.py b/tests/components/wilight/test_init.py index 24efdaaa8e1..5aadce3caea 100644 --- a/tests/components/wilight/test_init.py +++ b/tests/components/wilight/test_init.py @@ -4,6 +4,7 @@ from unittest.mock import patch import pytest import pywilight from pywilight.const import DOMAIN +import requests from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -41,7 +42,11 @@ def mock_dummy_device_from_host(): async def test_config_entry_not_ready(hass: HomeAssistant) -> None: """Test the WiLight configuration entry not ready.""" - entry = await setup_integration(hass) + with patch( + "pywilight.device_from_host", + side_effect=requests.exceptions.Timeout, + ): + entry = await setup_integration(hass) assert entry.state is ConfigEntryState.SETUP_RETRY From 9b00e0cb7a646a1c1207622d104359677959405c Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Fri, 17 Sep 2021 15:28:43 +0200 Subject: [PATCH 439/843] Rfxtrx device triggers and actions (#47909) * Add helper * Add device actions * Add trigger * Just make use of standard command * Generalize code a bit * Switch tests to currently existing features * Add tests for capabilities * Don't check schema asserted value * Adjust strings somewhat * Directly expose action subtypes * Add a status event test * Switch to modern typing * Drop chime that is now part of command * Adjust strings a bit * Drop ability to set custom value * Adjust changed base schema * Validate triggers * Try fix typing for 3.8 --- .../components/rfxtrx/device_action.py | 99 +++++++++ .../components/rfxtrx/device_trigger.py | 110 ++++++++++ homeassistant/components/rfxtrx/helpers.py | 22 ++ homeassistant/components/rfxtrx/strings.json | 10 + .../components/rfxtrx/translations/en.json | 15 +- tests/components/rfxtrx/test_device_action.py | 206 ++++++++++++++++++ .../components/rfxtrx/test_device_trigger.py | 186 ++++++++++++++++ 7 files changed, 646 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/rfxtrx/device_action.py create mode 100644 homeassistant/components/rfxtrx/device_trigger.py create mode 100644 homeassistant/components/rfxtrx/helpers.py create mode 100644 tests/components/rfxtrx/test_device_action.py create mode 100644 tests/components/rfxtrx/test_device_trigger.py diff --git a/homeassistant/components/rfxtrx/device_action.py b/homeassistant/components/rfxtrx/device_action.py new file mode 100644 index 00000000000..37fb39cb499 --- /dev/null +++ b/homeassistant/components/rfxtrx/device_action.py @@ -0,0 +1,99 @@ +"""Provides device automations for RFXCOM RFXtrx.""" +from __future__ import annotations + +import voluptuous as vol + +from homeassistant.components.device_automation.exceptions import ( + InvalidDeviceAutomationConfig, +) +from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_TYPE +from homeassistant.core import Context, HomeAssistant +import homeassistant.helpers.config_validation as cv + +from . import DATA_RFXOBJECT, DOMAIN +from .helpers import async_get_device_object + +CONF_DATA = "data" +CONF_SUBTYPE = "subtype" + +ACTION_TYPE_COMMAND = "send_command" +ACTION_TYPE_STATUS = "send_status" + +ACTION_TYPES = { + ACTION_TYPE_COMMAND, + ACTION_TYPE_STATUS, +} + +ACTION_SELECTION = { + ACTION_TYPE_COMMAND: "COMMANDS", + ACTION_TYPE_STATUS: "STATUS", +} + +ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): vol.In(ACTION_TYPES), + vol.Required(CONF_SUBTYPE): str, + } +) + + +async def async_get_actions(hass: HomeAssistant, device_id: str) -> list[dict]: + """List device actions for RFXCOM RFXtrx devices.""" + + try: + device = async_get_device_object(hass, device_id) + except ValueError: + return [] + + actions = [] + for action_type in ACTION_TYPES: + if hasattr(device, action_type): + values = getattr(device, ACTION_SELECTION[action_type], {}) + for value in values.values(): + actions.append( + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_TYPE: action_type, + CONF_SUBTYPE: value, + } + ) + + return actions + + +def _get_commands(hass, device_id, action_type): + device = async_get_device_object(hass, device_id) + send_fun = getattr(device, action_type) + commands = getattr(device, ACTION_SELECTION[action_type], {}) + return commands, send_fun + + +async def async_validate_action_config(hass, config): + """Validate config.""" + config = ACTION_SCHEMA(config) + commands, _ = _get_commands(hass, config[CONF_DEVICE_ID], config[CONF_TYPE]) + sub_type = config[CONF_SUBTYPE] + + if sub_type not in commands.values(): + raise InvalidDeviceAutomationConfig( + f"Subtype {sub_type} not found in device commands {commands}" + ) + + return config + + +async def async_call_action_from_config( + hass: HomeAssistant, config: dict, variables: dict, context: Context | None +) -> None: + """Execute a device action.""" + config = ACTION_SCHEMA(config) + + rfx = hass.data[DOMAIN][DATA_RFXOBJECT] + commands, send_fun = _get_commands(hass, config[CONF_DEVICE_ID], config[CONF_TYPE]) + sub_type = config[CONF_SUBTYPE] + + for key, value in commands.items(): + if value == sub_type: + await hass.async_add_executor_job(send_fun, rfx.transport, key) + return diff --git a/homeassistant/components/rfxtrx/device_trigger.py b/homeassistant/components/rfxtrx/device_trigger.py new file mode 100644 index 00000000000..55430ad3fe2 --- /dev/null +++ b/homeassistant/components/rfxtrx/device_trigger.py @@ -0,0 +1,110 @@ +"""Provides device automations for RFXCOM RFXtrx.""" +from __future__ import annotations + +import voluptuous as vol + +from homeassistant.components.automation import AutomationActionType +from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA +from homeassistant.components.device_automation.exceptions import ( + InvalidDeviceAutomationConfig, +) +from homeassistant.components.homeassistant.triggers import event as event_trigger +from homeassistant.components.rfxtrx.const import EVENT_RFXTRX_EVENT +from homeassistant.const import ( + ATTR_DEVICE_ID, + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_PLATFORM, + CONF_TYPE, +) +from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.helpers.typing import ConfigType + +from . import DOMAIN +from .helpers import async_get_device_object + +CONF_SUBTYPE = "subtype" + +CONF_TYPE_COMMAND = "command" +CONF_TYPE_STATUS = "status" + +TRIGGER_SELECTION = { + CONF_TYPE_COMMAND: "COMMANDS", + CONF_TYPE_STATUS: "STATUS", +} +TRIGGER_TYPES = [ + CONF_TYPE_COMMAND, + CONF_TYPE_STATUS, +] +TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): vol.In(TRIGGER_TYPES), + vol.Required(CONF_SUBTYPE): str, + } +) + + +async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: + """List device triggers for RFXCOM RFXtrx devices.""" + device = async_get_device_object(hass, device_id) + + triggers = [] + for conf_type in TRIGGER_TYPES: + data = getattr(device, TRIGGER_SELECTION[conf_type], {}) + for command in data.values(): + triggers.append( + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_TYPE: conf_type, + CONF_SUBTYPE: command, + } + ) + return triggers + + +async def async_validate_trigger_config(hass, config): + """Validate config.""" + config = TRIGGER_SCHEMA(config) + + device = async_get_device_object(hass, config[CONF_DEVICE_ID]) + + action_type = config[CONF_TYPE] + sub_type = config[CONF_SUBTYPE] + commands = getattr(device, TRIGGER_SELECTION[action_type], {}) + if config[CONF_SUBTYPE] not in commands.values(): + raise InvalidDeviceAutomationConfig( + f"Subtype {sub_type} not found in device triggers {commands}" + ) + + return config + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: AutomationActionType, + automation_info: dict, +) -> CALLBACK_TYPE: + """Attach a trigger.""" + config = TRIGGER_SCHEMA(config) + + event_data = {ATTR_DEVICE_ID: config[CONF_DEVICE_ID]} + + if config[CONF_TYPE] == CONF_TYPE_COMMAND: + event_data["values"] = {"Command": config[CONF_SUBTYPE]} + elif config[CONF_TYPE] == CONF_TYPE_STATUS: + event_data["values"] = {"Status": config[CONF_SUBTYPE]} + + event_config = event_trigger.TRIGGER_SCHEMA( + { + event_trigger.CONF_PLATFORM: "event", + event_trigger.CONF_EVENT_TYPE: EVENT_RFXTRX_EVENT, + event_trigger.CONF_EVENT_DATA: event_data, + } + ) + + return await event_trigger.async_attach_trigger( + hass, event_config, action, automation_info, platform_type="device" + ) diff --git a/homeassistant/components/rfxtrx/helpers.py b/homeassistant/components/rfxtrx/helpers.py new file mode 100644 index 00000000000..ad7d049fb4c --- /dev/null +++ b/homeassistant/components/rfxtrx/helpers.py @@ -0,0 +1,22 @@ +"""Provides helpers for RFXtrx.""" + + +from RFXtrx import get_device + +from homeassistant.core import callback +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.typing import HomeAssistantType + + +@callback +def async_get_device_object(hass: HomeAssistantType, device_id): + """Get a device for the given device registry id.""" + device_registry = dr.async_get(hass) + registry_device = device_registry.async_get(device_id) + if registry_device is None: + raise ValueError(f"Device {device_id} not found") + + device_tuple = list(list(registry_device.identifiers)[0]) + return get_device( + int(device_tuple[1], 16), int(device_tuple[2], 16), device_tuple[3] + ) diff --git a/homeassistant/components/rfxtrx/strings.json b/homeassistant/components/rfxtrx/strings.json index c89fcddb002..75c0de88f13 100644 --- a/homeassistant/components/rfxtrx/strings.json +++ b/homeassistant/components/rfxtrx/strings.json @@ -70,5 +70,15 @@ "invalid_input_off_delay": "Invalid input for off delay", "unknown": "[%key:common::config_flow::error::unknown%]" } + }, + "device_automation": { + "action_type": { + "send_status": "Send status update: {subtype}", + "send_command": "Send command: {subtype}" + }, + "trigger_type": { + "status": "Received status: {subtype}", + "command": "Received command: {subtype}" + } } } diff --git a/homeassistant/components/rfxtrx/translations/en.json b/homeassistant/components/rfxtrx/translations/en.json index 5e3f551e0cf..69be3726865 100644 --- a/homeassistant/components/rfxtrx/translations/en.json +++ b/homeassistant/components/rfxtrx/translations/en.json @@ -70,5 +70,16 @@ "title": "Configure device options" } } - } -} \ No newline at end of file + }, + "device_automation": { + "action_type": { + "send_status": "Send status update: {subtype}", + "send_command": "Send command: {subtype}" + }, + "trigger_type": { + "status": "Received status: {subtype}", + "command": "Received command: {subtype}" + } + }, + "title": "Rfxtrx" +} diff --git a/tests/components/rfxtrx/test_device_action.py b/tests/components/rfxtrx/test_device_action.py new file mode 100644 index 00000000000..cedf2082fb2 --- /dev/null +++ b/tests/components/rfxtrx/test_device_action.py @@ -0,0 +1,206 @@ +"""The tests for RFXCOM RFXtrx device actions.""" +from __future__ import annotations + +from typing import Any, NamedTuple + +import RFXtrx +import pytest + +import homeassistant.components.automation as automation +from homeassistant.components.rfxtrx import DOMAIN +from homeassistant.helpers.device_registry import DeviceRegistry +from homeassistant.setup import async_setup_component + +from tests.common import ( + MockConfigEntry, + assert_lists_same, + async_get_device_automations, + async_mock_service, + mock_device_registry, + mock_registry, +) +from tests.components.rfxtrx.conftest import create_rfx_test_cfg + + +@pytest.fixture(name="device_reg") +def device_reg_fixture(hass): + """Return an empty, loaded, registry.""" + return mock_device_registry(hass) + + +@pytest.fixture(name="entity_reg") +def entity_reg_fixture(hass): + """Return an empty, loaded, registry.""" + return mock_registry(hass) + + +class DeviceTestData(NamedTuple): + """Test data linked to a device.""" + + code: str + device_identifiers: set[tuple[str, str, str, str]] + + +DEVICE_LIGHTING_1 = DeviceTestData("0710002a45050170", {("rfxtrx", "10", "0", "E5")}) + +DEVICE_BLINDS_1 = DeviceTestData( + "09190000009ba8010100", {("rfxtrx", "19", "0", "009ba8:1")} +) + +DEVICE_TEMPHUM_1 = DeviceTestData( + "0a52080705020095220269", {("rfxtrx", "52", "8", "05:02")} +) + + +@pytest.mark.parametrize("device", [DEVICE_LIGHTING_1, DEVICE_TEMPHUM_1]) +async def test_device_test_data(rfxtrx, device: DeviceTestData): + """Verify that our testing data remains correct.""" + pkt: RFXtrx.lowlevel.Packet = RFXtrx.lowlevel.parse(bytearray.fromhex(device.code)) + assert device.device_identifiers == { + ("rfxtrx", f"{pkt.packettype:x}", f"{pkt.subtype:x}", pkt.id_string) + } + + +async def setup_entry(hass, devices): + """Construct a config setup.""" + entry_data = create_rfx_test_cfg(devices=devices) + mock_entry = MockConfigEntry(domain="rfxtrx", unique_id=DOMAIN, data=entry_data) + + mock_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + await hass.async_start() + + +def _get_expected_actions(data): + for value in data.values(): + yield {"type": "send_command", "subtype": value} + + +@pytest.mark.parametrize( + "device,expected", + [ + [ + DEVICE_LIGHTING_1, + list(_get_expected_actions(RFXtrx.lowlevel.Lighting1.COMMANDS)), + ], + [ + DEVICE_BLINDS_1, + list(_get_expected_actions(RFXtrx.lowlevel.RollerTrol.COMMANDS)), + ], + [DEVICE_TEMPHUM_1, []], + ], +) +async def test_get_actions(hass, device_reg: DeviceRegistry, device, expected): + """Test we get the expected actions from a rfxtrx.""" + await setup_entry(hass, {device.code: {"signal_repetitions": 1}}) + + device_entry = device_reg.async_get_device(device.device_identifiers, set()) + assert device_entry + + actions = await async_get_device_automations(hass, "action", device_entry.id) + actions = [action for action in actions if action["domain"] == DOMAIN] + + expected_actions = [ + {"domain": DOMAIN, "device_id": device_entry.id, **action_type} + for action_type in expected + ] + + assert_lists_same(actions, expected_actions) + + +@pytest.mark.parametrize( + "device,config,expected", + [ + [ + DEVICE_LIGHTING_1, + {"type": "send_command", "subtype": "On"}, + "0710000045050100", + ], + [ + DEVICE_LIGHTING_1, + {"type": "send_command", "subtype": "Off"}, + "0710000045050000", + ], + [ + DEVICE_BLINDS_1, + {"type": "send_command", "subtype": "Stop"}, + "09190000009ba8010200", + ], + ], +) +async def test_action( + hass, device_reg: DeviceRegistry, rfxtrx: RFXtrx.Connect, device, config, expected +): + """Test for actions.""" + + await setup_entry(hass, {device.code: {"signal_repetitions": 1}}) + + device_entry = device_reg.async_get_device(device.device_identifiers, set()) + assert device_entry + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "event", + "event_type": "test_event", + }, + "action": { + "domain": DOMAIN, + "device_id": device_entry.id, + **config, + }, + }, + ] + }, + ) + + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + + rfxtrx.transport.send.assert_called_once_with(bytearray.fromhex(expected)) + + +async def test_invalid_action(hass, device_reg: DeviceRegistry): + """Test for invalid actions.""" + device = DEVICE_LIGHTING_1 + notification_calls = async_mock_service(hass, "persistent_notification", "create") + + await setup_entry(hass, {device.code: {"signal_repetitions": 1}}) + + device_identifers: Any = device.device_identifiers + device_entry = device_reg.async_get_device(device_identifers, set()) + assert device_entry + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "event", + "event_type": "test_event", + }, + "action": { + "domain": DOMAIN, + "device_id": device_entry.id, + "type": "send_command", + "subtype": "invalid", + }, + }, + ] + }, + ) + await hass.async_block_till_done() + + assert len(notification_calls) == 1 + assert ( + "The following integrations and platforms could not be set up" + in notification_calls[0].data["message"] + ) diff --git a/tests/components/rfxtrx/test_device_trigger.py b/tests/components/rfxtrx/test_device_trigger.py new file mode 100644 index 00000000000..9ac2c7e9819 --- /dev/null +++ b/tests/components/rfxtrx/test_device_trigger.py @@ -0,0 +1,186 @@ +"""The tests for RFXCOM RFXtrx device triggers.""" +from __future__ import annotations + +from typing import Any, NamedTuple + +import pytest + +import homeassistant.components.automation as automation +from homeassistant.components.rfxtrx import DOMAIN +from homeassistant.helpers.device_registry import DeviceRegistry +from homeassistant.setup import async_setup_component + +from tests.common import ( + MockConfigEntry, + assert_lists_same, + async_get_device_automations, + async_mock_service, + mock_device_registry, +) +from tests.components.rfxtrx.conftest import create_rfx_test_cfg + + +class EventTestData(NamedTuple): + """Test data linked to a device.""" + + code: str + device_identifiers: set[tuple[str, str, str, str]] + type: str + subtype: str + + +DEVICE_LIGHTING_1 = {("rfxtrx", "10", "0", "E5")} +EVENT_LIGHTING_1 = EventTestData("0710002a45050170", DEVICE_LIGHTING_1, "command", "On") + +DEVICE_ROLLERTROL_1 = {("rfxtrx", "19", "0", "009ba8:1")} +EVENT_ROLLERTROL_1 = EventTestData( + "09190000009ba8010100", DEVICE_ROLLERTROL_1, "command", "Down" +) + +DEVICE_FIREALARM_1 = {("rfxtrx", "20", "3", "a10900:32")} +EVENT_FIREALARM_1 = EventTestData( + "08200300a109000670", DEVICE_FIREALARM_1, "status", "Panic" +) + + +@pytest.fixture(name="device_reg") +def device_reg_fixture(hass): + """Return an empty, loaded, registry.""" + return mock_device_registry(hass) + + +async def setup_entry(hass, devices): + """Construct a config setup.""" + entry_data = create_rfx_test_cfg(devices=devices) + mock_entry = MockConfigEntry(domain="rfxtrx", unique_id=DOMAIN, data=entry_data) + + mock_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + await hass.async_start() + + +@pytest.mark.parametrize( + "event,expected", + [ + [ + EVENT_LIGHTING_1, + [ + {"type": "command", "subtype": subtype} + for subtype in [ + "Off", + "On", + "Dim", + "Bright", + "All/group Off", + "All/group On", + "Chime", + "Illegal command", + ] + ], + ] + ], +) +async def test_get_triggers(hass, device_reg, event: EventTestData, expected): + """Test we get the expected triggers from a rfxtrx.""" + await setup_entry(hass, {event.code: {"signal_repetitions": 1}}) + + device_entry = device_reg.async_get_device(event.device_identifiers, set()) + + expected_triggers = [ + {"domain": DOMAIN, "device_id": device_entry.id, "platform": "device", **expect} + for expect in expected + ] + + triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = [value for value in triggers if value["domain"] == "rfxtrx"] + assert_lists_same(triggers, expected_triggers) + + +@pytest.mark.parametrize( + "event", + [ + EVENT_LIGHTING_1, + EVENT_ROLLERTROL_1, + EVENT_FIREALARM_1, + ], +) +async def test_firing_event(hass, device_reg: DeviceRegistry, rfxtrx, event): + """Test for turn_on and turn_off triggers firing.""" + + await setup_entry(hass, {event.code: {"fire_event": True, "signal_repetitions": 1}}) + + device_entry = device_reg.async_get_device(event.device_identifiers, set()) + assert device_entry + + calls = async_mock_service(hass, "test", "automation") + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "type": event.type, + "subtype": event.subtype, + }, + "action": { + "service": "test.automation", + "data_template": {"some": ("{{trigger.platform}}")}, + }, + }, + ] + }, + ) + await hass.async_block_till_done() + + await rfxtrx.signal(event.code) + + assert len(calls) == 1 + assert calls[0].data["some"] == "device" + + +async def test_invalid_trigger(hass, device_reg: DeviceRegistry): + """Test for invalid actions.""" + event = EVENT_LIGHTING_1 + notification_calls = async_mock_service(hass, "persistent_notification", "create") + + await setup_entry(hass, {event.code: {"fire_event": True, "signal_repetitions": 1}}) + + device_identifers: Any = event.device_identifiers + device_entry = device_reg.async_get_device(device_identifers, set()) + assert device_entry + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "type": event.type, + "subtype": "invalid", + }, + "action": { + "service": "test.automation", + "data_template": {"some": ("{{trigger.platform}}")}, + }, + }, + ] + }, + ) + await hass.async_block_till_done() + + assert len(notification_calls) == 1 + assert ( + "The following integrations and platforms could not be set up" + in notification_calls[0].data["message"] + ) From 45046941c66201259406aa07aa65df93165d992b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 17 Sep 2021 16:16:57 +0200 Subject: [PATCH 440/843] Avoid creating sockets in homekit port available tests (#56342) --- tests/components/homekit/test_config_flow.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/components/homekit/test_config_flow.py b/tests/components/homekit/test_config_flow.py index 8d68b8aba73..3cbe49f664b 100644 --- a/tests/components/homekit/test_config_flow.py +++ b/tests/components/homekit/test_config_flow.py @@ -1087,7 +1087,9 @@ async def test_converting_bridge_to_accessory_mode(hass, hk_driver, mock_get_sou with patch( "homeassistant.components.homekit.async_setup_entry", return_value=True, - ) as mock_setup_entry: + ) as mock_setup_entry, patch( + "homeassistant.components.homekit.async_port_is_available" + ): result3 = await hass.config_entries.options.async_configure( result2["flow_id"], user_input={"camera_copy": ["camera.tv"]}, From 16832bc35b708e40f59ce59778466c77085242f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Fri, 17 Sep 2021 20:01:00 +0200 Subject: [PATCH 441/843] AutomationTriggerInfo as type in rfxtrx (#56353) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * AutomationTriggerInfo as type in rfxtrx * style Signed-off-by: Daniel Hjelseth Høyer --- homeassistant/components/rfxtrx/device_trigger.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/rfxtrx/device_trigger.py b/homeassistant/components/rfxtrx/device_trigger.py index 55430ad3fe2..25c8825f6ed 100644 --- a/homeassistant/components/rfxtrx/device_trigger.py +++ b/homeassistant/components/rfxtrx/device_trigger.py @@ -3,7 +3,10 @@ from __future__ import annotations import voluptuous as vol -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.components.device_automation.exceptions import ( InvalidDeviceAutomationConfig, @@ -85,7 +88,7 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" config = TRIGGER_SCHEMA(config) From 5b0e00a74b70895a244b72a118c249ee5274eebe Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Fri, 17 Sep 2021 15:17:34 -0400 Subject: [PATCH 442/843] Refactor ZHA HVAC thermostat channel (#56238) * Refactor HVAC channel to use zigpy cached attributes * Allow named attributes in ZHA test attribute reports * Let attribute write to update cache * WIP Update tests * Cleanup --- .../components/zha/core/channels/hvac.py | 86 +++------ tests/components/zha/common.py | 11 +- tests/components/zha/conftest.py | 19 +- tests/components/zha/test_climate.py | 182 +++++++++--------- 4 files changed, 136 insertions(+), 162 deletions(-) diff --git a/homeassistant/components/zha/core/channels/hvac.py b/homeassistant/components/zha/core/channels/hvac.py index f4a3245bef8..31c75a0c794 100644 --- a/homeassistant/components/zha/core/channels/hvac.py +++ b/homeassistant/components/zha/core/channels/hvac.py @@ -125,134 +125,118 @@ class ThermostatChannel(ZigbeeChannel): "unoccupied_heating_setpoint": False, "unoccupied_cooling_setpoint": False, } - self._abs_max_cool_setpoint_limit = 3200 # 32C - self._abs_min_cool_setpoint_limit = 1600 # 16C - self._ctrl_seqe_of_oper = 0xFF - self._abs_max_heat_setpoint_limit = 3000 # 30C - self._abs_min_heat_setpoint_limit = 700 # 7C - self._running_mode = None - self._max_cool_setpoint_limit = None - self._max_heat_setpoint_limit = None - self._min_cool_setpoint_limit = None - self._min_heat_setpoint_limit = None - self._local_temp = None - self._occupancy = None - self._occupied_cooling_setpoint = None - self._occupied_heating_setpoint = None - self._pi_cooling_demand = None - self._pi_heating_demand = None - self._running_state = None - self._system_mode = None - self._unoccupied_cooling_setpoint = None - self._unoccupied_heating_setpoint = None @property def abs_max_cool_setpoint_limit(self) -> int: """Absolute maximum cooling setpoint.""" - return self._abs_max_cool_setpoint_limit + return self.cluster.get("abs_max_cool_setpoint_limit", 3200) @property def abs_min_cool_setpoint_limit(self) -> int: """Absolute minimum cooling setpoint.""" - return self._abs_min_cool_setpoint_limit + return self.cluster.get("abs_min_cool_setpoint_limit", 1600) @property def abs_max_heat_setpoint_limit(self) -> int: """Absolute maximum heating setpoint.""" - return self._abs_max_heat_setpoint_limit + return self.cluster.get("abs_max_heat_setpoint_limit", 3000) @property def abs_min_heat_setpoint_limit(self) -> int: """Absolute minimum heating setpoint.""" - return self._abs_min_heat_setpoint_limit + return self.cluster.get("abs_min_heat_setpoint_limit", 700) @property def ctrl_seqe_of_oper(self) -> int: """Control Sequence of operations attribute.""" - return self._ctrl_seqe_of_oper + return self.cluster.get("ctrl_seqe_of_oper", 0xFF) @property def max_cool_setpoint_limit(self) -> int: """Maximum cooling setpoint.""" - if self._max_cool_setpoint_limit is None: + sp_limit = self.cluster.get("max_cool_setpoint_limit") + if sp_limit is None: return self.abs_max_cool_setpoint_limit - return self._max_cool_setpoint_limit + return sp_limit @property def min_cool_setpoint_limit(self) -> int: """Minimum cooling setpoint.""" - if self._min_cool_setpoint_limit is None: + sp_limit = self.cluster.get("min_cool_setpoint_limit") + if sp_limit is None: return self.abs_min_cool_setpoint_limit - return self._min_cool_setpoint_limit + return sp_limit @property def max_heat_setpoint_limit(self) -> int: """Maximum heating setpoint.""" - if self._max_heat_setpoint_limit is None: + sp_limit = self.cluster.get("max_heat_setpoint_limit") + if sp_limit is None: return self.abs_max_heat_setpoint_limit - return self._max_heat_setpoint_limit + return sp_limit @property def min_heat_setpoint_limit(self) -> int: """Minimum heating setpoint.""" - if self._min_heat_setpoint_limit is None: + sp_limit = self.cluster.get("min_heat_setpoint_limit") + if sp_limit is None: return self.abs_min_heat_setpoint_limit - return self._min_heat_setpoint_limit + return sp_limit @property def local_temp(self) -> int | None: """Thermostat temperature.""" - return self._local_temp + return self.cluster.get("local_temp") @property def occupancy(self) -> int | None: """Is occupancy detected.""" - return self._occupancy + return self.cluster.get("occupancy") @property def occupied_cooling_setpoint(self) -> int | None: """Temperature when room is occupied.""" - return self._occupied_cooling_setpoint + return self.cluster.get("occupied_cooling_setpoint") @property def occupied_heating_setpoint(self) -> int | None: """Temperature when room is occupied.""" - return self._occupied_heating_setpoint + return self.cluster.get("occupied_heating_setpoint") @property def pi_cooling_demand(self) -> int: """Cooling demand.""" - return self._pi_cooling_demand + return self.cluster.get("pi_cooling_demand") @property def pi_heating_demand(self) -> int: """Heating demand.""" - return self._pi_heating_demand + return self.cluster.get("pi_heating_demand") @property def running_mode(self) -> int | None: """Thermostat running mode.""" - return self._running_mode + return self.cluster.get("running_mode") @property def running_state(self) -> int | None: """Thermostat running state, state of heat, cool, fan relays.""" - return self._running_state + return self.cluster.get("running_state") @property def system_mode(self) -> int | None: """System mode.""" - return self._system_mode + return self.cluster.get("system_mode") @property def unoccupied_cooling_setpoint(self) -> int | None: """Temperature when room is not occupied.""" - return self._unoccupied_cooling_setpoint + return self.cluster.get("unoccupied_cooling_setpoint") @property def unoccupied_heating_setpoint(self) -> int | None: """Temperature when room is not occupied.""" - return self._unoccupied_heating_setpoint + return self.cluster.get("unoccupied_heating_setpoint") @callback def attribute_updated(self, attrid, value): @@ -261,7 +245,6 @@ class ThermostatChannel(ZigbeeChannel): self.debug( "Attribute report '%s'[%s] = %s", self.cluster.name, attr_name, value ) - setattr(self, f"_{attr_name}", value) self.async_send_signal( f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", AttributeUpdateRecord(attrid, attr_name, value), @@ -276,8 +259,6 @@ class ThermostatChannel(ZigbeeChannel): self._init_attrs.pop(attr, None) if attr in fail: continue - if isinstance(attr, str): - setattr(self, f"_{attr}", res[attr]) self.async_send_signal( f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", AttributeUpdateRecord(None, attr, res[attr]), @@ -301,7 +282,6 @@ class ThermostatChannel(ZigbeeChannel): self.debug("couldn't set '%s' operation mode", mode) return False - self._system_mode = mode self.debug("set system to %s", mode) return True @@ -317,11 +297,6 @@ class ThermostatChannel(ZigbeeChannel): self.debug("couldn't set heating setpoint") return False - if is_away: - self._unoccupied_heating_setpoint = temperature - else: - self._occupied_heating_setpoint = temperature - self.debug("set heating setpoint to %s", temperature) return True async def async_set_cooling_setpoint( @@ -335,10 +310,6 @@ class ThermostatChannel(ZigbeeChannel): if not await self.write_attributes(data): self.debug("couldn't set cooling setpoint") return False - if is_away: - self._unoccupied_cooling_setpoint = temperature - else: - self._occupied_cooling_setpoint = temperature self.debug("set cooling setpoint to %s", temperature) return True @@ -349,7 +320,6 @@ class ThermostatChannel(ZigbeeChannel): self.debug("read 'occupancy' attr, success: %s, fail: %s", res, fail) if "occupancy" not in res: return None - self._occupancy = res["occupancy"] return bool(self.occupancy) except ZigbeeException as ex: self.debug("Couldn't read 'occupancy' attribute: %s", ex) diff --git a/tests/components/zha/common.py b/tests/components/zha/common.py index 0115838c70d..d8889a0208c 100644 --- a/tests/components/zha/common.py +++ b/tests/components/zha/common.py @@ -3,6 +3,7 @@ import asyncio import math from unittest.mock import AsyncMock, Mock +import zigpy.zcl import zigpy.zcl.foundation as zcl_f import homeassistant.components.zha.core.const as zha_const @@ -47,7 +48,8 @@ def patch_cluster(cluster): cluster.read_attributes = AsyncMock(wraps=cluster.read_attributes) cluster.read_attributes_raw = AsyncMock(side_effect=_read_attribute_raw) cluster.unbind = AsyncMock(return_value=[0]) - cluster.write_attributes = AsyncMock( + cluster.write_attributes = AsyncMock(wraps=cluster.write_attributes) + cluster._write_attributes = AsyncMock( return_value=[zcl_f.WriteAttributesResponse.deserialize(b"\x00")[0]] ) if cluster.cluster_id == 4: @@ -76,13 +78,16 @@ def send_attribute_report(hass, cluster, attrid, value): return send_attributes_report(hass, cluster, {attrid: value}) -async def send_attributes_report(hass, cluster: int, attributes: dict): +async def send_attributes_report(hass, cluster: zigpy.zcl.Cluster, attributes: dict): """Cause the sensor to receive an attribute report from the network. This is to simulate the normal device communication that happens when a device is paired to the zigbee network. """ - attrs = [make_attribute(attrid, value) for attrid, value in attributes.items()] + attrs = [ + make_attribute(cluster.attridx.get(attr, attr), value) + for attr, value in attributes.items() + ] hdr = make_zcl_header(zcl_f.Command.Report_Attributes) hdr.frame_control.disable_default_response = True cluster.handle_message(hdr, [attrs]) diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py index da76b7015c9..fd138567367 100644 --- a/tests/components/zha/conftest.py +++ b/tests/components/zha/conftest.py @@ -1,4 +1,5 @@ """Test configuration for the ZHA component.""" +import itertools import time from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch @@ -116,6 +117,7 @@ def zigpy_device_mock(zigpy_app_controller): node_descriptor=b"\x02@\x807\x10\x7fd\x00\x00*d\x00\x00", nwk=0xB79C, patch_cluster=True, + quirk=None, ): """Make a fake device using the specified cluster classes.""" device = zigpy.device.Device( @@ -133,13 +135,20 @@ def zigpy_device_mock(zigpy_app_controller): endpoint.request = AsyncMock(return_value=[0]) for cluster_id in ep.get(SIG_EP_INPUT, []): - cluster = endpoint.add_input_cluster(cluster_id) - if patch_cluster: - common.patch_cluster(cluster) + endpoint.add_input_cluster(cluster_id) for cluster_id in ep.get(SIG_EP_OUTPUT, []): - cluster = endpoint.add_output_cluster(cluster_id) - if patch_cluster: + endpoint.add_output_cluster(cluster_id) + + if quirk: + device = quirk(zigpy_app_controller, device.ieee, device.nwk, device) + + if patch_cluster: + for endpoint in (ep for epid, ep in device.endpoints.items() if epid): + endpoint.request = AsyncMock(return_value=[0]) + for cluster in itertools.chain( + endpoint.in_clusters.values(), endpoint.out_clusters.values() + ): common.patch_cluster(cluster) return device diff --git a/tests/components/zha/test_climate.py b/tests/components/zha/test_climate.py index f3808fee78d..e452d90d60f 100644 --- a/tests/components/zha/test_climate.py +++ b/tests/components/zha/test_climate.py @@ -3,6 +3,8 @@ from unittest.mock import patch import pytest +import zhaquirks.sinope.thermostat +import zhaquirks.tuya.valve import zigpy.profiles import zigpy.zcl.clusters from zigpy.zcl.clusters.hvac import Thermostat @@ -96,6 +98,12 @@ CLIMATE_SINOPE = { ], SIG_EP_OUTPUT: [zigpy.zcl.clusters.general.Ota.cluster_id, 65281], }, + 196: { + SIG_EP_PROFILE: 0xC25D, + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.THERMOSTAT, + SIG_EP_INPUT: [zigpy.zcl.clusters.general.PowerConfiguration.cluster_id], + SIG_EP_OUTPUT: [], + }, } CLIMATE_ZEN = { @@ -159,13 +167,13 @@ ZCL_ATTR_PLUG = { def device_climate_mock(hass, zigpy_device_mock, zha_device_joined): """Test regular thermostat device.""" - async def _dev(clusters, plug=None, manuf=None): + async def _dev(clusters, plug=None, manuf=None, quirk=None): if plug is None: plugged_attrs = ZCL_ATTR_PLUG else: plugged_attrs = {**ZCL_ATTR_PLUG, **plug} - zigpy_device = zigpy_device_mock(clusters, manufacturer=manuf) + zigpy_device = zigpy_device_mock(clusters, manufacturer=manuf, quirk=quirk) zigpy_device.endpoints[1].thermostat.PLUGGED_ATTR_READS = plugged_attrs zha_device = await zha_device_joined(zigpy_device) await async_enable_traffic(hass, [zha_device]) @@ -198,7 +206,11 @@ async def device_climate_fan(device_climate_mock): async def device_climate_sinope(device_climate_mock): """Sinope thermostat.""" - return await device_climate_mock(CLIMATE_SINOPE, manuf=MANUF_SINOPE) + return await device_climate_mock( + CLIMATE_SINOPE, + manuf=MANUF_SINOPE, + quirk=zhaquirks.sinope.thermostat.SinopeTechnologiesThermostat, + ) @pytest.fixture @@ -212,7 +224,9 @@ async def device_climate_zen(device_climate_mock): async def device_climate_moes(device_climate_mock): """MOES thermostat.""" - return await device_climate_mock(CLIMATE_MOES, manuf=MANUF_MOES) + return await device_climate_mock( + CLIMATE_MOES, manuf=MANUF_MOES, quirk=zhaquirks.tuya.valve.MoesHY368_Type1 + ) def test_sequence_mappings(): @@ -456,22 +470,18 @@ async def test_target_temperature( ): """Test target temperature property.""" - with patch.object( - zigpy.zcl.clusters.manufacturer_specific.ManufacturerSpecificCluster, - "ep_attribute", - "sinope_manufacturer_specific", - ): - device_climate = await device_climate_mock( - CLIMATE_SINOPE, - { - "occupied_cooling_setpoint": 2500, - "occupied_heating_setpoint": 2200, - "system_mode": sys_mode, - "unoccupied_heating_setpoint": 1600, - "unoccupied_cooling_setpoint": 2700, - }, - manuf=MANUF_SINOPE, - ) + device_climate = await device_climate_mock( + CLIMATE_SINOPE, + { + "occupied_cooling_setpoint": 2500, + "occupied_heating_setpoint": 2200, + "system_mode": sys_mode, + "unoccupied_heating_setpoint": 1600, + "unoccupied_cooling_setpoint": 2700, + }, + manuf=MANUF_SINOPE, + quirk=zhaquirks.sinope.thermostat.SinopeTechnologiesThermostat, + ) entity_id = await find_entity_id(DOMAIN, device_climate, hass) if preset: await hass.services.async_call( @@ -498,20 +508,16 @@ async def test_target_temperature_high( ): """Test target temperature high property.""" - with patch.object( - zigpy.zcl.clusters.manufacturer_specific.ManufacturerSpecificCluster, - "ep_attribute", - "sinope_manufacturer_specific", - ): - device_climate = await device_climate_mock( - CLIMATE_SINOPE, - { - "occupied_cooling_setpoint": 1700, - "system_mode": Thermostat.SystemMode.Auto, - "unoccupied_cooling_setpoint": unoccupied, - }, - manuf=MANUF_SINOPE, - ) + device_climate = await device_climate_mock( + CLIMATE_SINOPE, + { + "occupied_cooling_setpoint": 1700, + "system_mode": Thermostat.SystemMode.Auto, + "unoccupied_cooling_setpoint": unoccupied, + }, + manuf=MANUF_SINOPE, + quirk=zhaquirks.sinope.thermostat.SinopeTechnologiesThermostat, + ) entity_id = await find_entity_id(DOMAIN, device_climate, hass) if preset: await hass.services.async_call( @@ -538,20 +544,16 @@ async def test_target_temperature_low( ): """Test target temperature low property.""" - with patch.object( - zigpy.zcl.clusters.manufacturer_specific.ManufacturerSpecificCluster, - "ep_attribute", - "sinope_manufacturer_specific", - ): - device_climate = await device_climate_mock( - CLIMATE_SINOPE, - { - "occupied_heating_setpoint": 2100, - "system_mode": Thermostat.SystemMode.Auto, - "unoccupied_heating_setpoint": unoccupied, - }, - manuf=MANUF_SINOPE, - ) + device_climate = await device_climate_mock( + CLIMATE_SINOPE, + { + "occupied_heating_setpoint": 2100, + "system_mode": Thermostat.SystemMode.Auto, + "unoccupied_heating_setpoint": unoccupied, + }, + manuf=MANUF_SINOPE, + quirk=zhaquirks.sinope.thermostat.SinopeTechnologiesThermostat, + ) entity_id = await find_entity_id(DOMAIN, device_climate, hass) if preset: await hass.services.async_call( @@ -748,22 +750,18 @@ async def test_set_temperature_hvac_mode(hass, device_climate): async def test_set_temperature_heat_cool(hass, device_climate_mock): """Test setting temperature service call in heating/cooling HVAC mode.""" - with patch.object( - zigpy.zcl.clusters.manufacturer_specific.ManufacturerSpecificCluster, - "ep_attribute", - "sinope_manufacturer_specific", - ): - device_climate = await device_climate_mock( - CLIMATE_SINOPE, - { - "occupied_cooling_setpoint": 2500, - "occupied_heating_setpoint": 2000, - "system_mode": Thermostat.SystemMode.Auto, - "unoccupied_heating_setpoint": 1600, - "unoccupied_cooling_setpoint": 2700, - }, - manuf=MANUF_SINOPE, - ) + device_climate = await device_climate_mock( + CLIMATE_SINOPE, + { + "occupied_cooling_setpoint": 2500, + "occupied_heating_setpoint": 2000, + "system_mode": Thermostat.SystemMode.Auto, + "unoccupied_heating_setpoint": 1600, + "unoccupied_cooling_setpoint": 2700, + }, + manuf=MANUF_SINOPE, + quirk=zhaquirks.sinope.thermostat.SinopeTechnologiesThermostat, + ) entity_id = await find_entity_id(DOMAIN, device_climate, hass) thrm_cluster = device_climate.device.endpoints[1].thermostat @@ -838,22 +836,18 @@ async def test_set_temperature_heat_cool(hass, device_climate_mock): async def test_set_temperature_heat(hass, device_climate_mock): """Test setting temperature service call in heating HVAC mode.""" - with patch.object( - zigpy.zcl.clusters.manufacturer_specific.ManufacturerSpecificCluster, - "ep_attribute", - "sinope_manufacturer_specific", - ): - device_climate = await device_climate_mock( - CLIMATE_SINOPE, - { - "occupied_cooling_setpoint": 2500, - "occupied_heating_setpoint": 2000, - "system_mode": Thermostat.SystemMode.Heat, - "unoccupied_heating_setpoint": 1600, - "unoccupied_cooling_setpoint": 2700, - }, - manuf=MANUF_SINOPE, - ) + device_climate = await device_climate_mock( + CLIMATE_SINOPE, + { + "occupied_cooling_setpoint": 2500, + "occupied_heating_setpoint": 2000, + "system_mode": Thermostat.SystemMode.Heat, + "unoccupied_heating_setpoint": 1600, + "unoccupied_cooling_setpoint": 2700, + }, + manuf=MANUF_SINOPE, + quirk=zhaquirks.sinope.thermostat.SinopeTechnologiesThermostat, + ) entity_id = await find_entity_id(DOMAIN, device_climate, hass) thrm_cluster = device_climate.device.endpoints[1].thermostat @@ -921,22 +915,18 @@ async def test_set_temperature_heat(hass, device_climate_mock): async def test_set_temperature_cool(hass, device_climate_mock): """Test setting temperature service call in cooling HVAC mode.""" - with patch.object( - zigpy.zcl.clusters.manufacturer_specific.ManufacturerSpecificCluster, - "ep_attribute", - "sinope_manufacturer_specific", - ): - device_climate = await device_climate_mock( - CLIMATE_SINOPE, - { - "occupied_cooling_setpoint": 2500, - "occupied_heating_setpoint": 2000, - "system_mode": Thermostat.SystemMode.Cool, - "unoccupied_cooling_setpoint": 1600, - "unoccupied_heating_setpoint": 2700, - }, - manuf=MANUF_SINOPE, - ) + device_climate = await device_climate_mock( + CLIMATE_SINOPE, + { + "occupied_cooling_setpoint": 2500, + "occupied_heating_setpoint": 2000, + "system_mode": Thermostat.SystemMode.Cool, + "unoccupied_cooling_setpoint": 1600, + "unoccupied_heating_setpoint": 2700, + }, + manuf=MANUF_SINOPE, + quirk=zhaquirks.sinope.thermostat.SinopeTechnologiesThermostat, + ) entity_id = await find_entity_id(DOMAIN, device_climate, hass) thrm_cluster = device_climate.device.endpoints[1].thermostat From e880f1c8f989843419bb207d9eda1524e42b0614 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Sep 2021 10:39:00 -1000 Subject: [PATCH 443/843] Index config entries by domain (#56316) --- homeassistant/config_entries.py | 22 ++++++++++++++++++---- tests/common.py | 4 ++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 50d279ec8b0..bcc0289ea98 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -769,6 +769,7 @@ class ConfigEntries: self.options = OptionsFlowManager(hass) self._hass_config = hass_config self._entries: dict[str, ConfigEntry] = {} + self._domain_index: dict[str, list[str]] = {} self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) EntityRegistryDisabledHandler(hass).async_setup() @@ -796,7 +797,9 @@ class ConfigEntries: """Return all entries or entries for a specific domain.""" if domain is None: return list(self._entries.values()) - return [entry for entry in self._entries.values() if entry.domain == domain] + return [ + self._entries[entry_id] for entry_id in self._domain_index.get(domain, []) + ] async def async_add(self, entry: ConfigEntry) -> None: """Add and setup an entry.""" @@ -805,6 +808,7 @@ class ConfigEntries: f"An entry with the id {entry.entry_id} already exists." ) self._entries[entry.entry_id] = entry + self._domain_index.setdefault(entry.domain, []).append(entry.entry_id) await self.async_setup(entry.entry_id) self._async_schedule_save() @@ -823,6 +827,9 @@ class ConfigEntries: await entry.async_remove(self.hass) del self._entries[entry.entry_id] + self._domain_index[entry.domain].remove(entry.entry_id) + if not self._domain_index[entry.domain]: + del self._domain_index[entry.domain] self._async_schedule_save() dev_reg, ent_reg = await asyncio.gather( @@ -881,9 +888,11 @@ class ConfigEntries: if config is None: self._entries = {} + self._domain_index = {} return entries = {} + domain_index: dict[str, list[str]] = {} for entry in config["entries"]: pref_disable_new_entities = entry.get("pref_disable_new_entities") @@ -894,10 +903,13 @@ class ConfigEntries: "disable_new_entities" ) - entries[entry["entry_id"]] = ConfigEntry( + domain = entry["domain"] + entry_id = entry["entry_id"] + + entries[entry_id] = ConfigEntry( version=entry["version"], - domain=entry["domain"], - entry_id=entry["entry_id"], + domain=domain, + entry_id=entry_id, data=entry["data"], source=entry["source"], title=entry["title"], @@ -911,7 +923,9 @@ class ConfigEntries: pref_disable_new_entities=pref_disable_new_entities, pref_disable_polling=entry.get("pref_disable_polling"), ) + domain_index.setdefault(domain, []).append(entry_id) + self._domain_index = domain_index self._entries = entries async def async_setup(self, entry_id: str) -> bool: diff --git a/tests/common.py b/tests/common.py index 3d5e28be514..519b53cd991 100644 --- a/tests/common.py +++ b/tests/common.py @@ -771,10 +771,14 @@ class MockConfigEntry(config_entries.ConfigEntry): def add_to_hass(self, hass): """Test helper to add entry to hass.""" hass.config_entries._entries[self.entry_id] = self + hass.config_entries._domain_index.setdefault(self.domain, []).append( + self.entry_id + ) def add_to_manager(self, manager): """Test helper to add entry to entry manager.""" manager._entries[self.entry_id] = self + manager._domain_index.setdefault(self.domain, []).append(self.entry_id) def patch_yaml_files(files_dict, endswith=True): From f64aa0f8df19f8febd091983b5d2b7ee1ff17691 Mon Sep 17 00:00:00 2001 From: Milan Meulemans Date: Fri, 17 Sep 2021 22:43:23 +0200 Subject: [PATCH 444/843] Fix netgear strings (#56351) --- homeassistant/components/netgear/strings.json | 4 +--- homeassistant/components/netgear/translations/en.json | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/netgear/strings.json b/homeassistant/components/netgear/strings.json index 9fdd548d992..318864291f1 100644 --- a/homeassistant/components/netgear/strings.json +++ b/homeassistant/components/netgear/strings.json @@ -2,8 +2,7 @@ "config": { "step": { "user": { - "title": "Netgear", - "description": "Default host: {host}\n Default port: {port}\n Default username: {username}", + "description": "Default host: {host}\nDefault port: {port}\nDefault username: {username}", "data": { "host": "[%key:common::config_flow::data::host%] (Optional)", "port": "[%key:common::config_flow::data::port%] (Optional)", @@ -23,7 +22,6 @@ "options": { "step": { "init": { - "title": "Netgear", "description": "Specify optional settings", "data": { "consider_home": "Consider home time (seconds)" diff --git a/homeassistant/components/netgear/translations/en.json b/homeassistant/components/netgear/translations/en.json index b3c14648fb1..64dbeda0d7f 100644 --- a/homeassistant/components/netgear/translations/en.json +++ b/homeassistant/components/netgear/translations/en.json @@ -15,15 +15,13 @@ "ssl": "Use SSL (Optional)", "username": "Username (Optional)" }, - "description": "Default host: {host}\n Default port: {port}\n Default username: {username}", - "title": "Netgear" + "description": "Default host: {host}\nDefault port: {port}\nDefault username: {username}" } } }, "options": { "step": { "init": { - "title": "Netgear", "description": "Specify optional settings", "data": { "consider_home": "Consider home time (seconds)" From c4195c547c3201f0ece388307d1fe0231a3ffae7 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Sat, 18 Sep 2021 00:13:17 +0200 Subject: [PATCH 445/843] Update template/test_init.py to use pytest (#56336) --- tests/components/template/test_init.py | 263 +++++++++---------------- 1 file changed, 91 insertions(+), 172 deletions(-) diff --git a/tests/components/template/test_init.py b/tests/components/template/test_init.py index ddbb165e509..c179123e035 100644 --- a/tests/components/template/test_init.py +++ b/tests/components/template/test_init.py @@ -3,6 +3,8 @@ from datetime import timedelta from os import path from unittest.mock import patch +import pytest + from homeassistant import config from homeassistant.components.template import DOMAIN from homeassistant.helpers.reload import SERVICE_RELOAD @@ -12,13 +14,10 @@ from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed -async def test_reloadable(hass): - """Test that we can reload.""" - hass.states.async_set("sensor.test_sensor", "mytest") - - await async_setup_component( - hass, - "sensor", +@pytest.mark.parametrize("count,domain", [(1, "sensor")]) +@pytest.mark.parametrize( + "config", + [ { "sensor": { "platform": DOMAIN, @@ -48,17 +47,17 @@ async def test_reloadable(hass): }, ], }, - ) - await hass.async_block_till_done() - - await hass.async_start() + ], +) +async def test_reloadable(hass, start_ha): + """Test that we can reload.""" + hass.states.async_set("sensor.test_sensor", "mytest") await hass.async_block_till_done() assert hass.states.get("sensor.top_level_state").state == "unknown + 2" assert hass.states.get("binary_sensor.top_level_state").state == "off" hass.bus.async_fire("event_1", {"source": "init"}) await hass.async_block_till_done() - assert len(hass.states.async_all()) == 5 assert hass.states.get("sensor.state").state == "mytest" assert hass.states.get("sensor.top_level").state == "init" @@ -66,25 +65,11 @@ async def test_reloadable(hass): assert hass.states.get("sensor.top_level_state").state == "init + 2" assert hass.states.get("binary_sensor.top_level_state").state == "on" - yaml_path = path.join( - _get_fixtures_base_path(), - "fixtures", - "template/sensor_configuration.yaml", - ) - with patch.object(config, "YAML_CONFIG_FILE", yaml_path): - await hass.services.async_call( - DOMAIN, - SERVICE_RELOAD, - {}, - blocking=True, - ) - await hass.async_block_till_done() - + await async_yaml_patch_helper(hass, "sensor_configuration.yaml") assert len(hass.states.async_all()) == 4 hass.bus.async_fire("event_2", {"source": "reload"}) await hass.async_block_till_done() - assert hass.states.get("sensor.state") is None assert hass.states.get("sensor.top_level") is None assert hass.states.get("sensor.watching_tv_in_master_bedroom").state == "off" @@ -92,13 +77,10 @@ async def test_reloadable(hass): assert hass.states.get("sensor.top_level_2").state == "reload" -async def test_reloadable_can_remove(hass): - """Test that we can reload and remove all template sensors.""" - hass.states.async_set("sensor.test_sensor", "mytest") - - await async_setup_component( - hass, - "sensor", +@pytest.mark.parametrize("count,domain", [(1, "sensor")]) +@pytest.mark.parametrize( + "config", + [ { "sensor": { "platform": DOMAIN, @@ -116,43 +98,54 @@ async def test_reloadable_can_remove(hass): }, }, }, - ) + ], +) +async def test_reloadable_can_remove(hass, start_ha): + """Test that we can reload and remove all template sensors.""" + hass.states.async_set("sensor.test_sensor", "mytest") await hass.async_block_till_done() - - await hass.async_start() - await hass.async_block_till_done() - hass.bus.async_fire("event_1", {"source": "init"}) await hass.async_block_till_done() - assert len(hass.states.async_all()) == 3 assert hass.states.get("sensor.state").state == "mytest" assert hass.states.get("sensor.top_level").state == "init" - yaml_path = path.join( - _get_fixtures_base_path(), - "fixtures", - "template/empty_configuration.yaml", - ) - with patch.object(config, "YAML_CONFIG_FILE", yaml_path): - await hass.services.async_call( - DOMAIN, - SERVICE_RELOAD, - {}, - blocking=True, - ) - await hass.async_block_till_done() - + await async_yaml_patch_helper(hass, "empty_configuration.yaml") assert len(hass.states.async_all()) == 1 -async def test_reloadable_stops_on_invalid_config(hass): +@pytest.mark.parametrize("count,domain", [(1, "sensor")]) +@pytest.mark.parametrize( + "config", + [ + { + "sensor": { + "platform": DOMAIN, + "sensors": { + "state": { + "value_template": "{{ states.sensor.test_sensor.state }}" + }, + }, + } + }, + ], +) +async def test_reloadable_stops_on_invalid_config(hass, start_ha): """Test we stop the reload if configuration.yaml is completely broken.""" hass.states.async_set("sensor.test_sensor", "mytest") + await hass.async_block_till_done() + assert hass.states.get("sensor.state").state == "mytest" + assert len(hass.states.async_all()) == 2 - await async_setup_component( - hass, - "sensor", + await async_yaml_patch_helper(hass, "configuration.yaml.corrupt") + assert hass.states.get("sensor.state").state == "mytest" + assert len(hass.states.async_all()) == 2 + + +@pytest.mark.parametrize("count,domain", [(1, "sensor")]) +@pytest.mark.parametrize( + "config", + [ { "sensor": { "platform": DOMAIN, @@ -163,75 +156,16 @@ async def test_reloadable_stops_on_invalid_config(hass): }, } }, - ) - - await hass.async_block_till_done() - - await hass.async_start() - await hass.async_block_till_done() - - assert hass.states.get("sensor.state").state == "mytest" - assert len(hass.states.async_all()) == 2 - - yaml_path = path.join( - _get_fixtures_base_path(), - "fixtures", - "template/configuration.yaml.corrupt", - ) - with patch.object(config, "YAML_CONFIG_FILE", yaml_path): - await hass.services.async_call( - DOMAIN, - SERVICE_RELOAD, - {}, - blocking=True, - ) - await hass.async_block_till_done() - - assert hass.states.get("sensor.state").state == "mytest" - assert len(hass.states.async_all()) == 2 - - -async def test_reloadable_handles_partial_valid_config(hass): + ], +) +async def test_reloadable_handles_partial_valid_config(hass, start_ha): """Test we can still setup valid sensors when configuration.yaml has a broken entry.""" hass.states.async_set("sensor.test_sensor", "mytest") - - await async_setup_component( - hass, - "sensor", - { - "sensor": { - "platform": DOMAIN, - "sensors": { - "state": { - "value_template": "{{ states.sensor.test_sensor.state }}" - }, - }, - } - }, - ) - await hass.async_block_till_done() - - await hass.async_start() - await hass.async_block_till_done() - assert hass.states.get("sensor.state").state == "mytest" assert len(hass.states.async_all()) == 2 - yaml_path = path.join( - _get_fixtures_base_path(), - "fixtures", - "template/broken_configuration.yaml", - ) - with patch.object(config, "YAML_CONFIG_FILE", yaml_path): - await hass.services.async_call( - DOMAIN, - SERVICE_RELOAD, - {}, - blocking=True, - ) - await hass.async_block_till_done() - + await async_yaml_patch_helper(hass, "broken_configuration.yaml") assert len(hass.states.async_all()) == 3 assert hass.states.get("sensor.state") is None @@ -239,13 +173,10 @@ async def test_reloadable_handles_partial_valid_config(hass): assert float(hass.states.get("sensor.combined_sensor_energy_usage").state) == 0 -async def test_reloadable_multiple_platforms(hass): - """Test that we can reload.""" - hass.states.async_set("sensor.test_sensor", "mytest") - - await async_setup_component( - hass, - "sensor", +@pytest.mark.parametrize("count,domain", [(1, "sensor")]) +@pytest.mark.parametrize( + "config", + [ { "sensor": { "platform": DOMAIN, @@ -256,7 +187,11 @@ async def test_reloadable_multiple_platforms(hass): }, } }, - ) + ], +) +async def test_reloadable_multiple_platforms(hass, start_ha): + """Test that we can reload.""" + hass.states.async_set("sensor.test_sensor", "mytest") await async_setup_component( hass, "binary_sensor", @@ -272,43 +207,22 @@ async def test_reloadable_multiple_platforms(hass): }, ) await hass.async_block_till_done() - - await hass.async_start() - await hass.async_block_till_done() - assert hass.states.get("sensor.state").state == "mytest" assert hass.states.get("binary_sensor.state").state == "off" - assert len(hass.states.async_all()) == 3 - yaml_path = path.join( - _get_fixtures_base_path(), - "fixtures", - "template/sensor_configuration.yaml", - ) - with patch.object(config, "YAML_CONFIG_FILE", yaml_path): - await hass.services.async_call( - DOMAIN, - SERVICE_RELOAD, - {}, - blocking=True, - ) - await hass.async_block_till_done() - + await async_yaml_patch_helper(hass, "sensor_configuration.yaml") assert len(hass.states.async_all()) == 4 - assert hass.states.get("sensor.state") is None assert hass.states.get("sensor.watching_tv_in_master_bedroom").state == "off" assert float(hass.states.get("sensor.combined_sensor_energy_usage").state) == 0 assert hass.states.get("sensor.top_level_2") is not None -async def test_reload_sensors_that_reference_other_template_sensors(hass): - """Test that we can reload sensor that reference other template sensors.""" - - await async_setup_component( - hass, - "sensor", +@pytest.mark.parametrize("count,domain", [(1, "sensor")]) +@pytest.mark.parametrize( + "config", + [ { "sensor": { "platform": DOMAIN, @@ -317,22 +231,11 @@ async def test_reload_sensors_that_reference_other_template_sensors(hass): }, } }, - ) - await hass.async_block_till_done() - yaml_path = path.join( - _get_fixtures_base_path(), - "fixtures", - "template/ref_configuration.yaml", - ) - with patch.object(config, "YAML_CONFIG_FILE", yaml_path): - await hass.services.async_call( - DOMAIN, - SERVICE_RELOAD, - {}, - blocking=True, - ) - await hass.async_block_till_done() - + ], +) +async def test_reload_sensors_that_reference_other_template_sensors(hass, start_ha): + """Test that we can reload sensor that reference other template sensors.""" + await async_yaml_patch_helper(hass, "ref_configuration.yaml") assert len(hass.states.async_all()) == 3 await hass.async_block_till_done() @@ -342,7 +245,6 @@ async def test_reload_sensors_that_reference_other_template_sensors(hass): ): async_fire_time_changed(hass, next_time) await hass.async_block_till_done() - assert hass.states.get("sensor.test1").state == "3" assert hass.states.get("sensor.test2").state == "1" assert hass.states.get("sensor.test3").state == "2" @@ -350,3 +252,20 @@ async def test_reload_sensors_that_reference_other_template_sensors(hass): def _get_fixtures_base_path(): return path.dirname(path.dirname(path.dirname(__file__))) + + +async def async_yaml_patch_helper(hass, filename): + """Help update configuration.yaml.""" + yaml_path = path.join( + _get_fixtures_base_path(), + "fixtures", + f"template/{filename}", + ) + with patch.object(config, "YAML_CONFIG_FILE", yaml_path): + await hass.services.async_call( + DOMAIN, + SERVICE_RELOAD, + {}, + blocking=True, + ) + await hass.async_block_till_done() From f5dd71d1e040a955bdbfd6dcc2a61924ab943c7d Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Fri, 17 Sep 2021 19:52:49 -0400 Subject: [PATCH 446/843] Bump up ZHA dependencies (#56359) --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index fbaa0f84568..7f9afc472a7 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zha", "requirements": [ - "bellows==0.27.0", + "bellows==0.28.0", "pyserial==3.5", "pyserial-asyncio==0.5", "zha-quirks==0.0.61", diff --git a/requirements_all.txt b/requirements_all.txt index 16c75dc1add..505b4c01429 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -369,7 +369,7 @@ beautifulsoup4==4.9.3 # beewi_smartclim==0.0.10 # homeassistant.components.zha -bellows==0.27.0 +bellows==0.28.0 # homeassistant.components.bmw_connected_drive bimmer_connected==0.7.20 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1fc41dc7335..335e848bab0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -227,7 +227,7 @@ azure-eventhub==5.5.0 base36==0.1.1 # homeassistant.components.zha -bellows==0.27.0 +bellows==0.28.0 # homeassistant.components.bmw_connected_drive bimmer_connected==0.7.20 From 4160a5ee3b2dee863990b00edbba9c34c44fa660 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sat, 18 Sep 2021 06:51:07 +0200 Subject: [PATCH 447/843] Strict typing for SamsungTV (#53585) Co-authored-by: Franck Nijhof Co-authored-by: J. Nick Koston --- .strict-typing | 1 + .../components/samsungtv/__init__.py | 36 ++++-- homeassistant/components/samsungtv/bridge.py | 95 ++++++++------- .../components/samsungtv/config_flow.py | 111 ++++++++++++------ .../components/samsungtv/media_player.py | 108 ++++++++++------- .../components/samsungtv/strings.json | 5 +- .../components/samsungtv/translations/en.json | 4 +- mypy.ini | 11 ++ 8 files changed, 234 insertions(+), 137 deletions(-) diff --git a/.strict-typing b/.strict-typing index b664fc3b886..64fbcb9e82d 100644 --- a/.strict-typing +++ b/.strict-typing @@ -87,6 +87,7 @@ homeassistant.components.recorder.statistics homeassistant.components.remote.* homeassistant.components.renault.* homeassistant.components.rituals_perfume_genie.* +homeassistant.components.samsungtv.* homeassistant.components.scene.* homeassistant.components.select.* homeassistant.components.sensor.* diff --git a/homeassistant/components/samsungtv/__init__.py b/homeassistant/components/samsungtv/__init__.py index 773c340d7b9..f55dc0639ba 100644 --- a/homeassistant/components/samsungtv/__init__.py +++ b/homeassistant/components/samsungtv/__init__.py @@ -1,13 +1,16 @@ """The Samsung TV integration.""" +from __future__ import annotations + from functools import partial import socket +from typing import Any import getmac import voluptuous as vol from homeassistant import config_entries from homeassistant.components.media_player.const import DOMAIN as MP_DOMAIN -from homeassistant.config_entries import ConfigEntryNotReady +from homeassistant.config_entries import ConfigEntry, ConfigEntryNotReady from homeassistant.const import ( CONF_HOST, CONF_MAC, @@ -17,10 +20,17 @@ from homeassistant.const import ( CONF_TOKEN, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import callback +from homeassistant.core import Event, HomeAssistant, callback import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType -from .bridge import SamsungTVBridge, async_get_device_info, mac_from_device_info +from .bridge import ( + SamsungTVBridge, + SamsungTVLegacyBridge, + SamsungTVWSBridge, + async_get_device_info, + mac_from_device_info, +) from .const import ( CONF_ON_ACTION, DEFAULT_NAME, @@ -32,7 +42,7 @@ from .const import ( ) -def ensure_unique_hosts(value): +def ensure_unique_hosts(value: dict[Any, Any]) -> dict[Any, Any]: """Validate that all configs have a unique host.""" vol.Schema(vol.Unique("duplicate host entries found"))( [entry[CONF_HOST] for entry in value] @@ -64,7 +74,7 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup(hass, config): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Samsung TV integration.""" hass.data[DOMAIN] = {} if DOMAIN not in config: @@ -88,7 +98,9 @@ async def async_setup(hass, config): @callback -def _async_get_device_bridge(data): +def _async_get_device_bridge( + data: dict[str, Any] +) -> SamsungTVLegacyBridge | SamsungTVWSBridge: """Get device bridge.""" return SamsungTVBridge.get_bridge( data[CONF_METHOD], @@ -98,13 +110,13 @@ def _async_get_device_bridge(data): ) -async def async_setup_entry(hass, entry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the Samsung TV platform.""" # Initialize bridge bridge = await _async_create_bridge_with_updated_data(hass, entry) - def stop_bridge(event): + def stop_bridge(event: Event) -> None: """Stop SamsungTV bridge connection.""" bridge.stop() @@ -117,7 +129,9 @@ async def async_setup_entry(hass, entry): return True -async def _async_create_bridge_with_updated_data(hass, entry): +async def _async_create_bridge_with_updated_data( + hass: HomeAssistant, entry: ConfigEntry +) -> SamsungTVLegacyBridge | SamsungTVWSBridge: """Create a bridge object and update any missing data in the config entry.""" updated_data = {} host = entry.data[CONF_HOST] @@ -163,7 +177,7 @@ async def _async_create_bridge_with_updated_data(hass, entry): return bridge -async def async_unload_entry(hass, entry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: @@ -171,7 +185,7 @@ async def async_unload_entry(hass, entry): return unload_ok -async def async_migrate_entry(hass, config_entry): +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Migrate old entry.""" version = config_entry.version diff --git a/homeassistant/components/samsungtv/bridge.py b/homeassistant/components/samsungtv/bridge.py index 0d00a0cb94f..262bf4ce67f 100644 --- a/homeassistant/components/samsungtv/bridge.py +++ b/homeassistant/components/samsungtv/bridge.py @@ -1,6 +1,9 @@ """samsungctl and samsungtvws bridge classes.""" +from __future__ import annotations + from abc import ABC, abstractmethod import contextlib +from typing import Any from samsungctl import Remote from samsungctl.exceptions import AccessDenied, ConnectionClosed, UnhandledResponse @@ -17,6 +20,7 @@ from homeassistant.const import ( CONF_TIMEOUT, CONF_TOKEN, ) +from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.helpers.device_registry import format_mac from .const import ( @@ -37,7 +41,7 @@ from .const import ( ) -def mac_from_device_info(info): +def mac_from_device_info(info: dict[str, Any]) -> str | None: """Extract the mac address from the device info.""" dev_info = info.get("device", {}) if dev_info.get("networkType") == "wireless" and dev_info.get("wifiMac"): @@ -45,12 +49,18 @@ def mac_from_device_info(info): return None -async def async_get_device_info(hass, bridge, host): +async def async_get_device_info( + hass: HomeAssistant, + bridge: SamsungTVWSBridge | SamsungTVLegacyBridge | None, + host: str, +) -> tuple[int | None, str | None, dict[str, Any] | None]: """Fetch the port, method, and device info.""" return await hass.async_add_executor_job(_get_device_info, bridge, host) -def _get_device_info(bridge, host): +def _get_device_info( + bridge: SamsungTVWSBridge | SamsungTVLegacyBridge, host: str +) -> tuple[int | None, str | None, dict[str, Any] | None]: """Fetch the port, method, and device info.""" if bridge and bridge.port: return bridge.port, bridge.method, bridge.device_info() @@ -72,40 +82,42 @@ class SamsungTVBridge(ABC): """The Base Bridge abstract class.""" @staticmethod - def get_bridge(method, host, port=None, token=None): + def get_bridge( + method: str, host: str, port: int | None = None, token: str | None = None + ) -> SamsungTVLegacyBridge | SamsungTVWSBridge: """Get Bridge instance.""" if method == METHOD_LEGACY or port == LEGACY_PORT: return SamsungTVLegacyBridge(method, host, port) return SamsungTVWSBridge(method, host, port, token) - def __init__(self, method, host, port): + def __init__(self, method: str, host: str, port: int | None = None) -> None: """Initialize Bridge.""" self.port = port self.method = method self.host = host - self.token = None - self._remote = None - self._callback = None + self.token: str | None = None + self._remote: Remote | None = None + self._callback: CALLBACK_TYPE | None = None - def register_reauth_callback(self, func): + def register_reauth_callback(self, func: CALLBACK_TYPE) -> None: """Register a callback function.""" self._callback = func @abstractmethod - def try_connect(self): + def try_connect(self) -> str | None: """Try to connect to the TV.""" @abstractmethod - def device_info(self): + def device_info(self) -> dict[str, Any] | None: """Try to gather infos of this TV.""" @abstractmethod - def mac_from_device(self): + def mac_from_device(self) -> str | None: """Try to fetch the mac address of the TV.""" - def is_on(self): + def is_on(self) -> bool: """Tells if the TV is on.""" - if self._remote: + if self._remote is not None: self.close_remote() try: @@ -121,7 +133,7 @@ class SamsungTVBridge(ABC): # Different reasons, e.g. hostname not resolveable return False - def send_key(self, key): + def send_key(self, key: str) -> None: """Send a key to the tv and handles exceptions.""" try: # recreate connection if connection was dead @@ -146,14 +158,14 @@ class SamsungTVBridge(ABC): pass @abstractmethod - def _send_key(self, key): + def _send_key(self, key: str) -> None: """Send the key.""" @abstractmethod - def _get_remote(self, avoid_open: bool = False): + def _get_remote(self, avoid_open: bool = False) -> Remote: """Get Remote object.""" - def close_remote(self): + def close_remote(self) -> None: """Close remote object.""" try: if self._remote is not None: @@ -163,16 +175,16 @@ class SamsungTVBridge(ABC): except OSError: LOGGER.debug("Could not establish connection") - def _notify_callback(self): + def _notify_callback(self) -> None: """Notify access denied callback.""" - if self._callback: + if self._callback is not None: self._callback() class SamsungTVLegacyBridge(SamsungTVBridge): """The Bridge for Legacy TVs.""" - def __init__(self, method, host, port): + def __init__(self, method: str, host: str, port: int | None) -> None: """Initialize Bridge.""" super().__init__(method, host, LEGACY_PORT) self.config = { @@ -185,11 +197,11 @@ class SamsungTVLegacyBridge(SamsungTVBridge): CONF_TIMEOUT: 1, } - def mac_from_device(self): + def mac_from_device(self) -> None: """Try to fetch the mac address of the TV.""" return None - def try_connect(self): + def try_connect(self) -> str: """Try to connect to the Legacy TV.""" config = { CONF_NAME: VALUE_CONF_NAME, @@ -216,11 +228,11 @@ class SamsungTVLegacyBridge(SamsungTVBridge): LOGGER.debug("Failing config: %s, error: %s", config, err) return RESULT_CANNOT_CONNECT - def device_info(self): + def device_info(self) -> None: """Try to gather infos of this device.""" return None - def _get_remote(self, avoid_open: bool = False): + def _get_remote(self, avoid_open: bool = False) -> Remote: """Create or return a remote control instance.""" if self._remote is None: # We need to create a new instance to reconnect. @@ -238,12 +250,12 @@ class SamsungTVLegacyBridge(SamsungTVBridge): pass return self._remote - def _send_key(self, key): + def _send_key(self, key: str) -> None: """Send the key using legacy protocol.""" if remote := self._get_remote(): remote.control(key) - def stop(self): + def stop(self) -> None: """Stop Bridge.""" LOGGER.debug("Stopping SamsungTVLegacyBridge") self.close_remote() @@ -252,17 +264,19 @@ class SamsungTVLegacyBridge(SamsungTVBridge): class SamsungTVWSBridge(SamsungTVBridge): """The Bridge for WebSocket TVs.""" - def __init__(self, method, host, port, token=None): + def __init__( + self, method: str, host: str, port: int | None = None, token: str | None = None + ) -> None: """Initialize Bridge.""" super().__init__(method, host, port) self.token = token - def mac_from_device(self): + def mac_from_device(self) -> str | None: """Try to fetch the mac address of the TV.""" info = self.device_info() return mac_from_device_info(info) if info else None - def try_connect(self): + def try_connect(self) -> str: """Try to connect to the Websocket TV.""" for self.port in WEBSOCKET_PORTS: config = { @@ -286,7 +300,7 @@ class SamsungTVWSBridge(SamsungTVBridge): ) as remote: remote.open() self.token = remote.token - if self.token: + if self.token is None: config[CONF_TOKEN] = "*****" LOGGER.debug("Working config: %s", config) return RESULT_SUCCESS @@ -304,22 +318,23 @@ class SamsungTVWSBridge(SamsungTVBridge): return RESULT_CANNOT_CONNECT - def device_info(self): + def device_info(self) -> dict[str, Any] | None: """Try to gather infos of this TV.""" - remote = self._get_remote(avoid_open=True) - if not remote: - return None - with contextlib.suppress(HttpApiError): - return remote.rest_device_info() + if remote := self._get_remote(avoid_open=True): + with contextlib.suppress(HttpApiError): + device_info: dict[str, Any] = remote.rest_device_info() + return device_info - def _send_key(self, key): + return None + + def _send_key(self, key: str) -> None: """Send the key using websocket protocol.""" if key == "KEY_POWEROFF": key = "KEY_POWER" if remote := self._get_remote(): remote.send_key(key) - def _get_remote(self, avoid_open: bool = False): + def _get_remote(self, avoid_open: bool = False) -> Remote: """Create or return a remote control instance.""" if self._remote is None: # We need to create a new instance to reconnect. @@ -344,7 +359,7 @@ class SamsungTVWSBridge(SamsungTVBridge): self._remote = None return self._remote - def stop(self): + def stop(self) -> None: """Stop Bridge.""" LOGGER.debug("Stopping SamsungTVWSBridge") self.close_remote() diff --git a/homeassistant/components/samsungtv/config_flow.py b/homeassistant/components/samsungtv/config_flow.py index da13d0fe70c..bcce5eec5ed 100644 --- a/homeassistant/components/samsungtv/config_flow.py +++ b/homeassistant/components/samsungtv/config_flow.py @@ -1,5 +1,9 @@ """Config flow for Samsung TV.""" +from __future__ import annotations + import socket +from types import MappingProxyType +from typing import Any from urllib.parse import urlparse import getmac @@ -25,7 +29,13 @@ from homeassistant.core import callback from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.typing import DiscoveryInfoType -from .bridge import SamsungTVBridge, async_get_device_info, mac_from_device_info +from .bridge import ( + SamsungTVBridge, + SamsungTVLegacyBridge, + SamsungTVWSBridge, + async_get_device_info, + mac_from_device_info, +) from .const import ( ATTR_PROPERTIES, CONF_MANUFACTURER, @@ -48,11 +58,11 @@ DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str, vol.Required(CONF_NAME): SUPPORTED_METHODS = [METHOD_LEGACY, METHOD_WEBSOCKET] -def _strip_uuid(udn): +def _strip_uuid(udn: str) -> str: return udn[5:] if udn.startswith("uuid:") else udn -def _entry_is_complete(entry): +def _entry_is_complete(entry: config_entries.ConfigEntry) -> bool: """Return True if the config entry information is complete.""" return bool(entry.unique_id and entry.data.get(CONF_MAC)) @@ -62,22 +72,24 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 2 - def __init__(self): + def __init__(self) -> None: """Initialize flow.""" - self._reauth_entry = None - self._host = None - self._mac = None - self._udn = None - self._manufacturer = None - self._model = None - self._name = None - self._title = None - self._id = None - self._bridge = None - self._device_info = None + self._reauth_entry: config_entries.ConfigEntry | None = None + self._host: str = "" + self._mac: str | None = None + self._udn: str | None = None + self._manufacturer: str | None = None + self._model: str | None = None + self._name: str | None = None + self._title: str = "" + self._id: int | None = None + self._bridge: SamsungTVLegacyBridge | SamsungTVWSBridge | None = None + self._device_info: dict[str, Any] | None = None - def _get_entry_from_bridge(self): + def _get_entry_from_bridge(self) -> data_entry_flow.FlowResult: """Get device entry.""" + assert self._bridge + data = { CONF_HOST: self._host, CONF_MAC: self._mac, @@ -94,14 +106,16 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): data=data, ) - async def _async_set_device_unique_id(self, raise_on_progress=True): + async def _async_set_device_unique_id(self, raise_on_progress: bool = True) -> None: """Set device unique_id.""" if not await self._async_get_and_check_device_info(): raise data_entry_flow.AbortFlow(RESULT_NOT_SUPPORTED) await self._async_set_unique_id_from_udn(raise_on_progress) self._async_update_and_abort_for_matching_unique_id() - async def _async_set_unique_id_from_udn(self, raise_on_progress=True): + async def _async_set_unique_id_from_udn( + self, raise_on_progress: bool = True + ) -> None: """Set the unique id from the udn.""" assert self._host is not None await self.async_set_unique_id(self._udn, raise_on_progress=raise_on_progress) @@ -110,14 +124,14 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ): raise data_entry_flow.AbortFlow("already_configured") - def _async_update_and_abort_for_matching_unique_id(self): + def _async_update_and_abort_for_matching_unique_id(self) -> None: """Abort and update host and mac if we have it.""" updates = {CONF_HOST: self._host} if self._mac: updates[CONF_MAC] = self._mac self._abort_if_unique_id_configured(updates=updates) - def _try_connect(self): + def _try_connect(self) -> None: """Try to connect and check auth.""" for method in SUPPORTED_METHODS: self._bridge = SamsungTVBridge.get_bridge(method, self._host) @@ -129,7 +143,7 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): LOGGER.debug("No working config found") raise data_entry_flow.AbortFlow(RESULT_CANNOT_CONNECT) - async def _async_get_and_check_device_info(self): + async def _async_get_and_check_device_info(self) -> bool: """Try to get the device info.""" _port, _method, info = await async_get_device_info( self.hass, self._bridge, self._host @@ -160,7 +174,9 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._device_info = info return True - async def async_step_import(self, user_input=None): + async def async_step_import( + self, user_input: dict[str, Any] + ) -> data_entry_flow.FlowResult: """Handle configuration by yaml file.""" # We need to import even if we cannot validate # since the TV may be off at startup @@ -177,21 +193,24 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): data=user_input, ) - async def _async_set_name_host_from_input(self, user_input): + async def _async_set_name_host_from_input(self, user_input: dict[str, Any]) -> None: try: self._host = await self.hass.async_add_executor_job( socket.gethostbyname, user_input[CONF_HOST] ) except socket.gaierror as err: raise data_entry_flow.AbortFlow(RESULT_UNKNOWN_HOST) from err - self._name = user_input.get(CONF_NAME, self._host) + self._name = user_input.get(CONF_NAME, self._host) or "" self._title = self._name - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> data_entry_flow.FlowResult: """Handle a flow initialized by the user.""" if user_input is not None: await self._async_set_name_host_from_input(user_input) await self.hass.async_add_executor_job(self._try_connect) + assert self._bridge self._async_abort_entries_match({CONF_HOST: self._host}) if self._bridge.method != METHOD_LEGACY: # Legacy bridge does not provide device info @@ -201,7 +220,7 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA) @callback - def _async_update_existing_host_entry(self): + def _async_update_existing_host_entry(self) -> config_entries.ConfigEntry | None: """Check existing entries and update them. Returns the existing entry if it was updated. @@ -209,7 +228,7 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): for entry in self._async_current_entries(include_ignore=False): if entry.data[CONF_HOST] != self._host: continue - entry_kw_args = {} + entry_kw_args: dict = {} if self.unique_id and entry.unique_id is None: entry_kw_args["unique_id"] = self.unique_id if self._mac and not entry.data.get(CONF_MAC): @@ -222,7 +241,7 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return entry return None - async def _async_start_discovery_with_mac_address(self): + async def _async_start_discovery_with_mac_address(self) -> None: """Start discovery.""" assert self._host is not None if (entry := self._async_update_existing_host_entry()) and entry.unique_id: @@ -232,25 +251,28 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._async_abort_if_host_already_in_progress() @callback - def _async_abort_if_host_already_in_progress(self): + def _async_abort_if_host_already_in_progress(self) -> None: self.context[CONF_HOST] = self._host for progress in self._async_in_progress(): if progress.get("context", {}).get(CONF_HOST) == self._host: raise data_entry_flow.AbortFlow("already_in_progress") @callback - def _abort_if_manufacturer_is_not_samsung(self): + def _abort_if_manufacturer_is_not_samsung(self) -> None: if not self._manufacturer or not self._manufacturer.lower().startswith( "samsung" ): raise data_entry_flow.AbortFlow(RESULT_NOT_SUPPORTED) - async def async_step_ssdp(self, discovery_info: DiscoveryInfoType): + async def async_step_ssdp( + self, discovery_info: DiscoveryInfoType + ) -> data_entry_flow.FlowResult: """Handle a flow initialized by ssdp discovery.""" LOGGER.debug("Samsung device found via SSDP: %s", discovery_info) - model_name = discovery_info.get(ATTR_UPNP_MODEL_NAME) + model_name: str = discovery_info.get(ATTR_UPNP_MODEL_NAME) or "" self._udn = _strip_uuid(discovery_info[ATTR_UPNP_UDN]) - self._host = urlparse(discovery_info[ATTR_SSDP_LOCATION]).hostname + if hostname := urlparse(discovery_info[ATTR_SSDP_LOCATION]).hostname: + self._host = hostname await self._async_set_unique_id_from_udn() self._manufacturer = discovery_info[ATTR_UPNP_MANUFACTURER] self._abort_if_manufacturer_is_not_samsung() @@ -263,7 +285,9 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.context["title_placeholders"] = {"device": self._title} return await self.async_step_confirm() - async def async_step_dhcp(self, discovery_info: DiscoveryInfoType): + async def async_step_dhcp( + self, discovery_info: DiscoveryInfoType + ) -> data_entry_flow.FlowResult: """Handle a flow initialized by dhcp discovery.""" LOGGER.debug("Samsung device found via DHCP: %s", discovery_info) self._mac = discovery_info[MAC_ADDRESS] @@ -273,7 +297,9 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.context["title_placeholders"] = {"device": self._title} return await self.async_step_confirm() - async def async_step_zeroconf(self, discovery_info: DiscoveryInfoType): + async def async_step_zeroconf( + self, discovery_info: DiscoveryInfoType + ) -> data_entry_flow.FlowResult: """Handle a flow initialized by zeroconf discovery.""" LOGGER.debug("Samsung device found via ZEROCONF: %s", discovery_info) self._mac = format_mac(discovery_info[ATTR_PROPERTIES]["deviceid"]) @@ -283,11 +309,14 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.context["title_placeholders"] = {"device": self._title} return await self.async_step_confirm() - async def async_step_confirm(self, user_input=None): + async def async_step_confirm( + self, user_input: dict[str, Any] | None = None + ) -> data_entry_flow.FlowResult: """Handle user-confirmation of discovered node.""" if user_input is not None: await self.hass.async_add_executor_job(self._try_connect) + assert self._bridge return self._get_entry_from_bridge() self._set_confirm_only() @@ -295,11 +324,14 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="confirm", description_placeholders={"device": self._title} ) - async def async_step_reauth(self, data): + async def async_step_reauth( + self, data: MappingProxyType[str, Any] + ) -> data_entry_flow.FlowResult: """Handle configuration by re-auth.""" self._reauth_entry = self.hass.config_entries.async_get_entry( self.context["entry_id"] ) + assert self._reauth_entry data = self._reauth_entry.data if data.get(CONF_MODEL) and data.get(CONF_NAME): self._title = f"{data[CONF_NAME]} ({data[CONF_MODEL]})" @@ -307,9 +339,12 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._title = data.get(CONF_NAME) or data[CONF_HOST] return await self.async_step_reauth_confirm() - async def async_step_reauth_confirm(self, user_input=None): + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> data_entry_flow.FlowResult: """Confirm reauth.""" errors = {} + assert self._reauth_entry if user_input is not None: bridge = SamsungTVBridge.get_bridge( self._reauth_entry.data[CONF_METHOD], self._reauth_entry.data[CONF_HOST] diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py index 7efdcdcd439..4e17c65b461 100644 --- a/homeassistant/components/samsungtv/media_player.py +++ b/homeassistant/components/samsungtv/media_player.py @@ -1,6 +1,9 @@ """Support for interface with an Samsung TV.""" +from __future__ import annotations + import asyncio -from datetime import timedelta +from datetime import datetime, timedelta +from typing import Any import voluptuous as vol from wakeonlan import send_magic_packet @@ -19,11 +22,18 @@ from homeassistant.components.media_player.const import ( SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP, ) -from homeassistant.config_entries import SOURCE_REAUTH +from homeassistant.components.samsungtv.bridge import ( + SamsungTVLegacyBridge, + SamsungTVWSBridge, +) +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_component import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.script import Script from homeassistant.util import dt as dt_util @@ -59,7 +69,9 @@ SCAN_INTERVAL_PLUS_OFF_TIME = entity_component.DEFAULT_SCAN_INTERVAL + timedelta ) -async def async_setup_entry(hass, entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up the Samsung TV from a config entry.""" bridge = hass.data[DOMAIN][entry.entry_id] @@ -77,33 +89,38 @@ async def async_setup_entry(hass, entry, async_add_entities): class SamsungTVDevice(MediaPlayerEntity): """Representation of a Samsung TV.""" - def __init__(self, bridge, config_entry, on_script): + def __init__( + self, + bridge: SamsungTVLegacyBridge | SamsungTVWSBridge, + config_entry: ConfigEntry, + on_script: Script | None, + ) -> None: """Initialize the Samsung device.""" self._config_entry = config_entry - self._host = config_entry.data[CONF_HOST] - self._mac = config_entry.data.get(CONF_MAC) - self._manufacturer = config_entry.data.get(CONF_MANUFACTURER) - self._model = config_entry.data.get(CONF_MODEL) - self._name = config_entry.data.get(CONF_NAME) + self._host: str | None = config_entry.data[CONF_HOST] + self._mac: str | None = config_entry.data.get(CONF_MAC) + self._manufacturer: str | None = config_entry.data.get(CONF_MANUFACTURER) + self._model: str | None = config_entry.data.get(CONF_MODEL) + self._name: str | None = config_entry.data.get(CONF_NAME) self._on_script = on_script self._uuid = config_entry.unique_id # Assume that the TV is not muted - self._muted = False + self._muted: bool = False # Assume that the TV is in Play mode - self._playing = True - self._state = None + self._playing: bool = True + self._state: str | None = None # Mark the end of a shutdown command (need to wait 15 seconds before # sending the next command to avoid turning the TV back ON). - self._end_of_power_off = None + self._end_of_power_off: datetime | None = None self._bridge = bridge self._auth_failed = False self._bridge.register_reauth_callback(self.access_denied) - def access_denied(self): + def access_denied(self) -> None: """Access denied callback.""" LOGGER.debug("Access denied in getting remote object") self._auth_failed = True - self.hass.add_job( + self.hass.async_create_task( self.hass.config_entries.flow.async_init( DOMAIN, context={ @@ -114,7 +131,7 @@ class SamsungTVDevice(MediaPlayerEntity): ) ) - def update(self): + def update(self) -> None: """Update state of device.""" if self._auth_failed: return @@ -123,82 +140,83 @@ class SamsungTVDevice(MediaPlayerEntity): else: self._state = STATE_ON if self._bridge.is_on() else STATE_OFF - def send_key(self, key): + def send_key(self, key: str) -> None: """Send a key to the tv and handles exceptions.""" if self._power_off_in_progress() and key != "KEY_POWEROFF": LOGGER.info("TV is powering off, not sending command: %s", key) return self._bridge.send_key(key) - def _power_off_in_progress(self): + def _power_off_in_progress(self) -> bool: return ( self._end_of_power_off is not None and self._end_of_power_off > dt_util.utcnow() ) @property - def unique_id(self) -> str: + def unique_id(self) -> str | None: """Return the unique ID of the device.""" return self._uuid @property - def name(self): + def name(self) -> str | None: """Return the name of the device.""" return self._name @property - def state(self): + def state(self) -> str | None: """Return the state of the device.""" return self._state @property - def available(self): + def available(self) -> bool: """Return the availability of the device.""" if self._auth_failed: return False return ( self._state == STATE_ON - or self._on_script - or self._mac + or self._on_script is not None + or self._mac is not None or self._power_off_in_progress() ) @property - def device_info(self): + def device_info(self) -> DeviceInfo | None: """Return device specific attributes.""" - info = { + info: DeviceInfo = { "name": self.name, - "identifiers": {(DOMAIN, self.unique_id)}, "manufacturer": self._manufacturer, "model": self._model, } + if self.unique_id: + info["identifiers"] = {(DOMAIN, self.unique_id)} if self._mac: info["connections"] = {(CONNECTION_NETWORK_MAC, self._mac)} return info @property - def is_volume_muted(self): + def is_volume_muted(self) -> bool: """Boolean if volume is currently muted.""" return self._muted @property - def source_list(self): + def source_list(self) -> list: """List of available input sources.""" return list(SOURCES) @property - def supported_features(self): + def supported_features(self) -> int: """Flag media player features that are supported.""" if self._on_script or self._mac: return SUPPORT_SAMSUNGTV | SUPPORT_TURN_ON return SUPPORT_SAMSUNGTV @property - def device_class(self): + def device_class(self) -> str: """Set the device class to TV.""" return DEVICE_CLASS_TV - def turn_off(self): + def turn_off(self) -> None: """Turn off media player.""" self._end_of_power_off = dt_util.utcnow() + SCAN_INTERVAL_PLUS_OFF_TIME @@ -206,44 +224,46 @@ class SamsungTVDevice(MediaPlayerEntity): # Force closing of remote session to provide instant UI feedback self._bridge.close_remote() - def volume_up(self): + def volume_up(self) -> None: """Volume up the media player.""" self.send_key("KEY_VOLUP") - def volume_down(self): + def volume_down(self) -> None: """Volume down media player.""" self.send_key("KEY_VOLDOWN") - def mute_volume(self, mute): + def mute_volume(self, mute: bool) -> None: """Send mute command.""" self.send_key("KEY_MUTE") - def media_play_pause(self): + def media_play_pause(self) -> None: """Simulate play pause media player.""" if self._playing: self.media_pause() else: self.media_play() - def media_play(self): + def media_play(self) -> None: """Send play command.""" self._playing = True self.send_key("KEY_PLAY") - def media_pause(self): + def media_pause(self) -> None: """Send media pause command to media player.""" self._playing = False self.send_key("KEY_PAUSE") - def media_next_track(self): + def media_next_track(self) -> None: """Send next track command.""" self.send_key("KEY_CHUP") - def media_previous_track(self): + def media_previous_track(self) -> None: """Send the previous track command.""" self.send_key("KEY_CHDOWN") - async def async_play_media(self, media_type, media_id, **kwargs): + async def async_play_media( + self, media_type: str, media_id: str, **kwargs: Any + ) -> None: """Support changing a channel.""" if media_type != MEDIA_TYPE_CHANNEL: LOGGER.error("Unsupported media type") @@ -261,21 +281,21 @@ class SamsungTVDevice(MediaPlayerEntity): await asyncio.sleep(KEY_PRESS_TIMEOUT, self.hass.loop) await self.hass.async_add_executor_job(self.send_key, "KEY_ENTER") - def _wake_on_lan(self): + def _wake_on_lan(self) -> None: """Wake the device via wake on lan.""" send_magic_packet(self._mac, ip_address=self._host) # If the ip address changed since we last saw the device # broadcast a packet as well send_magic_packet(self._mac) - async def async_turn_on(self): + async def async_turn_on(self) -> None: """Turn the media player on.""" if self._on_script: await self._on_script.async_run(context=self._context) elif self._mac: await self.hass.async_add_executor_job(self._wake_on_lan) - def select_source(self, source): + def select_source(self, source: str) -> None: """Select input source.""" if source not in SOURCES: LOGGER.error("Unsupported source") diff --git a/homeassistant/components/samsungtv/strings.json b/homeassistant/components/samsungtv/strings.json index f92990e6163..89ac85f85eb 100644 --- a/homeassistant/components/samsungtv/strings.json +++ b/homeassistant/components/samsungtv/strings.json @@ -14,7 +14,7 @@ }, "reauth_confirm": { "description": "After submitting, accept the the popup on {device} requesting authorization within 30 seconds." - } + } }, "error": { "auth_missing": "[%key:component::samsungtv::config::abort::auth_missing%]" @@ -27,7 +27,8 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "not_supported": "This Samsung device is currently not supported.", "unknown": "[%key:common::config_flow::error::unknown%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "missing_config_entry": "This Samsung device doesn't have a configuration entry." } } } \ No newline at end of file diff --git a/homeassistant/components/samsungtv/translations/en.json b/homeassistant/components/samsungtv/translations/en.json index 91576e76ee5..fa5369012c0 100644 --- a/homeassistant/components/samsungtv/translations/en.json +++ b/homeassistant/components/samsungtv/translations/en.json @@ -6,6 +6,7 @@ "auth_missing": "Home Assistant is not authorized to connect to this Samsung TV. Check your TV's External Device Manager settings to authorize Home Assistant.", "cannot_connect": "Failed to connect", "id_missing": "This Samsung device doesn't have a SerialNumber.", + "missing_config_entry": "This Samsung device doesn't have a configuration entry.", "not_supported": "This Samsung device is currently not supported.", "reauth_successful": "Re-authentication was successful", "unknown": "Unexpected error" @@ -16,8 +17,7 @@ "flow_title": "{device}", "step": { "confirm": { - "description": "Do you want to set up {device}? If you never connected Home Assistant before you should see a popup on your TV asking for authorization.", - "title": "Samsung TV" + "description": "Do you want to set up {device}? If you never connected Home Assistant before you should see a popup on your TV asking for authorization." }, "reauth_confirm": { "description": "After submitting, accept the the popup on {device} requesting authorization within 30 seconds." diff --git a/mypy.ini b/mypy.ini index e8524d236b3..084355c2beb 100644 --- a/mypy.ini +++ b/mypy.ini @@ -968,6 +968,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.samsungtv.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.scene.*] check_untyped_defs = true disallow_incomplete_defs = true From bad6b2f7f57ee53d2faddfca5cf2443de98f6641 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Sep 2021 19:25:19 -1000 Subject: [PATCH 448/843] Standardize yeelight exception handling (#56362) --- homeassistant/components/yeelight/__init__.py | 30 +- homeassistant/components/yeelight/light.py | 276 +++++++++--------- tests/components/yeelight/__init__.py | 1 + tests/components/yeelight/test_light.py | 115 ++++++-- 4 files changed, 239 insertions(+), 183 deletions(-) diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py index 5473e8eb553..8d6dcb9122a 100644 --- a/homeassistant/components/yeelight/__init__.py +++ b/homeassistant/components/yeelight/__init__.py @@ -6,6 +6,7 @@ import contextlib from datetime import timedelta from ipaddress import IPv4Address, IPv6Address import logging +import socket from urllib.parse import urlparse from async_upnp_client.search import SsdpSearchListener @@ -163,7 +164,9 @@ UPDATE_REQUEST_PROPERTIES = [ "active_mode", ] -BULB_EXCEPTIONS = (BulbException, asyncio.TimeoutError) +BULB_NETWORK_EXCEPTIONS = (socket.error, asyncio.TimeoutError) +BULB_EXCEPTIONS = (BulbException, *BULB_NETWORK_EXCEPTIONS) + PLATFORMS = ["binary_sensor", "light"] @@ -582,6 +585,11 @@ class YeelightDevice: """Return true is device is available.""" return self._available + @callback + def async_mark_unavailable(self): + """Set unavailable on api call failure due to a network issue.""" + self._available = False + @property def model(self): """Return configured/autodetected device model.""" @@ -642,26 +650,6 @@ class YeelightDevice: return self._device_type - async def async_turn_on( - self, duration=DEFAULT_TRANSITION, light_type=None, power_mode=None - ): - """Turn on device.""" - try: - await self.bulb.async_turn_on( - duration=duration, light_type=light_type, power_mode=power_mode - ) - except BULB_EXCEPTIONS as ex: - _LOGGER.error("Unable to turn the bulb on: %s", ex) - - async def async_turn_off(self, duration=DEFAULT_TRANSITION, light_type=None): - """Turn off device.""" - try: - await self.bulb.async_turn_off(duration=duration, light_type=light_type) - except BULB_EXCEPTIONS as ex: - _LOGGER.error( - "Unable to turn the bulb off: %s, %s: %s", self._host, self.name, ex - ) - async def _async_update_properties(self): """Read new properties from the device.""" if not self.bulb: diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py index e0c21f21fc7..deda6ebf9ab 100644 --- a/homeassistant/components/yeelight/light.py +++ b/homeassistant/components/yeelight/light.py @@ -6,7 +6,7 @@ import math import voluptuous as vol import yeelight -from yeelight import Bulb, Flow, RGBTransition, SleepTransition, flows +from yeelight import Bulb, BulbException, Flow, RGBTransition, SleepTransition, flows from yeelight.enums import BulbType, LightType, PowerMode, SceneClass from homeassistant.components.light import ( @@ -34,6 +34,7 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, ATTR_MODE, CONF_NAME from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -49,7 +50,7 @@ from . import ( ATTR_COUNT, ATTR_MODE_MUSIC, ATTR_TRANSITIONS, - BULB_EXCEPTIONS, + BULB_NETWORK_EXCEPTIONS, CONF_FLOW_PARAMS, CONF_MODE_MUSIC, CONF_NIGHTLIGHT_SWITCH, @@ -242,8 +243,18 @@ def _async_cmd(func): try: _LOGGER.debug("Calling %s with %s %s", func, args, kwargs) return await func(self, *args, **kwargs) - except BULB_EXCEPTIONS as ex: - _LOGGER.error("Error when calling %s: %s", func, ex) + except BULB_NETWORK_EXCEPTIONS as ex: + # A network error happened, the bulb is likely offline now + self.device.async_mark_unavailable() + self.async_write_ha_state() + raise HomeAssistantError( + f"Error when calling {func.__name__} for bulb {self.device.name} at {self.device.host}: {ex}" + ) from ex + except BulbException as ex: + # The bulb likely responded but had an error + raise HomeAssistantError( + f"Error when calling {func.__name__} for bulb {self.device.name} at {self.device.host}: {ex}" + ) from ex return _async_wrap @@ -375,7 +386,7 @@ def _async_setup_services(hass: HomeAssistant): _async_set_auto_delay_off_scene, ) platform.async_register_entity_service( - SERVICE_SET_MUSIC_MODE, SERVICE_SCHEMA_SET_MUSIC_MODE, "set_music_mode" + SERVICE_SET_MUSIC_MODE, SERVICE_SCHEMA_SET_MUSIC_MODE, "async_set_music_mode" ) @@ -509,9 +520,7 @@ class YeelightGenericLight(YeelightEntity, LightEntity): @property def effect(self): """Return the current effect.""" - if not self.device.is_color_flow_enabled: - return None - return self._effect + return self._effect if self.device.is_color_flow_enabled else None @property def _bulb(self) -> Bulb: @@ -519,9 +528,7 @@ class YeelightGenericLight(YeelightEntity, LightEntity): @property def _properties(self) -> dict: - if self._bulb is None: - return {} - return self._bulb.last_properties + return self._bulb.last_properties if self._bulb else {} def _get_property(self, prop, default=None): return self._properties.get(prop, default) @@ -564,83 +571,88 @@ class YeelightGenericLight(YeelightEntity, LightEntity): """Update light properties.""" await self.device.async_update() - def set_music_mode(self, music_mode) -> None: + async def async_set_music_mode(self, music_mode) -> None: """Set the music mode on or off.""" - if music_mode: - try: - self._bulb.start_music() - except AssertionError as ex: - _LOGGER.error(ex) - else: - self._bulb.stop_music() + try: + await self._async_set_music_mode(music_mode) + except AssertionError as ex: + _LOGGER.error("Unable to turn on music mode, consider disabling it: %s", ex) + + @_async_cmd + async def _async_set_music_mode(self, music_mode) -> None: + """Set the music mode on or off wrapped with _async_cmd.""" + bulb = self._bulb + method = bulb.stop_music if not music_mode else bulb.start_music + await self.hass.async_add_executor_job(method) @_async_cmd async def async_set_brightness(self, brightness, duration) -> None: """Set bulb brightness.""" - if brightness: - if math.floor(self.brightness) == math.floor(brightness): - _LOGGER.debug("brightness already set to: %s", brightness) - # Already set, and since we get pushed updates - # we avoid setting it again to ensure we do not - # hit the rate limit - return + if not brightness: + return + if math.floor(self.brightness) == math.floor(brightness): + _LOGGER.debug("brightness already set to: %s", brightness) + # Already set, and since we get pushed updates + # we avoid setting it again to ensure we do not + # hit the rate limit + return - _LOGGER.debug("Setting brightness: %s", brightness) - await self._bulb.async_set_brightness( - brightness / 255 * 100, duration=duration, light_type=self.light_type - ) + _LOGGER.debug("Setting brightness: %s", brightness) + await self._bulb.async_set_brightness( + brightness / 255 * 100, duration=duration, light_type=self.light_type + ) @_async_cmd async def async_set_hs(self, hs_color, duration) -> None: """Set bulb's color.""" - if hs_color and COLOR_MODE_HS in self.supported_color_modes: - if self.color_mode == COLOR_MODE_HS and self.hs_color == hs_color: - _LOGGER.debug("HS already set to: %s", hs_color) - # Already set, and since we get pushed updates - # we avoid setting it again to ensure we do not - # hit the rate limit - return + if not hs_color or COLOR_MODE_HS not in self.supported_color_modes: + return + if self.color_mode == COLOR_MODE_HS and self.hs_color == hs_color: + _LOGGER.debug("HS already set to: %s", hs_color) + # Already set, and since we get pushed updates + # we avoid setting it again to ensure we do not + # hit the rate limit + return - _LOGGER.debug("Setting HS: %s", hs_color) - await self._bulb.async_set_hsv( - hs_color[0], hs_color[1], duration=duration, light_type=self.light_type - ) + _LOGGER.debug("Setting HS: %s", hs_color) + await self._bulb.async_set_hsv( + hs_color[0], hs_color[1], duration=duration, light_type=self.light_type + ) @_async_cmd async def async_set_rgb(self, rgb, duration) -> None: """Set bulb's color.""" - if rgb and COLOR_MODE_RGB in self.supported_color_modes: - if self.color_mode == COLOR_MODE_RGB and self.rgb_color == rgb: - _LOGGER.debug("RGB already set to: %s", rgb) - # Already set, and since we get pushed updates - # we avoid setting it again to ensure we do not - # hit the rate limit - return + if not rgb or COLOR_MODE_RGB not in self.supported_color_modes: + return + if self.color_mode == COLOR_MODE_RGB and self.rgb_color == rgb: + _LOGGER.debug("RGB already set to: %s", rgb) + # Already set, and since we get pushed updates + # we avoid setting it again to ensure we do not + # hit the rate limit + return - _LOGGER.debug("Setting RGB: %s", rgb) - await self._bulb.async_set_rgb( - *rgb, duration=duration, light_type=self.light_type - ) + _LOGGER.debug("Setting RGB: %s", rgb) + await self._bulb.async_set_rgb( + *rgb, duration=duration, light_type=self.light_type + ) @_async_cmd async def async_set_colortemp(self, colortemp, duration) -> None: """Set bulb's color temperature.""" - if colortemp and COLOR_MODE_COLOR_TEMP in self.supported_color_modes: - temp_in_k = mired_to_kelvin(colortemp) + if not colortemp or COLOR_MODE_COLOR_TEMP not in self.supported_color_modes: + return + temp_in_k = mired_to_kelvin(colortemp) - if ( - self.color_mode == COLOR_MODE_COLOR_TEMP - and self.color_temp == colortemp - ): - _LOGGER.debug("Color temp already set to: %s", temp_in_k) - # Already set, and since we get pushed updates - # we avoid setting it again to ensure we do not - # hit the rate limit - return + if self.color_mode == COLOR_MODE_COLOR_TEMP and self.color_temp == colortemp: + _LOGGER.debug("Color temp already set to: %s", temp_in_k) + # Already set, and since we get pushed updates + # we avoid setting it again to ensure we do not + # hit the rate limit + return - await self._bulb.async_set_color_temp( - temp_in_k, duration=duration, light_type=self.light_type - ) + await self._bulb.async_set_color_temp( + temp_in_k, duration=duration, light_type=self.light_type + ) @_async_cmd async def async_set_default(self) -> None: @@ -650,37 +662,33 @@ class YeelightGenericLight(YeelightEntity, LightEntity): @_async_cmd async def async_set_flash(self, flash) -> None: """Activate flash.""" - if flash: - if int(self._bulb.last_properties["color_mode"]) != 1: - _LOGGER.error("Flash supported currently only in RGB mode") - return + if not flash: + return + if int(self._bulb.last_properties["color_mode"]) != 1: + _LOGGER.error("Flash supported currently only in RGB mode") + return - transition = int(self.config[CONF_TRANSITION]) - if flash == FLASH_LONG: - count = 1 - duration = transition * 5 - if flash == FLASH_SHORT: - count = 1 - duration = transition * 2 + transition = int(self.config[CONF_TRANSITION]) + if flash == FLASH_LONG: + count = 1 + duration = transition * 5 + if flash == FLASH_SHORT: + count = 1 + duration = transition * 2 - red, green, blue = color_util.color_hs_to_RGB(*self.hs_color) + red, green, blue = color_util.color_hs_to_RGB(*self.hs_color) - transitions = [] - transitions.append( - RGBTransition(255, 0, 0, brightness=10, duration=duration) - ) - transitions.append(SleepTransition(duration=transition)) - transitions.append( - RGBTransition( - red, green, blue, brightness=self.brightness, duration=duration - ) + transitions = [] + transitions.append(RGBTransition(255, 0, 0, brightness=10, duration=duration)) + transitions.append(SleepTransition(duration=transition)) + transitions.append( + RGBTransition( + red, green, blue, brightness=self.brightness, duration=duration ) + ) - flow = Flow(count=count, transitions=transitions) - try: - await self._bulb.async_start_flow(flow, light_type=self.light_type) - except BULB_EXCEPTIONS as ex: - _LOGGER.error("Unable to set flash: %s", ex) + flow = Flow(count=count, transitions=transitions) + await self._bulb.async_start_flow(flow, light_type=self.light_type) @_async_cmd async def async_set_effect(self, effect) -> None: @@ -707,11 +715,17 @@ class YeelightGenericLight(YeelightEntity, LightEntity): else: return - try: - await self._bulb.async_start_flow(flow, light_type=self.light_type) - self._effect = effect - except BULB_EXCEPTIONS as ex: - _LOGGER.error("Unable to set effect: %s", ex) + await self._bulb.async_start_flow(flow, light_type=self.light_type) + self._effect = effect + + @_async_cmd + async def _async_turn_on(self, duration) -> None: + """Turn on the bulb for with a transition duration wrapped with _async_cmd.""" + await self._bulb.async_turn_on( + duration=duration, + light_type=self.light_type, + power_mode=self._turn_on_power_mode, + ) async def async_turn_on(self, **kwargs) -> None: """Turn the bulb on.""" @@ -727,46 +741,31 @@ class YeelightGenericLight(YeelightEntity, LightEntity): duration = int(kwargs.get(ATTR_TRANSITION) * 1000) # kwarg in s if not self.is_on: - await self.device.async_turn_on( - duration=duration, - light_type=self.light_type, - power_mode=self._turn_on_power_mode, - ) + await self._async_turn_on(duration) if self.config[CONF_MODE_MUSIC] and not self._bulb.music_mode: - try: - await self.hass.async_add_executor_job( - self.set_music_mode, self.config[CONF_MODE_MUSIC] - ) - except BULB_EXCEPTIONS as ex: - _LOGGER.error( - "Unable to turn on music mode, consider disabling it: %s", ex - ) + await self.async_set_music_mode(True) - try: - # values checked for none in methods - await self.async_set_hs(hs_color, duration) - await self.async_set_rgb(rgb, duration) - await self.async_set_colortemp(colortemp, duration) - await self.async_set_brightness(brightness, duration) - await self.async_set_flash(flash) - await self.async_set_effect(effect) - except BULB_EXCEPTIONS as ex: - _LOGGER.error("Unable to set bulb properties: %s", ex) - return + await self.async_set_hs(hs_color, duration) + await self.async_set_rgb(rgb, duration) + await self.async_set_colortemp(colortemp, duration) + await self.async_set_brightness(brightness, duration) + await self.async_set_flash(flash) + await self.async_set_effect(effect) # save the current state if we had a manual change. if self.config[CONF_SAVE_ON_CHANGE] and (brightness or colortemp or rgb): - try: - await self.async_set_default() - except BULB_EXCEPTIONS as ex: - _LOGGER.error("Unable to set the defaults: %s", ex) - return + await self.async_set_default() # Some devices (mainly nightlights) will not send back the on state so we need to force a refresh if not self.is_on: await self.device.async_update(True) + @_async_cmd + async def _async_turn_off(self, duration) -> None: + """Turn off with a given transition duration wrapped with _async_cmd.""" + await self._bulb.async_turn_off(duration=duration, light_type=self.light_type) + async def async_turn_off(self, **kwargs) -> None: """Turn off.""" if not self.is_on: @@ -776,39 +775,30 @@ class YeelightGenericLight(YeelightEntity, LightEntity): if ATTR_TRANSITION in kwargs: # passed kwarg overrides config duration = int(kwargs.get(ATTR_TRANSITION) * 1000) # kwarg in s - await self.device.async_turn_off(duration=duration, light_type=self.light_type) + await self._async_turn_off(duration) # Some devices will not send back the off state so we need to force a refresh if self.is_on: await self.device.async_update(True) + @_async_cmd async def async_set_mode(self, mode: str): """Set a power mode.""" - try: - await self._bulb.async_set_power_mode(PowerMode[mode.upper()]) - except BULB_EXCEPTIONS as ex: - _LOGGER.error("Unable to set the power mode: %s", ex) + await self._bulb.async_set_power_mode(PowerMode[mode.upper()]) + @_async_cmd async def async_start_flow(self, transitions, count=0, action=ACTION_RECOVER): """Start flow.""" - try: - flow = Flow( - count=count, action=Flow.actions[action], transitions=transitions - ) - - await self._bulb.async_start_flow(flow, light_type=self.light_type) - except BULB_EXCEPTIONS as ex: - _LOGGER.error("Unable to set effect: %s", ex) + flow = Flow(count=count, action=Flow.actions[action], transitions=transitions) + await self._bulb.async_start_flow(flow, light_type=self.light_type) + @_async_cmd async def async_set_scene(self, scene_class, *args): """ Set the light directly to the specified state. If the light is off, it will first be turned on. """ - try: - await self._bulb.async_set_scene(scene_class, *args) - except BULB_EXCEPTIONS as ex: - _LOGGER.error("Unable to set scene: %s", ex) + await self._bulb.async_set_scene(scene_class, *args) class YeelightColorLightSupport(YeelightGenericLight): diff --git a/tests/components/yeelight/__init__.py b/tests/components/yeelight/__init__.py index eb7ac01e3b1..84035f61fdf 100644 --- a/tests/components/yeelight/__init__.py +++ b/tests/components/yeelight/__init__.py @@ -125,6 +125,7 @@ def _mocked_bulb(cannot_connect=False): ) type(bulb).get_model_specs = MagicMock(return_value=_MODEL_SPECS[MODEL]) bulb.capabilities = CAPABILITIES.copy() + bulb.available = True bulb.last_properties = PROPERTIES.copy() bulb.music_mode = False bulb.async_get_properties = AsyncMock() diff --git a/tests/components/yeelight/test_light.py b/tests/components/yeelight/test_light.py index 030f6a54cea..9b52ab5f53b 100644 --- a/tests/components/yeelight/test_light.py +++ b/tests/components/yeelight/test_light.py @@ -1,7 +1,9 @@ """Test the Yeelight light.""" +import asyncio import logging from unittest.mock import ANY, AsyncMock, MagicMock, call, patch +import pytest from yeelight import ( BulbException, BulbType, @@ -28,6 +30,7 @@ from homeassistant.components.light import ( ATTR_RGB_COLOR, ATTR_TRANSITION, FLASH_LONG, + FLASH_SHORT, SERVICE_TURN_OFF, SERVICE_TURN_ON, ) @@ -82,8 +85,16 @@ from homeassistant.components.yeelight.light import ( YEELIGHT_MONO_EFFECT_LIST, YEELIGHT_TEMP_ONLY_EFFECT_LIST, ) -from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, CONF_NAME +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_HOST, + CONF_NAME, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, +) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util.color import ( @@ -122,6 +133,7 @@ CONFIG_ENTRY_DATA = { async def test_services(hass: HomeAssistant, caplog): """Test Yeelight services.""" + assert await async_setup_component(hass, "homeassistant", {}) config_entry = MockConfigEntry( domain=DOMAIN, data={ @@ -140,13 +152,16 @@ async def test_services(hass: HomeAssistant, caplog): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + assert hass.states.get(ENTITY_LIGHT).state == STATE_ON + assert hass.states.get(ENTITY_NIGHTLIGHT).state == STATE_OFF + async def _async_test_service( service, data, method, payload=None, domain=DOMAIN, - failure_side_effect=BulbException, + failure_side_effect=HomeAssistantError, ): err_count = len([x for x in caplog.records if x.levelno == logging.ERROR]) @@ -174,11 +189,8 @@ async def test_services(hass: HomeAssistant, caplog): else: mocked_method = MagicMock(side_effect=failure_side_effect) setattr(mocked_bulb, method, mocked_method) - await hass.services.async_call(domain, service, data, blocking=True) - assert ( - len([x for x in caplog.records if x.levelno == logging.ERROR]) - == err_count + 1 - ) + with pytest.raises(failure_side_effect): + await hass.services.async_call(domain, service, data, blocking=True) # turn_on rgb_color brightness = 100 @@ -303,7 +315,50 @@ async def test_services(hass: HomeAssistant, caplog): mocked_bulb.async_start_flow.assert_called_once() # flash mocked_bulb.async_stop_flow.assert_called_once_with(light_type=LightType.Main) + # turn_on color_temp - flash short + brightness = 100 + color_temp = 200 + transition = 1 + mocked_bulb.start_music.reset_mock() + mocked_bulb.async_set_brightness.reset_mock() + mocked_bulb.async_set_color_temp.reset_mock() + mocked_bulb.async_start_flow.reset_mock() + mocked_bulb.async_stop_flow.reset_mock() + mocked_bulb.last_properties["power"] = "off" + await hass.services.async_call( + "light", + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: ENTITY_LIGHT, + ATTR_BRIGHTNESS: brightness, + ATTR_COLOR_TEMP: color_temp, + ATTR_FLASH: FLASH_SHORT, + ATTR_EFFECT: EFFECT_STOP, + ATTR_TRANSITION: transition, + }, + blocking=True, + ) + mocked_bulb.async_turn_on.assert_called_once_with( + duration=transition * 1000, + light_type=LightType.Main, + power_mode=PowerMode.NORMAL, + ) + mocked_bulb.async_turn_on.reset_mock() + mocked_bulb.start_music.assert_called_once() + mocked_bulb.async_set_brightness.assert_called_once_with( + brightness / 255 * 100, duration=transition * 1000, light_type=LightType.Main + ) + mocked_bulb.async_set_color_temp.assert_called_once_with( + color_temperature_mired_to_kelvin(color_temp), + duration=transition * 1000, + light_type=LightType.Main, + ) + mocked_bulb.async_set_hsv.assert_not_called() + mocked_bulb.async_set_rgb.assert_not_called() + mocked_bulb.async_start_flow.assert_called_once() # flash + mocked_bulb.async_stop_flow.assert_called_once_with(light_type=LightType.Main) + # turn_on nightlight await _async_test_service( SERVICE_TURN_ON, @@ -318,6 +373,7 @@ async def test_services(hass: HomeAssistant, caplog): ) mocked_bulb.last_properties["power"] = "on" + assert hass.states.get(ENTITY_LIGHT).state != STATE_UNAVAILABLE # turn_off await _async_test_service( SERVICE_TURN_OFF, @@ -393,12 +449,16 @@ async def test_services(hass: HomeAssistant, caplog): ) # set_music_mode failure enable - await _async_test_service( + mocked_bulb.start_music = MagicMock(side_effect=AssertionError) + assert "Unable to turn on music mode, consider disabling it" not in caplog.text + await hass.services.async_call( + DOMAIN, SERVICE_SET_MUSIC_MODE, {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_MODE_MUSIC: "true"}, - "start_music", - failure_side_effect=AssertionError, + blocking=True, ) + assert mocked_bulb.start_music.mock_calls == [call()] + assert "Unable to turn on music mode, consider disabling it" in caplog.text # set_music_mode disable await _async_test_service( @@ -417,18 +477,35 @@ async def test_services(hass: HomeAssistant, caplog): ) # test _cmd wrapper error handler mocked_bulb.last_properties["power"] = "off" - err_count = len([x for x in caplog.records if x.levelno == logging.ERROR]) - type(mocked_bulb).turn_on = MagicMock() - type(mocked_bulb).set_brightness = MagicMock(side_effect=BulbException) + mocked_bulb.available = True await hass.services.async_call( - "light", - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_BRIGHTNESS: 50}, + "homeassistant", + "update_entity", + {ATTR_ENTITY_ID: ENTITY_LIGHT}, blocking=True, ) - assert ( - len([x for x in caplog.records if x.levelno == logging.ERROR]) == err_count + 1 - ) + assert hass.states.get(ENTITY_LIGHT).state == STATE_OFF + + mocked_bulb.async_turn_on = AsyncMock() + mocked_bulb.async_set_brightness = AsyncMock(side_effect=BulbException) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "light", + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_BRIGHTNESS: 50}, + blocking=True, + ) + assert hass.states.get(ENTITY_LIGHT).state == STATE_OFF + + mocked_bulb.async_set_brightness = AsyncMock(side_effect=asyncio.TimeoutError) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "light", + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_BRIGHTNESS: 55}, + blocking=True, + ) + assert hass.states.get(ENTITY_LIGHT).state == STATE_UNAVAILABLE async def test_state_already_set_avoid_ratelimit(hass: HomeAssistant): From eb98ac9415bd4b4c57270c0872f1581b64886054 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Sep 2021 19:25:50 -1000 Subject: [PATCH 449/843] Allow IntegrationNotFound when checking config in safe mode (#56283) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Joakim Sørensen --- homeassistant/helpers/check_config.py | 13 +++++-- tests/helpers/test_check_config.py | 54 ++++++++++++++++++++++++++- tests/scripts/test_check_config.py | 2 +- 3 files changed, 64 insertions(+), 5 deletions(-) diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py index 26e063ae1f2..00f952013b5 100644 --- a/homeassistant/helpers/check_config.py +++ b/homeassistant/helpers/check_config.py @@ -125,8 +125,12 @@ async def async_check_ha_config_file( # noqa: C901 for domain in components: try: integration = await async_get_integration_with_requirements(hass, domain) - except (RequirementsNotFound, loader.IntegrationNotFound) as ex: - result.add_error(f"Component error: {domain} - {ex}") + except loader.IntegrationNotFound as ex: + if not hass.config.safe_mode: + result.add_error(f"Integration error: {domain} - {ex}") + continue + except RequirementsNotFound as ex: + result.add_error(f"Integration error: {domain} - {ex}") continue try: @@ -210,8 +214,11 @@ async def async_check_ha_config_file( # noqa: C901 hass, p_name ) platform = p_integration.get_platform(domain) + except loader.IntegrationNotFound as ex: + if not hass.config.safe_mode: + result.add_error(f"Platform error {domain}.{p_name} - {ex}") + continue except ( - loader.IntegrationNotFound, RequirementsNotFound, ImportError, ) as ex: diff --git a/tests/helpers/test_check_config.py b/tests/helpers/test_check_config.py index c5b75b84342..e79250a084d 100644 --- a/tests/helpers/test_check_config.py +++ b/tests/helpers/test_check_config.py @@ -7,6 +7,7 @@ from homeassistant.helpers.check_config import ( CheckConfigError, async_check_ha_config_file, ) +from homeassistant.requirements import RequirementsNotFound from tests.common import mock_platform, patch_yaml_files @@ -75,7 +76,7 @@ async def test_component_platform_not_found(hass): assert res.keys() == {"homeassistant"} assert res.errors[0] == CheckConfigError( - "Component error: beer - Integration 'beer' not found.", None, None + "Integration error: beer - Integration 'beer' not found.", None, None ) # Only 1 error expected @@ -83,6 +84,42 @@ async def test_component_platform_not_found(hass): assert not res.errors +async def test_component_requirement_not_found(hass): + """Test errors if component with a requirement not found not found.""" + # Make sure they don't exist + files = {YAML_CONFIG_FILE: BASE_CONFIG + "test_custom_component:"} + with patch( + "homeassistant.helpers.check_config.async_get_integration_with_requirements", + side_effect=RequirementsNotFound("test_custom_component", ["any"]), + ), patch("os.path.isfile", return_value=True), patch_yaml_files(files): + res = await async_check_ha_config_file(hass) + log_ha_config(res) + + assert res.keys() == {"homeassistant"} + assert res.errors[0] == CheckConfigError( + "Integration error: test_custom_component - Requirements for test_custom_component not found: ['any'].", + None, + None, + ) + + # Only 1 error expected + res.errors.pop(0) + assert not res.errors + + +async def test_component_not_found_safe_mode(hass): + """Test no errors if component not found in safe mode.""" + # Make sure they don't exist + files = {YAML_CONFIG_FILE: BASE_CONFIG + "beer:"} + hass.config.safe_mode = True + with patch("os.path.isfile", return_value=True), patch_yaml_files(files): + res = await async_check_ha_config_file(hass) + log_ha_config(res) + + assert res.keys() == {"homeassistant"} + assert not res.errors + + async def test_component_platform_not_found_2(hass): """Test errors if component or platform not found.""" # Make sure they don't exist @@ -103,6 +140,21 @@ async def test_component_platform_not_found_2(hass): assert not res.errors +async def test_platform_not_found_safe_mode(hass): + """Test no errors if platform not found in safe_mode.""" + # Make sure they don't exist + files = {YAML_CONFIG_FILE: BASE_CONFIG + "light:\n platform: beer"} + hass.config.safe_mode = True + with patch("os.path.isfile", return_value=True), patch_yaml_files(files): + res = await async_check_ha_config_file(hass) + log_ha_config(res) + + assert res.keys() == {"homeassistant", "light"} + assert res["light"] == [] + + assert not res.errors + + async def test_package_invalid(hass): """Test a valid platform setup.""" files = { diff --git a/tests/scripts/test_check_config.py b/tests/scripts/test_check_config.py index 1a96568f8ef..05ebb8fb0e5 100644 --- a/tests/scripts/test_check_config.py +++ b/tests/scripts/test_check_config.py @@ -74,7 +74,7 @@ def test_component_platform_not_found(mock_is_file, loop): assert res["components"].keys() == {"homeassistant"} assert res["except"] == { check_config.ERROR_STR: [ - "Component error: beer - Integration 'beer' not found." + "Integration error: beer - Integration 'beer' not found." ] } assert res["secret_cache"] == {} From b6763c724564fcf2252e7ddf770d58b3eaf1206a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Sep 2021 19:26:25 -1000 Subject: [PATCH 450/843] Fix yeelight nightlight mode (#56363) --- homeassistant/components/yeelight/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py index 8d6dcb9122a..6a5fd32213a 100644 --- a/homeassistant/components/yeelight/__init__.py +++ b/homeassistant/components/yeelight/__init__.py @@ -71,8 +71,8 @@ ACTION_RECOVER = "recover" ACTION_STAY = "stay" ACTION_OFF = "off" -ACTIVE_MODE_NIGHTLIGHT = "1" -ACTIVE_COLOR_FLOWING = "1" +ACTIVE_MODE_NIGHTLIGHT = 1 +ACTIVE_COLOR_FLOWING = 1 NIGHTLIGHT_SWITCH_TYPE_LIGHT = "light" @@ -618,7 +618,7 @@ class YeelightDevice: # Only ceiling lights have active_mode, from SDK docs: # active_mode 0: daylight mode / 1: moonlight mode (ceiling light only) if self._active_mode is not None: - return self._active_mode == ACTIVE_MODE_NIGHTLIGHT + return int(self._active_mode) == ACTIVE_MODE_NIGHTLIGHT if self._nightlight_brightness is not None: return int(self._nightlight_brightness) > 0 @@ -628,7 +628,7 @@ class YeelightDevice: @property def is_color_flow_enabled(self) -> bool: """Return true / false if color flow is currently running.""" - return self._color_flow == ACTIVE_COLOR_FLOWING + return int(self._color_flow) == ACTIVE_COLOR_FLOWING @property def _active_mode(self): From aaadd4253915174d117e97ad850c539e5c9794a5 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Sat, 18 Sep 2021 08:42:58 +0300 Subject: [PATCH 451/843] Bump aioswitcher to 2.0.6 (#56358) --- homeassistant/components/switcher_kis/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/switcher_kis/manifest.json b/homeassistant/components/switcher_kis/manifest.json index 33ec7a67d92..88d3d2a3ad4 100644 --- a/homeassistant/components/switcher_kis/manifest.json +++ b/homeassistant/components/switcher_kis/manifest.json @@ -3,7 +3,7 @@ "name": "Switcher", "documentation": "https://www.home-assistant.io/integrations/switcher_kis/", "codeowners": ["@tomerfi","@thecode"], - "requirements": ["aioswitcher==2.0.5"], + "requirements": ["aioswitcher==2.0.6"], "iot_class": "local_push", "config_flow": true } diff --git a/requirements_all.txt b/requirements_all.txt index 505b4c01429..f9c369f60d9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -243,7 +243,7 @@ aiorecollect==1.0.8 aioshelly==0.6.4 # homeassistant.components.switcher_kis -aioswitcher==2.0.5 +aioswitcher==2.0.6 # homeassistant.components.syncthing aiosyncthing==0.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 335e848bab0..0516fd4aa5d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -164,7 +164,7 @@ aiorecollect==1.0.8 aioshelly==0.6.4 # homeassistant.components.switcher_kis -aioswitcher==2.0.5 +aioswitcher==2.0.6 # homeassistant.components.syncthing aiosyncthing==0.5.1 From 7524daad86816abc6fb1467d10ffd9ffe25f09be Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Sep 2021 19:47:06 -1000 Subject: [PATCH 452/843] Fix HomeKit requests with hvac mode and temperature in the same call (#56239) --- .../components/homekit/type_thermostats.py | 17 ++- .../homekit/test_type_thermostats.py | 113 ++++++++++++++++++ 2 files changed, 125 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/homekit/type_thermostats.py b/homeassistant/components/homekit/type_thermostats.py index c36a32b0d5b..95c5f87b6c2 100644 --- a/homeassistant/components/homekit/type_thermostats.py +++ b/homeassistant/components/homekit/type_thermostats.py @@ -245,7 +245,7 @@ class Thermostat(HomeAccessory): def _set_chars(self, char_values): _LOGGER.debug("Thermostat _set_chars: %s", char_values) events = [] - params = {} + params = {ATTR_ENTITY_ID: self.entity_id} service = None state = self.hass.states.get(self.entity_id) features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) @@ -285,12 +285,20 @@ class Thermostat(HomeAccessory): target_hc = hc_fallback break - service = SERVICE_SET_HVAC_MODE_THERMOSTAT - hass_value = self.hc_homekit_to_hass[target_hc] - params = {ATTR_HVAC_MODE: hass_value} + params[ATTR_HVAC_MODE] = self.hc_homekit_to_hass[target_hc] events.append( f"{CHAR_TARGET_HEATING_COOLING} to {char_values[CHAR_TARGET_HEATING_COOLING]}" ) + # Many integrations do not actually implement `hvac_mode` for the + # `SERVICE_SET_TEMPERATURE_THERMOSTAT` service so we made a call to + # `SERVICE_SET_HVAC_MODE_THERMOSTAT` before calling `SERVICE_SET_TEMPERATURE_THERMOSTAT` + # to ensure the device is in the right mode before setting the temp. + self.async_call_service( + DOMAIN_CLIMATE, + SERVICE_SET_HVAC_MODE_THERMOSTAT, + params.copy(), + ", ".join(events), + ) if CHAR_TARGET_TEMPERATURE in char_values: hc_target_temp = char_values[CHAR_TARGET_TEMPERATURE] @@ -357,7 +365,6 @@ class Thermostat(HomeAccessory): ) if service: - params[ATTR_ENTITY_ID] = self.entity_id self.async_call_service( DOMAIN_CLIMATE, service, diff --git a/tests/components/homekit/test_type_thermostats.py b/tests/components/homekit/test_type_thermostats.py index d9c2a6bf0ed..e73465b0ab0 100644 --- a/tests/components/homekit/test_type_thermostats.py +++ b/tests/components/homekit/test_type_thermostats.py @@ -560,6 +560,119 @@ async def test_thermostat_auto(hass, hk_driver, events): ) +async def test_thermostat_mode_and_temp_change(hass, hk_driver, events): + """Test if accessory where the mode and temp change in the same call.""" + entity_id = "climate.test" + + # support_auto = True + hass.states.async_set( + entity_id, + HVAC_MODE_OFF, + { + ATTR_SUPPORTED_FEATURES: SUPPORT_TARGET_TEMPERATURE + | SUPPORT_TARGET_TEMPERATURE_RANGE, + ATTR_HVAC_MODES: [ + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_COOL, + HVAC_MODE_OFF, + HVAC_MODE_AUTO, + ], + }, + ) + await hass.async_block_till_done() + acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + await acc.run() + await hass.async_block_till_done() + + assert acc.char_cooling_thresh_temp.value == 23.0 + assert acc.char_heating_thresh_temp.value == 19.0 + + assert acc.char_cooling_thresh_temp.properties[PROP_MAX_VALUE] == DEFAULT_MAX_TEMP + assert acc.char_cooling_thresh_temp.properties[PROP_MIN_VALUE] == 7.0 + assert acc.char_cooling_thresh_temp.properties[PROP_MIN_STEP] == 0.1 + assert acc.char_heating_thresh_temp.properties[PROP_MAX_VALUE] == DEFAULT_MAX_TEMP + assert acc.char_heating_thresh_temp.properties[PROP_MIN_VALUE] == 7.0 + assert acc.char_heating_thresh_temp.properties[PROP_MIN_STEP] == 0.1 + + hass.states.async_set( + entity_id, + HVAC_MODE_COOL, + { + ATTR_TARGET_TEMP_HIGH: 23.0, + ATTR_TARGET_TEMP_LOW: 19.0, + ATTR_CURRENT_TEMPERATURE: 21.0, + ATTR_HVAC_ACTION: CURRENT_HVAC_COOL, + ATTR_HVAC_MODES: [ + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_COOL, + HVAC_MODE_OFF, + HVAC_MODE_AUTO, + ], + }, + ) + await hass.async_block_till_done() + assert acc.char_heating_thresh_temp.value == 19.0 + assert acc.char_cooling_thresh_temp.value == 23.0 + assert acc.char_current_heat_cool.value == HC_HEAT_COOL_COOL + assert acc.char_target_heat_cool.value == HC_HEAT_COOL_COOL + assert acc.char_current_temp.value == 21.0 + assert acc.char_display_units.value == 0 + + # Set from HomeKit + call_set_temperature = async_mock_service(hass, DOMAIN_CLIMATE, "set_temperature") + call_set_hvac_mode = async_mock_service(hass, DOMAIN_CLIMATE, "set_hvac_mode") + + char_heating_thresh_temp_iid = acc.char_heating_thresh_temp.to_HAP()[HAP_REPR_IID] + char_cooling_thresh_temp_iid = acc.char_cooling_thresh_temp.to_HAP()[HAP_REPR_IID] + char_target_heat_cool_iid = acc.char_target_heat_cool.to_HAP()[HAP_REPR_IID] + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_heating_thresh_temp_iid, + HAP_REPR_VALUE: 20.0, + }, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_cooling_thresh_temp_iid, + HAP_REPR_VALUE: 25.0, + }, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_target_heat_cool_iid, + HAP_REPR_VALUE: HC_HEAT_COOL_AUTO, + }, + ] + }, + "mock_addr", + ) + + await hass.async_block_till_done() + assert call_set_hvac_mode[0] + assert call_set_hvac_mode[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_hvac_mode[0].data[ATTR_HVAC_MODE] == HVAC_MODE_HEAT_COOL + assert call_set_temperature[0] + assert call_set_temperature[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_temperature[0].data[ATTR_TARGET_TEMP_LOW] == 20.0 + assert call_set_temperature[0].data[ATTR_TARGET_TEMP_HIGH] == 25.0 + assert acc.char_heating_thresh_temp.value == 20.0 + assert acc.char_cooling_thresh_temp.value == 25.0 + assert len(events) == 2 + assert events[-2].data[ATTR_VALUE] == "TargetHeatingCoolingState to 3" + assert ( + events[-1].data[ATTR_VALUE] + == "TargetHeatingCoolingState to 3, CoolingThresholdTemperature to 25.0°C, HeatingThresholdTemperature to 20.0°C" + ) + + async def test_thermostat_humidity(hass, hk_driver, events): """Test if accessory and HA are updated accordingly with humidity.""" entity_id = "climate.test" From 02ba3c60895446e2cb6f72e760850ed72ddc5469 Mon Sep 17 00:00:00 2001 From: Sean Vig Date: Sat, 18 Sep 2021 02:34:51 -0400 Subject: [PATCH 453/843] Update amcrest version to 1.9.3 (#56348) This version fixes a bug that affects the current non-async Home Assistant integration --- homeassistant/components/amcrest/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/amcrest/manifest.json b/homeassistant/components/amcrest/manifest.json index 6035c62ff0e..0d6c1380c20 100644 --- a/homeassistant/components/amcrest/manifest.json +++ b/homeassistant/components/amcrest/manifest.json @@ -2,7 +2,7 @@ "domain": "amcrest", "name": "Amcrest", "documentation": "https://www.home-assistant.io/integrations/amcrest", - "requirements": ["amcrest==1.9.2"], + "requirements": ["amcrest==1.9.3"], "dependencies": ["ffmpeg"], "codeowners": ["@flacjacket"], "iot_class": "local_polling" diff --git a/requirements_all.txt b/requirements_all.txt index f9c369f60d9..e66ece52b80 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -276,7 +276,7 @@ ambee==0.3.0 ambiclimate==0.2.1 # homeassistant.components.amcrest -amcrest==1.9.2 +amcrest==1.9.3 # homeassistant.components.androidtv androidtv[async]==0.0.60 From 6947912fa99d3b900d8f55c65852d0b8e23d6fa2 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Sat, 18 Sep 2021 08:57:27 +0200 Subject: [PATCH 454/843] Modbus entity update does not occur until after scan_interval (#56221) * Secure update is called when integration is started. * Review comments. * Update homeassistant/components/modbus/base_platform.py Co-authored-by: Paulus Schoutsen * Update homeassistant/components/modbus/base_platform.py Co-authored-by: Paulus Schoutsen Co-authored-by: Paulus Schoutsen --- .../components/modbus/base_platform.py | 30 ++++++++++++------- homeassistant/components/modbus/const.py | 4 +-- tests/components/modbus/test_sensor.py | 1 + 3 files changed, 22 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/modbus/base_platform.py b/homeassistant/components/modbus/base_platform.py index 3167313fae8..64b7de1976c 100644 --- a/homeassistant/components/modbus/base_platform.py +++ b/homeassistant/components/modbus/base_platform.py @@ -28,6 +28,7 @@ from homeassistant.helpers.event import async_call_later, async_track_time_inter from homeassistant.helpers.restore_state import RestoreEntity from .const import ( + ACTIVE_SCAN_INTERVAL, CALL_TYPE_COIL, CALL_TYPE_DISCRETE, CALL_TYPE_REGISTER_HOLDING, @@ -78,6 +79,7 @@ class BasePlatform(Entity): self._scan_interval = int(entry[CONF_SCAN_INTERVAL]) self._call_active = False self._cancel_timer: Callable[[], None] | None = None + self._cancel_call: Callable[[], None] | None = None self._attr_name = entry[CONF_NAME] self._attr_should_poll = False @@ -92,11 +94,11 @@ class BasePlatform(Entity): """Virtual function to be overwritten.""" @callback - def async_remote_start(self) -> None: + def async_run(self) -> None: """Remote start entity.""" - if self._cancel_timer: - self._cancel_timer() - self._cancel_timer = None + self.async_hold(update=False) + if self._scan_interval == 0 or self._scan_interval > ACTIVE_SCAN_INTERVAL: + self._cancel_call = async_call_later(self.hass, 1, self.async_update) if self._scan_interval > 0: self._cancel_timer = async_track_time_interval( self.hass, self.async_update, timedelta(seconds=self._scan_interval) @@ -105,20 +107,26 @@ class BasePlatform(Entity): self.async_write_ha_state() @callback - def async_remote_stop(self) -> None: + def async_hold(self, update=True) -> None: """Remote stop entity.""" + if self._cancel_call: + self._cancel_call() + self._cancel_call = None if self._cancel_timer: self._cancel_timer() self._cancel_timer = None - self._attr_available = False - self.async_write_ha_state() + if update: + self._attr_available = False + self.async_write_ha_state() async def async_base_added_to_hass(self): """Handle entity which will be added.""" - self.async_remote_start() - async_dispatcher_connect(self.hass, SIGNAL_STOP_ENTITY, self.async_remote_stop) - async_dispatcher_connect( - self.hass, SIGNAL_START_ENTITY, self.async_remote_start + self.async_run() + self.async_on_remove( + async_dispatcher_connect(self.hass, SIGNAL_STOP_ENTITY, self.async_hold) + ) + self.async_on_remove( + async_dispatcher_connect(self.hass, SIGNAL_START_ENTITY, self.async_run) ) diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py index f2c3b7dd19c..d3240565982 100644 --- a/homeassistant/components/modbus/const.py +++ b/homeassistant/components/modbus/const.py @@ -118,11 +118,11 @@ DEFAULT_HUB = "modbus_hub" DEFAULT_SCAN_INTERVAL = 15 # seconds DEFAULT_SLAVE = 1 DEFAULT_STRUCTURE_PREFIX = ">f" - - DEFAULT_TEMP_UNIT = "C" MODBUS_DOMAIN = "modbus" +ACTIVE_SCAN_INTERVAL = 2 # limit to force an extra update + PLATFORMS = ( (BINARY_SENSOR_DOMAIN, CONF_BINARY_SENSORS), (CLIMATE_DOMAIN, CONF_CLIMATES), diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index 5227be835db..e2435d6acc8 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -246,6 +246,7 @@ async def test_config_wrong_struct_sensor(hass, error_message, mock_modbus, capl { CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 51, + CONF_SCAN_INTERVAL: 1, }, ], }, From 539ef31046b92e3d736df60b601a4a3db8d21611 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Sat, 18 Sep 2021 09:05:08 +0200 Subject: [PATCH 455/843] Reflect changes to pydeconz v84 (#56361) Mostly snake case conversions and typing But also a change in retry mechanism Added a more complete set_* call to most types to remove the direct relation to rest API of deCONZ --- .../components/deconz/alarm_control_panel.py | 8 ++-- .../components/deconz/binary_sensor.py | 6 +-- homeassistant/components/deconz/climate.py | 24 +++++------ .../components/deconz/config_flow.py | 17 +++++--- homeassistant/components/deconz/cover.py | 2 +- .../components/deconz/deconz_device.py | 12 +++--- .../components/deconz/deconz_event.py | 2 +- homeassistant/components/deconz/fan.py | 2 +- homeassistant/components/deconz/gateway.py | 10 ++--- homeassistant/components/deconz/light.py | 40 +++++++++---------- homeassistant/components/deconz/lock.py | 4 +- homeassistant/components/deconz/logbook.py | 4 +- homeassistant/components/deconz/manifest.json | 2 +- homeassistant/components/deconz/scene.py | 2 +- homeassistant/components/deconz/sensor.py | 4 +- homeassistant/components/deconz/services.py | 2 +- homeassistant/components/deconz/switch.py | 10 ++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/deconz/conftest.py | 5 ++- tests/components/deconz/test_gateway.py | 6 +-- tests/components/deconz/test_init.py | 6 ++- 22 files changed, 87 insertions(+), 85 deletions(-) diff --git a/homeassistant/components/deconz/alarm_control_panel.py b/homeassistant/components/deconz/alarm_control_panel.py index c0ebc9d3134..00d21f8141e 100644 --- a/homeassistant/components/deconz/alarm_control_panel.py +++ b/homeassistant/components/deconz/alarm_control_panel.py @@ -55,7 +55,7 @@ DECONZ_TO_ALARM_STATE = { def get_alarm_system_for_unique_id(gateway, unique_id: str): """Retrieve alarm system unique ID is registered to.""" - for alarm_system in gateway.api.alarm_systems.values(): + for alarm_system in gateway.api.alarmsystems.values(): if unique_id in alarm_system.devices: return alarm_system @@ -77,8 +77,8 @@ async def async_setup_entry(hass, config_entry, async_add_entities) -> None: if ( sensor.type in AncillaryControl.ZHATYPE - and sensor.uniqueid not in gateway.entities[DOMAIN] - and get_alarm_system_for_unique_id(gateway, sensor.uniqueid) + and sensor.unique_id not in gateway.entities[DOMAIN] + and get_alarm_system_for_unique_id(gateway, sensor.unique_id) ): entities.append(DeconzAlarmControlPanel(sensor, gateway)) @@ -110,7 +110,7 @@ class DeconzAlarmControlPanel(DeconzDevice, AlarmControlPanelEntity): def __init__(self, device, gateway) -> None: """Set up alarm control panel device.""" super().__init__(device, gateway) - self.alarm_system = get_alarm_system_for_unique_id(gateway, device.uniqueid) + self.alarm_system = get_alarm_system_for_unique_id(gateway, device.unique_id) @callback def async_update_callback(self, force_update: bool = False) -> None: diff --git a/homeassistant/components/deconz/binary_sensor.py b/homeassistant/components/deconz/binary_sensor.py index 392d2e03885..571ad5dc68b 100644 --- a/homeassistant/components/deconz/binary_sensor.py +++ b/homeassistant/components/deconz/binary_sensor.py @@ -48,7 +48,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): if ( sensor.BINARY - and sensor.uniqueid not in gateway.entities[DOMAIN] + and sensor.unique_id not in gateway.entities[DOMAIN] and ( gateway.option_allow_clip_sensor or not sensor.type.startswith("CLIP") @@ -116,8 +116,8 @@ class DeconzBinarySensor(DeconzDevice, BinarySensorEntity): elif self._device.type in Vibration.ZHATYPE: attr[ATTR_ORIENTATION] = self._device.orientation - attr[ATTR_TILTANGLE] = self._device.tiltangle - attr[ATTR_VIBRATIONSTRENGTH] = self._device.vibrationstrength + attr[ATTR_TILTANGLE] = self._device.tilt_angle + attr[ATTR_VIBRATIONSTRENGTH] = self._device.vibration_strength return attr diff --git a/homeassistant/components/deconz/climate.py b/homeassistant/components/deconz/climate.py index 4f8345b5e92..307636480c9 100644 --- a/homeassistant/components/deconz/climate.py +++ b/homeassistant/components/deconz/climate.py @@ -86,7 +86,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): if ( sensor.type in Thermostat.ZHATYPE - and sensor.uniqueid not in gateway.entities[DOMAIN] + and sensor.unique_id not in gateway.entities[DOMAIN] and ( gateway.option_allow_clip_sensor or not sensor.type.startswith("CLIP") @@ -142,7 +142,7 @@ class DeconzThermostat(DeconzDevice, ClimateEntity): def fan_mode(self) -> str: """Return fan operation.""" return DECONZ_TO_FAN_MODE.get( - self._device.fanmode, FAN_ON if self._device.state_on else FAN_OFF + self._device.fan_mode, FAN_ON if self._device.state_on else FAN_OFF ) @property @@ -155,9 +155,7 @@ class DeconzThermostat(DeconzDevice, ClimateEntity): if fan_mode not in FAN_MODE_TO_DECONZ: raise ValueError(f"Unsupported fan mode {fan_mode}") - data = {"fanmode": FAN_MODE_TO_DECONZ[fan_mode]} - - await self._device.async_set_config(data) + await self._device.set_config(fan_mode=FAN_MODE_TO_DECONZ[fan_mode]) # HVAC control @@ -186,7 +184,7 @@ class DeconzThermostat(DeconzDevice, ClimateEntity): if len(self._hvac_mode_to_deconz) == 2: # Only allow turn on and off thermostat data = {"on": self._hvac_mode_to_deconz[hvac_mode]} - await self._device.async_set_config(data) + await self._device.set_config(**data) # Preset control @@ -205,9 +203,7 @@ class DeconzThermostat(DeconzDevice, ClimateEntity): if preset_mode not in PRESET_MODE_TO_DECONZ: raise ValueError(f"Unsupported preset mode {preset_mode}") - data = {"preset": PRESET_MODE_TO_DECONZ[preset_mode]} - - await self._device.async_set_config(data) + await self._device.set_config(preset=PRESET_MODE_TO_DECONZ[preset_mode]) # Temperature control @@ -220,19 +216,19 @@ class DeconzThermostat(DeconzDevice, ClimateEntity): def target_temperature(self) -> float: """Return the target temperature.""" if self._device.mode == "cool": - return self._device.coolsetpoint - return self._device.heatsetpoint + return self._device.cooling_setpoint + return self._device.heating_setpoint async def async_set_temperature(self, **kwargs): """Set new target temperature.""" if ATTR_TEMPERATURE not in kwargs: raise ValueError(f"Expected attribute {ATTR_TEMPERATURE}") - data = {"heatsetpoint": kwargs[ATTR_TEMPERATURE] * 100} + data = {"heating_setpoint": kwargs[ATTR_TEMPERATURE] * 100} if self._device.mode == "cool": - data = {"coolsetpoint": kwargs[ATTR_TEMPERATURE] * 100} + data = {"cooling_setpoint": kwargs[ATTR_TEMPERATURE] * 100} - await self._device.async_set_config(data) + await self._device.set_config(**data) @property def extra_state_attributes(self): diff --git a/homeassistant/components/deconz/config_flow.py b/homeassistant/components/deconz/config_flow.py index 2f15aaa50cc..3a6c5aecfb5 100644 --- a/homeassistant/components/deconz/config_flow.py +++ b/homeassistant/components/deconz/config_flow.py @@ -5,10 +5,10 @@ from urllib.parse import urlparse import async_timeout from pydeconz.errors import RequestError, ResponseError +from pydeconz.gateway import DeconzSession from pydeconz.utils import ( - async_discovery, - async_get_api_key, - async_get_bridge_id, + discovery as deconz_discovery, + get_bridge_id as deconz_get_bridge_id, normalize_bridge_id, ) import voluptuous as vol @@ -86,7 +86,7 @@ class DeconzFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): try: with async_timeout.timeout(10): - self.bridges = await async_discovery(session) + self.bridges = await deconz_discovery(session) except (asyncio.TimeoutError, ResponseError): self.bridges = [] @@ -134,10 +134,15 @@ class DeconzFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: session = aiohttp_client.async_get_clientsession(self.hass) + deconz_session = DeconzSession( + session, + host=self.deconz_config[CONF_HOST], + port=self.deconz_config[CONF_PORT], + ) try: with async_timeout.timeout(10): - api_key = await async_get_api_key(session, **self.deconz_config) + api_key = await deconz_session.get_api_key() except (ResponseError, RequestError, asyncio.TimeoutError): errors["base"] = "no_key" @@ -155,7 +160,7 @@ class DeconzFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): try: with async_timeout.timeout(10): - self.bridge_id = await async_get_bridge_id( + self.bridge_id = await deconz_get_bridge_id( session, **self.deconz_config ) await self.async_set_unique_id(self.bridge_id) diff --git a/homeassistant/components/deconz/cover.py b/homeassistant/components/deconz/cover.py index 21618127905..bc16b7881af 100644 --- a/homeassistant/components/deconz/cover.py +++ b/homeassistant/components/deconz/cover.py @@ -48,7 +48,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): for light in lights: if ( light.type in COVER_TYPES - and light.uniqueid not in gateway.entities[DOMAIN] + and light.unique_id not in gateway.entities[DOMAIN] ): entities.append(DeconzCover(light, gateway)) diff --git a/homeassistant/components/deconz/deconz_device.py b/homeassistant/components/deconz/deconz_device.py index 63f624ba643..dfaba153070 100644 --- a/homeassistant/components/deconz/deconz_device.py +++ b/homeassistant/components/deconz/deconz_device.py @@ -18,15 +18,15 @@ class DeconzBase: @property def unique_id(self): """Return a unique identifier for this device.""" - return self._device.uniqueid + return self._device.unique_id @property def serial(self): """Return a serial number for this device.""" - if self._device.uniqueid is None or self._device.uniqueid.count(":") != 7: + if self._device.unique_id is None or self._device.unique_id.count(":") != 7: return None - return self._device.uniqueid.split("-", 1)[0] + return self._device.unique_id.split("-", 1)[0] @property def device_info(self): @@ -38,10 +38,10 @@ class DeconzBase: "connections": {(CONNECTION_ZIGBEE, self.serial)}, "identifiers": {(DECONZ_DOMAIN, self.serial)}, "manufacturer": self._device.manufacturer, - "model": self._device.modelid, + "model": self._device.model_id, "name": self._device.name, - "sw_version": self._device.swversion, - "via_device": (DECONZ_DOMAIN, self.gateway.api.config.bridgeid), + "sw_version": self._device.software_version, + "via_device": (DECONZ_DOMAIN, self.gateway.api.config.bridge_id), } diff --git a/homeassistant/components/deconz/deconz_event.py b/homeassistant/components/deconz/deconz_event.py index 2fa9ec87fe3..ce67d2a4b29 100644 --- a/homeassistant/components/deconz/deconz_event.py +++ b/homeassistant/components/deconz/deconz_event.py @@ -47,7 +47,7 @@ async def async_setup_events(gateway) -> None: if ( sensor.type not in Switch.ZHATYPE + AncillaryControl.ZHATYPE - or sensor.uniqueid in {event.unique_id for event in gateway.events} + or sensor.unique_id in {event.unique_id for event in gateway.events} ): continue diff --git a/homeassistant/components/deconz/fan.py b/homeassistant/components/deconz/fan.py index cb64bff6d16..8a94b78b85f 100644 --- a/homeassistant/components/deconz/fan.py +++ b/homeassistant/components/deconz/fan.py @@ -39,7 +39,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities) -> None: for light in lights: - if light.type in FANS and light.uniqueid not in gateway.entities[DOMAIN]: + if light.type in FANS and light.unique_id not in gateway.entities[DOMAIN]: entities.append(DeconzFan(light, gateway)) if entities: diff --git a/homeassistant/components/deconz/gateway.py b/homeassistant/components/deconz/gateway.py index 0a7d7e0c849..ecbc36ebadc 100644 --- a/homeassistant/components/deconz/gateway.py +++ b/homeassistant/components/deconz/gateway.py @@ -151,11 +151,11 @@ class DeconzGateway: # Gateway service device_registry.async_get_or_create( config_entry_id=self.config_entry.entry_id, - identifiers={(DECONZ_DOMAIN, self.api.config.bridgeid)}, + identifiers={(DECONZ_DOMAIN, self.api.config.bridge_id)}, manufacturer="Dresden Elektronik", - model=self.api.config.modelid, + model=self.api.config.model_id, name=self.api.config.name, - sw_version=self.api.config.swversion, + sw_version=self.api.config.software_version, via_device=(CONNECTION_NETWORK_MAC, self.api.config.mac), ) @@ -266,12 +266,12 @@ async def get_gateway( config[CONF_HOST], config[CONF_PORT], config[CONF_API_KEY], - async_add_device=async_add_device_callback, + add_device=async_add_device_callback, connection_status=async_connection_status_callback, ) try: with async_timeout.timeout(10): - await deconz.initialize() + await deconz.refresh_state() return deconz except errors.Unauthorized as err: diff --git a/homeassistant/components/deconz/light.py b/homeassistant/components/deconz/light.py index 058147189e6..59eb1bb426e 100644 --- a/homeassistant/components/deconz/light.py +++ b/homeassistant/components/deconz/light.py @@ -58,7 +58,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): for light in lights: if ( light.type not in other_light_resource_types - and light.uniqueid not in gateway.entities[DOMAIN] + and light.unique_id not in gateway.entities[DOMAIN] ): entities.append(DeconzLight(light, gateway)) @@ -112,10 +112,10 @@ class DeconzBaseLight(DeconzDevice, LightEntity): self._attr_supported_color_modes = set() - if device.ct is not None: + if device.color_temp is not None: self._attr_supported_color_modes.add(COLOR_MODE_COLOR_TEMP) - if device.hue is not None and device.sat is not None: + if device.hue is not None and device.saturation is not None: self._attr_supported_color_modes.add(COLOR_MODE_HS) if device.xy is not None: @@ -137,11 +137,11 @@ class DeconzBaseLight(DeconzDevice, LightEntity): @property def color_mode(self) -> str: """Return the color mode of the light.""" - if self._device.colormode == "ct": + if self._device.color_mode == "ct": color_mode = COLOR_MODE_COLOR_TEMP - elif self._device.colormode == "hs": + elif self._device.color_mode == "hs": color_mode = COLOR_MODE_HS - elif self._device.colormode == "xy": + elif self._device.color_mode == "xy": color_mode = COLOR_MODE_XY elif self._device.brightness is not None: color_mode = COLOR_MODE_BRIGHTNESS @@ -162,12 +162,12 @@ class DeconzBaseLight(DeconzDevice, LightEntity): @property def color_temp(self): """Return the CT color value.""" - return self._device.ct + return self._device.color_temp @property def hs_color(self) -> tuple: """Return the hs color value.""" - return (self._device.hue / 65535 * 360, self._device.sat / 255 * 100) + return (self._device.hue / 65535 * 360, self._device.saturation / 255 * 100) @property def xy_color(self) -> tuple | None: @@ -184,25 +184,25 @@ class DeconzBaseLight(DeconzDevice, LightEntity): data = {"on": True} if ATTR_BRIGHTNESS in kwargs: - data["bri"] = kwargs[ATTR_BRIGHTNESS] + data["brightness"] = kwargs[ATTR_BRIGHTNESS] if ATTR_COLOR_TEMP in kwargs: - data["ct"] = kwargs[ATTR_COLOR_TEMP] + data["color_temperature"] = kwargs[ATTR_COLOR_TEMP] if ATTR_HS_COLOR in kwargs: if COLOR_MODE_XY in self._attr_supported_color_modes: data["xy"] = color_hs_to_xy(*kwargs[ATTR_HS_COLOR]) else: data["hue"] = int(kwargs[ATTR_HS_COLOR][0] / 360 * 65535) - data["sat"] = int(kwargs[ATTR_HS_COLOR][1] / 100 * 255) + data["saturation"] = int(kwargs[ATTR_HS_COLOR][1] / 100 * 255) if ATTR_XY_COLOR in kwargs: data["xy"] = kwargs[ATTR_XY_COLOR] if ATTR_TRANSITION in kwargs: - data["transitiontime"] = int(kwargs[ATTR_TRANSITION] * 10) + data["transition_time"] = int(kwargs[ATTR_TRANSITION] * 10) elif "IKEA" in self._device.manufacturer: - data["transitiontime"] = 0 + data["transition_time"] = 0 if ATTR_FLASH in kwargs: if kwargs[ATTR_FLASH] == FLASH_SHORT: @@ -218,7 +218,7 @@ class DeconzBaseLight(DeconzDevice, LightEntity): else: data["effect"] = "none" - await self._device.async_set_state(data) + await self._device.set_state(**data) async def async_turn_off(self, **kwargs): """Turn off light.""" @@ -228,8 +228,8 @@ class DeconzBaseLight(DeconzDevice, LightEntity): data = {"on": False} if ATTR_TRANSITION in kwargs: - data["bri"] = 0 - data["transitiontime"] = int(kwargs[ATTR_TRANSITION] * 10) + data["brightness"] = 0 + data["transition_time"] = int(kwargs[ATTR_TRANSITION] * 10) if ATTR_FLASH in kwargs: if kwargs[ATTR_FLASH] == FLASH_SHORT: @@ -239,7 +239,7 @@ class DeconzBaseLight(DeconzDevice, LightEntity): data["alert"] = "lselect" del data["on"] - await self._device.async_set_state(data) + await self._device.set_state(**data) @property def extra_state_attributes(self): @@ -253,12 +253,12 @@ class DeconzLight(DeconzBaseLight): @property def max_mireds(self): """Return the warmest color_temp that this light supports.""" - return self._device.ctmax or super().max_mireds + return self._device.max_color_temp or super().max_mireds @property def min_mireds(self): """Return the coldest color_temp that this light supports.""" - return self._device.ctmin or super().min_mireds + return self._device.min_color_temp or super().min_mireds class DeconzGroup(DeconzBaseLight): @@ -282,7 +282,7 @@ class DeconzGroup(DeconzBaseLight): "manufacturer": "Dresden Elektronik", "model": "deCONZ group", "name": self._device.name, - "via_device": (DECONZ_DOMAIN, self.gateway.api.config.bridgeid), + "via_device": (DECONZ_DOMAIN, self.gateway.api.config.bridge_id), } @property diff --git a/homeassistant/components/deconz/lock.py b/homeassistant/components/deconz/lock.py index 75f6bc872db..19770734087 100644 --- a/homeassistant/components/deconz/lock.py +++ b/homeassistant/components/deconz/lock.py @@ -22,7 +22,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): if ( light.type in LOCK_TYPES - and light.uniqueid not in gateway.entities[DOMAIN] + and light.unique_id not in gateway.entities[DOMAIN] ): entities.append(DeconzLock(light, gateway)) @@ -44,7 +44,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): if ( sensor.type in LOCK_TYPES - and sensor.uniqueid not in gateway.entities[DOMAIN] + and sensor.unique_id not in gateway.entities[DOMAIN] ): entities.append(DeconzLock(sensor, gateway)) diff --git a/homeassistant/components/deconz/logbook.py b/homeassistant/components/deconz/logbook.py index b36e06c0cf6..16497d00ccb 100644 --- a/homeassistant/components/deconz/logbook.py +++ b/homeassistant/components/deconz/logbook.py @@ -153,9 +153,9 @@ def async_describe_events( interface = None data = event.data.get(CONF_EVENT) or event.data.get(CONF_GESTURE, "") - if data and deconz_event.device.modelid in REMOTES: + if data and deconz_event.device.model_id in REMOTES: action, interface = _get_device_event_description( - deconz_event.device.modelid, data + deconz_event.device.model_id, data ) # Unknown event diff --git a/homeassistant/components/deconz/manifest.json b/homeassistant/components/deconz/manifest.json index 69deac8b5e0..a3dae8f5470 100644 --- a/homeassistant/components/deconz/manifest.json +++ b/homeassistant/components/deconz/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/deconz", "requirements": [ - "pydeconz==83" + "pydeconz==84" ], "ssdp": [ { diff --git a/homeassistant/components/deconz/scene.py b/homeassistant/components/deconz/scene.py index f4a4d328d22..45e891add28 100644 --- a/homeassistant/components/deconz/scene.py +++ b/homeassistant/components/deconz/scene.py @@ -51,4 +51,4 @@ class DeconzScene(Scene): async def async_activate(self, **kwargs: Any) -> None: """Activate the scene.""" - await self._scene.async_set_state({}) + await self._scene.recall() diff --git a/homeassistant/components/deconz/sensor.py b/homeassistant/components/deconz/sensor.py index 012e686534f..b5e2e60b8d5 100644 --- a/homeassistant/components/deconz/sensor.py +++ b/homeassistant/components/deconz/sensor.py @@ -124,7 +124,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): + DoorLock.ZHATYPE + Switch.ZHATYPE + Thermostat.ZHATYPE - and sensor.uniqueid not in gateway.entities[DOMAIN] + and sensor.unique_id not in gateway.entities[DOMAIN] ): entities.append(DeconzSensor(sensor, gateway)) @@ -273,7 +273,7 @@ class DeconzBattery(DeconzDevice, SensorEntity): Normally there should only be one battery sensor per device from deCONZ. With specific Danfoss devices each endpoint can report its own battery state. """ - if self._device.manufacturer == "Danfoss" and self._device.modelid in [ + if self._device.manufacturer == "Danfoss" and self._device.model_id in [ "0x8030", "0x8031", "0x8034", diff --git a/homeassistant/components/deconz/services.py b/homeassistant/components/deconz/services.py index 08ee9e11561..361ab1715c0 100644 --- a/homeassistant/components/deconz/services.py +++ b/homeassistant/components/deconz/services.py @@ -185,7 +185,7 @@ async def async_remove_orphaned_entries_service(gateway): # Don't remove the Gateway service entry gateway_service = device_registry.async_get_device( - identifiers={(DOMAIN, gateway.api.config.bridgeid)}, connections=set() + identifiers={(DOMAIN, gateway.api.config.bridge_id)}, connections=set() ) if gateway_service.id in devices_to_be_removed: devices_to_be_removed.remove(gateway_service.id) diff --git a/homeassistant/components/deconz/switch.py b/homeassistant/components/deconz/switch.py index 492872ecca0..5fee752a71f 100644 --- a/homeassistant/components/deconz/switch.py +++ b/homeassistant/components/deconz/switch.py @@ -25,12 +25,12 @@ async def async_setup_entry(hass, config_entry, async_add_entities): if ( light.type in POWER_PLUGS - and light.uniqueid not in gateway.entities[DOMAIN] + and light.unique_id not in gateway.entities[DOMAIN] ): entities.append(DeconzPowerPlug(light, gateway)) elif ( - light.type in SIRENS and light.uniqueid not in gateway.entities[DOMAIN] + light.type in SIRENS and light.unique_id not in gateway.entities[DOMAIN] ): entities.append(DeconzSiren(light, gateway)) @@ -58,13 +58,11 @@ class DeconzPowerPlug(DeconzDevice, SwitchEntity): async def async_turn_on(self, **kwargs): """Turn on switch.""" - data = {"on": True} - await self._device.async_set_state(data) + await self._device.set_state(on=True) async def async_turn_off(self, **kwargs): """Turn off switch.""" - data = {"on": False} - await self._device.async_set_state(data) + await self._device.set_state(on=False) class DeconzSiren(DeconzDevice, SwitchEntity): diff --git a/requirements_all.txt b/requirements_all.txt index e66ece52b80..89fc8e9f55e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1415,7 +1415,7 @@ pydaikin==2.4.4 pydanfossair==0.1.0 # homeassistant.components.deconz -pydeconz==83 +pydeconz==84 # homeassistant.components.delijn pydelijn==0.6.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0516fd4aa5d..af453498438 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -815,7 +815,7 @@ pycoolmasternet-async==0.1.2 pydaikin==2.4.4 # homeassistant.components.deconz -pydeconz==83 +pydeconz==84 # homeassistant.components.dexcom pydexcom==0.2.0 diff --git a/tests/components/deconz/conftest.py b/tests/components/deconz/conftest.py index 7b2c691bcae..8b92e94416a 100644 --- a/tests/components/deconz/conftest.py +++ b/tests/components/deconz/conftest.py @@ -3,6 +3,7 @@ from __future__ import annotations from unittest.mock import patch +from pydeconz.websocket import SIGNAL_CONNECTION_STATE, SIGNAL_DATA import pytest from tests.components.light.conftest import mock_light_profiles # noqa: F401 @@ -19,10 +20,10 @@ def mock_deconz_websocket(): if data: mock.return_value.data = data - await pydeconz_gateway_session_handler(signal="data") + await pydeconz_gateway_session_handler(signal=SIGNAL_DATA) elif state: mock.return_value.state = state - await pydeconz_gateway_session_handler(signal="state") + await pydeconz_gateway_session_handler(signal=SIGNAL_CONNECTION_STATE) else: raise NotImplementedError diff --git a/tests/components/deconz/test_gateway.py b/tests/components/deconz/test_gateway.py index 8a160b7ef19..120ceaa9327 100644 --- a/tests/components/deconz/test_gateway.py +++ b/tests/components/deconz/test_gateway.py @@ -267,14 +267,14 @@ async def test_reset_after_successful_setup(hass, aioclient_mock): async def test_get_gateway(hass): """Successful call.""" - with patch("pydeconz.DeconzSession.initialize", return_value=True): + with patch("pydeconz.DeconzSession.refresh_state", return_value=True): assert await get_gateway(hass, ENTRY_CONFIG, Mock(), Mock()) async def test_get_gateway_fails_unauthorized(hass): """Failed call.""" with patch( - "pydeconz.DeconzSession.initialize", + "pydeconz.DeconzSession.refresh_state", side_effect=pydeconz.errors.Unauthorized, ), pytest.raises(AuthenticationRequired): assert await get_gateway(hass, ENTRY_CONFIG, Mock(), Mock()) is False @@ -283,7 +283,7 @@ async def test_get_gateway_fails_unauthorized(hass): async def test_get_gateway_fails_cannot_connect(hass): """Failed call.""" with patch( - "pydeconz.DeconzSession.initialize", + "pydeconz.DeconzSession.refresh_state", side_effect=pydeconz.errors.RequestError, ), pytest.raises(CannotConnect): assert await get_gateway(hass, ENTRY_CONFIG, Mock(), Mock()) is False diff --git a/tests/components/deconz/test_init.py b/tests/components/deconz/test_init.py index 814ec588b1e..e50ac41d63d 100644 --- a/tests/components/deconz/test_init.py +++ b/tests/components/deconz/test_init.py @@ -44,14 +44,16 @@ async def setup_entry(hass, entry): async def test_setup_entry_fails(hass): """Test setup entry fails if deCONZ is not available.""" - with patch("pydeconz.DeconzSession.initialize", side_effect=Exception): + with patch("pydeconz.DeconzSession.refresh_state", side_effect=Exception): await setup_deconz_integration(hass) assert not hass.data[DECONZ_DOMAIN] async def test_setup_entry_no_available_bridge(hass): """Test setup entry fails if deCONZ is not available.""" - with patch("pydeconz.DeconzSession.initialize", side_effect=asyncio.TimeoutError): + with patch( + "pydeconz.DeconzSession.refresh_state", side_effect=asyncio.TimeoutError + ): await setup_deconz_integration(hass) assert not hass.data[DECONZ_DOMAIN] From 0830100df1febbdbff712417bfc905641f0a311c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Sep 2021 21:22:14 -1000 Subject: [PATCH 456/843] Do not reload the isy994 on ip change since there is already a reload listener (#54602) --- homeassistant/components/isy994/__init__.py | 7 +-- .../components/isy994/config_flow.py | 3 - homeassistant/components/isy994/const.py | 2 - tests/components/isy994/test_config_flow.py | 60 ++----------------- 4 files changed, 7 insertions(+), 65 deletions(-) diff --git a/homeassistant/components/isy994/__init__.py b/homeassistant/components/isy994/__init__.py index e3d11efd739..02bbea29bdb 100644 --- a/homeassistant/components/isy994/__init__.py +++ b/homeassistant/components/isy994/__init__.py @@ -41,7 +41,6 @@ from .const import ( MANUFACTURER, PLATFORMS, PROGRAM_PLATFORMS, - UNDO_UPDATE_LISTENER, ) from .helpers import _categorize_nodes, _categorize_programs, _categorize_variables from .services import async_setup_services, async_unload_services @@ -218,9 +217,7 @@ async def async_setup_entry( await hass.async_add_executor_job(_start_auto_update) - undo_listener = entry.add_update_listener(_async_update_listener) - - hass_isy_data[UNDO_UPDATE_LISTENER] = undo_listener + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_auto_update) ) @@ -290,8 +287,6 @@ async def async_unload_entry( await hass.async_add_executor_job(_stop_auto_update) - hass_isy_data[UNDO_UPDATE_LISTENER]() - if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) diff --git a/homeassistant/components/isy994/config_flow.py b/homeassistant/components/isy994/config_flow.py index 58e5238cbee..34c7a40cfc0 100644 --- a/homeassistant/components/isy994/config_flow.py +++ b/homeassistant/components/isy994/config_flow.py @@ -182,9 +182,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ), }, ) - self.hass.async_create_task( - self.hass.config_entries.async_reload(existing_entry.entry_id) - ) raise data_entry_flow.AbortFlow("already_configured") async def async_step_dhcp(self, discovery_info): diff --git a/homeassistant/components/isy994/const.py b/homeassistant/components/isy994/const.py index b7b2f283a84..8e634006ec2 100644 --- a/homeassistant/components/isy994/const.py +++ b/homeassistant/components/isy994/const.py @@ -183,8 +183,6 @@ TYPE_CATEGORY_X10 = "113." TYPE_EZIO2X4 = "7.3.255." TYPE_INSTEON_MOTION = ("16.1.", "16.22.") -UNDO_UPDATE_LISTENER = "undo_update_listener" - # Used for discovery UDN_UUID_PREFIX = "uuid:" ISY_URL_POSTFIX = "/desc" diff --git a/tests/components/isy994/test_config_flow.py b/tests/components/isy994/test_config_flow.py index 1e96de9ff2f..09e6e26e777 100644 --- a/tests/components/isy994/test_config_flow.py +++ b/tests/components/isy994/test_config_flow.py @@ -383,12 +383,7 @@ async def test_form_ssdp_existing_entry(hass: HomeAssistant): ) entry.add_to_hass(hass) - with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE), patch( - PATCH_ASYNC_SETUP, return_value=True - ) as mock_setup, patch( - PATCH_ASYNC_SETUP_ENTRY, - return_value=True, - ) as mock_setup_entry: + with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, @@ -404,9 +399,6 @@ async def test_form_ssdp_existing_entry(hass: HomeAssistant): assert result["reason"] == "already_configured" assert entry.data[CONF_HOST] == f"http://3.3.3.3:80{ISY_URL_POSTFIX}" - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 - async def test_form_ssdp_existing_entry_with_no_port(hass: HomeAssistant): """Test we update the ip of an existing entry from ssdp with no port.""" @@ -418,12 +410,7 @@ async def test_form_ssdp_existing_entry_with_no_port(hass: HomeAssistant): ) entry.add_to_hass(hass) - with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE), patch( - PATCH_ASYNC_SETUP, return_value=True - ) as mock_setup, patch( - PATCH_ASYNC_SETUP_ENTRY, - return_value=True, - ) as mock_setup_entry: + with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, @@ -439,9 +426,6 @@ async def test_form_ssdp_existing_entry_with_no_port(hass: HomeAssistant): assert result["reason"] == "already_configured" assert entry.data[CONF_HOST] == f"http://3.3.3.3:80/{ISY_URL_POSTFIX}" - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 - async def test_form_ssdp_existing_entry_with_alternate_port(hass: HomeAssistant): """Test we update the ip of an existing entry from ssdp with an alternate port.""" @@ -453,12 +437,7 @@ async def test_form_ssdp_existing_entry_with_alternate_port(hass: HomeAssistant) ) entry.add_to_hass(hass) - with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE), patch( - PATCH_ASYNC_SETUP, return_value=True - ) as mock_setup, patch( - PATCH_ASYNC_SETUP_ENTRY, - return_value=True, - ) as mock_setup_entry: + with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, @@ -474,9 +453,6 @@ async def test_form_ssdp_existing_entry_with_alternate_port(hass: HomeAssistant) assert result["reason"] == "already_configured" assert entry.data[CONF_HOST] == f"http://3.3.3.3:1443/{ISY_URL_POSTFIX}" - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 - async def test_form_ssdp_existing_entry_no_port_https(hass: HomeAssistant): """Test we update the ip of an existing entry from ssdp with no port and https.""" @@ -488,12 +464,7 @@ async def test_form_ssdp_existing_entry_no_port_https(hass: HomeAssistant): ) entry.add_to_hass(hass) - with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE), patch( - PATCH_ASYNC_SETUP, return_value=True - ) as mock_setup, patch( - PATCH_ASYNC_SETUP_ENTRY, - return_value=True, - ) as mock_setup_entry: + with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, @@ -509,9 +480,6 @@ async def test_form_ssdp_existing_entry_no_port_https(hass: HomeAssistant): assert result["reason"] == "already_configured" assert entry.data[CONF_HOST] == f"https://3.3.3.3:443/{ISY_URL_POSTFIX}" - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 - async def test_form_dhcp(hass: HomeAssistant): """Test we can setup from dhcp.""" @@ -560,12 +528,7 @@ async def test_form_dhcp_existing_entry(hass: HomeAssistant): ) entry.add_to_hass(hass) - with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE), patch( - PATCH_ASYNC_SETUP, return_value=True - ) as mock_setup, patch( - PATCH_ASYNC_SETUP_ENTRY, - return_value=True, - ) as mock_setup_entry: + with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, @@ -581,9 +544,6 @@ async def test_form_dhcp_existing_entry(hass: HomeAssistant): assert result["reason"] == "already_configured" assert entry.data[CONF_HOST] == f"http://1.2.3.4{ISY_URL_POSTFIX}" - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 - async def test_form_dhcp_existing_entry_preserves_port(hass: HomeAssistant): """Test we update the ip of an existing entry from dhcp preserves port.""" @@ -598,12 +558,7 @@ async def test_form_dhcp_existing_entry_preserves_port(hass: HomeAssistant): ) entry.add_to_hass(hass) - with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE), patch( - PATCH_ASYNC_SETUP, return_value=True - ) as mock_setup, patch( - PATCH_ASYNC_SETUP_ENTRY, - return_value=True, - ) as mock_setup_entry: + with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, @@ -619,6 +574,3 @@ async def test_form_dhcp_existing_entry_preserves_port(hass: HomeAssistant): assert result["reason"] == "already_configured" assert entry.data[CONF_HOST] == f"http://1.2.3.4:1443{ISY_URL_POSTFIX}" assert entry.data[CONF_USERNAME] == "bob" - - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 From be0819b45602b00e98183d38f9b427f80e4f38a8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sat, 18 Sep 2021 09:40:58 +0200 Subject: [PATCH 457/843] Mock out network.util.async_get_source_ip in tests (#56339) --- tests/components/default_config/test_init.py | 2 +- tests/components/homekit/test_init.py | 2 +- tests/components/homekit/test_util.py | 2 +- tests/components/sonos/conftest.py | 6 ++++++ 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/components/default_config/test_init.py b/tests/components/default_config/test_init.py index ec2d207a68b..1052eeeb164 100644 --- a/tests/components/default_config/test_init.py +++ b/tests/components/default_config/test_init.py @@ -29,6 +29,6 @@ def recorder_url_mock(): yield -async def test_setup(hass, mock_zeroconf): +async def test_setup(hass, mock_zeroconf, mock_get_source_ip): """Test setup.""" assert await async_setup_component(hass, "default_config", {"foo": "bar"}) diff --git a/tests/components/homekit/test_init.py b/tests/components/homekit/test_init.py index 6643ae9ae18..8652f8b032a 100644 --- a/tests/components/homekit/test_init.py +++ b/tests/components/homekit/test_init.py @@ -14,7 +14,7 @@ from homeassistant.setup import async_setup_component from tests.components.logbook.test_init import MockLazyEventPartialState -async def test_humanify_homekit_changed_event(hass, hk_driver): +async def test_humanify_homekit_changed_event(hass, hk_driver, mock_get_source_ip): """Test humanifying HomeKit changed event.""" hass.config.components.add("recorder") with patch("homeassistant.components.homekit.HomeKit"): diff --git a/tests/components/homekit/test_util.py b/tests/components/homekit/test_util.py index 2d4ac2171da..2c1deb3bd8e 100644 --- a/tests/components/homekit/test_util.py +++ b/tests/components/homekit/test_util.py @@ -238,7 +238,7 @@ def test_density_to_air_quality(): assert density_to_air_quality(300) == 5 -async def test_show_setup_msg(hass, hk_driver): +async def test_show_setup_msg(hass, hk_driver, mock_get_source_ip): """Test show setup message as persistence notification.""" pincode = b"123-45-678" diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index b1b3dd7be10..b81934e2593 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -202,3 +202,9 @@ def alarm_event_fixture(soco): } return SonosMockEvent(soco, soco.alarmClock, variables) + + +@pytest.fixture(autouse=True) +def mock_get_source_ip(mock_get_source_ip): + """Mock network util's async_get_source_ip in all sonos tests.""" + return mock_get_source_ip From 39bc127dd6749f39d772ce7078aec46b9cee792d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sat, 18 Sep 2021 10:22:44 +0200 Subject: [PATCH 458/843] Prevent 3rd party lib from opening sockets in glances tests (#56345) --- tests/components/glances/test_config_flow.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/components/glances/test_config_flow.py b/tests/components/glances/test_config_flow.py index c9a2c333b8b..1b2c2434fab 100644 --- a/tests/components/glances/test_config_flow.py +++ b/tests/components/glances/test_config_flow.py @@ -1,13 +1,13 @@ """Tests for Glances config flow.""" from unittest.mock import patch -from glances_api import Glances +from glances_api import exceptions from homeassistant import config_entries, data_entry_flow from homeassistant.components import glances from homeassistant.const import CONF_SCAN_INTERVAL -from tests.common import MockConfigEntry, mock_coro +from tests.common import MockConfigEntry NAME = "Glances" HOST = "0.0.0.0" @@ -38,9 +38,7 @@ async def test_form(hass): assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" - with patch("glances_api.Glances"), patch.object( - Glances, "get_data", return_value=mock_coro() - ): + with patch("homeassistant.components.glances.Glances.get_data", autospec=True): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=DEMO_USER_INPUT @@ -54,7 +52,10 @@ async def test_form(hass): async def test_form_cannot_connect(hass): """Test to return error if we cannot connect.""" - with patch("glances_api.Glances"): + with patch( + "homeassistant.components.glances.Glances.get_data", + side_effect=exceptions.GlancesApiConnectionError, + ): result = await hass.config_entries.flow.async_init( glances.DOMAIN, context={"source": config_entries.SOURCE_USER} ) From 476d04e2fb086f6768ee9a808597f2a68c699a06 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Sat, 18 Sep 2021 11:02:24 +0200 Subject: [PATCH 459/843] Activate mypy. (#55965) --- .../components/entur_public_transport/sensor.py | 10 ++++++---- mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/entur_public_transport/sensor.py b/homeassistant/components/entur_public_transport/sensor.py index cad8a49884f..3256b26171b 100644 --- a/homeassistant/components/entur_public_transport/sensor.py +++ b/homeassistant/components/entur_public_transport/sensor.py @@ -1,4 +1,6 @@ """Real-time information about public transport departures in Norway.""" +from __future__ import annotations + from datetime import datetime, timedelta from enturclient import EnturPublicTransportData @@ -158,9 +160,9 @@ class EnturPublicTransportSensor(SensorEntity): self._stop = stop self._show_on_map = show_on_map self._name = name - self._state = None + self._state: int | None = None self._icon = ICONS[DEFAULT_ICON_KEY] - self._attributes = {} + self._attributes: dict[str, str] = {} @property def name(self) -> str: @@ -168,7 +170,7 @@ class EnturPublicTransportSensor(SensorEntity): return self._name @property - def native_value(self) -> str: + def native_value(self) -> int | None: """Return the state of the sensor.""" return self._state @@ -195,7 +197,7 @@ class EnturPublicTransportSensor(SensorEntity): self._attributes = {} - data = self.api.get_stop_info(self._stop) + data: EnturPublicTransportData = self.api.get_stop_info(self._stop) if data is None: self._state = None return diff --git a/mypy.ini b/mypy.ini index 084355c2beb..fc3d76863ef 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1348,9 +1348,6 @@ ignore_errors = true [mypy-homeassistant.components.enphase_envoy.*] ignore_errors = true -[mypy-homeassistant.components.entur_public_transport.*] -ignore_errors = true - [mypy-homeassistant.components.evohome.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index fc6b12ac70e..6114030f2b2 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -27,7 +27,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.dhcp.*", "homeassistant.components.doorbird.*", "homeassistant.components.enphase_envoy.*", - "homeassistant.components.entur_public_transport.*", "homeassistant.components.evohome.*", "homeassistant.components.fireservicerota.*", "homeassistant.components.firmata.*", From 48bada5a184d48f1ccb668e43a54ad346676d1ec Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 18 Sep 2021 13:52:59 +0200 Subject: [PATCH 460/843] Update pylint to 2.11.1 (#56364) --- homeassistant/components/ambiclimate/config_flow.py | 1 + homeassistant/components/androidtv/media_player.py | 1 + homeassistant/components/api/__init__.py | 2 ++ homeassistant/components/auth/__init__.py | 2 ++ homeassistant/components/auth/login_flow.py | 1 + homeassistant/components/awair/__init__.py | 1 + homeassistant/components/blueprint/models.py | 2 +- homeassistant/components/camera/__init__.py | 1 + homeassistant/components/config/config_entries.py | 1 + homeassistant/components/conversation/agent.py | 2 ++ homeassistant/components/denonavr/media_player.py | 5 +---- homeassistant/components/doorbird/__init__.py | 1 + homeassistant/components/ecobee/climate.py | 2 +- homeassistant/components/elkm1/climate.py | 5 +++-- homeassistant/components/esphome/__init__.py | 1 + homeassistant/components/fibaro/climate.py | 2 +- homeassistant/components/fritzbox/climate.py | 6 +++--- homeassistant/components/homekit/__init__.py | 1 + homeassistant/components/homematicip_cloud/hap.py | 1 + homeassistant/components/huisbaasje/config_flow.py | 1 + homeassistant/components/limitlessled/light.py | 1 + homeassistant/components/lovelace/dashboard.py | 2 ++ homeassistant/components/manual/alarm_control_panel.py | 7 ++----- homeassistant/components/media_player/__init__.py | 1 + homeassistant/components/melcloud/config_flow.py | 2 +- homeassistant/components/nest/camera_sdm.py | 1 + .../openweathermap/weather_update_coordinator.py | 6 +++--- homeassistant/components/ozw/__init__.py | 2 +- homeassistant/components/plex/config_flow.py | 1 + homeassistant/components/sensehat/sensor.py | 6 +++++- homeassistant/components/smarttub/controller.py | 1 + homeassistant/components/sonos/speaker.py | 5 +---- homeassistant/components/spotify/media_player.py | 1 + homeassistant/components/stream/core.py | 1 - homeassistant/components/tank_utility/sensor.py | 8 +++----- homeassistant/components/timer/__init__.py | 2 +- homeassistant/components/webhook/__init__.py | 1 + homeassistant/components/webostv/media_player.py | 4 +--- homeassistant/components/websocket_api/http.py | 1 + homeassistant/components/whirlpool/config_flow.py | 2 +- homeassistant/components/withings/common.py | 1 + homeassistant/components/zwave/climate.py | 6 +++--- homeassistant/components/zwave_js/api.py | 1 + homeassistant/components/zwave_js/sensor.py | 1 + homeassistant/components/zwave_js/services.py | 4 ++++ homeassistant/core.py | 1 + homeassistant/helpers/config_entry_oauth2_flow.py | 1 + pyproject.toml | 9 ++++++++- requirements_test.txt | 2 +- 49 files changed, 77 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/ambiclimate/config_flow.py b/homeassistant/components/ambiclimate/config_flow.py index 2643b01185a..04d1b749d10 100644 --- a/homeassistant/components/ambiclimate/config_flow.py +++ b/homeassistant/components/ambiclimate/config_flow.py @@ -142,6 +142,7 @@ class AmbiclimateAuthCallbackView(HomeAssistantView): async def get(self, request: web.Request) -> str: """Receive authorization token.""" + # pylint: disable=no-self-use code = request.query.get("code") if code is None: return "No code" diff --git a/homeassistant/components/androidtv/media_player.py b/homeassistant/components/androidtv/media_player.py index 8bc53bd86b7..533470181c1 100644 --- a/homeassistant/components/androidtv/media_player.py +++ b/homeassistant/components/androidtv/media_player.py @@ -358,6 +358,7 @@ def adb_decorator(override_available=False): @functools.wraps(func) async def _adb_exception_catcher(self, *args, **kwargs): """Call an ADB-related method and catch exceptions.""" + # pylint: disable=protected-access if not self.available and not override_available: return None diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index a6096a14658..144205b3b25 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -93,6 +93,7 @@ class APIEventStream(HomeAssistantView): async def get(self, request): """Provide a streaming interface for the event bus.""" + # pylint: disable=no-self-use if not request["hass_user"].is_admin: raise Unauthorized() hass = request.app["hass"] @@ -414,6 +415,7 @@ class APIErrorLog(HomeAssistantView): async def get(self, request): """Retrieve API error log.""" + # pylint: disable=no-self-use if not request["hass_user"].is_admin: raise Unauthorized() return web.FileResponse(request.app["hass"].data[DATA_LOGGING]) diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py index 7381be5e9de..c4a48f7eda4 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -264,6 +264,8 @@ class TokenView(HomeAssistantView): async def _async_handle_revoke_token(self, hass, data): """Handle revoke token request.""" + # pylint: disable=no-self-use + # OAuth 2.0 Token Revocation [RFC7009] # 2.2 The authorization server responds with HTTP status code 200 # if the token has been revoked successfully or if the client diff --git a/homeassistant/components/auth/login_flow.py b/homeassistant/components/auth/login_flow.py index b01e6e0c01e..f948233b33b 100644 --- a/homeassistant/components/auth/login_flow.py +++ b/homeassistant/components/auth/login_flow.py @@ -157,6 +157,7 @@ class LoginFlowIndexView(HomeAssistantView): async def get(self, request): """Do not allow index of flows in progress.""" + # pylint: disable=no-self-use return web.Response(status=HTTP_METHOD_NOT_ALLOWED) @RequestDataValidator( diff --git a/homeassistant/components/awair/__init__.py b/homeassistant/components/awair/__init__.py index 8199c3881c9..39853dab9de 100644 --- a/homeassistant/components/awair/__init__.py +++ b/homeassistant/components/awair/__init__.py @@ -74,6 +74,7 @@ class AwairDataUpdateCoordinator(DataUpdateCoordinator): async def _fetch_air_data(self, device): """Fetch latest air quality data.""" + # pylint: disable=no-self-use LOGGER.debug("Fetching data for %s", device.uuid) air_data = await device.air_data_latest() LOGGER.debug(air_data) diff --git a/homeassistant/components/blueprint/models.py b/homeassistant/components/blueprint/models.py index 797f9bd1512..827d37843d9 100644 --- a/homeassistant/components/blueprint/models.py +++ b/homeassistant/components/blueprint/models.py @@ -316,7 +316,7 @@ class DomainBlueprints: raise FileAlreadyExists(self.domain, blueprint_path) path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(blueprint.yaml()) + path.write_text(blueprint.yaml(), encoding="utf-8") async def async_add_blueprint( self, blueprint: Blueprint, blueprint_path: str diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 9724e8e1e70..040a49dcc4a 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -434,6 +434,7 @@ class Camera(Entity): async def stream_source(self) -> str | None: """Return the source of the stream.""" + # pylint: disable=no-self-use return None def camera_image( diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index 7fe5cb0d190..f842a240dc1 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -116,6 +116,7 @@ class ConfigManagerFlowIndexView(FlowManagerIndexView): async def get(self, request): """Not implemented.""" + # pylint: disable=no-self-use raise aiohttp.web_exceptions.HTTPMethodNotAllowed("GET", ["POST"]) # pylint: disable=arguments-differ diff --git a/homeassistant/components/conversation/agent.py b/homeassistant/components/conversation/agent.py index 56cf4aecdea..251058c7edd 100644 --- a/homeassistant/components/conversation/agent.py +++ b/homeassistant/components/conversation/agent.py @@ -17,10 +17,12 @@ class AbstractConversationAgent(ABC): async def async_get_onboarding(self): """Get onboard data.""" + # pylint: disable=no-self-use return None async def async_set_onboarding(self, shown): """Set onboard data.""" + # pylint: disable=no-self-use return True @abstractmethod diff --git a/homeassistant/components/denonavr/media_player.py b/homeassistant/components/denonavr/media_player.py index caa34e352d0..68a5b8c71d8 100644 --- a/homeassistant/components/denonavr/media_player.py +++ b/homeassistant/components/denonavr/media_player.py @@ -309,10 +309,7 @@ class DenonDevice(MediaPlayerEntity): @property def media_content_type(self): """Content type of current playing media.""" - if ( - self._receiver.state == STATE_PLAYING - or self._receiver.state == STATE_PAUSED - ): + if self._receiver.state in (STATE_PLAYING, STATE_PAUSED): return MEDIA_TYPE_MUSIC return MEDIA_TYPE_CHANNEL diff --git a/homeassistant/components/doorbird/__init__.py b/homeassistant/components/doorbird/__init__.py index 07366ad1a9a..4e4b1cb6ae9 100644 --- a/homeassistant/components/doorbird/__init__.py +++ b/homeassistant/components/doorbird/__init__.py @@ -324,6 +324,7 @@ class DoorBirdRequestView(HomeAssistantView): async def get(self, request, event): """Respond to requests from the device.""" + # pylint: disable=no-self-use hass = request.app["hass"] token = request.query.get("token") diff --git a/homeassistant/components/ecobee/climate.py b/homeassistant/components/ecobee/climate.py index eeac7ddb224..0e7a5e52fa7 100644 --- a/homeassistant/components/ecobee/climate.py +++ b/homeassistant/components/ecobee/climate.py @@ -676,7 +676,7 @@ class Thermostat(ClimateEntity): heatCoolMinDelta property. https://www.ecobee.com/home/developer/api/examples/ex5.shtml """ - if self.hvac_mode == HVAC_MODE_HEAT or self.hvac_mode == HVAC_MODE_COOL: + if self.hvac_mode in (HVAC_MODE_HEAT, HVAC_MODE_COOL): heat_temp = temp cool_temp = temp else: diff --git a/homeassistant/components/elkm1/climate.py b/homeassistant/components/elkm1/climate.py index 6d10df45adf..bc5f3ae4b7a 100644 --- a/homeassistant/components/elkm1/climate.py +++ b/homeassistant/components/elkm1/climate.py @@ -65,8 +65,9 @@ class ElkThermostat(ElkEntity, ClimateEntity): @property def target_temperature(self): """Return the temperature we are trying to reach.""" - if (self._element.mode == ThermostatMode.HEAT.value) or ( - self._element.mode == ThermostatMode.EMERGENCY_HEAT.value + if self._element.mode in ( + ThermostatMode.HEAT.value, + ThermostatMode.EMERGENCY_HEAT.value, ): return self._element.heat_setpoint if self._element.mode == ThermostatMode.COOL.value: diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index b3fd4c5075c..64e75910c2d 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -805,6 +805,7 @@ def esphome_state_property(func: _PropT) -> _PropT: @property # type: ignore[misc] @functools.wraps(func) def _wrapper(self): # type: ignore[no-untyped-def] + # pylint: disable=protected-access if not self._has_state: return None val = func(self) diff --git a/homeassistant/components/fibaro/climate.py b/homeassistant/components/fibaro/climate.py index 58fde1e370b..e72eb7762d6 100644 --- a/homeassistant/components/fibaro/climate.py +++ b/homeassistant/components/fibaro/climate.py @@ -136,7 +136,7 @@ class FibaroThermostat(FibaroDevice, ClimateEntity): "value" in device.properties or "heatingThermostatSetpoint" in device.properties ) - and (device.properties.unit == "C" or device.properties.unit == "F") + and device.properties.unit in ("C", "F") ): self._temp_sensor_device = FibaroDevice(device) tempunit = device.properties.unit diff --git a/homeassistant/components/fritzbox/climate.py b/homeassistant/components/fritzbox/climate.py index f8e394e1ef0..bf2d857e30e 100644 --- a/homeassistant/components/fritzbox/climate.py +++ b/homeassistant/components/fritzbox/climate.py @@ -114,9 +114,9 @@ class FritzboxThermostat(FritzBoxEntity, ClimateEntity): @property def hvac_mode(self) -> str: """Return the current operation mode.""" - if ( - self.device.target_temperature == OFF_REPORT_SET_TEMPERATURE - or self.device.target_temperature == OFF_API_TEMPERATURE + if self.device.target_temperature in ( + OFF_REPORT_SET_TEMPERATURE, + OFF_API_TEMPERATURE, ): return HVAC_MODE_OFF diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 0ce634be94e..8fc68ca641c 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -963,6 +963,7 @@ class HomeKitPairingQRView(HomeAssistantView): async def get(self, request): """Retrieve the pairing QRCode image.""" + # pylint: disable=no-self-use if not request.query_string: raise Unauthorized() entry_id, secret = request.query_string.split("-") diff --git a/homeassistant/components/homematicip_cloud/hap.py b/homeassistant/components/homematicip_cloud/hap.py index 5cccc9a9999..3f8f6ae6086 100644 --- a/homeassistant/components/homematicip_cloud/hap.py +++ b/homeassistant/components/homematicip_cloud/hap.py @@ -59,6 +59,7 @@ class HomematicipAuth: async def get_auth(self, hass: HomeAssistant, hapid, pin): """Create a HomematicIP access point object.""" + # pylint: disable=no-self-use auth = AsyncAuth(hass.loop, async_get_clientsession(hass)) try: await auth.init(hapid) diff --git a/homeassistant/components/huisbaasje/config_flow.py b/homeassistant/components/huisbaasje/config_flow.py index 4139b0d75c5..fc686f25809 100644 --- a/homeassistant/components/huisbaasje/config_flow.py +++ b/homeassistant/components/huisbaasje/config_flow.py @@ -69,6 +69,7 @@ class HuisbaasjeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): Data has the keys from DATA_SCHEMA with values provided by the user. """ + # pylint: disable=no-self-use username = user_input[CONF_USERNAME] password = user_input[CONF_PASSWORD] diff --git a/homeassistant/components/limitlessled/light.py b/homeassistant/components/limitlessled/light.py index 6dbaf0c3b7c..ac307f68d08 100644 --- a/homeassistant/components/limitlessled/light.py +++ b/homeassistant/components/limitlessled/light.py @@ -179,6 +179,7 @@ def state(new_state): def wrapper(self, **kwargs): """Wrap a group state change.""" + # pylint: disable=protected-access pipeline = Pipeline() transition_time = DEFAULT_TRANSITION diff --git a/homeassistant/components/lovelace/dashboard.py b/homeassistant/components/lovelace/dashboard.py index 93b127259d2..bb043028ae6 100644 --- a/homeassistant/components/lovelace/dashboard.py +++ b/homeassistant/components/lovelace/dashboard.py @@ -69,10 +69,12 @@ class LovelaceConfig(ABC): async def async_save(self, config): """Save config.""" + # pylint: disable=no-self-use raise HomeAssistantError("Not supported") async def async_delete(self): """Delete config.""" + # pylint: disable=no-self-use raise HomeAssistantError("Not supported") @callback diff --git a/homeassistant/components/manual/alarm_control_panel.py b/homeassistant/components/manual/alarm_control_panel.py index a78476cf5d3..d8b1ed088e3 100644 --- a/homeassistant/components/manual/alarm_control_panel.py +++ b/homeassistant/components/manual/alarm_control_panel.py @@ -412,7 +412,7 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): @property def extra_state_attributes(self): """Return the state attributes.""" - if self.state == STATE_ALARM_PENDING or self.state == STATE_ALARM_ARMING: + if self.state in (STATE_ALARM_PENDING, STATE_ALARM_ARMING): return { ATTR_PREVIOUS_STATE: self._previous_state, ATTR_NEXT_STATE: self._state, @@ -430,10 +430,7 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): state = await self.async_get_last_state() if state: if ( - ( - state.state == STATE_ALARM_PENDING - or state.state == STATE_ALARM_ARMING - ) + state.state in (STATE_ALARM_PENDING, STATE_ALARM_ARMING) and hasattr(state, "attributes") and state.attributes[ATTR_PREVIOUS_STATE] ): diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index b0030786ed7..9ac350a5714 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -507,6 +507,7 @@ class MediaPlayerEntity(Entity): Must be implemented by integration. """ + # pylint: disable=no-self-use return None, None @property diff --git a/homeassistant/components/melcloud/config_flow.py b/homeassistant/components/melcloud/config_flow.py index e3c041727c0..48ee84382fa 100644 --- a/homeassistant/components/melcloud/config_flow.py +++ b/homeassistant/components/melcloud/config_flow.py @@ -60,7 +60,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self.hass.helpers.aiohttp_client.async_get_clientsession(), ) except ClientResponseError as err: - if err.status == HTTP_UNAUTHORIZED or err.status == HTTP_FORBIDDEN: + if err.status in (HTTP_UNAUTHORIZED, HTTP_FORBIDDEN): return self.async_abort(reason="invalid_auth") return self.async_abort(reason="cannot_connect") except (asyncio.TimeoutError, ClientError): diff --git a/homeassistant/components/nest/camera_sdm.py b/homeassistant/components/nest/camera_sdm.py index 242c6147201..07a75129b44 100644 --- a/homeassistant/components/nest/camera_sdm.py +++ b/homeassistant/components/nest/camera_sdm.py @@ -222,6 +222,7 @@ class NestCamera(Camera): self, trait: EventImageGenerator ) -> bytes | None: """Return image bytes for an active event.""" + # pylint: disable=no-self-use try: event_image = await trait.generate_active_event_image() except GoogleNestException as err: diff --git a/homeassistant/components/openweathermap/weather_update_coordinator.py b/homeassistant/components/openweathermap/weather_update_coordinator.py index 73edc9fae75..db8c48aeac4 100644 --- a/homeassistant/components/openweathermap/weather_update_coordinator.py +++ b/homeassistant/components/openweathermap/weather_update_coordinator.py @@ -83,9 +83,9 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): async def _get_owm_weather(self): """Poll weather data from OWM.""" - if ( - self._forecast_mode == FORECAST_MODE_ONECALL_HOURLY - or self._forecast_mode == FORECAST_MODE_ONECALL_DAILY + if self._forecast_mode in ( + FORECAST_MODE_ONECALL_HOURLY, + FORECAST_MODE_ONECALL_DAILY, ): weather = await self.hass.async_add_executor_job( self._owm_client.one_call, self._latitude, self._longitude diff --git a/homeassistant/components/ozw/__init__.py b/homeassistant/components/ozw/__init__.py index c3c23ea6741..238e7dcd8cd 100644 --- a/homeassistant/components/ozw/__init__.py +++ b/homeassistant/components/ozw/__init__.py @@ -370,7 +370,7 @@ async def async_handle_node_update(hass: HomeAssistant, node: OZWNode): return # update device in device registry with (updated) info for item in dev_registry.devices.values(): - if item.id != device.id and item.via_device_id != device.id: + if device.id not in (item.id, item.via_device_id): continue dev_name = create_device_name(node) dev_registry.async_update_device( diff --git a/homeassistant/components/plex/config_flow.py b/homeassistant/components/plex/config_flow.py index e18d72337ca..cffb484ac5a 100644 --- a/homeassistant/components/plex/config_flow.py +++ b/homeassistant/components/plex/config_flow.py @@ -422,6 +422,7 @@ class PlexAuthorizationCallbackView(HomeAssistantView): async def get(self, request): """Receive authorization confirmation.""" + # pylint: disable=no-self-use hass = request.app["hass"] await hass.config_entries.flow.async_configure( flow_id=request.query["flow_id"], user_input=None diff --git a/homeassistant/components/sensehat/sensor.py b/homeassistant/components/sensehat/sensor.py index 8dc74ae4e08..10f86609ae2 100644 --- a/homeassistant/components/sensehat/sensor.py +++ b/homeassistant/components/sensehat/sensor.py @@ -62,7 +62,11 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( def get_cpu_temp(): """Get CPU temperature.""" - t_cpu = Path("/sys/class/thermal/thermal_zone0/temp").read_text().strip() + t_cpu = ( + Path("/sys/class/thermal/thermal_zone0/temp") + .read_text(encoding="utf-8") + .strip() + ) return float(t_cpu) * 0.001 diff --git a/homeassistant/components/smarttub/controller.py b/homeassistant/components/smarttub/controller.py index 48b1d603c5c..adb7f3bf720 100644 --- a/homeassistant/components/smarttub/controller.py +++ b/homeassistant/components/smarttub/controller.py @@ -93,6 +93,7 @@ class SmartTubController: return data async def _get_spa_data(self, spa): + # pylint: disable=no-self-use full_status, reminders, errors = await asyncio.gather( spa.get_status_full(), spa.get_reminders(), diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index bf0a8c589ee..744850380bc 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -903,10 +903,7 @@ class SonosSpeaker: for speaker in (s for s in speakers if s.snapshot_group): assert speaker.snapshot_group is not None if speaker.snapshot_group[0] == speaker: - if ( - speaker.snapshot_group != speaker.sonos_group - and speaker.snapshot_group != [speaker] - ): + if speaker.snapshot_group not in (speaker.sonos_group, [speaker]): speaker.join(speaker.snapshot_group) groups.append(speaker.snapshot_group.copy()) diff --git a/homeassistant/components/spotify/media_player.py b/homeassistant/components/spotify/media_player.py index fedec630c35..780febf6791 100644 --- a/homeassistant/components/spotify/media_player.py +++ b/homeassistant/components/spotify/media_player.py @@ -207,6 +207,7 @@ def spotify_exception_handler(func): """ def wrapper(self, *args, **kwargs): + # pylint: disable=protected-access try: result = func(self, *args, **kwargs) self._attr_available = True diff --git a/homeassistant/components/stream/core.py b/homeassistant/components/stream/core.py index 998e27dcaec..b51c953e915 100644 --- a/homeassistant/components/stream/core.py +++ b/homeassistant/components/stream/core.py @@ -170,7 +170,6 @@ class Segment: # Preload hints help save round trips by informing the client about the next part. # The next part will usually be in this segment but will be first part of the next # segment if this segment is already complete. - # pylint: disable=undefined-loop-variable if self.complete: # Next part belongs to next segment sequence = self.sequence + 1 part_num = 0 diff --git a/homeassistant/components/tank_utility/sensor.py b/homeassistant/components/tank_utility/sensor.py index 93794ce0c50..2ffff492b92 100644 --- a/homeassistant/components/tank_utility/sensor.py +++ b/homeassistant/components/tank_utility/sensor.py @@ -111,11 +111,9 @@ class TankUtilitySensor(SensorEntity): try: data = tank_monitor.get_device_data(self._token, self.device) except requests.exceptions.HTTPError as http_error: - if ( - http_error.response.status_code - == requests.codes.unauthorized # pylint: disable=no-member - or http_error.response.status_code - == requests.codes.bad_request # pylint: disable=no-member + if http_error.response.status_code in ( + requests.codes.unauthorized, # pylint: disable=no-member + requests.codes.bad_request, # pylint: disable=no-member ): _LOGGER.info("Getting new token") self._token = auth.get_token(self._email, self._password, force=True) diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py index 31b9b14c9da..c4544a4b13f 100644 --- a/homeassistant/components/timer/__init__.py +++ b/homeassistant/components/timer/__init__.py @@ -271,7 +271,7 @@ class Timer(RestoreEntity): newduration = duration event = EVENT_TIMER_STARTED - if self._state == STATUS_ACTIVE or self._state == STATUS_PAUSED: + if self._state in (STATUS_ACTIVE, STATUS_PAUSED): event = EVENT_TIMER_RESTARTED self._state = STATUS_ACTIVE diff --git a/homeassistant/components/webhook/__init__.py b/homeassistant/components/webhook/__init__.py index 8331722c397..cc0d8db1407 100644 --- a/homeassistant/components/webhook/__init__.py +++ b/homeassistant/components/webhook/__init__.py @@ -131,6 +131,7 @@ class WebhookView(HomeAssistantView): async def _handle(self, request: Request, webhook_id): """Handle webhook call.""" + # pylint: disable=no-self-use _LOGGER.debug("Handling webhook %s payload for %s", request.method, webhook_id) hass = request.app["hass"] return await async_handle_webhook(hass, webhook_id, request) diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index c451645e013..7380f15b983 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -304,9 +304,7 @@ class LgWebOSMediaPlayerEntity(MediaPlayerEntity): """Flag media player features that are supported.""" supported = SUPPORT_WEBOSTV - if (self._client.sound_output == "external_arc") or ( - self._client.sound_output == "external_speaker" - ): + if self._client.sound_output in ("external_arc", "external_speaker"): supported = supported | SUPPORT_WEBOSTV_VOLUME elif self._client.sound_output != "lineout": supported = supported | SUPPORT_WEBOSTV_VOLUME | SUPPORT_VOLUME_SET diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index a80ff111f0d..d51eff7459e 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -42,6 +42,7 @@ class WebsocketAPIView(HomeAssistantView): async def get(self, request: web.Request) -> web.WebSocketResponse: """Handle an incoming websocket connection.""" + # pylint: disable=no-self-use return await WebSocketHandler(request.app["hass"], request).async_handle() diff --git a/homeassistant/components/whirlpool/config_flow.py b/homeassistant/components/whirlpool/config_flow.py index d5fdfd90568..c7ec37290cb 100644 --- a/homeassistant/components/whirlpool/config_flow.py +++ b/homeassistant/components/whirlpool/config_flow.py @@ -9,7 +9,7 @@ from whirlpool.auth import Auth from homeassistant import config_entries, core, exceptions from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from .const import DOMAIN # pylint: disable=unused-import +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/withings/common.py b/homeassistant/components/withings/common.py index b70b8b5ca1a..732197b7dcb 100644 --- a/homeassistant/components/withings/common.py +++ b/homeassistant/components/withings/common.py @@ -640,6 +640,7 @@ class DataManager: Withings' API occasionally and incorrectly throws errors. Retrying the call tends to work. """ + # pylint: disable=no-self-use exception = None for attempt in range(1, attempts + 1): _LOGGER.debug("Attempt %s of %s", attempt, attempts) diff --git a/homeassistant/components/zwave/climate.py b/homeassistant/components/zwave/climate.py index 75780eb314a..a09f839e6c4 100644 --- a/homeassistant/components/zwave/climate.py +++ b/homeassistant/components/zwave/climate.py @@ -268,7 +268,7 @@ class ZWaveClimateBase(ZWaveDeviceEntity, ClimateEntity): # Default operation mode for mode in DEFAULT_HVAC_MODES: - if mode in self._hvac_mapping.keys(): + if mode in self._hvac_mapping: self._default_hvac_mode = mode break @@ -291,14 +291,14 @@ class ZWaveClimateBase(ZWaveDeviceEntity, ClimateEntity): # The current mode is not a hvac mode if ( "heat" in current_mode.lower() - and HVAC_MODE_HEAT in self._hvac_mapping.keys() + and HVAC_MODE_HEAT in self._hvac_mapping ): # The current preset modes maps to HVAC_MODE_HEAT _LOGGER.debug("Mapped to HEAT") self._hvac_mode = HVAC_MODE_HEAT elif ( "cool" in current_mode.lower() - and HVAC_MODE_COOL in self._hvac_mapping.keys() + and HVAC_MODE_COOL in self._hvac_mapping ): # The current preset modes maps to HVAC_MODE_COOL _LOGGER.debug("Mapped to COOL") diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 549f1b1b950..da8794e8048 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -1274,6 +1274,7 @@ class DumpView(HomeAssistantView): async def get(self, request: web.Request, config_entry_id: str) -> web.Response: """Dump the state of Z-Wave.""" + # pylint: disable=no-self-use if not request["hass_user"].is_admin: raise Unauthorized() hass = request.app["hass"] diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 61fae8ac834..944c6979298 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -471,6 +471,7 @@ class ZWaveNodeStatusSensor(SensorEntity): async def async_poll_value(self, _: bool) -> None: """Poll a value.""" + # pylint: disable=no-self-use raise ValueError("There is no value to poll for this entity") @callback diff --git a/homeassistant/components/zwave_js/services.py b/homeassistant/components/zwave_js/services.py index 431f88a875d..9b165aada18 100644 --- a/homeassistant/components/zwave_js/services.py +++ b/homeassistant/components/zwave_js/services.py @@ -359,6 +359,7 @@ class ZWaveServices: async def async_set_config_parameter(self, service: ServiceCall) -> None: """Set a config value on a node.""" + # pylint: disable=no-self-use nodes = service.data[const.ATTR_NODES] property_or_property_name = service.data[const.ATTR_CONFIG_PARAMETER] property_key = service.data.get(const.ATTR_CONFIG_PARAMETER_BITMASK) @@ -386,6 +387,7 @@ class ZWaveServices: self, service: ServiceCall ) -> None: """Bulk set multiple partial config values on a node.""" + # pylint: disable=no-self-use nodes = service.data[const.ATTR_NODES] property_ = service.data[const.ATTR_CONFIG_PARAMETER] new_value = service.data[const.ATTR_CONFIG_VALUE] @@ -420,6 +422,7 @@ class ZWaveServices: async def async_set_value(self, service: ServiceCall) -> None: """Set a value on a node.""" + # pylint: disable=no-self-use nodes = service.data[const.ATTR_NODES] command_class = service.data[const.ATTR_COMMAND_CLASS] property_ = service.data[const.ATTR_PROPERTY] @@ -496,5 +499,6 @@ class ZWaveServices: async def async_ping(self, service: ServiceCall) -> None: """Ping node(s).""" + # pylint: disable=no-self-use nodes: set[ZwaveNode] = service.data[const.ATTR_NODES] await asyncio.gather(*(node.async_ping() for node in nodes)) diff --git a/homeassistant/core.py b/homeassistant/core.py index 1b1849ba548..922b6603f1b 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -488,6 +488,7 @@ class HomeAssistant: async def _await_and_log_pending(self, pending: Iterable[Awaitable[Any]]) -> None: """Await and log tasks that take a long time.""" + # pylint: disable=no-self-use wait_time = 0 while pending: _, pending = await asyncio.wait(pending, timeout=BLOCK_LOG_TIMEOUT) diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index 71281b57a30..a3a00d46df3 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -406,6 +406,7 @@ class OAuth2AuthorizeCallbackView(http.HomeAssistantView): async def get(self, request: web.Request) -> web.Response: """Receive authorization code.""" + # pylint: disable=no-self-use if "code" not in request.query or "state" not in request.query: return web.Response( text=f"Missing code or state parameter in {request.url}" diff --git a/pyproject.toml b/pyproject.toml index 8ca6a06868f..32c87227940 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ forced_separate = [ combine_as_imports = true [tool.pylint.MASTER] +py-version = "3.8" ignore = [ "tests", ] @@ -69,9 +70,11 @@ good-names = [ # inconsistent-return-statements - doesn't handle raise # too-many-ancestors - it's too strict. # wrong-import-order - isort guards this +# consider-using-f-string - str.format sometimes more readable # --- # Enable once current issues are fixed: # consider-using-namedtuple-or-dataclass (Pylint CodeStyle extension) +# consider-using-assignment-expr (Pylint CodeStyle extension) disable = [ "format", "abstract-class-little-used", @@ -94,7 +97,9 @@ disable = [ "too-many-boolean-expressions", "unused-argument", "wrong-import-order", + "consider-using-f-string", "consider-using-namedtuple-or-dataclass", + "consider-using-assignment-expr", ] enable = [ #"useless-suppression", # temporarily every now and then to clean them up @@ -120,9 +125,11 @@ overgeneral-exceptions = [ ] [tool.pylint.TYPING] -py-version = "3.8" runtime-typing = false +[tool.pylint.CODE_STYLE] +max-line-length-suggestions = 72 + [tool.pytest.ini_options] testpaths = [ "tests", diff --git a/requirements_test.txt b/requirements_test.txt index c0207dddc14..e23ebbc38d5 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -13,7 +13,7 @@ jsonpickle==1.4.1 mock-open==1.4.0 mypy==0.910 pre-commit==2.14.0 -pylint==2.10.2 +pylint==2.11.1 pipdeptree==1.0.0 pylint-strict-informational==0.1 pytest-aiohttp==0.3.0 From 0a5fdb2e6827c188be61debc99e1c67bf20ed7ba Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sat, 18 Sep 2021 15:42:36 +0200 Subject: [PATCH 461/843] Address late review of samsungtv (#56382) --- homeassistant/components/samsungtv/media_player.py | 2 +- homeassistant/components/samsungtv/strings.json | 3 +-- homeassistant/components/samsungtv/translations/en.json | 1 - 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py index 4e17c65b461..8644335959e 100644 --- a/homeassistant/components/samsungtv/media_player.py +++ b/homeassistant/components/samsungtv/media_player.py @@ -120,7 +120,7 @@ class SamsungTVDevice(MediaPlayerEntity): """Access denied callback.""" LOGGER.debug("Access denied in getting remote object") self._auth_failed = True - self.hass.async_create_task( + self.hass.create_task( self.hass.config_entries.flow.async_init( DOMAIN, context={ diff --git a/homeassistant/components/samsungtv/strings.json b/homeassistant/components/samsungtv/strings.json index 89ac85f85eb..f413a7f1219 100644 --- a/homeassistant/components/samsungtv/strings.json +++ b/homeassistant/components/samsungtv/strings.json @@ -27,8 +27,7 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "not_supported": "This Samsung device is currently not supported.", "unknown": "[%key:common::config_flow::error::unknown%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "missing_config_entry": "This Samsung device doesn't have a configuration entry." + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } } } \ No newline at end of file diff --git a/homeassistant/components/samsungtv/translations/en.json b/homeassistant/components/samsungtv/translations/en.json index fa5369012c0..8b48de950ee 100644 --- a/homeassistant/components/samsungtv/translations/en.json +++ b/homeassistant/components/samsungtv/translations/en.json @@ -6,7 +6,6 @@ "auth_missing": "Home Assistant is not authorized to connect to this Samsung TV. Check your TV's External Device Manager settings to authorize Home Assistant.", "cannot_connect": "Failed to connect", "id_missing": "This Samsung device doesn't have a SerialNumber.", - "missing_config_entry": "This Samsung device doesn't have a configuration entry.", "not_supported": "This Samsung device is currently not supported.", "reauth_successful": "Re-authentication was successful", "unknown": "Unexpected error" From 811feb69bafcdb466a6774da2264b8319cd35d25 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 18 Sep 2021 16:08:22 +0200 Subject: [PATCH 462/843] Fix dangerous brackets (#56384) --- homeassistant/components/asuswrt/__init__.py | 2 +- homeassistant/components/asuswrt/router.py | 2 +- homeassistant/components/radiotherm/climate.py | 2 +- homeassistant/components/screenlogic/sensor.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/asuswrt/__init__.py b/homeassistant/components/asuswrt/__init__.py index af7f3b05e33..c87ed85b759 100644 --- a/homeassistant/components/asuswrt/__init__.py +++ b/homeassistant/components/asuswrt/__init__.py @@ -81,7 +81,7 @@ async def async_setup(hass, config): options = {} mode = conf.get(CONF_MODE, MODE_ROUTER) for name, value in conf.items(): - if name in ([CONF_DNSMASQ, CONF_INTERFACE, CONF_REQUIRE_IP]): + if name in [CONF_DNSMASQ, CONF_INTERFACE, CONF_REQUIRE_IP]: if name == CONF_REQUIRE_IP and mode != MODE_AP: continue options[name] = value diff --git a/homeassistant/components/asuswrt/router.py b/homeassistant/components/asuswrt/router.py index 9acea7ba762..5cdcfb834b8 100644 --- a/homeassistant/components/asuswrt/router.py +++ b/homeassistant/components/asuswrt/router.py @@ -373,7 +373,7 @@ class AsusWrtRouter: """Update router options.""" req_reload = False for name, new_opt in new_options.items(): - if name in (CONF_REQ_RELOAD): + if name in CONF_REQ_RELOAD: old_opt = self._options.get(name) if not old_opt or old_opt != new_opt: req_reload = True diff --git a/homeassistant/components/radiotherm/climate.py b/homeassistant/components/radiotherm/climate.py index fc108af56a7..112e9f3a76d 100644 --- a/homeassistant/components/radiotherm/climate.py +++ b/homeassistant/components/radiotherm/climate.py @@ -371,7 +371,7 @@ class RadioThermostat(ClimateEntity): def set_preset_mode(self, preset_mode): """Set Preset mode (Home, Alternate, Away, Holiday).""" - if preset_mode in (PRESET_MODES): + if preset_mode in PRESET_MODES: self.device.program_mode = PRESET_MODE_TO_CODE[preset_mode] else: _LOGGER.error( diff --git a/homeassistant/components/screenlogic/sensor.py b/homeassistant/components/screenlogic/sensor.py index c8e4f84caf0..a35e4c8f7c1 100644 --- a/homeassistant/components/screenlogic/sensor.py +++ b/homeassistant/components/screenlogic/sensor.py @@ -85,7 +85,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): for chem_sensor_name in coordinator.data[SL_DATA.KEY_CHEMISTRY]: enabled = True if equipment_flags & EQUIPMENT.FLAG_CHLORINATOR: - if chem_sensor_name in ("salt_tds_ppm"): + if chem_sensor_name in ("salt_tds_ppm",): enabled = False if chem_sensor_name in SUPPORTED_CHEM_SENSORS: entities.append( From f97cce6f574b16e48e0777be49c0498e09c7d267 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Sat, 18 Sep 2021 17:48:58 +0200 Subject: [PATCH 463/843] Surepetcare, service to set pet location (#56198) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Surepetcare, add handle_set_pet_location Signed-off-by: Daniel Hjelseth Høyer * Surepetcare, add handle_set_pet_location Signed-off-by: Daniel Hjelseth Høyer * Surepetcare, add handle_set_pet_location Signed-off-by: Daniel Hjelseth Høyer --- .../components/surepetcare/__init__.py | 129 ++++++++++++------ homeassistant/components/surepetcare/const.py | 5 +- .../components/surepetcare/services.yaml | 20 +++ 3 files changed, 113 insertions(+), 41 deletions(-) diff --git a/homeassistant/components/surepetcare/__init__.py b/homeassistant/components/surepetcare/__init__.py index 59b91dabb2a..1efcaa6b5c1 100644 --- a/homeassistant/components/surepetcare/__init__.py +++ b/homeassistant/components/surepetcare/__init__.py @@ -5,7 +5,7 @@ from datetime import timedelta import logging from surepy import Surepy, SurepyEntity -from surepy.enums import LockState +from surepy.enums import EntityType, Location, LockState from surepy.exceptions import SurePetcareAuthenticationError, SurePetcareError import voluptuous as vol @@ -20,17 +20,21 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service import ServiceCall from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( ATTR_FLAP_ID, + ATTR_LOCATION, ATTR_LOCK_STATE, + ATTR_PET_NAME, CONF_FEEDERS, CONF_FLAPS, CONF_PETS, DOMAIN, SERVICE_SET_LOCK_STATE, + SERVICE_SET_PET_LOCATION, SURE_API_TIMEOUT, ) @@ -91,12 +95,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {}) try: - surepy = Surepy( - entry.data[CONF_USERNAME], - entry.data[CONF_PASSWORD], - auth_token=entry.data[CONF_TOKEN], - api_timeout=SURE_API_TIMEOUT, - session=async_get_clientsession(hass), + hass.data[DOMAIN][entry.entry_id] = coordinator = SurePetcareDataCoordinator( + entry, + hass, ) except SurePetcareAuthenticationError: _LOGGER.error("Unable to connect to surepetcare.io: Wrong credentials!") @@ -105,40 +106,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.error("Unable to connect to surepetcare.io: Wrong %s!", error) return False - async def _update_method() -> dict[int, SurepyEntity]: - """Get the latest data from Sure Petcare.""" - try: - return await surepy.get_entities(refresh=True) - except SurePetcareError as err: - raise UpdateFailed(f"Unable to fetch data: {err}") from err - - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - name=DOMAIN, - update_method=_update_method, - update_interval=SCAN_INTERVAL, - ) - - hass.data[DOMAIN][entry.entry_id] = coordinator await coordinator.async_config_entry_first_refresh() hass.config_entries.async_setup_platforms(entry, PLATFORMS) - lock_states = { - LockState.UNLOCKED.name.lower(): surepy.sac.unlock, - LockState.LOCKED_IN.name.lower(): surepy.sac.lock_in, - LockState.LOCKED_OUT.name.lower(): surepy.sac.lock_out, - LockState.LOCKED_ALL.name.lower(): surepy.sac.lock, - } - - async def handle_set_lock_state(call): - """Call when setting the lock state.""" - flap_id = call.data[ATTR_FLAP_ID] - state = call.data[ATTR_LOCK_STATE] - await lock_states[state](flap_id) - await coordinator.async_request_refresh() - lock_state_service_schema = vol.Schema( { vol.Required(ATTR_FLAP_ID): vol.All( @@ -147,18 +118,35 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: vol.Required(ATTR_LOCK_STATE): vol.All( cv.string, vol.Lower, - vol.In(lock_states.keys()), + vol.In(coordinator.lock_states.keys()), ), } ) - hass.services.async_register( DOMAIN, SERVICE_SET_LOCK_STATE, - handle_set_lock_state, + coordinator.handle_set_lock_state, schema=lock_state_service_schema, ) + set_pet_location_schema = vol.Schema( + { + vol.Optional(ATTR_PET_NAME): vol.In(coordinator.get_pets().values()), + vol.Required(ATTR_LOCATION): vol.In( + [ + Location.INSIDE.name.title(), + Location.OUTSIDE.name.title(), + ] + ), + } + ) + hass.services.async_register( + DOMAIN, + SERVICE_SET_PET_LOCATION, + coordinator.handle_set_pet_location, + schema=set_pet_location_schema, + ) + return True @@ -169,3 +157,64 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok + + +class SurePetcareDataCoordinator(DataUpdateCoordinator): + """Handle Surepetcare data.""" + + def __init__(self, entry: ConfigEntry, hass: HomeAssistant) -> None: + """Initialize the data handler.""" + self.surepy = Surepy( + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + auth_token=entry.data[CONF_TOKEN], + api_timeout=SURE_API_TIMEOUT, + session=async_get_clientsession(hass), + ) + self.lock_states = { + LockState.UNLOCKED.name.lower(): self.surepy.sac.unlock, + LockState.LOCKED_IN.name.lower(): self.surepy.sac.lock_in, + LockState.LOCKED_OUT.name.lower(): self.surepy.sac.lock_out, + LockState.LOCKED_ALL.name.lower(): self.surepy.sac.lock, + } + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + + async def _async_update_data(self) -> dict[int, SurepyEntity]: + """Get the latest data from Sure Petcare.""" + try: + return await self.surepy.get_entities(refresh=True) + except SurePetcareError as err: + raise UpdateFailed(f"Unable to fetch data: {err}") from err + + async def handle_set_lock_state(self, call: ServiceCall) -> None: + """Call when setting the lock state.""" + flap_id = call.data[ATTR_FLAP_ID] + state = call.data[ATTR_LOCK_STATE] + await self.lock_states[state](flap_id) + await self.async_request_refresh() + + def get_pets(self) -> dict[int, str]: + """Get pets.""" + names = {} + for surepy_entity in self.data.values(): + if surepy_entity.type == EntityType.PET and surepy_entity.name: + names[surepy_entity.id] = surepy_entity.name + return names + + async def handle_set_pet_location(self, call: ServiceCall) -> None: + """Call when setting the pet location.""" + pet_name = call.data[ATTR_PET_NAME] + location = call.data[ATTR_LOCATION] + for device_id, device_name in self.get_pets().items(): + if pet_name == device_name: + await self.surepy.sac.set_pet_location( + device_id, Location[location.upper()] + ) + await self.async_request_refresh() + return + _LOGGER.error("Unknown pet %s", pet_name) diff --git a/homeassistant/components/surepetcare/const.py b/homeassistant/components/surepetcare/const.py index 6349ebe14a8..6617137b026 100644 --- a/homeassistant/components/surepetcare/const.py +++ b/homeassistant/components/surepetcare/const.py @@ -13,7 +13,10 @@ SURE_BATT_VOLTAGE_FULL = 1.6 # voltage SURE_BATT_VOLTAGE_LOW = 1.25 # voltage SURE_BATT_VOLTAGE_DIFF = SURE_BATT_VOLTAGE_FULL - SURE_BATT_VOLTAGE_LOW -# lock state service +# state service SERVICE_SET_LOCK_STATE = "set_lock_state" +SERVICE_SET_PET_LOCATION = "set_pet_location" ATTR_FLAP_ID = "flap_id" +ATTR_LOCATION = "location" ATTR_LOCK_STATE = "lock_state" +ATTR_PET_NAME = "pet_name" diff --git a/homeassistant/components/surepetcare/services.yaml b/homeassistant/components/surepetcare/services.yaml index 77887a18b87..fc352aeb6ab 100644 --- a/homeassistant/components/surepetcare/services.yaml +++ b/homeassistant/components/surepetcare/services.yaml @@ -20,3 +20,23 @@ set_lock_state: - 'locked_in' - 'locked_out' - 'unlocked' + +set_pet_location: + name: Set pet location + description: Set pet location + fields: + pet_name: + description: Name of pet + example: My_cat + required: true + selector: + text: + location: + description: Pet location (Inside or Outside) + example: inside + required: true + selector: + select: + options: + - 'Inside' + - 'Outside' From 312a9e5df227adff8c1d9584ab6c9fbccded2728 Mon Sep 17 00:00:00 2001 From: Ashley 'DrToxic' Devine <41945324+DrToxic@users.noreply.github.com> Date: Sat, 18 Sep 2021 17:49:29 +0100 Subject: [PATCH 464/843] Add Shiba Inu coin to coinbase (#56304) Added SHIB coin --- homeassistant/components/coinbase/const.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/coinbase/const.py b/homeassistant/components/coinbase/const.py index dc2922d1531..7fba24a4813 100644 --- a/homeassistant/components/coinbase/const.py +++ b/homeassistant/components/coinbase/const.py @@ -206,6 +206,7 @@ WALLETS = { "SCR": "SCR", "SEK": "SEK", "SGD": "SGD", + "SHIB": "SHIB", "SHP": "SHP", "SKL": "SKL", "SLL": "SLL", @@ -435,6 +436,7 @@ RATES = { "SCR": "SCR", "SEK": "SEK", "SGD": "SGD", + "SHIB": "SHIB", "SHP": "SHP", "SKL": "SKL", "SLL": "SLL", From f31b9eae614873855ba7caa6922201a4b5b07aa1 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Sat, 18 Sep 2021 12:54:54 -0500 Subject: [PATCH 465/843] Fix creating `cert_expiry` configs during runtime (#56298) * Fix creating cert_expiry configs during runtime * Address review feedback on tests * Improve delayed startup test --- .../components/cert_expiry/__init__.py | 13 +++++-- tests/components/cert_expiry/test_init.py | 39 +++++++++++++++++++ tests/components/cert_expiry/test_sensors.py | 21 +++++----- 3 files changed, 58 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/cert_expiry/__init__.py b/homeassistant/components/cert_expiry/__init__.py index 22339b9f4c4..61c7a0758c7 100644 --- a/homeassistant/components/cert_expiry/__init__.py +++ b/homeassistant/components/cert_expiry/__init__.py @@ -7,7 +7,7 @@ from typing import Optional from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STARTED -from homeassistant.core import HomeAssistant +from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DEFAULT_PORT, DOMAIN @@ -38,9 +38,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_refresh() hass.config_entries.async_setup_platforms(entry, PLATFORMS) - entry.async_on_unload( - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, async_finish_startup) - ) + if hass.state == CoreState.running: + await async_finish_startup(None) + else: + entry.async_on_unload( + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STARTED, async_finish_startup + ) + ) return True diff --git a/tests/components/cert_expiry/test_init.py b/tests/components/cert_expiry/test_init.py index 25771c39250..9bf7512d238 100644 --- a/tests/components/cert_expiry/test_init.py +++ b/tests/components/cert_expiry/test_init.py @@ -12,6 +12,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STARTED, STATE_UNAVAILABLE, ) +from homeassistant.core import CoreState from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -23,6 +24,8 @@ from tests.common import MockConfigEntry, async_fire_time_changed async def test_setup_with_config(hass): """Test setup component with config.""" + assert hass.state is CoreState.running + config = { SENSOR_DOMAIN: [ {"platform": DOMAIN, CONF_HOST: HOST, CONF_PORT: PORT}, @@ -49,6 +52,8 @@ async def test_setup_with_config(hass): async def test_update_unique_id(hass): """Test updating a config entry without a unique_id.""" + assert hass.state is CoreState.running + entry = MockConfigEntry(domain=DOMAIN, data={CONF_HOST: HOST, CONF_PORT: PORT}) entry.add_to_hass(hass) @@ -71,6 +76,8 @@ async def test_update_unique_id(hass): @patch("homeassistant.util.dt.utcnow", return_value=static_datetime()) async def test_unload_config_entry(mock_now, hass): """Test unloading a config entry.""" + assert hass.state is CoreState.running + entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: HOST, CONF_PORT: PORT}, @@ -107,3 +114,35 @@ async def test_unload_config_entry(mock_now, hass): await hass.async_block_till_done() state = hass.states.get("sensor.cert_expiry_timestamp_example_com") assert state is None + + +async def test_delay_load_during_startup(hass): + """Test delayed loading of a config entry during startup.""" + hass.state = CoreState.not_running + + entry = MockConfigEntry(domain=DOMAIN, data={CONF_HOST: HOST, CONF_PORT: PORT}) + entry.add_to_hass(hass) + + assert await async_setup_component(hass, DOMAIN, {}) is True + await hass.async_block_till_done() + + assert hass.state is CoreState.not_running + assert entry.state is ConfigEntryState.LOADED + + state = hass.states.get("sensor.cert_expiry_timestamp_example_com") + assert state is None + + timestamp = future_timestamp(100) + with patch( + "homeassistant.components.cert_expiry.get_cert_expiry_timestamp", + return_value=timestamp, + ): + await hass.async_start() + await hass.async_block_till_done() + + assert hass.state is CoreState.running + + state = hass.states.get("sensor.cert_expiry_timestamp_example_com") + assert state.state == timestamp.isoformat() + assert state.attributes.get("error") == "None" + assert state.attributes.get("is_valid") diff --git a/tests/components/cert_expiry/test_sensors.py b/tests/components/cert_expiry/test_sensors.py index 0ca228dc344..a4456724270 100644 --- a/tests/components/cert_expiry/test_sensors.py +++ b/tests/components/cert_expiry/test_sensors.py @@ -5,13 +5,8 @@ import ssl from unittest.mock import patch from homeassistant.components.cert_expiry.const import DOMAIN -from homeassistant.const import ( - CONF_HOST, - CONF_PORT, - EVENT_HOMEASSISTANT_STARTED, - STATE_UNAVAILABLE, - STATE_UNKNOWN, -) +from homeassistant.const import CONF_HOST, CONF_PORT, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import CoreState from homeassistant.util.dt import utcnow from .const import HOST, PORT @@ -23,6 +18,8 @@ from tests.common import MockConfigEntry, async_fire_time_changed @patch("homeassistant.util.dt.utcnow", return_value=static_datetime()) async def test_async_setup_entry(mock_now, hass): """Test async_setup_entry.""" + assert hass.state is CoreState.running + entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: HOST, CONF_PORT: PORT}, @@ -37,7 +34,6 @@ async def test_async_setup_entry(mock_now, hass): ): entry.add_to_hass(hass) assert await hass.config_entries.async_setup(entry.entry_id) - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() state = hass.states.get("sensor.cert_expiry_timestamp_example_com") @@ -50,6 +46,8 @@ async def test_async_setup_entry(mock_now, hass): async def test_async_setup_entry_bad_cert(hass): """Test async_setup_entry with a bad/expired cert.""" + assert hass.state is CoreState.running + entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: HOST, CONF_PORT: PORT}, @@ -62,7 +60,6 @@ async def test_async_setup_entry_bad_cert(hass): ): entry.add_to_hass(hass) assert await hass.config_entries.async_setup(entry.entry_id) - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() state = hass.states.get("sensor.cert_expiry_timestamp_example_com") @@ -74,6 +71,8 @@ async def test_async_setup_entry_bad_cert(hass): async def test_update_sensor(hass): """Test async_update for sensor.""" + assert hass.state is CoreState.running + entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: HOST, CONF_PORT: PORT}, @@ -89,7 +88,6 @@ async def test_update_sensor(hass): ): entry.add_to_hass(hass) assert await hass.config_entries.async_setup(entry.entry_id) - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() state = hass.states.get("sensor.cert_expiry_timestamp_example_com") @@ -117,6 +115,8 @@ async def test_update_sensor(hass): async def test_update_sensor_network_errors(hass): """Test async_update for sensor.""" + assert hass.state is CoreState.running + entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: HOST, CONF_PORT: PORT}, @@ -132,7 +132,6 @@ async def test_update_sensor_network_errors(hass): ): entry.add_to_hass(hass) assert await hass.config_entries.async_setup(entry.entry_id) - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() state = hass.states.get("sensor.cert_expiry_timestamp_example_com") From 3ce8109e5eb802b50368afc28aa2905949efc77c Mon Sep 17 00:00:00 2001 From: RenierM26 <66512715+RenierM26@users.noreply.github.com> Date: Sat, 18 Sep 2021 21:25:05 +0200 Subject: [PATCH 466/843] Add config flow to Switchbot (#50653) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Daniel Hjelseth Høyer Co-authored-by: J. Nick Koston --- .coveragerc | 3 + CODEOWNERS | 2 +- .../components/switchbot/__init__.py | 112 ++++++++- .../components/switchbot/config_flow.py | 190 +++++++++++++++ homeassistant/components/switchbot/const.py | 24 ++ .../components/switchbot/coordinator.py | 59 +++++ .../components/switchbot/manifest.json | 5 +- .../components/switchbot/strings.json | 35 +++ homeassistant/components/switchbot/switch.py | 186 ++++++++++---- .../components/switchbot/translations/en.json | 36 +++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 2 +- requirements_test_all.txt | 3 + tests/components/switchbot/__init__.py | 64 +++++ tests/components/switchbot/conftest.py | 78 ++++++ .../components/switchbot/test_config_flow.py | 226 ++++++++++++++++++ 16 files changed, 978 insertions(+), 48 deletions(-) create mode 100644 homeassistant/components/switchbot/config_flow.py create mode 100644 homeassistant/components/switchbot/const.py create mode 100644 homeassistant/components/switchbot/coordinator.py create mode 100644 homeassistant/components/switchbot/strings.json create mode 100644 homeassistant/components/switchbot/translations/en.json create mode 100644 tests/components/switchbot/__init__.py create mode 100644 tests/components/switchbot/conftest.py create mode 100644 tests/components/switchbot/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index e5573b40cd6..d3465668bfb 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1015,6 +1015,9 @@ omit = homeassistant/components/swiss_public_transport/sensor.py homeassistant/components/swisscom/device_tracker.py homeassistant/components/switchbot/switch.py + homeassistant/components/switchbot/__init__.py + homeassistant/components/switchbot/const.py + homeassistant/components/switchbot/coordinator.py homeassistant/components/switchmate/switch.py homeassistant/components/syncthing/__init__.py homeassistant/components/syncthing/sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 9982fc8de7b..bc3f6f6f838 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -503,7 +503,7 @@ homeassistant/components/supla/* @mwegrzynek homeassistant/components/surepetcare/* @benleb @danielhiversen homeassistant/components/swiss_hydrological_data/* @fabaff homeassistant/components/swiss_public_transport/* @fabaff -homeassistant/components/switchbot/* @danielhiversen +homeassistant/components/switchbot/* @danielhiversen @RenierM26 homeassistant/components/switcher_kis/* @tomerfi @thecode homeassistant/components/switchmate/* @danielhiversen homeassistant/components/syncthing/* @zhulik diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py index a8768a9cd44..123aefb512f 100644 --- a/homeassistant/components/switchbot/__init__.py +++ b/homeassistant/components/switchbot/__init__.py @@ -1 +1,111 @@ -"""The switchbot component.""" +"""Support for Switchbot devices.""" +from asyncio import Lock + +import switchbot # pylint: disable=import-error + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import ( + BTLE_LOCK, + COMMON_OPTIONS, + CONF_RETRY_COUNT, + CONF_RETRY_TIMEOUT, + CONF_SCAN_TIMEOUT, + CONF_TIME_BETWEEN_UPDATE_COMMAND, + DATA_COORDINATOR, + DEFAULT_RETRY_COUNT, + DEFAULT_RETRY_TIMEOUT, + DEFAULT_SCAN_TIMEOUT, + DEFAULT_TIME_BETWEEN_UPDATE_COMMAND, + DOMAIN, +) +from .coordinator import SwitchbotDataUpdateCoordinator + +PLATFORMS = ["switch"] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Switchbot from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + + if not entry.options: + options = { + CONF_TIME_BETWEEN_UPDATE_COMMAND: DEFAULT_TIME_BETWEEN_UPDATE_COMMAND, + CONF_RETRY_COUNT: DEFAULT_RETRY_COUNT, + CONF_RETRY_TIMEOUT: DEFAULT_RETRY_TIMEOUT, + CONF_SCAN_TIMEOUT: DEFAULT_SCAN_TIMEOUT, + } + + hass.config_entries.async_update_entry(entry, options=options) + + # Use same coordinator instance for all entities. + # Uses BTLE advertisement data, all Switchbot devices in range is stored here. + if DATA_COORDINATOR not in hass.data[DOMAIN]: + + # Check if asyncio.lock is stored in hass data. + # BTLE has issues with multiple connections, + # so we use a lock to ensure that only one API request is reaching it at a time: + if BTLE_LOCK not in hass.data[DOMAIN]: + hass.data[DOMAIN][BTLE_LOCK] = Lock() + + if COMMON_OPTIONS not in hass.data[DOMAIN]: + hass.data[DOMAIN][COMMON_OPTIONS] = {**entry.options} + + switchbot.DEFAULT_RETRY_TIMEOUT = hass.data[DOMAIN][COMMON_OPTIONS][ + CONF_RETRY_TIMEOUT + ] + + # Store api in coordinator. + coordinator = SwitchbotDataUpdateCoordinator( + hass, + update_interval=hass.data[DOMAIN][COMMON_OPTIONS][ + CONF_TIME_BETWEEN_UPDATE_COMMAND + ], + api=switchbot, + retry_count=hass.data[DOMAIN][COMMON_OPTIONS][CONF_RETRY_COUNT], + scan_timeout=hass.data[DOMAIN][COMMON_OPTIONS][CONF_SCAN_TIMEOUT], + api_lock=hass.data[DOMAIN][BTLE_LOCK], + ) + + hass.data[DOMAIN][DATA_COORDINATOR] = coordinator + + else: + coordinator = hass.data[DOMAIN][DATA_COORDINATOR] + + await coordinator.async_config_entry_first_refresh() + + if not coordinator.last_update_success: + raise ConfigEntryNotReady + + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + + hass.data[DOMAIN][entry.entry_id] = {DATA_COORDINATOR: coordinator} + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + if len(hass.config_entries.async_entries(DOMAIN)) == 0: + hass.data.pop(DOMAIN) + + return unload_ok + + +async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + # Update entity options stored in hass. + if {**entry.options} != hass.data[DOMAIN][COMMON_OPTIONS]: + hass.data[DOMAIN][COMMON_OPTIONS] = {**entry.options} + hass.data[DOMAIN].pop(DATA_COORDINATOR) + + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/switchbot/config_flow.py b/homeassistant/components/switchbot/config_flow.py new file mode 100644 index 00000000000..fcb9cdc3b8c --- /dev/null +++ b/homeassistant/components/switchbot/config_flow.py @@ -0,0 +1,190 @@ +"""Config flow for Switchbot.""" +from __future__ import annotations + +from asyncio import Lock +import logging +from typing import Any + +from switchbot import GetSwitchbotDevices # pylint: disable=import-error +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow +from homeassistant.const import CONF_MAC, CONF_NAME, CONF_PASSWORD, CONF_SENSOR_TYPE +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult + +from .const import ( + ATTR_BOT, + BTLE_LOCK, + CONF_RETRY_COUNT, + CONF_RETRY_TIMEOUT, + CONF_SCAN_TIMEOUT, + CONF_TIME_BETWEEN_UPDATE_COMMAND, + DEFAULT_RETRY_COUNT, + DEFAULT_RETRY_TIMEOUT, + DEFAULT_SCAN_TIMEOUT, + DEFAULT_TIME_BETWEEN_UPDATE_COMMAND, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + + +def _btle_connect(mac: str) -> dict: + """Scan for BTLE advertisement data.""" + # Try to find switchbot mac in nearby devices, + # by scanning for btle devices. + + switchbots = GetSwitchbotDevices() + switchbots.discover() + switchbot_device = switchbots.get_device_data(mac=mac) + + if not switchbot_device: + raise NotConnectedError("Failed to discover switchbot") + + return switchbot_device + + +class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Switchbot.""" + + VERSION = 1 + + async def _validate_mac(self, data: dict) -> FlowResult: + """Try to connect to Switchbot device and create entry if successful.""" + await self.async_set_unique_id(data[CONF_MAC].replace(":", "")) + self._abort_if_unique_id_configured() + + # asyncio.lock prevents btle adapter exceptions if there are multiple calls to this method. + # store asyncio.lock in hass data if not present. + if DOMAIN not in self.hass.data: + self.hass.data.setdefault(DOMAIN, {}) + if BTLE_LOCK not in self.hass.data[DOMAIN]: + self.hass.data[DOMAIN][BTLE_LOCK] = Lock() + + connect_lock = self.hass.data[DOMAIN][BTLE_LOCK] + + # Validate bluetooth device mac. + async with connect_lock: + _btle_adv_data = await self.hass.async_add_executor_job( + _btle_connect, data[CONF_MAC] + ) + + if _btle_adv_data["modelName"] == "WoHand": + data[CONF_SENSOR_TYPE] = ATTR_BOT + return self.async_create_entry(title=data[CONF_NAME], data=data) + + return self.async_abort(reason="switchbot_unsupported_type") + + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> SwitchbotOptionsFlowHandler: + """Get the options flow for this handler.""" + return SwitchbotOptionsFlowHandler(config_entry) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by the user.""" + + errors = {} + + if user_input is not None: + user_input[CONF_MAC] = user_input[CONF_MAC].replace("-", ":").lower() + + # abort if already configured. + for item in self._async_current_entries(): + if item.data.get(CONF_MAC) == user_input[CONF_MAC]: + return self.async_abort(reason="already_configured_device") + + try: + return await self._validate_mac(user_input) + + except NotConnectedError: + errors["base"] = "cannot_connect" + + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") + + data_schema = vol.Schema( + { + vol.Required(CONF_NAME): str, + vol.Optional(CONF_PASSWORD): str, + vol.Required(CONF_MAC): str, + } + ) + + return self.async_show_form( + step_id="user", data_schema=data_schema, errors=errors + ) + + async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: + """Handle config import from yaml.""" + _LOGGER.debug("import config: %s", import_config) + + import_config[CONF_MAC] = import_config[CONF_MAC].replace("-", ":").lower() + + await self.async_set_unique_id(import_config[CONF_MAC].replace(":", "")) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=import_config[CONF_NAME], data=import_config + ) + + +class SwitchbotOptionsFlowHandler(OptionsFlow): + """Handle Switchbot options.""" + + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage Switchbot options.""" + if user_input is not None: + # Update common entity options for all other entities. + for entry in self.hass.config_entries.async_entries(DOMAIN): + if entry.unique_id != self.config_entry.unique_id: + self.hass.config_entries.async_update_entry( + entry, options=user_input + ) + return self.async_create_entry(title="", data=user_input) + + options = { + vol.Optional( + CONF_TIME_BETWEEN_UPDATE_COMMAND, + default=self.config_entry.options.get( + CONF_TIME_BETWEEN_UPDATE_COMMAND, + DEFAULT_TIME_BETWEEN_UPDATE_COMMAND, + ), + ): int, + vol.Optional( + CONF_RETRY_COUNT, + default=self.config_entry.options.get( + CONF_RETRY_COUNT, DEFAULT_RETRY_COUNT + ), + ): int, + vol.Optional( + CONF_RETRY_TIMEOUT, + default=self.config_entry.options.get( + CONF_RETRY_TIMEOUT, DEFAULT_RETRY_TIMEOUT + ), + ): int, + vol.Optional( + CONF_SCAN_TIMEOUT, + default=self.config_entry.options.get( + CONF_SCAN_TIMEOUT, DEFAULT_SCAN_TIMEOUT + ), + ): int, + } + + return self.async_show_form(step_id="init", data_schema=vol.Schema(options)) + + +class NotConnectedError(Exception): + """Exception for unable to find device.""" diff --git a/homeassistant/components/switchbot/const.py b/homeassistant/components/switchbot/const.py new file mode 100644 index 00000000000..c94dae3dddd --- /dev/null +++ b/homeassistant/components/switchbot/const.py @@ -0,0 +1,24 @@ +"""Constants for the switchbot integration.""" +DOMAIN = "switchbot" +MANUFACTURER = "switchbot" + +# Config Attributes +ATTR_BOT = "bot" +DEFAULT_NAME = "Switchbot" + +# Config Defaults +DEFAULT_RETRY_COUNT = 3 +DEFAULT_RETRY_TIMEOUT = 5 +DEFAULT_TIME_BETWEEN_UPDATE_COMMAND = 60 +DEFAULT_SCAN_TIMEOUT = 5 + +# Config Options +CONF_TIME_BETWEEN_UPDATE_COMMAND = "update_time" +CONF_RETRY_COUNT = "retry_count" +CONF_RETRY_TIMEOUT = "retry_timeout" +CONF_SCAN_TIMEOUT = "scan_timeout" + +# Data +DATA_COORDINATOR = "coordinator" +BTLE_LOCK = "btle_lock" +COMMON_OPTIONS = "common_options" diff --git a/homeassistant/components/switchbot/coordinator.py b/homeassistant/components/switchbot/coordinator.py new file mode 100644 index 00000000000..4976af18809 --- /dev/null +++ b/homeassistant/components/switchbot/coordinator.py @@ -0,0 +1,59 @@ +"""Provides the switchbot DataUpdateCoordinator.""" +from __future__ import annotations + +from asyncio import Lock +from datetime import timedelta +import logging + +import switchbot # pylint: disable=import-error + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class SwitchbotDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching switchbot data.""" + + def __init__( + self, + hass: HomeAssistant, + *, + update_interval: int, + api: switchbot, + retry_count: int, + scan_timeout: int, + api_lock: Lock, + ) -> None: + """Initialize global switchbot data updater.""" + self.switchbot_api = api + self.retry_count = retry_count + self.scan_timeout = scan_timeout + self.update_interval = timedelta(seconds=update_interval) + + super().__init__( + hass, _LOGGER, name=DOMAIN, update_interval=self.update_interval + ) + + self.api_lock = api_lock + + def _update_data(self) -> dict | None: + """Fetch device states from switchbot api.""" + + return self.switchbot_api.GetSwitchbotDevices().discover( + retry=self.retry_count, scan_timeout=self.scan_timeout + ) + + async def _async_update_data(self) -> dict | None: + """Fetch data from switchbot.""" + + async with self.api_lock: + switchbot_data = await self.hass.async_add_executor_job(self._update_data) + + if not switchbot_data: + raise UpdateFailed("Unable to fetch switchbot services data") + + return switchbot_data diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index 365f4ce475c..38743981ed5 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -2,7 +2,8 @@ "domain": "switchbot", "name": "SwitchBot", "documentation": "https://www.home-assistant.io/integrations/switchbot", - "requirements": ["PySwitchbot==0.8.0"], - "codeowners": ["@danielhiversen"], + "requirements": ["PySwitchbot==0.11.0"], + "config_flow": true, + "codeowners": ["@danielhiversen", "@RenierM26"], "iot_class": "local_polling" } diff --git a/homeassistant/components/switchbot/strings.json b/homeassistant/components/switchbot/strings.json new file mode 100644 index 00000000000..970dc9f47ce --- /dev/null +++ b/homeassistant/components/switchbot/strings.json @@ -0,0 +1,35 @@ +{ + "config": { + "flow_title": "{name}", + "step": { + "user": { + "title": "Setup Switchbot device", + "data": { + "name": "[%key:common::config_flow::data::name%]", + "password": "[%key:common::config_flow::data::password%]", + "mac": "Device MAC address" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "abort": { + "already_configured_device": "[%key:common::config_flow::abort::already_configured_device%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "switchbot_unsupported_type": "Unsupported Switchbot Type." + } + }, + "options": { + "step": { + "init": { + "data": { + "update_time": "Time between updates (seconds)", + "retry_count": "Retry count", + "retry_timeout": "Timeout between retries", + "scan_timeout": "How long to scan for advertisement data" + } + } + } + } +} diff --git a/homeassistant/components/switchbot/switch.py b/homeassistant/components/switchbot/switch.py index 3fcf789da93..ea2f3c0dfff 100644 --- a/homeassistant/components/switchbot/switch.py +++ b/homeassistant/components/switchbot/switch.py @@ -1,18 +1,48 @@ -"""Support for Switchbot.""" +"""Support for Switchbot bot.""" from __future__ import annotations +import logging from typing import Any -# pylint: disable=import-error -import switchbot import voluptuous as vol -from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchEntity -from homeassistant.const import CONF_MAC, CONF_NAME, CONF_PASSWORD -import homeassistant.helpers.config_validation as cv +from homeassistant.components.switch import ( + DEVICE_CLASS_SWITCH, + PLATFORM_SCHEMA, + SwitchEntity, +) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import ( + CONF_MAC, + CONF_NAME, + CONF_PASSWORD, + CONF_SENSOR_TYPE, + STATE_ON, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_platform, +) +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.update_coordinator import CoordinatorEntity -DEFAULT_NAME = "Switchbot" +from .const import ( + ATTR_BOT, + CONF_RETRY_COUNT, + DATA_COORDINATOR, + DEFAULT_NAME, + DOMAIN, + MANUFACTURER, +) +from .coordinator import SwitchbotDataUpdateCoordinator + +# Initialize the logger +_LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 1 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -23,46 +53,120 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform(hass, config, add_entities, discovery_info=None): - """Perform the setup for Switchbot devices.""" - name = config.get(CONF_NAME) - mac_addr = config[CONF_MAC] - password = config.get(CONF_PASSWORD) - add_entities([SwitchBot(mac_addr, name, password)]) +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: entity_platform.AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Import yaml config and initiates config flow for Switchbot devices.""" + + # Check if entry config exists and skips import if it does. + if hass.config_entries.async_entries(DOMAIN): + return + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_NAME: config[CONF_NAME], + CONF_PASSWORD: config.get(CONF_PASSWORD, None), + CONF_MAC: config[CONF_MAC].replace("-", ":").lower(), + CONF_SENSOR_TYPE: ATTR_BOT, + }, + ) + ) -class SwitchBot(SwitchEntity, RestoreEntity): +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: entity_platform.AddEntitiesCallback, +) -> None: + """Set up Switchbot based on a config entry.""" + coordinator: SwitchbotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ + DATA_COORDINATOR + ] + + if entry.data[CONF_SENSOR_TYPE] != ATTR_BOT: + return + + async_add_entities( + [ + SwitchBot( + coordinator, + entry.unique_id, + entry.data[CONF_MAC], + entry.data[CONF_NAME], + entry.data.get(CONF_PASSWORD, None), + entry.options[CONF_RETRY_COUNT], + ) + ] + ) + + +class SwitchBot(CoordinatorEntity, SwitchEntity, RestoreEntity): """Representation of a Switchbot.""" - def __init__(self, mac, name, password) -> None: + coordinator: SwitchbotDataUpdateCoordinator + _attr_device_class = DEVICE_CLASS_SWITCH + + def __init__( + self, + coordinator: SwitchbotDataUpdateCoordinator, + idx: str | None, + mac: str, + name: str, + password: str, + retry_count: int, + ) -> None: """Initialize the Switchbot.""" - - self._state: bool | None = None + super().__init__(coordinator) + self._idx = idx self._last_run_success: bool | None = None - self._name = name self._mac = mac - self._device = switchbot.Switchbot(mac=mac, password=password) + self._device = self.coordinator.switchbot_api.Switchbot( + mac=mac, password=password, retry_count=retry_count + ) + self._attr_unique_id = self._mac.replace(":", "") + self._attr_name = name + self._attr_device_info: DeviceInfo = { + "connections": {(dr.CONNECTION_NETWORK_MAC, self._mac)}, + "name": name, + "model": self.coordinator.data[self._idx]["modelName"], + "manufacturer": MANUFACTURER, + } - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Run when entity about to be added.""" await super().async_added_to_hass() - state = await self.async_get_last_state() - if not state: + last_state = await self.async_get_last_state() + if not last_state: return - self._state = state.state == "on" + self._attr_is_on = last_state.state == STATE_ON + self._last_run_success = last_state.attributes["last_run_success"] - def turn_on(self, **kwargs) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn device on.""" - if self._device.turn_on(): - self._state = True + _LOGGER.info("Turn Switchbot bot on %s", self._mac) + + async with self.coordinator.api_lock: + update_ok = await self.hass.async_add_executor_job(self._device.turn_on) + + if update_ok: self._last_run_success = True else: self._last_run_success = False - def turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn device off.""" - if self._device.turn_off(): - self._state = False + _LOGGER.info("Turn Switchbot bot off %s", self._mac) + + async with self.coordinator.api_lock: + update_ok = await self.hass.async_add_executor_job(self._device.turn_off) + + if update_ok: self._last_run_success = True else: self._last_run_success = False @@ -70,24 +174,20 @@ class SwitchBot(SwitchEntity, RestoreEntity): @property def assumed_state(self) -> bool: """Return true if unable to access real state of entity.""" - return True + if not self.coordinator.data[self._idx]["data"]["switchMode"]: + return True + return False @property def is_on(self) -> bool: """Return true if device is on.""" - return bool(self._state) + return self.coordinator.data[self._idx]["data"]["isOn"] @property - def unique_id(self) -> str: - """Return a unique, Home Assistant friendly identifier for this entity.""" - return self._mac.replace(":", "") - - @property - def name(self) -> str: - """Return the name of the switch.""" - return self._name - - @property - def extra_state_attributes(self) -> dict[str, Any]: + def device_state_attributes(self) -> dict: """Return the state attributes.""" - return {"last_run_success": self._last_run_success} + return { + "last_run_success": self._last_run_success, + "mac_address": self._mac, + "switch_mode": self.coordinator.data[self._idx]["data"]["switchMode"], + } diff --git a/homeassistant/components/switchbot/translations/en.json b/homeassistant/components/switchbot/translations/en.json new file mode 100644 index 00000000000..a9800265297 --- /dev/null +++ b/homeassistant/components/switchbot/translations/en.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured_device": "Device is already configured", + "unknown": "Unexpected error", + "switchbot_unsupported_type": "Unsupported Switchbot Type." + }, + "error": { + "cannot_connect": "Failed to connect" + }, + "flow_title": "{name}", + "step": { + + "user": { + "data": { + "name": "Name", + "password": "Password", + "mac": "Mac" + }, + "title": "Setup Switchbot device" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "update_time": "Time between updates (seconds)", + "retry_count": "Retry count", + "retry_timeout": "Timeout between retries", + "scan_timeout": "How long to scan for advertisement data" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 6265360700a..80395e8e3f6 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -268,6 +268,7 @@ FLOWS = [ "starline", "subaru", "surepetcare", + "switchbot", "switcher_kis", "syncthing", "syncthru", diff --git a/requirements_all.txt b/requirements_all.txt index 89fc8e9f55e..f0e42c8c57e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -49,7 +49,7 @@ PyRMVtransport==0.3.2 PySocks==1.7.1 # homeassistant.components.switchbot -# PySwitchbot==0.8.0 +# PySwitchbot==0.11.0 # homeassistant.components.transport_nsw PyTransportNSW==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index af453498438..759538363f2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -23,6 +23,9 @@ PyQRCode==1.2.1 # homeassistant.components.rmvtransport PyRMVtransport==0.3.2 +# homeassistant.components.switchbot +# PySwitchbot==0.11.0 + # homeassistant.components.transport_nsw PyTransportNSW==0.1.1 diff --git a/tests/components/switchbot/__init__.py b/tests/components/switchbot/__init__.py new file mode 100644 index 00000000000..f74edffc19e --- /dev/null +++ b/tests/components/switchbot/__init__.py @@ -0,0 +1,64 @@ +"""Tests for the switchbot integration.""" +from unittest.mock import patch + +from homeassistant.const import CONF_MAC, CONF_NAME, CONF_PASSWORD, CONF_SENSOR_TYPE +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +DOMAIN = "switchbot" + +ENTRY_CONFIG = { + CONF_NAME: "test-name", + CONF_PASSWORD: "test-password", + CONF_MAC: "e7:89:43:99:99:99", +} + +USER_INPUT = { + CONF_NAME: "test-name", + CONF_PASSWORD: "test-password", + CONF_MAC: "e7:89:43:99:99:99", +} + +USER_INPUT_UNSUPPORTED_DEVICE = { + CONF_NAME: "test-name", + CONF_PASSWORD: "test-password", + CONF_MAC: "test", +} + +USER_INPUT_INVALID = { + CONF_NAME: "test-name", + CONF_PASSWORD: "test-password", + CONF_MAC: "invalid-mac", +} + +YAML_CONFIG = { + CONF_NAME: "test-name", + CONF_PASSWORD: "test-password", + CONF_MAC: "e7:89:43:99:99:99", + CONF_SENSOR_TYPE: "bot", +} + + +def _patch_async_setup_entry(return_value=True): + return patch( + "homeassistant.components.switchbot.async_setup_entry", + return_value=return_value, + ) + + +async def init_integration( + hass: HomeAssistant, + *, + data: dict = ENTRY_CONFIG, + skip_entry_setup: bool = False, +) -> MockConfigEntry: + """Set up the Switchbot integration in Home Assistant.""" + entry = MockConfigEntry(domain=DOMAIN, data=data) + entry.add_to_hass(hass) + + if not skip_entry_setup: + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry diff --git a/tests/components/switchbot/conftest.py b/tests/components/switchbot/conftest.py new file mode 100644 index 00000000000..b722776e9b1 --- /dev/null +++ b/tests/components/switchbot/conftest.py @@ -0,0 +1,78 @@ +"""Define fixtures available for all tests.""" +import sys +from unittest.mock import MagicMock, patch + +from pytest import fixture + + +class MocGetSwitchbotDevices: + """Scan for all Switchbot devices and return by type.""" + + def __init__(self, interface=None) -> None: + """Get switchbot devices class constructor.""" + self._interface = interface + self._all_services_data = { + "mac_address": "e7:89:43:99:99:99", + "Flags": "06", + "Manufacturer": "5900e78943d9fe7c", + "Complete 128b Services": "cba20d00-224d-11e6-9fb8-0002a5d5c51b", + "data": { + "switchMode": "true", + "isOn": "true", + "battery": 91, + "rssi": -71, + }, + "model": "H", + "modelName": "WoHand", + } + self._unsupported_device = { + "mac_address": "test", + "Flags": "06", + "Manufacturer": "5900e78943d9fe7c", + "Complete 128b Services": "cba20d00-224d-11e6-9fb8-0002a5d5c51b", + "data": { + "switchMode": "true", + "isOn": "true", + "battery": 91, + "rssi": -71, + }, + "model": "HoN", + "modelName": "WoOther", + } + + def discover(self, retry=0, scan_timeout=0): + """Mock discover.""" + return self._all_services_data + + def get_device_data(self, mac=None): + """Return data for specific device.""" + if mac == "e7:89:43:99:99:99": + return self._all_services_data + if mac == "test": + return self._unsupported_device + + return None + + +class MocNotConnectedError(Exception): + """Mock exception.""" + + +module = type(sys)("switchbot") +module.GetSwitchbotDevices = MocGetSwitchbotDevices +module.NotConnectedError = MocNotConnectedError +sys.modules["switchbot"] = module + + +@fixture +def switchbot_config_flow(hass): + """Mock the bluepy api for easier config flow testing.""" + with patch.object(MocGetSwitchbotDevices, "discover", return_value=True), patch( + "homeassistant.components.switchbot.config_flow.GetSwitchbotDevices" + ) as mock_switchbot: + instance = mock_switchbot.return_value + + instance.discover = MagicMock(return_value=True) + instance.get_device_data = MagicMock(return_value=True) + + yield mock_switchbot diff --git a/tests/components/switchbot/test_config_flow.py b/tests/components/switchbot/test_config_flow.py new file mode 100644 index 00000000000..e9baace081b --- /dev/null +++ b/tests/components/switchbot/test_config_flow.py @@ -0,0 +1,226 @@ +"""Test the switchbot config flow.""" + +from unittest.mock import patch + +from homeassistant.components.switchbot.config_flow import NotConnectedError +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_MAC, CONF_NAME, CONF_PASSWORD, CONF_SENSOR_TYPE +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) +from homeassistant.setup import async_setup_component + +from . import ( + USER_INPUT, + USER_INPUT_INVALID, + USER_INPUT_UNSUPPORTED_DEVICE, + YAML_CONFIG, + _patch_async_setup_entry, + init_integration, +) + +DOMAIN = "switchbot" + + +async def test_user_form_valid_mac(hass): + """Test the user initiated form with password and valid mac.""" + await async_setup_component(hass, "persistent_notification", {}) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with _patch_async_setup_entry() as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "test-name" + assert result["data"] == { + CONF_MAC: "e7:89:43:99:99:99", + CONF_NAME: "test-name", + CONF_PASSWORD: "test-password", + CONF_SENSOR_TYPE: "bot", + } + + assert len(mock_setup_entry.mock_calls) == 1 + + # test duplicate device creation fails. + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured_device" + + +async def test_user_form_unsupported_device(hass): + """Test the user initiated form for unsupported device type.""" + await async_setup_component(hass, "persistent_notification", {}) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT_UNSUPPORTED_DEVICE, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "switchbot_unsupported_type" + + +async def test_user_form_invalid_device(hass): + """Test the user initiated form for invalid device type.""" + await async_setup_component(hass, "persistent_notification", {}) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT_INVALID, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_async_step_import(hass): + """Test the config import flow.""" + await async_setup_component(hass, "persistent_notification", {}) + + with _patch_async_setup_entry() as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=YAML_CONFIG + ) + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["data"] == { + CONF_MAC: "e7:89:43:99:99:99", + CONF_NAME: "test-name", + CONF_PASSWORD: "test-password", + CONF_SENSOR_TYPE: "bot", + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_user_form_exception(hass, switchbot_config_flow): + """Test we handle exception on user form.""" + await async_setup_component(hass, "persistent_notification", {}) + + switchbot_config_flow.side_effect = NotConnectedError + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + switchbot_config_flow.side_effect = Exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "unknown" + + +async def test_options_flow(hass): + """Test updating options.""" + with patch("homeassistant.components.switchbot.PLATFORMS", []): + entry = await init_integration(hass) + + assert entry.options["update_time"] == 60 + assert entry.options["retry_count"] == 3 + assert entry.options["retry_timeout"] == 5 + assert entry.options["scan_timeout"] == 5 + + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "init" + assert result["errors"] is None + + with _patch_async_setup_entry() as mock_setup_entry: + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "update_time": 60, + "retry_count": 3, + "retry_timeout": 5, + "scan_timeout": 5, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["data"]["update_time"] == 60 + assert result["data"]["retry_count"] == 3 + assert result["data"]["retry_timeout"] == 5 + assert result["data"]["scan_timeout"] == 5 + + assert len(mock_setup_entry.mock_calls) == 0 + + # Test changing of entry options. + + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "init" + assert result["errors"] is None + + with _patch_async_setup_entry() as mock_setup_entry: + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "update_time": 60, + "retry_count": 3, + "retry_timeout": 5, + "scan_timeout": 5, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["data"]["update_time"] == 60 + assert result["data"]["retry_count"] == 3 + assert result["data"]["retry_timeout"] == 5 + assert result["data"]["scan_timeout"] == 5 + + assert len(mock_setup_entry.mock_calls) == 0 From 5c19368ce397c4b620d2eac4f005f2e2cc40e355 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Sat, 18 Sep 2021 21:49:47 +0200 Subject: [PATCH 467/843] Strictly type sensor.py (#56388) --- homeassistant/components/tradfri/sensor.py | 24 ++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/tradfri/sensor.py b/homeassistant/components/tradfri/sensor.py index 7f0ed233d1b..1e7d771cb39 100644 --- a/homeassistant/components/tradfri/sensor.py +++ b/homeassistant/components/tradfri/sensor.py @@ -1,13 +1,25 @@ """Support for IKEA Tradfri sensors.""" +from __future__ import annotations + +from typing import Any, Callable, cast + +from pytradfri.command import Command from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import DEVICE_CLASS_BATTERY, PERCENTAGE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .base_class import TradfriBaseDevice from .const import CONF_GATEWAY_ID, DEVICES, DOMAIN, KEY_API -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up a Tradfri config entry.""" gateway_id = config_entry.data[CONF_GATEWAY_ID] tradfri_data = hass.data[DOMAIN][config_entry.entry_id] @@ -32,12 +44,16 @@ class TradfriSensor(TradfriBaseDevice, SensorEntity): _attr_device_class = DEVICE_CLASS_BATTERY _attr_native_unit_of_measurement = PERCENTAGE - def __init__(self, device, api, gateway_id): + def __init__( + self, device: Command, api: Callable[[str], Any], gateway_id: str + ) -> None: """Initialize the device.""" super().__init__(device, api, gateway_id) self._attr_unique_id = f"{gateway_id}-{device.id}" @property - def native_value(self): + def native_value(self) -> int | None: """Return the current state of the device.""" - return self._device.device_info.battery_level + if not self._device: + return None + return cast(int, self._device.device_info.battery_level) From 6b6e26c96d1d41d3fab501509ecbb4f3f993de0b Mon Sep 17 00:00:00 2001 From: jan iversen Date: Sat, 18 Sep 2021 21:54:11 +0200 Subject: [PATCH 468/843] Strictly type binary_sensor.py. (#56376) --- homeassistant/components/modbus/binary_sensor.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/modbus/binary_sensor.py b/homeassistant/components/modbus/binary_sensor.py index adc5e2d28f1..d3a8578f47d 100644 --- a/homeassistant/components/modbus/binary_sensor.py +++ b/homeassistant/components/modbus/binary_sensor.py @@ -1,11 +1,13 @@ """Support for Modbus Coil and Discrete Input sensors.""" from __future__ import annotations +from datetime import datetime import logging from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.const import CONF_BINARY_SENSORS, CONF_NAME, STATE_ON from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -19,9 +21,9 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - async_add_entities, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, -): +) -> None: """Set up the Modbus binary sensors.""" sensors = [] @@ -38,14 +40,14 @@ async def async_setup_platform( class ModbusBinarySensor(BasePlatform, RestoreEntity, BinarySensorEntity): """Modbus binary sensor.""" - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await self.async_base_added_to_hass() state = await self.async_get_last_state() if state: self._attr_is_on = state.state == STATE_ON - async def async_update(self, now=None): + async def async_update(self, now: datetime | None = None) -> None: """Update the state of the sensor.""" # do not allow multiple active calls to the same platform From bf7c2753d59c6999d38cb248221f64146099dd45 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Sat, 18 Sep 2021 21:59:04 +0200 Subject: [PATCH 469/843] deCONZ use siren platform (#56397) * Add siren.py * Working siren platform with 100% test coverage * Also add test file... * Add test to verify that switch platform cleans up legacy entities now that sirens are their own platform * Update homeassistant/components/deconz/siren.py Co-authored-by: jjlawren --- homeassistant/components/deconz/const.py | 7 +- homeassistant/components/deconz/light.py | 11 +- homeassistant/components/deconz/siren.py | 78 +++++++++++++ homeassistant/components/deconz/switch.py | 37 ++---- tests/components/deconz/test_gateway.py | 4 +- tests/components/deconz/test_siren.py | 132 ++++++++++++++++++++++ tests/components/deconz/test_switch.py | 73 ------------ 7 files changed, 237 insertions(+), 105 deletions(-) create mode 100644 homeassistant/components/deconz/siren.py create mode 100644 tests/components/deconz/test_siren.py diff --git a/homeassistant/components/deconz/const.py b/homeassistant/components/deconz/const.py index d2d7025771e..e961a62c7a0 100644 --- a/homeassistant/components/deconz/const.py +++ b/homeassistant/components/deconz/const.py @@ -12,6 +12,7 @@ from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.siren import DOMAIN as SIREN_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN LOGGER = logging.getLogger(__package__) @@ -41,6 +42,7 @@ PLATFORMS = [ LOCK_DOMAIN, SCENE_DOMAIN, SENSOR_DOMAIN, + SIREN_DOMAIN, SWITCH_DOMAIN, ] @@ -69,10 +71,11 @@ FANS = ["Fan"] # Locks LOCK_TYPES = ["Door Lock", "ZHADoorLock"] +# Sirens +SIRENS = ["Warning device"] + # Switches POWER_PLUGS = ["On/Off light", "On/Off plug-in unit", "Smart plug"] -SIRENS = ["Warning device"] -SWITCH_TYPES = POWER_PLUGS + SIRENS CONF_ANGLE = "angle" CONF_GESTURE = "gesture" diff --git a/homeassistant/components/deconz/light.py b/homeassistant/components/deconz/light.py index 59eb1bb426e..3c48fbc5177 100644 --- a/homeassistant/components/deconz/light.py +++ b/homeassistant/components/deconz/light.py @@ -34,7 +34,8 @@ from .const import ( LOCK_TYPES, NEW_GROUP, NEW_LIGHT, - SWITCH_TYPES, + POWER_PLUGS, + SIRENS, ) from .deconz_device import DeconzDevice from .gateway import get_gateway_from_config_entry @@ -42,14 +43,16 @@ from .gateway import get_gateway_from_config_entry CONTROLLER = ["Configuration tool"] DECONZ_GROUP = "is_deconz_group" +OTHER_LIGHT_RESOURCE_TYPES = ( + CONTROLLER + COVER_TYPES + LOCK_TYPES + POWER_PLUGS + SIRENS +) + async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the deCONZ lights and groups from a config entry.""" gateway = get_gateway_from_config_entry(hass, config_entry) gateway.entities[DOMAIN] = set() - other_light_resource_types = CONTROLLER + COVER_TYPES + LOCK_TYPES + SWITCH_TYPES - @callback def async_add_light(lights=gateway.api.lights.values()): """Add light from deCONZ.""" @@ -57,7 +60,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): for light in lights: if ( - light.type not in other_light_resource_types + light.type not in OTHER_LIGHT_RESOURCE_TYPES and light.unique_id not in gateway.entities[DOMAIN] ): entities.append(DeconzLight(light, gateway)) diff --git a/homeassistant/components/deconz/siren.py b/homeassistant/components/deconz/siren.py new file mode 100644 index 00000000000..9138bb3ac14 --- /dev/null +++ b/homeassistant/components/deconz/siren.py @@ -0,0 +1,78 @@ +"""Support for deCONZ siren.""" + +from pydeconz.light import Siren + +from homeassistant.components.siren import ( + ATTR_DURATION, + DOMAIN, + SUPPORT_DURATION, + SUPPORT_TURN_OFF, + SUPPORT_TURN_ON, + SirenEntity, +) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .const import NEW_LIGHT +from .deconz_device import DeconzDevice +from .gateway import get_gateway_from_config_entry + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up sirens for deCONZ component.""" + gateway = get_gateway_from_config_entry(hass, config_entry) + gateway.entities[DOMAIN] = set() + + @callback + def async_add_siren(lights=gateway.api.lights.values()): + """Add siren from deCONZ.""" + entities = [] + + for light in lights: + + if ( + isinstance(light, Siren) + and light.unique_id not in gateway.entities[DOMAIN] + ): + entities.append(DeconzSiren(light, gateway)) + + if entities: + async_add_entities(entities) + + config_entry.async_on_unload( + async_dispatcher_connect( + hass, gateway.async_signal_new_device(NEW_LIGHT), async_add_siren + ) + ) + + async_add_siren() + + +class DeconzSiren(DeconzDevice, SirenEntity): + """Representation of a deCONZ siren.""" + + TYPE = DOMAIN + + def __init__(self, device, gateway) -> None: + """Set up siren.""" + super().__init__(device, gateway) + + self._attr_supported_features = ( + SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_DURATION + ) + + @property + def is_on(self): + """Return true if siren is on.""" + return self._device.is_on + + async def async_turn_on(self, **kwargs): + """Turn on siren.""" + data = {} + if (duration := kwargs.get(ATTR_DURATION)) is not None: + data["duration"] = duration * 10 + await self._device.turn_on(**data) + + async def async_turn_off(self, **kwargs): + """Turn off siren.""" + await self._device.turn_off() diff --git a/homeassistant/components/deconz/switch.py b/homeassistant/components/deconz/switch.py index 5fee752a71f..8f31af3a6cf 100644 --- a/homeassistant/components/deconz/switch.py +++ b/homeassistant/components/deconz/switch.py @@ -3,7 +3,7 @@ from homeassistant.components.switch import DOMAIN, SwitchEntity from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from .const import NEW_LIGHT, POWER_PLUGS, SIRENS +from .const import DOMAIN as DECONZ_DOMAIN, NEW_LIGHT, POWER_PLUGS, SIRENS from .deconz_device import DeconzDevice from .gateway import get_gateway_from_config_entry @@ -16,6 +16,17 @@ async def async_setup_entry(hass, config_entry, async_add_entities): gateway = get_gateway_from_config_entry(hass, config_entry) gateway.entities[DOMAIN] = set() + entity_registry = await hass.helpers.entity_registry.async_get_registry() + + # Siren platform replacing sirens in switch platform added in 2021.10 + for light in gateway.api.lights.values(): + if light.type not in SIRENS: + continue + if entity_id := entity_registry.async_get_entity_id( + DOMAIN, DECONZ_DOMAIN, light.unique_id + ): + entity_registry.async_remove(entity_id) + @callback def async_add_switch(lights=gateway.api.lights.values()): """Add switch from deCONZ.""" @@ -29,11 +40,6 @@ async def async_setup_entry(hass, config_entry, async_add_entities): ): entities.append(DeconzPowerPlug(light, gateway)) - elif ( - light.type in SIRENS and light.unique_id not in gateway.entities[DOMAIN] - ): - entities.append(DeconzSiren(light, gateway)) - if entities: async_add_entities(entities) @@ -63,22 +69,3 @@ class DeconzPowerPlug(DeconzDevice, SwitchEntity): async def async_turn_off(self, **kwargs): """Turn off switch.""" await self._device.set_state(on=False) - - -class DeconzSiren(DeconzDevice, SwitchEntity): - """Representation of a deCONZ siren.""" - - TYPE = DOMAIN - - @property - def is_on(self): - """Return true if switch is on.""" - return self._device.is_on - - async def async_turn_on(self, **kwargs): - """Turn on switch.""" - await self._device.turn_on() - - async def async_turn_off(self, **kwargs): - """Turn off switch.""" - await self._device.turn_off() diff --git a/tests/components/deconz/test_gateway.py b/tests/components/deconz/test_gateway.py index 120ceaa9327..4ee071f10d3 100644 --- a/tests/components/deconz/test_gateway.py +++ b/tests/components/deconz/test_gateway.py @@ -25,6 +25,7 @@ from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.siren import DOMAIN as SIREN_DOMAIN from homeassistant.components.ssdp import ( ATTR_SSDP_LOCATION, ATTR_UPNP_MANUFACTURER_URL, @@ -163,7 +164,8 @@ async def test_gateway_setup(hass, aioclient_mock): assert forward_entry_setup.mock_calls[6][1] == (config_entry, LOCK_DOMAIN) assert forward_entry_setup.mock_calls[7][1] == (config_entry, SCENE_DOMAIN) assert forward_entry_setup.mock_calls[8][1] == (config_entry, SENSOR_DOMAIN) - assert forward_entry_setup.mock_calls[9][1] == (config_entry, SWITCH_DOMAIN) + assert forward_entry_setup.mock_calls[9][1] == (config_entry, SIREN_DOMAIN) + assert forward_entry_setup.mock_calls[10][1] == (config_entry, SWITCH_DOMAIN) async def test_gateway_retry(hass): diff --git a/tests/components/deconz/test_siren.py b/tests/components/deconz/test_siren.py new file mode 100644 index 00000000000..c24e2087768 --- /dev/null +++ b/tests/components/deconz/test_siren.py @@ -0,0 +1,132 @@ +"""deCONZ switch platform tests.""" + +from unittest.mock import patch + +from homeassistant.components.deconz.const import DOMAIN as DECONZ_DOMAIN +from homeassistant.components.siren import ATTR_DURATION, DOMAIN as SIREN_DOMAIN +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, +) +from homeassistant.helpers import entity_registry as er + +from .test_gateway import ( + DECONZ_WEB_REQUEST, + mock_deconz_put_request, + setup_deconz_integration, +) + + +async def test_sirens(hass, aioclient_mock, mock_deconz_websocket): + """Test that siren entities are created.""" + data = { + "lights": { + "1": { + "name": "Warning device", + "type": "Warning device", + "state": {"alert": "lselect", "reachable": True}, + "uniqueid": "00:00:00:00:00:00:00:00-00", + }, + "2": { + "name": "Unsupported siren", + "type": "Not a siren", + "state": {"reachable": True}, + "uniqueid": "00:00:00:00:00:00:00:01-00", + }, + } + } + with patch.dict(DECONZ_WEB_REQUEST, data): + config_entry = await setup_deconz_integration(hass, aioclient_mock) + + assert len(hass.states.async_all()) == 2 + assert hass.states.get("siren.warning_device").state == STATE_ON + assert not hass.states.get("siren.unsupported_siren") + + event_changed_light = { + "t": "event", + "e": "changed", + "r": "lights", + "id": "1", + "state": {"alert": None}, + } + await mock_deconz_websocket(data=event_changed_light) + await hass.async_block_till_done() + + assert hass.states.get("siren.warning_device").state == STATE_OFF + + # Verify service calls + + mock_deconz_put_request(aioclient_mock, config_entry.data, "/lights/1/state") + + # Service turn on siren + + await hass.services.async_call( + SIREN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "siren.warning_device"}, + blocking=True, + ) + assert aioclient_mock.mock_calls[1][2] == {"alert": "lselect"} + + # Service turn off siren + + await hass.services.async_call( + SIREN_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "siren.warning_device"}, + blocking=True, + ) + assert aioclient_mock.mock_calls[2][2] == {"alert": "none"} + + # Service turn on siren with duration + + await hass.services.async_call( + SIREN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "siren.warning_device", ATTR_DURATION: 10}, + blocking=True, + ) + assert aioclient_mock.mock_calls[3][2] == {"alert": "lselect", "ontime": 100} + + await hass.config_entries.async_unload(config_entry.entry_id) + + states = hass.states.async_all() + assert len(states) == 2 + for state in states: + assert state.state == STATE_UNAVAILABLE + + await hass.config_entries.async_remove(config_entry.entry_id) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 0 + + +async def test_remove_legacy_siren_switch(hass, aioclient_mock): + """Test that switch platform cleans up legacy siren entities.""" + unique_id = "00:00:00:00:00:00:00:00-00" + + registry = er.async_get(hass) + switch_siren_entity = registry.async_get_or_create( + SWITCH_DOMAIN, DECONZ_DOMAIN, unique_id + ) + + assert switch_siren_entity + + data = { + "lights": { + "1": { + "name": "Warning device", + "type": "Warning device", + "state": {"alert": "lselect", "reachable": True}, + "uniqueid": unique_id, + }, + } + } + with patch.dict(DECONZ_WEB_REQUEST, data): + await setup_deconz_integration(hass, aioclient_mock) + + assert not registry.async_get(switch_siren_entity.entity_id) diff --git a/tests/components/deconz/test_switch.py b/tests/components/deconz/test_switch.py index cffdf07ae2b..99dcbe1089a 100644 --- a/tests/components/deconz/test_switch.py +++ b/tests/components/deconz/test_switch.py @@ -107,76 +107,3 @@ async def test_power_plugs(hass, aioclient_mock, mock_deconz_websocket): await hass.config_entries.async_remove(config_entry.entry_id) await hass.async_block_till_done() assert len(hass.states.async_all()) == 0 - - -async def test_sirens(hass, aioclient_mock, mock_deconz_websocket): - """Test that siren entities are created.""" - data = { - "lights": { - "1": { - "name": "Warning device", - "type": "Warning device", - "state": {"alert": "lselect", "reachable": True}, - "uniqueid": "00:00:00:00:00:00:00:00-00", - }, - "2": { - "name": "Unsupported switch", - "type": "Not a switch", - "state": {"reachable": True}, - "uniqueid": "00:00:00:00:00:00:00:01-00", - }, - } - } - with patch.dict(DECONZ_WEB_REQUEST, data): - config_entry = await setup_deconz_integration(hass, aioclient_mock) - - assert len(hass.states.async_all()) == 2 - assert hass.states.get("switch.warning_device").state == STATE_ON - assert not hass.states.get("switch.unsupported_switch") - - event_changed_light = { - "t": "event", - "e": "changed", - "r": "lights", - "id": "1", - "state": {"alert": None}, - } - await mock_deconz_websocket(data=event_changed_light) - await hass.async_block_till_done() - - assert hass.states.get("switch.warning_device").state == STATE_OFF - - # Verify service calls - - mock_deconz_put_request(aioclient_mock, config_entry.data, "/lights/1/state") - - # Service turn on siren - - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.warning_device"}, - blocking=True, - ) - assert aioclient_mock.mock_calls[1][2] == {"alert": "lselect"} - - # Service turn off siren - - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "switch.warning_device"}, - blocking=True, - ) - assert aioclient_mock.mock_calls[2][2] == {"alert": "none"} - - await hass.config_entries.async_unload(config_entry.entry_id) - - states = hass.states.async_all() - assert len(states) == 2 - for state in states: - assert state.state == STATE_UNAVAILABLE - - await hass.config_entries.async_remove(config_entry.entry_id) - await hass.async_block_till_done() - assert len(hass.states.async_all()) == 0 From 43b5dcff7631124e4509b6c951f3875ef7be905c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sat, 18 Sep 2021 23:12:02 +0200 Subject: [PATCH 470/843] Use hass_client_no_auth test fixture in withings tests (#56337) --- tests/components/withings/common.py | 8 ++++---- tests/components/withings/conftest.py | 6 ++++-- tests/components/withings/test_config_flow.py | 4 ++-- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/tests/components/withings/common.py b/tests/components/withings/common.py index 9e6efeb37bf..847f482b6c5 100644 --- a/tests/components/withings/common.py +++ b/tests/components/withings/common.py @@ -103,13 +103,13 @@ class ComponentFactory: self, hass: HomeAssistant, api_class_mock: MagicMock, - aiohttp_client, + hass_client_no_auth, aioclient_mock: AiohttpClientMocker, ) -> None: """Initialize the object.""" self._hass = hass self._api_class_mock = api_class_mock - self._aiohttp_client = aiohttp_client + self._hass_client = hass_client_no_auth self._aioclient_mock = aioclient_mock self._client_id = None self._client_secret = None @@ -208,7 +208,7 @@ class ComponentFactory: ) # Simulate user being redirected from withings site. - client: TestClient = await self._aiohttp_client(self._hass.http.app) + client: TestClient = await self._hass_client() resp = await client.get(f"{AUTH_CALLBACK_PATH}?code=abcd&state={state}") assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" @@ -259,7 +259,7 @@ class ComponentFactory: async def call_webhook(self, user_id: int, appli: NotifyAppli) -> WebhookResponse: """Call the webhook to notify of data changes.""" - client: TestClient = await self._aiohttp_client(self._hass.http.app) + client: TestClient = await self._hass_client() data_manager = get_data_manager_by_user_id(self._hass, user_id) resp = await client.post( diff --git a/tests/components/withings/conftest.py b/tests/components/withings/conftest.py index c95abc8addd..787a2ee4cb0 100644 --- a/tests/components/withings/conftest.py +++ b/tests/components/withings/conftest.py @@ -13,10 +13,12 @@ from tests.test_util.aiohttp import AiohttpClientMocker @pytest.fixture() def component_factory( - hass: HomeAssistant, aiohttp_client, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, hass_client_no_auth, aioclient_mock: AiohttpClientMocker ): """Return a factory for initializing the withings component.""" with patch( "homeassistant.components.withings.common.ConfigEntryWithingsApi" ) as api_class_mock: - yield ComponentFactory(hass, api_class_mock, aiohttp_client, aioclient_mock) + yield ComponentFactory( + hass, api_class_mock, hass_client_no_auth, aioclient_mock + ) diff --git a/tests/components/withings/test_config_flow.py b/tests/components/withings/test_config_flow.py index 618dd19f80b..83368ed3fa1 100644 --- a/tests/components/withings/test_config_flow.py +++ b/tests/components/withings/test_config_flow.py @@ -35,7 +35,7 @@ async def test_config_non_unique_profile(hass: HomeAssistant) -> None: async def test_config_reauth_profile( - hass: HomeAssistant, aiohttp_client, aioclient_mock, current_request_with_host + hass: HomeAssistant, hass_client_no_auth, aioclient_mock, current_request_with_host ) -> None: """Test reauth an existing profile re-creates the config entry.""" hass_config = { @@ -81,7 +81,7 @@ async def test_config_reauth_profile( }, ) - client: TestClient = await aiohttp_client(hass.http.app) + client: TestClient = await hass_client_no_auth() resp = await client.get(f"{AUTH_CALLBACK_PATH}?code=abcd&state={state}") assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" From 8b64cd7e7d9bbf0aaf9e16b02ab8da4ad9dd31fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Sat, 18 Sep 2021 23:15:39 +0200 Subject: [PATCH 471/843] Bump pyTibber to 0.19.1 (#56405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Daniel Hjelseth Høyer --- homeassistant/components/tibber/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json index 20b62832619..bbc90f7218c 100644 --- a/homeassistant/components/tibber/manifest.json +++ b/homeassistant/components/tibber/manifest.json @@ -2,7 +2,7 @@ "domain": "tibber", "name": "Tibber", "documentation": "https://www.home-assistant.io/integrations/tibber", - "requirements": ["pyTibber==0.19.0"], + "requirements": ["pyTibber==0.19.1"], "codeowners": ["@danielhiversen"], "quality_scale": "silver", "config_flow": true, diff --git a/requirements_all.txt b/requirements_all.txt index f0e42c8c57e..8d4cce4c6b0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1316,7 +1316,7 @@ pyRFXtrx==0.27.0 # pySwitchmate==0.4.6 # homeassistant.components.tibber -pyTibber==0.19.0 +pyTibber==0.19.1 # homeassistant.components.dlink pyW215==0.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 759538363f2..07f07060019 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -764,7 +764,7 @@ pyMetno==0.8.3 pyRFXtrx==0.27.0 # homeassistant.components.tibber -pyTibber==0.19.0 +pyTibber==0.19.1 # homeassistant.components.nextbus py_nextbusnext==0.1.5 From f6526de7b6c6f43e3be6f99b2393a633d7a786f8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sat, 18 Sep 2021 23:17:09 +0200 Subject: [PATCH 472/843] Use hass_client_no_auth test fixture in nest tests (#56326) --- tests/components/nest/test_config_flow_sdm.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/components/nest/test_config_flow_sdm.py b/tests/components/nest/test_config_flow_sdm.py index f8c9c69698a..a8f892045f5 100644 --- a/tests/components/nest/test_config_flow_sdm.py +++ b/tests/components/nest/test_config_flow_sdm.py @@ -37,10 +37,10 @@ def get_config_entry(hass): class OAuthFixture: """Simulate the oauth flow used by the config flow.""" - def __init__(self, hass, aiohttp_client, aioclient_mock): + def __init__(self, hass, hass_client_no_auth, aioclient_mock): """Initialize OAuthFixture.""" self.hass = hass - self.aiohttp_client = aiohttp_client + self.hass_client = hass_client_no_auth self.aioclient_mock = aioclient_mock async def async_oauth_flow(self, result): @@ -63,7 +63,7 @@ class OAuthFixture: "&access_type=offline&prompt=consent" ) - client = await self.aiohttp_client(self.hass.http.app) + client = await self.hass_client() resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" @@ -86,9 +86,9 @@ class OAuthFixture: @pytest.fixture -async def oauth(hass, aiohttp_client, aioclient_mock, current_request_with_host): +async def oauth(hass, hass_client_no_auth, aioclient_mock, current_request_with_host): """Create the simulated oauth flow.""" - return OAuthFixture(hass, aiohttp_client, aioclient_mock) + return OAuthFixture(hass, hass_client_no_auth, aioclient_mock) async def test_full_flow(hass, oauth): From 9b710cad5d990c4a028f76676fe7e5a3162dc915 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Sat, 18 Sep 2021 23:24:35 +0200 Subject: [PATCH 473/843] Add strict typing to tradfri __init__ and switch (#56002) * Add strict typing to __init__ and switch. * Review comments. * Review comments. * Corrected switch. --- homeassistant/components/tradfri/__init__.py | 10 +++--- .../components/tradfri/base_class.py | 22 ++++++++---- homeassistant/components/tradfri/switch.py | 35 +++++++++++++++---- 3 files changed, 49 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/tradfri/__init__.py b/homeassistant/components/tradfri/__init__.py index 2c113b63727..4f5997b2fa1 100644 --- a/homeassistant/components/tradfri/__init__.py +++ b/homeassistant/components/tradfri/__init__.py @@ -1,7 +1,7 @@ """Support for IKEA Tradfri.""" from __future__ import annotations -from datetime import timedelta +from datetime import datetime, timedelta import logging from typing import Any @@ -15,7 +15,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.event import Event, async_track_time_interval from homeassistant.helpers.typing import ConfigType from .const import ( @@ -97,7 +97,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: psk=entry.data[CONF_KEY], ) - async def on_hass_stop(event): + async def on_hass_stop(event: Event) -> None: """Close connection when hass stops.""" await factory.shutdown() @@ -135,7 +135,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.config_entries.async_setup_platforms(entry, PLATFORMS) - async def async_keep_alive(now): + async def async_keep_alive(now: datetime) -> None: if hass.is_stopping: return @@ -151,7 +151,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: diff --git a/homeassistant/components/tradfri/base_class.py b/homeassistant/components/tradfri/base_class.py index ed95e47abd5..eb1884cfc1b 100644 --- a/homeassistant/components/tradfri/base_class.py +++ b/homeassistant/components/tradfri/base_class.py @@ -1,7 +1,15 @@ """Base class for IKEA TRADFRI.""" +from __future__ import annotations + from functools import wraps import logging +from typing import Any, Callable +from pytradfri.command import Command +from pytradfri.device.blind import Blind +from pytradfri.device.light import Light +from pytradfri.device.socket import Socket +from pytradfri.device.socket_control import SocketControl from pytradfri.error import PytradfriError from homeassistant.core import callback @@ -34,12 +42,14 @@ class TradfriBaseClass(Entity): _attr_should_poll = False - def __init__(self, device, api, gateway_id): + def __init__( + self, device: Command, api: Callable[[str], Any], gateway_id: str + ) -> None: """Initialize a device.""" self._api = handle_error(api) - self._device = None - self._device_control = None - self._device_data = None + self._device: Command | None = None + self._device_control: SocketControl | None = None + self._device_data: Socket | Light | Blind | None = None self._gateway_id = gateway_id self._refresh(device) @@ -71,7 +81,7 @@ class TradfriBaseClass(Entity): self._refresh(device) self.async_write_ha_state() - def _refresh(self, device): + def _refresh(self, device: Command) -> None: """Refresh the device data.""" self._device = device self._attr_name = device.name @@ -97,7 +107,7 @@ class TradfriBaseDevice(TradfriBaseClass): "via_device": (DOMAIN, self._gateway_id), } - def _refresh(self, device): + def _refresh(self, device: Command) -> None: """Refresh the device data.""" super()._refresh(device) self._attr_available = device.reachable diff --git a/homeassistant/components/tradfri/switch.py b/homeassistant/components/tradfri/switch.py index 00e15f1b875..6dc934814f0 100644 --- a/homeassistant/components/tradfri/switch.py +++ b/homeassistant/components/tradfri/switch.py @@ -1,11 +1,24 @@ """Support for IKEA Tradfri switches.""" +from __future__ import annotations + +from typing import Any, Callable, cast + +from pytradfri.command import Command + from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .base_class import TradfriBaseDevice from .const import CONF_GATEWAY_ID, DEVICES, DOMAIN, KEY_API -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Load Tradfri switches based on a config entry.""" gateway_id = config_entry.data[CONF_GATEWAY_ID] tradfri_data = hass.data[DOMAIN][config_entry.entry_id] @@ -22,12 +35,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class TradfriSwitch(TradfriBaseDevice, SwitchEntity): """The platform class required by Home Assistant.""" - def __init__(self, device, api, gateway_id): + def __init__( + self, device: Command, api: Callable[[str], Any], gateway_id: str + ) -> None: """Initialize a switch.""" super().__init__(device, api, gateway_id) self._attr_unique_id = f"{gateway_id}-{device.id}" - def _refresh(self, device): + def _refresh(self, device: Command) -> None: """Refresh the switch data.""" super()._refresh(device) @@ -36,14 +51,20 @@ class TradfriSwitch(TradfriBaseDevice, SwitchEntity): self._device_data = device.socket_control.sockets[0] @property - def is_on(self): + def is_on(self) -> bool: """Return true if switch is on.""" - return self._device_data.state + if not self._device_data: + return False + return cast(bool, self._device_data.state) - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Instruct the switch to turn off.""" + if not self._device_control: + return None await self._api(self._device_control.set_state(False)) - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Instruct the switch to turn on.""" + if not self._device_control: + return None await self._api(self._device_control.set_state(True)) From a4f6c3336fc598a51a3adce81349b15320e1a3e2 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 19 Sep 2021 01:10:15 +0200 Subject: [PATCH 474/843] Use EntityDescription - august (#56395) --- .../components/august/binary_sensor.py | 132 +++++++++++------- homeassistant/components/august/sensor.py | 84 ++++++++--- 2 files changed, 145 insertions(+), 71 deletions(-) diff --git a/homeassistant/components/august/binary_sensor.py b/homeassistant/components/august/binary_sensor.py index 27a115a0823..6a2c9a2ff6d 100644 --- a/homeassistant/components/august/binary_sensor.py +++ b/homeassistant/components/august/binary_sensor.py @@ -1,17 +1,29 @@ """Support for August binary sensors.""" +from __future__ import annotations + +from dataclasses import dataclass from datetime import datetime, timedelta import logging +from typing import Callable, cast -from yalexs.activity import ACTION_DOORBELL_CALL_MISSED, SOURCE_PUBNUB, ActivityType +from yalexs.activity import ( + ACTION_DOORBELL_CALL_MISSED, + SOURCE_PUBNUB, + Activity, + ActivityType, +) +from yalexs.doorbell import DoorbellDetail from yalexs.lock import LockDoorStatus from yalexs.util import update_lock_detail_from_activity +from homeassistant.components.august import AugustData from homeassistant.components.binary_sensor import ( DEVICE_CLASS_CONNECTIVITY, DEVICE_CLASS_DOOR, DEVICE_CLASS_MOTION, DEVICE_CLASS_OCCUPANCY, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.core import callback from homeassistant.helpers.event import async_call_later @@ -27,7 +39,7 @@ TIME_TO_RECHECK_DETECTION = timedelta( ) -def _retrieve_online_state(data, detail): +def _retrieve_online_state(data: AugustData, detail: DoorbellDetail) -> bool: """Get the latest state of the sensor.""" # The doorbell will go into standby mode when there is no motion # for a short while. It will wake by itself when needed so we need @@ -36,7 +48,7 @@ def _retrieve_online_state(data, detail): return detail.is_online or detail.is_standby -def _retrieve_motion_state(data, detail): +def _retrieve_motion_state(data: AugustData, detail: DoorbellDetail) -> bool: latest = data.activity_stream.get_latest_device_activity( detail.device_id, {ActivityType.DOORBELL_MOTION} ) @@ -47,7 +59,7 @@ def _retrieve_motion_state(data, detail): return _activity_time_based_state(latest) -def _retrieve_ding_state(data, detail): +def _retrieve_ding_state(data: AugustData, detail: DoorbellDetail) -> bool: latest = data.activity_stream.get_latest_device_activity( detail.device_id, {ActivityType.DOORBELL_DING} ) @@ -64,34 +76,62 @@ def _retrieve_ding_state(data, detail): return _activity_time_based_state(latest) -def _activity_time_based_state(latest): +def _activity_time_based_state(latest: Activity) -> bool: """Get the latest state of the sensor.""" start = latest.activity_start_time end = latest.activity_end_time + TIME_TO_DECLARE_DETECTION return start <= _native_datetime() <= end -def _native_datetime(): +def _native_datetime() -> datetime: """Return time in the format august uses without timezone.""" return datetime.now() -SENSOR_NAME = 0 -SENSOR_DEVICE_CLASS = 1 -SENSOR_STATE_PROVIDER = 2 -SENSOR_STATE_IS_TIME_BASED = 3 +@dataclass +class AugustRequiredKeysMixin: + """Mixin for required keys.""" -# sensor_type: [name, device_class, state_provider, is_time_based] -SENSOR_TYPES_DOORBELL = { - "doorbell_ding": ["Ding", DEVICE_CLASS_OCCUPANCY, _retrieve_ding_state, True], - "doorbell_motion": ["Motion", DEVICE_CLASS_MOTION, _retrieve_motion_state, True], - "doorbell_online": [ - "Online", - DEVICE_CLASS_CONNECTIVITY, - _retrieve_online_state, - False, - ], -} + state_provider: Callable[[AugustData, DoorbellDetail], bool] + is_time_based: bool + + +@dataclass +class AugustBinarySensorEntityDescription( + BinarySensorEntityDescription, AugustRequiredKeysMixin +): + """Describes August binary_sensor entity.""" + + +SENSOR_TYPE_DOOR = BinarySensorEntityDescription( + key="door_open", + name="Open", +) + + +SENSOR_TYPES_DOORBELL: tuple[AugustBinarySensorEntityDescription, ...] = ( + AugustBinarySensorEntityDescription( + key="doorbell_ding", + name="Ding", + device_class=DEVICE_CLASS_OCCUPANCY, + state_provider=_retrieve_ding_state, + is_time_based=True, + ), + AugustBinarySensorEntityDescription( + key="doorbell_motion", + name="Motion", + device_class=DEVICE_CLASS_MOTION, + state_provider=_retrieve_motion_state, + is_time_based=True, + ), + AugustBinarySensorEntityDescription( + key="doorbell_online", + name="Online", + device_class=DEVICE_CLASS_CONNECTIVITY, + state_provider=_retrieve_online_state, + is_time_based=False, + ), +) async def async_setup_entry(hass, config_entry, async_add_entities): @@ -109,16 +149,16 @@ async def async_setup_entry(hass, config_entry, async_add_entities): continue _LOGGER.debug("Adding sensor class door for %s", door.device_name) - entities.append(AugustDoorBinarySensor(data, "door_open", door)) + entities.append(AugustDoorBinarySensor(data, door, SENSOR_TYPE_DOOR)) for doorbell in data.doorbells: - for sensor_type, sensor in SENSOR_TYPES_DOORBELL.items(): + for description in SENSOR_TYPES_DOORBELL: _LOGGER.debug( "Adding doorbell sensor class %s for %s", - sensor[SENSOR_DEVICE_CLASS], + description.device_class, doorbell.device_name, ) - entities.append(AugustDoorbellBinarySensor(data, sensor_type, doorbell)) + entities.append(AugustDoorbellBinarySensor(data, doorbell, description)) async_add_entities(entities) @@ -128,14 +168,16 @@ class AugustDoorBinarySensor(AugustEntityMixin, BinarySensorEntity): _attr_device_class = DEVICE_CLASS_DOOR - def __init__(self, data, sensor_type, device): + def __init__(self, data, device, description: BinarySensorEntityDescription): """Initialize the sensor.""" super().__init__(data, device) + self.entity_description = description self._data = data - self._sensor_type = sensor_type self._device = device - self._attr_name = f"{device.device_name} Open" - self._attr_unique_id = f"{self._device_id}_open" + self._attr_name = f"{device.device_name} {description.name}" + self._attr_unique_id = ( + f"{self._device_id}_{cast(str, description.name).lower()}" + ) self._update_from_data() @callback @@ -164,41 +206,29 @@ class AugustDoorBinarySensor(AugustEntityMixin, BinarySensorEntity): class AugustDoorbellBinarySensor(AugustEntityMixin, BinarySensorEntity): """Representation of an August binary sensor.""" - def __init__(self, data, sensor_type, device): + entity_description: AugustBinarySensorEntityDescription + + def __init__(self, data, device, description: AugustBinarySensorEntityDescription): """Initialize the sensor.""" super().__init__(data, device) + self.entity_description = description self._check_for_off_update_listener = None self._data = data - self._sensor_type = sensor_type - self._attr_device_class = self._sensor_config[SENSOR_DEVICE_CLASS] - self._attr_name = f"{device.device_name} {self._sensor_config[SENSOR_NAME]}" + self._attr_name = f"{device.device_name} {description.name}" self._attr_unique_id = ( - f"{self._device_id}_{self._sensor_config[SENSOR_NAME].lower()}" + f"{self._device_id}_{cast(str, description.name).lower()}" ) self._update_from_data() - @property - def _sensor_config(self): - """Return the config for the sensor.""" - return SENSOR_TYPES_DOORBELL[self._sensor_type] - - @property - def _state_provider(self): - """Return the state provider for the binary sensor.""" - return self._sensor_config[SENSOR_STATE_PROVIDER] - - @property - def _is_time_based(self): - """Return true of false if the sensor is time based.""" - return self._sensor_config[SENSOR_STATE_IS_TIME_BASED] - @callback def _update_from_data(self): """Get the latest state of the sensor.""" self._cancel_any_pending_updates() - self._attr_is_on = self._state_provider(self._data, self._detail) + self._attr_is_on = self.entity_description.state_provider( + self._data, self._detail + ) - if self._is_time_based: + if self.entity_description.is_time_based: self._attr_available = _retrieve_online_state(self._data, self._detail) self._schedule_update_to_recheck_turn_off_sensor() else: diff --git a/homeassistant/components/august/sensor.py b/homeassistant/components/august/sensor.py index b6d93d3b3b1..e78ae520034 100644 --- a/homeassistant/components/august/sensor.py +++ b/homeassistant/components/august/sensor.py @@ -1,9 +1,20 @@ """Support for August sensors.""" +from __future__ import annotations + +from dataclasses import dataclass import logging +from typing import Callable, Generic, TypeVar from yalexs.activity import ActivityType +from yalexs.keypad import KeypadDetail +from yalexs.lock import LockDetail -from homeassistant.components.sensor import DEVICE_CLASS_BATTERY, SensorEntity +from homeassistant.components.august import AugustData +from homeassistant.components.sensor import ( + DEVICE_CLASS_BATTERY, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ATTR_ENTITY_PICTURE, PERCENTAGE, STATE_UNAVAILABLE from homeassistant.core import callback from homeassistant.helpers.entity_registry import async_get_registry @@ -26,20 +37,44 @@ from .entity import AugustEntityMixin _LOGGER = logging.getLogger(__name__) -def _retrieve_device_battery_state(detail): +def _retrieve_device_battery_state(detail: LockDetail) -> int: """Get the latest state of the sensor.""" return detail.battery_level -def _retrieve_linked_keypad_battery_state(detail): +def _retrieve_linked_keypad_battery_state(detail: KeypadDetail) -> int | None: """Get the latest state of the sensor.""" return detail.battery_percentage -SENSOR_TYPES_BATTERY = { - "device_battery": {"state_provider": _retrieve_device_battery_state}, - "linked_keypad_battery": {"state_provider": _retrieve_linked_keypad_battery_state}, -} +T = TypeVar("T", LockDetail, KeypadDetail) + + +@dataclass +class AugustRequiredKeysMixin(Generic[T]): + """Mixin for required keys.""" + + state_provider: Callable[[T], int | None] + + +@dataclass +class AugustSensorEntityDescription( + SensorEntityDescription, AugustRequiredKeysMixin[T] +): + """Describes August sensor entity.""" + + +SENSOR_TYPE_DEVICE_BATTERY = AugustSensorEntityDescription[LockDetail]( + key="device_battery", + name="Battery", + state_provider=_retrieve_device_battery_state, +) + +SENSOR_TYPE_KEYPAD_BATTERY = AugustSensorEntityDescription[KeypadDetail]( + key="linked_keypad_battery", + name="Battery", + state_provider=_retrieve_linked_keypad_battery_state, +) async def async_setup_entry(hass, config_entry, async_add_entities): @@ -60,9 +95,8 @@ async def async_setup_entry(hass, config_entry, async_add_entities): operation_sensors.append(device) for device in batteries["device_battery"]: - state_provider = SENSOR_TYPES_BATTERY["device_battery"]["state_provider"] detail = data.get_device_detail(device.device_id) - if detail is None or state_provider(detail) is None: + if detail is None or SENSOR_TYPE_DEVICE_BATTERY.state_provider(detail) is None: _LOGGER.debug( "Not adding battery sensor for %s because it is not present", device.device_name, @@ -72,7 +106,11 @@ async def async_setup_entry(hass, config_entry, async_add_entities): "Adding battery sensor for %s", device.device_name, ) - entities.append(AugustBatterySensor(data, "device_battery", device, device)) + entities.append( + AugustBatterySensor[LockDetail]( + data, device, device, SENSOR_TYPE_DEVICE_BATTERY + ) + ) for device in batteries["linked_keypad_battery"]: detail = data.get_device_detail(device.device_id) @@ -87,8 +125,8 @@ async def async_setup_entry(hass, config_entry, async_add_entities): "Adding keypad battery sensor for %s", device.device_name, ) - keypad_battery_sensor = AugustBatterySensor( - data, "linked_keypad_battery", detail.keypad, device + keypad_battery_sensor = AugustBatterySensor[KeypadDetail]( + data, detail.keypad, device, SENSOR_TYPE_KEYPAD_BATTERY ) entities.append(keypad_battery_sensor) migrate_unique_id_devices.append(keypad_battery_sensor) @@ -204,29 +242,35 @@ class AugustOperatorSensor(AugustEntityMixin, RestoreEntity, SensorEntity): return f"{self._device_id}_lock_operator" -class AugustBatterySensor(AugustEntityMixin, SensorEntity): +class AugustBatterySensor(AugustEntityMixin, SensorEntity, Generic[T]): """Representation of an August sensor.""" + entity_description: AugustSensorEntityDescription[T] _attr_device_class = DEVICE_CLASS_BATTERY _attr_native_unit_of_measurement = PERCENTAGE - def __init__(self, data, sensor_type, device, old_device): + def __init__( + self, + data: AugustData, + device, + old_device, + description: AugustSensorEntityDescription[T], + ): """Initialize the sensor.""" super().__init__(data, device) - self._sensor_type = sensor_type + self.entity_description = description self._old_device = old_device - self._attr_name = f"{device.device_name} Battery" - self._attr_unique_id = f"{self._device_id}_{sensor_type}" + self._attr_name = f"{device.device_name} {description.name}" + self._attr_unique_id = f"{self._device_id}_{description.key}" self._update_from_data() @callback def _update_from_data(self): """Get the latest state of the sensor.""" - state_provider = SENSOR_TYPES_BATTERY[self._sensor_type]["state_provider"] - self._attr_native_value = state_provider(self._detail) + self._attr_native_value = self.entity_description.state_provider(self._detail) self._attr_available = self._attr_native_value is not None @property def old_unique_id(self) -> str: """Get the old unique id of the device sensor.""" - return f"{self._old_device.device_id}_{self._sensor_type}" + return f"{self._old_device.device_id}_{self.entity_description.key}" From 7af67d34cfe96a1d4ed02f3cbd26f5e197f3703b Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 19 Sep 2021 01:31:35 +0200 Subject: [PATCH 475/843] Use assignment expressions 01 (#56394) --- homeassistant/__main__.py | 6 +-- homeassistant/auth/__init__.py | 9 ++-- homeassistant/auth/auth_store.py | 12 ++--- homeassistant/auth/mfa_modules/notify.py | 10 ++-- homeassistant/auth/mfa_modules/totp.py | 7 +-- homeassistant/auth/permissions/__init__.py | 4 +- homeassistant/auth/permissions/util.py | 3 +- homeassistant/auth/providers/__init__.py | 4 +- homeassistant/auth/providers/homeassistant.py | 8 +--- homeassistant/bootstrap.py | 18 ++----- homeassistant/config.py | 12 ++--- homeassistant/config_entries.py | 30 ++++-------- homeassistant/core.py | 16 ++----- homeassistant/data_entry_flow.py | 8 +--- homeassistant/helpers/__init__.py | 4 +- homeassistant/helpers/entity.py | 48 +++++++------------ homeassistant/helpers/event.py | 25 ++++------ homeassistant/setup.py | 4 +- homeassistant/util/dt.py | 9 ++-- homeassistant/util/percentage.py | 3 +- homeassistant/util/yaml/loader.py | 4 +- 21 files changed, 73 insertions(+), 171 deletions(-) diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index 177c3a10853..8f12028b437 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -132,16 +132,14 @@ def get_arguments() -> argparse.Namespace: def daemonize() -> None: """Move current process to daemon process.""" # Create first fork - pid = os.fork() - if pid > 0: + if os.fork() > 0: sys.exit(0) # Decouple fork os.setsid() # Create second fork - pid = os.fork() - if pid > 0: + if os.fork() > 0: sys.exit(0) # redirect standard file descriptors to devnull diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index 717285d7b51..c528aff221f 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -341,8 +341,7 @@ class AuthManager: "System generated users cannot enable multi-factor auth module." ) - module = self.get_auth_mfa_module(mfa_module_id) - if module is None: + if (module := self.get_auth_mfa_module(mfa_module_id)) is None: raise ValueError(f"Unable find multi-factor auth module: {mfa_module_id}") await module.async_setup_user(user.id, data) @@ -356,8 +355,7 @@ class AuthManager: "System generated users cannot disable multi-factor auth module." ) - module = self.get_auth_mfa_module(mfa_module_id) - if module is None: + if (module := self.get_auth_mfa_module(mfa_module_id)) is None: raise ValueError(f"Unable find multi-factor auth module: {mfa_module_id}") await module.async_depose_user(user.id) @@ -498,8 +496,7 @@ class AuthManager: Will raise InvalidAuthError on errors. """ - provider = self._async_resolve_provider(refresh_token) - if provider: + if provider := self._async_resolve_provider(refresh_token): provider.async_validate_refresh_token(refresh_token, remote_ip) async def async_validate_access_token( diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py index 63cbeb1bf7e..c935a0da7d0 100644 --- a/homeassistant/auth/auth_store.py +++ b/homeassistant/auth/auth_store.py @@ -96,8 +96,7 @@ class AuthStore: groups = [] for group_id in group_ids or []: - group = self._groups.get(group_id) - if group is None: + if (group := self._groups.get(group_id)) is None: raise ValueError(f"Invalid group specified {group_id}") groups.append(group) @@ -160,8 +159,7 @@ class AuthStore: if group_ids is not None: groups = [] for grid in group_ids: - group = self._groups.get(grid) - if group is None: + if (group := self._groups.get(grid)) is None: raise ValueError("Invalid group specified.") groups.append(group) @@ -446,16 +444,14 @@ class AuthStore: ) continue - token_type = rt_dict.get("token_type") - if token_type is None: + if (token_type := rt_dict.get("token_type")) is None: if rt_dict["client_id"] is None: token_type = models.TOKEN_TYPE_SYSTEM else: token_type = models.TOKEN_TYPE_NORMAL # old refresh_token don't have last_used_at (pre-0.78) - last_used_at_str = rt_dict.get("last_used_at") - if last_used_at_str: + if last_used_at_str := rt_dict.get("last_used_at"): last_used_at = dt_util.parse_datetime(last_used_at_str) else: last_used_at = None diff --git a/homeassistant/auth/mfa_modules/notify.py b/homeassistant/auth/mfa_modules/notify.py index 31210e2d39a..7d5cf0b0641 100644 --- a/homeassistant/auth/mfa_modules/notify.py +++ b/homeassistant/auth/mfa_modules/notify.py @@ -118,9 +118,7 @@ class NotifyAuthModule(MultiFactorAuthModule): if self._user_settings is not None: return - data = await self._user_store.async_load() - - if data is None: + if (data := await self._user_store.async_load()) is None: data = {STORAGE_USERS: {}} self._user_settings = { @@ -207,8 +205,7 @@ class NotifyAuthModule(MultiFactorAuthModule): await self._async_load() assert self._user_settings is not None - notify_setting = self._user_settings.get(user_id) - if notify_setting is None: + if (notify_setting := self._user_settings.get(user_id)) is None: return False # user_input has been validate in caller @@ -225,8 +222,7 @@ class NotifyAuthModule(MultiFactorAuthModule): await self._async_load() assert self._user_settings is not None - notify_setting = self._user_settings.get(user_id) - if notify_setting is None: + if (notify_setting := self._user_settings.get(user_id)) is None: raise ValueError("Cannot find user_id") def generate_secret_and_one_time_password() -> str: diff --git a/homeassistant/auth/mfa_modules/totp.py b/homeassistant/auth/mfa_modules/totp.py index 20030ae166b..5ff2c01c755 100644 --- a/homeassistant/auth/mfa_modules/totp.py +++ b/homeassistant/auth/mfa_modules/totp.py @@ -92,9 +92,7 @@ class TotpAuthModule(MultiFactorAuthModule): if self._users is not None: return - data = await self._user_store.async_load() - - if data is None: + if (data := await self._user_store.async_load()) is None: data = {STORAGE_USERS: {}} self._users = data.get(STORAGE_USERS, {}) @@ -163,8 +161,7 @@ class TotpAuthModule(MultiFactorAuthModule): """Validate two factor authentication code.""" import pyotp # pylint: disable=import-outside-toplevel - ota_secret = self._users.get(user_id) # type: ignore - if ota_secret is None: + if (ota_secret := self._users.get(user_id)) is None: # type: ignore # even we cannot find user, we still do verify # to make timing the same as if user was found. pyotp.TOTP(DUMMY_SECRET).verify(code, valid_window=1) diff --git a/homeassistant/auth/permissions/__init__.py b/homeassistant/auth/permissions/__init__.py index 28ff3f638d4..898a8334234 100644 --- a/homeassistant/auth/permissions/__init__.py +++ b/homeassistant/auth/permissions/__init__.py @@ -33,9 +33,7 @@ class AbstractPermissions: def check_entity(self, entity_id: str, key: str) -> bool: """Check if we can access entity.""" - entity_func = self._cached_entity_func - - if entity_func is None: + if (entity_func := self._cached_entity_func) is None: entity_func = self._cached_entity_func = self._entity_func() return entity_func(entity_id, key) diff --git a/homeassistant/auth/permissions/util.py b/homeassistant/auth/permissions/util.py index e95e0080b50..28823a9fd1b 100644 --- a/homeassistant/auth/permissions/util.py +++ b/homeassistant/auth/permissions/util.py @@ -72,8 +72,7 @@ def compile_policy( def apply_policy_funcs(object_id: str, key: str) -> bool: """Apply several policy functions.""" for func in funcs: - result = func(object_id, key) - if result is not None: + if (result := func(object_id, key)) is not None: return result return False diff --git a/homeassistant/auth/providers/__init__.py b/homeassistant/auth/providers/__init__.py index 4faa277a081..dc5f8f2580c 100644 --- a/homeassistant/auth/providers/__init__.py +++ b/homeassistant/auth/providers/__init__.py @@ -169,9 +169,7 @@ async def load_auth_provider_module( if hass.config.skip_pip or not hasattr(module, "REQUIREMENTS"): return module - processed = hass.data.get(DATA_REQS) - - if processed is None: + if (processed := hass.data.get(DATA_REQS)) is None: processed = hass.data[DATA_REQS] = set() elif provider in processed: return module diff --git a/homeassistant/auth/providers/homeassistant.py b/homeassistant/auth/providers/homeassistant.py index b08c59bf3aa..6ac9fac03e5 100644 --- a/homeassistant/auth/providers/homeassistant.py +++ b/homeassistant/auth/providers/homeassistant.py @@ -82,9 +82,7 @@ class Data: async def async_load(self) -> None: """Load stored data.""" - data = await self._store.async_load() - - if data is None: + if (data := await self._store.async_load()) is None: data = {"users": []} seen: set[str] = set() @@ -93,9 +91,7 @@ class Data: username = user["username"] # check if we have duplicates - folded = username.casefold() - - if folded in seen: + if (folded := username.casefold()) in seen: self.is_legacy = True logging.getLogger(__name__).warning( diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 3877a6bf6e1..f2b3d5e6ec4 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -109,9 +109,8 @@ async def async_setup_hass( config_dict = None basic_setup_success = False - safe_mode = runtime_config.safe_mode - if not safe_mode: + if not (safe_mode := runtime_config.safe_mode): await hass.async_add_executor_job(conf_util.process_ha_config_upgrade, hass) try: @@ -368,8 +367,7 @@ async def async_mount_local_lib_path(config_dir: str) -> str: This function is a coroutine. """ deps_dir = os.path.join(config_dir, "deps") - lib_dir = await async_get_user_site(deps_dir) - if lib_dir not in sys.path: + if (lib_dir := await async_get_user_site(deps_dir)) not in sys.path: sys.path.insert(0, lib_dir) return deps_dir @@ -494,17 +492,13 @@ async def _async_set_up_integrations( _LOGGER.info("Domains to be set up: %s", domains_to_setup) - logging_domains = domains_to_setup & LOGGING_INTEGRATIONS - # Load logging as soon as possible - if logging_domains: + if logging_domains := domains_to_setup & LOGGING_INTEGRATIONS: _LOGGER.info("Setting up logging: %s", logging_domains) await async_setup_multi_components(hass, logging_domains, config) # Start up debuggers. Start these first in case they want to wait. - debuggers = domains_to_setup & DEBUGGER_INTEGRATIONS - - if debuggers: + if debuggers := domains_to_setup & DEBUGGER_INTEGRATIONS: _LOGGER.debug("Setting up debuggers: %s", debuggers) await async_setup_multi_components(hass, debuggers, config) @@ -524,9 +518,7 @@ async def _async_set_up_integrations( stage_1_domains.add(domain) - dep_itg = integration_cache.get(domain) - - if dep_itg is None: + if (dep_itg := integration_cache.get(domain)) is None: continue deps_promotion.update(dep_itg.all_dependencies) diff --git a/homeassistant/config.py b/homeassistant/config.py index 754420dbcce..a51fe711ea5 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -512,9 +512,7 @@ async def async_process_ha_core_config(hass: HomeAssistant, config: dict) -> Non # Only load auth during startup. if not hasattr(hass, "auth"): - auth_conf = config.get(CONF_AUTH_PROVIDERS) - - if auth_conf is None: + if (auth_conf := config.get(CONF_AUTH_PROVIDERS)) is None: auth_conf = [{"type": "homeassistant"}] mfa_conf = config.get( @@ -598,9 +596,7 @@ async def async_process_ha_core_config(hass: HomeAssistant, config: dict) -> Non cust_glob = OrderedDict(config[CONF_CUSTOMIZE_GLOB]) for name, pkg in config[CONF_PACKAGES].items(): - pkg_cust = pkg.get(CONF_CORE) - - if pkg_cust is None: + if (pkg_cust := pkg.get(CONF_CORE)) is None: continue try: @@ -957,9 +953,7 @@ def async_notify_setup_error( # pylint: disable=import-outside-toplevel from homeassistant.components import persistent_notification - errors = hass.data.get(DATA_PERSISTENT_ERRORS) - - if errors is None: + if (errors := hass.data.get(DATA_PERSISTENT_ERRORS)) is None: errors = hass.data[DATA_PERSISTENT_ERRORS] = {} errors[component] = errors.get(component) or display_link diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index bcc0289ea98..03d7df740ba 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -492,8 +492,7 @@ class ConfigEntry: Returns True if config entry is up-to-date or has been migrated. """ - handler = HANDLERS.get(self.domain) - if handler is None: + if (handler := HANDLERS.get(self.domain)) is None: _LOGGER.error( "Flow handler not found for entry %s for %s", self.title, self.domain ) @@ -716,9 +715,7 @@ class ConfigEntriesFlowManager(data_entry_flow.FlowManager): ) raise data_entry_flow.UnknownHandler - handler = HANDLERS.get(handler_key) - - if handler is None: + if (handler := HANDLERS.get(handler_key)) is None: raise data_entry_flow.UnknownHandler if not context or "source" not in context: @@ -814,9 +811,7 @@ class ConfigEntries: async def async_remove(self, entry_id: str) -> dict[str, Any]: """Remove an entry.""" - entry = self.async_get_entry(entry_id) - - if entry is None: + if (entry := self.async_get_entry(entry_id)) is None: raise UnknownEntry if not entry.state.recoverable: @@ -933,9 +928,7 @@ class ConfigEntries: Return True if entry has been successfully loaded. """ - entry = self.async_get_entry(entry_id) - - if entry is None: + if (entry := self.async_get_entry(entry_id)) is None: raise UnknownEntry if entry.state is not ConfigEntryState.NOT_LOADED: @@ -957,9 +950,7 @@ class ConfigEntries: async def async_unload(self, entry_id: str) -> bool: """Unload a config entry.""" - entry = self.async_get_entry(entry_id) - - if entry is None: + if (entry := self.async_get_entry(entry_id)) is None: raise UnknownEntry if not entry.state.recoverable: @@ -972,9 +963,7 @@ class ConfigEntries: If an entry was not loaded, will just load. """ - entry = self.async_get_entry(entry_id) - - if entry is None: + if (entry := self.async_get_entry(entry_id)) is None: raise UnknownEntry unload_result = await self.async_unload(entry_id) @@ -991,9 +980,7 @@ class ConfigEntries: If disabled_by is changed, the config entry will be reloaded. """ - entry = self.async_get_entry(entry_id) - - if entry is None: + if (entry := self.async_get_entry(entry_id)) is None: raise UnknownEntry if entry.disabled_by == disabled_by: @@ -1066,8 +1053,7 @@ class ConfigEntries: return False for listener_ref in entry.update_listeners: - listener = listener_ref() - if listener is not None: + if (listener := listener_ref()) is not None: self.hass.async_create_task(listener(self.hass, entry)) self._async_schedule_save() diff --git a/homeassistant/core.py b/homeassistant/core.py index 922b6603f1b..1a795c30b0f 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -971,8 +971,7 @@ class State: if isinstance(last_updated, str): last_updated = dt_util.parse_datetime(last_updated) - context = json_dict.get("context") - if context: + if context := json_dict.get("context"): context = Context(id=context.get("id"), user_id=context.get("user_id")) return cls( @@ -1199,8 +1198,7 @@ class StateMachine: entity_id = entity_id.lower() new_state = str(new_state) attributes = attributes or {} - old_state = self._states.get(entity_id) - if old_state is None: + if (old_state := self._states.get(entity_id)) is None: same_state = False same_attr = False last_changed = None @@ -1658,9 +1656,7 @@ class Config: def set_time_zone(self, time_zone_str: str) -> None: """Help to set the time zone.""" - time_zone = dt_util.get_time_zone(time_zone_str) - - if time_zone: + if time_zone := dt_util.get_time_zone(time_zone_str): self.time_zone = time_zone_str dt_util.set_default_time_zone(time_zone) else: @@ -1717,9 +1713,8 @@ class Config: store = self.hass.helpers.storage.Store( CORE_STORAGE_VERSION, CORE_STORAGE_KEY, private=True ) - data = await store.async_load() - if not data: + if not (data := await store.async_load()): return # In 2021.9 we fixed validation to disallow a path (because that's never correct) @@ -1792,8 +1787,7 @@ def _async_create_timer(hass: HomeAssistant) -> None: ) # If we are more than a second late, a tick was missed - late = monotonic() - target - if late > 1: + if (late := monotonic() - target) > 1: hass.bus.async_fire( EVENT_TIMER_OUT_OF_SYNC, {ATTR_SECONDS: late}, diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 786cfe7e286..63d5566db40 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -93,9 +93,7 @@ class FlowManager(abc.ABC): async def async_wait_init_flow_finish(self, handler: str) -> None: """Wait till all flows in progress are initialized.""" - current = self._initializing.get(handler) - - if not current: + if not (current := self._initializing.get(handler)): return await asyncio.wait(current) @@ -189,9 +187,7 @@ class FlowManager(abc.ABC): self, flow_id: str, user_input: dict | None = None ) -> FlowResult: """Continue a configuration flow.""" - flow = self._progress.get(flow_id) - - if flow is None: + if (flow := self._progress.get(flow_id)) is None: raise UnknownFlow cur_step = flow.cur_step diff --git a/homeassistant/helpers/__init__.py b/homeassistant/helpers/__init__.py index a0642e8ead2..93383f49b1e 100644 --- a/homeassistant/helpers/__init__.py +++ b/homeassistant/helpers/__init__.py @@ -18,9 +18,7 @@ def config_per_platform(config: ConfigType, domain: str) -> Iterable[tuple[Any, Async friendly. """ for config_key in extract_domain_configs(config, domain): - platform_config = config[config_key] - - if not platform_config: + if not (platform_config := config[config_key]): continue if not isinstance(platform_config, list): diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 847dc062764..be204ebaa6f 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -99,13 +99,11 @@ def get_capability(hass: HomeAssistant, entity_id: str, capability: str) -> Any First try the statemachine, then entity registry. """ - state = hass.states.get(entity_id) - if state: + if state := hass.states.get(entity_id): return state.attributes.get(capability) entity_registry = er.async_get(hass) - entry = entity_registry.async_get(entity_id) - if not entry: + if not (entry := entity_registry.async_get(entity_id)): raise HomeAssistantError(f"Unknown entity {entity_id}") return entry.capabilities.get(capability) if entry.capabilities else None @@ -116,13 +114,11 @@ def get_device_class(hass: HomeAssistant, entity_id: str) -> str | None: First try the statemachine, then entity registry. """ - state = hass.states.get(entity_id) - if state: + if state := hass.states.get(entity_id): return state.attributes.get(ATTR_DEVICE_CLASS) entity_registry = er.async_get(hass) - entry = entity_registry.async_get(entity_id) - if not entry: + if not (entry := entity_registry.async_get(entity_id)): raise HomeAssistantError(f"Unknown entity {entity_id}") return entry.device_class @@ -133,13 +129,11 @@ def get_supported_features(hass: HomeAssistant, entity_id: str) -> int: First try the statemachine, then entity registry. """ - state = hass.states.get(entity_id) - if state: + if state := hass.states.get(entity_id): return state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) entity_registry = er.async_get(hass) - entry = entity_registry.async_get(entity_id) - if not entry: + if not (entry := entity_registry.async_get(entity_id)): raise HomeAssistantError(f"Unknown entity {entity_id}") return entry.supported_features or 0 @@ -150,13 +144,11 @@ def get_unit_of_measurement(hass: HomeAssistant, entity_id: str) -> str | None: First try the statemachine, then entity registry. """ - state = hass.states.get(entity_id) - if state: + if state := hass.states.get(entity_id): return state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) entity_registry = er.async_get(hass) - entry = entity_registry.async_get(entity_id) - if not entry: + if not (entry := entity_registry.async_get(entity_id)): raise HomeAssistantError(f"Unknown entity {entity_id}") return entry.unit_of_measurement @@ -467,8 +459,7 @@ class Entity(ABC): """Convert state to string.""" if not self.available: return STATE_UNAVAILABLE - state = self.state - if state is None: + if (state := self.state) is None: return STATE_UNKNOWN if isinstance(state, float): # If the entity's state is a float, limit precision according to machine @@ -511,28 +502,22 @@ class Entity(ABC): entry = self.registry_entry # pylint: disable=consider-using-ternary - name = (entry and entry.name) or self.name - if name is not None: + if (name := (entry and entry.name) or self.name) is not None: attr[ATTR_FRIENDLY_NAME] = name - icon = (entry and entry.icon) or self.icon - if icon is not None: + if (icon := (entry and entry.icon) or self.icon) is not None: attr[ATTR_ICON] = icon - entity_picture = self.entity_picture - if entity_picture is not None: + if (entity_picture := self.entity_picture) is not None: attr[ATTR_ENTITY_PICTURE] = entity_picture - assumed_state = self.assumed_state - if assumed_state: + if assumed_state := self.assumed_state: attr[ATTR_ASSUMED_STATE] = assumed_state - supported_features = self.supported_features - if supported_features is not None: + if (supported_features := self.supported_features) is not None: attr[ATTR_SUPPORTED_FEATURES] = supported_features - device_class = self.device_class - if device_class is not None: + if (device_class := self.device_class) is not None: attr[ATTR_DEVICE_CLASS] = str(device_class) end = timer() @@ -636,8 +621,7 @@ class Entity(ABC): finished, _ = await asyncio.wait([task], timeout=SLOW_UPDATE_WARNING) for done in finished: - exc = done.exception() - if exc: + if exc := done.exception(): raise exc return diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 5d5f71d2fd5..b37a79a83ec 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -175,16 +175,14 @@ def async_track_state_change( def state_change_filter(event: Event) -> bool: """Handle specific state changes.""" if from_state is not None: - old_state = event.data.get("old_state") - if old_state is not None: + if (old_state := event.data.get("old_state")) is not None: old_state = old_state.state if not match_from_state(old_state): return False if to_state is not None: - new_state = event.data.get("new_state") - if new_state is not None: + if (new_state := event.data.get("new_state")) is not None: new_state = new_state.state if not match_to_state(new_state): @@ -246,8 +244,7 @@ def async_track_state_change_event( care about the state change events so we can do a fast dict lookup to route events. """ - entity_ids = _async_string_to_lower_list(entity_ids) - if not entity_ids: + if not (entity_ids := _async_string_to_lower_list(entity_ids)): return _remove_empty_listener entity_callbacks = hass.data.setdefault(TRACK_STATE_CHANGE_CALLBACKS, {}) @@ -336,8 +333,7 @@ def async_track_entity_registry_updated_event( Similar to async_track_state_change_event. """ - entity_ids = _async_string_to_lower_list(entity_ids) - if not entity_ids: + if not (entity_ids := _async_string_to_lower_list(entity_ids)): return _remove_empty_listener entity_callbacks = hass.data.setdefault(TRACK_ENTITY_REGISTRY_UPDATED_CALLBACKS, {}) @@ -419,8 +415,7 @@ def async_track_state_added_domain( action: Callable[[Event], Any], ) -> Callable[[], None]: """Track state change events when an entity is added to domains.""" - domains = _async_string_to_lower_list(domains) - if not domains: + if not (domains := _async_string_to_lower_list(domains)): return _remove_empty_listener domain_callbacks = hass.data.setdefault(TRACK_STATE_ADDED_DOMAIN_CALLBACKS, {}) @@ -472,8 +467,7 @@ def async_track_state_removed_domain( action: Callable[[Event], Any], ) -> Callable[[], None]: """Track state change events when an entity is removed from domains.""" - domains = _async_string_to_lower_list(domains) - if not domains: + if not (domains := _async_string_to_lower_list(domains)): return _remove_empty_listener domain_callbacks = hass.data.setdefault(TRACK_STATE_REMOVED_DOMAIN_CALLBACKS, {}) @@ -1185,8 +1179,7 @@ def async_track_point_in_utc_time( # as measured by utcnow(). That is bad when callbacks have assumptions # about the current time. Thus, we rearm the timer for the remaining # time. - delta = (utc_point_in_time - now).total_seconds() - if delta > 0: + if (delta := (utc_point_in_time - now).total_seconds()) > 0: _LOGGER.debug("Called %f seconds too early, rearming", delta) cancel_callback = hass.loop.call_later(delta, run_action, job) @@ -1520,11 +1513,9 @@ def _rate_limit_for_event( event: Event, info: RenderInfo, track_template_: TrackTemplate ) -> timedelta | None: """Determine the rate limit for an event.""" - entity_id = event.data.get(ATTR_ENTITY_ID) - # Specifically referenced entities are excluded # from the rate limit - if entity_id in info.entities: + if event.data.get(ATTR_ENTITY_ID) in info.entities: return None if track_template_.rate_limit is not None: diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 95bb29c4b9d..1c372394c43 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -366,9 +366,7 @@ async def async_process_deps_reqs( Module is a Python module of either a component or platform. """ - processed = hass.data.get(DATA_DEPS_REQS) - - if processed is None: + if (processed := hass.data.get(DATA_DEPS_REQS)) is None: processed = hass.data[DATA_DEPS_REQS] = set() elif integration.domain in processed: return diff --git a/homeassistant/util/dt.py b/homeassistant/util/dt.py index 93737ce0c3d..e2dd92a8b95 100644 --- a/homeassistant/util/dt.py +++ b/homeassistant/util/dt.py @@ -132,8 +132,7 @@ def parse_datetime(dt_str: str) -> dt.datetime | None: with suppress(ValueError, IndexError): return ciso8601.parse_datetime(dt_str) - match = DATETIME_RE.match(dt_str) - if not match: + if not (match := DATETIME_RE.match(dt_str)): return None kws: dict[str, Any] = match.groupdict() if kws["microsecond"]: @@ -269,16 +268,14 @@ def find_next_time_expression_time( Return None if no such value exists. """ - left = bisect.bisect_left(arr, cmp) - if left == len(arr): + if (left := bisect.bisect_left(arr, cmp)) == len(arr): return None return arr[left] result = now.replace(microsecond=0) # Match next second - next_second = _lower_bound(seconds, result.second) - if next_second is None: + if (next_second := _lower_bound(seconds, result.second)) is None: # No second to match in this minute. Roll-over to next minute. next_second = seconds[0] result += dt.timedelta(minutes=1) diff --git a/homeassistant/util/percentage.py b/homeassistant/util/percentage.py index 260c4f374fe..d5646b44c0f 100644 --- a/homeassistant/util/percentage.py +++ b/homeassistant/util/percentage.py @@ -43,8 +43,7 @@ def percentage_to_ordered_list_item(ordered_list: list[T], percentage: int) -> T 51-75: high 76-100: very_high """ - list_len = len(ordered_list) - if not list_len: + if not (list_len := len(ordered_list)): raise ValueError("The ordered list is empty") for offset, speed in enumerate(ordered_list): diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index 58edac6d280..e6ac5fd364a 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -60,9 +60,7 @@ class Secrets: def _load_secret_yaml(self, secret_dir: Path) -> dict[str, str]: """Load the secrets yaml from path.""" - secret_path = secret_dir / SECRET_YAML - - if secret_path in self._cache: + if (secret_path := secret_dir / SECRET_YAML) in self._cache: return self._cache[secret_path] _LOGGER.debug("Loading %s", secret_path) From 53d4c0ce2d374b5e97bbdc37742656c27adf8eea Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Sun, 19 Sep 2021 00:34:28 +0100 Subject: [PATCH 476/843] Increase Lyric update interval to 300 seconds (#56393) --- homeassistant/components/lyric/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/lyric/__init__.py b/homeassistant/components/lyric/__init__.py index 07c5bfeaf89..d253c1b0349 100644 --- a/homeassistant/components/lyric/__init__.py +++ b/homeassistant/components/lyric/__init__.py @@ -109,7 +109,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: name="lyric_coordinator", update_method=async_update_data, # Polling interval. Will only be polled if there are subscribers. - update_interval=timedelta(seconds=120), + update_interval=timedelta(seconds=300), ) hass.data[DOMAIN][entry.entry_id] = coordinator From a57d7717a88d7a53ff8c8e45fabc29bd3e2721e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Sun, 19 Sep 2021 08:38:43 +0200 Subject: [PATCH 477/843] Improve Surepetcare set_pet_location service (#56401) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Surepetcare, improve set_pet_location Signed-off-by: Daniel Hjelseth Høyer * Surepetcare, improve set_pet_location Signed-off-by: Daniel Hjelseth Høyer * Surepetcare, improve set_pet_location Signed-off-by: Daniel Hjelseth Høyer * Surepetcare, improve set_pet_location Signed-off-by: Daniel Hjelseth Høyer * Surepetcare, improve set_pet_location Signed-off-by: Daniel Hjelseth Høyer --- .../components/surepetcare/__init__.py | 21 +++++++------------ 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/surepetcare/__init__.py b/homeassistant/components/surepetcare/__init__.py index 1efcaa6b5c1..ece42d0f410 100644 --- a/homeassistant/components/surepetcare/__init__.py +++ b/homeassistant/components/surepetcare/__init__.py @@ -131,7 +131,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: set_pet_location_schema = vol.Schema( { - vol.Optional(ATTR_PET_NAME): vol.In(coordinator.get_pets().values()), + vol.Required(ATTR_PET_NAME): vol.In(coordinator.get_pets().keys()), vol.Required(ATTR_LOCATION): vol.In( [ Location.INSIDE.name.title(), @@ -198,23 +198,18 @@ class SurePetcareDataCoordinator(DataUpdateCoordinator): await self.lock_states[state](flap_id) await self.async_request_refresh() - def get_pets(self) -> dict[int, str]: + def get_pets(self) -> dict[str, int]: """Get pets.""" - names = {} + pets = {} for surepy_entity in self.data.values(): if surepy_entity.type == EntityType.PET and surepy_entity.name: - names[surepy_entity.id] = surepy_entity.name - return names + pets[surepy_entity.name] = surepy_entity.id + return pets async def handle_set_pet_location(self, call: ServiceCall) -> None: """Call when setting the pet location.""" pet_name = call.data[ATTR_PET_NAME] location = call.data[ATTR_LOCATION] - for device_id, device_name in self.get_pets().items(): - if pet_name == device_name: - await self.surepy.sac.set_pet_location( - device_id, Location[location.upper()] - ) - await self.async_request_refresh() - return - _LOGGER.error("Unknown pet %s", pet_name) + device_id = self.get_pets()[pet_name] + await self.surepy.sac.set_pet_location(device_id, Location[location.upper()]) + await self.async_request_refresh() From 75c029c56ba3cc016309ec04c5578295100ac614 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 18 Sep 2021 21:52:09 -1000 Subject: [PATCH 478/843] Bump zeroconf to 0.36.5 (#56413) --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 172acde2daf..713b992b573 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -2,7 +2,7 @@ "domain": "zeroconf", "name": "Zero-configuration networking (zeroconf)", "documentation": "https://www.home-assistant.io/integrations/zeroconf", - "requirements": ["zeroconf==0.36.4"], + "requirements": ["zeroconf==0.36.5"], "dependencies": ["network", "api"], "codeowners": ["@bdraco"], "quality_scale": "internal", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 8f23aef2eb0..9ce8a0ada4a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ sqlalchemy==1.4.23 voluptuous-serialize==2.4.0 voluptuous==0.12.1 yarl==1.6.3 -zeroconf==0.36.4 +zeroconf==0.36.5 pycryptodome>=3.6.6 diff --git a/requirements_all.txt b/requirements_all.txt index 8d4cce4c6b0..bc0347b4bb0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2463,7 +2463,7 @@ youtube_dl==2021.04.26 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.36.4 +zeroconf==0.36.5 # homeassistant.components.zha zha-quirks==0.0.61 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 07f07060019..5c841bb3486 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1395,7 +1395,7 @@ yeelight==0.7.4 youless-api==0.12 # homeassistant.components.zeroconf -zeroconf==0.36.4 +zeroconf==0.36.5 # homeassistant.components.zha zha-quirks==0.0.61 From 80a57f5118c56658e578dc8c978c01cef55d783e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sun, 19 Sep 2021 10:18:45 +0200 Subject: [PATCH 479/843] Prevent 3rd party lib from opening sockets in smhi tests (#56335) --- tests/components/smhi/test_init.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/components/smhi/test_init.py b/tests/components/smhi/test_init.py index ab937d266a4..2cf54ba7533 100644 --- a/tests/components/smhi/test_init.py +++ b/tests/components/smhi/test_init.py @@ -26,8 +26,12 @@ async def test_setup_entry( assert state -async def test_remove_entry(hass: HomeAssistant) -> None: +async def test_remove_entry( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, api_response: str +) -> None: """Test remove entry.""" + uri = APIURL_TEMPLATE.format(TEST_CONFIG["longitude"], TEST_CONFIG["latitude"]) + aioclient_mock.get(uri, text=api_response) entry = MockConfigEntry(domain=DOMAIN, data=TEST_CONFIG) entry.add_to_hass(hass) From ec5276370633d8f45f99302c562e18634687a7fe Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sun, 19 Sep 2021 10:24:27 +0200 Subject: [PATCH 480/843] Prevent 3rd party lib from opening sockets in samsungtv tests (#56334) --- tests/components/samsungtv/conftest.py | 7 ++++++ .../components/samsungtv/test_config_flow.py | 22 +++++++++++++------ tests/components/samsungtv/test_init.py | 6 +++-- .../components/samsungtv/test_media_player.py | 7 +++--- 4 files changed, 29 insertions(+), 13 deletions(-) diff --git a/tests/components/samsungtv/conftest.py b/tests/components/samsungtv/conftest.py index c3da2652a6d..05c51fdf591 100644 --- a/tests/components/samsungtv/conftest.py +++ b/tests/components/samsungtv/conftest.py @@ -112,3 +112,10 @@ def delay_fixture(): def mock_now(): """Fixture for dtutil.now.""" return dt_util.utcnow() + + +@pytest.fixture(name="no_mac_address") +def mac_address_fixture(): + """Patch getmac.get_mac_address.""" + with patch("getmac.get_mac_address", return_value=None) as mac: + yield mac diff --git a/tests/components/samsungtv/test_config_flow.py b/tests/components/samsungtv/test_config_flow.py index 64d0c95c084..d9c96982aa1 100644 --- a/tests/components/samsungtv/test_config_flow.py +++ b/tests/components/samsungtv/test_config_flow.py @@ -389,7 +389,9 @@ async def test_ssdp_legacy_not_supported(hass: HomeAssistant, remote: Mock): async def test_ssdp_websocket_success_populates_mac_address( - hass: HomeAssistant, remotews: Mock + hass: HomeAssistant, + remote: Mock, + remotews: Mock, ): """Test starting a flow from ssdp for a supported device populates the mac.""" result = await hass.config_entries.flow.async_init( @@ -441,7 +443,9 @@ async def test_ssdp_model_not_supported(hass: HomeAssistant, remote: Mock): assert result["reason"] == RESULT_NOT_SUPPORTED -async def test_ssdp_not_successful(hass: HomeAssistant, remote: Mock): +async def test_ssdp_not_successful( + hass: HomeAssistant, remote: Mock, no_mac_address: Mock +): """Test starting a flow from discovery but no device found.""" with patch( "homeassistant.components.samsungtv.bridge.Remote", @@ -469,7 +473,9 @@ async def test_ssdp_not_successful(hass: HomeAssistant, remote: Mock): assert result["reason"] == RESULT_CANNOT_CONNECT -async def test_ssdp_not_successful_2(hass: HomeAssistant, remote: Mock): +async def test_ssdp_not_successful_2( + hass: HomeAssistant, remote: Mock, no_mac_address: Mock +): """Test starting a flow from discovery but no device found.""" with patch( "homeassistant.components.samsungtv.bridge.Remote", @@ -564,7 +570,9 @@ async def test_import_legacy(hass: HomeAssistant, remote: Mock): assert entries[0].data[CONF_PORT] == LEGACY_PORT -async def test_import_legacy_without_name(hass: HomeAssistant, remote: Mock): +async def test_import_legacy_without_name( + hass: HomeAssistant, remote: Mock, no_mac_address: Mock +): """Test importing from yaml without a name.""" with patch( "homeassistant.components.samsungtv.config_flow.socket.gethostbyname", @@ -651,7 +659,7 @@ async def test_import_unknown_host(hass: HomeAssistant, remotews: Mock): assert result["reason"] == RESULT_UNKNOWN_HOST -async def test_dhcp(hass: HomeAssistant, remotews: Mock): +async def test_dhcp(hass: HomeAssistant, remote: Mock, remotews: Mock): """Test starting a flow from dhcp.""" # confirm to add the entry result = await hass.config_entries.flow.async_init( @@ -677,7 +685,7 @@ async def test_dhcp(hass: HomeAssistant, remotews: Mock): assert result["result"].unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" -async def test_zeroconf(hass: HomeAssistant, remotews: Mock): +async def test_zeroconf(hass: HomeAssistant, remote: Mock, remotews: Mock): """Test starting a flow from zeroconf.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -715,7 +723,7 @@ async def test_zeroconf_ignores_soundbar(hass: HomeAssistant, remotews_soundbar: async def test_zeroconf_no_device_info( - hass: HomeAssistant, remotews_no_device_info: Mock + hass: HomeAssistant, remote: Mock, remotews_no_device_info: Mock ): """Test starting a flow from zeroconf where device_info returns None.""" result = await hass.config_entries.flow.async_init( diff --git a/tests/components/samsungtv/test_init.py b/tests/components/samsungtv/test_init.py index c5c1519556d..1f6c13809cb 100644 --- a/tests/components/samsungtv/test_init.py +++ b/tests/components/samsungtv/test_init.py @@ -53,7 +53,7 @@ REMOTE_CALL = { } -async def test_setup(hass: HomeAssistant, remote: Mock): +async def test_setup(hass: HomeAssistant, remote: Mock, no_mac_address: Mock): """Test Samsung TV integration is setup.""" with patch("homeassistant.components.samsungtv.bridge.Remote") as remote, patch( "homeassistant.components.samsungtv.config_flow.socket.gethostbyname", @@ -129,7 +129,9 @@ async def test_setup_duplicate_config(hass: HomeAssistant, remote: Mock, caplog) assert "duplicate host entries found" in caplog.text -async def test_setup_duplicate_entries(hass: HomeAssistant, remote: Mock, caplog): +async def test_setup_duplicate_entries( + hass: HomeAssistant, remote: Mock, no_mac_address: Mock, caplog +): """Test duplicate setup of platform.""" await async_setup_component(hass, SAMSUNGTV_DOMAIN, MOCK_CONFIG) await hass.async_block_till_done() diff --git a/tests/components/samsungtv/test_media_player.py b/tests/components/samsungtv/test_media_player.py index de9183915a2..1d81769ad8b 100644 --- a/tests/components/samsungtv/test_media_player.py +++ b/tests/components/samsungtv/test_media_player.py @@ -127,10 +127,9 @@ def delay_fixture(): yield delay -@pytest.fixture -def mock_now(): - """Fixture for dtutil.now.""" - return dt_util.utcnow() +@pytest.fixture(autouse=True) +def mock_no_mac_address(no_mac_address): + """Fake mac address in all mediaplayer tests.""" async def setup_samsungtv(hass, config): From 88e42a540e3fcc6f99e3eeff643089e47b2c714a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Sep 2021 00:43:02 -1000 Subject: [PATCH 481/843] Remove leftover debug prints in tests (#56409) --- tests/components/homekit/test_util.py | 3 --- tests/components/zeroconf/test_init.py | 3 --- 2 files changed, 6 deletions(-) diff --git a/tests/components/homekit/test_util.py b/tests/components/homekit/test_util.py index 2c1deb3bd8e..94936e3e2c2 100644 --- a/tests/components/homekit/test_util.py +++ b/tests/components/homekit/test_util.py @@ -67,9 +67,6 @@ def _mock_socket(failure_attempts: int = 0) -> MagicMock: attempts = 0 def _simulate_bind(*_): - import pprint - - pprint.pprint("Calling bind") nonlocal attempts attempts += 1 if attempts <= failure_attempts: diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index d13bbc97547..b8ce28b6259 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -679,9 +679,6 @@ async def test_removed_ignored(hass, mock_async_zeroconf): await hass.async_block_till_done() assert len(mock_service_info.mock_calls) == 2 - import pprint - - pprint.pprint(mock_service_info.mock_calls[0][1]) assert mock_service_info.mock_calls[0][1][0] == "_service.added.local." assert mock_service_info.mock_calls[1][1][0] == "_service.updated.local." From 713d294627940e98e119f7ebcc27ccbdc7058497 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Sun, 19 Sep 2021 15:10:51 +0200 Subject: [PATCH 482/843] Use `_attr_*` for the GitHub integration (#56419) --- homeassistant/components/github/sensor.py | 94 ++++++++--------------- 1 file changed, 33 insertions(+), 61 deletions(-) diff --git a/homeassistant/components/github/sensor.py b/homeassistant/components/github/sensor.py index 56cd7137504..53c28fcdaae 100644 --- a/homeassistant/components/github/sensor.py +++ b/homeassistant/components/github/sensor.py @@ -74,12 +74,11 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class GitHubSensor(SensorEntity): """Representation of a GitHub sensor.""" + _attr_icon = "mdi:github" + def __init__(self, github_data): """Initialize the GitHub sensor.""" - self._unique_id = github_data.repository_path - self._name = None - self._state = None - self._available = False + self._attr_unique_id = github_data.repository_path self._repository_path = None self._latest_commit_message = None self._latest_commit_sha = None @@ -97,68 +96,15 @@ class GitHubSensor(SensorEntity): self._views_unique = None self._github_data = github_data - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def unique_id(self): - """Return unique ID for the sensor.""" - return self._unique_id - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def available(self): - """Return True if entity is available.""" - return self._available - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - attrs = { - ATTR_PATH: self._github_data.repository_path, - ATTR_NAME: self._name, - ATTR_LATEST_COMMIT_MESSAGE: self._latest_commit_message, - ATTR_LATEST_COMMIT_SHA: self._latest_commit_sha, - ATTR_LATEST_RELEASE_URL: self._latest_release_url, - ATTR_LATEST_OPEN_ISSUE_URL: self._latest_open_issue_url, - ATTR_OPEN_ISSUES: self._open_issue_count, - ATTR_LATEST_OPEN_PULL_REQUEST_URL: self._latest_open_pr_url, - ATTR_OPEN_PULL_REQUESTS: self._pull_request_count, - ATTR_STARGAZERS: self._stargazers, - ATTR_FORKS: self._forks, - } - if self._latest_release_tag is not None: - attrs[ATTR_LATEST_RELEASE_TAG] = self._latest_release_tag - if self._clones is not None: - attrs[ATTR_CLONES] = self._clones - if self._clones_unique is not None: - attrs[ATTR_CLONES_UNIQUE] = self._clones_unique - if self._views is not None: - attrs[ATTR_VIEWS] = self._views - if self._views_unique is not None: - attrs[ATTR_VIEWS_UNIQUE] = self._views_unique - return attrs - - @property - def icon(self): - """Return the icon to use in the frontend.""" - return "mdi:github" - async def async_update(self): """Collect updated data from GitHub API.""" await self._github_data.async_update() - self._available = self._github_data.available - if not self._available: + self._attr_available = self._github_data.available + if not self.available: return - self._name = self._github_data.name - self._state = self._github_data.last_commit.sha[0:7] + self._attr_name = self._github_data.name + self._attr_native_value = self._github_data.last_commit.sha[0:7] self._latest_commit_message = self._github_data.last_commit.commit.message self._latest_commit_sha = self._github_data.last_commit.sha @@ -188,6 +134,32 @@ class GitHubSensor(SensorEntity): self._views = self._github_data.views_response.data.count self._views_unique = self._github_data.views_response.data.uniques + self._attr_extra_state_attributes = { + ATTR_PATH: self._github_data.repository_path, + ATTR_NAME: self.name, + ATTR_LATEST_COMMIT_MESSAGE: self._latest_commit_message, + ATTR_LATEST_COMMIT_SHA: self._latest_commit_sha, + ATTR_LATEST_RELEASE_URL: self._latest_release_url, + ATTR_LATEST_OPEN_ISSUE_URL: self._latest_open_issue_url, + ATTR_OPEN_ISSUES: self._open_issue_count, + ATTR_LATEST_OPEN_PULL_REQUEST_URL: self._latest_open_pr_url, + ATTR_OPEN_PULL_REQUESTS: self._pull_request_count, + ATTR_STARGAZERS: self._stargazers, + ATTR_FORKS: self._forks, + } + if self._latest_release_tag is not None: + self._attr_extra_state_attributes[ + ATTR_LATEST_RELEASE_TAG + ] = self._latest_release_tag + if self._clones is not None: + self._attr_extra_state_attributes[ATTR_CLONES] = self._clones + if self._clones_unique is not None: + self._attr_extra_state_attributes[ATTR_CLONES_UNIQUE] = self._clones_unique + if self._views is not None: + self._attr_extra_state_attributes[ATTR_VIEWS] = self._views + if self._views_unique is not None: + self._attr_extra_state_attributes[ATTR_VIEWS_UNIQUE] = self._views_unique + class GitHubData: """GitHub Data object.""" From ea189f930a7a840d1491653902527e38bba09529 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 19 Sep 2021 17:04:33 +0200 Subject: [PATCH 483/843] Use attrs in Xiaomi Miio humidifier platform (#56371) * Use attrs in humidifier platform * Cleanup min/max humidity attrs --- .../components/xiaomi_miio/humidifier.py | 39 ++++--------------- 1 file changed, 7 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/humidifier.py b/homeassistant/components/xiaomi_miio/humidifier.py index 584d5caf6b5..411d1428c70 100644 --- a/homeassistant/components/xiaomi_miio/humidifier.py +++ b/homeassistant/components/xiaomi_miio/humidifier.py @@ -9,8 +9,6 @@ from miio.airhumidifier_mjjsq import OperationMode as AirhumidifierMjjsqOperatio from homeassistant.components.humidifier import HumidifierEntity from homeassistant.components.humidifier.const import ( - DEFAULT_MAX_HUMIDITY, - DEFAULT_MIN_HUMIDITY, DEVICE_CLASS_HUMIDIFIER, SUPPORT_MODES, ) @@ -117,10 +115,7 @@ class XiaomiGenericHumidifier(XiaomiCoordinatedMiioEntity, HumidifierEntity): self._state = None self._attributes = {} - self._available_modes = [] self._mode = None - self._min_humidity = DEFAULT_MIN_HUMIDITY - self._max_humidity = DEFAULT_MAX_HUMIDITY self._humidity_steps = 100 self._target_humidity = None @@ -137,26 +132,11 @@ class XiaomiGenericHumidifier(XiaomiCoordinatedMiioEntity, HumidifierEntity): return value - @property - def available_modes(self) -> list: - """Get the list of available modes.""" - return self._available_modes - @property def mode(self): """Get the current mode.""" return self._mode - @property - def min_humidity(self): - """Return the minimum target humidity.""" - return self._min_humidity - - @property - def max_humidity(self): - """Return the maximum target humidity.""" - return self._max_humidity - async def async_turn_on( self, **kwargs, @@ -196,25 +176,20 @@ class XiaomiAirHumidifier(XiaomiGenericHumidifier, HumidifierEntity): def __init__(self, name, device, entry, unique_id, coordinator): """Initialize the plug switch.""" super().__init__(name, device, entry, unique_id, coordinator) + + self._attr_min_humidity = 30 + self._attr_max_humidity = 80 if self._model in [MODEL_AIRHUMIDIFIER_CA1, MODEL_AIRHUMIDIFIER_CB1]: - self._available_modes = AVAILABLE_MODES_CA1_CB1 - self._min_humidity = 30 - self._max_humidity = 80 + self._attr_available_modes = AVAILABLE_MODES_CA1_CB1 self._humidity_steps = 10 elif self._model in [MODEL_AIRHUMIDIFIER_CA4]: - self._available_modes = AVAILABLE_MODES_CA4 - self._min_humidity = 30 - self._max_humidity = 80 + self._attr_available_modes = AVAILABLE_MODES_CA4 self._humidity_steps = 100 elif self._model in MODELS_HUMIDIFIER_MJJSQ: - self._available_modes = AVAILABLE_MODES_MJJSQ - self._min_humidity = 30 - self._max_humidity = 80 + self._attr_available_modes = AVAILABLE_MODES_MJJSQ self._humidity_steps = 100 else: - self._available_modes = AVAILABLE_MODES_OTHER - self._min_humidity = 30 - self._max_humidity = 80 + self._attr_available_modes = AVAILABLE_MODES_OTHER self._humidity_steps = 10 self._state = self.coordinator.data.is_on From d76163e5bef7c505c5c3d36ddab0fdf7c8f56838 Mon Sep 17 00:00:00 2001 From: Milan Meulemans Date: Sun, 19 Sep 2021 20:51:57 +0200 Subject: [PATCH 484/843] Add tests for Rituals Perfume Genie number, select and binary_sensor platforms (#55224) --- .coveragerc | 3 - .../rituals_perfume_genie/common.py | 14 +- .../test_binary_sensor.py | 30 ++++ .../rituals_perfume_genie/test_init.py | 4 +- .../rituals_perfume_genie/test_number.py | 161 ++++++++++++++++++ .../rituals_perfume_genie/test_select.py | 100 +++++++++++ .../rituals_perfume_genie/test_sensor.py | 4 +- .../rituals_perfume_genie/test_switch.py | 6 +- 8 files changed, 308 insertions(+), 14 deletions(-) create mode 100644 tests/components/rituals_perfume_genie/test_binary_sensor.py create mode 100644 tests/components/rituals_perfume_genie/test_number.py create mode 100644 tests/components/rituals_perfume_genie/test_select.py diff --git a/.coveragerc b/.coveragerc index d3465668bfb..322c7e1af48 100644 --- a/.coveragerc +++ b/.coveragerc @@ -873,9 +873,6 @@ omit = homeassistant/components/rest/switch.py homeassistant/components/ring/camera.py homeassistant/components/ripple/sensor.py - homeassistant/components/rituals_perfume_genie/binary_sensor.py - homeassistant/components/rituals_perfume_genie/number.py - homeassistant/components/rituals_perfume_genie/select.py homeassistant/components/rocketchat/notify.py homeassistant/components/roomba/__init__.py homeassistant/components/roomba/binary_sensor.py diff --git a/tests/components/rituals_perfume_genie/common.py b/tests/components/rituals_perfume_genie/common.py index 35555e2b842..1f12d3e651e 100644 --- a/tests/components/rituals_perfume_genie/common.py +++ b/tests/components/rituals_perfume_genie/common.py @@ -10,12 +10,12 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -def mock_config_entry(uniqe_id: str, entry_id: str = "an_entry_id") -> MockConfigEntry: +def mock_config_entry(unique_id: str, entry_id: str = "an_entry_id") -> MockConfigEntry: """Return a mock Config Entry for the Rituals Perfume Genie integration.""" return MockConfigEntry( domain=DOMAIN, title="name@example.com", - unique_id=uniqe_id, + unique_id=unique_id, data={ACCOUNT_HASH: "an_account_hash"}, entry_id=entry_id, ) @@ -32,6 +32,8 @@ def mock_diffuser( is_on: bool = True, name: str = "Genie", perfume: str = "Ritual of Sakura", + perfume_amount: int = 2, + room_size_square_meter: int = 60, version: str = "4.0", wifi_percentage: int = 75, ) -> MagicMock: @@ -47,6 +49,10 @@ def mock_diffuser( diffuser_mock.is_on = is_on diffuser_mock.name = name diffuser_mock.perfume = perfume + diffuser_mock.perfume_amount = perfume_amount + diffuser_mock.room_size_square_meter = room_size_square_meter + diffuser_mock.set_perfume_amount = AsyncMock() + diffuser_mock.set_room_size_square_meter = AsyncMock() diffuser_mock.turn_off = AsyncMock() diffuser_mock.turn_on = AsyncMock() diffuser_mock.update_data = AsyncMock() @@ -55,12 +61,12 @@ def mock_diffuser( return diffuser_mock -def mock_diffuser_v1_battery_cartridge(): +def mock_diffuser_v1_battery_cartridge() -> MagicMock: """Create and return a mock version 1 Diffuser with battery and a cartridge.""" return mock_diffuser(hublot="lot123v1") -def mock_diffuser_v2_no_battery_no_cartridge(): +def mock_diffuser_v2_no_battery_no_cartridge() -> MagicMock: """Create and return a mock version 2 Diffuser without battery and cartridge.""" return mock_diffuser( hublot="lot123v2", diff --git a/tests/components/rituals_perfume_genie/test_binary_sensor.py b/tests/components/rituals_perfume_genie/test_binary_sensor.py new file mode 100644 index 00000000000..f2e499655ca --- /dev/null +++ b/tests/components/rituals_perfume_genie/test_binary_sensor.py @@ -0,0 +1,30 @@ +"""Tests for the Rituals Perfume Genie binary sensor platform.""" +from homeassistant.components.binary_sensor import DEVICE_CLASS_BATTERY_CHARGING +from homeassistant.components.rituals_perfume_genie.binary_sensor import CHARGING_SUFFIX +from homeassistant.const import ATTR_DEVICE_CLASS, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry + +from .common import ( + init_integration, + mock_config_entry, + mock_diffuser_v1_battery_cartridge, +) + + +async def test_binary_sensors(hass: HomeAssistant) -> None: + """Test the creation and values of the Rituals Perfume Genie binary sensor.""" + config_entry = mock_config_entry(unique_id="binary_sensor_test_diffuser_v1") + diffuser = mock_diffuser_v1_battery_cartridge() + await init_integration(hass, config_entry, [diffuser]) + registry = entity_registry.async_get(hass) + hublot = diffuser.hublot + + state = hass.states.get("binary_sensor.genie_battery_charging") + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_BATTERY_CHARGING + + entry = registry.async_get("binary_sensor.genie_battery_charging") + assert entry + assert entry.unique_id == f"{hublot}{CHARGING_SUFFIX}" diff --git a/tests/components/rituals_perfume_genie/test_init.py b/tests/components/rituals_perfume_genie/test_init.py index 887417a41f8..ea79a99da0e 100644 --- a/tests/components/rituals_perfume_genie/test_init.py +++ b/tests/components/rituals_perfume_genie/test_init.py @@ -12,7 +12,7 @@ from .common import init_integration, mock_config_entry async def test_config_entry_not_ready(hass: HomeAssistant): """Test the Rituals configuration entry setup if connection to Rituals is missing.""" - config_entry = mock_config_entry(uniqe_id="id_123_not_ready") + config_entry = mock_config_entry(unique_id="id_123_not_ready") config_entry.add_to_hass(hass) with patch( "homeassistant.components.rituals_perfume_genie.Account.get_devices", @@ -24,7 +24,7 @@ async def test_config_entry_not_ready(hass: HomeAssistant): async def test_config_entry_unload(hass: HomeAssistant) -> None: """Test the Rituals Perfume Genie configuration entry setup and unloading.""" - config_entry = mock_config_entry(uniqe_id="id_123_unload") + config_entry = mock_config_entry(unique_id="id_123_unload") await init_integration(hass, config_entry) await hass.config_entries.async_unload(config_entry.entry_id) diff --git a/tests/components/rituals_perfume_genie/test_number.py b/tests/components/rituals_perfume_genie/test_number.py new file mode 100644 index 00000000000..fc3937897b9 --- /dev/null +++ b/tests/components/rituals_perfume_genie/test_number.py @@ -0,0 +1,161 @@ +"""Tests for the Rituals Perfume Genie number platform.""" +from __future__ import annotations + +import pytest + +from homeassistant.components.homeassistant import SERVICE_UPDATE_ENTITY +from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN +from homeassistant.components.number.const import ( + ATTR_MAX, + ATTR_MIN, + ATTR_VALUE, + SERVICE_SET_VALUE, +) +from homeassistant.components.rituals_perfume_genie.number import ( + MAX_PERFUME_AMOUNT, + MIN_PERFUME_AMOUNT, + PERFUME_AMOUNT_SUFFIX, +) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_ICON +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry +from homeassistant.setup import async_setup_component + +from .common import ( + init_integration, + mock_config_entry, + mock_diffuser, + mock_diffuser_v1_battery_cartridge, +) + + +async def test_number_entity(hass: HomeAssistant) -> None: + """Test the creation and values of the diffuser number entity.""" + config_entry = mock_config_entry(unique_id="number_test") + diffuser = mock_diffuser(hublot="lot123", perfume_amount=2) + await init_integration(hass, config_entry, [diffuser]) + + registry = entity_registry.async_get(hass) + + state = hass.states.get("number.genie_perfume_amount") + assert state + assert state.state == str(diffuser.perfume_amount) + assert state.attributes[ATTR_ICON] == "mdi:gauge" + assert state.attributes[ATTR_MIN] == MIN_PERFUME_AMOUNT + assert state.attributes[ATTR_MAX] == MAX_PERFUME_AMOUNT + + entry = registry.async_get("number.genie_perfume_amount") + assert entry + assert entry.unique_id == f"{diffuser.hublot}{PERFUME_AMOUNT_SUFFIX}" + + +async def test_set_number_value(hass: HomeAssistant) -> None: + """Test setting the diffuser number entity value.""" + config_entry = mock_config_entry(unique_id="number_set_value_test") + diffuser = mock_diffuser_v1_battery_cartridge() + await init_integration(hass, config_entry, [diffuser]) + await async_setup_component(hass, "homeassistant", {}) + diffuser.perfume_amount = 1 + + state = hass.states.get("number.genie_perfume_amount") + assert state + assert state.state == "2" + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: "number.genie_perfume_amount", ATTR_VALUE: 1}, + blocking=True, + ) + await hass.services.async_call( + "homeassistant", + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: ["number.genie_perfume_amount"]}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("number.genie_perfume_amount") + assert state + assert state.state == "1" + + +async def test_set_number_value_out_of_range(hass: HomeAssistant): + """Test setting the diffuser number entity value out of range.""" + config_entry = mock_config_entry(unique_id="number_set_value_out_of_range_test") + diffuser = mock_diffuser(hublot="lot123", perfume_amount=2) + await init_integration(hass, config_entry, [diffuser]) + await async_setup_component(hass, "homeassistant", {}) + + state = hass.states.get("number.genie_perfume_amount") + assert state + assert state.state == "2" + + with pytest.raises(ValueError): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: "number.genie_perfume_amount", ATTR_VALUE: 4}, + blocking=True, + ) + await hass.services.async_call( + "homeassistant", + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: ["number.genie_perfume_amount"]}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("number.genie_perfume_amount") + assert state + assert state.state == "2" + + with pytest.raises(ValueError): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: "number.genie_perfume_amount", ATTR_VALUE: 0}, + blocking=True, + ) + await hass.services.async_call( + "homeassistant", + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: ["number.genie_perfume_amount"]}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("number.genie_perfume_amount") + assert state + assert state.state == "2" + + +async def test_set_number_value_to_float(hass: HomeAssistant): + """Test setting the diffuser number entity value to a float.""" + config_entry = mock_config_entry(unique_id="number_set_value_to_float_test") + diffuser = mock_diffuser(hublot="lot123", perfume_amount=3) + await init_integration(hass, config_entry, [diffuser]) + await async_setup_component(hass, "homeassistant", {}) + + state = hass.states.get("number.genie_perfume_amount") + assert state + assert state.state == "3" + + with pytest.raises(ValueError): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: "number.genie_perfume_amount", ATTR_VALUE: 1.5}, + blocking=True, + ) + await hass.services.async_call( + "homeassistant", + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: ["number.genie_perfume_amount"]}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("number.genie_perfume_amount") + assert state + assert state.state == "3" diff --git a/tests/components/rituals_perfume_genie/test_select.py b/tests/components/rituals_perfume_genie/test_select.py new file mode 100644 index 00000000000..fb159166fb7 --- /dev/null +++ b/tests/components/rituals_perfume_genie/test_select.py @@ -0,0 +1,100 @@ +"""Tests for the Rituals Perfume Genie select platform.""" +import pytest + +from homeassistant.components.homeassistant import SERVICE_UPDATE_ENTITY +from homeassistant.components.rituals_perfume_genie.select import ROOM_SIZE_SUFFIX +from homeassistant.components.select import DOMAIN as SELECT_DOMAIN +from homeassistant.components.select.const import ATTR_OPTION, ATTR_OPTIONS +from homeassistant.const import ( + AREA_SQUARE_METERS, + ATTR_ENTITY_ID, + ATTR_ICON, + SERVICE_SELECT_OPTION, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry +from homeassistant.setup import async_setup_component + +from .common import init_integration, mock_config_entry, mock_diffuser + + +async def test_select_entity(hass: HomeAssistant) -> None: + """Test the creation and state of the diffuser select entity.""" + config_entry = mock_config_entry(unique_id="select_test") + diffuser = mock_diffuser(hublot="lot123", room_size_square_meter=60) + await init_integration(hass, config_entry, [diffuser]) + + registry = entity_registry.async_get(hass) + + state = hass.states.get("select.genie_room_size") + assert state + assert state.state == str(diffuser.room_size_square_meter) + assert state.attributes[ATTR_ICON] == "mdi:ruler-square" + assert state.attributes[ATTR_OPTIONS] == ["15", "30", "60", "100"] + + entry = registry.async_get("select.genie_room_size") + assert entry + assert entry.unique_id == f"{diffuser.hublot}{ROOM_SIZE_SUFFIX}" + assert entry.unit_of_measurement == AREA_SQUARE_METERS + + +async def test_select_option(hass: HomeAssistant) -> None: + """Test selecting of a option.""" + config_entry = mock_config_entry(unique_id="select_invalid_option_test") + diffuser = mock_diffuser(hublot="lot123", room_size_square_meter=60) + await init_integration(hass, config_entry, [diffuser]) + await async_setup_component(hass, "homeassistant", {}) + diffuser.room_size_square_meter = 30 + + state = hass.states.get("select.genie_room_size") + assert state + assert state.state == "60" + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: "select.genie_room_size", ATTR_OPTION: "30"}, + blocking=True, + ) + await hass.services.async_call( + "homeassistant", + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: ["select.genie_room_size"]}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("select.genie_room_size") + assert state + assert state.state == "30" + + +async def test_select_invalid_option(hass: HomeAssistant) -> None: + """Test selecting an invalid option.""" + config_entry = mock_config_entry(unique_id="select_invalid_option_test") + diffuser = mock_diffuser(hublot="lot123", room_size_square_meter=60) + await init_integration(hass, config_entry, [diffuser]) + await async_setup_component(hass, "homeassistant", {}) + + state = hass.states.get("select.genie_room_size") + assert state + assert state.state == "60" + + with pytest.raises(ValueError): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: "select.genie_room_size", ATTR_OPTION: "120"}, + blocking=True, + ) + await hass.services.async_call( + "homeassistant", + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: ["select.genie_room_size"]}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("select.genie_room_size") + assert state + assert state.state == "60" diff --git a/tests/components/rituals_perfume_genie/test_sensor.py b/tests/components/rituals_perfume_genie/test_sensor.py index 477353d3b83..2c72d429a99 100644 --- a/tests/components/rituals_perfume_genie/test_sensor.py +++ b/tests/components/rituals_perfume_genie/test_sensor.py @@ -26,7 +26,7 @@ from .common import ( async def test_sensors_diffuser_v1_battery_cartridge(hass: HomeAssistant) -> None: """Test the creation and values of the Rituals Perfume Genie sensors.""" - config_entry = mock_config_entry(uniqe_id="id_123_sensor_test_diffuser_v1") + config_entry = mock_config_entry(unique_id="id_123_sensor_test_diffuser_v1") diffuser = mock_diffuser_v1_battery_cartridge() await init_integration(hass, config_entry, [diffuser]) registry = entity_registry.async_get(hass) @@ -73,7 +73,7 @@ async def test_sensors_diffuser_v1_battery_cartridge(hass: HomeAssistant) -> Non async def test_sensors_diffuser_v2_no_battery_no_cartridge(hass: HomeAssistant) -> None: """Test the creation and values of the Rituals Perfume Genie sensors.""" - config_entry = mock_config_entry(uniqe_id="id_123_sensor_test_diffuser_v2") + config_entry = mock_config_entry(unique_id="id_123_sensor_test_diffuser_v2") await init_integration( hass, config_entry, [mock_diffuser_v2_no_battery_no_cartridge()] diff --git a/tests/components/rituals_perfume_genie/test_switch.py b/tests/components/rituals_perfume_genie/test_switch.py index a2691da0e0e..960923a1b77 100644 --- a/tests/components/rituals_perfume_genie/test_switch.py +++ b/tests/components/rituals_perfume_genie/test_switch.py @@ -25,7 +25,7 @@ from .common import ( async def test_switch_entity(hass: HomeAssistant) -> None: """Test the creation and values of the Rituals Perfume Genie diffuser switch.""" - config_entry = mock_config_entry(uniqe_id="id_123_switch_set_state_test") + config_entry = mock_config_entry(unique_id="id_123_switch_test") diffuser = mock_diffuser_v1_battery_cartridge() await init_integration(hass, config_entry, [diffuser]) @@ -43,7 +43,7 @@ async def test_switch_entity(hass: HomeAssistant) -> None: async def test_switch_handle_coordinator_update(hass: HomeAssistant) -> None: """Test handling a coordinator update.""" - config_entry = mock_config_entry(uniqe_id="id_123_switch_set_state_test") + config_entry = mock_config_entry(unique_id="switch_handle_coordinator_update_test") diffuser = mock_diffuser_v1_battery_cartridge() await init_integration(hass, config_entry, [diffuser]) await async_setup_component(hass, "homeassistant", {}) @@ -74,7 +74,7 @@ async def test_switch_handle_coordinator_update(hass: HomeAssistant) -> None: async def test_set_switch_state(hass: HomeAssistant) -> None: """Test changing the diffuser switch entity state.""" - config_entry = mock_config_entry(uniqe_id="id_123_switch_set_state_test") + config_entry = mock_config_entry(unique_id="id_123_switch_set_state_test") await init_integration(hass, config_entry, [mock_diffuser_v1_battery_cartridge()]) state = hass.states.get("switch.genie") From 00f7548fa0454227199275e3fabb36d8f6e0f39c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Sun, 19 Sep 2021 20:57:28 +0200 Subject: [PATCH 485/843] Surepetcare, strict typing (#56425) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Surepetcare, strict typing Signed-off-by: Daniel Hjelseth Høyer * Surepetcare, strict typing Signed-off-by: Daniel Hjelseth Høyer --- .strict-typing | 1 + .../components/surepetcare/binary_sensor.py | 21 ++++++++++++------- .../components/surepetcare/config_flow.py | 2 +- .../components/surepetcare/sensor.py | 10 ++++++--- mypy.ini | 11 ++++++++++ 5 files changed, 34 insertions(+), 11 deletions(-) diff --git a/.strict-typing b/.strict-typing index 64fbcb9e82d..df0c0168a9e 100644 --- a/.strict-typing +++ b/.strict-typing @@ -98,6 +98,7 @@ homeassistant.components.sonos.media_player homeassistant.components.ssdp.* homeassistant.components.stream.* homeassistant.components.sun.* +homeassistant.components.surepetcare.* homeassistant.components.switch.* homeassistant.components.switcher_kis.* homeassistant.components.synology_dsm.* diff --git a/homeassistant/components/surepetcare/binary_sensor.py b/homeassistant/components/surepetcare/binary_sensor.py index 2f411e8c2a9..e0903230697 100644 --- a/homeassistant/components/surepetcare/binary_sensor.py +++ b/homeassistant/components/surepetcare/binary_sensor.py @@ -3,8 +3,10 @@ from __future__ import annotations from abc import abstractmethod import logging +from typing import cast from surepy.entities import SurepyEntity +from surepy.entities.pet import Pet as SurepyPet from surepy.enums import EntityType, Location from homeassistant.components.binary_sensor import ( @@ -12,7 +14,9 @@ from homeassistant.components.binary_sensor import ( DEVICE_CLASS_PRESENCE, BinarySensorEntity, ) -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -23,10 +27,12 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass, entry, async_add_entities) -> None: +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up Sure PetCare Flaps binary sensors based on a config entry.""" - entities: list[SurepyEntity | Pet | Hub | DeviceConnectivity] = [] + entities: list[SurePetcareBinarySensor] = [] coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] @@ -75,7 +81,7 @@ class SurePetcareBinarySensor(CoordinatorEntity, BinarySensorEntity): @abstractmethod @callback - def _update_attr(self, surepy_entity) -> None: + def _update_attr(self, surepy_entity: SurepyEntity) -> None: """Update the state and attributes.""" @callback @@ -96,7 +102,7 @@ class Hub(SurePetcareBinarySensor): return super().available and bool(self._attr_is_on) @callback - def _update_attr(self, surepy_entity) -> None: + def _update_attr(self, surepy_entity: SurepyEntity) -> None: """Get the latest data and update the state.""" state = surepy_entity.raw_data()["status"] self._attr_is_on = self._attr_available = bool(state["online"]) @@ -118,8 +124,9 @@ class Pet(SurePetcareBinarySensor): _attr_device_class = DEVICE_CLASS_PRESENCE @callback - def _update_attr(self, surepy_entity) -> None: + def _update_attr(self, surepy_entity: SurepyEntity) -> None: """Get the latest data and update the state.""" + surepy_entity = cast(SurepyPet, surepy_entity) state = surepy_entity.location try: self._attr_is_on = bool(Location(state.where) == Location.INSIDE) @@ -153,7 +160,7 @@ class DeviceConnectivity(SurePetcareBinarySensor): ) @callback - def _update_attr(self, surepy_entity): + def _update_attr(self, surepy_entity: SurepyEntity) -> None: state = surepy_entity.raw_data()["status"] self._attr_is_on = bool(state) if state: diff --git a/homeassistant/components/surepetcare/config_flow.py b/homeassistant/components/surepetcare/config_flow.py index e2e5f07f05e..bc3589aa4bb 100644 --- a/homeassistant/components/surepetcare/config_flow.py +++ b/homeassistant/components/surepetcare/config_flow.py @@ -46,7 +46,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_import(self, import_info): + async def async_step_import(self, import_info: dict[str, Any] | None) -> FlowResult: """Set the config entry up from yaml.""" return await self.async_step_user(import_info) diff --git a/homeassistant/components/surepetcare/sensor.py b/homeassistant/components/surepetcare/sensor.py index 0122dc2905d..a52c1d7d0ed 100644 --- a/homeassistant/components/surepetcare/sensor.py +++ b/homeassistant/components/surepetcare/sensor.py @@ -7,8 +7,10 @@ from surepy.entities import SurepyEntity from surepy.enums import EntityType from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_VOLTAGE, DEVICE_CLASS_BATTERY, PERCENTAGE -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -19,10 +21,12 @@ from .const import DOMAIN, SURE_BATT_VOLTAGE_DIFF, SURE_BATT_VOLTAGE_LOW _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass, entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up Sure PetCare Flaps sensors.""" - entities: list[SurepyEntity] = [] + entities: list[SureBattery] = [] coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/mypy.ini b/mypy.ini index fc3d76863ef..b1b2657aac9 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1089,6 +1089,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.surepetcare.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.switch.*] check_untyped_defs = true disallow_incomplete_defs = true From b060c025ced33ce123ca6220e810f91a43bdba22 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Sep 2021 19:00:46 -1000 Subject: [PATCH 486/843] Bump zeroconf to 0.36.6 (#56438) - Performance improvements (faster HomeKit startup) Changelog: https://github.com/jstasiak/python-zeroconf/compare/0.36.5...0.36.6 --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 713b992b573..da413b142b8 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -2,7 +2,7 @@ "domain": "zeroconf", "name": "Zero-configuration networking (zeroconf)", "documentation": "https://www.home-assistant.io/integrations/zeroconf", - "requirements": ["zeroconf==0.36.5"], + "requirements": ["zeroconf==0.36.6"], "dependencies": ["network", "api"], "codeowners": ["@bdraco"], "quality_scale": "internal", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 9ce8a0ada4a..10d5906401e 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ sqlalchemy==1.4.23 voluptuous-serialize==2.4.0 voluptuous==0.12.1 yarl==1.6.3 -zeroconf==0.36.5 +zeroconf==0.36.6 pycryptodome>=3.6.6 diff --git a/requirements_all.txt b/requirements_all.txt index bc0347b4bb0..636ed6b022e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2463,7 +2463,7 @@ youtube_dl==2021.04.26 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.36.5 +zeroconf==0.36.6 # homeassistant.components.zha zha-quirks==0.0.61 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5c841bb3486..8177b515c91 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1395,7 +1395,7 @@ yeelight==0.7.4 youless-api==0.12 # homeassistant.components.zeroconf -zeroconf==0.36.5 +zeroconf==0.36.6 # homeassistant.components.zha zha-quirks==0.0.61 From b05c1b516e4f359696e78a259a0e22fa32bc6f84 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Mon, 20 Sep 2021 06:31:58 +0100 Subject: [PATCH 487/843] restore float and not string (#56406) --- homeassistant/components/utility_meter/sensor.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index 4ff3c04355d..36094e7d3e1 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -306,7 +306,11 @@ class UtilityMeterSensor(RestoreEntity, SensorEntity): if state: self._state = Decimal(state.state) self._unit_of_measurement = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - self._last_period = state.attributes.get(ATTR_LAST_PERIOD) + self._last_period = ( + float(state.attributes.get(ATTR_LAST_PERIOD)) + if state.attributes.get(ATTR_LAST_PERIOD) + else 0 + ) self._last_reset = dt_util.as_utc( dt_util.parse_datetime(state.attributes.get(ATTR_LAST_RESET)) ) From ddd31951bcb43d6320bef5013ee63c6bceed3c7a Mon Sep 17 00:00:00 2001 From: jan iversen Date: Mon, 20 Sep 2021 07:35:11 +0200 Subject: [PATCH 488/843] Strictly type sensor.py. (#56377) --- homeassistant/components/modbus/sensor.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py index 83ffafc7441..6702e6f22d1 100644 --- a/homeassistant/components/modbus/sensor.py +++ b/homeassistant/components/modbus/sensor.py @@ -1,12 +1,14 @@ """Support for Modbus Register sensors.""" from __future__ import annotations +from datetime import datetime import logging from typing import Any from homeassistant.components.sensor import CONF_STATE_CLASS, SensorEntity from homeassistant.const import CONF_NAME, CONF_SENSORS, CONF_UNIT_OF_MEASUREMENT from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -21,9 +23,9 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - async_add_entities, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, -): +) -> None: """Set up the Modbus sensors.""" sensors = [] @@ -50,14 +52,14 @@ class ModbusRegisterSensor(BaseStructPlatform, RestoreEntity, SensorEntity): self._attr_native_unit_of_measurement = entry.get(CONF_UNIT_OF_MEASUREMENT) self._attr_state_class = entry.get(CONF_STATE_CLASS) - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await self.async_base_added_to_hass() state = await self.async_get_last_state() if state: self._attr_native_value = state.state - async def async_update(self, now=None): + async def async_update(self, now: datetime | None = None) -> None: """Update the state of the sensor.""" # remark "now" is a dummy parameter to avoid problems with # async_track_time_interval From be19c676fa254dba0e47cfbca5c7ea68609d0806 Mon Sep 17 00:00:00 2001 From: mbo18 Date: Mon, 20 Sep 2021 08:55:07 +0200 Subject: [PATCH 489/843] Add missing generic-x86-64 image (#56424) --- homeassistant/components/version/sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/version/sensor.py b/homeassistant/components/version/sensor.py index 925e9111c1a..63e8421ed0e 100644 --- a/homeassistant/components/version/sensor.py +++ b/homeassistant/components/version/sensor.py @@ -22,6 +22,7 @@ from homeassistant.util import Throttle ALL_IMAGES = [ "default", + "generic-x86-64", "intel-nuc", "odroid-c2", "odroid-n2", From a54854d129733d6c3506a86d30fb3a9e346c9d20 Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Mon, 20 Sep 2021 09:02:17 +0200 Subject: [PATCH 490/843] ESPHome Noise Transport Encryption support (#56216) --- homeassistant/components/esphome/__init__.py | 10 + .../components/esphome/config_flow.py | 136 ++++++++-- .../components/esphome/manifest.json | 2 +- homeassistant/components/esphome/strings.json | 18 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/esphome/test_config_flow.py | 232 +++++++++++++++--- 7 files changed, 341 insertions(+), 61 deletions(-) diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 64e75910c2d..e4a5de7541b 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -18,6 +18,8 @@ from aioesphomeapi import ( EntityInfo, EntityState, HomeassistantServiceCall, + InvalidEncryptionKeyAPIError, + RequiresEncryptionAPIError, UserService, UserServiceArgType, ) @@ -52,6 +54,7 @@ from homeassistant.helpers.template import Template from .entry_data import RuntimeEntryData DOMAIN = "esphome" +CONF_NOISE_PSK = "noise_psk" _LOGGER = logging.getLogger(__name__) _T = TypeVar("_T") @@ -110,6 +113,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: host = entry.data[CONF_HOST] port = entry.data[CONF_PORT] password = entry.data[CONF_PASSWORD] + noise_psk = entry.data.get(CONF_NOISE_PSK) device_id = None zeroconf_instance = await zeroconf.async_get_instance(hass) @@ -121,6 +125,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: password, client_info=f"Home Assistant {const.__version__}", zeroconf_instance=zeroconf_instance, + noise_psk=noise_psk, ) domain_data = DomainData.get(hass) @@ -399,6 +404,11 @@ class ReconnectLogic(RecordUpdateListener): try: await self._cli.connect(on_stop=self._on_disconnect, login=True) except APIConnectionError as error: + if isinstance( + error, (RequiresEncryptionAPIError, InvalidEncryptionKeyAPIError) + ): + self._entry.async_start_reauth(self._hass) + level = logging.WARNING if tries == 0 else logging.DEBUG _LOGGER.log( level, diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index 940fee11076..7a7e45c440b 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -4,7 +4,15 @@ from __future__ import annotations from collections import OrderedDict from typing import Any -from aioesphomeapi import APIClient, APIConnectionError, DeviceInfo +from aioesphomeapi import ( + APIClient, + APIConnectionError, + DeviceInfo, + InvalidAuthAPIError, + InvalidEncryptionKeyAPIError, + RequiresEncryptionAPIError, + ResolveAPIError, +) import voluptuous as vol from homeassistant.components import zeroconf @@ -14,7 +22,9 @@ from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.typing import DiscoveryInfoType -from . import DOMAIN, DomainData +from . import CONF_NOISE_PSK, DOMAIN, DomainData + +ERROR_REQUIRES_ENCRYPTION_KEY = "requires_encryption_key" class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): @@ -27,12 +37,14 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): self._host: str | None = None self._port: int | None = None self._password: str | None = None + self._noise_psk: str | None = None + self._device_info: DeviceInfo | None = None async def _async_step_user_base( self, user_input: dict[str, Any] | None = None, error: str | None = None ) -> FlowResult: if user_input is not None: - return await self._async_authenticate_or_add(user_input) + return await self._async_try_fetch_device_info(user_input) fields: dict[Any, type] = OrderedDict() fields[vol.Required(CONF_HOST, default=self._host or vol.UNDEFINED)] = str @@ -52,6 +64,36 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a flow initialized by the user.""" return await self._async_step_user_base(user_input=user_input) + async def async_step_reauth(self, data: dict[str, Any] | None = None) -> FlowResult: + """Handle a flow initialized by a reauth event.""" + entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + assert entry is not None + self._host = entry.data[CONF_HOST] + self._port = entry.data[CONF_PORT] + self._password = entry.data[CONF_PASSWORD] + self._noise_psk = entry.data.get(CONF_NOISE_PSK) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle reauthorization flow.""" + errors = {} + + if user_input is not None: + self._noise_psk = user_input[CONF_NOISE_PSK] + error = await self.fetch_device_info() + if error is None: + return await self._async_authenticate_or_add() + errors["base"] = error + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema({vol.Required(CONF_NOISE_PSK): str}), + errors=errors, + description_placeholders={"name": self._name}, + ) + @property def _name(self) -> str | None: return self.context.get(CONF_NAME) @@ -67,18 +109,21 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): self._host = user_input[CONF_HOST] self._port = user_input[CONF_PORT] - async def _async_authenticate_or_add( + async def _async_try_fetch_device_info( self, user_input: dict[str, Any] | None ) -> FlowResult: self._set_user_input(user_input) - error, device_info = await self.fetch_device_info() + error = await self.fetch_device_info() + if error == ERROR_REQUIRES_ENCRYPTION_KEY: + return await self.async_step_encryption_key() if error is not None: return await self._async_step_user_base(error=error) - assert device_info is not None - self._name = device_info.name + return await self._async_authenticate_or_add() + async def _async_authenticate_or_add(self) -> FlowResult: # Only show authentication step if device uses password - if device_info.uses_password: + assert self._device_info is not None + if self._device_info.uses_password: return await self.async_step_authenticate() return self._async_get_entry() @@ -88,7 +133,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): ) -> FlowResult: """Handle user-confirmation of discovered node.""" if user_input is not None: - return await self._async_authenticate_or_add(None) + return await self._async_try_fetch_device_info(None) return self.async_show_form( step_id="discovery_confirm", description_placeholders={"name": self._name} ) @@ -144,15 +189,47 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): @callback def _async_get_entry(self) -> FlowResult: + config_data = { + CONF_HOST: self._host, + CONF_PORT: self._port, + # The API uses protobuf, so empty string denotes absence + CONF_PASSWORD: self._password or "", + CONF_NOISE_PSK: self._noise_psk or "", + } + if "entry_id" in self.context: + entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + assert entry is not None + self.hass.config_entries.async_update_entry(entry, data=config_data) + # Reload the config entry to notify of updated config + self.hass.async_create_task( + self.hass.config_entries.async_reload(entry.entry_id) + ) + + return self.async_abort(reason="reauth_successful") + assert self._name is not None return self.async_create_entry( title=self._name, - data={ - CONF_HOST: self._host, - CONF_PORT: self._port, - # The API uses protobuf, so empty string denotes absence - CONF_PASSWORD: self._password or "", - }, + data=config_data, + ) + + async def async_step_encryption_key( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle getting psk for transport encryption.""" + errors = {} + if user_input is not None: + self._noise_psk = user_input[CONF_NOISE_PSK] + error = await self.fetch_device_info() + if error is None: + return await self._async_authenticate_or_add() + errors["base"] = error + + return self.async_show_form( + step_id="encryption_key", + data_schema=vol.Schema({vol.Required(CONF_NOISE_PSK): str}), + errors=errors, + description_placeholders={"name": self._name}, ) async def async_step_authenticate( @@ -177,7 +254,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def fetch_device_info(self) -> tuple[str | None, DeviceInfo | None]: + async def fetch_device_info(self) -> str | None: """Fetch device info from API and return any errors.""" zeroconf_instance = await zeroconf.async_get_instance(self.hass) assert self._host is not None @@ -188,19 +265,26 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): self._port, "", zeroconf_instance=zeroconf_instance, + noise_psk=self._noise_psk, ) try: await cli.connect() - device_info = await cli.device_info() - except APIConnectionError as err: - if "resolving" in str(err): - return "resolve_error", None - return "connection_error", None + self._device_info = await cli.device_info() + except RequiresEncryptionAPIError: + return ERROR_REQUIRES_ENCRYPTION_KEY + except InvalidEncryptionKeyAPIError: + return "invalid_psk" + except ResolveAPIError: + return "resolve_error" + except APIConnectionError: + return "connection_error" finally: await cli.disconnect(force=True) - return None, device_info + self._name = self._device_info.name + + return None async def try_login(self) -> str | None: """Try logging in to device and return any errors.""" @@ -213,12 +297,16 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): self._port, self._password, zeroconf_instance=zeroconf_instance, + noise_psk=self._noise_psk, ) try: await cli.connect(login=True) - except APIConnectionError: - await cli.disconnect(force=True) + except InvalidAuthAPIError: return "invalid_auth" + except APIConnectionError: + return "connection_error" + finally: + await cli.disconnect(force=True) return None diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index a78d2efb763..857aebdc4dd 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -3,7 +3,7 @@ "name": "ESPHome", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/esphome", - "requirements": ["aioesphomeapi==8.0.0"], + "requirements": ["aioesphomeapi==9.1.0"], "zeroconf": ["_esphomelib._tcp.local."], "codeowners": ["@OttoWinter", "@jesserockz"], "after_dependencies": ["zeroconf", "tag"], diff --git a/homeassistant/components/esphome/strings.json b/homeassistant/components/esphome/strings.json index 6d1c9a91e3d..62814f2723b 100644 --- a/homeassistant/components/esphome/strings.json +++ b/homeassistant/components/esphome/strings.json @@ -2,12 +2,14 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]" + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "error": { "resolve_error": "Can't resolve address of the ESP. If this error persists, please set a static IP address: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips", "connection_error": "Can't connect to ESP. Please make sure your YAML file contains an 'api:' line.", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_psk": "The transport encryption key is invalid. Please ensure it matches what you have in your configuration" }, "step": { "user": { @@ -23,6 +25,18 @@ }, "description": "Please enter the password you set in your configuration for {name}." }, + "encryption_key": { + "data": { + "noise_psk": "Encryption key" + }, + "description": "Please enter the encryption key you set in your configuration for {name}." + }, + "reauth_confirm": { + "data": { + "noise_psk": "Encryption key" + }, + "description": "The ESPHome device {name} enabled transport encryption or changed the encryption key. Please enter the updated key." + }, "discovery_confirm": { "description": "Do you want to add the ESPHome node `{name}` to Home Assistant?", "title": "Discovered ESPHome node" diff --git a/requirements_all.txt b/requirements_all.txt index 636ed6b022e..db505b3584a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -164,7 +164,7 @@ aioeagle==1.1.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==8.0.0 +aioesphomeapi==9.1.0 # homeassistant.components.flo aioflo==0.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8177b515c91..0d5c83200ab 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -109,7 +109,7 @@ aioeagle==1.1.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==8.0.0 +aioesphomeapi==9.1.0 # homeassistant.components.flo aioflo==0.4.1 diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index 27f0c853615..b7916a3af8d 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -2,10 +2,17 @@ from collections import namedtuple from unittest.mock import AsyncMock, MagicMock, patch +from aioesphomeapi import ( + APIConnectionError, + InvalidAuthAPIError, + InvalidEncryptionKeyAPIError, + RequiresEncryptionAPIError, + ResolveAPIError, +) import pytest from homeassistant import config_entries -from homeassistant.components.esphome import DOMAIN, DomainData +from homeassistant.components.esphome import CONF_NOISE_PSK, DOMAIN, DomainData from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT from homeassistant.data_entry_flow import ( RESULT_TYPE_ABORT, @@ -16,6 +23,8 @@ from homeassistant.data_entry_flow import ( from tests.common import MockConfigEntry MockDeviceInfo = namedtuple("DeviceInfo", ["uses_password", "name"]) +VALID_NOISE_PSK = "bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU=" +INVALID_NOISE_PSK = "lSYBYEjQI1bVL8s2Vask4YytGMj1f1epNtmoim2yuTM=" @pytest.fixture @@ -23,12 +32,15 @@ def mock_client(): """Mock APIClient.""" with patch("homeassistant.components.esphome.config_flow.APIClient") as mock_client: - def mock_constructor(loop, host, port, password, zeroconf_instance=None): + def mock_constructor( + loop, host, port, password, zeroconf_instance=None, noise_psk=None + ): """Fake the client constructor.""" mock_client.host = host mock_client.port = port mock_client.password = password mock_client.zeroconf_instance = zeroconf_instance + mock_client.noise_psk = noise_psk return mock_client mock_client.side_effect = mock_constructor @@ -38,16 +50,6 @@ def mock_client(): yield mock_client -@pytest.fixture(autouse=True) -def mock_api_connection_error(): - """Mock out the try login method.""" - with patch( - "homeassistant.components.esphome.config_flow.APIConnectionError", - new_callable=lambda: OSError, - ) as mock_error: - yield mock_error - - @pytest.fixture(autouse=True) def mock_setup_entry(): """Mock setting up a config entry.""" @@ -75,7 +77,12 @@ async def test_user_connection_works(hass, mock_client, mock_zeroconf): ) assert result["type"] == RESULT_TYPE_CREATE_ENTRY - assert result["data"] == {CONF_HOST: "127.0.0.1", CONF_PORT: 80, CONF_PASSWORD: ""} + assert result["data"] == { + CONF_HOST: "127.0.0.1", + CONF_PORT: 80, + CONF_PASSWORD: "", + CONF_NOISE_PSK: "", + } assert result["title"] == "test" assert len(mock_client.connect.mock_calls) == 1 @@ -84,23 +91,15 @@ async def test_user_connection_works(hass, mock_client, mock_zeroconf): assert mock_client.host == "127.0.0.1" assert mock_client.port == 80 assert mock_client.password == "" + assert mock_client.noise_psk is None -async def test_user_resolve_error( - hass, mock_api_connection_error, mock_client, mock_zeroconf -): +async def test_user_resolve_error(hass, mock_client, mock_zeroconf): """Test user step with IP resolve error.""" - class MockResolveError(mock_api_connection_error): - """Create an exception with a specific error message.""" - - def __init__(self): - """Initialize.""" - super().__init__("Error resolving IP address") - with patch( "homeassistant.components.esphome.config_flow.APIConnectionError", - new_callable=lambda: MockResolveError, + new_callable=lambda: ResolveAPIError, ) as exc: mock_client.device_info.side_effect = exc result = await hass.config_entries.flow.async_init( @@ -118,11 +117,9 @@ async def test_user_resolve_error( assert len(mock_client.disconnect.mock_calls) == 1 -async def test_user_connection_error( - hass, mock_api_connection_error, mock_client, mock_zeroconf -): +async def test_user_connection_error(hass, mock_client, mock_zeroconf): """Test user step with connection error.""" - mock_client.device_info.side_effect = mock_api_connection_error + mock_client.device_info.side_effect = APIConnectionError result = await hass.config_entries.flow.async_init( "esphome", @@ -161,13 +158,12 @@ async def test_user_with_password(hass, mock_client, mock_zeroconf): CONF_HOST: "127.0.0.1", CONF_PORT: 6053, CONF_PASSWORD: "password1", + CONF_NOISE_PSK: "", } assert mock_client.password == "password1" -async def test_user_invalid_password( - hass, mock_api_connection_error, mock_client, mock_zeroconf -): +async def test_user_invalid_password(hass, mock_client, mock_zeroconf): """Test user step with invalid password.""" mock_client.device_info = AsyncMock(return_value=MockDeviceInfo(True, "test")) @@ -180,7 +176,7 @@ async def test_user_invalid_password( assert result["type"] == RESULT_TYPE_FORM assert result["step_id"] == "authenticate" - mock_client.connect.side_effect = mock_api_connection_error + mock_client.connect.side_effect = InvalidAuthAPIError result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_PASSWORD: "invalid"} @@ -191,6 +187,30 @@ async def test_user_invalid_password( assert result["errors"] == {"base": "invalid_auth"} +async def test_login_connection_error(hass, mock_client, mock_zeroconf): + """Test user step with connection error on login attempt.""" + mock_client.device_info = AsyncMock(return_value=MockDeviceInfo(True, "test")) + + result = await hass.config_entries.flow.async_init( + "esphome", + context={"source": config_entries.SOURCE_USER}, + data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053}, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "authenticate" + + mock_client.connect.side_effect = APIConnectionError + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PASSWORD: "valid"} + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "authenticate" + assert result["errors"] == {"base": "connection_error"} + + async def test_discovery_initiation(hass, mock_client, mock_zeroconf): """Test discovery importing works.""" mock_client.device_info = AsyncMock(return_value=MockDeviceInfo(False, "test8266")) @@ -345,3 +365,151 @@ async def test_discovery_updates_unique_id(hass, mock_client): assert result["reason"] == "already_configured" assert entry.unique_id == "test8266" + + +async def test_user_requires_psk(hass, mock_client, mock_zeroconf): + """Test user step with requiring encryption key.""" + mock_client.device_info.side_effect = RequiresEncryptionAPIError + + result = await hass.config_entries.flow.async_init( + "esphome", + context={"source": config_entries.SOURCE_USER}, + data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053}, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "encryption_key" + assert result["errors"] == {} + + assert len(mock_client.connect.mock_calls) == 1 + assert len(mock_client.device_info.mock_calls) == 1 + assert len(mock_client.disconnect.mock_calls) == 1 + + +async def test_encryption_key_valid_psk(hass, mock_client, mock_zeroconf): + """Test encryption key step with valid key.""" + + mock_client.device_info.side_effect = RequiresEncryptionAPIError + + result = await hass.config_entries.flow.async_init( + "esphome", + context={"source": config_entries.SOURCE_USER}, + data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053}, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "encryption_key" + + mock_client.device_info = AsyncMock(return_value=MockDeviceInfo(False, "test")) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} + ) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["data"] == { + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_NOISE_PSK: VALID_NOISE_PSK, + } + assert mock_client.noise_psk == VALID_NOISE_PSK + + +async def test_encryption_key_invalid_psk(hass, mock_client, mock_zeroconf): + """Test encryption key step with invalid key.""" + + mock_client.device_info.side_effect = RequiresEncryptionAPIError + + result = await hass.config_entries.flow.async_init( + "esphome", + context={"source": config_entries.SOURCE_USER}, + data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053}, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "encryption_key" + + mock_client.device_info.side_effect = InvalidEncryptionKeyAPIError + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_NOISE_PSK: INVALID_NOISE_PSK} + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "encryption_key" + assert result["errors"] == {"base": "invalid_psk"} + assert mock_client.noise_psk == INVALID_NOISE_PSK + + +async def test_reauth_initiation(hass, mock_client, mock_zeroconf): + """Test reauth initiation shows form.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053, CONF_PASSWORD: ""}, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + "esphome", + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + "unique_id": entry.unique_id, + }, + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "reauth_confirm" + + +async def test_reauth_confirm_valid(hass, mock_client, mock_zeroconf): + """Test reauth initiation with valid PSK.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053, CONF_PASSWORD: ""}, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + "esphome", + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + "unique_id": entry.unique_id, + }, + ) + + mock_client.device_info = AsyncMock(return_value=MockDeviceInfo(False, "test")) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "reauth_successful" + assert entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK + + +async def test_reauth_confirm_invalid(hass, mock_client, mock_zeroconf): + """Test reauth initiation with invalid PSK.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053, CONF_PASSWORD: ""}, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + "esphome", + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + "unique_id": entry.unique_id, + }, + ) + + mock_client.device_info.side_effect = InvalidEncryptionKeyAPIError + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_NOISE_PSK: INVALID_NOISE_PSK} + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] + assert result["errors"]["base"] == "invalid_psk" From 5c717cbb1d9a0f9c19da06173520c51cf6ddec47 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 20 Sep 2021 13:56:50 +0200 Subject: [PATCH 491/843] Prevent opening of sockets in onboarding tests (#56443) --- tests/components/onboarding/test_views.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index 77666f18fad..206cafa197b 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -325,10 +325,16 @@ async def test_onboarding_integration_invalid_redirect_uri( client = await hass_client() - resp = await client.post( - "/api/onboarding/integration", - json={"client_id": CLIENT_ID, "redirect_uri": "http://invalid-redirect.uri"}, - ) + with patch( + "homeassistant.components.auth.indieauth.fetch_redirect_uris", return_value=[] + ): + resp = await client.post( + "/api/onboarding/integration", + json={ + "client_id": CLIENT_ID, + "redirect_uri": "http://invalid-redirect.uri", + }, + ) assert resp.status == 400 From 93e9a67d7d47279b67e0966f9cffefec9dd9f90d Mon Sep 17 00:00:00 2001 From: jan iversen Date: Mon, 20 Sep 2021 14:33:50 +0200 Subject: [PATCH 492/843] Make tradfri base_class.py strictly typed (#56341) * Make base_class.py strictly typed. --- .../components/tradfri/base_class.py | 36 ++++++++++++------- homeassistant/components/tradfri/sensor.py | 5 ++- homeassistant/components/tradfri/switch.py | 5 ++- 3 files changed, 31 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/tradfri/base_class.py b/homeassistant/components/tradfri/base_class.py index eb1884cfc1b..1e86be6c1a5 100644 --- a/homeassistant/components/tradfri/base_class.py +++ b/homeassistant/components/tradfri/base_class.py @@ -6,25 +6,31 @@ import logging from typing import Any, Callable from pytradfri.command import Command +from pytradfri.device import Device from pytradfri.device.blind import Blind +from pytradfri.device.blind_control import BlindControl from pytradfri.device.light import Light +from pytradfri.device.light_control import LightControl +from pytradfri.device.signal_repeater_control import SignalRepeaterControl from pytradfri.device.socket import Socket from pytradfri.device.socket_control import SocketControl from pytradfri.error import PytradfriError from homeassistant.core import callback -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import DeviceInfo, Entity from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -def handle_error(func): +def handle_error( + func: Callable[[Command | list[Command]], Any] +) -> Callable[[str], Any]: """Handle tradfri api call error.""" @wraps(func) - async def wrapper(command): + async def wrapper(command: Command | list[Command]) -> None: """Decorate api call.""" try: await func(command) @@ -43,18 +49,23 @@ class TradfriBaseClass(Entity): _attr_should_poll = False def __init__( - self, device: Command, api: Callable[[str], Any], gateway_id: str + self, + device: Device, + api: Callable[[Command | list[Command]], Any], + gateway_id: str, ) -> None: """Initialize a device.""" self._api = handle_error(api) - self._device: Command | None = None - self._device_control: SocketControl | None = None + self._device: Device = device + self._device_control: BlindControl | LightControl | SocketControl | SignalRepeaterControl | None = ( + None + ) self._device_data: Socket | Light | Blind | None = None self._gateway_id = gateway_id self._refresh(device) @callback - def _async_start_observe(self, exc=None): + def _async_start_observe(self, exc: Exception | None = None) -> None: """Start observation of device.""" if exc: self.async_write_ha_state() @@ -71,17 +82,17 @@ class TradfriBaseClass(Entity): _LOGGER.warning("Observation failed, trying again", exc_info=err) self._async_start_observe() - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Start thread when added to hass.""" self._async_start_observe() @callback - def _observe_update(self, device): + def _observe_update(self, device: Device) -> None: """Receive new state data for this device.""" self._refresh(device) self.async_write_ha_state() - def _refresh(self, device: Command) -> None: + def _refresh(self, device: Device) -> None: """Refresh the device data.""" self._device = device self._attr_name = device.name @@ -94,10 +105,9 @@ class TradfriBaseDevice(TradfriBaseClass): """ @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return the device info.""" info = self._device.device_info - return { "identifiers": {(DOMAIN, self._device.id)}, "manufacturer": info.manufacturer, @@ -107,7 +117,7 @@ class TradfriBaseDevice(TradfriBaseClass): "via_device": (DOMAIN, self._gateway_id), } - def _refresh(self, device: Command) -> None: + def _refresh(self, device: Device) -> None: """Refresh the device data.""" super()._refresh(device) self._attr_available = device.reachable diff --git a/homeassistant/components/tradfri/sensor.py b/homeassistant/components/tradfri/sensor.py index 1e7d771cb39..23b7ecc2fab 100644 --- a/homeassistant/components/tradfri/sensor.py +++ b/homeassistant/components/tradfri/sensor.py @@ -45,7 +45,10 @@ class TradfriSensor(TradfriBaseDevice, SensorEntity): _attr_native_unit_of_measurement = PERCENTAGE def __init__( - self, device: Command, api: Callable[[str], Any], gateway_id: str + self, + device: Command, + api: Callable[[Command | list[Command]], Any], + gateway_id: str, ) -> None: """Initialize the device.""" super().__init__(device, api, gateway_id) diff --git a/homeassistant/components/tradfri/switch.py b/homeassistant/components/tradfri/switch.py index 6dc934814f0..7366bf7a898 100644 --- a/homeassistant/components/tradfri/switch.py +++ b/homeassistant/components/tradfri/switch.py @@ -36,7 +36,10 @@ class TradfriSwitch(TradfriBaseDevice, SwitchEntity): """The platform class required by Home Assistant.""" def __init__( - self, device: Command, api: Callable[[str], Any], gateway_id: str + self, + device: Command, + api: Callable[[Command | list[Command]], Any], + gateway_id: str, ) -> None: """Initialize a switch.""" super().__init__(device, api, gateway_id) From bb6f97c4d3f63433c60caf23d91e665b903a05f9 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 20 Sep 2021 14:49:39 +0200 Subject: [PATCH 493/843] Rework Xiaomi Miio fan platform (#55846) --- homeassistant/components/xiaomi_miio/fan.py | 339 +++++++++----------- 1 file changed, 155 insertions(+), 184 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index b3dbb4fa379..b5aa0cce780 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -1,6 +1,6 @@ """Support for Xiaomi Mi Air Purifier and Xiaomi Mi Air Humidifier.""" +from abc import abstractmethod import asyncio -from enum import Enum import logging import math @@ -20,7 +20,7 @@ from homeassistant.components.fan import ( SUPPORT_SET_SPEED, FanEntity, ) -from homeassistant.const import ATTR_ENTITY_ID, ATTR_MODE +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.util.percentage import ( @@ -62,13 +62,10 @@ from .device import XiaomiCoordinatedMiioEntity _LOGGER = logging.getLogger(__name__) -DEFAULT_NAME = "Xiaomi Miio Device" DATA_KEY = "fan.xiaomi_miio" CONF_MODEL = "model" -ATTR_MODEL = "model" - ATTR_MODE_NATURE = "Nature" ATTR_MODE_NORMAL = "Normal" @@ -86,7 +83,6 @@ ATTR_BUTTON_PRESSED = "button_pressed" # Map attributes to properties of the state object AVAILABLE_ATTRIBUTES_AIRPURIFIER_COMMON = { - ATTR_MODE: "mode", ATTR_EXTRA_FEATURES: "extra_features", ATTR_TURBO_MODE_SUPPORTED: "turbo_mode_supported", ATTR_BUTTON_PRESSED: "button_pressed", @@ -107,16 +103,12 @@ AVAILABLE_ATTRIBUTES_AIRPURIFIER_PRO = { ATTR_SLEEP_LEARN_COUNT: "sleep_mode_learn_count", } -AVAILABLE_ATTRIBUTES_AIRPURIFIER_MIOT = { - ATTR_MODE: "mode", - ATTR_USE_TIME: "use_time", -} +AVAILABLE_ATTRIBUTES_AIRPURIFIER_MIOT = {ATTR_USE_TIME: "use_time"} AVAILABLE_ATTRIBUTES_AIRPURIFIER_PRO_V7 = AVAILABLE_ATTRIBUTES_AIRPURIFIER_COMMON AVAILABLE_ATTRIBUTES_AIRPURIFIER_V3 = { # Common set isn't used here. It's a very basic version of the device. - ATTR_MODE: "mode", ATTR_SLEEP_TIME: "sleep_time", ATTR_SLEEP_LEARN_COUNT: "sleep_mode_learn_count", ATTR_EXTRA_FEATURES: "extra_features", @@ -125,29 +117,16 @@ AVAILABLE_ATTRIBUTES_AIRPURIFIER_V3 = { } AVAILABLE_ATTRIBUTES_AIRFRESH = { - ATTR_MODE: "mode", ATTR_USE_TIME: "use_time", ATTR_EXTRA_FEATURES: "extra_features", } PRESET_MODES_AIRPURIFIER = ["Auto", "Silent", "Favorite", "Idle"] PRESET_MODES_AIRPURIFIER_MIOT = ["Auto", "Silent", "Favorite", "Fan"] -OPERATION_MODES_AIRPURIFIER_PRO = ["Auto", "Silent", "Favorite"] PRESET_MODES_AIRPURIFIER_PRO = ["Auto", "Silent", "Favorite"] -OPERATION_MODES_AIRPURIFIER_PRO_V7 = OPERATION_MODES_AIRPURIFIER_PRO PRESET_MODES_AIRPURIFIER_PRO_V7 = PRESET_MODES_AIRPURIFIER_PRO -OPERATION_MODES_AIRPURIFIER_2S = ["Auto", "Silent", "Favorite"] PRESET_MODES_AIRPURIFIER_2S = ["Auto", "Silent", "Favorite"] -OPERATION_MODES_AIRPURIFIER_3 = ["Auto", "Silent", "Favorite", "Fan"] -OPERATION_MODES_AIRPURIFIER_V3 = [ - "Auto", - "Silent", - "Favorite", - "Idle", - "Medium", - "High", - "Strong", -] +PRESET_MODES_AIRPURIFIER_3C = ["Auto", "Silent", "Favorite"] PRESET_MODES_AIRPURIFIER_V3 = [ "Auto", "Silent", @@ -157,7 +136,6 @@ PRESET_MODES_AIRPURIFIER_V3 = [ "High", "Strong", ] -OPERATION_MODES_AIRFRESH = ["Auto", "Silent", "Interval", "Low", "Middle", "Strong"] PRESET_MODES_AIRFRESH = ["Auto", "Interval"] AIRPURIFIER_SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.entity_ids}) @@ -272,15 +250,13 @@ class XiaomiGenericDevice(XiaomiCoordinatedMiioEntity, FanEntity): """Initialize the generic Xiaomi device.""" super().__init__(name, device, entry, unique_id, coordinator) - self._available = False self._available_attributes = {} self._state = None self._mode = None self._fan_level = None - self._state_attrs = {ATTR_MODEL: self._model} + self._state_attrs = {} self._device_features = 0 self._supported_features = 0 - self._speed_count = 100 self._preset_modes = [] @property @@ -288,11 +264,6 @@ class XiaomiGenericDevice(XiaomiCoordinatedMiioEntity, FanEntity): """Flag supported features.""" return self._supported_features - @property - def speed_count(self): - """Return the number of speeds of the fan supported.""" - return self._speed_count - @property def preset_modes(self) -> list: """Get the list of available preset modes.""" @@ -303,16 +274,6 @@ class XiaomiGenericDevice(XiaomiCoordinatedMiioEntity, FanEntity): """Return the percentage based speed of the fan.""" return None - @property - def preset_mode(self): - """Return the percentage based speed of the fan.""" - return None - - @property - def available(self): - """Return true when state is known.""" - return super().available and self._available - @property def extra_state_attributes(self): """Return the state attributes of the device.""" @@ -323,36 +284,6 @@ class XiaomiGenericDevice(XiaomiCoordinatedMiioEntity, FanEntity): """Return true if device is on.""" return self._state - @staticmethod - def _extract_value_from_attribute(state, attribute): - value = getattr(state, attribute) - if isinstance(value, Enum): - return value.value - - return value - - @callback - def _handle_coordinator_update(self): - """Fetch state from the device.""" - self._available = True - self._state = self.coordinator.data.is_on - self._state_attrs.update( - { - key: self._extract_value_from_attribute(self.coordinator.data, value) - for key, value in self._available_attributes.items() - } - ) - self._mode = self._state_attrs.get(ATTR_MODE) - self._fan_level = getattr(self.coordinator.data, ATTR_FAN_LEVEL, None) - self.async_write_ha_state() - - # - # The fan entity model has changed to use percentages and preset_modes - # instead of speeds. - # - # Please review - # https://developers.home-assistant.io/docs/core/entity/fan/ - # async def async_turn_on( self, speed: str = None, @@ -386,15 +317,51 @@ class XiaomiGenericDevice(XiaomiCoordinatedMiioEntity, FanEntity): self.async_write_ha_state() -class XiaomiAirPurifier(XiaomiGenericDevice): - """Representation of a Xiaomi Air Purifier.""" +class XiaomiGenericAirPurifier(XiaomiGenericDevice): + """Representation of a generic AirPurifier device.""" - PRESET_MODE_MAPPING = { - "Auto": AirpurifierOperationMode.Auto, - "Silent": AirpurifierOperationMode.Silent, - "Favorite": AirpurifierOperationMode.Favorite, - "Idle": AirpurifierOperationMode.Favorite, - } + def __init__(self, name, device, entry, unique_id, coordinator): + """Initialize the generic AirPurifier device.""" + super().__init__(name, device, entry, unique_id, coordinator) + + self._speed_count = 100 + + @property + @abstractmethod + def operation_mode_class(self): + """Hold operation mode class.""" + + @property + def speed_count(self): + """Return the number of speeds of the fan supported.""" + return self._speed_count + + @property + def preset_mode(self): + """Get the active preset mode.""" + if self._state: + preset_mode = self.operation_mode_class(self._mode).name + return preset_mode if preset_mode in self._preset_modes else None + + return None + + @callback + def _handle_coordinator_update(self): + """Fetch state from the device.""" + self._state = self.coordinator.data.is_on + self._state_attrs.update( + { + key: getattr(self.coordinator.data, value) + for key, value in self._available_attributes.items() + } + ) + self._mode = self.coordinator.data.mode.value + self._fan_level = getattr(self.coordinator.data, ATTR_FAN_LEVEL, None) + self.async_write_ha_state() + + +class XiaomiAirPurifier(XiaomiGenericAirPurifier): + """Representation of a Xiaomi Air Purifier.""" SPEED_MODE_MAPPING = { 1: AirpurifierOperationMode.Silent, @@ -415,63 +382,57 @@ class XiaomiAirPurifier(XiaomiGenericDevice): self._preset_modes = PRESET_MODES_AIRPURIFIER_PRO self._supported_features = SUPPORT_PRESET_MODE self._speed_count = 1 - self._operation_mode_class = AirpurifierOperationMode elif self._model == MODEL_AIRPURIFIER_PRO_V7: self._device_features = FEATURE_FLAGS_AIRPURIFIER_PRO_V7 self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER_PRO_V7 self._preset_modes = PRESET_MODES_AIRPURIFIER_PRO_V7 self._supported_features = SUPPORT_PRESET_MODE self._speed_count = 1 - self._operation_mode_class = AirpurifierOperationMode elif self._model in [MODEL_AIRPURIFIER_2S, MODEL_AIRPURIFIER_2H]: self._device_features = FEATURE_FLAGS_AIRPURIFIER_2S self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER_COMMON self._preset_modes = PRESET_MODES_AIRPURIFIER_2S self._supported_features = SUPPORT_PRESET_MODE self._speed_count = 1 - self._operation_mode_class = AirpurifierOperationMode elif self._model in MODELS_PURIFIER_MIOT: self._device_features = FEATURE_FLAGS_AIRPURIFIER_MIOT self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER_MIOT self._preset_modes = PRESET_MODES_AIRPURIFIER_MIOT self._supported_features = SUPPORT_SET_SPEED | SUPPORT_PRESET_MODE self._speed_count = 3 - self._operation_mode_class = AirpurifierMiotOperationMode elif self._model == MODEL_AIRPURIFIER_V3: self._device_features = FEATURE_FLAGS_AIRPURIFIER_V3 self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER_V3 self._preset_modes = PRESET_MODES_AIRPURIFIER_V3 self._supported_features = SUPPORT_PRESET_MODE self._speed_count = 1 - self._operation_mode_class = AirpurifierOperationMode else: self._device_features = FEATURE_FLAGS_AIRPURIFIER_MIIO self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER self._preset_modes = PRESET_MODES_AIRPURIFIER self._supported_features = SUPPORT_PRESET_MODE self._speed_count = 1 - self._operation_mode_class = AirpurifierOperationMode + self._state = self.coordinator.data.is_on self._state_attrs.update( - {attribute: None for attribute in self._available_attributes} + { + key: getattr(self.coordinator.data, value) + for key, value in self._available_attributes.items() + } ) - self._mode = self._state_attrs.get(ATTR_MODE) + self._mode = self.coordinator.data.mode.value self._fan_level = getattr(self.coordinator.data, ATTR_FAN_LEVEL, None) @property - def preset_mode(self): - """Get the active preset mode.""" - if self._state: - preset_mode = self._operation_mode_class(self._mode).name - return preset_mode if preset_mode in self._preset_modes else None - - return None + def operation_mode_class(self): + """Hold operation mode class.""" + return AirpurifierOperationMode @property def percentage(self): """Return the current percentage based speed.""" if self._state: - mode = self._operation_mode_class(self._state_attrs[ATTR_MODE]) + mode = self.operation_mode_class(self._mode) if mode in self.REVERSE_SPEED_MODE_MAPPING: return ranged_value_to_percentage( (1, self._speed_count), self.REVERSE_SPEED_MODE_MAPPING[mode] @@ -495,7 +456,7 @@ class XiaomiAirPurifier(XiaomiGenericDevice): await self._try_command( "Setting operation mode of the miio device failed.", self._device.set_mode, - self._operation_mode_class(self.SPEED_MODE_MAPPING[speed_mode]), + self.operation_mode_class(self.SPEED_MODE_MAPPING[speed_mode]), ) async def async_set_preset_mode(self, preset_mode: str) -> None: @@ -509,9 +470,9 @@ class XiaomiAirPurifier(XiaomiGenericDevice): if await self._try_command( "Setting operation mode of the miio device failed.", self._device.set_mode, - self.PRESET_MODE_MAPPING[preset_mode], + self.operation_mode_class[preset_mode], ): - self._mode = self._operation_mode_class[preset_mode].value + self._mode = self.operation_mode_class[preset_mode].value self.async_write_ha_state() async def async_set_extra_features(self, features: int = 1): @@ -539,12 +500,10 @@ class XiaomiAirPurifier(XiaomiGenericDevice): class XiaomiAirPurifierMiot(XiaomiAirPurifier): """Representation of a Xiaomi Air Purifier (MiOT protocol).""" - PRESET_MODE_MAPPING = { - "Auto": AirpurifierMiotOperationMode.Auto, - "Silent": AirpurifierMiotOperationMode.Silent, - "Favorite": AirpurifierMiotOperationMode.Favorite, - "Fan": AirpurifierMiotOperationMode.Fan, - } + @property + def operation_mode_class(self): + """Hold operation mode class.""" + return AirpurifierMiotOperationMode @property def percentage(self): @@ -577,31 +536,24 @@ class XiaomiAirPurifierMiot(XiaomiAirPurifier): self.async_write_ha_state() -class XiaomiAirPurifierMB4(XiaomiGenericDevice): +class XiaomiAirPurifierMB4(XiaomiGenericAirPurifier): """Representation of a Xiaomi Air Purifier MB4.""" - PRESET_MODE_MAPPING = { - "Auto": AirpurifierMiotOperationMode.Auto, - "Silent": AirpurifierMiotOperationMode.Silent, - "Favorite": AirpurifierMiotOperationMode.Favorite, - } - def __init__(self, name, device, entry, unique_id, coordinator): """Initialize Air Purifier MB4.""" super().__init__(name, device, entry, unique_id, coordinator) self._device_features = FEATURE_FLAGS_AIRPURIFIER_3C - self._preset_modes = list(self.PRESET_MODE_MAPPING) + self._preset_modes = PRESET_MODES_AIRPURIFIER_3C self._supported_features = SUPPORT_PRESET_MODE - @property - def preset_mode(self): - """Get the active preset mode.""" - if self.coordinator.data.is_on: - preset_mode = AirpurifierMiotOperationMode(self._mode).name - return preset_mode if preset_mode in self._preset_modes else None + self._state = self.coordinator.data.is_on + self._mode = self.coordinator.data.mode.value - return None + @property + def operation_mode_class(self): + """Hold operation mode class.""" + return AirpurifierMiotOperationMode async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode of the fan.""" @@ -611,21 +563,20 @@ class XiaomiAirPurifierMB4(XiaomiGenericDevice): if await self._try_command( "Setting operation mode of the miio device failed.", self._device.set_mode, - self.PRESET_MODE_MAPPING[preset_mode], + self.operation_mode_class[preset_mode], ): - self._mode = self.PRESET_MODE_MAPPING[preset_mode].value + self._mode = self.operation_mode_class[preset_mode].value self.async_write_ha_state() @callback def _handle_coordinator_update(self): """Fetch state from the device.""" - self._available = True self._state = self.coordinator.data.is_on self._mode = self.coordinator.data.mode.value self.async_write_ha_state() -class XiaomiAirFresh(XiaomiGenericDevice): +class XiaomiAirFresh(XiaomiGenericAirPurifier): """Representation of a Xiaomi Air Fresh.""" SPEED_MODE_MAPPING = { @@ -651,19 +602,20 @@ class XiaomiAirFresh(XiaomiGenericDevice): self._speed_count = 4 self._preset_modes = PRESET_MODES_AIRFRESH self._supported_features = SUPPORT_SET_SPEED | SUPPORT_PRESET_MODE + + self._state = self.coordinator.data.is_on self._state_attrs.update( - {attribute: None for attribute in self._available_attributes} + { + key: getattr(self.coordinator.data, value) + for key, value in self._available_attributes.items() + } ) - self._mode = self._state_attrs.get(ATTR_MODE) + self._mode = self.coordinator.data.mode.value @property - def preset_mode(self): - """Get the active preset mode.""" - if self._state: - preset_mode = AirfreshOperationMode(self._mode).name - return preset_mode if preset_mode in self._preset_modes else None - - return None + def operation_mode_class(self): + """Hold operation mode class.""" + return AirfreshOperationMode @property def percentage(self): @@ -707,9 +659,9 @@ class XiaomiAirFresh(XiaomiGenericDevice): if await self._try_command( "Setting operation mode of the miio device failed.", self._device.set_mode, - self.PRESET_MODE_MAPPING[preset_mode], + self.operation_mode_class[preset_mode], ): - self._mode = self.PRESET_MODE_MAPPING[preset_mode].value + self._mode = self.operation_mode_class[preset_mode].value self.async_write_ha_state() async def async_set_extra_features(self, features: int = 1): @@ -734,11 +686,11 @@ class XiaomiAirFresh(XiaomiGenericDevice): ) -class XiaomiFan(XiaomiGenericDevice): - """Representation of a Xiaomi Fan.""" +class XiaomiGenericFan(XiaomiGenericDevice): + """Representation of a generic Xiaomi Fan.""" def __init__(self, name, device, entry, unique_id, coordinator): - """Initialize the plug switch.""" + """Initialize the fan.""" super().__init__(name, device, entry, unique_id, coordinator) if self._model == MODEL_FAN_P5: @@ -747,7 +699,6 @@ class XiaomiFan(XiaomiGenericDevice): else: self._device_features = FEATURE_FLAGS_FAN self._preset_modes = [ATTR_MODE_NATURE, ATTR_MODE_NORMAL] - self._nature_mode = False self._supported_features = ( SUPPORT_SET_SPEED | SUPPORT_OSCILLATE @@ -761,32 +712,73 @@ class XiaomiFan(XiaomiGenericDevice): @property def preset_mode(self): """Get the active preset mode.""" - return ATTR_MODE_NATURE if self._nature_mode else ATTR_MODE_NORMAL + return self._preset_mode @property def percentage(self): """Return the current speed as a percentage.""" - return self._percentage + if self._state: + return self._percentage + + return None @property def oscillating(self): """Return whether or not the fan is currently oscillating.""" return self._oscillating - @callback - def _handle_coordinator_update(self): - """Fetch state from the device.""" - self._available = True + async def async_oscillate(self, oscillating: bool) -> None: + """Set oscillation.""" + await self._try_command( + "Setting oscillate on/off of the miio device failed.", + self._device.set_oscillate, + oscillating, + ) + self._oscillating = oscillating + self.async_write_ha_state() + + async def async_set_direction(self, direction: str) -> None: + """Set the direction of the fan.""" + if self._oscillating: + await self.async_oscillate(oscillating=False) + + await self._try_command( + "Setting move direction of the miio device failed.", + self._device.set_rotate, + FanMoveDirection(FAN_DIRECTIONS_MAP[direction]), + ) + + +class XiaomiFan(XiaomiGenericFan): + """Representation of a Xiaomi Fan.""" + + def __init__(self, name, device, entry, unique_id, coordinator): + """Initialize the fan.""" + super().__init__(name, device, entry, unique_id, coordinator) + self._state = self.coordinator.data.is_on self._oscillating = self.coordinator.data.oscillate self._nature_mode = self.coordinator.data.natural_speed != 0 - if self.coordinator.data.is_on: - if self._nature_mode: - self._percentage = self.coordinator.data.natural_speed - else: - self._percentage = self.coordinator.data.direct_speed + if self._nature_mode: + self._percentage = self.coordinator.data.natural_speed else: - self._percentage = 0 + self._percentage = self.coordinator.data.direct_speed + + @property + def preset_mode(self): + """Get the active preset mode.""" + return ATTR_MODE_NATURE if self._nature_mode else ATTR_MODE_NORMAL + + @callback + def _handle_coordinator_update(self): + """Fetch state from the device.""" + self._state = self.coordinator.data.is_on + self._oscillating = self.coordinator.data.oscillate + self._nature_mode = self.coordinator.data.natural_speed != 0 + if self._nature_mode: + self._percentage = self.coordinator.data.natural_speed + else: + self._percentage = self.coordinator.data.direct_speed self.async_write_ha_state() @@ -838,47 +830,26 @@ class XiaomiFan(XiaomiGenericDevice): else: self.async_write_ha_state() - async def async_oscillate(self, oscillating: bool) -> None: - """Set oscillation.""" - await self._try_command( - "Setting oscillate on/off of the miio device failed.", - self._device.set_oscillate, - oscillating, - ) - self._oscillating = oscillating - self.async_write_ha_state() - async def async_set_direction(self, direction: str) -> None: - """Set the direction of the fan.""" - if self._oscillating: - await self.async_oscillate(oscillating=False) - - await self._try_command( - "Setting move direction of the miio device failed.", - self._device.set_rotate, - FanMoveDirection(FAN_DIRECTIONS_MAP[direction]), - ) - - -class XiaomiFanP5(XiaomiFan): +class XiaomiFanP5(XiaomiGenericFan): """Representation of a Xiaomi Fan P5.""" - @property - def preset_mode(self): - """Get the active preset mode.""" - return self._preset_mode + def __init__(self, name, device, entry, unique_id, coordinator): + """Initialize the fan.""" + super().__init__(name, device, entry, unique_id, coordinator) + + self._state = self.coordinator.data.is_on + self._preset_mode = self.coordinator.data.mode.name + self._oscillating = self.coordinator.data.oscillate + self._percentage = self.coordinator.data.speed @callback def _handle_coordinator_update(self): """Fetch state from the device.""" - self._available = True self._state = self.coordinator.data.is_on self._preset_mode = self.coordinator.data.mode.name self._oscillating = self.coordinator.data.oscillate - if self.coordinator.data.is_on: - self._percentage = self.coordinator.data.speed - else: - self._percentage = 0 + self._percentage = self.coordinator.data.speed self.async_write_ha_state() From a84e86ff13ffccee321e2888de70787c21c8237d Mon Sep 17 00:00:00 2001 From: jan iversen Date: Mon, 20 Sep 2021 14:59:30 +0200 Subject: [PATCH 494/843] Strictly type modbus base_platform.py (#56343) --- .../components/modbus/base_platform.py | 36 +++++++++---------- homeassistant/components/modbus/modbus.py | 9 ++++- 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/modbus/base_platform.py b/homeassistant/components/modbus/base_platform.py index 64b7de1976c..0c91fe8e3a6 100644 --- a/homeassistant/components/modbus/base_platform.py +++ b/homeassistant/components/modbus/base_platform.py @@ -2,10 +2,10 @@ from __future__ import annotations from abc import abstractmethod -from datetime import timedelta +from datetime import datetime, timedelta import logging import struct -from typing import Any, Callable +from typing import Any, Callable, cast from homeassistant.const import ( CONF_ADDRESS, @@ -75,7 +75,7 @@ class BasePlatform(Entity): self._slave = entry.get(CONF_SLAVE, 0) self._address = int(entry[CONF_ADDRESS]) self._input_type = entry[CONF_INPUT_TYPE] - self._value = None + self._value: str | None = None self._scan_interval = int(entry[CONF_SCAN_INTERVAL]) self._call_active = False self._cancel_timer: Callable[[], None] | None = None @@ -90,7 +90,7 @@ class BasePlatform(Entity): self._lazy_errors = self._lazy_error_count @abstractmethod - async def async_update(self, now=None): + async def async_update(self, now: datetime | None = None) -> None: """Virtual function to be overwritten.""" @callback @@ -107,7 +107,7 @@ class BasePlatform(Entity): self.async_write_ha_state() @callback - def async_hold(self, update=True) -> None: + def async_hold(self, update: bool = True) -> None: """Remote stop entity.""" if self._cancel_call: self._cancel_call() @@ -119,7 +119,7 @@ class BasePlatform(Entity): self._attr_available = False self.async_write_ha_state() - async def async_base_added_to_hass(self): + async def async_base_added_to_hass(self) -> None: """Handle entity which will be added.""" self.async_run() self.async_on_remove( @@ -138,13 +138,13 @@ class BaseStructPlatform(BasePlatform, RestoreEntity): super().__init__(hub, config) self._swap = config[CONF_SWAP] self._data_type = config[CONF_DATA_TYPE] - self._structure = config.get(CONF_STRUCTURE) + self._structure: str = config[CONF_STRUCTURE] self._precision = config[CONF_PRECISION] self._scale = config[CONF_SCALE] self._offset = config[CONF_OFFSET] self._count = config[CONF_COUNT] - def _swap_registers(self, registers): + def _swap_registers(self, registers: list[int]) -> list[int]: """Do swap as needed.""" if self._swap in (CONF_SWAP_BYTE, CONF_SWAP_WORD_BYTE): # convert [12][34] --> [21][43] @@ -159,7 +159,7 @@ class BaseStructPlatform(BasePlatform, RestoreEntity): registers.reverse() return registers - def unpack_structure_result(self, registers): + def unpack_structure_result(self, registers: list[int]) -> str: """Convert registers to proper result.""" registers = self._swap_registers(registers) @@ -187,14 +187,14 @@ class BaseStructPlatform(BasePlatform, RestoreEntity): return ",".join(map(str, v_result)) # Apply scale and precision to floats and ints - val = self._scale * val[0] + self._offset + val_result: float | int = self._scale * val[0] + self._offset # We could convert int to float, and the code would still work; however # we lose some precision, and unit tests will fail. Therefore, we do # the conversion only when it's absolutely necessary. - if isinstance(val, int) and self._precision == 0: - return str(val) - return f"{float(val):.{self._precision}f}" + if isinstance(val_result, int) and self._precision == 0: + return str(val_result) + return f"{float(val_result):.{self._precision}f}" class BaseSwitch(BasePlatform, ToggleEntity, RestoreEntity): @@ -225,7 +225,7 @@ class BaseSwitch(BasePlatform, ToggleEntity, RestoreEntity): CALL_TYPE_WRITE_REGISTERS, ), } - self._write_type = convert[config[CONF_WRITE_TYPE]][1] + self._write_type = cast(str, convert[config[CONF_WRITE_TYPE]][1]) self.command_on = config[CONF_COMMAND_ON] self._command_off = config[CONF_COMMAND_OFF] if CONF_VERIFY in config: @@ -244,14 +244,14 @@ class BaseSwitch(BasePlatform, ToggleEntity, RestoreEntity): else: self._verify_active = False - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await self.async_base_added_to_hass() state = await self.async_get_last_state() if state: self._attr_is_on = state.state == STATE_ON - async def async_turn(self, command): + async def async_turn(self, command: int) -> None: """Evaluate switch result.""" result = await self._hub.async_pymodbus_call( self._slave, self._address, command, self._write_type @@ -272,11 +272,11 @@ class BaseSwitch(BasePlatform, ToggleEntity, RestoreEntity): else: await self.async_update() - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Set switch off.""" await self.async_turn(self._command_off) - async def async_update(self, now=None): + async def async_update(self, now: datetime | None = None) -> None: """Update the entity state.""" # remark "now" is a dummy parameter to avoid problems with # async_track_time_interval diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index fb9af241048..747e4e2f484 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -8,6 +8,7 @@ import logging from pymodbus.client.sync import ModbusSerialClient, ModbusTcpClient, ModbusUdpClient from pymodbus.constants import Defaults from pymodbus.exceptions import ModbusException +from pymodbus.pdu import ModbusResponse from pymodbus.transaction import ModbusRtuFramer from homeassistant.const import ( @@ -356,7 +357,13 @@ class ModbusHub: self._in_error = False return result - async def async_pymodbus_call(self, unit, address, value, use_call): + async def async_pymodbus_call( + self, + unit: str | int | None, + address: int, + value: str | int, + use_call: str | None, + ) -> ModbusResponse | None: """Convert async to sync pymodbus call.""" if self._config_delay: return None From fc4bb40a6321ae5f2dd0b249962a30a2baf88af6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 20 Sep 2021 15:01:49 +0200 Subject: [PATCH 495/843] Prevent opening sockets in panasonic_viera tests (#56441) --- tests/components/panasonic_viera/test_init.py | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/tests/components/panasonic_viera/test_init.py b/tests/components/panasonic_viera/test_init.py index 0f30e315683..e3fc74133d3 100644 --- a/tests/components/panasonic_viera/test_init.py +++ b/tests/components/panasonic_viera/test_init.py @@ -1,5 +1,5 @@ """Test the Panasonic Viera setup process.""" -from unittest.mock import patch +from unittest.mock import Mock, patch from homeassistant.components.panasonic_viera.const import ( ATTR_DEVICE_INFO, @@ -185,14 +185,21 @@ async def test_setup_entry_unencrypted_missing_device_info_none(hass): async def test_setup_config_flow_initiated(hass): """Test if config flow is initiated in setup.""" - assert ( - await async_setup_component( - hass, - DOMAIN, - {DOMAIN: {CONF_HOST: "0.0.0.0"}}, + mock_remote = get_mock_remote() + mock_remote.get_device_info = Mock(side_effect=OSError) + + with patch( + "homeassistant.components.panasonic_viera.config_flow.RemoteControl", + return_value=mock_remote, + ): + assert ( + await async_setup_component( + hass, + DOMAIN, + {DOMAIN: {CONF_HOST: "0.0.0.0"}}, + ) + is True ) - is True - ) assert len(hass.config_entries.flow.async_progress()) == 1 From 6f36419c6f4a5273de12ac0590b8d63444fdd603 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 20 Sep 2021 17:54:25 +0200 Subject: [PATCH 496/843] Improve statistics validation (#56457) --- homeassistant/components/sensor/recorder.py | 10 ++++------ .../components/recorder/test_websocket_api.py | 20 +++++++++---------- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 02269439cb8..8ea2a52c278 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -562,15 +562,13 @@ def validate_statistics( state = hass.states.get(entity_id) assert state is not None - metadata = statistics.get_metadata(hass, entity_id) - if not metadata: - continue - state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - metadata_unit = metadata["unit_of_measurement"] if device_class not in UNIT_CONVERSIONS: - + metadata = statistics.get_metadata(hass, entity_id) + if not metadata: + continue + metadata_unit = metadata["unit_of_measurement"] if state_unit != metadata_unit: validation_result[entity_id].append( statistics.ValidationIssue( diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index 51334432121..ed07d949808 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -86,20 +86,11 @@ async def test_validate_statistics_supported_device_class( await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) await assert_validation_result(client, {}) - # No statistics, invalid state - empty response + # No statistics, invalid state - expect error hass.states.async_set( "sensor.test", 11, attributes={**attributes, **{"unit_of_measurement": "dogs"}} ) await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) - await assert_validation_result(client, {}) - - # Statistics has run, invalid state - expect error - await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) - hass.data[DATA_INSTANCE].do_adhoc_statistics(start=now) - hass.states.async_set( - "sensor.test", 12, attributes={**attributes, **{"unit_of_measurement": "dogs"}} - ) - await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) expected = { "sensor.test": [ { @@ -114,6 +105,15 @@ async def test_validate_statistics_supported_device_class( } await assert_validation_result(client, expected) + # Statistics has run, invalid state - expect error + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + hass.data[DATA_INSTANCE].do_adhoc_statistics(start=now) + hass.states.async_set( + "sensor.test", 12, attributes={**attributes, **{"unit_of_measurement": "dogs"}} + ) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + await assert_validation_result(client, expected) + # Valid state - empty response hass.states.async_set( "sensor.test", 13, attributes={**attributes, **{"unit_of_measurement": unit}} From 4c4bd740f3b8734c8525d9b00f2c555c526fc110 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 20 Sep 2021 18:13:09 +0200 Subject: [PATCH 497/843] Use EntityDescription - flume (#56433) --- homeassistant/components/flume/const.py | 50 +++++++++++++++---- homeassistant/components/flume/sensor.py | 61 +++++++++++------------- 2 files changed, 68 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/flume/const.py b/homeassistant/components/flume/const.py index a7bb9fbd3c8..5060bd96489 100644 --- a/homeassistant/components/flume/const.py +++ b/homeassistant/components/flume/const.py @@ -1,4 +1,8 @@ """The Flume component.""" +from __future__ import annotations + +from homeassistant.components.sensor import SensorEntityDescription + DOMAIN = "flume" PLATFORMS = ["sensor"] @@ -6,15 +10,43 @@ PLATFORMS = ["sensor"] DEFAULT_NAME = "Flume Sensor" FLUME_TYPE_SENSOR = 2 -FLUME_QUERIES_SENSOR = { - "current_interval": {"friendly_name": "Current", "unit_of_measurement": "gal/m"}, - "month_to_date": {"friendly_name": "Current Month", "unit_of_measurement": "gal"}, - "week_to_date": {"friendly_name": "Current Week", "unit_of_measurement": "gal"}, - "today": {"friendly_name": "Current Day", "unit_of_measurement": "gal"}, - "last_60_min": {"friendly_name": "60 Minutes", "unit_of_measurement": "gal/h"}, - "last_24_hrs": {"friendly_name": "24 Hours", "unit_of_measurement": "gal/d"}, - "last_30_days": {"friendly_name": "30 Days", "unit_of_measurement": "gal/mo"}, -} +FLUME_QUERIES_SENSOR: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="current_interval", + name="Current", + native_unit_of_measurement="gal/m", + ), + SensorEntityDescription( + key="month_to_date", + name="Current Month", + native_unit_of_measurement="gal", + ), + SensorEntityDescription( + key="week_to_date", + name="Current Week", + native_unit_of_measurement="gal", + ), + SensorEntityDescription( + key="today", + name="Current Day", + native_unit_of_measurement="gal", + ), + SensorEntityDescription( + key="last_60_min", + name="60 Minutes", + native_unit_of_measurement="gal/h", + ), + SensorEntityDescription( + key="last_24_hrs", + name="24 Hours", + native_unit_of_measurement="gal/d", + ), + SensorEntityDescription( + key="last_30_days", + name="30 Days", + native_unit_of_measurement="gal/mo", + ), +) FLUME_AUTH = "flume_auth" FLUME_HTTP_SESSION = "http_session" diff --git a/homeassistant/components/flume/sensor.py b/homeassistant/components/flume/sensor.py index ee67a863be6..ff4610ca788 100644 --- a/homeassistant/components/flume/sensor.py +++ b/homeassistant/components/flume/sensor.py @@ -6,7 +6,11 @@ from numbers import Number from pyflume import FlumeData import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( CONF_CLIENT_ID, @@ -93,16 +97,18 @@ async def async_setup_entry(hass, config_entry, async_add_entities): coordinator = _create_flume_device_coordinator(hass, flume_device) - for flume_query_sensor in FLUME_QUERIES_SENSOR.items(): - flume_entity_list.append( + flume_entity_list.extend( + [ FlumeSensor( coordinator, flume_device, - flume_query_sensor, - f"{device_friendly_name} {flume_query_sensor[1]['friendly_name']}", + device_friendly_name, device_id, + description, ) - ) + for description in FLUME_QUERIES_SENSOR + ] + ) if flume_entity_list: async_add_entities(flume_entity_list) @@ -111,50 +117,37 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class FlumeSensor(CoordinatorEntity, SensorEntity): """Representation of the Flume sensor.""" - def __init__(self, coordinator, flume_device, flume_query_sensor, name, device_id): + def __init__( + self, + coordinator, + flume_device, + name, + device_id, + description: SensorEntityDescription, + ): """Initialize the Flume sensor.""" super().__init__(coordinator) + self.entity_description = description self._flume_device = flume_device - self._flume_query_sensor = flume_query_sensor - self._name = name - self._device_id = device_id - self._state = None - @property - def device_info(self): - """Device info for the flume sensor.""" - return { - "name": self._name, - "identifiers": {(DOMAIN, self._device_id)}, + self._attr_name = f"{name} {description.name}" + self._attr_unique_id = f"{description.key}_{device_id}" + self._attr_device_info = { + "name": self.name, + "identifiers": {(DOMAIN, device_id)}, "manufacturer": "Flume, Inc.", "model": "Flume Smart Water Monitor", } - @property - def name(self): - """Return the name of the sensor.""" - return self._name - @property def native_value(self): """Return the state of the sensor.""" - sensor_key = self._flume_query_sensor[0] + sensor_key = self.entity_description.key if sensor_key not in self._flume_device.values: return None return _format_state_value(self._flume_device.values[sensor_key]) - @property - def native_unit_of_measurement(self): - """Return the unit the value is expressed in.""" - # This is in gallons per SCAN_INTERVAL - return self._flume_query_sensor[1]["unit_of_measurement"] - - @property - def unique_id(self): - """Flume query and Device unique ID.""" - return f"{self._flume_query_sensor[0]}_{self._device_id}" - async def async_added_to_hass(self): """Request an update when added.""" await super().async_added_to_hass() From f3ad4ca0cc85ccd828bc68dee0d3fd0da73844a9 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Mon, 20 Sep 2021 18:47:05 +0200 Subject: [PATCH 498/843] Strictly type modbus.py. (#56375) --- homeassistant/components/modbus/modbus.py | 70 +++++++++++++---------- 1 file changed, 41 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 747e4e2f484..f30f7893022 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -4,12 +4,19 @@ from __future__ import annotations import asyncio from collections import namedtuple import logging +from typing import Any, Callable -from pymodbus.client.sync import ModbusSerialClient, ModbusTcpClient, ModbusUdpClient +from pymodbus.client.sync import ( + BaseModbusClient, + ModbusSerialClient, + ModbusTcpClient, + ModbusUdpClient, +) from pymodbus.constants import Defaults from pymodbus.exceptions import ModbusException from pymodbus.pdu import ModbusResponse from pymodbus.transaction import ModbusRtuFramer +import voluptuous as vol from homeassistant.const import ( CONF_DELAY, @@ -21,10 +28,11 @@ from homeassistant.const import ( CONF_TYPE, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.event import Event, async_call_later +from homeassistant.helpers.typing import ConfigType from .const import ( ATTR_ADDRESS, @@ -113,12 +121,12 @@ PYMODBUS_CALL = [ async def async_modbus_setup( - hass, - config, - service_write_register_schema, - service_write_coil_schema, - service_stop_start_schema, -): + hass: HomeAssistant, + config: ConfigType, + service_write_register_schema: vol.Schema, + service_write_coil_schema: vol.Schema, + service_stop_start_schema: vol.Schema, +) -> bool: """Set up Modbus component.""" hass.data[DOMAIN] = hub_collect = {} @@ -138,7 +146,7 @@ async def async_modbus_setup( async_load_platform(hass, component, DOMAIN, conf_hub, config) ) - async def async_stop_modbus(event): + async def async_stop_modbus(event: Event) -> None: """Stop Modbus service.""" async_dispatcher_send(hass, SIGNAL_STOP_ENTITY) @@ -147,7 +155,7 @@ async def async_modbus_setup( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop_modbus) - async def async_write_register(service): + async def async_write_register(service: ServiceCall) -> None: """Write Modbus registers.""" unit = int(float(service.data[ATTR_UNIT])) address = int(float(service.data[ATTR_ADDRESS])) @@ -171,7 +179,7 @@ async def async_modbus_setup( schema=service_write_register_schema, ) - async def async_write_coil(service): + async def async_write_coil(service: ServiceCall) -> None: """Write Modbus coil.""" unit = service.data[ATTR_UNIT] address = service.data[ATTR_ADDRESS] @@ -188,7 +196,7 @@ async def async_modbus_setup( DOMAIN, SERVICE_WRITE_COIL, async_write_coil, schema=service_write_coil_schema ) - async def async_stop_hub(service): + async def async_stop_hub(service: ServiceCall) -> None: """Stop Modbus hub.""" async_dispatcher_send(hass, SIGNAL_STOP_ENTITY) hub = hub_collect[service.data[ATTR_HUB]] @@ -198,7 +206,7 @@ async def async_modbus_setup( DOMAIN, SERVICE_STOP, async_stop_hub, schema=service_stop_start_schema ) - async def async_restart_hub(service): + async def async_restart_hub(service: ServiceCall) -> None: """Restart Modbus hub.""" async_dispatcher_send(hass, SIGNAL_START_ENTITY) hub = hub_collect[service.data[ATTR_HUB]] @@ -213,19 +221,19 @@ async def async_modbus_setup( class ModbusHub: """Thread safe wrapper class for pymodbus.""" - def __init__(self, hass, client_config): + def __init__(self, hass: HomeAssistant, client_config: dict[str, Any]) -> None: """Initialize the Modbus hub.""" # generic configuration - self._client = None - self._async_cancel_listener = None + self._client: BaseModbusClient | None = None + self._async_cancel_listener: Callable[[], None] | None = None self._in_error = False self._lock = asyncio.Lock() self.hass = hass self.name = client_config[CONF_NAME] self._config_type = client_config[CONF_TYPE] self._config_delay = client_config[CONF_DELAY] - self._pb_call = {} + self._pb_call: dict[str, RunEntry] = {} self._pb_class = { SERIAL: ModbusSerialClient, TCP: ModbusTcpClient, @@ -264,7 +272,7 @@ class ModbusHub: else: self._msg_wait = 0 - def _log_error(self, text: str, error_state=True): + def _log_error(self, text: str, error_state: bool = True) -> None: log_text = f"Pymodbus: {self.name}: {text}" if self._in_error: _LOGGER.debug(log_text) @@ -272,7 +280,7 @@ class ModbusHub: _LOGGER.error(log_text) self._in_error = error_state - async def async_setup(self): + async def async_setup(self) -> bool: """Set up pymodbus client.""" try: self._client = self._pb_class[self._config_type](**self._pb_params) @@ -287,7 +295,7 @@ class ModbusHub: await self.async_connect_task() return True - async def async_connect_task(self): + async def async_connect_task(self) -> None: """Try to connect, and retry if needed.""" async with self._lock: if not await self.hass.async_add_executor_job(self._pymodbus_connect): @@ -302,19 +310,19 @@ class ModbusHub: ) @callback - def async_end_delay(self, args): + def async_end_delay(self, args: Any) -> None: """End startup delay.""" self._async_cancel_listener = None self._config_delay = 0 - async def async_restart(self): + async def async_restart(self) -> None: """Reconnect client.""" if self._client: await self.async_close() await self.async_setup() - async def async_close(self): + async def async_close(self) -> None: """Disconnect client.""" if self._async_cancel_listener: self._async_cancel_listener() @@ -330,8 +338,10 @@ class ModbusHub: message = f"modbus {self.name} communication closed" _LOGGER.warning(message) - def _pymodbus_connect(self): + def _pymodbus_connect(self) -> bool: """Connect client.""" + if not self._client: + return False try: self._client.connect() except ModbusException as exception_error: @@ -342,7 +352,9 @@ class ModbusHub: _LOGGER.warning(message) return True - def _pymodbus_call(self, unit, address, value, use_call): + def _pymodbus_call( + self, unit: int, address: int, value: int | list[int], use_call: str + ) -> ModbusResponse: """Call sync. pymodbus.""" kwargs = {"unit": unit} if unit else {} entry = self._pb_call[use_call] @@ -359,10 +371,10 @@ class ModbusHub: async def async_pymodbus_call( self, - unit: str | int | None, + unit: int | None, address: int, - value: str | int, - use_call: str | None, + value: int | list[int], + use_call: str, ) -> ModbusResponse | None: """Convert async to sync pymodbus call.""" if self._config_delay: From 9e2a29dc37dd17b8e819272d7d9b147542c275cf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 20 Sep 2021 12:32:01 -0500 Subject: [PATCH 499/843] Improve yeelight stability by moving timeout handling to upstream library (#56432) --- homeassistant/components/yeelight/__init__.py | 18 +++--- homeassistant/components/yeelight/light.py | 5 +- .../components/yeelight/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/yeelight/test_light.py | 59 +++++++++++++++++++ 6 files changed, 72 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py index 6a5fd32213a..19fe5c550ad 100644 --- a/homeassistant/components/yeelight/__init__.py +++ b/homeassistant/components/yeelight/__init__.py @@ -164,8 +164,8 @@ UPDATE_REQUEST_PROPERTIES = [ "active_mode", ] -BULB_NETWORK_EXCEPTIONS = (socket.error, asyncio.TimeoutError) -BULB_EXCEPTIONS = (BulbException, *BULB_NETWORK_EXCEPTIONS) +BULB_NETWORK_EXCEPTIONS = (socket.error,) +BULB_EXCEPTIONS = (BulbException, asyncio.TimeoutError, *BULB_NETWORK_EXCEPTIONS) PLATFORMS = ["binary_sensor", "light"] @@ -612,9 +612,6 @@ class YeelightDevice: @property def is_nightlight_enabled(self) -> bool: """Return true / false if nightlight is currently enabled.""" - if self.bulb is None: - return False - # Only ceiling lights have active_mode, from SDK docs: # active_mode 0: daylight mode / 1: moonlight mode (ceiling light only) if self._active_mode is not None: @@ -652,23 +649,22 @@ class YeelightDevice: async def _async_update_properties(self): """Read new properties from the device.""" - if not self.bulb: - return - try: await self.bulb.async_get_properties(UPDATE_REQUEST_PROPERTIES) self._available = True if not self._initialized: self._initialized = True async_dispatcher_send(self._hass, DEVICE_INITIALIZED.format(self._host)) - except BULB_EXCEPTIONS as ex: + except BULB_NETWORK_EXCEPTIONS as ex: if self._available: # just inform once _LOGGER.error( "Unable to update device %s, %s: %s", self._host, self.name, ex ) self._available = False - - return self._available + except BULB_EXCEPTIONS as ex: + _LOGGER.debug( + "Unable to update device %s, %s: %s", self._host, self.name, ex + ) async def async_setup(self): """Fetch capabilities and setup name if available.""" diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py index deda6ebf9ab..30861fa0001 100644 --- a/homeassistant/components/yeelight/light.py +++ b/homeassistant/components/yeelight/light.py @@ -6,7 +6,7 @@ import math import voluptuous as vol import yeelight -from yeelight import Bulb, BulbException, Flow, RGBTransition, SleepTransition, flows +from yeelight import Bulb, Flow, RGBTransition, SleepTransition, flows from yeelight.enums import BulbType, LightType, PowerMode, SceneClass from homeassistant.components.light import ( @@ -50,6 +50,7 @@ from . import ( ATTR_COUNT, ATTR_MODE_MUSIC, ATTR_TRANSITIONS, + BULB_EXCEPTIONS, BULB_NETWORK_EXCEPTIONS, CONF_FLOW_PARAMS, CONF_MODE_MUSIC, @@ -250,7 +251,7 @@ def _async_cmd(func): raise HomeAssistantError( f"Error when calling {func.__name__} for bulb {self.device.name} at {self.device.host}: {ex}" ) from ex - except BulbException as ex: + except BULB_EXCEPTIONS as ex: # The bulb likely responded but had an error raise HomeAssistantError( f"Error when calling {func.__name__} for bulb {self.device.name} at {self.device.host}: {ex}" diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index 47329235863..d0f1eee2828 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -2,7 +2,7 @@ "domain": "yeelight", "name": "Yeelight", "documentation": "https://www.home-assistant.io/integrations/yeelight", - "requirements": ["yeelight==0.7.4", "async-upnp-client==0.21.2"], + "requirements": ["yeelight==0.7.5", "async-upnp-client==0.21.2"], "codeowners": ["@rytilahti", "@zewelor", "@shenxn", "@starkillerOG"], "config_flow": true, "dependencies": ["network"], diff --git a/requirements_all.txt b/requirements_all.txt index db505b3584a..8ab676e8870 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2448,7 +2448,7 @@ yalesmartalarmclient==0.3.4 yalexs==1.1.13 # homeassistant.components.yeelight -yeelight==0.7.4 +yeelight==0.7.5 # homeassistant.components.yeelightsunflower yeelightsunflower==0.0.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0d5c83200ab..ebdf6abee6d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1389,7 +1389,7 @@ yalesmartalarmclient==0.3.4 yalexs==1.1.13 # homeassistant.components.yeelight -yeelight==0.7.4 +yeelight==0.7.5 # homeassistant.components.youless youless-api==0.12 diff --git a/tests/components/yeelight/test_light.py b/tests/components/yeelight/test_light.py index 9b52ab5f53b..f4cae17a30c 100644 --- a/tests/components/yeelight/test_light.py +++ b/tests/components/yeelight/test_light.py @@ -1,6 +1,7 @@ """Test the Yeelight light.""" import asyncio import logging +import socket from unittest.mock import ANY, AsyncMock, MagicMock, call, patch import pytest @@ -505,6 +506,64 @@ async def test_services(hass: HomeAssistant, caplog): {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_BRIGHTNESS: 55}, blocking=True, ) + assert hass.states.get(ENTITY_LIGHT).state == STATE_OFF + + mocked_bulb.async_set_brightness = AsyncMock(side_effect=socket.error) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "light", + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_BRIGHTNESS: 55}, + blocking=True, + ) + assert hass.states.get(ENTITY_LIGHT).state == STATE_UNAVAILABLE + + +async def test_update_errors(hass: HomeAssistant, caplog): + """Test update errors.""" + assert await async_setup_component(hass, "homeassistant", {}) + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + **CONFIG_ENTRY_DATA, + CONF_MODE_MUSIC: True, + CONF_SAVE_ON_CHANGE: True, + CONF_NIGHTLIGHT_SWITCH: True, + }, + ) + config_entry.add_to_hass(hass) + + mocked_bulb = _mocked_bulb() + with _patch_discovery(), _patch_discovery_interval(), patch( + f"{MODULE}.AsyncBulb", return_value=mocked_bulb + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(ENTITY_LIGHT).state == STATE_ON + assert hass.states.get(ENTITY_NIGHTLIGHT).state == STATE_OFF + + # Timeout usually means the bulb is overloaded with commands + # but will still respond eventually. + mocked_bulb.async_get_properties = AsyncMock(side_effect=asyncio.TimeoutError) + await hass.services.async_call( + "light", + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_LIGHT}, + blocking=True, + ) + assert hass.states.get(ENTITY_LIGHT).state == STATE_ON + + # socket.error usually means the bulb dropped the connection + # or lost wifi, then came back online and forced the existing + # connection closed with a TCP RST + mocked_bulb.async_get_properties = AsyncMock(side_effect=socket.error) + await hass.services.async_call( + "light", + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_LIGHT}, + blocking=True, + ) assert hass.states.get(ENTITY_LIGHT).state == STATE_UNAVAILABLE From 542f637ac4310d3322fa12151c98841486dfb9e2 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Mon, 20 Sep 2021 21:12:18 +0300 Subject: [PATCH 500/843] Improve Shelly light application/consumption type handling (#56461) --- homeassistant/components/shelly/light.py | 18 +++++++++++------- homeassistant/components/shelly/switch.py | 21 +++++++++++---------- homeassistant/components/shelly/utils.py | 12 ++++++++++++ 3 files changed, 34 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/shelly/light.py b/homeassistant/components/shelly/light.py index 6a1035816a5..cd034c1e7e5 100644 --- a/homeassistant/components/shelly/light.py +++ b/homeassistant/components/shelly/light.py @@ -51,7 +51,13 @@ from .const import ( STANDARD_RGB_EFFECTS, ) from .entity import ShellyBlockEntity, ShellyRpcEntity -from .utils import async_remove_shelly_entity, get_device_entry_gen, get_rpc_key_ids +from .utils import ( + async_remove_shelly_entity, + get_device_entry_gen, + get_rpc_key_ids, + is_block_channel_type_light, + is_rpc_channel_type_light, +) _LOGGER: Final = logging.getLogger(__name__) @@ -82,10 +88,9 @@ async def async_setup_block_entry( if block.type == "light": blocks.append(block) elif block.type == "relay": - app_type = wrapper.device.settings["relays"][int(block.channel)].get( - "appliance_type" - ) - if not app_type or app_type.lower() != "light": + if not is_block_channel_type_light( + wrapper.device.settings, int(block.channel) + ): continue blocks.append(block) @@ -110,8 +115,7 @@ async def async_setup_rpc_entry( switch_ids = [] for id_ in switch_key_ids: - con_types = wrapper.device.config["sys"]["ui_data"].get("consumption_types") - if con_types is None or con_types[id_] != "lights": + if not is_rpc_channel_type_light(wrapper.device.config, id_): continue switch_ids.append(id_) diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index d6e8fa11798..0291258b511 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -13,7 +13,13 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import BlockDeviceWrapper, RpcDeviceWrapper from .const import BLOCK, DATA_CONFIG_ENTRY, DOMAIN, RPC from .entity import ShellyBlockEntity, ShellyRpcEntity -from .utils import async_remove_shelly_entity, get_device_entry_gen, get_rpc_key_ids +from .utils import ( + async_remove_shelly_entity, + get_device_entry_gen, + get_rpc_key_ids, + is_block_channel_type_light, + is_rpc_channel_type_light, +) async def async_setup_entry( @@ -46,13 +52,9 @@ async def async_setup_block_entry( relay_blocks = [] assert wrapper.device.blocks for block in wrapper.device.blocks: - if block.type != "relay": - continue - - app_type = wrapper.device.settings["relays"][int(block.channel)].get( - "appliance_type" - ) - if app_type and app_type.lower() == "light": + if block.type != "relay" or is_block_channel_type_light( + wrapper.device.settings, int(block.channel) + ): continue relay_blocks.append(block) @@ -76,8 +78,7 @@ async def async_setup_rpc_entry( switch_ids = [] for id_ in switch_key_ids: - con_types = wrapper.device.config["sys"]["ui_data"].get("consumption_types") - if con_types is not None and con_types[id_] == "lights": + if is_rpc_channel_type_light(wrapper.device.config, id_): continue switch_ids.append(id_) diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 13b34ef5aea..4d3655829a7 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -302,3 +302,15 @@ def get_rpc_key_ids(keys_dict: dict[str, Any], key: str) -> list[int]: def is_rpc_momentary_input(config: dict[str, Any], key: str) -> bool: """Return true if rpc input button settings is set to a momentary type.""" return cast(bool, config[key]["type"] == "button") + + +def is_block_channel_type_light(settings: dict[str, Any], channel: int) -> bool: + """Return true if block channel appliance type is set to light.""" + app_type = settings["relays"][channel].get("appliance_type") + return app_type is not None and app_type.lower().startswith("light") + + +def is_rpc_channel_type_light(config: dict[str, Any], channel: int) -> bool: + """Return true if rpc channel consumption type is set to light.""" + con_types = config["sys"]["ui_data"].get("consumption_types") + return con_types is not None and con_types[channel].lower().startswith("light") From df56953c9895ca643b347be14195c030e505c517 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Mon, 20 Sep 2021 21:54:50 +0200 Subject: [PATCH 501/843] Strictly type tradfri light.py (#56389) * Strictly type light.py. --- homeassistant/components/tradfri/light.py | 81 +++++++++++++++++------ 1 file changed, 59 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/tradfri/light.py b/homeassistant/components/tradfri/light.py index 3dfdb7e6fe7..e4d7fb1fc4f 100644 --- a/homeassistant/components/tradfri/light.py +++ b/homeassistant/components/tradfri/light.py @@ -1,4 +1,10 @@ """Support for IKEA Tradfri lights.""" +from __future__ import annotations + +from typing import Any, Callable, cast + +from pytradfri.command import Command + from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, @@ -9,6 +15,9 @@ from homeassistant.components.light import ( SUPPORT_COLOR_TEMP, LightEntity, ) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.color as color_util from .base_class import TradfriBaseClass, TradfriBaseDevice @@ -28,7 +37,11 @@ from .const import ( ) -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Load Tradfri lights based on a config entry.""" gateway_id = config_entry.data[CONF_GATEWAY_ID] tradfri_data = hass.data[DOMAIN][config_entry.entry_id] @@ -50,7 +63,12 @@ class TradfriGroup(TradfriBaseClass, LightEntity): _attr_supported_features = SUPPORTED_GROUP_FEATURES - def __init__(self, device, api, gateway_id): + def __init__( + self, + device: Command, + api: Callable[[Command | list[Command]], Any], + gateway_id: str, + ) -> None: """Initialize a Group.""" super().__init__(device, api, gateway_id) @@ -58,7 +76,7 @@ class TradfriGroup(TradfriBaseClass, LightEntity): self._attr_should_poll = True self._refresh(device) - async def async_update(self): + async def async_update(self) -> None: """Fetch new state data for the group. This method is required for groups to update properly. @@ -66,20 +84,20 @@ class TradfriGroup(TradfriBaseClass, LightEntity): await self._api(self._device.update()) @property - def is_on(self): + def is_on(self) -> bool: """Return true if group lights are on.""" - return self._device.state + return cast(bool, self._device.state) @property - def brightness(self): + def brightness(self) -> int | None: """Return the brightness of the group lights.""" - return self._device.dimmer + return cast(int, self._device.dimmer) - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Instruct the group lights to turn off.""" await self._api(self._device.set_state(0)) - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Instruct the group lights to turn on, or dim.""" keys = {} if ATTR_TRANSITION in kwargs: @@ -97,7 +115,12 @@ class TradfriGroup(TradfriBaseClass, LightEntity): class TradfriLight(TradfriBaseDevice, LightEntity): """The platform class required by Home Assistant.""" - def __init__(self, device, api, gateway_id): + def __init__( + self, + device: Command, + api: Callable[[Command | list[Command]], Any], + gateway_id: str, + ) -> None: """Initialize a Light.""" super().__init__(device, api, gateway_id) self._attr_unique_id = f"light-{gateway_id}-{device.id}" @@ -114,38 +137,50 @@ class TradfriLight(TradfriBaseDevice, LightEntity): self._attr_supported_features = _features self._refresh(device) - self._attr_min_mireds = self._device_control.min_mireds - self._attr_max_mireds = self._device_control.max_mireds + if self._device_control: + self._attr_min_mireds = self._device_control.min_mireds + self._attr_max_mireds = self._device_control.max_mireds @property - def is_on(self): + def is_on(self) -> bool: """Return true if light is on.""" - return self._device_data.state + if not self._device_data: + return False + return cast(bool, self._device_data.state) @property - def brightness(self): + def brightness(self) -> int | None: """Return the brightness of the light.""" - return self._device_data.dimmer + if not self._device_data: + return None + return cast(int, self._device_data.dimmer) @property - def color_temp(self): + def color_temp(self) -> int | None: """Return the color temp value in mireds.""" - return self._device_data.color_temp + if not self._device_data: + return None + return cast(int, self._device_data.color_temp) @property - def hs_color(self): + def hs_color(self) -> tuple[float, float] | None: """HS color of the light.""" + if not self._device_control or not self._device_data: + return None if self._device_control.can_set_color: hsbxy = self._device_data.hsb_xy_color hue = hsbxy[0] / (self._device_control.max_hue / 360) sat = hsbxy[1] / (self._device_control.max_saturation / 100) if hue is not None and sat is not None: return hue, sat + return None - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Instruct the light to turn off.""" # This allows transitioning to off, but resets the brightness # to 1 for the next set_state(True) command + if not self._device_control: + return transition_time = None if ATTR_TRANSITION in kwargs: transition_time = int(kwargs[ATTR_TRANSITION]) * 10 @@ -155,8 +190,10 @@ class TradfriLight(TradfriBaseDevice, LightEntity): else: await self._api(self._device_control.set_state(False)) - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Instruct the light to turn on.""" + if not self._device_control: + return transition_time = None if ATTR_TRANSITION in kwargs: transition_time = int(kwargs[ATTR_TRANSITION]) * 10 @@ -235,7 +272,7 @@ class TradfriLight(TradfriBaseDevice, LightEntity): if command is not None: await self._api(command) - def _refresh(self, device): + def _refresh(self, device: Command) -> None: """Refresh the light data.""" super()._refresh(device) From 47340802b31fa920e515f23231c5b5176d905446 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Tue, 21 Sep 2021 00:09:44 +0300 Subject: [PATCH 502/843] Add Shelly RPC device trigger and logbook platforms (#56428) * Add RPC device trigger and logbook platforms * Single input event for Block and RPC * Add device generation to shelly.click --- homeassistant/components/shelly/__init__.py | 81 ++++++++-- homeassistant/components/shelly/const.py | 22 ++- .../components/shelly/device_trigger.py | 112 ++++++++------ homeassistant/components/shelly/logbook.py | 36 +++-- homeassistant/components/shelly/strings.json | 10 +- .../components/shelly/translations/en.json | 10 +- homeassistant/components/shelly/utils.py | 33 +++- tests/components/shelly/conftest.py | 2 + .../components/shelly/test_device_trigger.py | 145 +++++++++++++++++- tests/components/shelly/test_logbook.py | 59 ++++++- 10 files changed, 409 insertions(+), 101 deletions(-) diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 34a09338c81..ad0ad5f4387 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -31,6 +31,7 @@ from .const import ( ATTR_CHANNEL, ATTR_CLICK_TYPE, ATTR_DEVICE, + ATTR_GENERATION, BATTERY_DEVICES_WITH_PERMANENT_CONNECTION, BLOCK, CONF_COAP_PORT, @@ -44,6 +45,7 @@ from .const import ( REST, REST_SENSORS_UPDATE_INTERVAL, RPC, + RPC_INPUTS_EVENTS_TYPES, RPC_RECONNECT_INTERVAL, SHBTN_MODELS, SLEEP_PERIOD_MULTIPLIER, @@ -250,8 +252,8 @@ class BlockDeviceWrapper(update_coordinator.DataUpdateCoordinator): self.entry = entry self.device = device - self._async_remove_device_updates_handler = self.async_add_listener( - self._async_device_updates_handler + entry.async_on_unload( + self.async_add_listener(self._async_device_updates_handler) ) self._last_input_events_count: dict = {} @@ -306,6 +308,7 @@ class BlockDeviceWrapper(update_coordinator.DataUpdateCoordinator): ATTR_DEVICE: self.device.settings["device"]["hostname"], ATTR_CHANNEL: channel, ATTR_CLICK_TYPE: INPUTS_EVENTS_DICT[event_type], + ATTR_GENERATION: 1, }, ) else: @@ -356,7 +359,6 @@ class BlockDeviceWrapper(update_coordinator.DataUpdateCoordinator): def shutdown(self) -> None: """Shutdown the wrapper.""" self.device.shutdown() - self._async_remove_device_updates_handler() @callback def _handle_ha_stop(self, _event: Event) -> None: @@ -435,27 +437,40 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -def get_device_wrapper( +def get_block_device_wrapper( hass: HomeAssistant, device_id: str -) -> BlockDeviceWrapper | RpcDeviceWrapper | None: - """Get a Shelly device wrapper for the given device id.""" +) -> BlockDeviceWrapper | None: + """Get a Shelly block device wrapper for the given device id.""" if not hass.data.get(DOMAIN): return None - for config_entry in hass.data[DOMAIN][DATA_CONFIG_ENTRY]: - block_wrapper: BlockDeviceWrapper | None = hass.data[DOMAIN][DATA_CONFIG_ENTRY][ - config_entry - ].get(BLOCK) + dev_reg = device_registry.async_get(hass) + if device := dev_reg.async_get(device_id): + for config_entry in device.config_entries: + if not hass.data[DOMAIN][DATA_CONFIG_ENTRY].get(config_entry): + continue - if block_wrapper and block_wrapper.device_id == device_id: - return block_wrapper + if wrapper := hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry].get(BLOCK): + return cast(BlockDeviceWrapper, wrapper) - rpc_wrapper: RpcDeviceWrapper | None = hass.data[DOMAIN][DATA_CONFIG_ENTRY][ - config_entry - ].get(RPC) + return None - if rpc_wrapper and rpc_wrapper.device_id == device_id: - return rpc_wrapper + +def get_rpc_device_wrapper( + hass: HomeAssistant, device_id: str +) -> RpcDeviceWrapper | None: + """Get a Shelly RPC device wrapper for the given device id.""" + if not hass.data.get(DOMAIN): + return None + + dev_reg = device_registry.async_get(hass) + if device := dev_reg.async_get(device_id): + for config_entry in device.config_entries: + if not hass.data[DOMAIN][DATA_CONFIG_ENTRY].get(config_entry): + continue + + if wrapper := hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry].get(RPC): + return cast(RpcDeviceWrapper, wrapper) return None @@ -479,10 +494,42 @@ class RpcDeviceWrapper(update_coordinator.DataUpdateCoordinator): self.entry = entry self.device = device + entry.async_on_unload( + self.async_add_listener(self._async_device_updates_handler) + ) + self._last_event: dict[str, Any] | None = None + entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop) ) + @callback + def _async_device_updates_handler(self) -> None: + """Handle device updates.""" + if ( + not self.device.initialized + or not self.device.event + or self.device.event == self._last_event + ): + return + + self._last_event = self.device.event + + for event in self.device.event["events"]: + if event.get("event") not in RPC_INPUTS_EVENTS_TYPES: + continue + + self.hass.bus.async_fire( + EVENT_SHELLY_CLICK, + { + ATTR_DEVICE_ID: self.device_id, + ATTR_DEVICE: self.device.hostname, + ATTR_CHANNEL: event["id"] + 1, + ATTR_CLICK_TYPE: event["event"], + ATTR_GENERATION: 2, + }, + ) + async def _async_update_data(self) -> None: """Fetch data.""" if self.device.connected: diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index 917c10ff57c..3c9c24b1f7f 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -64,18 +64,28 @@ INPUTS_EVENTS_DICT: Final = { # List of battery devices that maintain a permanent WiFi connection BATTERY_DEVICES_WITH_PERMANENT_CONNECTION: Final = ["SHMOS-01"] +# Button/Click events for Block & RPC devices EVENT_SHELLY_CLICK: Final = "shelly.click" ATTR_CLICK_TYPE: Final = "click_type" ATTR_CHANNEL: Final = "channel" ATTR_DEVICE: Final = "device" +ATTR_GENERATION: Final = "generation" CONF_SUBTYPE: Final = "subtype" BASIC_INPUTS_EVENTS_TYPES: Final = {"single", "long"} SHBTN_INPUTS_EVENTS_TYPES: Final = {"single", "double", "triple", "long"} -SUPPORTED_INPUTS_EVENTS_TYPES: Final = { +RPC_INPUTS_EVENTS_TYPES: Final = { + "btn_down", + "btn_up", + "single_push", + "double_push", + "long_push", +} + +BLOCK_INPUTS_EVENTS_TYPES: Final = { "single", "double", "triple", @@ -84,9 +94,15 @@ SUPPORTED_INPUTS_EVENTS_TYPES: Final = { "long_single", } -SHIX3_1_INPUTS_EVENTS_TYPES = SUPPORTED_INPUTS_EVENTS_TYPES +SHIX3_1_INPUTS_EVENTS_TYPES = BLOCK_INPUTS_EVENTS_TYPES -INPUTS_EVENTS_SUBTYPES: Final = {"button": 1, "button1": 1, "button2": 2, "button3": 3} +INPUTS_EVENTS_SUBTYPES: Final = { + "button": 1, + "button1": 1, + "button2": 2, + "button3": 3, + "button4": 4, +} SHBTN_MODELS: Final = ["SHBTN-1", "SHBTN-2"] diff --git a/homeassistant/components/shelly/device_trigger.py b/homeassistant/components/shelly/device_trigger.py index 552d1d62032..f5abf76e8f2 100644 --- a/homeassistant/components/shelly/device_trigger.py +++ b/homeassistant/components/shelly/device_trigger.py @@ -25,28 +25,52 @@ from homeassistant.const import ( from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.helpers.typing import ConfigType -from . import RpcDeviceWrapper, get_device_wrapper +from . import get_block_device_wrapper, get_rpc_device_wrapper from .const import ( ATTR_CHANNEL, ATTR_CLICK_TYPE, + BLOCK_INPUTS_EVENTS_TYPES, CONF_SUBTYPE, DOMAIN, EVENT_SHELLY_CLICK, INPUTS_EVENTS_SUBTYPES, - SHBTN_INPUTS_EVENTS_TYPES, + RPC_INPUTS_EVENTS_TYPES, SHBTN_MODELS, - SUPPORTED_INPUTS_EVENTS_TYPES, ) -from .utils import get_input_triggers +from .utils import ( + get_block_input_triggers, + get_rpc_input_triggers, + get_shbtn_input_triggers, +) TRIGGER_SCHEMA: Final = DEVICE_TRIGGER_BASE_SCHEMA.extend( { - vol.Required(CONF_TYPE): vol.In(SUPPORTED_INPUTS_EVENTS_TYPES), + vol.Required(CONF_TYPE): vol.In( + RPC_INPUTS_EVENTS_TYPES | BLOCK_INPUTS_EVENTS_TYPES + ), vol.Required(CONF_SUBTYPE): vol.In(INPUTS_EVENTS_SUBTYPES), } ) +def append_input_triggers( + triggers: list[dict[str, Any]], + input_triggers: list[tuple[str, str]], + device_id: str, +) -> None: + """Add trigger to triggers list.""" + for trigger, subtype in input_triggers: + triggers.append( + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_TYPE: trigger, + CONF_SUBTYPE: subtype, + } + ) + + async def async_validate_trigger_config( hass: HomeAssistant, config: dict[str, Any] ) -> dict[str, Any]: @@ -54,23 +78,29 @@ async def async_validate_trigger_config( config = TRIGGER_SCHEMA(config) # if device is available verify parameters against device capabilities - wrapper = get_device_wrapper(hass, config[CONF_DEVICE_ID]) - - if isinstance(wrapper, RpcDeviceWrapper): - return config - - if not wrapper or not wrapper.device.initialized: - return config - trigger = (config[CONF_TYPE], config[CONF_SUBTYPE]) - assert wrapper.device.blocks + if config[CONF_TYPE] in RPC_INPUTS_EVENTS_TYPES: + rpc_wrapper = get_rpc_device_wrapper(hass, config[CONF_DEVICE_ID]) + if not rpc_wrapper or not rpc_wrapper.device.initialized: + return config - for block in wrapper.device.blocks: - input_triggers = get_input_triggers(wrapper.device, block) + input_triggers = get_rpc_input_triggers(rpc_wrapper.device) if trigger in input_triggers: return config + elif config[CONF_TYPE] in BLOCK_INPUTS_EVENTS_TYPES: + block_wrapper = get_block_device_wrapper(hass, config[CONF_DEVICE_ID]) + if not block_wrapper or not block_wrapper.device.initialized: + return config + + assert block_wrapper.device.blocks + + for block in block_wrapper.device.blocks: + input_triggers = get_block_input_triggers(block_wrapper.device, block) + if trigger in input_triggers: + return config + raise InvalidDeviceAutomationConfig( f"Invalid ({CONF_TYPE},{CONF_SUBTYPE}): {trigger}" ) @@ -80,45 +110,28 @@ async def async_get_triggers( hass: HomeAssistant, device_id: str ) -> list[dict[str, Any]]: """List device triggers for Shelly devices.""" - wrapper = get_device_wrapper(hass, device_id) - if not wrapper: - raise InvalidDeviceAutomationConfig(f"Device not found: {device_id}") + triggers: list[dict[str, Any]] = [] - if isinstance(wrapper, RpcDeviceWrapper): - return [] - - triggers = [] - - if wrapper.model in SHBTN_MODELS: - for trigger in SHBTN_INPUTS_EVENTS_TYPES: - triggers.append( - { - CONF_PLATFORM: "device", - CONF_DEVICE_ID: device_id, - CONF_DOMAIN: DOMAIN, - CONF_TYPE: trigger, - CONF_SUBTYPE: "button", - } - ) + if rpc_wrapper := get_rpc_device_wrapper(hass, device_id): + input_triggers = get_rpc_input_triggers(rpc_wrapper.device) + append_input_triggers(triggers, input_triggers, device_id) return triggers - assert wrapper.device.blocks + if block_wrapper := get_block_device_wrapper(hass, device_id): + if block_wrapper.model in SHBTN_MODELS: + input_triggers = get_shbtn_input_triggers() + append_input_triggers(triggers, input_triggers, device_id) + return triggers - for block in wrapper.device.blocks: - input_triggers = get_input_triggers(wrapper.device, block) + assert block_wrapper.device.blocks - for trigger, subtype in input_triggers: - triggers.append( - { - CONF_PLATFORM: "device", - CONF_DEVICE_ID: device_id, - CONF_DOMAIN: DOMAIN, - CONF_TYPE: trigger, - CONF_SUBTYPE: subtype, - } - ) + for block in block_wrapper.device.blocks: + input_triggers = get_block_input_triggers(block_wrapper.device, block) + append_input_triggers(triggers, input_triggers, device_id) - return triggers + return triggers + + raise InvalidDeviceAutomationConfig(f"Device not found: {device_id}") async def async_attach_trigger( @@ -137,6 +150,7 @@ async def async_attach_trigger( ATTR_CLICK_TYPE: config[CONF_TYPE], }, } + event_config = event_trigger.TRIGGER_SCHEMA(event_config) return await event_trigger.async_attach_trigger( hass, event_config, action, automation_info, platform_type="device" diff --git a/homeassistant/components/shelly/logbook.py b/homeassistant/components/shelly/logbook.py index d58691439cf..a1c8d5eceee 100644 --- a/homeassistant/components/shelly/logbook.py +++ b/homeassistant/components/shelly/logbook.py @@ -7,15 +7,17 @@ from homeassistant.const import ATTR_DEVICE_ID from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.typing import EventType -from . import RpcDeviceWrapper, get_device_wrapper +from . import get_block_device_wrapper, get_rpc_device_wrapper from .const import ( ATTR_CHANNEL, ATTR_CLICK_TYPE, ATTR_DEVICE, + BLOCK_INPUTS_EVENTS_TYPES, DOMAIN, EVENT_SHELLY_CLICK, + RPC_INPUTS_EVENTS_TYPES, ) -from .utils import get_block_device_name +from .utils import get_block_device_name, get_rpc_entity_name @callback @@ -27,23 +29,27 @@ def async_describe_events( @callback def async_describe_shelly_click_event(event: EventType) -> dict[str, str]: - """Describe shelly.click logbook event.""" - wrapper = get_device_wrapper(hass, event.data[ATTR_DEVICE_ID]) - - if isinstance(wrapper, RpcDeviceWrapper): - return {} - - if wrapper and wrapper.device.initialized: - device_name = get_block_device_name(wrapper.device) - else: - device_name = event.data[ATTR_DEVICE] - - channel = event.data[ATTR_CHANNEL] + """Describe shelly.click logbook event (block device).""" + device_id = event.data[ATTR_DEVICE_ID] click_type = event.data[ATTR_CLICK_TYPE] + channel = event.data[ATTR_CHANNEL] + input_name = f"{event.data[ATTR_DEVICE]} channel {channel}" + + if click_type in RPC_INPUTS_EVENTS_TYPES: + rpc_wrapper = get_rpc_device_wrapper(hass, device_id) + if rpc_wrapper and rpc_wrapper.device.initialized: + key = f"input:{channel-1}" + input_name = get_rpc_entity_name(rpc_wrapper.device, key) + + elif click_type in BLOCK_INPUTS_EVENTS_TYPES: + block_wrapper = get_block_device_wrapper(hass, device_id) + if block_wrapper and block_wrapper.device.initialized: + device_name = get_block_device_name(block_wrapper.device) + input_name = f"{device_name} channel {channel}" return { "name": "Shelly", - "message": f"'{click_type}' click event for {device_name} channel {channel} was fired.", + "message": f"'{click_type}' click event for {input_name} Input was fired.", } async_describe_event(DOMAIN, EVENT_SHELLY_CLICK, async_describe_shelly_click_event) diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index 85a1fa87d0c..43cae79f94a 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -33,7 +33,8 @@ "button": "Button", "button1": "First button", "button2": "Second button", - "button3": "Third button" + "button3": "Third button", + "button4": "Fourth button" }, "trigger_type": { "single": "{subtype} single clicked", @@ -41,7 +42,12 @@ "triple": "{subtype} triple clicked", "long": " {subtype} long clicked", "single_long": "{subtype} single clicked and then long clicked", - "long_single": "{subtype} long clicked and then single clicked" + "long_single": "{subtype} long clicked and then single clicked", + "btn_down": "{subtype} button down", + "btn_up": "{subtype} button up", + "single_push": "{subtype} single push", + "double_push": "{subtype} double push", + "long_push": " {subtype} long push" } } } diff --git a/homeassistant/components/shelly/translations/en.json b/homeassistant/components/shelly/translations/en.json index b60d9dfbe3e..2ed09356363 100644 --- a/homeassistant/components/shelly/translations/en.json +++ b/homeassistant/components/shelly/translations/en.json @@ -33,7 +33,8 @@ "button": "Button", "button1": "First button", "button2": "Second button", - "button3": "Third button" + "button3": "Third button", + "button4": "Fourth button" }, "trigger_type": { "double": "{subtype} double clicked", @@ -41,7 +42,12 @@ "long_single": "{subtype} long clicked and then single clicked", "single": "{subtype} single clicked", "single_long": "{subtype} single clicked and then long clicked", - "triple": "{subtype} triple clicked" + "triple": "{subtype} triple clicked", + "btn_down": "{subtype} button down", + "btn_up": "{subtype} button up", + "single_push": "{subtype} single push", + "double_push": "{subtype} double push", + "long_push": " {subtype} long push" } } } \ No newline at end of file diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 4d3655829a7..6f24b4a64be 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -22,6 +22,7 @@ from .const import ( DEFAULT_COAP_PORT, DOMAIN, MAX_RPC_KEY_INSTANCES, + RPC_INPUTS_EVENTS_TYPES, SHBTN_INPUTS_EVENTS_TYPES, SHBTN_MODELS, SHIX3_1_INPUTS_EVENTS_TYPES, @@ -162,7 +163,9 @@ def get_device_uptime(uptime: float, last_uptime: str | None) -> str: return last_uptime -def get_input_triggers(device: BlockDevice, block: Block) -> list[tuple[str, str]]: +def get_block_input_triggers( + device: BlockDevice, block: Block +) -> list[tuple[str, str]]: """Return list of input triggers for block.""" if "inputEvent" not in block.sensor_ids or "inputEventCnt" not in block.sensor_ids: return [] @@ -191,6 +194,16 @@ def get_input_triggers(device: BlockDevice, block: Block) -> list[tuple[str, str return triggers +def get_shbtn_input_triggers() -> list[tuple[str, str]]: + """Return list of input triggers for SHBTN models.""" + triggers = [] + + for trigger_type in SHBTN_INPUTS_EVENTS_TYPES: + triggers.append((trigger_type, "button")) + + return triggers + + @singleton.singleton("shelly_coap") async def get_coap_context(hass: HomeAssistant) -> COAP: """Get CoAP context to be used in all Shelly devices.""" @@ -314,3 +327,21 @@ def is_rpc_channel_type_light(config: dict[str, Any], channel: int) -> bool: """Return true if rpc channel consumption type is set to light.""" con_types = config["sys"]["ui_data"].get("consumption_types") return con_types is not None and con_types[channel].lower().startswith("light") + + +def get_rpc_input_triggers(device: RpcDevice) -> list[tuple[str, str]]: + """Return list of input triggers for RPC device.""" + triggers = [] + + key_ids = get_rpc_key_ids(device.config, "input") + + for id_ in key_ids: + key = f"input:{id_}" + if not is_rpc_momentary_input(device.config, key): + continue + + for trigger_type in RPC_INPUTS_EVENTS_TYPES: + subtype = f"button{id_+1}" + triggers.append((trigger_type, subtype)) + + return triggers diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index e38dd252b3a..9dbba7732ac 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -56,6 +56,7 @@ MOCK_BLOCKS = [ ] MOCK_CONFIG = { + "input:0": {"id": 0, "type": "button"}, "switch:0": {"name": "test switch_0"}, "sys": {"ui_data": {}}, "wifi": { @@ -147,6 +148,7 @@ async def rpc_wrapper(hass): device = Mock( call_rpc=AsyncMock(), config=MOCK_CONFIG, + event={}, shelly=MOCK_SHELLY, status=MOCK_STATUS, firmware_version="some fw string", diff --git a/tests/components/shelly/test_device_trigger.py b/tests/components/shelly/test_device_trigger.py index bf1529e4aaf..67e4660d167 100644 --- a/tests/components/shelly/test_device_trigger.py +++ b/tests/components/shelly/test_device_trigger.py @@ -30,8 +30,8 @@ from tests.common import ( ) -async def test_get_triggers(hass, coap_wrapper): - """Test we get the expected triggers from a shelly.""" +async def test_get_triggers_block_device(hass, coap_wrapper): + """Test we get the expected triggers from a shelly block device.""" assert coap_wrapper expected_triggers = [ { @@ -57,6 +57,54 @@ async def test_get_triggers(hass, coap_wrapper): assert_lists_same(triggers, expected_triggers) +async def test_get_triggers_rpc_device(hass, rpc_wrapper): + """Test we get the expected triggers from a shelly RPC device.""" + assert rpc_wrapper + expected_triggers = [ + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: rpc_wrapper.device_id, + CONF_DOMAIN: DOMAIN, + CONF_TYPE: "btn_down", + CONF_SUBTYPE: "button1", + }, + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: rpc_wrapper.device_id, + CONF_DOMAIN: DOMAIN, + CONF_TYPE: "btn_up", + CONF_SUBTYPE: "button1", + }, + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: rpc_wrapper.device_id, + CONF_DOMAIN: DOMAIN, + CONF_TYPE: "single_push", + CONF_SUBTYPE: "button1", + }, + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: rpc_wrapper.device_id, + CONF_DOMAIN: DOMAIN, + CONF_TYPE: "double_push", + CONF_SUBTYPE: "button1", + }, + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: rpc_wrapper.device_id, + CONF_DOMAIN: DOMAIN, + CONF_TYPE: "long_push", + CONF_SUBTYPE: "button1", + }, + ] + + triggers = await async_get_device_automations( + hass, "trigger", rpc_wrapper.device_id + ) + + assert_lists_same(triggers, expected_triggers) + + async def test_get_triggers_button(hass): """Test we get the expected triggers from a shelly button.""" await async_setup_component(hass, "shelly", {}) @@ -136,8 +184,8 @@ async def test_get_triggers_for_invalid_device_id(hass, device_reg, coap_wrapper await async_get_device_automations(hass, "trigger", invalid_device.id) -async def test_if_fires_on_click_event(hass, calls, coap_wrapper): - """Test for click_event trigger firing.""" +async def test_if_fires_on_click_event_block_device(hass, calls, coap_wrapper): + """Test for click_event trigger firing for block device.""" assert coap_wrapper await setup.async_setup_component(hass, "persistent_notification", {}) @@ -175,8 +223,47 @@ async def test_if_fires_on_click_event(hass, calls, coap_wrapper): assert calls[0].data["some"] == "test_trigger_single_click" -async def test_validate_trigger_config_no_device(hass, calls, coap_wrapper): - """Test for click_event with no device.""" +async def test_if_fires_on_click_event_rpc_device(hass, calls, rpc_wrapper): + """Test for click_event trigger firing for rpc device.""" + assert rpc_wrapper + await setup.async_setup_component(hass, "persistent_notification", {}) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + CONF_PLATFORM: "device", + CONF_DOMAIN: DOMAIN, + CONF_DEVICE_ID: rpc_wrapper.device_id, + CONF_TYPE: "single_push", + CONF_SUBTYPE: "button1", + }, + "action": { + "service": "test.automation", + "data_template": {"some": "test_trigger_single_push"}, + }, + }, + ] + }, + ) + + message = { + CONF_DEVICE_ID: rpc_wrapper.device_id, + ATTR_CLICK_TYPE: "single_push", + ATTR_CHANNEL: 1, + } + hass.bus.async_fire(EVENT_SHELLY_CLICK, message) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].data["some"] == "test_trigger_single_push" + + +async def test_validate_trigger_block_device_not_ready(hass, calls, coap_wrapper): + """Test validate trigger config when block device is not ready.""" assert coap_wrapper await setup.async_setup_component(hass, "persistent_notification", {}) @@ -189,7 +276,7 @@ async def test_validate_trigger_config_no_device(hass, calls, coap_wrapper): "trigger": { CONF_PLATFORM: "device", CONF_DOMAIN: DOMAIN, - CONF_DEVICE_ID: "no_device", + CONF_DEVICE_ID: "device_not_ready", CONF_TYPE: "single", CONF_SUBTYPE: "button1", }, @@ -201,7 +288,11 @@ async def test_validate_trigger_config_no_device(hass, calls, coap_wrapper): ] }, ) - message = {CONF_DEVICE_ID: "no_device", ATTR_CLICK_TYPE: "single", ATTR_CHANNEL: 1} + message = { + CONF_DEVICE_ID: "device_not_ready", + ATTR_CLICK_TYPE: "single", + ATTR_CHANNEL: 1, + } hass.bus.async_fire(EVENT_SHELLY_CLICK, message) await hass.async_block_till_done() @@ -209,6 +300,44 @@ async def test_validate_trigger_config_no_device(hass, calls, coap_wrapper): assert calls[0].data["some"] == "test_trigger_single_click" +async def test_validate_trigger_rpc_device_not_ready(hass, calls, rpc_wrapper): + """Test validate trigger config when RPC device is not ready.""" + assert rpc_wrapper + await setup.async_setup_component(hass, "persistent_notification", {}) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + CONF_PLATFORM: "device", + CONF_DOMAIN: DOMAIN, + CONF_DEVICE_ID: "device_not_ready", + CONF_TYPE: "single_push", + CONF_SUBTYPE: "button1", + }, + "action": { + "service": "test.automation", + "data_template": {"some": "test_trigger_single_push"}, + }, + }, + ] + }, + ) + message = { + CONF_DEVICE_ID: "device_not_ready", + ATTR_CLICK_TYPE: "single_push", + ATTR_CHANNEL: 1, + } + hass.bus.async_fire(EVENT_SHELLY_CLICK, message) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].data["some"] == "test_trigger_single_push" + + async def test_validate_trigger_invalid_triggers(hass, coap_wrapper): """Test for click_event with invalid triggers.""" assert coap_wrapper diff --git a/tests/components/shelly/test_logbook.py b/tests/components/shelly/test_logbook.py index 9cfda9ddcaa..9ece9590cbb 100644 --- a/tests/components/shelly/test_logbook.py +++ b/tests/components/shelly/test_logbook.py @@ -13,8 +13,8 @@ from homeassistant.setup import async_setup_component from tests.components.logbook.test_init import MockLazyEventPartialState -async def test_humanify_shelly_click_event(hass, coap_wrapper): - """Test humanifying Shelly click event.""" +async def test_humanify_shelly_click_event_block_device(hass, coap_wrapper): + """Test humanifying Shelly click event for block device.""" assert coap_wrapper hass.config.components.add("recorder") assert await async_setup_component(hass, "logbook", {}) @@ -51,12 +51,63 @@ async def test_humanify_shelly_click_event(hass, coap_wrapper): assert event1["name"] == "Shelly" assert event1["domain"] == DOMAIN assert ( - event1["message"] == "'single' click event for Test name channel 1 was fired." + event1["message"] + == "'single' click event for Test name channel 1 Input was fired." ) assert event2["name"] == "Shelly" assert event2["domain"] == DOMAIN assert ( event2["message"] - == "'long' click event for shellyswitch25-12345678 channel 2 was fired." + == "'long' click event for shellyswitch25-12345678 channel 2 Input was fired." + ) + + +async def test_humanify_shelly_click_event_rpc_device(hass, rpc_wrapper): + """Test humanifying Shelly click event for rpc device.""" + assert rpc_wrapper + hass.config.components.add("recorder") + assert await async_setup_component(hass, "logbook", {}) + entity_attr_cache = logbook.EntityAttributeCache(hass) + + event1, event2 = list( + logbook.humanify( + hass, + [ + MockLazyEventPartialState( + EVENT_SHELLY_CLICK, + { + ATTR_DEVICE_ID: rpc_wrapper.device_id, + ATTR_DEVICE: "shellyplus1pm-12345678", + ATTR_CLICK_TYPE: "single_push", + ATTR_CHANNEL: 1, + }, + ), + MockLazyEventPartialState( + EVENT_SHELLY_CLICK, + { + ATTR_DEVICE_ID: "no_device_id", + ATTR_DEVICE: "shellypro4pm-12345678", + ATTR_CLICK_TYPE: "btn_down", + ATTR_CHANNEL: 2, + }, + ), + ], + entity_attr_cache, + {}, + ) + ) + + assert event1["name"] == "Shelly" + assert event1["domain"] == DOMAIN + assert ( + event1["message"] + == "'single_push' click event for test switch_0 Input was fired." + ) + + assert event2["name"] == "Shelly" + assert event2["domain"] == DOMAIN + assert ( + event2["message"] + == "'btn_down' click event for shellypro4pm-12345678 channel 2 Input was fired." ) From 20ddd092f67cb83b18189b90ac371993ed9cc0a3 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 21 Sep 2021 00:14:33 +0200 Subject: [PATCH 503/843] Remove xiaomi_aqara entity_description property (#56456) --- homeassistant/components/xiaomi_aqara/sensor.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/homeassistant/components/xiaomi_aqara/sensor.py b/homeassistant/components/xiaomi_aqara/sensor.py index df86ef0fe77..3935f4fdc57 100644 --- a/homeassistant/components/xiaomi_aqara/sensor.py +++ b/homeassistant/components/xiaomi_aqara/sensor.py @@ -1,7 +1,6 @@ """Support for Xiaomi Aqara sensors.""" from __future__ import annotations -from functools import cached_property import logging from homeassistant.components.sensor import SensorEntity, SensorEntityDescription @@ -154,13 +153,9 @@ class XiaomiSensor(XiaomiDevice, SensorEntity): def __init__(self, device, name, data_key, xiaomi_hub, config_entry): """Initialize the XiaomiSensor.""" self._data_key = data_key + self.entity_description = SENSOR_TYPES[data_key] super().__init__(device, name, xiaomi_hub, config_entry) - @cached_property - def entity_description(self) -> SensorEntityDescription: # type: ignore[override] - """Return entity_description for data_key.""" - return SENSOR_TYPES[self._data_key] - def parse_data(self, data, raw_data): """Parse data sent by gateway.""" value = data.get(self._data_key) From b9ffd74db5d32dc5d35cabe42343583833dc5f83 Mon Sep 17 00:00:00 2001 From: Marcin Ciupak <32123526+mciupak@users.noreply.github.com> Date: Tue, 21 Sep 2021 00:38:42 +0200 Subject: [PATCH 504/843] Fix recorder Oracle DB models (#55564) * Fix recorder models for Oracle DB * Fix StatisticsRuns * Update migration for Oracle Identity columns. * Update migration logic Migration to schema version 22 done only for engine dialect oracle * Add missing table check in schema 22 migration --- .../components/recorder/migration.py | 32 ++++++++++++++++++- homeassistant/components/recorder/models.py | 8 ++--- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index c7ccefeca02..3ceac8903a7 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -20,6 +20,7 @@ from .models import ( Statistics, StatisticsMeta, StatisticsRuns, + StatisticsShortTerm, ) from .statistics import get_start_time from .util import session_scope @@ -484,7 +485,7 @@ def _apply_update(engine, session, new_version, old_version): # noqa: C901 session.add(StatisticsRuns(start=get_start_time())) elif new_version == 20: # This changed the precision of statistics from float to double - if engine.dialect.name in ["mysql", "oracle", "postgresql"]: + if engine.dialect.name in ["mysql", "postgresql"]: _modify_columns( connection, engine, @@ -513,6 +514,35 @@ def _apply_update(engine, session, new_version, old_version): # noqa: C901 "CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" ) ) + elif new_version == 22: + # Recreate the all statistics tables for Oracle DB with Identity columns + # + # Order matters! Statistics has a relation with StatisticsMeta, + # so statistics need to be deleted before meta (or in pair depending + # on the SQL backend); and meta needs to be created before statistics. + if engine.dialect.name == "oracle": + if ( + sqlalchemy.inspect(engine).has_table(StatisticsMeta.__tablename__) + or sqlalchemy.inspect(engine).has_table(Statistics.__tablename__) + or sqlalchemy.inspect(engine).has_table(StatisticsRuns.__tablename__) + or sqlalchemy.inspect(engine).has_table( + StatisticsShortTerm.__tablename__ + ) + ): + Base.metadata.drop_all( + bind=engine, + tables=[ + StatisticsShortTerm.__table__, + Statistics.__table__, + StatisticsMeta.__table__, + StatisticsRuns.__table__, + ], + ) + + StatisticsRuns.__table__.create(engine) + StatisticsMeta.__table__.create(engine) + StatisticsShortTerm.__table__.create(engine) + Statistics.__table__.create(engine) else: raise ValueError(f"No schema migration defined for version {new_version}") diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py index 1c5c9fa90f0..354811bee40 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -40,7 +40,7 @@ import homeassistant.util.dt as dt_util # pylint: disable=invalid-name Base = declarative_base() -SCHEMA_VERSION = 21 +SCHEMA_VERSION = 22 _LOGGER = logging.getLogger(__name__) @@ -238,7 +238,7 @@ class StatisticData(TypedDict, total=False): class StatisticsBase: """Statistics base class.""" - id = Column(Integer, primary_key=True) + id = Column(Integer, Identity(), primary_key=True) created = Column(DATETIME_TYPE, default=dt_util.utcnow) @declared_attr @@ -309,7 +309,7 @@ class StatisticsMeta(Base): # type: ignore {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, ) __tablename__ = TABLE_STATISTICS_META - id = Column(Integer, primary_key=True) + id = Column(Integer, Identity(), primary_key=True) statistic_id = Column(String(255), index=True) source = Column(String(32)) unit_of_measurement = Column(String(255)) @@ -406,7 +406,7 @@ class StatisticsRuns(Base): # type: ignore """Representation of statistics run.""" __tablename__ = TABLE_STATISTICS_RUNS - run_id = Column(Integer, primary_key=True) + run_id = Column(Integer, Identity(), primary_key=True) start = Column(DateTime(timezone=True)) def __repr__(self) -> str: From aabc8cd2d5b476f8f4e1a962a22f8ef718d2ab4a Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Mon, 20 Sep 2021 22:10:24 -0600 Subject: [PATCH 505/843] Add WattTime integration (#56149) --- .coveragerc | 2 + CODEOWNERS | 1 + homeassistant/components/watttime/__init__.py | 78 ++++++ .../components/watttime/config_flow.py | 165 +++++++++++ homeassistant/components/watttime/const.py | 11 + .../components/watttime/manifest.json | 17 ++ homeassistant/components/watttime/sensor.py | 134 +++++++++ .../components/watttime/strings.json | 34 +++ .../components/watttime/translations/en.json | 34 +++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/watttime/__init__.py | 1 + tests/components/watttime/test_config_flow.py | 263 ++++++++++++++++++ 14 files changed, 747 insertions(+) create mode 100644 homeassistant/components/watttime/__init__.py create mode 100644 homeassistant/components/watttime/config_flow.py create mode 100644 homeassistant/components/watttime/const.py create mode 100644 homeassistant/components/watttime/manifest.json create mode 100644 homeassistant/components/watttime/sensor.py create mode 100644 homeassistant/components/watttime/strings.json create mode 100644 homeassistant/components/watttime/translations/en.json create mode 100644 tests/components/watttime/__init__.py create mode 100644 tests/components/watttime/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 322c7e1af48..d75eb6aa9d6 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1178,6 +1178,8 @@ omit = homeassistant/components/waterfurnace/* homeassistant/components/watson_iot/* homeassistant/components/watson_tts/tts.py + homeassistant/components/watttime/__init__.py + homeassistant/components/watttime/sensor.py homeassistant/components/waze_travel_time/__init__.py homeassistant/components/waze_travel_time/helpers.py homeassistant/components/waze_travel_time/sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index bc3f6f6f838..76748306cfe 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -574,6 +574,7 @@ homeassistant/components/wake_on_lan/* @ntilley905 homeassistant/components/wallbox/* @hesselonline homeassistant/components/waqi/* @andrey-git homeassistant/components/watson_tts/* @rutkai +homeassistant/components/watttime/* @bachya homeassistant/components/weather/* @fabaff homeassistant/components/webostv/* @bendavid @thecode homeassistant/components/websocket_api/* @home-assistant/core diff --git a/homeassistant/components/watttime/__init__.py b/homeassistant/components/watttime/__init__.py new file mode 100644 index 00000000000..d376dd40db6 --- /dev/null +++ b/homeassistant/components/watttime/__init__.py @@ -0,0 +1,78 @@ +"""The WattTime integration.""" +from __future__ import annotations + +from datetime import timedelta + +from aiowatttime import Client +from aiowatttime.emissions import RealTimeEmissionsResponseType +from aiowatttime.errors import WattTimeError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_PASSWORD, + CONF_USERNAME, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DATA_COORDINATOR, DOMAIN, LOGGER + +DEFAULT_UPDATE_INTERVAL = timedelta(minutes=5) + +PLATFORMS: list[str] = ["sensor"] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up WattTime from a config entry.""" + hass.data.setdefault(DOMAIN, {DATA_COORDINATOR: {}}) + + session = aiohttp_client.async_get_clientsession(hass) + + try: + client = await Client.async_login( + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + session=session, + logger=LOGGER, + ) + except WattTimeError as err: + LOGGER.error("Error while authenticating with WattTime: %s", err) + return False + + async def async_update_data() -> RealTimeEmissionsResponseType: + """Get the latest realtime emissions data.""" + try: + return await client.emissions.async_get_realtime_emissions( + entry.data[CONF_LATITUDE], entry.data[CONF_LONGITUDE] + ) + except WattTimeError as err: + raise UpdateFailed( + f"Error while requesting data from WattTime: {err}" + ) from err + + coordinator = DataUpdateCoordinator( + hass, + LOGGER, + name=entry.title, + update_interval=DEFAULT_UPDATE_INTERVAL, + update_method=async_update_data, + ) + + await coordinator.async_config_entry_first_refresh() + hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id] = coordinator + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN][DATA_COORDINATOR].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/watttime/config_flow.py b/homeassistant/components/watttime/config_flow.py new file mode 100644 index 00000000000..a6c5dd422c2 --- /dev/null +++ b/homeassistant/components/watttime/config_flow.py @@ -0,0 +1,165 @@ +"""Config flow for WattTime integration.""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from aiowatttime import Client +from aiowatttime.errors import CoordinatesNotFoundError, InvalidCredentialsError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import ( + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_PASSWORD, + CONF_USERNAME, +) +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import aiohttp_client, config_validation as cv + +from .const import ( + CONF_BALANCING_AUTHORITY, + CONF_BALANCING_AUTHORITY_ABBREV, + DOMAIN, + LOGGER, +) + +CONF_LOCATION_TYPE = "location_type" + +LOCATION_TYPE_COORDINATES = "Specify coordinates" +LOCATION_TYPE_HOME = "Use home location" + +STEP_COORDINATES_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_LATITUDE): cv.latitude, + vol.Required(CONF_LONGITUDE): cv.longitude, + } +) + +STEP_LOCATION_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_LOCATION_TYPE): vol.In( + [LOCATION_TYPE_HOME, LOCATION_TYPE_COORDINATES] + ), + } +) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for WattTime.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize.""" + self._client: Client | None = None + self._password: str | None = None + self._username: str | None = None + + async def async_step_coordinates( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the coordinates step.""" + if not user_input: + return self.async_show_form( + step_id="coordinates", data_schema=STEP_COORDINATES_DATA_SCHEMA + ) + + if TYPE_CHECKING: + assert self._client + + unique_id = f"{user_input[CONF_LATITUDE]}, {user_input[CONF_LONGITUDE]}" + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + try: + grid_region = await self._client.emissions.async_get_grid_region( + user_input[CONF_LATITUDE], user_input[CONF_LONGITUDE] + ) + except CoordinatesNotFoundError: + return self.async_show_form( + step_id="coordinates", + data_schema=STEP_COORDINATES_DATA_SCHEMA, + errors={CONF_LATITUDE: "unknown_coordinates"}, + ) + except Exception as err: # pylint: disable=broad-except + LOGGER.exception("Unexpected exception while getting region: %s", err) + return self.async_show_form( + step_id="coordinates", + data_schema=STEP_COORDINATES_DATA_SCHEMA, + errors={"base": "unknown"}, + ) + + return self.async_create_entry( + title=unique_id, + data={ + CONF_USERNAME: self._username, + CONF_PASSWORD: self._password, + CONF_LATITUDE: user_input[CONF_LATITUDE], + CONF_LONGITUDE: user_input[CONF_LONGITUDE], + CONF_BALANCING_AUTHORITY: grid_region["name"], + CONF_BALANCING_AUTHORITY_ABBREV: grid_region["abbrev"], + }, + ) + + async def async_step_location( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the "pick a location" step.""" + if not user_input: + return self.async_show_form( + step_id="location", data_schema=STEP_LOCATION_DATA_SCHEMA + ) + + if user_input[CONF_LOCATION_TYPE] == LOCATION_TYPE_COORDINATES: + return self.async_show_form( + step_id="coordinates", data_schema=STEP_COORDINATES_DATA_SCHEMA + ) + return await self.async_step_coordinates( + { + CONF_LATITUDE: self.hass.config.latitude, + CONF_LONGITUDE: self.hass.config.longitude, + } + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if not user_input: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA + ) + + session = aiohttp_client.async_get_clientsession(self.hass) + + try: + self._client = await Client.async_login( + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + session=session, + ) + except InvalidCredentialsError: + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors={CONF_USERNAME: "invalid_auth"}, + ) + except Exception as err: # pylint: disable=broad-except + LOGGER.exception("Unexpected exception while logging in: %s", err) + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors={"base": "unknown"}, + ) + + self._username = user_input[CONF_USERNAME] + self._password = user_input[CONF_PASSWORD] + return await self.async_step_location() diff --git a/homeassistant/components/watttime/const.py b/homeassistant/components/watttime/const.py new file mode 100644 index 00000000000..680505c8d43 --- /dev/null +++ b/homeassistant/components/watttime/const.py @@ -0,0 +1,11 @@ +"""Constants for the WattTime integration.""" +import logging + +DOMAIN = "watttime" + +LOGGER = logging.getLogger(__package__) + +CONF_BALANCING_AUTHORITY = "balancing_authority" +CONF_BALANCING_AUTHORITY_ABBREV = "balancing_authority_abbreviation" + +DATA_COORDINATOR = "coordinator" diff --git a/homeassistant/components/watttime/manifest.json b/homeassistant/components/watttime/manifest.json new file mode 100644 index 00000000000..d4000b6f6b1 --- /dev/null +++ b/homeassistant/components/watttime/manifest.json @@ -0,0 +1,17 @@ +{ + "domain": "watttime", + "name": "WattTime", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/watttime", + "requirements": [ + "aiowatttime==0.1.1" + ], + "ssdp": [], + "zeroconf": [], + "homekit": {}, + "dependencies": [], + "codeowners": [ + "@bachya" + ], + "iot_class": "cloud_polling" +} diff --git a/homeassistant/components/watttime/sensor.py b/homeassistant/components/watttime/sensor.py new file mode 100644 index 00000000000..f44249ecde1 --- /dev/null +++ b/homeassistant/components/watttime/sensor.py @@ -0,0 +1,134 @@ +"""Support for WattTime sensors.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_LATITUDE, + ATTR_LONGITUDE, + MASS_POUNDS, + PERCENTAGE, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import ( + CONF_BALANCING_AUTHORITY, + CONF_BALANCING_AUTHORITY_ABBREV, + DATA_COORDINATOR, + DOMAIN, +) + +ATTR_BALANCING_AUTHORITY = "balancing_authority" + +DEFAULT_ATTRIBUTION = "Pickup data provided by WattTime" + +SENSOR_TYPE_REALTIME_EMISSIONS_MOER = "realtime_emissions_moer" +SENSOR_TYPE_REALTIME_EMISSIONS_PERCENT = "realtime_emissions_percent" + + +@dataclass +class RealtimeEmissionsSensorDescriptionMixin: + """Define an entity description mixin for realtime emissions sensors.""" + + data_key: str + + +@dataclass +class RealtimeEmissionsSensorEntityDescription( + SensorEntityDescription, RealtimeEmissionsSensorDescriptionMixin +): + """Describe a realtime emissions sensor.""" + + +REALTIME_EMISSIONS_SENSOR_DESCRIPTIONS = ( + RealtimeEmissionsSensorEntityDescription( + key=SENSOR_TYPE_REALTIME_EMISSIONS_MOER, + name="Marginal Operating Emissions Rate", + icon="mdi:blur", + native_unit_of_measurement=f"{MASS_POUNDS} CO2/MWh", + state_class=STATE_CLASS_MEASUREMENT, + data_key="moer", + ), + RealtimeEmissionsSensorEntityDescription( + key=SENSOR_TYPE_REALTIME_EMISSIONS_PERCENT, + name="Relative Marginal Emissions Intensity", + icon="mdi:blur", + native_unit_of_measurement=PERCENTAGE, + state_class=STATE_CLASS_MEASUREMENT, + data_key="percent", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up WattTime sensors based on a config entry.""" + coordinator = hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id] + async_add_entities( + [ + RealtimeEmissionsSensor(coordinator, description) + for description in REALTIME_EMISSIONS_SENSOR_DESCRIPTIONS + if description.data_key in coordinator.data + ] + ) + + +class RealtimeEmissionsSensor(CoordinatorEntity, SensorEntity): + """Define a realtime emissions sensor.""" + + entity_description: RealtimeEmissionsSensorEntityDescription + + def __init__( + self, + coordinator: DataUpdateCoordinator, + description: RealtimeEmissionsSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + + if TYPE_CHECKING: + assert coordinator.config_entry + + self._attr_extra_state_attributes = { + ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION, + ATTR_BALANCING_AUTHORITY: coordinator.config_entry.data[ + CONF_BALANCING_AUTHORITY + ], + ATTR_LATITUDE: coordinator.config_entry.data[ATTR_LATITUDE], + ATTR_LONGITUDE: coordinator.config_entry.data[ATTR_LONGITUDE], + } + self._attr_name = f"{description.name} ({coordinator.config_entry.data[CONF_BALANCING_AUTHORITY_ABBREV]})" + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}" + self.entity_description = description + + @callback + def _handle_coordinator_update(self) -> None: + """Respond to a DataUpdateCoordinator update.""" + self.update_from_latest_data() + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + self.update_from_latest_data() + + @callback + def update_from_latest_data(self) -> None: + """Update the state.""" + self._attr_native_value = self.coordinator.data[ + self.entity_description.data_key + ] diff --git a/homeassistant/components/watttime/strings.json b/homeassistant/components/watttime/strings.json new file mode 100644 index 00000000000..34dc253dcde --- /dev/null +++ b/homeassistant/components/watttime/strings.json @@ -0,0 +1,34 @@ +{ + "config": { + "step": { + "coordinates": { + "description": "Input the latitude and longitude to monitor:", + "data": { + "latitude": "[%key:common::config_flow::data::latitude%]", + "longitude": "[%key:common::config_flow::data::longitude%]" + } + }, + "location": { + "description": "Pick a location to monitor:", + "data": { + "location_type": "[%key:common::config_flow::data::location%]" + } + }, + "user": { + "description": "Input your username and password:", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "unknown_coordinates": "No data for latitude/longitude" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/watttime/translations/en.json b/homeassistant/components/watttime/translations/en.json new file mode 100644 index 00000000000..44ae51fae53 --- /dev/null +++ b/homeassistant/components/watttime/translations/en.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error", + "unknown_coordinates": "No data for latitude/longitude" + }, + "step": { + "coordinates": { + "data": { + "latitude": "Latitude", + "longitude": "Longitude" + }, + "description": "Input the latitude and longitude to monitor:" + }, + "location": { + "data": { + "location_type": "Location" + }, + "description": "Pick a location to monitor:" + }, + "user": { + "data": { + "password": "Password", + "username": "Username" + }, + "description": "Input your username and password:" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 80395e8e3f6..0983da03f98 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -303,6 +303,7 @@ FLOWS = [ "vizio", "volumio", "wallbox", + "watttime", "waze_travel_time", "wemo", "whirlpool", diff --git a/requirements_all.txt b/requirements_all.txt index 13ea083eb12..986b79e278d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -254,6 +254,9 @@ aiotractive==0.5.2 # homeassistant.components.unifi aiounifi==26 +# homeassistant.components.watttime +aiowatttime==0.1.1 + # homeassistant.components.yandex_transport aioymaps==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3238c57590f..c0dff9c66d4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -178,6 +178,9 @@ aiotractive==0.5.2 # homeassistant.components.unifi aiounifi==26 +# homeassistant.components.watttime +aiowatttime==0.1.1 + # homeassistant.components.yandex_transport aioymaps==1.1.0 diff --git a/tests/components/watttime/__init__.py b/tests/components/watttime/__init__.py new file mode 100644 index 00000000000..6e01f28b518 --- /dev/null +++ b/tests/components/watttime/__init__.py @@ -0,0 +1 @@ +"""Tests for the WattTime integration.""" diff --git a/tests/components/watttime/test_config_flow.py b/tests/components/watttime/test_config_flow.py new file mode 100644 index 00000000000..a3d2867eb2d --- /dev/null +++ b/tests/components/watttime/test_config_flow.py @@ -0,0 +1,263 @@ +"""Test the WattTime config flow.""" +from unittest.mock import AsyncMock, patch + +from aiowatttime.errors import CoordinatesNotFoundError, InvalidCredentialsError +import pytest + +from homeassistant import config_entries, setup +from homeassistant.components.watttime.config_flow import ( + CONF_LOCATION_TYPE, + LOCATION_TYPE_COORDINATES, + LOCATION_TYPE_HOME, +) +from homeassistant.components.watttime.const import ( + CONF_BALANCING_AUTHORITY, + CONF_BALANCING_AUTHORITY_ABBREV, + DOMAIN, +) +from homeassistant.const import ( + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_PASSWORD, + CONF_USERNAME, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + +from tests.common import MockConfigEntry + + +@pytest.fixture(name="client") +def client_fixture(get_grid_region): + """Define a fixture for an aiowatttime client.""" + client = AsyncMock(return_value=None) + client.emissions.async_get_grid_region = get_grid_region + return client + + +@pytest.fixture(name="client_login") +def client_login_fixture(client): + """Define a fixture for patching the aiowatttime coroutine to get a client.""" + with patch("homeassistant.components.watttime.config_flow.Client.async_login") as m: + m.return_value = client + yield m + + +@pytest.fixture(name="get_grid_region") +def get_grid_region_fixture(): + """Define a fixture for getting grid region data.""" + return AsyncMock(return_value={"abbrev": "AUTH_1", "id": 1, "name": "Authority 1"}) + + +async def test_duplicate_error(hass: HomeAssistant, client_login): + """Test that errors are shown when duplicate entries are added.""" + MockConfigEntry( + domain=DOMAIN, + unique_id="32.87336, -117.22743", + data={ + CONF_USERNAME: "user", + CONF_PASSWORD: "password", + CONF_LATITUDE: 32.87336, + CONF_LONGITUDE: -117.22743, + }, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_USERNAME: "user", CONF_PASSWORD: "password"}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_LOCATION_TYPE: LOCATION_TYPE_HOME}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_show_form_coordinates(hass: HomeAssistant) -> None: + """Test showing the form to input custom latitude/longitude.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_USERNAME: "user", CONF_PASSWORD: "password"}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_LOCATION_TYPE: LOCATION_TYPE_COORDINATES}, + ) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "coordinates" + assert result["errors"] is None + + +async def test_show_form_user(hass: HomeAssistant) -> None: + """Test showing the form to select the authentication type.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] is None + + +@pytest.mark.parametrize( + "get_grid_region", [AsyncMock(side_effect=CoordinatesNotFoundError)] +) +async def test_step_coordinates_unknown_coordinates( + hass: HomeAssistant, client_login +) -> None: + """Test that providing coordinates with no data is handled.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_USERNAME: "user", CONF_PASSWORD: "password"}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_LOCATION_TYPE: LOCATION_TYPE_COORDINATES}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_LATITUDE: "0", CONF_LONGITUDE: "0"}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {"latitude": "unknown_coordinates"} + + +@pytest.mark.parametrize("get_grid_region", [AsyncMock(side_effect=Exception)]) +async def test_step_coordinates_unknown_error( + hass: HomeAssistant, client_login +) -> None: + """Test that providing coordinates with no data is handled.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_USERNAME: "user", CONF_PASSWORD: "password"}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_LOCATION_TYPE: LOCATION_TYPE_HOME}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {"base": "unknown"} + + +async def test_step_login_coordinates(hass: HomeAssistant, client_login) -> None: + """Test a full login flow (inputting custom coordinates).""" + await setup.async_setup_component(hass, "persistent_notification", {}) + with patch( + "homeassistant.components.watttime.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_USERNAME: "user", CONF_PASSWORD: "password"}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_LOCATION_TYPE: LOCATION_TYPE_COORDINATES}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_LATITUDE: "51.528308", CONF_LONGITUDE: "-0.3817765"}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "51.528308, -0.3817765" + assert result["data"] == { + CONF_USERNAME: "user", + CONF_PASSWORD: "password", + CONF_LATITUDE: 51.528308, + CONF_LONGITUDE: -0.3817765, + CONF_BALANCING_AUTHORITY: "Authority 1", + CONF_BALANCING_AUTHORITY_ABBREV: "AUTH_1", + } + + +async def test_step_user_home(hass: HomeAssistant, client_login) -> None: + """Test a full login flow (selecting the home location).""" + await setup.async_setup_component(hass, "persistent_notification", {}) + with patch( + "homeassistant.components.watttime.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_USERNAME: "user", CONF_PASSWORD: "password"}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_LOCATION_TYPE: LOCATION_TYPE_HOME}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "32.87336, -117.22743" + assert result["data"] == { + CONF_USERNAME: "user", + CONF_PASSWORD: "password", + CONF_LATITUDE: 32.87336, + CONF_LONGITUDE: -117.22743, + CONF_BALANCING_AUTHORITY: "Authority 1", + CONF_BALANCING_AUTHORITY_ABBREV: "AUTH_1", + } + + +async def test_step_user_invalid_credentials(hass: HomeAssistant) -> None: + """Test that invalid credentials are handled.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + with patch( + "homeassistant.components.watttime.config_flow.Client.async_login", + AsyncMock(side_effect=InvalidCredentialsError), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_USERNAME: "user", CONF_PASSWORD: "password"}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {"username": "invalid_auth"} + + +@pytest.mark.parametrize("get_grid_region", [AsyncMock(side_effect=Exception)]) +async def test_step_user_unknown_error(hass: HomeAssistant, client_login) -> None: + """Test that an unknown error during the login step is handled.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + with patch( + "homeassistant.components.watttime.config_flow.Client.async_login", + AsyncMock(side_effect=Exception), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_USERNAME: "user", CONF_PASSWORD: "password"}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {"base": "unknown"} From d4864f575075aa68d3a6106a10114ab3c586201a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 20 Sep 2021 21:49:02 -0700 Subject: [PATCH 506/843] Deprecate passing template to notify (#56069) --- homeassistant/components/notify/__init__.py | 21 +++++++++++++++++++-- tests/components/notify/test_init.py | 17 +++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/notify/__init__.py b/homeassistant/components/notify/__init__.py index 00047f0a32b..f3c2778bc59 100644 --- a/homeassistant/components/notify/__init__.py +++ b/homeassistant/components/notify/__init__.py @@ -10,9 +10,9 @@ import voluptuous as vol import homeassistant.components.persistent_notification as pn from homeassistant.const import CONF_DESCRIPTION, CONF_NAME, CONF_PLATFORM -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_per_platform, discovery +from homeassistant.helpers import config_per_platform, discovery, template import homeassistant.helpers.config_validation as cv from homeassistant.helpers.service import async_set_service_schema from homeassistant.loader import async_get_integration, bind_hass @@ -68,6 +68,19 @@ PERSISTENT_NOTIFICATION_SERVICE_SCHEMA = vol.Schema( ) +@callback +def _check_templates_warn(hass: HomeAssistant, tpl: template.Template) -> None: + """Warn user that passing templates to notify service is deprecated.""" + if tpl.is_static or hass.data.get("notify_template_warned"): + return + + hass.data["notify_template_warned"] = True + _LOGGER.warning( + "Passing templates to notify service is deprecated and will be removed in 2021.12. " + "Automations and scripts handle templates automatically" + ) + + @bind_hass async def async_reload(hass: HomeAssistant, integration_name: str) -> None: """Register notify services for an integration.""" @@ -144,6 +157,7 @@ class BaseNotificationService: title = service.data.get(ATTR_TITLE) if title: + _check_templates_warn(self.hass, title) title.hass = self.hass kwargs[ATTR_TITLE] = title.async_render(parse_result=False) @@ -152,6 +166,7 @@ class BaseNotificationService: elif service.data.get(ATTR_TARGET) is not None: kwargs[ATTR_TARGET] = service.data.get(ATTR_TARGET) + _check_templates_warn(self.hass, message) message.hass = self.hass kwargs[ATTR_MESSAGE] = message.async_render(parse_result=False) kwargs[ATTR_DATA] = service.data.get(ATTR_DATA) @@ -261,10 +276,12 @@ async def async_setup(hass, config): payload = {} message = service.data[ATTR_MESSAGE] message.hass = hass + _check_templates_warn(hass, message) payload[ATTR_MESSAGE] = message.async_render(parse_result=False) title = service.data.get(ATTR_TITLE) if title: + _check_templates_warn(hass, title) title.hass = hass payload[ATTR_TITLE] = title.async_render(parse_result=False) diff --git a/tests/components/notify/test_init.py b/tests/components/notify/test_init.py index cce26750c1c..92b71091e07 100644 --- a/tests/components/notify/test_init.py +++ b/tests/components/notify/test_init.py @@ -1,6 +1,7 @@ """The tests for notify services that change targets.""" from homeassistant.components import notify from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component async def test_same_targets(hass: HomeAssistant): @@ -81,3 +82,19 @@ class NotificationService(notify.BaseNotificationService): def targets(self): """Return a dictionary of devices.""" return self.target_list + + +async def test_warn_template(hass, caplog): + """Test warning when template used.""" + assert await async_setup_component(hass, "notify", {}) + assert await async_setup_component(hass, "persistent_notification", {}) + + await hass.services.async_call( + "notify", + "persistent_notification", + {"message": "{{ 1 + 1 }}", "title": "Test notif {{ 1 + 1 }}"}, + blocking=True, + ) + # We should only log it once + assert caplog.text.count("Passing templates to notify service is deprecated") == 1 + assert hass.states.get("persistent_notification.notification") is not None From 097fae0348b17656cab8734ad3b49f5256ad6973 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elliot=20Morales=20Sol=C3=A9?= Date: Tue, 21 Sep 2021 07:51:17 +0200 Subject: [PATCH 507/843] Correct Alexa scene activation (#56469) Co-authored-by: Paulus Schoutsen Co-authored-by: Paulus Schoutsen --- homeassistant/components/alexa/handlers.py | 5 +++-- homeassistant/components/alexa/state_report.py | 4 ++-- tests/components/alexa/__init__.py | 5 ++++- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index fc587128e82..192da955e3f 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -54,6 +54,7 @@ from .const import ( API_THERMOSTAT_MODES, API_THERMOSTAT_MODES_CUSTOM, API_THERMOSTAT_PRESETS, + DATE_FORMAT, Cause, Inputs, ) @@ -318,7 +319,7 @@ async def async_api_activate(hass, config, directive, context): payload = { "cause": {"type": Cause.VOICE_INTERACTION}, - "timestamp": f"{dt_util.utcnow().replace(tzinfo=None).isoformat()}Z", + "timestamp": dt_util.utcnow().strftime(DATE_FORMAT), } return directive.response( @@ -342,7 +343,7 @@ async def async_api_deactivate(hass, config, directive, context): payload = { "cause": {"type": Cause.VOICE_INTERACTION}, - "timestamp": f"{dt_util.utcnow().replace(tzinfo=None).isoformat()}Z", + "timestamp": dt_util.utcnow().strftime(DATE_FORMAT), } return directive.response( diff --git a/homeassistant/components/alexa/state_report.py b/homeassistant/components/alexa/state_report.py index 712a08ac6b9..7a23706b4ba 100644 --- a/homeassistant/components/alexa/state_report.py +++ b/homeassistant/components/alexa/state_report.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers.significant_change import create_checker import homeassistant.util.dt as dt_util -from .const import API_CHANGE, DOMAIN, Cause +from .const import API_CHANGE, DATE_FORMAT, DOMAIN, Cause from .entities import ENTITY_ADAPTERS, AlexaEntity, generate_alexa_id from .messages import AlexaResponse @@ -252,7 +252,7 @@ async def async_send_doorbell_event_message(hass, config, alexa_entity): namespace="Alexa.DoorbellEventSource", payload={ "cause": {"type": Cause.PHYSICAL_INTERACTION}, - "timestamp": f"{dt_util.utcnow().replace(tzinfo=None).isoformat()}Z", + "timestamp": dt_util.utcnow().strftime(DATE_FORMAT), }, ) diff --git a/tests/components/alexa/__init__.py b/tests/components/alexa/__init__.py index bc007fefb84..5b1706c15e2 100644 --- a/tests/components/alexa/__init__.py +++ b/tests/components/alexa/__init__.py @@ -1,4 +1,5 @@ """Tests for the Alexa integration.""" +import re from uuid import uuid4 from homeassistant.components.alexa import config, smart_home @@ -162,7 +163,8 @@ async def assert_scene_controller_works( ) assert response["event"]["payload"]["cause"]["type"] == "VOICE_INTERACTION" assert "timestamp" in response["event"]["payload"] - + pattern = r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.0Z" + assert re.search(pattern, response["event"]["payload"]["timestamp"]) if deactivate_service: await assert_request_calls_service( "Alexa.SceneController", @@ -175,6 +177,7 @@ async def assert_scene_controller_works( cause_type = response["event"]["payload"]["cause"]["type"] assert cause_type == "VOICE_INTERACTION" assert "timestamp" in response["event"]["payload"] + assert re.search(pattern, response["event"]["payload"]["timestamp"]) async def reported_properties(hass, endpoint): From f9fde7f7a1dcc38e1322b9179ecaad66aaf6d6f7 Mon Sep 17 00:00:00 2001 From: Oscar Calvo <2091582+ocalvo@users.noreply.github.com> Date: Tue, 21 Sep 2021 01:07:14 -0700 Subject: [PATCH 508/843] Support unicode in SMS messages (#56468) --- homeassistant/components/sms/notify.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sms/notify.py b/homeassistant/components/sms/notify.py index 04964c15878..1bd3c60b9b9 100644 --- a/homeassistant/components/sms/notify.py +++ b/homeassistant/components/sms/notify.py @@ -46,7 +46,7 @@ class SMSNotificationService(BaseNotificationService): """Send SMS message.""" smsinfo = { "Class": -1, - "Unicode": False, + "Unicode": True, "Entries": [{"ID": "ConcatenatedTextLong", "Buffer": message}], } try: From 7698c179ac7ed5db59a30e49aeb7a324b064319f Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Tue, 21 Sep 2021 11:06:52 +0200 Subject: [PATCH 509/843] Upgrade cryptography to 3.4.8 (#56481) * Upgrade cryptography to 3.4.8 * Fix file --- .github/workflows/wheels.yml | 4 ++-- homeassistant/package_constraints.txt | 2 +- requirements.txt | 2 +- requirements_test.txt | 1 - setup.py | 2 +- 5 files changed, 5 insertions(+), 6 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 578cc024738..9cced377f8c 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -89,7 +89,7 @@ jobs: wheels-key: ${{ secrets.WHEELS_KEY }} wheels-user: wheels env-file: true - apk: "build-base;cmake;git;linux-headers;bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev" + apk: "build-base;cmake;git;linux-headers;bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;cargo" pip: "Cython;numpy" skip-binary: aiohttp constraints: "homeassistant/package_constraints.txt" @@ -158,7 +158,7 @@ jobs: wheels-key: ${{ secrets.WHEELS_KEY }} wheels-user: wheels env-file: true - apk: "build-base;cmake;git;linux-headers;libexecinfo-dev;bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;autoconf;automake;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev" + apk: "build-base;cmake;git;linux-headers;libexecinfo-dev;bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;autoconf;automake;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;cargo" pip: "Cython;numpy;scikit-build" skip-binary: aiohttp constraints: "homeassistant/package_constraints.txt" diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 10d5906401e..70d1fddc790 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -12,7 +12,7 @@ backports.zoneinfo;python_version<"3.9" bcrypt==3.1.7 certifi>=2020.12.5 ciso8601==2.1.3 -cryptography==3.3.2 +cryptography==3.4.8 defusedxml==0.7.1 emoji==1.2.0 hass-nabucasa==0.49.0 diff --git a/requirements.txt b/requirements.txt index 7f986750b81..a9d9bc0d238 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,7 @@ ciso8601==2.1.3 httpx==0.19.0 jinja2==3.0.1 PyJWT==2.1.0 -cryptography==3.3.2 +cryptography==3.4.8 pip>=8.0.3,<20.3 python-slugify==4.0.1 pyyaml==5.4.1 diff --git a/requirements_test.txt b/requirements_test.txt index e23ebbc38d5..ce122687c33 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -32,7 +32,6 @@ types-croniter==1.0.0 types-backports==0.1.3 types-certifi==0.1.4 types-chardet==0.1.5 -types-cryptography==3.3.2 types-decorator==0.1.7 types-emoji==1.2.4 types-enum34==0.1.8 diff --git a/setup.py b/setup.py index 852cc89bbc5..38febf7c3a0 100755 --- a/setup.py +++ b/setup.py @@ -45,7 +45,7 @@ REQUIRES = [ "jinja2==3.0.1", "PyJWT==2.1.0", # PyJWT has loose dependency. We want the latest one. - "cryptography==3.3.2", + "cryptography==3.4.8", "pip>=8.0.3,<20.3", "python-slugify==4.0.1", "pyyaml==5.4.1", From 518c99c8b70c6272168866af6a36b4f281845d22 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Tue, 21 Sep 2021 13:15:45 +0200 Subject: [PATCH 510/843] Strictly type tradfri cover.py (#56390) * Strictly type cover.py. * Review comments from other PR. * Update homeassistant/components/tradfri/cover.py Co-authored-by: Ruslan Sayfutdinov * Update homeassistant/components/tradfri/cover.py Co-authored-by: Ruslan Sayfutdinov * Update homeassistant/components/tradfri/cover.py Co-authored-by: Ruslan Sayfutdinov * Update homeassistant/components/tradfri/cover.py Co-authored-by: Ruslan Sayfutdinov --- homeassistant/components/tradfri/cover.py | 49 ++++++++++++++++++----- 1 file changed, 38 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/tradfri/cover.py b/homeassistant/components/tradfri/cover.py index ad077f1f040..5a6140ed5fc 100644 --- a/homeassistant/components/tradfri/cover.py +++ b/homeassistant/components/tradfri/cover.py @@ -1,12 +1,24 @@ """Support for IKEA Tradfri covers.""" +from __future__ import annotations + +from typing import Any, Callable, cast + +from pytradfri.command import Command from homeassistant.components.cover import ATTR_POSITION, CoverEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .base_class import TradfriBaseDevice from .const import ATTR_MODEL, CONF_GATEWAY_ID, DEVICES, DOMAIN, KEY_API -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Load Tradfri covers based on a config entry.""" gateway_id = config_entry.data[CONF_GATEWAY_ID] tradfri_data = hass.data[DOMAIN][config_entry.entry_id] @@ -21,7 +33,12 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class TradfriCover(TradfriBaseDevice, CoverEntity): """The platform class required by Home Assistant.""" - def __init__(self, device, api, gateway_id): + def __init__( + self, + device: Command, + api: Callable[[Command | list[Command]], Any], + gateway_id: str, + ) -> None: """Initialize a cover.""" super().__init__(device, api, gateway_id) self._attr_unique_id = f"{gateway_id}-{device.id}" @@ -29,40 +46,50 @@ class TradfriCover(TradfriBaseDevice, CoverEntity): self._refresh(device) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, str] | None: """Return the state attributes.""" return {ATTR_MODEL: self._device.device_info.model_number} @property - def current_cover_position(self): + def current_cover_position(self) -> int | None: """Return current position of cover. None is unknown, 0 is closed, 100 is fully open. """ - return 100 - self._device_data.current_cover_position + if not self._device_data: + return None + return 100 - cast(int, self._device_data.current_cover_position) - async def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" + if not self._device_control: + return await self._api(self._device_control.set_state(100 - kwargs[ATTR_POSITION])) - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" + if not self._device_control: + return await self._api(self._device_control.set_state(0)) - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" + if not self._device_control: + return await self._api(self._device_control.set_state(100)) - async def async_stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs: Any) -> None: """Close cover.""" + if not self._device_control: + return await self._api(self._device_control.trigger_blind()) @property - def is_closed(self): + def is_closed(self) -> bool: """Return if the cover is closed or not.""" return self.current_cover_position == 0 - def _refresh(self, device): + def _refresh(self, device: Command) -> None: """Refresh the cover data.""" super()._refresh(device) self._device = device From c7c789f61808994d0c8ee6e2e0bbb25a916ae71c Mon Sep 17 00:00:00 2001 From: jan iversen Date: Tue, 21 Sep 2021 13:43:41 +0200 Subject: [PATCH 511/843] Strictly type modbus __init__.py, validator.py (#56378) * strictly type: __init__.py, validator.py --- homeassistant/components/modbus/__init__.py | 6 ++++-- homeassistant/components/modbus/validators.py | 8 +++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 830dadfcdb8..b7a1e9db8e7 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import cast import voluptuous as vol @@ -46,6 +47,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType from .const import ( ATTR_ADDRESS, @@ -378,10 +380,10 @@ SERVICE_STOP_START_SCHEMA = vol.Schema( def get_hub(hass: HomeAssistant, name: str) -> ModbusHub: """Return modbus hub with name.""" - return hass.data[DOMAIN][name] + return cast(ModbusHub, hass.data[DOMAIN][name]) -async def async_setup(hass, config): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Modbus component.""" return await async_modbus_setup( hass, diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py index df4fe3c1e62..3d13178bccc 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -84,7 +84,7 @@ DEFAULT_STRUCT_FORMAT = { } -def struct_validator(config): +def struct_validator(config: dict[str, Any]) -> dict[str, Any]: """Sensor schema validator.""" data_type = config[CONF_DATA_TYPE] @@ -154,13 +154,11 @@ def number_validator(value: Any) -> int | float: return value try: - value = int(value) - return value + return int(value) except (TypeError, ValueError): pass try: - value = float(value) - return value + return float(value) except (TypeError, ValueError) as err: raise vol.Invalid(f"invalid number {value}") from err From 56b66d51249f0f8bcb37463d0b98c8def1ab503b Mon Sep 17 00:00:00 2001 From: Justin Goette <53531335+jcgoette@users.noreply.github.com> Date: Tue, 21 Sep 2021 10:09:21 -0400 Subject: [PATCH 512/843] typo (#56477) --- homeassistant/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index 1a795c30b0f..4221c435a55 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -508,7 +508,7 @@ class HomeAssistant: """Stop Home Assistant and shuts down all threads. The "force" flag commands async_stop to proceed regardless of - Home Assistan't current state. You should not set this flag + Home Assistant's current state. You should not set this flag unless you're testing. This method is a coroutine. From 34de74d869d4896c7493dca103f0ae38baddaf75 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Tue, 21 Sep 2021 16:23:10 +0200 Subject: [PATCH 513/843] Strictly type tradfri config_flow.py (#56391) * Strictly type config_flow.py. * Review comments. --- .strict-typing | 1 + .../components/tradfri/config_flow.py | 34 +++++++++++++------ mypy.ini | 11 ++++++ 3 files changed, 36 insertions(+), 10 deletions(-) diff --git a/.strict-typing b/.strict-typing index df0c0168a9e..df85d61c4a9 100644 --- a/.strict-typing +++ b/.strict-typing @@ -106,6 +106,7 @@ homeassistant.components.systemmonitor.* homeassistant.components.tag.* homeassistant.components.tcp.* homeassistant.components.tile.* +homeassistant.components.tradfri.* homeassistant.components.tts.* homeassistant.components.upcloud.* homeassistant.components.uptime.* diff --git a/homeassistant/components/tradfri/config_flow.py b/homeassistant/components/tradfri/config_flow.py index 1a6ae8706f2..e45bd36753f 100644 --- a/homeassistant/components/tradfri/config_flow.py +++ b/homeassistant/components/tradfri/config_flow.py @@ -1,5 +1,8 @@ """Config flow for Tradfri.""" +from __future__ import annotations + import asyncio +from typing import Any from uuid import uuid4 import async_timeout @@ -8,6 +11,9 @@ from pytradfri.api.aiocoap_api import APIFactory import voluptuous as vol from homeassistant import config_entries +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.typing import DiscoveryInfoType from .const import ( CONF_GATEWAY_ID, @@ -23,7 +29,7 @@ from .const import ( class AuthError(Exception): """Exception if authentication occurs.""" - def __init__(self, code): + def __init__(self, code: str) -> None: """Initialize exception.""" super().__init__() self.code = code @@ -34,18 +40,22 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self): + def __init__(self) -> None: """Initialize flow.""" self._host = None self._import_groups = False - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initialized by the user.""" return await self.async_step_auth() - async def async_step_auth(self, user_input=None): + async def async_step_auth( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the authentication with a gateway.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: host = user_input.get(CONF_HOST, self._host) @@ -82,7 +92,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): step_id="auth", data_schema=vol.Schema(fields), errors=errors ) - async def async_step_homekit(self, discovery_info): + async def async_step_homekit(self, discovery_info: DiscoveryInfoType) -> FlowResult: """Handle homekit discovery.""" await self.async_set_unique_id(discovery_info["properties"]["id"]) self._abort_if_unique_id_configured({CONF_HOST: discovery_info["host"]}) @@ -104,7 +114,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self._host = host return await self.async_step_auth() - async def async_step_import(self, user_input): + async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: """Import a config entry.""" self._async_abort_entries_match({CONF_HOST: user_input["host"]}) @@ -131,7 +141,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self._host = user_input["host"] return await self.async_step_auth() - async def _entry_from_data(self, data): + async def _entry_from_data(self, data: dict[str, Any]) -> FlowResult: """Create an entry from data.""" host = data[CONF_HOST] gateway_id = data[CONF_GATEWAY_ID] @@ -154,7 +164,9 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=host, data=data) -async def authenticate(hass, host, security_code): +async def authenticate( + hass: HomeAssistant, host: str, security_code: str +) -> dict[str, str | bool]: """Authenticate with a Tradfri hub.""" identity = uuid4().hex @@ -174,7 +186,9 @@ async def authenticate(hass, host, security_code): return await get_gateway_info(hass, host, identity, key) -async def get_gateway_info(hass, host, identity, key): +async def get_gateway_info( + hass: HomeAssistant, host: str, identity: str, key: str +) -> dict[str, str | bool]: """Return info for the gateway.""" try: diff --git a/mypy.ini b/mypy.ini index b1b2657aac9..cec6e51d58e 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1177,6 +1177,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.tradfri.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.tts.*] check_untyped_defs = true disallow_incomplete_defs = true From 26e9590927f6fc9c5bf2cd5ad5098aa1bbbd31a0 Mon Sep 17 00:00:00 2001 From: RenierM26 <66512715+RenierM26@users.noreply.github.com> Date: Tue, 21 Sep 2021 19:35:47 +0200 Subject: [PATCH 514/843] Add cover platform to switchbot (#56414) Co-authored-by: J. Nick Koston --- .coveragerc | 2 + .../components/switchbot/__init__.py | 17 ++- .../components/switchbot/config_flow.py | 6 +- homeassistant/components/switchbot/const.py | 2 + homeassistant/components/switchbot/cover.py | 141 ++++++++++++++++++ homeassistant/components/switchbot/entity.py | 46 ++++++ homeassistant/components/switchbot/switch.py | 81 ++++------ tests/components/switchbot/__init__.py | 6 + tests/components/switchbot/conftest.py | 17 +++ .../components/switchbot/test_config_flow.py | 104 ++++++++----- 10 files changed, 322 insertions(+), 100 deletions(-) create mode 100644 homeassistant/components/switchbot/cover.py create mode 100644 homeassistant/components/switchbot/entity.py diff --git a/.coveragerc b/.coveragerc index d75eb6aa9d6..4caa8fd768c 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1014,6 +1014,8 @@ omit = homeassistant/components/switchbot/switch.py homeassistant/components/switchbot/__init__.py homeassistant/components/switchbot/const.py + homeassistant/components/switchbot/entity.py + homeassistant/components/switchbot/cover.py homeassistant/components/switchbot/coordinator.py homeassistant/components/switchmate/switch.py homeassistant/components/syncthing/__init__.py diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py index 123aefb512f..2bf91dc3a55 100644 --- a/homeassistant/components/switchbot/__init__.py +++ b/homeassistant/components/switchbot/__init__.py @@ -4,10 +4,13 @@ from asyncio import Lock import switchbot # pylint: disable=import-error from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_SENSOR_TYPE from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from .const import ( + ATTR_BOT, + ATTR_CURTAIN, BTLE_LOCK, COMMON_OPTIONS, CONF_RETRY_COUNT, @@ -23,7 +26,10 @@ from .const import ( ) from .coordinator import SwitchbotDataUpdateCoordinator -PLATFORMS = ["switch"] +PLATFORMS_BY_TYPE = { + ATTR_BOT: ["switch"], + ATTR_CURTAIN: ["cover"], +} async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -83,14 +89,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN][entry.entry_id] = {DATA_COORDINATOR: coordinator} - hass.config_entries.async_setup_platforms(entry, PLATFORMS) + sensor_type = entry.data[CONF_SENSOR_TYPE] + + hass.config_entries.async_setup_platforms(entry, PLATFORMS_BY_TYPE[sensor_type]) return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + sensor_type = entry.data[CONF_SENSOR_TYPE] + unload_ok = await hass.config_entries.async_unload_platforms( + entry, PLATFORMS_BY_TYPE[sensor_type] + ) if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) diff --git a/homeassistant/components/switchbot/config_flow.py b/homeassistant/components/switchbot/config_flow.py index fcb9cdc3b8c..f222c28acd6 100644 --- a/homeassistant/components/switchbot/config_flow.py +++ b/homeassistant/components/switchbot/config_flow.py @@ -14,7 +14,6 @@ from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from .const import ( - ATTR_BOT, BTLE_LOCK, CONF_RETRY_COUNT, CONF_RETRY_TIMEOUT, @@ -25,6 +24,7 @@ from .const import ( DEFAULT_SCAN_TIMEOUT, DEFAULT_TIME_BETWEEN_UPDATE_COMMAND, DOMAIN, + SUPPORTED_MODEL_TYPES, ) _LOGGER = logging.getLogger(__name__) @@ -70,8 +70,8 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN): _btle_connect, data[CONF_MAC] ) - if _btle_adv_data["modelName"] == "WoHand": - data[CONF_SENSOR_TYPE] = ATTR_BOT + if _btle_adv_data["modelName"] in SUPPORTED_MODEL_TYPES: + data[CONF_SENSOR_TYPE] = SUPPORTED_MODEL_TYPES[_btle_adv_data["modelName"]] return self.async_create_entry(title=data[CONF_NAME], data=data) return self.async_abort(reason="switchbot_unsupported_type") diff --git a/homeassistant/components/switchbot/const.py b/homeassistant/components/switchbot/const.py index c94dae3dddd..8ca7fadf41c 100644 --- a/homeassistant/components/switchbot/const.py +++ b/homeassistant/components/switchbot/const.py @@ -4,7 +4,9 @@ MANUFACTURER = "switchbot" # Config Attributes ATTR_BOT = "bot" +ATTR_CURTAIN = "curtain" DEFAULT_NAME = "Switchbot" +SUPPORTED_MODEL_TYPES = {"WoHand": ATTR_BOT, "WoCurtain": ATTR_CURTAIN} # Config Defaults DEFAULT_RETRY_COUNT = 3 diff --git a/homeassistant/components/switchbot/cover.py b/homeassistant/components/switchbot/cover.py new file mode 100644 index 00000000000..e28049eeb95 --- /dev/null +++ b/homeassistant/components/switchbot/cover.py @@ -0,0 +1,141 @@ +"""Support for SwitchBot curtains.""" +from __future__ import annotations + +import logging +from typing import Any + +from switchbot import SwitchbotCurtain # pylint: disable=import-error + +from homeassistant.components.cover import ( + ATTR_CURRENT_POSITION, + ATTR_POSITION, + DEVICE_CLASS_CURTAIN, + SUPPORT_CLOSE, + SUPPORT_OPEN, + SUPPORT_SET_POSITION, + SUPPORT_STOP, + CoverEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_MAC, CONF_NAME, CONF_PASSWORD +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity + +from .const import CONF_RETRY_COUNT, DATA_COORDINATOR, DOMAIN +from .coordinator import SwitchbotDataUpdateCoordinator +from .entity import SwitchbotEntity + +# Initialize the logger +_LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 1 + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Switchbot curtain based on a config entry.""" + coordinator: SwitchbotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ + DATA_COORDINATOR + ] + + async_add_entities( + [ + SwitchBotCurtainEntity( + coordinator, + entry.unique_id, + entry.data[CONF_MAC], + entry.data[CONF_NAME], + coordinator.switchbot_api.SwitchbotCurtain( + mac=entry.data[CONF_MAC], + password=entry.data.get(CONF_PASSWORD), + retry_count=entry.options[CONF_RETRY_COUNT], + ), + ) + ] + ) + + +class SwitchBotCurtainEntity(SwitchbotEntity, CoverEntity, RestoreEntity): + """Representation of a Switchbot.""" + + coordinator: SwitchbotDataUpdateCoordinator + _attr_device_class = DEVICE_CLASS_CURTAIN + _attr_supported_features = ( + SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_STOP | SUPPORT_SET_POSITION + ) + _attr_assumed_state = True + + def __init__( + self, + coordinator: SwitchbotDataUpdateCoordinator, + idx: str | None, + mac: str, + name: str, + device: SwitchbotCurtain, + ) -> None: + """Initialize the Switchbot.""" + super().__init__(coordinator, idx, mac, name) + self._attr_unique_id = idx + self._device = device + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added.""" + await super().async_added_to_hass() + last_state = await self.async_get_last_state() + if not last_state or ATTR_CURRENT_POSITION not in last_state.attributes: + return + + self._attr_current_cover_position = last_state.attributes[ATTR_CURRENT_POSITION] + self._last_run_success = last_state.attributes["last_run_success"] + self._attr_is_closed = last_state.attributes[ATTR_CURRENT_POSITION] <= 20 + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the curtain.""" + + _LOGGER.debug("Switchbot to open curtain %s", self._mac) + + async with self.coordinator.api_lock: + self._last_run_success = bool( + await self.hass.async_add_executor_job(self._device.open) + ) + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the curtain.""" + + _LOGGER.debug("Switchbot to close the curtain %s", self._mac) + + async with self.coordinator.api_lock: + self._last_run_success = bool( + await self.hass.async_add_executor_job(self._device.close) + ) + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop the moving of this device.""" + + _LOGGER.debug("Switchbot to stop %s", self._mac) + + async with self.coordinator.api_lock: + self._last_run_success = bool( + await self.hass.async_add_executor_job(self._device.stop) + ) + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the cover shutter to a specific position.""" + position = kwargs.get(ATTR_POSITION) + + _LOGGER.debug("Switchbot to move at %d %s", position, self._mac) + + async with self.coordinator.api_lock: + self._last_run_success = bool( + await self.hass.async_add_executor_job( + self._device.set_position, position + ) + ) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._attr_current_cover_position = self.data["data"]["position"] + self._attr_is_closed = self.data["data"]["position"] <= 20 + self.async_write_ha_state() diff --git a/homeassistant/components/switchbot/entity.py b/homeassistant/components/switchbot/entity.py new file mode 100644 index 00000000000..6b316789384 --- /dev/null +++ b/homeassistant/components/switchbot/entity.py @@ -0,0 +1,46 @@ +"""An abstract class common to all Switchbot entities.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import MANUFACTURER +from .coordinator import SwitchbotDataUpdateCoordinator + + +class SwitchbotEntity(CoordinatorEntity, Entity): + """Generic entity encapsulating common features of Switchbot device.""" + + def __init__( + self, + coordinator: SwitchbotDataUpdateCoordinator, + idx: str | None, + mac: str, + name: str, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._last_run_success: bool | None = None + self._idx = idx + self._mac = mac + self._attr_name = name + self._attr_device_info: DeviceInfo = { + "connections": {(dr.CONNECTION_NETWORK_MAC, self._mac)}, + "name": self._attr_name, + "model": self.data["modelName"], + "manufacturer": MANUFACTURER, + } + + @property + def data(self) -> dict[str, Any]: + """Return coordinator data for this entity.""" + return self.coordinator.data[self._idx] + + @property + def extra_state_attributes(self) -> Mapping[Any, Any]: + """Return the state attributes.""" + return {"last_run_success": self._last_run_success, "mac_address": self._mac} diff --git a/homeassistant/components/switchbot/switch.py b/homeassistant/components/switchbot/switch.py index ea2f3c0dfff..d8e90fd9925 100644 --- a/homeassistant/components/switchbot/switch.py +++ b/homeassistant/components/switchbot/switch.py @@ -4,6 +4,7 @@ from __future__ import annotations import logging from typing import Any +from switchbot import Switchbot # pylint: disable=import-error import voluptuous as vol from homeassistant.components.switch import ( @@ -20,25 +21,13 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import ( - config_validation as cv, - device_registry as dr, - entity_platform, -) -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ( - ATTR_BOT, - CONF_RETRY_COUNT, - DATA_COORDINATOR, - DEFAULT_NAME, - DOMAIN, - MANUFACTURER, -) +from .const import ATTR_BOT, CONF_RETRY_COUNT, DATA_COORDINATOR, DEFAULT_NAME, DOMAIN from .coordinator import SwitchbotDataUpdateCoordinator +from .entity import SwitchbotEntity # Initialize the logger _LOGGER = logging.getLogger(__name__) @@ -89,24 +78,24 @@ async def async_setup_entry( DATA_COORDINATOR ] - if entry.data[CONF_SENSOR_TYPE] != ATTR_BOT: - return - async_add_entities( [ - SwitchBot( + SwitchBotBotEntity( coordinator, entry.unique_id, entry.data[CONF_MAC], entry.data[CONF_NAME], - entry.data.get(CONF_PASSWORD, None), - entry.options[CONF_RETRY_COUNT], + coordinator.switchbot_api.Switchbot( + mac=entry.data[CONF_MAC], + password=entry.data.get(CONF_PASSWORD), + retry_count=entry.options[CONF_RETRY_COUNT], + ), ) ] ) -class SwitchBot(CoordinatorEntity, SwitchEntity, RestoreEntity): +class SwitchBotBotEntity(SwitchbotEntity, SwitchEntity, RestoreEntity): """Representation of a Switchbot.""" coordinator: SwitchbotDataUpdateCoordinator @@ -118,25 +107,12 @@ class SwitchBot(CoordinatorEntity, SwitchEntity, RestoreEntity): idx: str | None, mac: str, name: str, - password: str, - retry_count: int, + device: Switchbot, ) -> None: """Initialize the Switchbot.""" - super().__init__(coordinator) - self._idx = idx - self._last_run_success: bool | None = None - self._mac = mac - self._device = self.coordinator.switchbot_api.Switchbot( - mac=mac, password=password, retry_count=retry_count - ) + super().__init__(coordinator, idx, mac, name) self._attr_unique_id = self._mac.replace(":", "") - self._attr_name = name - self._attr_device_info: DeviceInfo = { - "connections": {(dr.CONNECTION_NETWORK_MAC, self._mac)}, - "name": name, - "model": self.coordinator.data[self._idx]["modelName"], - "manufacturer": MANUFACTURER, - } + self._device = device async def async_added_to_hass(self) -> None: """Run when entity about to be added.""" @@ -152,42 +128,35 @@ class SwitchBot(CoordinatorEntity, SwitchEntity, RestoreEntity): _LOGGER.info("Turn Switchbot bot on %s", self._mac) async with self.coordinator.api_lock: - update_ok = await self.hass.async_add_executor_job(self._device.turn_on) - - if update_ok: - self._last_run_success = True - else: - self._last_run_success = False + self._last_run_success = bool( + await self.hass.async_add_executor_job(self._device.turn_on) + ) async def async_turn_off(self, **kwargs: Any) -> None: """Turn device off.""" _LOGGER.info("Turn Switchbot bot off %s", self._mac) async with self.coordinator.api_lock: - update_ok = await self.hass.async_add_executor_job(self._device.turn_off) - - if update_ok: - self._last_run_success = True - else: - self._last_run_success = False + self._last_run_success = bool( + await self.hass.async_add_executor_job(self._device.turn_off) + ) @property def assumed_state(self) -> bool: """Return true if unable to access real state of entity.""" - if not self.coordinator.data[self._idx]["data"]["switchMode"]: + if not self.data["data"]["switchMode"]: return True return False @property def is_on(self) -> bool: """Return true if device is on.""" - return self.coordinator.data[self._idx]["data"]["isOn"] + return self.data["data"]["isOn"] @property - def device_state_attributes(self) -> dict: + def extra_state_attributes(self) -> dict: """Return the state attributes.""" return { - "last_run_success": self._last_run_success, - "mac_address": self._mac, - "switch_mode": self.coordinator.data[self._idx]["data"]["switchMode"], + **super().extra_state_attributes, + "switch_mode": self.data["data"]["switchMode"], } diff --git a/tests/components/switchbot/__init__.py b/tests/components/switchbot/__init__.py index f74edffc19e..5d01a8d0d68 100644 --- a/tests/components/switchbot/__init__.py +++ b/tests/components/switchbot/__init__.py @@ -20,6 +20,12 @@ USER_INPUT = { CONF_MAC: "e7:89:43:99:99:99", } +USER_INPUT_CURTAIN = { + CONF_NAME: "test-name", + CONF_PASSWORD: "test-password", + CONF_MAC: "e7:89:43:90:90:90", +} + USER_INPUT_UNSUPPORTED_DEVICE = { CONF_NAME: "test-name", CONF_PASSWORD: "test-password", diff --git a/tests/components/switchbot/conftest.py b/tests/components/switchbot/conftest.py index b722776e9b1..1b9019ddfde 100644 --- a/tests/components/switchbot/conftest.py +++ b/tests/components/switchbot/conftest.py @@ -25,6 +25,21 @@ class MocGetSwitchbotDevices: "model": "H", "modelName": "WoHand", } + self._curtain_all_services_data = { + "mac_address": "e7:89:43:90:90:90", + "Flags": "06", + "Manufacturer": "5900e78943d9fe7c", + "Complete 128b Services": "cba20d00-224d-11e6-9fb8-0002a5d5c51b", + "data": { + "calibration": True, + "battery": 74, + "position": 100, + "lightLevel": 2, + "rssi": -73, + }, + "model": "c", + "modelName": "WoCurtain", + } self._unsupported_device = { "mac_address": "test", "Flags": "06", @@ -50,6 +65,8 @@ class MocGetSwitchbotDevices: return self._all_services_data if mac == "test": return self._unsupported_device + if mac == "e7:89:43:90:90:90": + return self._curtain_all_services_data return None diff --git a/tests/components/switchbot/test_config_flow.py b/tests/components/switchbot/test_config_flow.py index e9baace081b..a8f13a8796c 100644 --- a/tests/components/switchbot/test_config_flow.py +++ b/tests/components/switchbot/test_config_flow.py @@ -1,8 +1,12 @@ """Test the switchbot config flow.""" -from unittest.mock import patch - from homeassistant.components.switchbot.config_flow import NotConnectedError +from homeassistant.components.switchbot.const import ( + CONF_RETRY_COUNT, + CONF_RETRY_TIMEOUT, + CONF_SCAN_TIMEOUT, + CONF_TIME_BETWEEN_UPDATE_COMMAND, +) from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER from homeassistant.const import CONF_MAC, CONF_NAME, CONF_PASSWORD, CONF_SENSOR_TYPE from homeassistant.data_entry_flow import ( @@ -14,6 +18,7 @@ from homeassistant.setup import async_setup_component from . import ( USER_INPUT, + USER_INPUT_CURTAIN, USER_INPUT_INVALID, USER_INPUT_UNSUPPORTED_DEVICE, YAML_CONFIG, @@ -71,6 +76,33 @@ async def test_user_form_valid_mac(hass): assert result["type"] == RESULT_TYPE_ABORT assert result["reason"] == "already_configured_device" + # test curtain device creation. + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with _patch_async_setup_entry() as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT_CURTAIN, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "test-name" + assert result["data"] == { + CONF_MAC: "e7:89:43:90:90:90", + CONF_NAME: "test-name", + CONF_PASSWORD: "test-password", + CONF_SENSOR_TYPE: "curtain", + } + + assert len(mock_setup_entry.mock_calls) == 1 + async def test_user_form_unsupported_device(hass): """Test the user initiated form for unsupported device type.""" @@ -165,62 +197,58 @@ async def test_user_form_exception(hass, switchbot_config_flow): async def test_options_flow(hass): """Test updating options.""" - with patch("homeassistant.components.switchbot.PLATFORMS", []): + with _patch_async_setup_entry() as mock_setup_entry: entry = await init_integration(hass) - assert entry.options["update_time"] == 60 - assert entry.options["retry_count"] == 3 - assert entry.options["retry_timeout"] == 5 - assert entry.options["scan_timeout"] == 5 + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "init" + assert result["errors"] is None - result = await hass.config_entries.options.async_init(entry.entry_id) - assert result["type"] == RESULT_TYPE_FORM - assert result["step_id"] == "init" - assert result["errors"] is None - - with _patch_async_setup_entry() as mock_setup_entry: result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ - "update_time": 60, - "retry_count": 3, - "retry_timeout": 5, - "scan_timeout": 5, + CONF_TIME_BETWEEN_UPDATE_COMMAND: 60, + CONF_RETRY_COUNT: 3, + CONF_RETRY_TIMEOUT: 5, + CONF_SCAN_TIMEOUT: 5, }, ) - await hass.async_block_till_done() + await hass.async_block_till_done() assert result["type"] == RESULT_TYPE_CREATE_ENTRY - assert result["data"]["update_time"] == 60 - assert result["data"]["retry_count"] == 3 - assert result["data"]["retry_timeout"] == 5 - assert result["data"]["scan_timeout"] == 5 + assert result["data"][CONF_TIME_BETWEEN_UPDATE_COMMAND] == 60 + assert result["data"][CONF_RETRY_COUNT] == 3 + assert result["data"][CONF_RETRY_TIMEOUT] == 5 + assert result["data"][CONF_SCAN_TIMEOUT] == 5 - assert len(mock_setup_entry.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 1 # Test changing of entry options. - result = await hass.config_entries.options.async_init(entry.entry_id) - assert result["type"] == RESULT_TYPE_FORM - assert result["step_id"] == "init" - assert result["errors"] is None - with _patch_async_setup_entry() as mock_setup_entry: + entry = await init_integration(hass) + + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "init" + assert result["errors"] is None + result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ - "update_time": 60, - "retry_count": 3, - "retry_timeout": 5, - "scan_timeout": 5, + CONF_TIME_BETWEEN_UPDATE_COMMAND: 66, + CONF_RETRY_COUNT: 6, + CONF_RETRY_TIMEOUT: 6, + CONF_SCAN_TIMEOUT: 6, }, ) - await hass.async_block_till_done() + await hass.async_block_till_done() assert result["type"] == RESULT_TYPE_CREATE_ENTRY - assert result["data"]["update_time"] == 60 - assert result["data"]["retry_count"] == 3 - assert result["data"]["retry_timeout"] == 5 - assert result["data"]["scan_timeout"] == 5 + assert result["data"][CONF_TIME_BETWEEN_UPDATE_COMMAND] == 66 + assert result["data"][CONF_RETRY_COUNT] == 6 + assert result["data"][CONF_RETRY_TIMEOUT] == 6 + assert result["data"][CONF_SCAN_TIMEOUT] == 6 - assert len(mock_setup_entry.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 1 From 1aa7c87151613ee74ebfeed3f9f17ecac3a1832f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Tue, 21 Sep 2021 20:51:12 +0300 Subject: [PATCH 515/843] Remove redundant aiohttp response status=200 kwargs (#56417) * Remove redundant aiohttp response status=200 kwargs * Remove some more in h.c.auth * Restore explicit status=HTTP_OK for auth and webhook per review request --- homeassistant/components/doorbird/__init__.py | 5 ++--- homeassistant/components/geofency/__init__.py | 3 +-- homeassistant/components/gpslogger/__init__.py | 3 +-- homeassistant/components/locative/__init__.py | 8 +++----- homeassistant/components/plaato/__init__.py | 3 +-- .../components/rss_feed_template/__init__.py | 5 +---- homeassistant/components/traccar/__init__.py | 11 +++-------- tests/components/api/test_init.py | 2 +- tests/components/http/test_auth.py | 2 +- tests/components/http/test_cors.py | 2 +- 10 files changed, 15 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/doorbird/__init__.py b/homeassistant/components/doorbird/__init__.py index 4e4b1cb6ae9..f1addbf477b 100644 --- a/homeassistant/components/doorbird/__init__.py +++ b/homeassistant/components/doorbird/__init__.py @@ -15,7 +15,6 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME, - HTTP_OK, HTTP_UNAUTHORIZED, ) from homeassistant.core import HomeAssistant, callback @@ -345,7 +344,7 @@ class DoorBirdRequestView(HomeAssistantView): hass.bus.async_fire(RESET_DEVICE_FAVORITES, {"token": token}) message = f"HTTP Favorites cleared for {device.slug}" - return web.Response(status=HTTP_OK, text=message) + return web.Response(text=message) event_data[ATTR_ENTITY_ID] = hass.data[DOMAIN][ DOOR_STATION_EVENT_ENTITY_IDS @@ -353,4 +352,4 @@ class DoorBirdRequestView(HomeAssistantView): hass.bus.async_fire(f"{DOMAIN}_{event}", event_data) - return web.Response(status=HTTP_OK, text="OK") + return web.Response(text="OK") diff --git a/homeassistant/components/geofency/__init__.py b/homeassistant/components/geofency/__init__.py index 1cbaea23733..6d604ec6e30 100644 --- a/homeassistant/components/geofency/__init__.py +++ b/homeassistant/components/geofency/__init__.py @@ -8,7 +8,6 @@ from homeassistant.const import ( ATTR_LONGITUDE, ATTR_NAME, CONF_WEBHOOK_ID, - HTTP_OK, HTTP_UNPROCESSABLE_ENTITY, STATE_NOT_HOME, ) @@ -129,7 +128,7 @@ def _set_location(hass, data, location_name): data, ) - return web.Response(text=f"Setting location for {device}", status=HTTP_OK) + return web.Response(text=f"Setting location for {device}") async def async_setup_entry(hass, entry): diff --git a/homeassistant/components/gpslogger/__init__.py b/homeassistant/components/gpslogger/__init__.py index 0ec8e658867..0c475872093 100644 --- a/homeassistant/components/gpslogger/__init__.py +++ b/homeassistant/components/gpslogger/__init__.py @@ -10,7 +10,6 @@ from homeassistant.const import ( ATTR_LATITUDE, ATTR_LONGITUDE, CONF_WEBHOOK_ID, - HTTP_OK, HTTP_UNPROCESSABLE_ENTITY, ) from homeassistant.helpers import config_entry_flow @@ -91,7 +90,7 @@ async def handle_webhook(hass, webhook_id, request): attrs, ) - return web.Response(text=f"Setting location for {device}", status=HTTP_OK) + return web.Response(text=f"Setting location for {device}") async def async_setup_entry(hass, entry): diff --git a/homeassistant/components/locative/__init__.py b/homeassistant/components/locative/__init__.py index 97df92a9f89..52cfc7900ba 100644 --- a/homeassistant/components/locative/__init__.py +++ b/homeassistant/components/locative/__init__.py @@ -12,7 +12,6 @@ from homeassistant.const import ( ATTR_LATITUDE, ATTR_LONGITUDE, CONF_WEBHOOK_ID, - HTTP_OK, HTTP_UNPROCESSABLE_ENTITY, STATE_NOT_HOME, ) @@ -78,7 +77,7 @@ async def handle_webhook(hass, webhook_id, request): if direction == "enter": async_dispatcher_send(hass, TRACKER_UPDATE, device, gps_location, location_name) - return web.Response(text=f"Setting location to {location_name}", status=HTTP_OK) + return web.Response(text=f"Setting location to {location_name}") if direction == "exit": current_state = hass.states.get(f"{DEVICE_TRACKER}.{device}") @@ -88,7 +87,7 @@ async def handle_webhook(hass, webhook_id, request): async_dispatcher_send( hass, TRACKER_UPDATE, device, gps_location, location_name ) - return web.Response(text="Setting location to not home", status=HTTP_OK) + return web.Response(text="Setting location to not home") # Ignore the message if it is telling us to exit a zone that we # aren't currently in. This occurs when a zone is entered @@ -96,13 +95,12 @@ async def handle_webhook(hass, webhook_id, request): # be sent first, then the exit message will be sent second. return web.Response( text=f"Ignoring exit from {location_name} (already in {current_state})", - status=HTTP_OK, ) if direction == "test": # In the app, a test message can be sent. Just return something to # the user to let them know that it works. - return web.Response(text="Received test message.", status=HTTP_OK) + return web.Response(text="Received test message.") _LOGGER.error("Received unidentified message from Locative: %s", direction) return web.Response( diff --git a/homeassistant/components/plaato/__init__.py b/homeassistant/components/plaato/__init__.py index c214061c416..333ee6b6e95 100644 --- a/homeassistant/components/plaato/__init__.py +++ b/homeassistant/components/plaato/__init__.py @@ -29,7 +29,6 @@ from homeassistant.const import ( CONF_SCAN_INTERVAL, CONF_TOKEN, CONF_WEBHOOK_ID, - HTTP_OK, TEMP_CELSIUS, TEMP_FAHRENHEIT, VOLUME_GALLONS, @@ -199,7 +198,7 @@ async def handle_webhook(hass, webhook_id, request): async_dispatcher_send(hass, SENSOR_UPDATE, *(device_id, sensor_data)) - return web.Response(text=f"Saving status for {device_id}", status=HTTP_OK) + return web.Response(text=f"Saving status for {device_id}") def _device_id(data): diff --git a/homeassistant/components/rss_feed_template/__init__.py b/homeassistant/components/rss_feed_template/__init__.py index 4ea9c27b82e..222b533235d 100644 --- a/homeassistant/components/rss_feed_template/__init__.py +++ b/homeassistant/components/rss_feed_template/__init__.py @@ -5,7 +5,6 @@ from aiohttp import web import voluptuous as vol from homeassistant.components.http import HomeAssistantView -from homeassistant.const import HTTP_OK import homeassistant.helpers.config_validation as cv CONTENT_TYPE_XML = "text/xml" @@ -101,6 +100,4 @@ class RssView(HomeAssistantView): response += "\n" - return web.Response( - body=response, content_type=CONTENT_TYPE_XML, status=HTTP_OK - ) + return web.Response(body=response, content_type=CONTENT_TYPE_XML) diff --git a/homeassistant/components/traccar/__init__.py b/homeassistant/components/traccar/__init__.py index 439bdc6f09e..916b0f71169 100644 --- a/homeassistant/components/traccar/__init__.py +++ b/homeassistant/components/traccar/__init__.py @@ -3,12 +3,7 @@ from aiohttp import web import voluptuous as vol from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER -from homeassistant.const import ( - ATTR_ID, - CONF_WEBHOOK_ID, - HTTP_OK, - HTTP_UNPROCESSABLE_ENTITY, -) +from homeassistant.const import ATTR_ID, CONF_WEBHOOK_ID, HTTP_UNPROCESSABLE_ENTITY from homeassistant.helpers import config_entry_flow import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -31,7 +26,7 @@ PLATFORMS = [DEVICE_TRACKER] TRACKER_UPDATE = f"{DOMAIN}_tracker_update" -DEFAULT_ACCURACY = HTTP_OK +DEFAULT_ACCURACY = 200 DEFAULT_BATTERY = -1 @@ -87,7 +82,7 @@ async def handle_webhook(hass, webhook_id, request): attrs, ) - return web.Response(text=f"Setting location for {device}", status=HTTP_OK) + return web.Response(text=f"Setting location for {device}") async def async_setup_entry(hass, entry): diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py index 6d5d2608f06..400755c39cd 100644 --- a/tests/components/api/test_init.py +++ b/tests/components/api/test_init.py @@ -395,7 +395,7 @@ async def test_api_error_log( assert resp.status == 401 with patch( - "aiohttp.web.FileResponse", return_value=web.Response(status=200, text="Hello") + "aiohttp.web.FileResponse", return_value=web.Response(text="Hello") ) as mock_file: resp = await client.get( const.URL_API_ERROR_LOG, diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index 6bd1d622b12..71d848d12ab 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -36,7 +36,7 @@ async def mock_handler(request): user = request.get("hass_user") user_id = user.id if user else None - return web.json_response(status=200, data={"user_id": user_id}) + return web.json_response(data={"user_id": user_id}) async def get_legacy_user(auth): diff --git a/tests/components/http/test_cors.py b/tests/components/http/test_cors.py index 04447191fd5..d03b40b2df3 100644 --- a/tests/components/http/test_cors.py +++ b/tests/components/http/test_cors.py @@ -44,7 +44,7 @@ async def test_cors_middleware_loaded_from_config(hass): async def mock_handler(request): """Return if request was authenticated.""" - return web.Response(status=200) + return web.Response() @pytest.fixture From d494b3539dcf4cb90ae8299d06782042610aa12a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 21 Sep 2021 20:27:10 +0200 Subject: [PATCH 516/843] Prevent 3rd party lib from opening sockets in google_assistant tests (#56346) --- tests/components/google_assistant/test_http.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/components/google_assistant/test_http.py b/tests/components/google_assistant/test_http.py index 69a8242b7cc..013fa3c1d0c 100644 --- a/tests/components/google_assistant/test_http.py +++ b/tests/components/google_assistant/test_http.py @@ -153,6 +153,10 @@ async def test_report_state(hass, aioclient_mock, hass_storage): await config.async_connect_agent_user(agent_user_id) message = {"devices": {}} + with patch.object(config, "async_call_homegraph_api"): + # Wait for google_assistant.helpers.async_initialize.sync_google to be called + await hass.async_block_till_done() + with patch.object(config, "async_call_homegraph_api") as mock_call: await config.async_report_state(message, agent_user_id) mock_call.assert_called_once_with( From 9831ff04874d870b90a51ba48be9c384c5dd6f66 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Sep 2021 16:35:14 -0500 Subject: [PATCH 517/843] Avoid deadlock on shutdown when a task is shielded from cancelation (#56499) --- homeassistant/runner.py | 70 +++++++++++++++++++++++++++++++++++- tests/test_runner.py | 80 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+), 1 deletion(-) diff --git a/homeassistant/runner.py b/homeassistant/runner.py index 5eae0b1b2da..3fd4118a25b 100644 --- a/homeassistant/runner.py +++ b/homeassistant/runner.py @@ -26,6 +26,9 @@ from homeassistant.util.thread import deadlock_safe_shutdown # use case. # MAX_EXECUTOR_WORKERS = 64 +TASK_CANCELATION_TIMEOUT = 5 + +_LOGGER = logging.getLogger(__name__) @dataclasses.dataclass @@ -105,4 +108,69 @@ async def setup_and_run_hass(runtime_config: RuntimeConfig) -> int: def run(runtime_config: RuntimeConfig) -> int: """Run Home Assistant.""" asyncio.set_event_loop_policy(HassEventLoopPolicy(runtime_config.debug)) - return asyncio.run(setup_and_run_hass(runtime_config)) + # Backport of cpython 3.9 asyncio.run with a _cancel_all_tasks that times out + loop = asyncio.new_event_loop() + try: + asyncio.set_event_loop(loop) + return loop.run_until_complete(setup_and_run_hass(runtime_config)) + finally: + try: + _cancel_all_tasks_with_timeout(loop, TASK_CANCELATION_TIMEOUT) + loop.run_until_complete(loop.shutdown_asyncgens()) + # Once cpython 3.8 is no longer supported we can use the + # the built-in loop.shutdown_default_executor + loop.run_until_complete(_shutdown_default_executor(loop)) + finally: + asyncio.set_event_loop(None) + loop.close() + + +def _cancel_all_tasks_with_timeout( + loop: asyncio.AbstractEventLoop, timeout: int +) -> None: + """Adapted _cancel_all_tasks from python 3.9 with a timeout.""" + to_cancel = asyncio.all_tasks(loop) + if not to_cancel: + return + + for task in to_cancel: + task.cancel() + + loop.run_until_complete(asyncio.wait(to_cancel, timeout=timeout)) + + for task in to_cancel: + if task.cancelled(): + continue + if not task.done(): + _LOGGER.warning( + "Task could not be canceled and was still running after shutdown: %s", + task, + ) + continue + if task.exception() is not None: + loop.call_exception_handler( + { + "message": "unhandled exception during shutdown", + "exception": task.exception(), + "task": task, + } + ) + + +async def _shutdown_default_executor(loop: asyncio.AbstractEventLoop) -> None: + """Backport of cpython 3.9 schedule the shutdown of the default executor.""" + future = loop.create_future() + + def _do_shutdown() -> None: + try: + loop._default_executor.shutdown(wait=True) # type: ignore # pylint: disable=protected-access + loop.call_soon_threadsafe(future.set_result, None) + except Exception as ex: # pylint: disable=broad-except + loop.call_soon_threadsafe(future.set_exception, ex) + + thread = threading.Thread(target=_do_shutdown) + thread.start() + try: + await future + finally: + thread.join() diff --git a/tests/test_runner.py b/tests/test_runner.py index 7bbe96dd077..0e38cef0fff 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -1,8 +1,11 @@ """Test the runner.""" +import asyncio import threading from unittest.mock import patch +import pytest + from homeassistant import core, runner from homeassistant.util import executor, thread @@ -37,3 +40,80 @@ async def test_setup_and_run_hass(hass, tmpdir): assert threading._shutdown == thread.deadlock_safe_shutdown assert mock_run.called + + +def test_run(hass, tmpdir): + """Test we can run.""" + test_dir = tmpdir.mkdir("config") + default_config = runner.RuntimeConfig(test_dir) + + with patch.object(runner, "TASK_CANCELATION_TIMEOUT", 1), patch( + "homeassistant.bootstrap.async_setup_hass", return_value=hass + ), patch("threading._shutdown"), patch( + "homeassistant.core.HomeAssistant.async_run" + ) as mock_run: + runner.run(default_config) + + assert mock_run.called + + +def test_run_executor_shutdown_throws(hass, tmpdir): + """Test we can run and we still shutdown if the executor shutdown throws.""" + test_dir = tmpdir.mkdir("config") + default_config = runner.RuntimeConfig(test_dir) + + with patch.object(runner, "TASK_CANCELATION_TIMEOUT", 1), pytest.raises( + RuntimeError + ), patch("homeassistant.bootstrap.async_setup_hass", return_value=hass), patch( + "threading._shutdown" + ), patch( + "homeassistant.runner.InterruptibleThreadPoolExecutor.shutdown", + side_effect=RuntimeError, + ) as mock_shutdown, patch( + "homeassistant.core.HomeAssistant.async_run" + ) as mock_run: + runner.run(default_config) + + assert mock_shutdown.called + assert mock_run.called + + +def test_run_does_not_block_forever_with_shielded_task(hass, tmpdir, caplog): + """Test we can shutdown and not block forever.""" + test_dir = tmpdir.mkdir("config") + default_config = runner.RuntimeConfig(test_dir) + created_tasks = False + + async def _async_create_tasks(*_): + nonlocal created_tasks + + async def async_raise(*_): + try: + await asyncio.sleep(2) + except asyncio.CancelledError: + raise Exception + + async def async_shielded(*_): + try: + await asyncio.sleep(2) + except asyncio.CancelledError: + await asyncio.sleep(2) + + asyncio.ensure_future(asyncio.shield(async_shielded())) + asyncio.ensure_future(asyncio.sleep(2)) + asyncio.ensure_future(async_raise()) + await asyncio.sleep(0.1) + created_tasks = True + return 0 + + with patch.object(runner, "TASK_CANCELATION_TIMEOUT", 1), patch( + "homeassistant.bootstrap.async_setup_hass", return_value=hass + ), patch("threading._shutdown"), patch( + "homeassistant.core.HomeAssistant.async_run", _async_create_tasks + ): + runner.run(default_config) + + assert created_tasks is True + assert ( + "Task could not be canceled and was still running after shutdown" in caplog.text + ) From b7a758bd0caee4d0920f51c4565cce2c00673469 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Tue, 21 Sep 2021 23:35:51 +0200 Subject: [PATCH 518/843] raise PlatformNotReady when speakers unreachable (#56508) Co-authored-by: Paulus Schoutsen --- homeassistant/components/kef/media_player.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/kef/media_player.py b/homeassistant/components/kef/media_player.py index f32f825acc4..c1c83f81b36 100644 --- a/homeassistant/components/kef/media_player.py +++ b/homeassistant/components/kef/media_player.py @@ -32,6 +32,7 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, ) +from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.event import async_track_time_interval @@ -123,7 +124,10 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= mode = get_ip_mode(host) mac = await hass.async_add_executor_job(partial(get_mac_address, **{mode: host})) - unique_id = f"kef-{mac}" if mac is not None else None + if mac is None: + raise PlatformNotReady("Cannot get the ip address of kef speaker.") + + unique_id = f"kef-{mac}" media_player = KefMediaPlayer( name, From a653da137c1c50d00f107eb0282b70d9bb569c07 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Tue, 21 Sep 2021 17:53:35 -0400 Subject: [PATCH 519/843] Use EntityDescription - efergy (#54210) --- homeassistant/components/efergy/sensor.py | 125 +++++++++++++--------- 1 file changed, 75 insertions(+), 50 deletions(-) diff --git a/homeassistant/components/efergy/sensor.py b/homeassistant/components/efergy/sensor.py index 391aca7b4af..e7116a360e7 100644 --- a/homeassistant/components/efergy/sensor.py +++ b/homeassistant/components/efergy/sensor.py @@ -1,18 +1,31 @@ """Support for Efergy sensors.""" +from __future__ import annotations + import logging +from typing import Any import requests import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( CONF_CURRENCY, CONF_MONITORED_VARIABLES, CONF_TYPE, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_MONETARY, + DEVICE_CLASS_POWER, ENERGY_KILO_WATT_HOUR, POWER_WATT, ) +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) _RESOURCE = "https://engage.efergy.com/mobile_proxy/" @@ -31,12 +44,34 @@ CONF_CURRENT_VALUES = "current_values" DEFAULT_PERIOD = "year" DEFAULT_UTC_OFFSET = "0" -SENSOR_TYPES = { - CONF_INSTANT: ["Energy Usage", POWER_WATT], - CONF_AMOUNT: ["Energy Consumed", ENERGY_KILO_WATT_HOUR], - CONF_BUDGET: ["Energy Budget", None], - CONF_COST: ["Energy Cost", None], - CONF_CURRENT_VALUES: ["Per-Device Usage", POWER_WATT], +SENSOR_TYPES: dict[str, SensorEntityDescription] = { + CONF_INSTANT: SensorEntityDescription( + key=CONF_INSTANT, + name="Energy Usage", + device_class=DEVICE_CLASS_POWER, + native_unit_of_measurement=POWER_WATT, + ), + CONF_AMOUNT: SensorEntityDescription( + key=CONF_AMOUNT, + name="Energy Consumed", + device_class=DEVICE_CLASS_ENERGY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + CONF_BUDGET: SensorEntityDescription( + key=CONF_BUDGET, + name="Energy Budget", + ), + CONF_COST: SensorEntityDescription( + key=CONF_COST, + name="Energy Cost", + device_class=DEVICE_CLASS_MONETARY, + ), + CONF_CURRENT_VALUES: SensorEntityDescription( + key=CONF_CURRENT_VALUES, + name="Per-Device Usage", + device_class=DEVICE_CLASS_POWER, + native_unit_of_measurement=POWER_WATT, + ), } TYPES_SCHEMA = vol.In(SENSOR_TYPES) @@ -58,7 +93,12 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform(hass, config, add_entities, discovery_info=None): +def setup_platform( + hass: HomeAssistant, + config: ConfigType, + add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType = None, +) -> None: """Set up the Efergy sensor.""" app_token = config.get(CONF_APPTOKEN) utc_offset = str(config.get(CONF_UTC_OFFSET)) @@ -72,21 +112,21 @@ def setup_platform(hass, config, add_entities, discovery_info=None): sid = sensor["sid"] dev.append( EfergySensor( - variable[CONF_TYPE], app_token, utc_offset, variable[CONF_PERIOD], variable[CONF_CURRENCY], - sid, + SENSOR_TYPES[variable[CONF_TYPE]], + sid=sid, ) ) dev.append( EfergySensor( - variable[CONF_TYPE], app_token, utc_offset, variable[CONF_PERIOD], variable[CONF_CURRENCY], + SENSOR_TYPES[variable[CONF_TYPE]], ) ) @@ -96,59 +136,46 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class EfergySensor(SensorEntity): """Implementation of an Efergy sensor.""" - def __init__(self, sensor_type, app_token, utc_offset, period, currency, sid=None): + def __init__( + self, + app_token: Any, + utc_offset: str, + period: str, + currency: str, + description: SensorEntityDescription, + sid: str = None, + ) -> None: """Initialize the sensor.""" + self.entity_description = description self.sid = sid if sid: - self._name = f"efergy_{sid}" - else: - self._name = SENSOR_TYPES[sensor_type][0] - self.type = sensor_type + self._attr_name = f"efergy_{sid}" self.app_token = app_token self.utc_offset = utc_offset - self._state = None self.period = period - self.currency = currency - if self.type == "cost": - self._unit_of_measurement = f"{self.currency}/{self.period}" - else: - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + if description.key == CONF_COST: + self._attr_native_unit_of_measurement = f"{currency}/{period}" - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement - - def update(self): + def update(self) -> None: """Get the Efergy monitor data from the web service.""" try: - if self.type == "instant_readings": + if self.entity_description.key == CONF_INSTANT: url_string = f"{_RESOURCE}getInstant?token={self.app_token}" response = requests.get(url_string, timeout=10) - self._state = response.json()["reading"] - elif self.type == "amount": + self._attr_native_value = response.json()["reading"] + elif self.entity_description.key == CONF_AMOUNT: url_string = f"{_RESOURCE}getEnergy?token={self.app_token}&offset={self.utc_offset}&period={self.period}" response = requests.get(url_string, timeout=10) - self._state = response.json()["sum"] - elif self.type == "budget": + self._attr_native_value = response.json()["sum"] + elif self.entity_description.key == CONF_BUDGET: url_string = f"{_RESOURCE}getBudget?token={self.app_token}" response = requests.get(url_string, timeout=10) - self._state = response.json()["status"] - elif self.type == "cost": + self._attr_native_value = response.json()["status"] + elif self.entity_description.key == CONF_COST: url_string = f"{_RESOURCE}getCost?token={self.app_token}&offset={self.utc_offset}&period={self.period}" response = requests.get(url_string, timeout=10) - self._state = response.json()["sum"] - elif self.type == "current_values": + self._attr_native_value = response.json()["sum"] + elif self.entity_description.key == CONF_CURRENT_VALUES: url_string = ( f"{_RESOURCE}getCurrentValuesSummary?token={self.app_token}" ) @@ -156,8 +183,6 @@ class EfergySensor(SensorEntity): for sensor in response.json(): if self.sid == sensor["sid"]: measurement = next(iter(sensor["data"][0].values())) - self._state = measurement - else: - self._state = None + self._attr_native_value = measurement except (requests.RequestException, ValueError, KeyError): _LOGGER.warning("Could not update status for %s", self.name) From 783cc1eacd34cae0259fcaef16c9e6a0ea371cad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niklas=20=C3=85str=C3=B6m?= Date: Wed, 22 Sep 2021 14:01:30 +0200 Subject: [PATCH 520/843] Optimise requests to the tado servers (#56261) This avoids calling the tado servers unnecessarily many times, especially for bigger homes. This is done by calling aggregating endpoints instead of iterating over the zones and devices and calling endpoints over and over. --- homeassistant/components/tado/__init__.py | 103 +++++-- homeassistant/components/tado/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/tado/util.py | 5 + tests/fixtures/tado/zone_states.json | 292 ++++++++++++++++++++ 6 files changed, 374 insertions(+), 32 deletions(-) create mode 100644 tests/fixtures/tado/zone_states.json diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index 3cb2abe90f6..7a1965e31fb 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -3,6 +3,7 @@ from datetime import timedelta import logging from PyTado.interface import Tado +from PyTado.zone import TadoZone from requests import RequestException import requests.exceptions @@ -155,52 +156,96 @@ class TadoConnector: @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Update the registered zones.""" - for device in self.devices: - self.update_sensor("device", device["shortSerialNo"]) - for zone in self.zones: - self.update_sensor("zone", zone["id"]) + self.update_devices() + self.update_zones() self.data["weather"] = self.tado.getWeather() dispatcher_send( self.hass, SIGNAL_TADO_UPDATE_RECEIVED.format(self.home_id, "weather", "data"), ) - def update_sensor(self, sensor_type, sensor): - """Update the internal data from Tado.""" - _LOGGER.debug("Updating %s %s", sensor_type, sensor) - try: - if sensor_type == "device": - data = self.tado.getDeviceInfo(sensor) + def update_devices(self): + """Update the device data from Tado.""" + devices = self.tado.getDevices() + for device in devices: + device_short_serial_no = device["shortSerialNo"] + _LOGGER.debug("Updating device %s", device_short_serial_no) + try: if ( INSIDE_TEMPERATURE_MEASUREMENT - in data["characteristics"]["capabilities"] + in device["characteristics"]["capabilities"] ): - data[TEMP_OFFSET] = self.tado.getDeviceInfo(sensor, TEMP_OFFSET) - elif sensor_type == "zone": - data = self.tado.getZoneState(sensor) - else: - _LOGGER.debug("Unknown sensor: %s", sensor_type) + device[TEMP_OFFSET] = self.tado.getDeviceInfo( + device_short_serial_no, TEMP_OFFSET + ) + except RuntimeError: + _LOGGER.error( + "Unable to connect to Tado while updating device %s", + device_short_serial_no, + ) return - except RuntimeError: - _LOGGER.error( - "Unable to connect to Tado while updating %s %s", - sensor_type, - sensor, + + self.data["device"][device_short_serial_no] = device + + _LOGGER.debug( + "Dispatching update to %s device %s: %s", + self.home_id, + device_short_serial_no, + device, ) + dispatcher_send( + self.hass, + SIGNAL_TADO_UPDATE_RECEIVED.format( + self.home_id, "device", device_short_serial_no + ), + ) + + def update_zones(self): + """Update the zone data from Tado.""" + try: + zone_states = self.tado.getZoneStates()["zoneStates"] + except RuntimeError: + _LOGGER.error("Unable to connect to Tado while updating zones") return - self.data[sensor_type][sensor] = data + for zone in self.zones: + zone_id = zone["id"] + _LOGGER.debug("Updating zone %s", zone_id) + zone_state = TadoZone(zone_states[str(zone_id)], zone_id) + + self.data["zone"][zone_id] = zone_state + + _LOGGER.debug( + "Dispatching update to %s zone %s: %s", + self.home_id, + zone_id, + zone_state, + ) + dispatcher_send( + self.hass, + SIGNAL_TADO_UPDATE_RECEIVED.format(self.home_id, "zone", zone["id"]), + ) + + def update_zone(self, zone_id): + """Update the internal data from Tado.""" + _LOGGER.debug("Updating zone %s", zone_id) + try: + data = self.tado.getZoneState(zone_id) + except RuntimeError: + _LOGGER.error("Unable to connect to Tado while updating zone %s", zone_id) + return + + self.data["zone"][zone_id] = data _LOGGER.debug( - "Dispatching update to %s %s %s: %s", + "Dispatching update to %s zone %s: %s", self.home_id, - sensor_type, - sensor, + zone_id, data, ) dispatcher_send( self.hass, - SIGNAL_TADO_UPDATE_RECEIVED.format(self.home_id, sensor_type, sensor), + SIGNAL_TADO_UPDATE_RECEIVED.format(self.home_id, "zone", zone_id), ) def get_capabilities(self, zone_id): @@ -210,7 +255,7 @@ class TadoConnector: def reset_zone_overlay(self, zone_id): """Reset the zone back to the default operation.""" self.tado.resetZoneOverlay(zone_id) - self.update_sensor("zone", zone_id) + self.update_zone(zone_id) def set_presence( self, @@ -262,7 +307,7 @@ class TadoConnector: except RequestException as exc: _LOGGER.error("Could not set zone overlay: %s", exc) - self.update_sensor("zone", zone_id) + self.update_zone(zone_id) def set_zone_off(self, zone_id, overlay_mode, device_type="HEATING"): """Set a zone to off.""" @@ -273,7 +318,7 @@ class TadoConnector: except RequestException as exc: _LOGGER.error("Could not set zone overlay: %s", exc) - self.update_sensor("zone", zone_id) + self.update_zone(zone_id) def set_temperature_offset(self, device_id, offset): """Set temperature offset of device.""" diff --git a/homeassistant/components/tado/manifest.json b/homeassistant/components/tado/manifest.json index 8cf0ed260e8..a77974ab803 100644 --- a/homeassistant/components/tado/manifest.json +++ b/homeassistant/components/tado/manifest.json @@ -2,7 +2,7 @@ "domain": "tado", "name": "Tado", "documentation": "https://www.home-assistant.io/integrations/tado", - "requirements": ["python-tado==0.10.0"], + "requirements": ["python-tado==0.12.0"], "codeowners": ["@michaelarnauts", "@noltari"], "config_flow": true, "homekit": { diff --git a/requirements_all.txt b/requirements_all.txt index 986b79e278d..5b16bd0f064 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1926,7 +1926,7 @@ python-sochain-api==0.0.2 python-songpal==0.12 # homeassistant.components.tado -python-tado==0.10.0 +python-tado==0.12.0 # homeassistant.components.telegram_bot python-telegram-bot==13.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c0dff9c66d4..f9428de92d3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1107,7 +1107,7 @@ python-smarttub==0.0.25 python-songpal==0.12 # homeassistant.components.tado -python-tado==0.10.0 +python-tado==0.12.0 # homeassistant.components.twitch python-twitch-client==0.6.0 diff --git a/tests/components/tado/util.py b/tests/components/tado/util.py index ce1dd92942d..a5e23b6021a 100644 --- a/tests/components/tado/util.py +++ b/tests/components/tado/util.py @@ -20,6 +20,7 @@ async def async_init_integration( me_fixture = "tado/me.json" weather_fixture = "tado/weather.json" zones_fixture = "tado/zones.json" + zone_states_fixture = "tado/zone_states.json" # WR1 Device device_wr1_fixture = "tado/device_wr1.json" @@ -80,6 +81,10 @@ async def async_init_integration( "https://my.tado.com/api/v2/homes/1/zones", text=load_fixture(zones_fixture), ) + m.get( + "https://my.tado.com/api/v2/homes/1/zoneStates", + text=load_fixture(zone_states_fixture), + ) m.get( "https://my.tado.com/api/v2/homes/1/zones/5/capabilities", text=load_fixture(zone_5_capabilities_fixture), diff --git a/tests/fixtures/tado/zone_states.json b/tests/fixtures/tado/zone_states.json new file mode 100644 index 00000000000..c5bd0dfbe2c --- /dev/null +++ b/tests/fixtures/tado/zone_states.json @@ -0,0 +1,292 @@ +{ + "zoneStates": { + "1": { + "tadoMode": "HOME", + "geolocationOverride": false, + "geolocationOverrideDisableTime": null, + "preparation": null, + "setting": { + "type": "HEATING", + "power": "ON", + "temperature": { + "celsius": 20.50, + "fahrenheit": 68.90 + } + }, + "overlayType": "MANUAL", + "overlay": { + "type": "MANUAL", + "setting": { + "type": "HEATING", + "power": "ON", + "temperature": { + "celsius": 20.50, + "fahrenheit": 68.90 + } + }, + "termination": { + "type": "MANUAL", + "typeSkillBasedApp": "MANUAL", + "projectedExpiry": null + } + }, + "openWindow": null, + "nextScheduleChange": { + "start": "2020-03-10T17:00:00Z", + "setting": { + "type": "HEATING", + "power": "ON", + "temperature": { + "celsius": 21.00, + "fahrenheit": 69.80 + } + } + }, + "nextTimeBlock": { + "start": "2020-03-10T17:00:00.000Z" + }, + "link": { + "state": "ONLINE" + }, + "activityDataPoints": { + "heatingPower": { + "type": "PERCENTAGE", + "percentage": 0.00, + "timestamp": "2020-03-10T07:47:45.978Z" + } + }, + "sensorDataPoints": { + "insideTemperature": { + "celsius": 20.65, + "fahrenheit": 69.17, + "timestamp": "2020-03-10T07:44:11.947Z", + "type": "TEMPERATURE", + "precision": { + "celsius": 0.1, + "fahrenheit": 0.1 + } + }, + "humidity": { + "type": "PERCENTAGE", + "percentage": 45.20, + "timestamp": "2020-03-10T07:44:11.947Z" + } + } + }, + "2": { + "tadoMode": "HOME", + "geolocationOverride": false, + "geolocationOverrideDisableTime": null, + "preparation": null, + "setting": { + "type": "HOT_WATER", + "power": "ON", + "temperature": { + "celsius": 65.00, + "fahrenheit": 149.00 + } + }, + "overlayType": null, + "overlay": null, + "openWindow": null, + "nextScheduleChange": { + "start": "2020-03-10T22:00:00Z", + "setting": { + "type": "HOT_WATER", + "power": "OFF", + "temperature": null + } + }, + "nextTimeBlock": { + "start": "2020-03-10T22:00:00.000Z" + }, + "link": { + "state": "ONLINE" + }, + "activityDataPoints": {}, + "sensorDataPoints": {} + }, + "3": { + "tadoMode": "HOME", + "sensorDataPoints": { + "insideTemperature": { + "fahrenheit": 76.57, + "timestamp": "2020-03-05T03:57:38.850Z", + "celsius": 24.76, + "type": "TEMPERATURE", + "precision": { + "fahrenheit": 0.1, + "celsius": 0.1 + } + }, + "humidity": { + "timestamp": "2020-03-05T03:57:38.850Z", + "percentage": 60.9, + "type": "PERCENTAGE" + } + }, + "link": { + "state": "ONLINE" + }, + "openWindow": null, + "geolocationOverride": false, + "geolocationOverrideDisableTime": null, + "overlay": { + "termination": { + "typeSkillBasedApp": "TADO_MODE", + "projectedExpiry": null, + "type": "TADO_MODE" + }, + "setting": { + "fanSpeed": "AUTO", + "type": "AIR_CONDITIONING", + "mode": "COOL", + "power": "ON", + "temperature": { + "fahrenheit": 64.0, + "celsius": 17.78 + } + }, + "type": "MANUAL" + }, + "activityDataPoints": { + "acPower": { + "timestamp": "2020-03-05T04:01:07.162Z", + "type": "POWER", + "value": "ON" + } + }, + "nextTimeBlock": { + "start": "2020-03-05T08:00:00.000Z" + }, + "preparation": null, + "overlayType": "MANUAL", + "nextScheduleChange": null, + "setting": { + "fanSpeed": "AUTO", + "type": "AIR_CONDITIONING", + "mode": "COOL", + "power": "ON", + "temperature": { + "fahrenheit": 64.0, + "celsius": 17.78 + } + } + }, + "4": { + "activityDataPoints": {}, + "preparation": null, + "openWindow": null, + "tadoMode": "HOME", + "nextScheduleChange": { + "setting": { + "temperature": { + "fahrenheit": 149, + "celsius": 65 + }, + "type": "HOT_WATER", + "power": "ON" + }, + "start": "2020-03-26T05:00:00Z" + }, + "nextTimeBlock": { + "start": "2020-03-26T05:00:00.000Z" + }, + "overlay": { + "setting": { + "temperature": { + "celsius": 30, + "fahrenheit": 86 + }, + "type": "HOT_WATER", + "power": "ON" + }, + "termination": { + "type": "TADO_MODE", + "projectedExpiry": "2020-03-26T05:00:00Z", + "typeSkillBasedApp": "TADO_MODE" + }, + "type": "MANUAL" + }, + "geolocationOverride": false, + "geolocationOverrideDisableTime": null, + "sensorDataPoints": {}, + "overlayType": "MANUAL", + "link": { + "state": "ONLINE" + }, + "setting": { + "type": "HOT_WATER", + "temperature": { + "fahrenheit": 86, + "celsius": 30 + }, + "power": "ON" + } + }, + "5": { + "tadoMode": "HOME", + "geolocationOverride": false, + "geolocationOverrideDisableTime": null, + "preparation": null, + "setting": { + "type": "AIR_CONDITIONING", + "power": "ON", + "mode": "HEAT", + "temperature": { + "celsius": 20.00, + "fahrenheit": 68.00 + }, + "fanSpeed": "AUTO", + "swing": "ON" + }, + "overlayType": null, + "overlay": null, + "openWindow": null, + "nextScheduleChange": { + "start": "2020-03-28T04:30:00Z", + "setting": { + "type": "AIR_CONDITIONING", + "power": "ON", + "mode": "HEAT", + "temperature": { + "celsius": 23.00, + "fahrenheit": 73.40 + }, + "fanSpeed": "AUTO", + "swing": "ON" + } + }, + "nextTimeBlock": { + "start": "2020-03-28T04:30:00.000Z" + }, + "link": { + "state": "ONLINE" + }, + "activityDataPoints": { + "acPower": { + "timestamp": "2020-03-27T23:02:22.260Z", + "type": "POWER", + "value": "ON" + } + }, + "sensorDataPoints": { + "insideTemperature": { + "celsius": 20.88, + "fahrenheit": 69.58, + "timestamp": "2020-03-28T02:09:27.830Z", + "type": "TEMPERATURE", + "precision": { + "celsius": 0.1, + "fahrenheit": 0.1 + } + }, + "humidity": { + "type": "PERCENTAGE", + "percentage": 42.30, + "timestamp": "2020-03-28T02:09:27.830Z" + } + } + } + } +} \ No newline at end of file From aab4b5ec064c97d4facfd1101af409c831720022 Mon Sep 17 00:00:00 2001 From: Roel van der Ark Date: Wed, 22 Sep 2021 14:08:23 +0200 Subject: [PATCH 521/843] Add extra power meter for YouLess (#56528) * #55535 added extra power meter * Update sensor.py Co-authored-by: Erik Montnemery --- homeassistant/components/youless/sensor.py | 31 ++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/youless/sensor.py b/homeassistant/components/youless/sensor.py index 0b081ab15a2..24983bb567b 100644 --- a/homeassistant/components/youless/sensor.py +++ b/homeassistant/components/youless/sensor.py @@ -47,7 +47,7 @@ async def async_setup_entry( DeliveryMeterSensor(coordinator, device, "low"), DeliveryMeterSensor(coordinator, device, "high"), ExtraMeterSensor(coordinator, device, "total"), - ExtraMeterSensor(coordinator, device, "usage"), + ExtraMeterPowerSensor(coordinator, device, "usage"), ] ) @@ -191,7 +191,8 @@ class ExtraMeterSensor(YoulessBaseSensor): """The Youless extra meter value sensor (s0).""" _attr_native_unit_of_measurement = ENERGY_KILO_WATT_HOUR - _attr_device_class = DEVICE_CLASS_POWER + _attr_device_class = DEVICE_CLASS_ENERGY + _attr_state_class = STATE_CLASS_TOTAL_INCREASING def __init__( self, coordinator: DataUpdateCoordinator, device: str, dev_type: str @@ -210,3 +211,29 @@ class ExtraMeterSensor(YoulessBaseSensor): return None return getattr(self.coordinator.data.extra_meter, f"_{self._type}", None) + + +class ExtraMeterPowerSensor(YoulessBaseSensor): + """The Youless extra meter power value sensor (s0).""" + + _attr_native_unit_of_measurement = POWER_WATT + _attr_device_class = DEVICE_CLASS_POWER + _attr_state_class = STATE_CLASS_MEASUREMENT + + def __init__( + self, coordinator: DataUpdateCoordinator, device: str, dev_type: str + ) -> None: + """Instantiate an extra meter power sensor.""" + super().__init__( + coordinator, device, "extra", "Extra meter", f"extra_{dev_type}" + ) + self._type = dev_type + self._attr_name = f"Extra {dev_type}" + + @property + def get_sensor(self) -> YoulessSensor | None: + """Get the sensor for providing the value.""" + if self.coordinator.data.extra_meter is None: + return None + + return getattr(self.coordinator.data.extra_meter, f"_{self._type}", None) From 26d310fc8a33e9f57b672db7d359820e50566302 Mon Sep 17 00:00:00 2001 From: Tobias Sauerwein Date: Wed, 22 Sep 2021 14:32:30 +0200 Subject: [PATCH 522/843] Split Netatmo camera persons by home (#55598) * Split persons by home * Bump pyatmo to 6.0.0 * Check is person exists * Extract method for fetching person ids --- homeassistant/components/netatmo/camera.py | 64 ++++++++++++------- .../components/netatmo/manifest.json | 2 +- homeassistant/components/netatmo/webhook.py | 7 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/netatmo/test_camera.py | 55 ++++++++++++++++ 6 files changed, 102 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/netatmo/camera.py b/homeassistant/components/netatmo/camera.py index 4d6141e2dfb..2666409f28e 100644 --- a/homeassistant/components/netatmo/camera.py +++ b/homeassistant/components/netatmo/camera.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.components.camera import SUPPORT_STREAM, Camera from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import PlatformNotReady +from homeassistant.exceptions import HomeAssistantError, PlatformNotReady from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -83,10 +83,16 @@ async def async_setup_entry( for camera in all_cameras ] - for person_id, person_data in data_handler.data[ - CAMERA_DATA_CLASS_NAME - ].persons.items(): - hass.data[DOMAIN][DATA_PERSONS][person_id] = person_data.get(ATTR_PSEUDO) + for home in data_class.homes.values(): + if home.get("id") is None: + continue + + hass.data[DOMAIN][DATA_PERSONS][home["id"]] = { + person_id: person_data.get(ATTR_PSEUDO) + for person_id, person_data in data_handler.data[CAMERA_DATA_CLASS_NAME] + .persons[home["id"]] + .items() + } _LOGGER.debug("Adding cameras %s", entities) async_add_entities(entities, True) @@ -309,14 +315,31 @@ class NetatmoCamera(NetatmoBase, Camera): ] = f"{self._vpnurl}/vod/{event['video_id']}/files/{self._quality}/index.m3u8" return events - async def _service_set_persons_home(self, **kwargs: Any) -> None: - """Service to change current home schedule.""" - persons = kwargs.get(ATTR_PERSONS, {}) + def fetch_person_ids(self, persons: list[str | None]) -> list[str]: + """Fetch matching person ids for give list of persons.""" person_ids = [] + person_id_errors = [] + for person in persons: - for pid, data in self._data.persons.items(): + person_id = None + for pid, data in self._data.persons[self._home_id].items(): if data.get("pseudo") == person: person_ids.append(pid) + person_id = pid + break + + if person_id is None: + person_id_errors.append(person) + + if person_id_errors: + raise HomeAssistantError(f"Person(s) not registered {person_id_errors}") + + return person_ids + + async def _service_set_persons_home(self, **kwargs: Any) -> None: + """Service to change current home schedule.""" + persons = kwargs.get(ATTR_PERSONS, []) + person_ids = self.fetch_person_ids(persons) await self._data.async_set_persons_home( person_ids=person_ids, home_id=self._home_id @@ -326,24 +349,17 @@ class NetatmoCamera(NetatmoBase, Camera): async def _service_set_person_away(self, **kwargs: Any) -> None: """Service to mark a person as away or set the home as empty.""" person = kwargs.get(ATTR_PERSON) - person_id = None - if person: - for pid, data in self._data.persons.items(): - if data.get("pseudo") == person: - person_id = pid + person_ids = self.fetch_person_ids([person] if person else []) + person_id = next(iter(person_ids), None) + + await self._data.async_set_persons_away( + person_id=person_id, + home_id=self._home_id, + ) if person_id: - await self._data.async_set_persons_away( - person_id=person_id, - home_id=self._home_id, - ) - _LOGGER.debug("Set %s as away", person) - + _LOGGER.debug("Set %s as away %s", person, person_id) else: - await self._data.async_set_persons_away( - person_id=person_id, - home_id=self._home_id, - ) _LOGGER.debug("Set home as empty") async def _service_set_camera_light(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/netatmo/manifest.json b/homeassistant/components/netatmo/manifest.json index 6c99f3c0786..f51f1a22f48 100644 --- a/homeassistant/components/netatmo/manifest.json +++ b/homeassistant/components/netatmo/manifest.json @@ -3,7 +3,7 @@ "name": "Netatmo", "documentation": "https://www.home-assistant.io/integrations/netatmo", "requirements": [ - "pyatmo==5.2.3" + "pyatmo==6.0.0" ], "after_dependencies": [ "cloud", diff --git a/homeassistant/components/netatmo/webhook.py b/homeassistant/components/netatmo/webhook.py index 4f39d5fe5f5..9761c8298c7 100644 --- a/homeassistant/components/netatmo/webhook.py +++ b/homeassistant/components/netatmo/webhook.py @@ -10,6 +10,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ( ATTR_EVENT_TYPE, ATTR_FACE_URL, + ATTR_HOME_ID, ATTR_IS_KNOWN, ATTR_PERSONS, DATA_DEVICE_IDS, @@ -60,9 +61,9 @@ def async_evaluate_event(hass: HomeAssistant, event_data: dict) -> None: for person in event_data.get(ATTR_PERSONS, {}): person_event_data = dict(event_data) person_event_data[ATTR_ID] = person.get(ATTR_ID) - person_event_data[ATTR_NAME] = hass.data[DOMAIN][DATA_PERSONS].get( - person_event_data[ATTR_ID], DEFAULT_PERSON - ) + person_event_data[ATTR_NAME] = hass.data[DOMAIN][DATA_PERSONS][ + event_data[ATTR_HOME_ID] + ].get(person_event_data[ATTR_ID], DEFAULT_PERSON) person_event_data[ATTR_IS_KNOWN] = person.get(ATTR_IS_KNOWN) person_event_data[ATTR_FACE_URL] = person.get(ATTR_FACE_URL) diff --git a/requirements_all.txt b/requirements_all.txt index 5b16bd0f064..8b14f2dc932 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1355,7 +1355,7 @@ pyarlo==0.2.4 pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==5.2.3 +pyatmo==6.0.0 # homeassistant.components.atome pyatome==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f9428de92d3..44ac5935a13 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -791,7 +791,7 @@ pyarlo==0.2.4 pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==5.2.3 +pyatmo==6.0.0 # homeassistant.components.apple_tv pyatv==0.8.2 diff --git a/tests/components/netatmo/test_camera.py b/tests/components/netatmo/test_camera.py index 4825946beab..c8132331bf3 100644 --- a/tests/components/netatmo/test_camera.py +++ b/tests/components/netatmo/test_camera.py @@ -14,6 +14,7 @@ from homeassistant.components.netatmo.const import ( SERVICE_SET_PERSONS_HOME, ) from homeassistant.const import CONF_WEBHOOK_ID +from homeassistant.exceptions import HomeAssistantError from homeassistant.util import dt from .common import fake_post_request, selected_platforms, simulate_webhook @@ -220,6 +221,60 @@ async def test_service_set_person_away(hass, config_entry, netatmo_auth): ) +async def test_service_set_person_away_invalid_person(hass, config_entry, netatmo_auth): + """Test service to set invalid person as away.""" + with selected_platforms(["camera"]): + await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() + + await hass.async_block_till_done() + + data = { + "entity_id": "camera.netatmo_hall", + "person": "Batman", + } + + with pytest.raises(HomeAssistantError) as excinfo: + await hass.services.async_call( + "netatmo", + SERVICE_SET_PERSON_AWAY, + service_data=data, + blocking=True, + ) + await hass.async_block_till_done() + + assert excinfo.value.args == ("Person(s) not registered ['Batman']",) + + +async def test_service_set_persons_home_invalid_person( + hass, config_entry, netatmo_auth +): + """Test service to set invalid persons as home.""" + with selected_platforms(["camera"]): + await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() + + await hass.async_block_till_done() + + data = { + "entity_id": "camera.netatmo_hall", + "persons": "Batman", + } + + with pytest.raises(HomeAssistantError) as excinfo: + await hass.services.async_call( + "netatmo", + SERVICE_SET_PERSONS_HOME, + service_data=data, + blocking=True, + ) + await hass.async_block_till_done() + + assert excinfo.value.args == ("Person(s) not registered ['Batman']",) + + async def test_service_set_persons_home(hass, config_entry, netatmo_auth): """Test service to set persons as home.""" with selected_platforms(["camera"]): From ac053388b4afcf6120c24da9bfe6699def5d83a6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 22 Sep 2021 15:00:28 +0200 Subject: [PATCH 523/843] Convert image_processing tests to pytest (#56451) * Convert image_processing tests to pytest * Use existing fixtures and test helpers * Remove useless code --- .../components/image_processing/test_init.py | 446 ++++++++---------- 1 file changed, 194 insertions(+), 252 deletions(-) diff --git a/tests/components/image_processing/test_init.py b/tests/components/image_processing/test_init.py index 55c76273ad7..c0c57b17a7c 100644 --- a/tests/components/image_processing/test_init.py +++ b/tests/components/image_processing/test_init.py @@ -4,289 +4,231 @@ from unittest.mock import PropertyMock, patch import homeassistant.components.http as http import homeassistant.components.image_processing as ip from homeassistant.const import ATTR_ENTITY_PICTURE -from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.loader import DATA_CUSTOM_COMPONENTS -from homeassistant.setup import setup_component +from homeassistant.setup import async_setup_component -from tests.common import ( - assert_setup_component, - get_test_home_assistant, - get_test_instance_port, -) +from tests.common import assert_setup_component, async_capture_events from tests.components.image_processing import common -class TestSetupImageProcessing: - """Test class for setup image processing.""" - - def setup_method(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - - def teardown_method(self): - """Stop everything that was started.""" - self.hass.stop() - - def test_setup_component(self): - """Set up demo platform on image_process component.""" - config = {ip.DOMAIN: {"platform": "demo"}} - - with assert_setup_component(1, ip.DOMAIN): - setup_component(self.hass, ip.DOMAIN, config) - - def test_setup_component_with_service(self): - """Set up demo platform on image_process component test service.""" - config = {ip.DOMAIN: {"platform": "demo"}} - - with assert_setup_component(1, ip.DOMAIN): - setup_component(self.hass, ip.DOMAIN, config) - - assert self.hass.services.has_service(ip.DOMAIN, "scan") +def get_url(hass): + """Return camera url.""" + state = hass.states.get("camera.demo_camera") + return f"{hass.config.internal_url}{state.attributes.get(ATTR_ENTITY_PICTURE)}" -class TestImageProcessing: - """Test class for image processing.""" - - def setup_method(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self.hass.data.pop(DATA_CUSTOM_COMPONENTS) - - setup_component( - self.hass, - http.DOMAIN, - {http.DOMAIN: {http.CONF_SERVER_PORT: get_test_instance_port()}}, - ) - - config = {ip.DOMAIN: {"platform": "test"}, "camera": {"platform": "demo"}} - - setup_component(self.hass, ip.DOMAIN, config) - self.hass.block_till_done() - - state = self.hass.states.get("camera.demo_camera") - self.url = f"{self.hass.config.internal_url}{state.attributes.get(ATTR_ENTITY_PICTURE)}" - - def teardown_method(self): - """Stop everything that was started.""" - self.hass.stop() - - @patch( - "homeassistant.components.demo.camera.Path.read_bytes", - return_value=b"Test", +async def setup_image_processing(hass, aiohttp_unused_port): + """Set up things to be run when tests are started.""" + await async_setup_component( + hass, + http.DOMAIN, + {http.DOMAIN: {http.CONF_SERVER_PORT: aiohttp_unused_port()}}, ) - def test_get_image_from_camera(self, mock_camera_read): - """Grab an image from camera entity.""" - common.scan(self.hass, entity_id="image_processing.test") - self.hass.block_till_done() - state = self.hass.states.get("image_processing.test") + config = {ip.DOMAIN: {"platform": "test"}, "camera": {"platform": "demo"}} - assert mock_camera_read.called - assert state.state == "1" - assert state.attributes["image"] == b"Test" - - @patch( - "homeassistant.components.camera.async_get_image", - side_effect=HomeAssistantError(), - ) - def test_get_image_without_exists_camera(self, mock_image): - """Try to get image without exists camera.""" - self.hass.states.remove("camera.demo_camera") - - common.scan(self.hass, entity_id="image_processing.test") - self.hass.block_till_done() - - state = self.hass.states.get("image_processing.test") - - assert mock_image.called - assert state.state == "0" + await async_setup_component(hass, ip.DOMAIN, config) + await hass.async_block_till_done() -class TestImageProcessingAlpr: - """Test class for alpr image processing.""" +async def setup_image_processing_alpr(hass): + """Set up things to be run when tests are started.""" + config = {ip.DOMAIN: {"platform": "demo"}, "camera": {"platform": "demo"}} - def setup_method(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() + await async_setup_component(hass, ip.DOMAIN, config) + await hass.async_block_till_done() - config = {ip.DOMAIN: {"platform": "demo"}, "camera": {"platform": "demo"}} - - with patch( - "homeassistant.components.demo.image_processing." - "DemoImageProcessingAlpr.should_poll", - new_callable=PropertyMock(return_value=False), - ): - setup_component(self.hass, ip.DOMAIN, config) - self.hass.block_till_done() - - state = self.hass.states.get("camera.demo_camera") - self.url = f"{self.hass.config.internal_url}{state.attributes.get(ATTR_ENTITY_PICTURE)}" - - self.alpr_events = [] - - @callback - def mock_alpr_event(event): - """Mock event.""" - self.alpr_events.append(event) - - self.hass.bus.listen("image_processing.found_plate", mock_alpr_event) - - def teardown_method(self): - """Stop everything that was started.""" - self.hass.stop() - - def test_alpr_event_single_call(self, aioclient_mock): - """Set up and scan a picture and test plates from event.""" - aioclient_mock.get(self.url, content=b"image") - - common.scan(self.hass, entity_id="image_processing.demo_alpr") - self.hass.block_till_done() - - state = self.hass.states.get("image_processing.demo_alpr") - - assert len(self.alpr_events) == 4 - assert state.state == "AC3829" - - event_data = [ - event.data - for event in self.alpr_events - if event.data.get("plate") == "AC3829" - ] - assert len(event_data) == 1 - assert event_data[0]["plate"] == "AC3829" - assert event_data[0]["confidence"] == 98.3 - assert event_data[0]["entity_id"] == "image_processing.demo_alpr" - - def test_alpr_event_double_call(self, aioclient_mock): - """Set up and scan a picture and test plates from event.""" - aioclient_mock.get(self.url, content=b"image") - - common.scan(self.hass, entity_id="image_processing.demo_alpr") - common.scan(self.hass, entity_id="image_processing.demo_alpr") - self.hass.block_till_done() - - state = self.hass.states.get("image_processing.demo_alpr") - - assert len(self.alpr_events) == 4 - assert state.state == "AC3829" - - event_data = [ - event.data - for event in self.alpr_events - if event.data.get("plate") == "AC3829" - ] - assert len(event_data) == 1 - assert event_data[0]["plate"] == "AC3829" - assert event_data[0]["confidence"] == 98.3 - assert event_data[0]["entity_id"] == "image_processing.demo_alpr" - - @patch( - "homeassistant.components.demo.image_processing." - "DemoImageProcessingAlpr.confidence", - new_callable=PropertyMock(return_value=95), - ) - def test_alpr_event_single_call_confidence(self, confidence_mock, aioclient_mock): - """Set up and scan a picture and test plates from event.""" - aioclient_mock.get(self.url, content=b"image") - - common.scan(self.hass, entity_id="image_processing.demo_alpr") - self.hass.block_till_done() - - state = self.hass.states.get("image_processing.demo_alpr") - - assert len(self.alpr_events) == 2 - assert state.state == "AC3829" - - event_data = [ - event.data - for event in self.alpr_events - if event.data.get("plate") == "AC3829" - ] - assert len(event_data) == 1 - assert event_data[0]["plate"] == "AC3829" - assert event_data[0]["confidence"] == 98.3 - assert event_data[0]["entity_id"] == "image_processing.demo_alpr" + return async_capture_events(hass, "image_processing.found_plate") -class TestImageProcessingFace: - """Test class for face image processing.""" +async def setup_image_processing_face(hass): + """Set up things to be run when tests are started.""" + config = {ip.DOMAIN: {"platform": "demo"}, "camera": {"platform": "demo"}} - def setup_method(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() + await async_setup_component(hass, ip.DOMAIN, config) + await hass.async_block_till_done() - config = {ip.DOMAIN: {"platform": "demo"}, "camera": {"platform": "demo"}} + return async_capture_events(hass, "image_processing.detect_face") - with patch( - "homeassistant.components.demo.image_processing." - "DemoImageProcessingFace.should_poll", - new_callable=PropertyMock(return_value=False), - ): - setup_component(self.hass, ip.DOMAIN, config) - self.hass.block_till_done() - state = self.hass.states.get("camera.demo_camera") - self.url = f"{self.hass.config.internal_url}{state.attributes.get(ATTR_ENTITY_PICTURE)}" +async def test_setup_component(hass): + """Set up demo platform on image_process component.""" + config = {ip.DOMAIN: {"platform": "demo"}} - self.face_events = [] + with assert_setup_component(1, ip.DOMAIN): + assert await async_setup_component(hass, ip.DOMAIN, config) - @callback - def mock_face_event(event): - """Mock event.""" - self.face_events.append(event) - self.hass.bus.listen("image_processing.detect_face", mock_face_event) +async def test_setup_component_with_service(hass): + """Set up demo platform on image_process component test service.""" + config = {ip.DOMAIN: {"platform": "demo"}} - def teardown_method(self): - """Stop everything that was started.""" - self.hass.stop() + with assert_setup_component(1, ip.DOMAIN): + assert await async_setup_component(hass, ip.DOMAIN, config) - def test_face_event_call(self, aioclient_mock): - """Set up and scan a picture and test faces from event.""" - aioclient_mock.get(self.url, content=b"image") + assert hass.services.has_service(ip.DOMAIN, "scan") - common.scan(self.hass, entity_id="image_processing.demo_face") - self.hass.block_till_done() - state = self.hass.states.get("image_processing.demo_face") +@patch( + "homeassistant.components.demo.camera.Path.read_bytes", + return_value=b"Test", +) +async def test_get_image_from_camera( + mock_camera_read, hass, aiohttp_unused_port, enable_custom_integrations +): + """Grab an image from camera entity.""" + await setup_image_processing(hass, aiohttp_unused_port) - assert len(self.face_events) == 2 - assert state.state == "Hans" - assert state.attributes["total_faces"] == 4 + common.async_scan(hass, entity_id="image_processing.test") + await hass.async_block_till_done() - event_data = [ - event.data for event in self.face_events if event.data.get("name") == "Hans" - ] - assert len(event_data) == 1 - assert event_data[0]["name"] == "Hans" - assert event_data[0]["confidence"] == 98.34 - assert event_data[0]["gender"] == "male" - assert event_data[0]["entity_id"] == "image_processing.demo_face" + state = hass.states.get("image_processing.test") - @patch( - "homeassistant.components.demo.image_processing." - "DemoImageProcessingFace.confidence", - new_callable=PropertyMock(return_value=None), - ) - def test_face_event_call_no_confidence(self, mock_config, aioclient_mock): - """Set up and scan a picture and test faces from event.""" - aioclient_mock.get(self.url, content=b"image") + assert mock_camera_read.called + assert state.state == "1" + assert state.attributes["image"] == b"Test" - common.scan(self.hass, entity_id="image_processing.demo_face") - self.hass.block_till_done() - state = self.hass.states.get("image_processing.demo_face") +@patch( + "homeassistant.components.camera.async_get_image", + side_effect=HomeAssistantError(), +) +async def test_get_image_without_exists_camera( + mock_image, hass, aiohttp_unused_port, enable_custom_integrations +): + """Try to get image without exists camera.""" + await setup_image_processing(hass, aiohttp_unused_port) - assert len(self.face_events) == 3 - assert state.state == "4" - assert state.attributes["total_faces"] == 4 + hass.states.async_remove("camera.demo_camera") - event_data = [ - event.data for event in self.face_events if event.data.get("name") == "Hans" - ] - assert len(event_data) == 1 - assert event_data[0]["name"] == "Hans" - assert event_data[0]["confidence"] == 98.34 - assert event_data[0]["gender"] == "male" - assert event_data[0]["entity_id"] == "image_processing.demo_face" + common.async_scan(hass, entity_id="image_processing.test") + await hass.async_block_till_done() + + state = hass.states.get("image_processing.test") + + assert mock_image.called + assert state.state == "0" + + +async def test_alpr_event_single_call(hass, aioclient_mock): + """Set up and scan a picture and test plates from event.""" + alpr_events = await setup_image_processing_alpr(hass) + aioclient_mock.get(get_url(hass), content=b"image") + + common.async_scan(hass, entity_id="image_processing.demo_alpr") + await hass.async_block_till_done() + + state = hass.states.get("image_processing.demo_alpr") + + assert len(alpr_events) == 4 + assert state.state == "AC3829" + + event_data = [ + event.data for event in alpr_events if event.data.get("plate") == "AC3829" + ] + assert len(event_data) == 1 + assert event_data[0]["plate"] == "AC3829" + assert event_data[0]["confidence"] == 98.3 + assert event_data[0]["entity_id"] == "image_processing.demo_alpr" + + +async def test_alpr_event_double_call(hass, aioclient_mock): + """Set up and scan a picture and test plates from event.""" + alpr_events = await setup_image_processing_alpr(hass) + aioclient_mock.get(get_url(hass), content=b"image") + + common.async_scan(hass, entity_id="image_processing.demo_alpr") + common.async_scan(hass, entity_id="image_processing.demo_alpr") + await hass.async_block_till_done() + + state = hass.states.get("image_processing.demo_alpr") + + assert len(alpr_events) == 4 + assert state.state == "AC3829" + + event_data = [ + event.data for event in alpr_events if event.data.get("plate") == "AC3829" + ] + assert len(event_data) == 1 + assert event_data[0]["plate"] == "AC3829" + assert event_data[0]["confidence"] == 98.3 + assert event_data[0]["entity_id"] == "image_processing.demo_alpr" + + +@patch( + "homeassistant.components.demo.image_processing.DemoImageProcessingAlpr.confidence", + new_callable=PropertyMock(return_value=95), +) +async def test_alpr_event_single_call_confidence(confidence_mock, hass, aioclient_mock): + """Set up and scan a picture and test plates from event.""" + alpr_events = await setup_image_processing_alpr(hass) + aioclient_mock.get(get_url(hass), content=b"image") + + common.async_scan(hass, entity_id="image_processing.demo_alpr") + await hass.async_block_till_done() + + state = hass.states.get("image_processing.demo_alpr") + + assert len(alpr_events) == 2 + assert state.state == "AC3829" + + event_data = [ + event.data for event in alpr_events if event.data.get("plate") == "AC3829" + ] + assert len(event_data) == 1 + assert event_data[0]["plate"] == "AC3829" + assert event_data[0]["confidence"] == 98.3 + assert event_data[0]["entity_id"] == "image_processing.demo_alpr" + + +async def test_face_event_call(hass, aioclient_mock): + """Set up and scan a picture and test faces from event.""" + face_events = await setup_image_processing_face(hass) + aioclient_mock.get(get_url(hass), content=b"image") + + common.async_scan(hass, entity_id="image_processing.demo_face") + await hass.async_block_till_done() + + state = hass.states.get("image_processing.demo_face") + + assert len(face_events) == 2 + assert state.state == "Hans" + assert state.attributes["total_faces"] == 4 + + event_data = [ + event.data for event in face_events if event.data.get("name") == "Hans" + ] + assert len(event_data) == 1 + assert event_data[0]["name"] == "Hans" + assert event_data[0]["confidence"] == 98.34 + assert event_data[0]["gender"] == "male" + assert event_data[0]["entity_id"] == "image_processing.demo_face" + + +@patch( + "homeassistant.components.demo.image_processing." + "DemoImageProcessingFace.confidence", + new_callable=PropertyMock(return_value=None), +) +async def test_face_event_call_no_confidence(mock_config, hass, aioclient_mock): + """Set up and scan a picture and test faces from event.""" + face_events = await setup_image_processing_face(hass) + aioclient_mock.get(get_url(hass), content=b"image") + + common.async_scan(hass, entity_id="image_processing.demo_face") + await hass.async_block_till_done() + + state = hass.states.get("image_processing.demo_face") + + assert len(face_events) == 3 + assert state.state == "4" + assert state.attributes["total_faces"] == 4 + + event_data = [ + event.data for event in face_events if event.data.get("name") == "Hans" + ] + assert len(event_data) == 1 + assert event_data[0]["name"] == "Hans" + assert event_data[0]["confidence"] == 98.34 + assert event_data[0]["gender"] == "male" + assert event_data[0]["entity_id"] == "image_processing.demo_face" From e34c985534c3718408b39939b80a2df6b5adec1a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 22 Sep 2021 07:51:31 -0700 Subject: [PATCH 524/843] Simplify cloud request connection handling (#56243) Co-authored-by: Pascal Vizeli --- homeassistant/components/cloud/__init__.py | 31 ++++++++++++++++++-- homeassistant/components/cloud/client.py | 4 +++ homeassistant/components/cloud/http_api.py | 2 -- homeassistant/components/cloud/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/cloud/test_http_api.py | 8 +---- tests/components/cloud/test_init.py | 4 ++- 9 files changed, 41 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 33b5e248561..f1833899fec 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -1,4 +1,6 @@ """Component to integrate the Home Assistant cloud.""" +import asyncio + from hass_nabucasa import Cloud import voluptuous as vol @@ -193,13 +195,13 @@ async def async_setup(hass, config): hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown) + _remote_handle_prefs_updated(cloud) + async def _service_handler(service): """Handle service for cloud.""" if service.service == SERVICE_REMOTE_CONNECT: - await cloud.remote.connect() await prefs.async_update(remote_enabled=True) elif service.service == SERVICE_REMOTE_DISCONNECT: - await cloud.remote.disconnect() await prefs.async_update(remote_enabled=False) hass.helpers.service.async_register_admin_service( @@ -234,3 +236,28 @@ async def async_setup(hass, config): account_link.async_setup(hass) return True + + +@callback +def _remote_handle_prefs_updated(cloud: Cloud) -> None: + """Handle remote preferences updated.""" + cur_pref = cloud.client.prefs.remote_enabled + lock = asyncio.Lock() + + # Sync remote connection with prefs + async def remote_prefs_updated(prefs: CloudPreferences) -> None: + """Update remote status.""" + nonlocal cur_pref + + async with lock: + if prefs.remote_enabled == cur_pref: + return + + cur_pref = prefs.remote_enabled + + if cur_pref: + await cloud.remote.connect() + else: + await cloud.remote.disconnect() + + cloud.client.prefs.async_listen_updates(remote_prefs_updated) diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index 4c039f3888c..54c471e2a83 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -172,6 +172,10 @@ class CloudClient(Interface): if identifier.startswith("remote_"): async_dispatcher_send(self._hass, DISPATCHER_REMOTE_UPDATE, data) + async def async_cloud_connect_update(self, connect: bool) -> None: + """Process cloud remote message to client.""" + await self._prefs.async_update(remote_enabled=connect) + async def async_alexa_message(self, payload: dict[Any, Any]) -> dict[Any, Any]: """Process cloud alexa message to client.""" cloud_user = await self._prefs.get_cloud_user() diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index cab41ebb0b8..1f17f46013e 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -452,7 +452,6 @@ async def websocket_remote_connect(hass, connection, msg): """Handle request for connect remote.""" cloud = hass.data[DOMAIN] await cloud.client.prefs.async_update(remote_enabled=True) - await cloud.remote.connect() connection.send_result(msg["id"], await _account_data(cloud)) @@ -465,7 +464,6 @@ async def websocket_remote_disconnect(hass, connection, msg): """Handle request for disconnect remote.""" cloud = hass.data[DOMAIN] await cloud.client.prefs.async_update(remote_enabled=False) - await cloud.remote.disconnect() connection.send_result(msg["id"], await _account_data(cloud)) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 8eaad3b4129..517aa887a30 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -2,7 +2,7 @@ "domain": "cloud", "name": "Home Assistant Cloud", "documentation": "https://www.home-assistant.io/integrations/cloud", - "requirements": ["hass-nabucasa==0.49.0"], + "requirements": ["hass-nabucasa==0.50.0"], "dependencies": ["http", "webhook"], "after_dependencies": ["google_assistant", "alexa"], "codeowners": ["@home-assistant/cloud"], diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 70d1fddc790..ceac76606d5 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -15,7 +15,7 @@ ciso8601==2.1.3 cryptography==3.4.8 defusedxml==0.7.1 emoji==1.2.0 -hass-nabucasa==0.49.0 +hass-nabucasa==0.50.0 home-assistant-frontend==20210911.0 httpx==0.19.0 ifaddr==0.1.7 diff --git a/requirements_all.txt b/requirements_all.txt index 8b14f2dc932..5023ecabd62 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -769,7 +769,7 @@ habitipy==0.2.0 hangups==0.4.14 # homeassistant.components.cloud -hass-nabucasa==0.49.0 +hass-nabucasa==0.50.0 # homeassistant.components.splunk hass_splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 44ac5935a13..da36a577fb5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -456,7 +456,7 @@ habitipy==0.2.0 hangups==0.4.14 # homeassistant.components.cloud -hass-nabucasa==0.49.0 +hass-nabucasa==0.50.0 # homeassistant.components.tasmota hatasmota==0.2.20 diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index b678796a5c4..4116e97be92 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -549,14 +549,8 @@ async def test_enabling_remote(hass, hass_ws_client, setup_api, mock_cloud_login assert len(mock_connect.mock_calls) == 1 - -async def test_disabling_remote(hass, hass_ws_client, setup_api, mock_cloud_login): - """Test we call right code to disable remote UI.""" - client = await hass_ws_client(hass) - cloud = hass.data[DOMAIN] - with patch("hass_nabucasa.remote.RemoteUI.disconnect") as mock_disconnect: - await client.send_json({"id": 5, "type": "cloud/remote/disconnect"}) + await client.send_json({"id": 6, "type": "cloud/remote/disconnect"}) response = await client.receive_json() assert response["success"] assert not cloud.client.remote_autostart diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index e478849d3ef..f4ca4cbd75a 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -163,7 +163,9 @@ async def test_remote_ui_url(hass, mock_cloud_fixture): with pytest.raises(cloud.CloudNotAvailable): cloud.async_remote_ui_url(hass) - await cl.client.prefs.async_update(remote_enabled=True) + with patch.object(cl.remote, "connect"): + await cl.client.prefs.async_update(remote_enabled=True) + await hass.async_block_till_done() # No instance domain with pytest.raises(cloud.CloudNotAvailable): From 2478ec887aba87842bf52969b7ab1137826f7b98 Mon Sep 17 00:00:00 2001 From: Tobias Sauerwein Date: Wed, 22 Sep 2021 16:54:12 +0200 Subject: [PATCH 525/843] Allow camera usage with HA cloud (#56533) --- homeassistant/components/netatmo/camera.py | 5 ----- homeassistant/components/netatmo/config_flow.py | 6 ++---- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/netatmo/camera.py b/homeassistant/components/netatmo/camera.py index 2666409f28e..5e63c56788b 100644 --- a/homeassistant/components/netatmo/camera.py +++ b/homeassistant/components/netatmo/camera.py @@ -52,11 +52,6 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Netatmo camera platform.""" - if "access_camera" not in entry.data["token"]["scope"]: - _LOGGER.info( - "Cameras are currently not supported with this authentication method" - ) - data_handler = hass.data[DOMAIN][entry.entry_id][DATA_HANDLER] await data_handler.register_data_class( diff --git a/homeassistant/components/netatmo/config_flow.py b/homeassistant/components/netatmo/config_flow.py index 9b7c3376076..bb6a034b19f 100644 --- a/homeassistant/components/netatmo/config_flow.py +++ b/homeassistant/components/netatmo/config_flow.py @@ -50,6 +50,8 @@ class NetatmoFlowHandler( def extra_authorize_data(self) -> dict: """Extra data that needs to be appended to the authorize url.""" scopes = [ + "access_camera", + "access_presence", "read_camera", "read_homecoach", "read_presence", @@ -61,10 +63,6 @@ class NetatmoFlowHandler( "write_thermostat", ] - if self.flow_impl.name != "Home Assistant Cloud": - scopes.extend(["access_camera", "access_presence"]) - scopes.sort() - return {"scope": " ".join(scopes)} async def async_step_user(self, user_input: dict | None = None) -> FlowResult: From a5d405700c703a0168839b885b612128b3702278 Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Wed, 22 Sep 2021 11:34:30 -0400 Subject: [PATCH 526/843] ZHA channel ZCL attributes initialization (#56476) * Add dict of attributes to initialize * Refactor get_attributes() method Read 5 attributes at the time. * Add ZCL_INIT_ATTRS attribute to base Zigbee channel * Update tests and general clusters * Update channels to use ZCL_INIT_ATTRS * Update channels to use ZCL_INIT_ATTRS * Fix tests * Refactor async_initialize() to be a retryable request * Maky pylint happy again --- .../components/zha/core/channels/base.py | 71 ++++++++++++------ .../components/zha/core/channels/general.py | 31 +++----- .../zha/core/channels/homeautomation.py | 21 ++---- .../components/zha/core/channels/hvac.py | 74 +++++-------------- .../components/zha/core/channels/lighting.py | 26 ++----- .../components/zha/core/channels/security.py | 8 +- .../zha/core/channels/smartenergy.py | 26 +++---- homeassistant/components/zha/core/const.py | 1 + tests/components/zha/common.py | 12 +++ tests/components/zha/test_fan.py | 6 +- tests/components/zha/test_number.py | 44 ++++++----- 11 files changed, 141 insertions(+), 179 deletions(-) diff --git a/homeassistant/components/zha/core/channels/base.py b/homeassistant/components/zha/core/channels/base.py index 64496b0b3bd..17f2693a090 100644 --- a/homeassistant/components/zha/core/channels/base.py +++ b/homeassistant/components/zha/core/channels/base.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio from enum import Enum -from functools import wraps +from functools import partialmethod, wraps import logging from typing import Any @@ -30,8 +30,9 @@ from ..const import ( ZHA_CHANNEL_MSG_BIND, ZHA_CHANNEL_MSG_CFG_RPT, ZHA_CHANNEL_MSG_DATA, + ZHA_CHANNEL_READS_PER_REQ, ) -from ..helpers import LogMixin, safe_read +from ..helpers import LogMixin, retryable_req, safe_read _LOGGER = logging.getLogger(__name__) @@ -92,6 +93,11 @@ class ZigbeeChannel(LogMixin): REPORT_CONFIG: tuple[dict[int | str, tuple[int, int, int | float]]] = () BIND: bool = True + # Dict of attributes to read on channel initialization. + # Dict keys -- attribute ID or names, with bool value indicating whether a cached + # attribute read is acceptable. + ZCL_INIT_ATTRS: dict[int | str, bool] = {} + def __init__( self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType ) -> None: @@ -301,6 +307,7 @@ class ZigbeeChannel(LogMixin): self.debug("skipping channel configuration") self._status = ChannelStatus.CONFIGURED + @retryable_req(delays=(1, 1, 3)) async def async_initialize(self, from_cache: bool) -> None: """Initialize channel.""" if not from_cache and self._ch_pool.skip_configuration: @@ -308,9 +315,14 @@ class ZigbeeChannel(LogMixin): return self.debug("initializing channel: from_cache: %s", from_cache) - attributes = [cfg["attr"] for cfg in self.REPORT_CONFIG] - if attributes: - await self.get_attributes(attributes, from_cache=from_cache) + cached = [a for a, cached in self.ZCL_INIT_ATTRS.items() if cached] + uncached = [a for a, cached in self.ZCL_INIT_ATTRS.items() if not cached] + uncached.extend([cfg["attr"] for cfg in self.REPORT_CONFIG]) + + if cached: + await self._get_attributes(True, cached, from_cache=True) + if uncached: + await self._get_attributes(True, uncached, from_cache=from_cache) ch_specific_init = getattr(self, "async_initialize_channel_specific", None) if ch_specific_init: @@ -367,28 +379,43 @@ class ZigbeeChannel(LogMixin): ) return result.get(attribute) - async def get_attributes(self, attributes, from_cache=True): + async def _get_attributes( + self, + raise_exceptions: bool, + attributes: list[int | str], + from_cache: bool = True, + ) -> dict[int | str, Any]: """Get the values for a list of attributes.""" manufacturer = None manufacturer_code = self._ch_pool.manufacturer_code if self.cluster.cluster_id >= 0xFC00 and manufacturer_code: manufacturer = manufacturer_code - try: - result, _ = await self.cluster.read_attributes( - attributes, - allow_cache=from_cache, - only_cache=from_cache and not self._ch_pool.is_mains_powered, - manufacturer=manufacturer, - ) - return result - except (asyncio.TimeoutError, zigpy.exceptions.ZigbeeException) as ex: - self.debug( - "failed to get attributes '%s' on '%s' cluster: %s", - attributes, - self.cluster.ep_attribute, - str(ex), - ) - return {} + chunk = attributes[:ZHA_CHANNEL_READS_PER_REQ] + rest = attributes[ZHA_CHANNEL_READS_PER_REQ:] + result = {} + while chunk: + try: + read, _ = await self.cluster.read_attributes( + attributes, + allow_cache=from_cache, + only_cache=from_cache and not self._ch_pool.is_mains_powered, + manufacturer=manufacturer, + ) + result.update(read) + except (asyncio.TimeoutError, zigpy.exceptions.ZigbeeException) as ex: + self.debug( + "failed to get attributes '%s' on '%s' cluster: %s", + attributes, + self.cluster.ep_attribute, + str(ex), + ) + if raise_exceptions: + raise + chunk = rest[:ZHA_CHANNEL_READS_PER_REQ] + rest = rest[ZHA_CHANNEL_READS_PER_REQ:] + return result + + get_attributes = partialmethod(_get_attributes, False) def log(self, level, msg, *args): """Log a message.""" diff --git a/homeassistant/components/zha/core/channels/general.py b/homeassistant/components/zha/core/channels/general.py index 5ca8c9fd4ba..d0216436ba2 100644 --- a/homeassistant/components/zha/core/channels/general.py +++ b/homeassistant/components/zha/core/channels/general.py @@ -23,7 +23,6 @@ from ..const import ( SIGNAL_SET_LEVEL, SIGNAL_UPDATE_DEVICE, ) -from ..helpers import retryable_req from .base import ClientChannel, ZigbeeChannel, parse_and_log_command @@ -44,7 +43,16 @@ class AnalogInput(ZigbeeChannel): class AnalogOutput(ZigbeeChannel): """Analog Output channel.""" - REPORT_CONFIG = [{"attr": "present_value", "config": REPORT_CONFIG_DEFAULT}] + REPORT_CONFIG = ({"attr": "present_value", "config": REPORT_CONFIG_DEFAULT},) + ZCL_INIT_ATTRS = { + "min_present_value": True, + "max_present_value": True, + "resolution": True, + "relinquish_default": True, + "description": True, + "engineering_units": True, + "application_type": True, + } @property def present_value(self) -> float | None: @@ -99,25 +107,6 @@ class AnalogOutput(ZigbeeChannel): return True return False - @retryable_req(delays=(1, 1, 3)) - def async_initialize_channel_specific(self, from_cache: bool) -> Coroutine: - """Initialize channel.""" - return self.fetch_config(from_cache) - - async def fetch_config(self, from_cache: bool) -> None: - """Get the channel configuration.""" - attributes = [ - "min_present_value", - "max_present_value", - "resolution", - "relinquish_default", - "description", - "engineering_units", - "application_type", - ] - # just populates the cache, if not already done - await self.get_attributes(attributes, from_cache=from_cache) - @registries.ZIGBEE_CHANNEL_REGISTRY.register(general.AnalogValue.cluster_id) class AnalogValue(ZigbeeChannel): diff --git a/homeassistant/components/zha/core/channels/homeautomation.py b/homeassistant/components/zha/core/channels/homeautomation.py index 583cfb105bd..fc00db4f2d4 100644 --- a/homeassistant/components/zha/core/channels/homeautomation.py +++ b/homeassistant/components/zha/core/channels/homeautomation.py @@ -1,8 +1,6 @@ """Home automation channels module for Zigbee Home Automation.""" from __future__ import annotations -from collections.abc import Coroutine - from zigpy.zcl.clusters import homeautomation from .. import registries @@ -49,6 +47,12 @@ class ElectricalMeasurementChannel(ZigbeeChannel): CHANNEL_NAME = CHANNEL_ELECTRICAL_MEASUREMENT REPORT_CONFIG = ({"attr": "active_power", "config": REPORT_CONFIG_DEFAULT},) + ZCL_INIT_ATTRS = { + "ac_power_divisor": True, + "power_divisor": True, + "ac_power_multiplier": True, + "power_multiplier": True, + } async def async_update(self): """Retrieve latest state.""" @@ -64,19 +68,6 @@ class ElectricalMeasurementChannel(ZigbeeChannel): result, ) - def async_initialize_channel_specific(self, from_cache: bool) -> Coroutine: - """Initialize channel specific attributes.""" - - return self.get_attributes( - [ - "ac_power_divisor", - "power_divisor", - "ac_power_multiplier", - "power_multiplier", - ], - from_cache=True, - ) - @property def divisor(self) -> int | None: """Return active power divisor.""" diff --git a/homeassistant/components/zha/core/channels/hvac.py b/homeassistant/components/zha/core/channels/hvac.py index 31c75a0c794..726d9f15376 100644 --- a/homeassistant/components/zha/core/channels/hvac.py +++ b/homeassistant/components/zha/core/channels/hvac.py @@ -15,14 +15,13 @@ from zigpy.zcl.foundation import Status from homeassistant.core import callback -from .. import registries, typing as zha_typing +from .. import registries from ..const import ( REPORT_CONFIG_MAX_INT, REPORT_CONFIG_MIN_INT, REPORT_CONFIG_OP, SIGNAL_ATTR_UPDATED, ) -from ..helpers import retryable_req from .base import ZigbeeChannel AttributeUpdateRecord = namedtuple("AttributeUpdateRecord", "attr_id, attr_name, value") @@ -43,12 +42,18 @@ class FanChannel(ZigbeeChannel): _value_attribute = 0 REPORT_CONFIG = ({"attr": "fan_mode", "config": REPORT_CONFIG_OP},) + ZCL_INIT_ATTRS = {"fan_mode_sequence": True} @property def fan_mode(self) -> int | None: """Return current fan mode.""" return self.cluster.get("fan_mode") + @property + def fan_mode_sequence(self) -> int | None: + """Return possible fan mode speeds.""" + return self.cluster.get("fan_mode_sequence") + async def async_set_speed(self, value) -> None: """Set the speed of the fan.""" @@ -97,34 +102,17 @@ class ThermostatChannel(ZigbeeChannel): {"attr": "pi_cooling_demand", "config": REPORT_CONFIG_CLIMATE_DEMAND}, {"attr": "pi_heating_demand", "config": REPORT_CONFIG_CLIMATE_DEMAND}, ) - - def __init__( - self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType - ) -> None: - """Init Thermostat channel instance.""" - super().__init__(cluster, ch_pool) - self._init_attrs = { - "abs_min_heat_setpoint_limit": True, - "abs_max_heat_setpoint_limit": True, - "abs_min_cool_setpoint_limit": True, - "abs_max_cool_setpoint_limit": True, - "ctrl_seqe_of_oper": False, - "local_temp": False, - "max_cool_setpoint_limit": True, - "max_heat_setpoint_limit": True, - "min_cool_setpoint_limit": True, - "min_heat_setpoint_limit": True, - "occupancy": False, - "occupied_cooling_setpoint": False, - "occupied_heating_setpoint": False, - "pi_cooling_demand": False, - "pi_heating_demand": False, - "running_mode": False, - "running_state": False, - "system_mode": False, - "unoccupied_heating_setpoint": False, - "unoccupied_cooling_setpoint": False, - } + ZCL_INIT_ATTRS: dict[int | str, bool] = { + "abs_min_heat_setpoint_limit": True, + "abs_max_heat_setpoint_limit": True, + "abs_min_cool_setpoint_limit": True, + "abs_max_cool_setpoint_limit": True, + "ctrl_seqe_of_oper": False, + "max_cool_setpoint_limit": True, + "max_heat_setpoint_limit": True, + "min_cool_setpoint_limit": True, + "min_heat_setpoint_limit": True, + } @property def abs_max_cool_setpoint_limit(self) -> int: @@ -250,32 +238,6 @@ class ThermostatChannel(ZigbeeChannel): AttributeUpdateRecord(attrid, attr_name, value), ) - async def _chunk_attr_read(self, attrs, cached=False): - chunk, attrs = attrs[:4], attrs[4:] - while chunk: - res, fail = await self.cluster.read_attributes(chunk, allow_cache=cached) - self.debug("read attributes: Success: %s. Failed: %s", res, fail) - for attr in chunk: - self._init_attrs.pop(attr, None) - if attr in fail: - continue - self.async_send_signal( - f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", - AttributeUpdateRecord(None, attr, res[attr]), - ) - - chunk, attrs = attrs[:4], attrs[4:] - - @retryable_req(delays=(1, 1, 3)) - async def async_initialize_channel_specific(self, from_cache: bool) -> None: - """Initialize channel.""" - - cached = [a for a, cached in self._init_attrs.items() if cached] - uncached = [a for a, cached in self._init_attrs.items() if not cached] - - await self._chunk_attr_read(cached, cached=True) - await self._chunk_attr_read(uncached, cached=False) - async def async_set_operation_mode(self, mode) -> bool: """Set Operation mode.""" if not await self.write_attributes({"system_mode": mode}): diff --git a/homeassistant/components/zha/core/channels/lighting.py b/homeassistant/components/zha/core/channels/lighting.py index fbf53bec9a5..1dbf1d201c8 100644 --- a/homeassistant/components/zha/core/channels/lighting.py +++ b/homeassistant/components/zha/core/channels/lighting.py @@ -1,7 +1,6 @@ """Lighting channels module for Zigbee Home Automation.""" from __future__ import annotations -from collections.abc import Coroutine from contextlib import suppress from zigpy.zcl.clusters import lighting @@ -36,6 +35,12 @@ class ColorChannel(ZigbeeChannel): ) MAX_MIREDS: int = 500 MIN_MIREDS: int = 153 + ZCL_INIT_ATTRS = { + "color_temp_physical_min": True, + "color_temp_physical_max": True, + "color_capabilities": True, + "color_loop_active": False, + } @property def color_capabilities(self) -> int: @@ -75,22 +80,3 @@ class ColorChannel(ZigbeeChannel): def max_mireds(self) -> int: """Return the warmest color_temp that this channel supports.""" return self.cluster.get("color_temp_physical_max", self.MAX_MIREDS) - - def async_configure_channel_specific(self) -> Coroutine: - """Configure channel.""" - return self.fetch_color_capabilities(False) - - def async_initialize_channel_specific(self, from_cache: bool) -> Coroutine: - """Initialize channel.""" - return self.fetch_color_capabilities(True) - - async def fetch_color_capabilities(self, from_cache: bool) -> None: - """Get the color configuration.""" - attributes = [ - "color_temp_physical_min", - "color_temp_physical_max", - "color_capabilities", - "color_temperature", - ] - # just populates the cache, if not already done - await self.get_attributes(attributes, from_cache=from_cache) diff --git a/homeassistant/components/zha/core/channels/security.py b/homeassistant/components/zha/core/channels/security.py index cb90c740065..0800fee1374 100644 --- a/homeassistant/components/zha/core/channels/security.py +++ b/homeassistant/components/zha/core/channels/security.py @@ -7,7 +7,6 @@ https://home-assistant.io/integrations/zha/ from __future__ import annotations import asyncio -from collections.abc import Coroutine import logging from zigpy.exceptions import ZigbeeException @@ -345,6 +344,8 @@ class IasWd(ZigbeeChannel): class IASZoneChannel(ZigbeeChannel): """Channel for the IASZone Zigbee cluster.""" + ZCL_INIT_ATTRS = {"zone_status": True, "zone_state": False, "zone_type": True} + @callback def cluster_command(self, tsn, command_id, args): """Handle commands received to this cluster.""" @@ -404,8 +405,3 @@ class IASZoneChannel(ZigbeeChannel): self.cluster.attributes.get(attrid, [attrid])[0], value, ) - - def async_initialize_channel_specific(self, from_cache: bool) -> Coroutine: - """Initialize channel.""" - attributes = ["zone_status", "zone_state", "zone_type"] - return self.get_attributes(attributes, from_cache=from_cache) diff --git a/homeassistant/components/zha/core/channels/smartenergy.py b/homeassistant/components/zha/core/channels/smartenergy.py index 4e6302d32b5..373d7312a4b 100644 --- a/homeassistant/components/zha/core/channels/smartenergy.py +++ b/homeassistant/components/zha/core/channels/smartenergy.py @@ -1,8 +1,6 @@ """Smart energy channels module for Zigbee Home Automation.""" from __future__ import annotations -from collections.abc import Coroutine - from zigpy.zcl.clusters import smartenergy from homeassistant.const import ( @@ -63,7 +61,13 @@ class Messaging(ZigbeeChannel): class Metering(ZigbeeChannel): """Metering channel.""" - REPORT_CONFIG = [{"attr": "instantaneous_demand", "config": REPORT_CONFIG_DEFAULT}] + REPORT_CONFIG = ({"attr": "instantaneous_demand", "config": REPORT_CONFIG_DEFAULT},) + ZCL_INIT_ATTRS = { + "divisor": True, + "multiplier": True, + "unit_of_measure": True, + "demand_formatting": True, + } unit_of_measure_map = { 0x00: POWER_WATT, @@ -98,14 +102,6 @@ class Metering(ZigbeeChannel): """Return multiplier for the value.""" return self.cluster.get("multiplier") or 1 - def async_configure_channel_specific(self) -> Coroutine: - """Configure channel.""" - return self.fetch_config(False) - - def async_initialize_channel_specific(self, from_cache: bool) -> Coroutine: - """Initialize channel.""" - return self.fetch_config(True) - @callback def attribute_updated(self, attrid: int, value: int) -> None: """Handle attribute update from Metering cluster.""" @@ -119,14 +115,10 @@ class Metering(ZigbeeChannel): uom = self.cluster.get("unit_of_measure", 0x7F) return self.unit_of_measure_map.get(uom & 0x7F, "unknown") - async def fetch_config(self, from_cache: bool) -> None: + async def async_initialize_channel_specific(self, from_cache: bool) -> None: """Fetch config from device and updates format specifier.""" - results = await self.get_attributes( - ["divisor", "multiplier", "unit_of_measure", "demand_formatting"], - from_cache=from_cache, - ) - fmting = results.get( + fmting = self.cluster.get( "demand_formatting", 0xF9 ) # 1 digit to the right, 15 digits to the left diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index fe0240472bd..dd6832e0d6b 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -374,6 +374,7 @@ ZHA_CHANNEL_MSG_BIND = "zha_channel_bind" ZHA_CHANNEL_MSG_CFG_RPT = "zha_channel_configure_reporting" ZHA_CHANNEL_MSG_DATA = "zha_channel_msg_data" ZHA_CHANNEL_CFG_DONE = "zha_channel_cfg_done" +ZHA_CHANNEL_READS_PER_REQ = 5 ZHA_GW_MSG = "zha_gateway_message" ZHA_GW_MSG_DEVICE_FULL_INIT = "device_fully_initialized" ZHA_GW_MSG_DEVICE_INFO = "device_info" diff --git a/tests/components/zha/common.py b/tests/components/zha/common.py index d8889a0208c..e87962c8d8b 100644 --- a/tests/components/zha/common.py +++ b/tests/components/zha/common.py @@ -56,6 +56,18 @@ def patch_cluster(cluster): cluster.add = AsyncMock(return_value=[0]) +def update_attribute_cache(cluster): + """Update attribute cache based on plugged attributes.""" + if cluster.PLUGGED_ATTR_READS: + attrs = [ + make_attribute(cluster.attridx.get(attr, attr), value) + for attr, value in cluster.PLUGGED_ATTR_READS.items() + ] + hdr = make_zcl_header(zcl_f.Command.Report_Attributes) + hdr.frame_control.disable_default_response = True + cluster.handle_message(hdr, [attrs]) + + def get_zha_gateway(hass): """Return ZHA gateway from hass.data.""" try: diff --git a/tests/components/zha/test_fan.py b/tests/components/zha/test_fan.py index 65b2df725dc..212152e231d 100644 --- a/tests/components/zha/test_fan.py +++ b/tests/components/zha/test_fan.py @@ -476,7 +476,7 @@ async def test_fan_update_entity( assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE] == 0 assert hass.states.get(entity_id).attributes[ATTR_PRESET_MODE] is None assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE_STEP] == 100 / 3 - assert cluster.read_attributes.await_count == 1 + assert cluster.read_attributes.await_count == 2 await async_setup_component(hass, "homeassistant", {}) await hass.async_block_till_done() @@ -486,7 +486,7 @@ async def test_fan_update_entity( ) assert hass.states.get(entity_id).state == STATE_OFF assert hass.states.get(entity_id).attributes[ATTR_SPEED] == SPEED_OFF - assert cluster.read_attributes.await_count == 2 + assert cluster.read_attributes.await_count == 3 cluster.PLUGGED_ATTR_READS = {"fan_mode": 1} await hass.services.async_call( @@ -497,4 +497,4 @@ async def test_fan_update_entity( assert hass.states.get(entity_id).attributes[ATTR_SPEED] == SPEED_LOW assert hass.states.get(entity_id).attributes[ATTR_PRESET_MODE] is None assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE_STEP] == 100 / 3 - assert cluster.read_attributes.await_count == 3 + assert cluster.read_attributes.await_count == 4 diff --git a/tests/components/zha/test_number.py b/tests/components/zha/test_number.py index 9623a89a8c2..c27cd9fd654 100644 --- a/tests/components/zha/test_number.py +++ b/tests/components/zha/test_number.py @@ -16,6 +16,7 @@ from .common import ( async_test_rejoin, find_entity_id, send_attributes_report, + update_attribute_cache, ) from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE @@ -41,25 +42,30 @@ async def test_number(hass, zha_device_joined_restored, zigpy_analog_output_devi cluster = zigpy_analog_output_device.endpoints.get(1).analog_output cluster.PLUGGED_ATTR_READS = { - "present_value": 15.0, "max_present_value": 100.0, - "min_present_value": 0.0, + "min_present_value": 1.0, "relinquish_default": 50.0, - "resolution": 1.0, + "resolution": 1.1, "description": "PWM1", "engineering_units": 98, "application_type": 4 * 0x10000, } + update_attribute_cache(cluster) + cluster.PLUGGED_ATTR_READS["present_value"] = 15.0 + zha_device = await zha_device_joined_restored(zigpy_analog_output_device) # one for present_value and one for the rest configuration attributes - assert cluster.read_attributes.call_count == 2 - assert "max_present_value" in cluster.read_attributes.call_args[0][0] - assert "min_present_value" in cluster.read_attributes.call_args[0][0] - assert "relinquish_default" in cluster.read_attributes.call_args[0][0] - assert "resolution" in cluster.read_attributes.call_args[0][0] - assert "description" in cluster.read_attributes.call_args[0][0] - assert "engineering_units" in cluster.read_attributes.call_args[0][0] - assert "application_type" in cluster.read_attributes.call_args[0][0] + assert cluster.read_attributes.call_count == 3 + attr_reads = set() + for call_args in cluster.read_attributes.call_args_list: + attr_reads |= set(call_args[0][0]) + assert "max_present_value" in attr_reads + assert "min_present_value" in attr_reads + assert "relinquish_default" in attr_reads + assert "resolution" in attr_reads + assert "description" in attr_reads + assert "engineering_units" in attr_reads + assert "application_type" in attr_reads entity_id = await find_entity_id(DOMAIN, zha_device, hass) assert entity_id is not None @@ -69,18 +75,18 @@ async def test_number(hass, zha_device_joined_restored, zigpy_analog_output_devi assert hass.states.get(entity_id).state == STATE_UNAVAILABLE # allow traffic to flow through the gateway and device - assert cluster.read_attributes.call_count == 2 + assert cluster.read_attributes.call_count == 3 await async_enable_traffic(hass, [zha_device]) await hass.async_block_till_done() - assert cluster.read_attributes.call_count == 4 + assert cluster.read_attributes.call_count == 6 # test that the state has changed from unavailable to 15.0 assert hass.states.get(entity_id).state == "15.0" # test attributes - assert hass.states.get(entity_id).attributes.get("min") == 0.0 + assert hass.states.get(entity_id).attributes.get("min") == 1.0 assert hass.states.get(entity_id).attributes.get("max") == 100.0 - assert hass.states.get(entity_id).attributes.get("step") == 1.0 + assert hass.states.get(entity_id).attributes.get("step") == 1.1 assert hass.states.get(entity_id).attributes.get("icon") == "mdi:percent" assert hass.states.get(entity_id).attributes.get("unit_of_measurement") == "%" assert ( @@ -89,7 +95,7 @@ async def test_number(hass, zha_device_joined_restored, zigpy_analog_output_devi ) # change value from device - assert cluster.read_attributes.call_count == 4 + assert cluster.read_attributes.call_count == 6 await send_attributes_report(hass, cluster, {0x0055: 15}) assert hass.states.get(entity_id).state == "15.0" @@ -111,10 +117,10 @@ async def test_number(hass, zha_device_joined_restored, zigpy_analog_output_devi cluster.PLUGGED_ATTR_READS["present_value"] = 30.0 # test rejoin - assert cluster.read_attributes.call_count == 4 + assert cluster.read_attributes.call_count == 6 await async_test_rejoin(hass, zigpy_analog_output_device, [cluster], (1,)) assert hass.states.get(entity_id).state == "30.0" - assert cluster.read_attributes.call_count == 6 + assert cluster.read_attributes.call_count == 9 # update device value with failed attribute report cluster.PLUGGED_ATTR_READS["present_value"] = 40.0 @@ -128,5 +134,5 @@ async def test_number(hass, zha_device_joined_restored, zigpy_analog_output_devi "homeassistant", "update_entity", {"entity_id": entity_id}, blocking=True ) assert hass.states.get(entity_id).state == "40.0" - assert cluster.read_attributes.call_count == 7 + assert cluster.read_attributes.call_count == 10 assert "present_value" in cluster.read_attributes.call_args[0][0] From 39aaa383b372012cf16a9699fdd721498822bffd Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 22 Sep 2021 08:41:10 -0700 Subject: [PATCH 527/843] Fix flaky srp energy test (#56536) --- tests/components/srp_energy/test_config_flow.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/components/srp_energy/test_config_flow.py b/tests/components/srp_energy/test_config_flow.py index c8d458dfb82..54f8629a980 100644 --- a/tests/components/srp_energy/test_config_flow.py +++ b/tests/components/srp_energy/test_config_flow.py @@ -93,13 +93,21 @@ async def test_form_unknown_exception(hass): async def test_config(hass): """Test handling of configuration imported.""" - with patch("homeassistant.components.srp_energy.config_flow.SrpEnergyClient"): + with patch( + "homeassistant.components.srp_energy.config_flow.SrpEnergyClient" + ), patch( + "homeassistant.components.srp_energy.async_setup_entry", + return_value=True, + ) as mock_setup_entry: result = await hass.config_entries.flow.async_init( SRP_ENERGY_DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=ENTRY_CONFIG, ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + await hass.async_block_till_done() + + assert len(mock_setup_entry.mock_calls) == 1 async def test_integration_already_configured(hass): From d8d34fdd3b3f33ecb40390735bc5352331ed9e2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Wed, 22 Sep 2021 21:59:52 +0300 Subject: [PATCH 528/843] Prefer HTTPStatus over int in HA view JSON functions (#56504) * Prefer HTTPStatus over int in HA view JSON functions * Update zwave tests to not expect a fixed typo --- homeassistant/components/api/__init__.py | 25 +++++------ homeassistant/components/auth/__init__.py | 33 ++++++++------ homeassistant/components/auth/login_flow.py | 31 ++++++------- homeassistant/components/config/__init__.py | 20 ++++----- .../components/config/config_entries.py | 9 ++-- homeassistant/components/config/zwave.py | 25 ++++++----- .../components/conversation/__init__.py | 4 +- .../components/emulated_hue/hue_api.py | 44 +++++++++---------- .../components/foursquare/__init__.py | 12 ++--- homeassistant/components/history/__init__.py | 13 ++---- homeassistant/components/html5/notify.py | 33 +++++++------- .../components/http/data_validator.py | 7 ++- homeassistant/components/http/view.py | 7 +-- homeassistant/components/ios/__init__.py | 8 ++-- .../components/konnected/__init__.py | 16 +++---- homeassistant/components/logbook/__init__.py | 8 ++-- .../components/logi_circle/config_flow.py | 5 ++- .../components/meraki/device_tracker.py | 14 +++--- .../components/mobile_app/http_api.py | 5 ++- homeassistant/components/onboarding/views.py | 14 +++--- .../components/shopping_list/__init__.py | 7 +-- .../components/telegram_bot/webhooks.py | 13 +++--- homeassistant/components/tts/__init__.py | 8 ++-- homeassistant/components/withings/common.py | 2 +- homeassistant/helpers/data_entry_flow.py | 16 ++++--- tests/components/config/test_zwave.py | 2 +- 26 files changed, 191 insertions(+), 190 deletions(-) diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index 144205b3b25..01d48a190fd 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -1,5 +1,6 @@ """Rest API for Home Assistant.""" import asyncio +from http import HTTPStatus import json import logging @@ -14,10 +15,6 @@ from homeassistant.components.http import HomeAssistantView from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, EVENT_TIME_CHANGED, - HTTP_BAD_REQUEST, - HTTP_CREATED, - HTTP_NOT_FOUND, - HTTP_OK, MATCH_ALL, URL_API, URL_API_COMPONENTS, @@ -231,7 +228,7 @@ class APIEntityStateView(HomeAssistantView): state = request.app["hass"].states.get(entity_id) if state: return self.json(state) - return self.json_message("Entity not found.", HTTP_NOT_FOUND) + return self.json_message("Entity not found.", HTTPStatus.NOT_FOUND) async def post(self, request, entity_id): """Update state of entity.""" @@ -241,12 +238,12 @@ class APIEntityStateView(HomeAssistantView): try: data = await request.json() except ValueError: - return self.json_message("Invalid JSON specified.", HTTP_BAD_REQUEST) + return self.json_message("Invalid JSON specified.", HTTPStatus.BAD_REQUEST) new_state = data.get("state") if new_state is None: - return self.json_message("No state specified.", HTTP_BAD_REQUEST) + return self.json_message("No state specified.", HTTPStatus.BAD_REQUEST) attributes = data.get("attributes") force_update = data.get("force_update", False) @@ -259,7 +256,7 @@ class APIEntityStateView(HomeAssistantView): ) # Read the state back for our response - status_code = HTTP_CREATED if is_new_state else HTTP_OK + status_code = HTTPStatus.CREATED if is_new_state else HTTPStatus.OK resp = self.json(hass.states.get(entity_id), status_code) resp.headers.add("Location", f"/api/states/{entity_id}") @@ -273,7 +270,7 @@ class APIEntityStateView(HomeAssistantView): raise Unauthorized(entity_id=entity_id) if request.app["hass"].states.async_remove(entity_id): return self.json_message("Entity removed.") - return self.json_message("Entity not found.", HTTP_NOT_FOUND) + return self.json_message("Entity not found.", HTTPStatus.NOT_FOUND) class APIEventListenersView(HomeAssistantView): @@ -303,12 +300,12 @@ class APIEventView(HomeAssistantView): event_data = json.loads(body) if body else None except ValueError: return self.json_message( - "Event data should be valid JSON.", HTTP_BAD_REQUEST + "Event data should be valid JSON.", HTTPStatus.BAD_REQUEST ) if event_data is not None and not isinstance(event_data, dict): return self.json_message( - "Event data should be a JSON object", HTTP_BAD_REQUEST + "Event data should be a JSON object", HTTPStatus.BAD_REQUEST ) # Special case handling for event STATE_CHANGED @@ -355,7 +352,9 @@ class APIDomainServicesView(HomeAssistantView): try: data = json.loads(body) if body else None except ValueError: - return self.json_message("Data should be valid JSON.", HTTP_BAD_REQUEST) + return self.json_message( + "Data should be valid JSON.", HTTPStatus.BAD_REQUEST + ) context = self.context(request) @@ -403,7 +402,7 @@ class APITemplateView(HomeAssistantView): return tpl.async_render(variables=data.get("variables"), parse_result=False) except (ValueError, TemplateError) as ex: return self.json_message( - f"Error rendering template: {ex}", HTTP_BAD_REQUEST + f"Error rendering template: {ex}", HTTPStatus.BAD_REQUEST ) diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py index c4a48f7eda4..49c18b4737a 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -117,6 +117,7 @@ Result will be a long-lived access token: from __future__ import annotations from datetime import timedelta +from http import HTTPStatus import uuid from aiohttp import web @@ -133,7 +134,7 @@ from homeassistant.components.http.auth import async_sign_path from homeassistant.components.http.ban import log_invalid_auth from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.view import HomeAssistantView -from homeassistant.const import HTTP_BAD_REQUEST, HTTP_FORBIDDEN, HTTP_OK +from homeassistant.const import HTTP_OK from homeassistant.core import HomeAssistant, callback from homeassistant.loader import bind_hass from homeassistant.util import dt as dt_util @@ -259,7 +260,7 @@ class TokenView(HomeAssistantView): return await self._async_handle_refresh_token(hass, data, request.remote) return self.json( - {"error": "unsupported_grant_type"}, status_code=HTTP_BAD_REQUEST + {"error": "unsupported_grant_type"}, status_code=HTTPStatus.BAD_REQUEST ) async def _async_handle_revoke_token(self, hass, data): @@ -289,7 +290,7 @@ class TokenView(HomeAssistantView): if client_id is None or not indieauth.verify_client_id(client_id): return self.json( {"error": "invalid_request", "error_description": "Invalid client id"}, - status_code=HTTP_BAD_REQUEST, + status_code=HTTPStatus.BAD_REQUEST, ) code = data.get("code") @@ -297,7 +298,7 @@ class TokenView(HomeAssistantView): if code is None: return self.json( {"error": "invalid_request", "error_description": "Invalid code"}, - status_code=HTTP_BAD_REQUEST, + status_code=HTTPStatus.BAD_REQUEST, ) credential = self._retrieve_auth(client_id, RESULT_TYPE_CREDENTIALS, code) @@ -305,7 +306,7 @@ class TokenView(HomeAssistantView): if credential is None or not isinstance(credential, Credentials): return self.json( {"error": "invalid_request", "error_description": "Invalid code"}, - status_code=HTTP_BAD_REQUEST, + status_code=HTTPStatus.BAD_REQUEST, ) user = await hass.auth.async_get_or_create_user(credential) @@ -313,7 +314,7 @@ class TokenView(HomeAssistantView): if not user.is_active: return self.json( {"error": "access_denied", "error_description": "User is not active"}, - status_code=HTTP_FORBIDDEN, + status_code=HTTPStatus.FORBIDDEN, ) refresh_token = await hass.auth.async_create_refresh_token( @@ -326,7 +327,7 @@ class TokenView(HomeAssistantView): except InvalidAuthError as exc: return self.json( {"error": "access_denied", "error_description": str(exc)}, - status_code=HTTP_FORBIDDEN, + status_code=HTTPStatus.FORBIDDEN, ) return self.json( @@ -346,21 +347,27 @@ class TokenView(HomeAssistantView): if client_id is not None and not indieauth.verify_client_id(client_id): return self.json( {"error": "invalid_request", "error_description": "Invalid client id"}, - status_code=HTTP_BAD_REQUEST, + status_code=HTTPStatus.BAD_REQUEST, ) token = data.get("refresh_token") if token is None: - return self.json({"error": "invalid_request"}, status_code=HTTP_BAD_REQUEST) + return self.json( + {"error": "invalid_request"}, status_code=HTTPStatus.BAD_REQUEST + ) refresh_token = await hass.auth.async_get_refresh_token_by_token(token) if refresh_token is None: - return self.json({"error": "invalid_grant"}, status_code=HTTP_BAD_REQUEST) + return self.json( + {"error": "invalid_grant"}, status_code=HTTPStatus.BAD_REQUEST + ) if refresh_token.client_id != client_id: - return self.json({"error": "invalid_request"}, status_code=HTTP_BAD_REQUEST) + return self.json( + {"error": "invalid_request"}, status_code=HTTPStatus.BAD_REQUEST + ) try: access_token = hass.auth.async_create_access_token( @@ -369,7 +376,7 @@ class TokenView(HomeAssistantView): except InvalidAuthError as exc: return self.json( {"error": "access_denied", "error_description": str(exc)}, - status_code=HTTP_FORBIDDEN, + status_code=HTTPStatus.FORBIDDEN, ) return self.json( @@ -404,7 +411,7 @@ class LinkUserView(HomeAssistantView): ) if credentials is None: - return self.json_message("Invalid code", status_code=HTTP_BAD_REQUEST) + return self.json_message("Invalid code", status_code=HTTPStatus.BAD_REQUEST) await hass.auth.async_link_user(user, credentials) return self.json_message("User linked") diff --git a/homeassistant/components/auth/login_flow.py b/homeassistant/components/auth/login_flow.py index f948233b33b..f15eeee2f16 100644 --- a/homeassistant/components/auth/login_flow.py +++ b/homeassistant/components/auth/login_flow.py @@ -66,6 +66,7 @@ associate with an credential if "type" set to "link_user" in "version": 1 } """ +from http import HTTPStatus from ipaddress import ip_address from aiohttp import web @@ -80,11 +81,7 @@ from homeassistant.components.http.ban import ( ) from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.view import HomeAssistantView -from homeassistant.const import ( - HTTP_BAD_REQUEST, - HTTP_METHOD_NOT_ALLOWED, - HTTP_NOT_FOUND, -) +from homeassistant.const import HTTP_METHOD_NOT_ALLOWED from . import indieauth @@ -109,7 +106,7 @@ class AuthProvidersView(HomeAssistantView): if not hass.components.onboarding.async_is_user_onboarded(): return self.json_message( message="Onboarding not finished", - status_code=HTTP_BAD_REQUEST, + status_code=HTTPStatus.BAD_REQUEST, message_code="onboarding_required", ) @@ -177,7 +174,7 @@ class LoginFlowIndexView(HomeAssistantView): request.app["hass"], data["client_id"], data["redirect_uri"] ): return self.json_message( - "invalid client id or redirect uri", HTTP_BAD_REQUEST + "invalid client id or redirect uri", HTTPStatus.BAD_REQUEST ) if isinstance(data["handler"], list): @@ -194,9 +191,11 @@ class LoginFlowIndexView(HomeAssistantView): }, ) except data_entry_flow.UnknownHandler: - return self.json_message("Invalid handler specified", HTTP_NOT_FOUND) + return self.json_message("Invalid handler specified", HTTPStatus.NOT_FOUND) except data_entry_flow.UnknownStep: - return self.json_message("Handler does not support init", HTTP_BAD_REQUEST) + return self.json_message( + "Handler does not support init", HTTPStatus.BAD_REQUEST + ) if result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY: await process_success_login(request) @@ -221,7 +220,7 @@ class LoginFlowResourceView(HomeAssistantView): async def get(self, request): """Do not allow getting status of a flow in progress.""" - return self.json_message("Invalid flow specified", HTTP_NOT_FOUND) + return self.json_message("Invalid flow specified", HTTPStatus.NOT_FOUND) @RequestDataValidator(vol.Schema({"client_id": str}, extra=vol.ALLOW_EXTRA)) @log_invalid_auth @@ -230,7 +229,7 @@ class LoginFlowResourceView(HomeAssistantView): client_id = data.pop("client_id") if not indieauth.verify_client_id(client_id): - return self.json_message("Invalid client id", HTTP_BAD_REQUEST) + return self.json_message("Invalid client id", HTTPStatus.BAD_REQUEST) try: # do not allow change ip during login flow @@ -238,13 +237,15 @@ class LoginFlowResourceView(HomeAssistantView): if flow["flow_id"] == flow_id and flow["context"][ "ip_address" ] != ip_address(request.remote): - return self.json_message("IP address changed", HTTP_BAD_REQUEST) + return self.json_message( + "IP address changed", HTTPStatus.BAD_REQUEST + ) result = await self._flow_mgr.async_configure(flow_id, data) except data_entry_flow.UnknownFlow: - return self.json_message("Invalid flow specified", HTTP_NOT_FOUND) + return self.json_message("Invalid flow specified", HTTPStatus.NOT_FOUND) except vol.Invalid: - return self.json_message("User input malformed", HTTP_BAD_REQUEST) + return self.json_message("User input malformed", HTTPStatus.BAD_REQUEST) if result["type"] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY: # @log_invalid_auth does not work here since it returns HTTP 200 @@ -266,6 +267,6 @@ class LoginFlowResourceView(HomeAssistantView): try: self._flow_mgr.async_abort(flow_id) except data_entry_flow.UnknownFlow: - return self.json_message("Invalid flow specified", HTTP_NOT_FOUND) + return self.json_message("Invalid flow specified", HTTPStatus.NOT_FOUND) return self.json_message("Flow aborted") diff --git a/homeassistant/components/config/__init__.py b/homeassistant/components/config/__init__.py index 7d07710a4d0..0815216ec79 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -1,17 +1,13 @@ """Component to configure Home Assistant via an API.""" import asyncio +from http import HTTPStatus import importlib import os import voluptuous as vol from homeassistant.components.http import HomeAssistantView -from homeassistant.const import ( - CONF_ID, - EVENT_COMPONENT_LOADED, - HTTP_BAD_REQUEST, - HTTP_NOT_FOUND, -) +from homeassistant.const import CONF_ID, EVENT_COMPONENT_LOADED from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import ATTR_COMPONENT @@ -125,7 +121,7 @@ class BaseEditConfigView(HomeAssistantView): value = self._get_value(hass, current, config_key) if value is None: - return self.json_message("Resource not found", HTTP_NOT_FOUND) + return self.json_message("Resource not found", HTTPStatus.NOT_FOUND) return self.json(value) @@ -134,12 +130,12 @@ class BaseEditConfigView(HomeAssistantView): try: data = await request.json() except ValueError: - return self.json_message("Invalid JSON specified", HTTP_BAD_REQUEST) + return self.json_message("Invalid JSON specified", HTTPStatus.BAD_REQUEST) try: self.key_schema(config_key) except vol.Invalid as err: - return self.json_message(f"Key malformed: {err}", HTTP_BAD_REQUEST) + return self.json_message(f"Key malformed: {err}", HTTPStatus.BAD_REQUEST) hass = request.app["hass"] @@ -151,7 +147,9 @@ class BaseEditConfigView(HomeAssistantView): else: self.data_schema(data) except (vol.Invalid, HomeAssistantError) as err: - return self.json_message(f"Message malformed: {err}", HTTP_BAD_REQUEST) + return self.json_message( + f"Message malformed: {err}", HTTPStatus.BAD_REQUEST + ) path = hass.config.path(self.path) @@ -177,7 +175,7 @@ class BaseEditConfigView(HomeAssistantView): path = hass.config.path(self.path) if value is None: - return self.json_message("Resource not found", HTTP_NOT_FOUND) + return self.json_message("Resource not found", HTTPStatus.BAD_REQUEST) self._delete_value(hass, current, config_key) await hass.async_add_executor_job(_write, path, current) diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index f842a240dc1..cf243137940 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -1,6 +1,8 @@ """Http views to control the config manager.""" from __future__ import annotations +from http import HTTPStatus + import aiohttp.web_exceptions import voluptuous as vol @@ -8,7 +10,6 @@ from homeassistant import config_entries, data_entry_flow from homeassistant.auth.permissions.const import CAT_CONFIG_ENTRIES, POLICY_EDIT from homeassistant.components import websocket_api from homeassistant.components.http import HomeAssistantView -from homeassistant.const import HTTP_FORBIDDEN, HTTP_NOT_FOUND from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import Unauthorized from homeassistant.helpers.data_entry_flow import ( @@ -69,7 +70,7 @@ class ConfigManagerEntryResourceView(HomeAssistantView): try: result = await hass.config_entries.async_remove(entry_id) except config_entries.UnknownEntry: - return self.json_message("Invalid entry specified", HTTP_NOT_FOUND) + return self.json_message("Invalid entry specified", HTTPStatus.NOT_FOUND) return self.json(result) @@ -90,9 +91,9 @@ class ConfigManagerEntryResourceReloadView(HomeAssistantView): try: result = await hass.config_entries.async_reload(entry_id) except config_entries.OperationNotAllowed: - return self.json_message("Entry cannot be reloaded", HTTP_FORBIDDEN) + return self.json_message("Entry cannot be reloaded", HTTPStatus.FORBIDDEN) except config_entries.UnknownEntry: - return self.json_message("Invalid entry specified", HTTP_NOT_FOUND) + return self.json_message("Invalid entry specified", HTTPStatus.NOT_FOUND) return self.json({"require_restart": not result}) diff --git a/homeassistant/components/config/zwave.py b/homeassistant/components/config/zwave.py index b0f6fca4817..f8a3ac0cd9f 100644 --- a/homeassistant/components/config/zwave.py +++ b/homeassistant/components/config/zwave.py @@ -1,12 +1,13 @@ """Provide configuration end points for Z-Wave.""" from collections import deque +from http import HTTPStatus import logging from aiohttp.web import Response from homeassistant.components.http import HomeAssistantView from homeassistant.components.zwave import DEVICE_CONFIG_SCHEMA_ENTRY, const -from homeassistant.const import HTTP_ACCEPTED, HTTP_BAD_REQUEST, HTTP_NOT_FOUND, HTTP_OK +from homeassistant.const import HTTP_BAD_REQUEST import homeassistant.core as ha import homeassistant.helpers.config_validation as cv @@ -82,10 +83,12 @@ class ZWaveConfigWriteView(HomeAssistantView): hass = request.app["hass"] network = hass.data.get(const.DATA_NETWORK) if network is None: - return self.json_message("No Z-Wave network data found", HTTP_NOT_FOUND) + return self.json_message( + "No Z-Wave network data found", HTTPStatus.NOT_FOUND + ) _LOGGER.info("Z-Wave configuration written to file") network.write_config() - return self.json_message("Z-Wave configuration saved to file", HTTP_OK) + return self.json_message("Z-Wave configuration saved to file") class ZWaveNodeValueView(HomeAssistantView): @@ -131,7 +134,7 @@ class ZWaveNodeGroupView(HomeAssistantView): network = hass.data.get(const.DATA_NETWORK) node = network.nodes.get(nodeid) if node is None: - return self.json_message("Node not found", HTTP_NOT_FOUND) + return self.json_message("Node not found", HTTPStatus.NOT_FOUND) groupdata = node.groups groups = {} for key, value in groupdata.items(): @@ -158,7 +161,7 @@ class ZWaveNodeConfigView(HomeAssistantView): network = hass.data.get(const.DATA_NETWORK) node = network.nodes.get(nodeid) if node is None: - return self.json_message("Node not found", HTTP_NOT_FOUND) + return self.json_message("Node not found", HTTPStatus.NOT_FOUND) config = {} for value in node.get_values( class_id=const.COMMAND_CLASS_CONFIGURATION @@ -189,7 +192,7 @@ class ZWaveUserCodeView(HomeAssistantView): network = hass.data.get(const.DATA_NETWORK) node = network.nodes.get(nodeid) if node is None: - return self.json_message("Node not found", HTTP_NOT_FOUND) + return self.json_message("Node not found", HTTPStatus.NOT_FOUND) usercodes = {} if not node.has_command_class(const.COMMAND_CLASS_USER_CODE): return self.json(usercodes) @@ -220,7 +223,7 @@ class ZWaveProtectionView(HomeAssistantView): """Get protection data.""" node = network.nodes.get(nodeid) if node is None: - return self.json_message("Node not found", HTTP_NOT_FOUND) + return self.json_message("Node not found", HTTPStatus.NOT_FOUND) protection_options = {} if not node.has_command_class(const.COMMAND_CLASS_PROTECTION): return self.json(protection_options) @@ -247,16 +250,16 @@ class ZWaveProtectionView(HomeAssistantView): selection = protection_data["selection"] value_id = int(protection_data[const.ATTR_VALUE_ID]) if node is None: - return self.json_message("Node not found", HTTP_NOT_FOUND) + return self.json_message("Node not found", HTTPStatus.NOT_FOUND) if not node.has_command_class(const.COMMAND_CLASS_PROTECTION): return self.json_message( - "No protection commandclass on this node", HTTP_NOT_FOUND + "No protection commandclass on this node", HTTPStatus.NOT_FOUND ) state = node.set_protection(value_id, selection) if not state: return self.json_message( - "Protection setting did not complete", HTTP_ACCEPTED + "Protection setting did not complete", HTTPStatus.ACCEPTED ) - return self.json_message("Protection setting succsessfully set", HTTP_OK) + return self.json_message("Protection setting successfully set") return await hass.async_add_executor_job(_set_protection) diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index f8534d99935..4d3297d8c65 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -1,4 +1,5 @@ """Support for functionality to have conversations with Home Assistant.""" +from http import HTTPStatus import logging import re @@ -7,7 +8,6 @@ import voluptuous as vol from homeassistant import core from homeassistant.components import http, websocket_api from homeassistant.components.http.data_validator import RequestDataValidator -from homeassistant.const import HTTP_INTERNAL_SERVER_ERROR from homeassistant.helpers import config_validation as cv, intent from homeassistant.loader import bind_hass @@ -146,7 +146,7 @@ class ConversationProcessView(http.HomeAssistantView): "message": str(err), }, }, - status_code=HTTP_INTERNAL_SERVER_ERROR, + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, ) return self.json(intent_result) diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index bbd899b559b..a7106f5105f 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -1,6 +1,7 @@ """Support for a Hue API to control Home Assistant.""" import asyncio import hashlib +from http import HTTPStatus from ipaddress import ip_address import logging import time @@ -55,9 +56,6 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE, - HTTP_BAD_REQUEST, - HTTP_NOT_FOUND, - HTTP_UNAUTHORIZED, SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER, SERVICE_TURN_OFF, @@ -136,15 +134,15 @@ class HueUsernameView(HomeAssistantView): async def post(self, request): """Handle a POST request.""" if not is_local(ip_address(request.remote)): - return self.json_message("Only local IPs allowed", HTTP_UNAUTHORIZED) + return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED) try: data = await request.json() except ValueError: - return self.json_message("Invalid JSON", HTTP_BAD_REQUEST) + return self.json_message("Invalid JSON", HTTPStatus.BAD_REQUEST) if "devicetype" not in data: - return self.json_message("devicetype not specified", HTTP_BAD_REQUEST) + return self.json_message("devicetype not specified", HTTPStatus.BAD_REQUEST) return self.json([{"success": {"username": HUE_API_USERNAME}}]) @@ -164,7 +162,7 @@ class HueAllGroupsStateView(HomeAssistantView): def get(self, request, username): """Process a request to make the Brilliant Lightpad work.""" if not is_local(ip_address(request.remote)): - return self.json_message("Only local IPs allowed", HTTP_UNAUTHORIZED) + return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED) return self.json({}) @@ -184,7 +182,7 @@ class HueGroupView(HomeAssistantView): def put(self, request, username): """Process a request to make the Logitech Pop working.""" if not is_local(ip_address(request.remote)): - return self.json_message("Only local IPs allowed", HTTP_UNAUTHORIZED) + return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED) return self.json( [ @@ -214,7 +212,7 @@ class HueAllLightsStateView(HomeAssistantView): def get(self, request, username): """Process a request to get the list of available lights.""" if not is_local(ip_address(request.remote)): - return self.json_message("Only local IPs allowed", HTTP_UNAUTHORIZED) + return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED) return self.json(create_list_of_entities(self.config, request)) @@ -234,7 +232,7 @@ class HueFullStateView(HomeAssistantView): def get(self, request, username): """Process a request to get the list of available lights.""" if not is_local(ip_address(request.remote)): - return self.json_message("only local IPs allowed", HTTP_UNAUTHORIZED) + return self.json_message("only local IPs allowed", HTTPStatus.UNAUTHORIZED) if username != HUE_API_USERNAME: return self.json(UNAUTHORIZED_USER) @@ -262,7 +260,7 @@ class HueConfigView(HomeAssistantView): def get(self, request, username=""): """Process a request to get the configuration.""" if not is_local(ip_address(request.remote)): - return self.json_message("only local IPs allowed", HTTP_UNAUTHORIZED) + return self.json_message("only local IPs allowed", HTTPStatus.UNAUTHORIZED) json_response = create_config_model(self.config, request) @@ -284,7 +282,7 @@ class HueOneLightStateView(HomeAssistantView): def get(self, request, username, entity_id): """Process a request to get the state of an individual light.""" if not is_local(ip_address(request.remote)): - return self.json_message("Only local IPs allowed", HTTP_UNAUTHORIZED) + return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED) hass = request.app["hass"] hass_entity_id = self.config.number_to_entity_id(entity_id) @@ -294,17 +292,17 @@ class HueOneLightStateView(HomeAssistantView): "Unknown entity number: %s not found in emulated_hue_ids.json", entity_id, ) - return self.json_message("Entity not found", HTTP_NOT_FOUND) + return self.json_message("Entity not found", HTTPStatus.NOT_FOUND) entity = hass.states.get(hass_entity_id) if entity is None: _LOGGER.error("Entity not found: %s", hass_entity_id) - return self.json_message("Entity not found", HTTP_NOT_FOUND) + return self.json_message("Entity not found", HTTPStatus.NOT_FOUND) if not self.config.is_entity_exposed(entity): _LOGGER.error("Entity not exposed: %s", entity_id) - return self.json_message("Entity not exposed", HTTP_UNAUTHORIZED) + return self.json_message("Entity not exposed", HTTPStatus.UNAUTHORIZED) json_response = entity_to_json(self.config, entity) @@ -325,7 +323,7 @@ class HueOneLightChangeView(HomeAssistantView): async def put(self, request, username, entity_number): # noqa: C901 """Process a request to set the state of an individual light.""" if not is_local(ip_address(request.remote)): - return self.json_message("Only local IPs allowed", HTTP_UNAUTHORIZED) + return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED) config = self.config hass = request.app["hass"] @@ -333,23 +331,23 @@ class HueOneLightChangeView(HomeAssistantView): if entity_id is None: _LOGGER.error("Unknown entity number: %s", entity_number) - return self.json_message("Entity not found", HTTP_NOT_FOUND) + return self.json_message("Entity not found", HTTPStatus.NOT_FOUND) entity = hass.states.get(entity_id) if entity is None: _LOGGER.error("Entity not found: %s", entity_id) - return self.json_message("Entity not found", HTTP_NOT_FOUND) + return self.json_message("Entity not found", HTTPStatus.NOT_FOUND) if not config.is_entity_exposed(entity): _LOGGER.error("Entity not exposed: %s", entity_id) - return self.json_message("Entity not exposed", HTTP_UNAUTHORIZED) + return self.json_message("Entity not exposed", HTTPStatus.UNAUTHORIZED) try: request_json = await request.json() except ValueError: _LOGGER.error("Received invalid json") - return self.json_message("Invalid JSON", HTTP_BAD_REQUEST) + return self.json_message("Invalid JSON", HTTPStatus.BAD_REQUEST) # Get the entity's supported features entity_features = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) @@ -370,7 +368,7 @@ class HueOneLightChangeView(HomeAssistantView): if HUE_API_STATE_ON in request_json: if not isinstance(request_json[HUE_API_STATE_ON], bool): _LOGGER.error("Unable to parse data: %s", request_json) - return self.json_message("Bad request", HTTP_BAD_REQUEST) + return self.json_message("Bad request", HTTPStatus.BAD_REQUEST) parsed[STATE_ON] = request_json[HUE_API_STATE_ON] else: parsed[STATE_ON] = entity.state != STATE_OFF @@ -387,7 +385,7 @@ class HueOneLightChangeView(HomeAssistantView): parsed[attr] = int(request_json[key]) except ValueError: _LOGGER.error("Unable to parse data (2): %s", request_json) - return self.json_message("Bad request", HTTP_BAD_REQUEST) + return self.json_message("Bad request", HTTPStatus.BAD_REQUEST) if HUE_API_STATE_XY in request_json: try: parsed[STATE_XY] = ( @@ -396,7 +394,7 @@ class HueOneLightChangeView(HomeAssistantView): ) except ValueError: _LOGGER.error("Unable to parse data (2): %s", request_json) - return self.json_message("Bad request", HTTP_BAD_REQUEST) + return self.json_message("Bad request", HTTPStatus.BAD_REQUEST) if HUE_API_STATE_BRI in request_json: if entity.domain == light.DOMAIN: diff --git a/homeassistant/components/foursquare/__init__.py b/homeassistant/components/foursquare/__init__.py index 6f33c9ff591..6dc0d1c8228 100644 --- a/homeassistant/components/foursquare/__init__.py +++ b/homeassistant/components/foursquare/__init__.py @@ -1,16 +1,12 @@ """Support for the Foursquare (Swarm) API.""" +from http import HTTPStatus import logging import requests import voluptuous as vol from homeassistant.components.http import HomeAssistantView -from homeassistant.const import ( - CONF_ACCESS_TOKEN, - HTTP_BAD_REQUEST, - HTTP_CREATED, - HTTP_OK, -) +from homeassistant.const import CONF_ACCESS_TOKEN, HTTP_CREATED, HTTP_OK import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -95,7 +91,7 @@ class FoursquarePushReceiver(HomeAssistantView): try: data = await request.json() except ValueError: - return self.json_message("Invalid JSON", HTTP_BAD_REQUEST) + return self.json_message("Invalid JSON", HTTPStatus.BAD_REQUEST) secret = data.pop("secret", None) @@ -105,6 +101,6 @@ class FoursquarePushReceiver(HomeAssistantView): _LOGGER.error( "Received Foursquare push with invalid push secret: %s", secret ) - return self.json_message("Incorrect secret", HTTP_BAD_REQUEST) + return self.json_message("Incorrect secret", HTTPStatus.BAD_REQUEST) request.app["hass"].bus.async_fire(EVENT_PUSH, data) diff --git a/homeassistant/components/history/__init__.py b/homeassistant/components/history/__init__.py index 4f50a5e66be..3ae71602dc1 100644 --- a/homeassistant/components/history/__init__.py +++ b/homeassistant/components/history/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Iterable from datetime import datetime as dt, timedelta +from http import HTTPStatus import logging import time from typing import cast @@ -19,13 +20,7 @@ from homeassistant.components.recorder.statistics import ( statistics_during_period, ) from homeassistant.components.recorder.util import session_scope -from homeassistant.const import ( - CONF_DOMAINS, - CONF_ENTITIES, - CONF_EXCLUDE, - CONF_INCLUDE, - HTTP_BAD_REQUEST, -) +from homeassistant.const import CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, CONF_INCLUDE from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.deprecation import deprecated_class, deprecated_function @@ -203,7 +198,7 @@ class HistoryPeriodView(HomeAssistantView): datetime_ = dt_util.parse_datetime(datetime) if datetime_ is None: - return self.json_message("Invalid datetime", HTTP_BAD_REQUEST) + return self.json_message("Invalid datetime", HTTPStatus.BAD_REQUEST) now = dt_util.utcnow() @@ -222,7 +217,7 @@ class HistoryPeriodView(HomeAssistantView): if end_time: end_time = dt_util.as_utc(end_time) else: - return self.json_message("Invalid end_time", HTTP_BAD_REQUEST) + return self.json_message("Invalid end_time", HTTPStatus.BAD_REQUEST) else: end_time = start_time + one_day entity_ids_str = request.query.get("filter_entity_id") diff --git a/homeassistant/components/html5/notify.py b/homeassistant/components/html5/notify.py index eceaa0b73b9..594d84a8068 100644 --- a/homeassistant/components/html5/notify.py +++ b/homeassistant/components/html5/notify.py @@ -2,6 +2,7 @@ from contextlib import suppress from datetime import datetime, timedelta from functools import partial +from http import HTTPStatus import json import logging import time @@ -26,13 +27,7 @@ from homeassistant.components.notify import ( PLATFORM_SCHEMA, BaseNotificationService, ) -from homeassistant.const import ( - ATTR_NAME, - HTTP_BAD_REQUEST, - HTTP_INTERNAL_SERVER_ERROR, - HTTP_UNAUTHORIZED, - URL_ROOT, -) +from homeassistant.const import ATTR_NAME, URL_ROOT from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.util import ensure_unique_string @@ -224,11 +219,11 @@ class HTML5PushRegistrationView(HomeAssistantView): try: data = await request.json() except ValueError: - return self.json_message("Invalid JSON", HTTP_BAD_REQUEST) + return self.json_message("Invalid JSON", HTTPStatus.BAD_REQUEST) try: data = REGISTER_SCHEMA(data) except vol.Invalid as ex: - return self.json_message(humanize_error(data, ex), HTTP_BAD_REQUEST) + return self.json_message(humanize_error(data, ex), HTTPStatus.BAD_REQUEST) devname = data.get(ATTR_NAME) data.pop(ATTR_NAME, None) @@ -252,7 +247,7 @@ class HTML5PushRegistrationView(HomeAssistantView): self.registrations.pop(name) return self.json_message( - "Error saving registration.", HTTP_INTERNAL_SERVER_ERROR + "Error saving registration.", HTTPStatus.INTERNAL_SERVER_ERROR ) def find_registration_name(self, data, suggested=None): @@ -269,7 +264,7 @@ class HTML5PushRegistrationView(HomeAssistantView): try: data = await request.json() except ValueError: - return self.json_message("Invalid JSON", HTTP_BAD_REQUEST) + return self.json_message("Invalid JSON", HTTPStatus.BAD_REQUEST) subscription = data.get(ATTR_SUBSCRIPTION) @@ -295,7 +290,7 @@ class HTML5PushRegistrationView(HomeAssistantView): except HomeAssistantError: self.registrations[found] = reg return self.json_message( - "Error saving registration.", HTTP_INTERNAL_SERVER_ERROR + "Error saving registration.", HTTPStatus.INTERNAL_SERVER_ERROR ) return self.json_message("Push notification subscriber unregistered.") @@ -330,7 +325,7 @@ class HTML5PushCallbackView(HomeAssistantView): return jwt.decode(token, key, algorithms=["ES256", "HS256"]) return self.json_message( - "No target found in JWT", status_code=HTTP_UNAUTHORIZED + "No target found in JWT", status_code=HTTPStatus.UNAUTHORIZED ) # The following is based on code from Auth0 @@ -341,7 +336,7 @@ class HTML5PushCallbackView(HomeAssistantView): auth = request.headers.get(AUTHORIZATION) if not auth: return self.json_message( - "Authorization header is expected", status_code=HTTP_UNAUTHORIZED + "Authorization header is expected", status_code=HTTPStatus.UNAUTHORIZED ) parts = auth.split() @@ -349,19 +344,21 @@ class HTML5PushCallbackView(HomeAssistantView): if parts[0].lower() != "bearer": return self.json_message( "Authorization header must start with Bearer", - status_code=HTTP_UNAUTHORIZED, + status_code=HTTPStatus.UNAUTHORIZED, ) if len(parts) != 2: return self.json_message( "Authorization header must be Bearer token", - status_code=HTTP_UNAUTHORIZED, + status_code=HTTPStatus.UNAUTHORIZED, ) token = parts[1] try: payload = self.decode_jwt(token) except jwt.exceptions.InvalidTokenError: - return self.json_message("token is invalid", status_code=HTTP_UNAUTHORIZED) + return self.json_message( + "token is invalid", status_code=HTTPStatus.UNAUTHORIZED + ) return payload async def post(self, request): @@ -373,7 +370,7 @@ class HTML5PushCallbackView(HomeAssistantView): try: data = await request.json() except ValueError: - return self.json_message("Invalid JSON", HTTP_BAD_REQUEST) + return self.json_message("Invalid JSON", HTTPStatus.BAD_REQUEST) event_payload = { ATTR_TAG: data.get(ATTR_TAG), diff --git a/homeassistant/components/http/data_validator.py b/homeassistant/components/http/data_validator.py index 2768350c183..f64a3c4830e 100644 --- a/homeassistant/components/http/data_validator.py +++ b/homeassistant/components/http/data_validator.py @@ -3,14 +3,13 @@ from __future__ import annotations from collections.abc import Awaitable from functools import wraps +from http import HTTPStatus import logging from typing import Any, Callable from aiohttp import web import voluptuous as vol -from homeassistant.const import HTTP_BAD_REQUEST - from .view import HomeAssistantView _LOGGER = logging.getLogger(__name__) @@ -49,7 +48,7 @@ class RequestDataValidator: except ValueError: if not self._allow_empty or (await request.content.read()) != b"": _LOGGER.error("Invalid JSON received") - return view.json_message("Invalid JSON.", HTTP_BAD_REQUEST) + return view.json_message("Invalid JSON.", HTTPStatus.BAD_REQUEST) data = {} try: @@ -57,7 +56,7 @@ class RequestDataValidator: except vol.Invalid as err: _LOGGER.error("Data does not match schema: %s", err) return view.json_message( - f"Message format incorrect: {err}", HTTP_BAD_REQUEST + f"Message format incorrect: {err}", HTTPStatus.BAD_REQUEST ) result = await method(view, request, *args, **kwargs) diff --git a/homeassistant/components/http/view.py b/homeassistant/components/http/view.py index 129c43600c4..39225c918e5 100644 --- a/homeassistant/components/http/view.py +++ b/homeassistant/components/http/view.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio from collections.abc import Awaitable, Callable +from http import HTTPStatus import json import logging from typing import Any @@ -48,7 +49,7 @@ class HomeAssistantView: @staticmethod def json( result: Any, - status_code: int = HTTP_OK, + status_code: HTTPStatus | int = HTTPStatus.OK, headers: LooseHeaders | None = None, ) -> web.Response: """Return a JSON response.""" @@ -60,7 +61,7 @@ class HomeAssistantView: response = web.Response( body=msg, content_type=CONTENT_TYPE_JSON, - status=status_code, + status=int(status_code), headers=headers, ) response.enable_compression() @@ -69,7 +70,7 @@ class HomeAssistantView: def json_message( self, message: str, - status_code: int = HTTP_OK, + status_code: HTTPStatus | int = HTTPStatus.OK, message_code: str | None = None, headers: LooseHeaders | None = None, ) -> web.Response: diff --git a/homeassistant/components/ios/__init__.py b/homeassistant/components/ios/__init__.py index 6797da9d8a6..048107910d1 100644 --- a/homeassistant/components/ios/__init__.py +++ b/homeassistant/components/ios/__init__.py @@ -1,11 +1,11 @@ """Native Home Assistant iOS app component.""" import datetime +from http import HTTPStatus import voluptuous as vol from homeassistant import config_entries from homeassistant.components.http import HomeAssistantView -from homeassistant.const import HTTP_BAD_REQUEST, HTTP_INTERNAL_SERVER_ERROR from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, discovery @@ -333,7 +333,7 @@ class iOSIdentifyDeviceView(HomeAssistantView): try: data = await request.json() except ValueError: - return self.json_message("Invalid JSON", HTTP_BAD_REQUEST) + return self.json_message("Invalid JSON", HTTPStatus.BAD_REQUEST) hass = request.app["hass"] @@ -348,6 +348,8 @@ class iOSIdentifyDeviceView(HomeAssistantView): try: save_json(self._config_path, hass.data[DOMAIN]) except HomeAssistantError: - return self.json_message("Error saving device.", HTTP_INTERNAL_SERVER_ERROR) + return self.json_message( + "Error saving device.", HTTPStatus.INTERNAL_SERVER_ERROR + ) return self.json({"status": "registered"}) diff --git a/homeassistant/components/konnected/__init__.py b/homeassistant/components/konnected/__init__.py index 6785e2e7124..29502f3878c 100644 --- a/homeassistant/components/konnected/__init__.py +++ b/homeassistant/components/konnected/__init__.py @@ -1,6 +1,7 @@ """Support for Konnected devices.""" import copy import hmac +from http import HTTPStatus import json import logging @@ -28,9 +29,6 @@ from homeassistant.const import ( CONF_SWITCHES, CONF_TYPE, CONF_ZONE, - HTTP_BAD_REQUEST, - HTTP_NOT_FOUND, - HTTP_UNAUTHORIZED, STATE_OFF, STATE_ON, ) @@ -325,7 +323,9 @@ class KonnectedView(HomeAssistantView): (True for token in tokens if hmac.compare_digest(f"Bearer {token}", auth)), False, ): - return self.json_message("unauthorized", status_code=HTTP_UNAUTHORIZED) + return self.json_message( + "unauthorized", status_code=HTTPStatus.UNAUTHORIZED + ) try: # Konnected 2.2.0 and above supports JSON payloads payload = await request.json() @@ -339,7 +339,7 @@ class KonnectedView(HomeAssistantView): device = data[CONF_DEVICES].get(device_id) if device is None: return self.json_message( - "unregistered device", status_code=HTTP_BAD_REQUEST + "unregistered device", status_code=HTTPStatus.BAD_REQUEST ) panel = device.get("panel") @@ -364,7 +364,7 @@ class KonnectedView(HomeAssistantView): if zone_data is None: return self.json_message( - "unregistered sensor/actuator", status_code=HTTP_BAD_REQUEST + "unregistered sensor/actuator", status_code=HTTPStatus.BAD_REQUEST ) zone_data["device_id"] = device_id @@ -385,7 +385,7 @@ class KonnectedView(HomeAssistantView): device = data[CONF_DEVICES].get(device_id) if not device: return self.json_message( - f"Device {device_id} not configured", status_code=HTTP_NOT_FOUND + f"Device {device_id} not configured", status_code=HTTPStatus.NOT_FOUND ) panel = device.get("panel") @@ -417,7 +417,7 @@ class KonnectedView(HomeAssistantView): ) return self.json_message( f"Switch on zone or pin {target} not configured", - status_code=HTTP_NOT_FOUND, + status_code=HTTPStatus.NOT_FOUND, ) resp = {} diff --git a/homeassistant/components/logbook/__init__.py b/homeassistant/components/logbook/__init__.py index 8bbfd08314d..91739aa5990 100644 --- a/homeassistant/components/logbook/__init__.py +++ b/homeassistant/components/logbook/__init__.py @@ -1,6 +1,7 @@ """Event parser and human readable log generator.""" from contextlib import suppress from datetime import timedelta +from http import HTTPStatus from itertools import groupby import json import re @@ -32,7 +33,6 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, EVENT_LOGBOOK_ENTRY, EVENT_STATE_CHANGED, - HTTP_BAD_REQUEST, ) from homeassistant.core import DOMAIN as HA_DOMAIN, callback, split_entity_id from homeassistant.exceptions import InvalidEntityFormatError @@ -198,7 +198,7 @@ class LogbookView(HomeAssistantView): datetime = dt_util.parse_datetime(datetime) if datetime is None: - return self.json_message("Invalid datetime", HTTP_BAD_REQUEST) + return self.json_message("Invalid datetime", HTTPStatus.BAD_REQUEST) else: datetime = dt_util.start_of_local_day() @@ -226,7 +226,7 @@ class LogbookView(HomeAssistantView): start_day = datetime end_day = dt_util.parse_datetime(end_time) if end_day is None: - return self.json_message("Invalid end_time", HTTP_BAD_REQUEST) + return self.json_message("Invalid end_time", HTTPStatus.BAD_REQUEST) hass = request.app["hass"] @@ -235,7 +235,7 @@ class LogbookView(HomeAssistantView): if entity_ids and context_id: return self.json_message( - "Can't combine entity with context_id", HTTP_BAD_REQUEST + "Can't combine entity with context_id", HTTPStatus.BAD_REQUEST ) def json_events(): diff --git a/homeassistant/components/logi_circle/config_flow.py b/homeassistant/components/logi_circle/config_flow.py index d61de1ea017..9054b476332 100644 --- a/homeassistant/components/logi_circle/config_flow.py +++ b/homeassistant/components/logi_circle/config_flow.py @@ -1,6 +1,7 @@ """Config flow to configure Logi Circle component.""" import asyncio from collections import OrderedDict +from http import HTTPStatus import async_timeout from logi_circle import LogiCircle @@ -14,7 +15,6 @@ from homeassistant.const import ( CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_SENSORS, - HTTP_BAD_REQUEST, ) from homeassistant.core import callback @@ -201,5 +201,6 @@ class LogiCircleAuthCallbackView(HomeAssistantView): ) return self.json_message("Authorisation code saved") return self.json_message( - "Authorisation code missing from query string", status_code=HTTP_BAD_REQUEST + "Authorisation code missing from query string", + status_code=HTTPStatus.BAD_REQUEST, ) diff --git a/homeassistant/components/meraki/device_tracker.py b/homeassistant/components/meraki/device_tracker.py index 159083ecd23..28953a47213 100644 --- a/homeassistant/components/meraki/device_tracker.py +++ b/homeassistant/components/meraki/device_tracker.py @@ -1,4 +1,5 @@ """Support for the Meraki CMX location service.""" +from http import HTTPStatus import json import logging @@ -9,7 +10,6 @@ from homeassistant.components.device_tracker import ( SOURCE_TYPE_ROUTER, ) from homeassistant.components.http import HomeAssistantView -from homeassistant.const import HTTP_BAD_REQUEST, HTTP_UNPROCESSABLE_ENTITY from homeassistant.core import callback import homeassistant.helpers.config_validation as cv @@ -56,21 +56,23 @@ class MerakiView(HomeAssistantView): try: data = await request.json() except ValueError: - return self.json_message("Invalid JSON", HTTP_BAD_REQUEST) + return self.json_message("Invalid JSON", HTTPStatus.BAD_REQUEST) _LOGGER.debug("Meraki Data from Post: %s", json.dumps(data)) if not data.get("secret", False): _LOGGER.error("The secret is invalid") - return self.json_message("No secret", HTTP_UNPROCESSABLE_ENTITY) + return self.json_message("No secret", HTTPStatus.UNPROCESSABLE_ENTITY) if data["secret"] != self.secret: _LOGGER.error("Invalid Secret received from Meraki") - return self.json_message("Invalid secret", HTTP_UNPROCESSABLE_ENTITY) + return self.json_message("Invalid secret", HTTPStatus.UNPROCESSABLE_ENTITY) if data["version"] != VERSION: _LOGGER.error("Invalid API version: %s", data["version"]) - return self.json_message("Invalid version", HTTP_UNPROCESSABLE_ENTITY) + return self.json_message("Invalid version", HTTPStatus.UNPROCESSABLE_ENTITY) _LOGGER.debug("Valid Secret") if data["type"] not in ("DevicesSeen", "BluetoothDevicesSeen"): _LOGGER.error("Unknown Device %s", data["type"]) - return self.json_message("Invalid device type", HTTP_UNPROCESSABLE_ENTITY) + return self.json_message( + "Invalid device type", HTTPStatus.UNPROCESSABLE_ENTITY + ) _LOGGER.debug("Processing %s", data["type"]) if not data["data"]["observations"]: _LOGGER.debug("No observations found") diff --git a/homeassistant/components/mobile_app/http_api.py b/homeassistant/components/mobile_app/http_api.py index 63bf13bad5e..05b370c711d 100644 --- a/homeassistant/components/mobile_app/http_api.py +++ b/homeassistant/components/mobile_app/http_api.py @@ -2,6 +2,7 @@ from __future__ import annotations from contextlib import suppress +from http import HTTPStatus import secrets from aiohttp.web import Request, Response @@ -11,7 +12,7 @@ import voluptuous as vol from homeassistant.components.http import HomeAssistantView from homeassistant.components.http.data_validator import RequestDataValidator -from homeassistant.const import ATTR_DEVICE_ID, CONF_WEBHOOK_ID, HTTP_CREATED +from homeassistant.const import ATTR_DEVICE_ID, CONF_WEBHOOK_ID from homeassistant.helpers import config_validation as cv from homeassistant.util import slugify @@ -109,5 +110,5 @@ class RegistrationsView(HomeAssistantView): CONF_SECRET: data.get(CONF_SECRET), CONF_WEBHOOK_ID: data[CONF_WEBHOOK_ID], }, - status_code=HTTP_CREATED, + status_code=HTTPStatus.CREATED, ) diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index cedce0d1d51..61a99d345ff 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -1,5 +1,6 @@ """Onboarding views.""" import asyncio +from http import HTTPStatus from aiohttp.web_exceptions import HTTPUnauthorized import voluptuous as vol @@ -9,7 +10,6 @@ from homeassistant.components.auth import indieauth from homeassistant.components.http.const import KEY_HASS_REFRESH_TOKEN_ID from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.view import HomeAssistantView -from homeassistant.const import HTTP_BAD_REQUEST, HTTP_FORBIDDEN from homeassistant.core import callback from homeassistant.helpers.system_info import async_get_system_info @@ -124,7 +124,7 @@ class UserOnboardingView(_BaseOnboardingView): async with self._lock: if self._async_is_done(): - return self.json_message("User step already done", HTTP_FORBIDDEN) + return self.json_message("User step already done", HTTPStatus.FORBIDDEN) provider = _async_get_hass_provider(hass) await provider.async_initialize() @@ -179,7 +179,7 @@ class CoreConfigOnboardingView(_BaseOnboardingView): async with self._lock: if self._async_is_done(): return self.json_message( - "Core config step already done", HTTP_FORBIDDEN + "Core config step already done", HTTPStatus.FORBIDDEN ) await self._async_mark_done(hass) @@ -217,7 +217,7 @@ class IntegrationOnboardingView(_BaseOnboardingView): async with self._lock: if self._async_is_done(): return self.json_message( - "Integration step already done", HTTP_FORBIDDEN + "Integration step already done", HTTPStatus.FORBIDDEN ) await self._async_mark_done(hass) @@ -227,13 +227,13 @@ class IntegrationOnboardingView(_BaseOnboardingView): request.app["hass"], data["client_id"], data["redirect_uri"] ): return self.json_message( - "invalid client id or redirect uri", HTTP_BAD_REQUEST + "invalid client id or redirect uri", HTTPStatus.BAD_REQUEST ) refresh_token = await hass.auth.async_get_refresh_token(refresh_token_id) if refresh_token is None or refresh_token.credential is None: return self.json_message( - "Credentials for user not available", HTTP_FORBIDDEN + "Credentials for user not available", HTTPStatus.FORBIDDEN ) # Return authorization code so we can redirect user and log them in @@ -257,7 +257,7 @@ class AnalyticsOnboardingView(_BaseOnboardingView): async with self._lock: if self._async_is_done(): return self.json_message( - "Analytics config step already done", HTTP_FORBIDDEN + "Analytics config step already done", HTTPStatus.FORBIDDEN ) await self._async_mark_done(hass) diff --git a/homeassistant/components/shopping_list/__init__.py b/homeassistant/components/shopping_list/__init__.py index 49b4d8a5d91..a38720bef59 100644 --- a/homeassistant/components/shopping_list/__init__.py +++ b/homeassistant/components/shopping_list/__init__.py @@ -1,4 +1,5 @@ """Support to manage a shopping list.""" +from http import HTTPStatus import logging import uuid @@ -7,7 +8,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import http, websocket_api from homeassistant.components.http.data_validator import RequestDataValidator -from homeassistant.const import ATTR_NAME, HTTP_BAD_REQUEST, HTTP_NOT_FOUND +from homeassistant.const import ATTR_NAME from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.util.json import load_json, save_json @@ -293,9 +294,9 @@ class UpdateShoppingListItemView(http.HomeAssistantView): request.app["hass"].bus.async_fire(EVENT) return self.json(item) except KeyError: - return self.json_message("Item not found", HTTP_NOT_FOUND) + return self.json_message("Item not found", HTTPStatus.NOT_FOUND) except vol.Invalid: - return self.json_message("Item not found", HTTP_BAD_REQUEST) + return self.json_message("Item not found", HTTPStatus.BAD_REQUEST) class CreateShoppingListItemView(http.HomeAssistantView): diff --git a/homeassistant/components/telegram_bot/webhooks.py b/homeassistant/components/telegram_bot/webhooks.py index bd0dde7c02c..c1e86129ebb 100644 --- a/homeassistant/components/telegram_bot/webhooks.py +++ b/homeassistant/components/telegram_bot/webhooks.py @@ -1,16 +1,13 @@ """Support for Telegram bots using webhooks.""" import datetime as dt +from http import HTTPStatus from ipaddress import ip_address import logging from telegram.error import TimedOut from homeassistant.components.http import HomeAssistantView -from homeassistant.const import ( - EVENT_HOMEASSISTANT_STOP, - HTTP_BAD_REQUEST, - HTTP_UNAUTHORIZED, -) +from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.helpers.network import get_url from . import ( @@ -98,13 +95,13 @@ class BotPushReceiver(HomeAssistantView, BaseTelegramBotEntity): real_ip = ip_address(request.remote) if not any(real_ip in net for net in self.trusted_networks): _LOGGER.warning("Access denied from %s", real_ip) - return self.json_message("Access denied", HTTP_UNAUTHORIZED) + return self.json_message("Access denied", HTTPStatus.UNAUTHORIZED) try: data = await request.json() except ValueError: - return self.json_message("Invalid JSON", HTTP_BAD_REQUEST) + return self.json_message("Invalid JSON", HTTPStatus.BAD_REQUEST) if not self.process_message(data): - return self.json_message("Invalid message", HTTP_BAD_REQUEST) + return self.json_message("Invalid message", HTTPStatus.BAD_REQUEST) return None diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index be38eb6ec09..59dfaf484b4 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio import functools as ft import hashlib +from http import HTTPStatus import io import logging import mimetypes @@ -29,7 +30,6 @@ from homeassistant.const import ( CONF_DESCRIPTION, CONF_NAME, CONF_PLATFORM, - HTTP_BAD_REQUEST, HTTP_NOT_FOUND, PLATFORM_FORMAT, ) @@ -598,10 +598,10 @@ class TextToSpeechUrlView(HomeAssistantView): try: data = await request.json() except ValueError: - return self.json_message("Invalid JSON specified", HTTP_BAD_REQUEST) + return self.json_message("Invalid JSON specified", HTTPStatus.BAD_REQUEST) if not data.get(ATTR_PLATFORM) and data.get(ATTR_MESSAGE): return self.json_message( - "Must specify platform and message", HTTP_BAD_REQUEST + "Must specify platform and message", HTTPStatus.BAD_REQUEST ) p_type = data[ATTR_PLATFORM] @@ -616,7 +616,7 @@ class TextToSpeechUrlView(HomeAssistantView): ) except HomeAssistantError as err: _LOGGER.error("Error on init tts: %s", err) - return self.json({"error": err}, HTTP_BAD_REQUEST) + return self.json({"error": err}, HTTPStatus.BAD_REQUEST) base = self.tts.base_url or get_url(self.tts.hass) url = base + path diff --git a/homeassistant/components/withings/common.py b/homeassistant/components/withings/common.py index 732197b7dcb..9d8d68c1927 100644 --- a/homeassistant/components/withings/common.py +++ b/homeassistant/components/withings/common.py @@ -507,7 +507,7 @@ class ConfigEntryWithingsApi(AbstractWithingsApi): def json_message_response(message: str, message_code: int) -> Response: """Produce common json output.""" - return HomeAssistantView.json({"message": message, "code": message_code}, 200) + return HomeAssistantView.json({"message": message, "code": message_code}) class WebhookAvailability(IntEnum): diff --git a/homeassistant/helpers/data_entry_flow.py b/homeassistant/helpers/data_entry_flow.py index 07f12a08262..7cdb1823ae0 100644 --- a/homeassistant/helpers/data_entry_flow.py +++ b/homeassistant/helpers/data_entry_flow.py @@ -1,6 +1,7 @@ """Helpers for the data entry flow.""" from __future__ import annotations +from http import HTTPStatus from typing import Any from aiohttp import web @@ -9,7 +10,6 @@ import voluptuous as vol from homeassistant import config_entries, data_entry_flow from homeassistant.components.http import HomeAssistantView from homeassistant.components.http.data_validator import RequestDataValidator -from homeassistant.const import HTTP_BAD_REQUEST, HTTP_NOT_FOUND import homeassistant.helpers.config_validation as cv @@ -77,9 +77,11 @@ class FlowManagerIndexView(_BaseFlowManagerView): }, ) except data_entry_flow.UnknownHandler: - return self.json_message("Invalid handler specified", HTTP_NOT_FOUND) + return self.json_message("Invalid handler specified", HTTPStatus.NOT_FOUND) except data_entry_flow.UnknownStep: - return self.json_message("Handler does not support user", HTTP_BAD_REQUEST) + return self.json_message( + "Handler does not support user", HTTPStatus.BAD_REQUEST + ) result = self._prepare_result_json(result) @@ -94,7 +96,7 @@ class FlowManagerResourceView(_BaseFlowManagerView): try: result = await self._flow_mgr.async_configure(flow_id) except data_entry_flow.UnknownFlow: - return self.json_message("Invalid flow specified", HTTP_NOT_FOUND) + return self.json_message("Invalid flow specified", HTTPStatus.NOT_FOUND) result = self._prepare_result_json(result) @@ -108,9 +110,9 @@ class FlowManagerResourceView(_BaseFlowManagerView): try: result = await self._flow_mgr.async_configure(flow_id, data) except data_entry_flow.UnknownFlow: - return self.json_message("Invalid flow specified", HTTP_NOT_FOUND) + return self.json_message("Invalid flow specified", HTTPStatus.NOT_FOUND) except vol.Invalid: - return self.json_message("User input malformed", HTTP_BAD_REQUEST) + return self.json_message("User input malformed", HTTPStatus.BAD_REQUEST) result = self._prepare_result_json(result) @@ -121,6 +123,6 @@ class FlowManagerResourceView(_BaseFlowManagerView): try: self._flow_mgr.async_abort(flow_id) except data_entry_flow.UnknownFlow: - return self.json_message("Invalid flow specified", HTTP_NOT_FOUND) + return self.json_message("Invalid flow specified", HTTPStatus.NOT_FOUND) return self.json_message("Flow aborted") diff --git a/tests/components/config/test_zwave.py b/tests/components/config/test_zwave.py index 2f15a167c92..06dd3434738 100644 --- a/tests/components/config/test_zwave.py +++ b/tests/components/config/test_zwave.py @@ -455,7 +455,7 @@ async def test_set_protection_value(hass, client): assert resp.status == 200 result = await resp.json() assert node.set_protection.called - assert result == {"message": "Protection setting succsessfully set"} + assert result == {"message": "Protection setting successfully set"} async def test_set_protection_value_failed(hass, client): From 92253f5192a591b020b2f8b3c2cbf517b62d2721 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 22 Sep 2021 22:31:33 +0200 Subject: [PATCH 529/843] Minor refactoring of periodic statistics (#56492) --- homeassistant/components/recorder/models.py | 24 +++++++-- .../components/recorder/statistics.py | 52 +++++++++++-------- homeassistant/components/sensor/recorder.py | 24 +++++---- tests/components/energy/test_sensor.py | 44 +++++++++------- tests/components/recorder/test_statistics.py | 33 +++++++----- 5 files changed, 107 insertions(+), 70 deletions(-) diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py index 354811bee40..7b561ab3f5b 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -1,6 +1,7 @@ """Models for SQLAlchemy.""" from __future__ import annotations +from collections.abc import Iterable from datetime import datetime, timedelta import json import logging @@ -223,7 +224,23 @@ class States(Base): # type: ignore return None -class StatisticData(TypedDict, total=False): +class StatisticResult(TypedDict): + """Statistic result data class. + + Allows multiple datapoints for the same statistic_id. + """ + + meta: StatisticMetaData + stat: Iterable[StatisticData] + + +class StatisticDataBase(TypedDict): + """Mandatory fields for statistic data class.""" + + start: datetime + + +class StatisticData(StatisticDataBase, total=False): """Statistic data class.""" mean: float @@ -260,11 +277,10 @@ class StatisticsBase: sum_increase = Column(DOUBLE_TYPE) @classmethod - def from_stats(cls, metadata_id: str, start: datetime, stats: StatisticData): + def from_stats(cls, metadata_id: str, stats: StatisticData): """Create object from a statistics.""" return cls( # type: ignore metadata_id=metadata_id, - start=start, **stats, ) @@ -293,7 +309,7 @@ class StatisticsShortTerm(Base, StatisticsBase): # type: ignore __tablename__ = TABLE_STATISTICS_SHORT_TERM -class StatisticMetaData(TypedDict, total=False): +class StatisticMetaData(TypedDict): """Statistic meta data class.""" statistic_id: str diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index afdaacca380..b3fce440108 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -30,7 +30,9 @@ import homeassistant.util.volume as volume_util from .const import DOMAIN from .models import ( + StatisticData, StatisticMetaData, + StatisticResult, Statistics, StatisticsMeta, StatisticsRuns, @@ -201,10 +203,10 @@ def _get_metadata_ids( def _update_or_add_metadata( hass: HomeAssistant, session: scoped_session, - statistic_id: str, new_metadata: StatisticMetaData, ) -> str: """Get metadata_id for a statistic_id, add if it doesn't exist.""" + statistic_id = new_metadata["statistic_id"] old_metadata_dict = _get_metadata(hass, session, [statistic_id], None) if not old_metadata_dict: unit = new_metadata["unit_of_measurement"] @@ -252,7 +254,7 @@ def compile_hourly_statistics( start_time = start.replace(minute=0) end_time = start_time + timedelta(hours=1) # Get last hour's average, min, max - summary = {} + summary: dict[str, StatisticData] = {} baked_query = instance.hass.data[STATISTICS_SHORT_TERM_BAKERY]( lambda session: session.query(*QUERY_STATISTICS_SUMMARY_MEAN) ) @@ -272,7 +274,7 @@ def compile_hourly_statistics( for stat in stats: metadata_id, _mean, _min, _max = stat summary[metadata_id] = { - "metadata_id": metadata_id, + "start": start_time, "mean": _mean, "min": _min, "max": _max, @@ -295,19 +297,26 @@ def compile_hourly_statistics( if stats: for stat in stats: metadata_id, start, last_reset, state, _sum, sum_increase, _ = stat - summary[metadata_id] = { - **summary.get(metadata_id, {}), - **{ - "metadata_id": metadata_id, + if metadata_id in summary: + summary[metadata_id].update( + { + "last_reset": process_timestamp(last_reset), + "state": state, + "sum": _sum, + "sum_increase": sum_increase, + } + ) + else: + summary[metadata_id] = { + "start": start_time, "last_reset": process_timestamp(last_reset), "state": state, "sum": _sum, "sum_increase": sum_increase, - }, - } + } - for stat in summary.values(): - session.add(Statistics.from_stats(stat.pop("metadata_id"), start_time, stat)) + for metadata_id, stat in summary.items(): + session.add(Statistics.from_stats(metadata_id, stat)) @retryable_database_job("statistics") @@ -322,30 +331,27 @@ def compile_statistics(instance: Recorder, start: datetime) -> bool: return True _LOGGER.debug("Compiling statistics for %s-%s", start, end) - platform_stats = [] + platform_stats: list[StatisticResult] = [] for domain, platform in instance.hass.data[DOMAIN].items(): if not hasattr(platform, "compile_statistics"): continue - platform_stats.append(platform.compile_statistics(instance.hass, start, end)) + platform_stat = platform.compile_statistics(instance.hass, start, end) _LOGGER.debug( - "Statistics for %s during %s-%s: %s", domain, start, end, platform_stats[-1] + "Statistics for %s during %s-%s: %s", domain, start, end, platform_stat ) + platform_stats.extend(platform_stat) with session_scope(session=instance.get_session()) as session: # type: ignore for stats in platform_stats: - for entity_id, stat in stats.items(): - metadata_id = _update_or_add_metadata( - instance.hass, session, entity_id, stat["meta"] - ) + metadata_id = _update_or_add_metadata(instance.hass, session, stats["meta"]) + for stat in stats["stat"]: try: - session.add( - StatisticsShortTerm.from_stats(metadata_id, start, stat["stat"]) - ) + session.add(StatisticsShortTerm.from_stats(metadata_id, stat)) except SQLAlchemyError: _LOGGER.exception( "Unexpected exception when inserting statistics %s:%s ", metadata_id, - stat, + stats, ) if start.minute == 55: @@ -431,7 +437,7 @@ def _configured_unit(unit: str, units: UnitSystem) -> str: def list_statistic_ids( hass: HomeAssistant, statistic_type: str | None = None -) -> list[StatisticMetaData | None]: +) -> list[dict | None]: """Return statistic_ids and meta data.""" units = hass.config.units statistic_ids = {} diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 8ea2a52c278..28e2f0c774b 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -9,6 +9,11 @@ import math from typing import Callable from homeassistant.components.recorder import history, statistics +from homeassistant.components.recorder.models import ( + StatisticData, + StatisticMetaData, + StatisticResult, +) from homeassistant.components.sensor import ( ATTR_STATE_CLASS, DEVICE_CLASS_ENERGY, @@ -309,12 +314,12 @@ def _wanted_statistics( def compile_statistics( # noqa: C901 hass: HomeAssistant, start: datetime.datetime, end: datetime.datetime -) -> dict: +) -> list[StatisticResult]: """Compile statistics for all entities during start-end. Note: This will query the database and must not be run in the event loop """ - result: dict = {} + result: list[StatisticResult] = [] entities = _get_entities(hass) @@ -375,21 +380,20 @@ def compile_statistics( # noqa: C901 ) continue - result[entity_id] = {} - # Set meta data - result[entity_id]["meta"] = { + meta: StatisticMetaData = { + "statistic_id": entity_id, "unit_of_measurement": unit, "has_mean": "mean" in wanted_statistics[entity_id], "has_sum": "sum" in wanted_statistics[entity_id], } # Make calculations - stat: dict = {} + stat: StatisticData = {"start": start} if "max" in wanted_statistics[entity_id]: - stat["max"] = max(*itertools.islice(zip(*fstates), 1)) + stat["max"] = max(*itertools.islice(zip(*fstates), 1)) # type: ignore[typeddict-item] if "min" in wanted_statistics[entity_id]: - stat["min"] = min(*itertools.islice(zip(*fstates), 1)) + stat["min"] = min(*itertools.islice(zip(*fstates), 1)) # type: ignore[typeddict-item] if "mean" in wanted_statistics[entity_id]: stat["mean"] = _time_weighted_average(fstates, start, end) @@ -480,12 +484,10 @@ def compile_statistics( # noqa: C901 # Deprecated, will be removed in Home Assistant 2021.11 if last_reset is None and state_class == STATE_CLASS_MEASUREMENT: # No valid updates - result.pop(entity_id) continue if new_state is None or old_state is None: # No valid updates - result.pop(entity_id) continue # Update the sum with the last state @@ -497,7 +499,7 @@ def compile_statistics( # noqa: C901 stat["sum_increase"] = sum_increase stat["state"] = new_state - result[entity_id]["stat"] = stat + result.append({"meta": meta, "stat": (stat,)}) return result diff --git a/tests/components/energy/test_sensor.py b/tests/components/energy/test_sensor.py index db215d21c40..dc9b28b55b9 100644 --- a/tests/components/energy/test_sensor.py +++ b/tests/components/energy/test_sensor.py @@ -38,6 +38,14 @@ async def setup_integration(hass): await hass.async_block_till_done() +def get_statistics_for_entity(statistics_results, entity_id): + """Get statistics for a certain entity, or None if there is none.""" + for statistics_result in statistics_results: + if statistics_result["meta"]["statistic_id"] == entity_id: + return statistics_result + return None + + async def test_cost_sensor_no_states(hass, hass_storage) -> None: """Test sensors are created.""" energy_data = data.EnergyManager.default_preferences() @@ -222,9 +230,9 @@ async def test_cost_sensor_price_entity_total_increasing( # Check generated statistics await async_wait_recording_done_without_instance(hass) - statistics = await hass.loop.run_in_executor(None, _compile_statistics, hass) - assert cost_sensor_entity_id in statistics - assert statistics[cost_sensor_entity_id]["stat"]["sum"] == 19.0 + all_statistics = await hass.loop.run_in_executor(None, _compile_statistics, hass) + statistics = get_statistics_for_entity(all_statistics, cost_sensor_entity_id) + assert statistics["stat"][0]["sum"] == 19.0 # Energy sensor has a small dip, no reset should be detected hass.states.async_set( @@ -262,9 +270,9 @@ async def test_cost_sensor_price_entity_total_increasing( # Check generated statistics await async_wait_recording_done_without_instance(hass) - statistics = await hass.loop.run_in_executor(None, _compile_statistics, hass) - assert cost_sensor_entity_id in statistics - assert statistics[cost_sensor_entity_id]["stat"]["sum"] == 38.0 + all_statistics = await hass.loop.run_in_executor(None, _compile_statistics, hass) + statistics = get_statistics_for_entity(all_statistics, cost_sensor_entity_id) + assert statistics["stat"][0]["sum"] == 38.0 @pytest.mark.parametrize("initial_energy,initial_cost", [(0, "0.0"), (None, "unknown")]) @@ -427,9 +435,9 @@ async def test_cost_sensor_price_entity_total( # Check generated statistics await async_wait_recording_done_without_instance(hass) - statistics = await hass.loop.run_in_executor(None, _compile_statistics, hass) - assert cost_sensor_entity_id in statistics - assert statistics[cost_sensor_entity_id]["stat"]["sum"] == 19.0 + all_statistics = await hass.loop.run_in_executor(None, _compile_statistics, hass) + statistics = get_statistics_for_entity(all_statistics, cost_sensor_entity_id) + assert statistics["stat"][0]["sum"] == 19.0 # Energy sensor has a small dip hass.states.async_set( @@ -468,9 +476,9 @@ async def test_cost_sensor_price_entity_total( # Check generated statistics await async_wait_recording_done_without_instance(hass) - statistics = await hass.loop.run_in_executor(None, _compile_statistics, hass) - assert cost_sensor_entity_id in statistics - assert statistics[cost_sensor_entity_id]["stat"]["sum"] == 38.0 + all_statistics = await hass.loop.run_in_executor(None, _compile_statistics, hass) + statistics = get_statistics_for_entity(all_statistics, cost_sensor_entity_id) + assert statistics["stat"][0]["sum"] == 38.0 @pytest.mark.parametrize("initial_energy,initial_cost", [(0, "0.0"), (None, "unknown")]) @@ -632,9 +640,9 @@ async def test_cost_sensor_price_entity_total_no_reset( # Check generated statistics await async_wait_recording_done_without_instance(hass) - statistics = await hass.loop.run_in_executor(None, _compile_statistics, hass) - assert cost_sensor_entity_id in statistics - assert statistics[cost_sensor_entity_id]["stat"]["sum"] == 19.0 + all_statistics = await hass.loop.run_in_executor(None, _compile_statistics, hass) + statistics = get_statistics_for_entity(all_statistics, cost_sensor_entity_id) + assert statistics["stat"][0]["sum"] == 19.0 # Energy sensor has a small dip hass.states.async_set( @@ -649,9 +657,9 @@ async def test_cost_sensor_price_entity_total_no_reset( # Check generated statistics await async_wait_recording_done_without_instance(hass) - statistics = await hass.loop.run_in_executor(None, _compile_statistics, hass) - assert cost_sensor_entity_id in statistics - assert statistics[cost_sensor_entity_id]["stat"]["sum"] == 18.0 + all_statistics = await hass.loop.run_in_executor(None, _compile_statistics, hass) + statistics = get_statistics_for_entity(all_statistics, cost_sensor_entity_id) + assert statistics["stat"][0]["sum"] == 18.0 async def test_cost_sensor_handle_wh(hass, hass_storage) -> None: diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index d82f74c155a..8116eaa4b06 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -111,21 +111,29 @@ def test_compile_hourly_statistics(hass_recorder): @pytest.fixture def mock_sensor_statistics(): """Generate some fake statistics.""" - sensor_stats = { - "meta": {"unit_of_measurement": "dogs", "has_mean": True, "has_sum": False}, - "stat": {}, - } - def get_fake_stats(): + def sensor_stats(entity_id, start): + """Generate fake statistics.""" return { - "sensor.test1": sensor_stats, - "sensor.test2": sensor_stats, - "sensor.test3": sensor_stats, + "meta": { + "statistic_id": entity_id, + "unit_of_measurement": "dogs", + "has_mean": True, + "has_sum": False, + }, + "stat": ({"start": start},), } + def get_fake_stats(_hass, start, _end): + return [ + sensor_stats("sensor.test1", start), + sensor_stats("sensor.test2", start), + sensor_stats("sensor.test3", start), + ] + with patch( "homeassistant.components.sensor.recorder.compile_statistics", - return_value=get_fake_stats(), + side_effect=get_fake_stats, ): yield @@ -136,12 +144,12 @@ def mock_from_stats(): counter = 0 real_from_stats = StatisticsShortTerm.from_stats - def from_stats(metadata_id, start, stats): + def from_stats(metadata_id, stats): nonlocal counter if counter == 0 and metadata_id == 2: counter += 1 return None - return real_from_stats(metadata_id, start, stats) + return real_from_stats(metadata_id, stats) with patch( "homeassistant.components.recorder.statistics.StatisticsShortTerm.from_stats", @@ -156,9 +164,6 @@ def test_compile_periodic_statistics_exception( ): """Test exception handling when compiling periodic statistics.""" - def mock_from_stats(): - raise ValueError - hass = hass_recorder() recorder = hass.data[DATA_INSTANCE] setup_component(hass, "sensor", {}) From 351ef0ab445b34e9b54cfee9ed7719954c4aa3a0 Mon Sep 17 00:00:00 2001 From: Sian Date: Thu, 23 Sep 2021 06:06:03 +0930 Subject: [PATCH 530/843] Register Google assistant energy storage trait (#56520) --- homeassistant/components/google_assistant/trait.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index b2af1d6f9a9..fea2ea4a310 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -614,6 +614,7 @@ class LocatorTrait(_Trait): ) +@register_trait class EnergyStorageTrait(_Trait): """Trait to offer EnergyStorage functionality. From f77e93ceeb68e95b5d8bdf83403a546b5aab7435 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 22 Sep 2021 22:57:58 +0200 Subject: [PATCH 531/843] Fix validation of cost entities for energy dashboard (#56219) --- homeassistant/components/energy/validate.py | 67 +++++++++++---------- tests/components/energy/test_validate.py | 15 +++++ 2 files changed, 50 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/energy/validate.py b/homeassistant/components/energy/validate.py index 2326851491c..dfa60eb7824 100644 --- a/homeassistant/components/energy/validate.py +++ b/homeassistant/components/energy/validate.py @@ -192,27 +192,13 @@ def _async_validate_cost_stat( ) ) - -@callback -def _async_validate_cost_entity( - hass: HomeAssistant, entity_id: str, result: list[ValidationIssue] -) -> None: - """Validate that the cost entity is correct.""" - if not recorder.is_entity_recorded(hass, entity_id): - result.append( - ValidationIssue( - "recorder_untracked", - entity_id, - ) - ) - - state = hass.states.get(entity_id) + state = hass.states.get(stat_id) if state is None: result.append( ValidationIssue( "entity_not_defined", - entity_id, + stat_id, ) ) return @@ -227,7 +213,21 @@ def _async_validate_cost_entity( if state_class not in supported_state_classes: result.append( ValidationIssue( - "entity_unexpected_state_class_total_increasing", entity_id, state_class + "entity_unexpected_state_class_total_increasing", stat_id, state_class + ) + ) + + +@callback +def _async_validate_auto_generated_cost_entity( + hass: HomeAssistant, entity_id: str, result: list[ValidationIssue] +) -> None: + """Validate that the auto generated cost entity is correct.""" + if not recorder.is_entity_recorded(hass, entity_id): + result.append( + ValidationIssue( + "recorder_untracked", + entity_id, ) ) @@ -259,11 +259,12 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: if flow.get("stat_cost") is not None: _async_validate_cost_stat(hass, flow["stat_cost"], source_result) - elif flow.get("entity_energy_price") is not None: - _async_validate_price_entity( - hass, flow["entity_energy_price"], source_result - ) - _async_validate_cost_entity( + else: + if flow.get("entity_energy_price") is not None: + _async_validate_price_entity( + hass, flow["entity_energy_price"], source_result + ) + _async_validate_auto_generated_cost_entity( hass, hass.data[DOMAIN]["cost_sensors"][flow["stat_energy_from"]], source_result, @@ -284,11 +285,12 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: hass, flow["stat_compensation"], source_result ) - elif flow.get("entity_energy_price") is not None: - _async_validate_price_entity( - hass, flow["entity_energy_price"], source_result - ) - _async_validate_cost_entity( + else: + if flow.get("entity_energy_price") is not None: + _async_validate_price_entity( + hass, flow["entity_energy_price"], source_result + ) + _async_validate_auto_generated_cost_entity( hass, hass.data[DOMAIN]["cost_sensors"][flow["stat_energy_to"]], source_result, @@ -307,11 +309,12 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: if source.get("stat_cost") is not None: _async_validate_cost_stat(hass, source["stat_cost"], source_result) - elif source.get("entity_energy_price") is not None: - _async_validate_price_entity( - hass, source["entity_energy_price"], source_result - ) - _async_validate_cost_entity( + else: + if source.get("entity_energy_price") is not None: + _async_validate_price_entity( + hass, source["entity_energy_price"], source_result + ) + _async_validate_auto_generated_cost_entity( hass, hass.data[DOMAIN]["cost_sensors"][source["stat_energy_from"]], source_result, diff --git a/tests/components/energy/test_validate.py b/tests/components/energy/test_validate.py index 76b9201a001..449138e2609 100644 --- a/tests/components/energy/test_validate.py +++ b/tests/components/energy/test_validate.py @@ -331,6 +331,11 @@ async def test_validation_grid(hass, mock_energy_manager, mock_is_entity_recorde "identifier": "sensor.grid_cost_1", "value": None, }, + { + "type": "entity_not_defined", + "identifier": "sensor.grid_cost_1", + "value": None, + }, { "type": "entity_unexpected_unit_energy", "identifier": "sensor.grid_production_1", @@ -341,6 +346,11 @@ async def test_validation_grid(hass, mock_energy_manager, mock_is_entity_recorde "identifier": "sensor.grid_compensation_1", "value": None, }, + { + "type": "entity_not_defined", + "identifier": "sensor.grid_compensation_1", + "value": None, + }, ] ], "device_consumption": [], @@ -558,6 +568,11 @@ async def test_validation_gas(hass, mock_energy_manager, mock_is_entity_recorded "identifier": "sensor.gas_cost_1", "value": None, }, + { + "type": "entity_not_defined", + "identifier": "sensor.gas_cost_1", + "value": None, + }, ], [], [], From 677abcd48470042b09e47e9979c5361cc8e59490 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 22 Sep 2021 14:17:04 -0700 Subject: [PATCH 532/843] Allow confirming local push notifications (#54947) * Allow confirming local push notifications * Fix from Zac * Add tests --- .../components/mobile_app/__init__.py | 59 +------- homeassistant/components/mobile_app/notify.py | 97 ++++++------- .../mobile_app/push_notification.py | 90 ++++++++++++ .../components/mobile_app/websocket_api.py | 121 +++++++++++++++++ .../components/websocket_api/connection.py | 4 +- .../components/websocket_api/http.py | 2 +- tests/components/mobile_app/test_notify.py | 128 ++++++++++++++++++ 7 files changed, 397 insertions(+), 104 deletions(-) create mode 100644 homeassistant/components/mobile_app/push_notification.py create mode 100644 homeassistant/components/mobile_app/websocket_api.py diff --git a/homeassistant/components/mobile_app/__init__.py b/homeassistant/components/mobile_app/__init__.py index 1fc5be2a890..73775f23e6d 100644 --- a/homeassistant/components/mobile_app/__init__.py +++ b/homeassistant/components/mobile_app/__init__.py @@ -1,25 +1,23 @@ """Integrates Native Apps to Home Assistant.""" from contextlib import suppress -import voluptuous as vol - -from homeassistant.components import cloud, notify as hass_notify, websocket_api +from homeassistant.components import cloud, notify as hass_notify from homeassistant.components.webhook import ( async_register as webhook_register, async_unregister as webhook_unregister, ) from homeassistant.const import ATTR_DEVICE_ID, CONF_WEBHOOK_ID -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, discovery from homeassistant.helpers.typing import ConfigType +from . import websocket_api from .const import ( ATTR_DEVICE_NAME, ATTR_MANUFACTURER, ATTR_MODEL, ATTR_OS_VERSION, CONF_CLOUDHOOK_URL, - CONF_USER_ID, DATA_CONFIG_ENTRIES, DATA_DELETED_IDS, DATA_DEVICES, @@ -66,7 +64,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: discovery.async_load_platform(hass, "notify", DOMAIN, {}, config) ) - websocket_api.async_register_command(hass, handle_push_notification_channel) + websocket_api.async_setup_commands(hass) return True @@ -127,52 +125,3 @@ async def async_remove_entry(hass, entry): if CONF_CLOUDHOOK_URL in entry.data: with suppress(cloud.CloudNotAvailable): await cloud.async_delete_cloudhook(hass, entry.data[CONF_WEBHOOK_ID]) - - -@callback -@websocket_api.websocket_command( - { - vol.Required("type"): "mobile_app/push_notification_channel", - vol.Required("webhook_id"): str, - } -) -def handle_push_notification_channel(hass, connection, msg): - """Set up a direct push notification channel.""" - webhook_id = msg["webhook_id"] - - # Validate that the webhook ID is registered to the user of the websocket connection - config_entry = hass.data[DOMAIN][DATA_CONFIG_ENTRIES].get(webhook_id) - - if config_entry is None: - connection.send_error( - msg["id"], websocket_api.ERR_NOT_FOUND, "Webhook ID not found" - ) - return - - if config_entry.data[CONF_USER_ID] != connection.user.id: - connection.send_error( - msg["id"], - websocket_api.ERR_UNAUTHORIZED, - "User not linked to this webhook ID", - ) - return - - registered_channels = hass.data[DOMAIN][DATA_PUSH_CHANNEL] - - if webhook_id in registered_channels: - registered_channels.pop(webhook_id) - - @callback - def forward_push_notification(data): - """Forward events to websocket.""" - connection.send_message(websocket_api.messages.event_message(msg["id"], data)) - - @callback - def unsub(): - # pylint: disable=comparison-with-callable - if registered_channels.get(webhook_id) == forward_push_notification: - registered_channels.pop(webhook_id) - - registered_channels[webhook_id] = forward_push_notification - connection.subscriptions[msg["id"]] = unsub - connection.send_result(msg["id"]) diff --git a/homeassistant/components/mobile_app/notify.py b/homeassistant/components/mobile_app/notify.py index c98fdeb9999..025880d8107 100644 --- a/homeassistant/components/mobile_app/notify.py +++ b/homeassistant/components/mobile_app/notify.py @@ -1,5 +1,6 @@ """Support for mobile_app push notifications.""" import asyncio +from functools import partial import logging import aiohttp @@ -124,61 +125,65 @@ class MobileAppNotificationService(BaseNotificationService): for target in targets: if target in local_push_channels: - local_push_channels[target](data) + local_push_channels[target].async_send_notification( + data, partial(self._async_send_remote_message_target, target) + ) continue - entry = self.hass.data[DOMAIN][DATA_CONFIG_ENTRIES][target] - entry_data = entry.data + await self._async_send_remote_message_target(target, data) - app_data = entry_data[ATTR_APP_DATA] - push_token = app_data[ATTR_PUSH_TOKEN] - push_url = app_data[ATTR_PUSH_URL] + async def _async_send_remote_message_target(self, target, data): + """Send a message to a target.""" + entry = self.hass.data[DOMAIN][DATA_CONFIG_ENTRIES][target] + entry_data = entry.data - target_data = dict(data) - target_data[ATTR_PUSH_TOKEN] = push_token + app_data = entry_data[ATTR_APP_DATA] + push_token = app_data[ATTR_PUSH_TOKEN] + push_url = app_data[ATTR_PUSH_URL] - reg_info = { - ATTR_APP_ID: entry_data[ATTR_APP_ID], - ATTR_APP_VERSION: entry_data[ATTR_APP_VERSION], - } - if ATTR_OS_VERSION in entry_data: - reg_info[ATTR_OS_VERSION] = entry_data[ATTR_OS_VERSION] + target_data = dict(data) + target_data[ATTR_PUSH_TOKEN] = push_token - target_data["registration_info"] = reg_info + reg_info = { + ATTR_APP_ID: entry_data[ATTR_APP_ID], + ATTR_APP_VERSION: entry_data[ATTR_APP_VERSION], + } + if ATTR_OS_VERSION in entry_data: + reg_info[ATTR_OS_VERSION] = entry_data[ATTR_OS_VERSION] - try: - with async_timeout.timeout(10): - response = await async_get_clientsession(self._hass).post( - push_url, json=target_data - ) - result = await response.json() + target_data["registration_info"] = reg_info - if response.status in (HTTP_OK, HTTP_CREATED, HTTP_ACCEPTED): - log_rate_limits(self.hass, entry_data[ATTR_DEVICE_NAME], result) - continue - - fallback_error = result.get("errorMessage", "Unknown error") - fallback_message = ( - f"Internal server error, please try again later: {fallback_error}" + try: + with async_timeout.timeout(10): + response = await async_get_clientsession(self._hass).post( + push_url, json=target_data ) - message = result.get("message", fallback_message) + result = await response.json() - if "message" in result: - if message[-1] not in [".", "?", "!"]: - message += "." - message += ( - " This message is generated externally to Home Assistant." - ) + if response.status in (HTTP_OK, HTTP_CREATED, HTTP_ACCEPTED): + log_rate_limits(self.hass, entry_data[ATTR_DEVICE_NAME], result) + return - if response.status == HTTP_TOO_MANY_REQUESTS: - _LOGGER.warning(message) - log_rate_limits( - self.hass, entry_data[ATTR_DEVICE_NAME], result, logging.WARNING - ) - else: - _LOGGER.error(message) + fallback_error = result.get("errorMessage", "Unknown error") + fallback_message = ( + f"Internal server error, please try again later: {fallback_error}" + ) + message = result.get("message", fallback_message) - except asyncio.TimeoutError: - _LOGGER.error("Timeout sending notification to %s", push_url) - except aiohttp.ClientError as err: - _LOGGER.error("Error sending notification to %s: %r", push_url, err) + if "message" in result: + if message[-1] not in [".", "?", "!"]: + message += "." + message += " This message is generated externally to Home Assistant." + + if response.status == HTTP_TOO_MANY_REQUESTS: + _LOGGER.warning(message) + log_rate_limits( + self.hass, entry_data[ATTR_DEVICE_NAME], result, logging.WARNING + ) + else: + _LOGGER.error(message) + + except asyncio.TimeoutError: + _LOGGER.error("Timeout sending notification to %s", push_url) + except aiohttp.ClientError as err: + _LOGGER.error("Error sending notification to %s: %r", push_url, err) diff --git a/homeassistant/components/mobile_app/push_notification.py b/homeassistant/components/mobile_app/push_notification.py new file mode 100644 index 00000000000..1cc5bac5d1c --- /dev/null +++ b/homeassistant/components/mobile_app/push_notification.py @@ -0,0 +1,90 @@ +"""Push notification handling.""" +import asyncio +from typing import Callable + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.event import async_call_later +from homeassistant.util.uuid import random_uuid_hex + +PUSH_CONFIRM_TIMEOUT = 10 # seconds + + +class PushChannel: + """Class that represents a push channel.""" + + def __init__( + self, + hass: HomeAssistant, + webhook_id: str, + support_confirm: bool, + send_message: Callable[[dict], None], + on_teardown: Callable[[], None], + ) -> None: + """Initialize a local push channel.""" + self.hass = hass + self.webhook_id = webhook_id + self.support_confirm = support_confirm + self._send_message = send_message + self.on_teardown = on_teardown + self.pending_confirms = {} + + @callback + def async_send_notification(self, data, fallback_send): + """Send a push notification.""" + if not self.support_confirm: + self._send_message(data) + return + + confirm_id = random_uuid_hex() + data["hass_confirm_id"] = confirm_id + + async def handle_push_failed(_=None): + """Handle a failed local push notification.""" + # Remove this handler from the pending dict + # If it didn't exist we hit a race condition between call_later and another + # push failing and tearing down the connection. + if self.pending_confirms.pop(confirm_id, None) is None: + return + + # Drop local channel if it's still open + if self.on_teardown is not None: + await self.async_teardown() + + await fallback_send(data) + + self.pending_confirms[confirm_id] = { + "unsub_scheduled_push_failed": async_call_later( + self.hass, PUSH_CONFIRM_TIMEOUT, handle_push_failed + ), + "handle_push_failed": handle_push_failed, + } + self._send_message(data) + + @callback + def async_confirm_notification(self, confirm_id) -> bool: + """Confirm a push notification. + + Returns if confirmation successful. + """ + if confirm_id not in self.pending_confirms: + return False + + self.pending_confirms.pop(confirm_id)["unsub_scheduled_push_failed"]() + return True + + async def async_teardown(self): + """Tear down this channel.""" + # Tear down is in progress + if self.on_teardown is None: + return + + self.on_teardown() + self.on_teardown = None + + cancel_pending_local_tasks = [ + actions["handle_push_failed"]() + for actions in self.pending_confirms.values() + ] + + if cancel_pending_local_tasks: + await asyncio.gather(*cancel_pending_local_tasks) diff --git a/homeassistant/components/mobile_app/websocket_api.py b/homeassistant/components/mobile_app/websocket_api.py new file mode 100644 index 00000000000..4b0863d77af --- /dev/null +++ b/homeassistant/components/mobile_app/websocket_api.py @@ -0,0 +1,121 @@ +"""Mobile app websocket API.""" +from __future__ import annotations + +from functools import wraps + +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.core import callback + +from .const import CONF_USER_ID, DATA_CONFIG_ENTRIES, DATA_PUSH_CHANNEL, DOMAIN +from .push_notification import PushChannel + + +@callback +def async_setup_commands(hass): + """Set up the mobile app websocket API.""" + websocket_api.async_register_command(hass, handle_push_notification_channel) + websocket_api.async_register_command(hass, handle_push_notification_confirm) + + +def _ensure_webhook_access(func): + """Decorate WS function to ensure user owns the webhook ID.""" + + @callback + @wraps(func) + def with_webhook_access(hass, connection, msg): + # Validate that the webhook ID is registered to the user of the websocket connection + config_entry = hass.data[DOMAIN][DATA_CONFIG_ENTRIES].get(msg["webhook_id"]) + + if config_entry is None: + connection.send_error( + msg["id"], websocket_api.ERR_NOT_FOUND, "Webhook ID not found" + ) + return + + if config_entry.data[CONF_USER_ID] != connection.user.id: + connection.send_error( + msg["id"], + websocket_api.ERR_UNAUTHORIZED, + "User not linked to this webhook ID", + ) + return + + func(hass, connection, msg) + + return with_webhook_access + + +@callback +@_ensure_webhook_access +@websocket_api.websocket_command( + { + vol.Required("type"): "mobile_app/push_notification_confirm", + vol.Required("webhook_id"): str, + vol.Required("confirm_id"): str, + } +) +def handle_push_notification_confirm(hass, connection, msg): + """Confirm receipt of a push notification.""" + channel: PushChannel | None = hass.data[DOMAIN][DATA_PUSH_CHANNEL].get( + msg["webhook_id"] + ) + if channel is None: + connection.send_error( + msg["id"], + websocket_api.ERR_NOT_FOUND, + "Push notification channel not found", + ) + return + + if channel.async_confirm_notification(msg["confirm_id"]): + connection.send_result(msg["id"]) + else: + connection.send_error( + msg["id"], + websocket_api.ERR_NOT_FOUND, + "Push notification channel not found", + ) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "mobile_app/push_notification_channel", + vol.Required("webhook_id"): str, + vol.Optional("support_confirm", default=False): bool, + } +) +@_ensure_webhook_access +@websocket_api.async_response +async def handle_push_notification_channel(hass, connection, msg): + """Set up a direct push notification channel.""" + webhook_id = msg["webhook_id"] + registered_channels: dict[str, PushChannel] = hass.data[DOMAIN][DATA_PUSH_CHANNEL] + + if webhook_id in registered_channels: + await registered_channels[webhook_id].async_teardown() + + @callback + def on_channel_teardown(): + """Handle teardown.""" + if registered_channels.get(webhook_id) == channel: + registered_channels.pop(webhook_id) + + # Remove subscription from connection if still exists + connection.subscriptions.pop(msg["id"], None) + + channel = registered_channels[webhook_id] = PushChannel( + hass, + webhook_id, + msg["support_confirm"], + lambda data: connection.send_message( + websocket_api.messages.event_message(msg["id"], data) + ), + on_channel_teardown, + ) + + connection.subscriptions[msg["id"]] = lambda: hass.async_create_task( + channel.async_teardown() + ) + connection.send_result(msg["id"]) diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py index 62c21ef5894..0d3bd5fdf4d 100644 --- a/homeassistant/components/websocket_api/connection.py +++ b/homeassistant/components/websocket_api/connection.py @@ -104,8 +104,8 @@ class ActiveConnection: self.last_id = cur_id @callback - def async_close(self) -> None: - """Close down connection.""" + def async_handle_close(self) -> None: + """Handle closing down connection.""" for unsub in self.subscriptions.values(): unsub() diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index d51eff7459e..aa6a74b27ec 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -231,7 +231,7 @@ class WebSocketHandler: unsub_stop() if connection is not None: - connection.async_close() + connection.async_handle_close() try: self._to_write.put_nowait(None) diff --git a/tests/components/mobile_app/test_notify.py b/tests/components/mobile_app/test_notify.py index 1e3b999d5f5..c0e1b4c2a85 100644 --- a/tests/components/mobile_app/test_notify.py +++ b/tests/components/mobile_app/test_notify.py @@ -1,5 +1,6 @@ """Notify platform tests for mobile_app.""" from datetime import datetime, timedelta +from unittest.mock import patch import pytest @@ -204,3 +205,130 @@ async def test_notify_ws_works( "code": "unauthorized", "message": "User not linked to this webhook ID", } + + +async def test_notify_ws_confirming_works( + hass, aioclient_mock, setup_push_receiver, hass_ws_client +): + """Test notify confirming works.""" + client = await hass_ws_client(hass) + + await client.send_json( + { + "id": 5, + "type": "mobile_app/push_notification_channel", + "webhook_id": "mock-webhook_id", + "support_confirm": True, + } + ) + + sub_result = await client.receive_json() + assert sub_result["success"] + + # Sent a message that will be delivered locally + assert await hass.services.async_call( + "notify", "mobile_app_test", {"message": "Hello world"}, blocking=True + ) + + msg_result = await client.receive_json() + confirm_id = msg_result["event"].pop("hass_confirm_id") + assert confirm_id is not None + assert msg_result["event"] == {"message": "Hello world"} + + # Try to confirm with incorrect confirm ID + await client.send_json( + { + "id": 6, + "type": "mobile_app/push_notification_confirm", + "webhook_id": "mock-webhook_id", + "confirm_id": "incorrect-confirm-id", + } + ) + + result = await client.receive_json() + assert not result["success"] + assert result["error"] == { + "code": "not_found", + "message": "Push notification channel not found", + } + + # Confirm with correct confirm ID + await client.send_json( + { + "id": 7, + "type": "mobile_app/push_notification_confirm", + "webhook_id": "mock-webhook_id", + "confirm_id": confirm_id, + } + ) + + result = await client.receive_json() + assert result["success"] + + # Drop local push channel and try to confirm another message + await client.send_json( + { + "id": 8, + "type": "unsubscribe_events", + "subscription": 5, + } + ) + sub_result = await client.receive_json() + assert sub_result["success"] + + await client.send_json( + { + "id": 9, + "type": "mobile_app/push_notification_confirm", + "webhook_id": "mock-webhook_id", + "confirm_id": confirm_id, + } + ) + + result = await client.receive_json() + assert not result["success"] + assert result["error"] == { + "code": "not_found", + "message": "Push notification channel not found", + } + + +async def test_notify_ws_not_confirming( + hass, aioclient_mock, setup_push_receiver, hass_ws_client +): + """Test we go via cloud when failed to confirm.""" + client = await hass_ws_client(hass) + + await client.send_json( + { + "id": 5, + "type": "mobile_app/push_notification_channel", + "webhook_id": "mock-webhook_id", + "support_confirm": True, + } + ) + + sub_result = await client.receive_json() + assert sub_result["success"] + + assert await hass.services.async_call( + "notify", "mobile_app_test", {"message": "Hello world 1"}, blocking=True + ) + + with patch( + "homeassistant.components.mobile_app.push_notification.PUSH_CONFIRM_TIMEOUT", 0 + ): + assert await hass.services.async_call( + "notify", "mobile_app_test", {"message": "Hello world 2"}, blocking=True + ) + await hass.async_block_till_done() + + # When we fail, all unconfirmed ones and failed one are sent via cloud + assert len(aioclient_mock.mock_calls) == 2 + + # All future ones also go via cloud + assert await hass.services.async_call( + "notify", "mobile_app_test", {"message": "Hello world 3"}, blocking=True + ) + + assert len(aioclient_mock.mock_calls) == 3 From 974376a8de734668c3f83c3284f7ed96d70cd984 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 22 Sep 2021 16:20:58 -0700 Subject: [PATCH 533/843] Bump frontend to 20210922.0 (#56546) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 615ed756138..47753067822 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", "requirements": [ - "home-assistant-frontend==20210911.0" + "home-assistant-frontend==20210922.0" ], "dependencies": [ "api", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ceac76606d5..7790e7859bc 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -16,7 +16,7 @@ cryptography==3.4.8 defusedxml==0.7.1 emoji==1.2.0 hass-nabucasa==0.50.0 -home-assistant-frontend==20210911.0 +home-assistant-frontend==20210922.0 httpx==0.19.0 ifaddr==0.1.7 jinja2==3.0.1 diff --git a/requirements_all.txt b/requirements_all.txt index 5023ecabd62..6a071858cf7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -802,7 +802,7 @@ hole==0.5.1 holidays==0.11.2 # homeassistant.components.frontend -home-assistant-frontend==20210911.0 +home-assistant-frontend==20210922.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index da36a577fb5..0bef36a9971 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -477,7 +477,7 @@ hole==0.5.1 holidays==0.11.2 # homeassistant.components.frontend -home-assistant-frontend==20210911.0 +home-assistant-frontend==20210922.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 From 83156fb9ec308574d579873b03fe537e12d8b125 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 23 Sep 2021 06:48:37 +0200 Subject: [PATCH 534/843] Energy validation: Require last_reset attribute to be set for state_class measurement energy and cost sensors (#56254) * Require last_reset attribute to be set for measurement state_class * Tweak * Improve tests * Lint Co-authored-by: Paulus Schoutsen --- homeassistant/components/energy/validate.py | 22 +++++++++-- tests/components/energy/test_validate.py | 43 ++++++++++++++++++++- 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/energy/validate.py b/homeassistant/components/energy/validate.py index dfa60eb7824..29c5ae88b48 100644 --- a/homeassistant/components/energy/validate.py +++ b/homeassistant/components/energy/validate.py @@ -135,12 +135,20 @@ def _async_validate_usage_stat( if state_class not in allowed_state_classes: result.append( ValidationIssue( - "entity_unexpected_state_class_total_increasing", + "entity_unexpected_state_class", stat_value, state_class, ) ) + if ( + state_class == sensor.STATE_CLASS_MEASUREMENT + and sensor.ATTR_LAST_RESET not in state.attributes + ): + result.append( + ValidationIssue("entity_state_class_measurement_no_last_reset", stat_value) + ) + @callback def _async_validate_price_entity( @@ -212,9 +220,15 @@ def _async_validate_cost_stat( ] if state_class not in supported_state_classes: result.append( - ValidationIssue( - "entity_unexpected_state_class_total_increasing", stat_id, state_class - ) + ValidationIssue("entity_unexpected_state_class", stat_id, state_class) + ) + + if ( + state_class == sensor.STATE_CLASS_MEASUREMENT + and sensor.ATTR_LAST_RESET not in state.attributes + ): + result.append( + ValidationIssue("entity_state_class_measurement_no_last_reset", stat_id) ) diff --git a/tests/components/energy/test_validate.py b/tests/components/energy/test_validate.py index 449138e2609..d566eb51b28 100644 --- a/tests/components/energy/test_validate.py +++ b/tests/components/energy/test_validate.py @@ -39,7 +39,16 @@ async def test_validation_empty_config(hass): } -async def test_validation(hass, mock_energy_manager): +@pytest.mark.parametrize( + "state_class, extra", + [ + ("total_increasing", {}), + ("total", {}), + ("total", {"last_reset": "abc"}), + ("measurement", {"last_reset": "abc"}), + ], +) +async def test_validation(hass, mock_energy_manager, state_class, extra): """Test validating success.""" for key in ("device_cons", "battery_import", "battery_export", "solar_production"): hass.states.async_set( @@ -48,7 +57,8 @@ async def test_validation(hass, mock_energy_manager): { "device_class": "energy", "unit_of_measurement": "kWh", - "state_class": "total_increasing", + "state_class": state_class, + **extra, }, ) @@ -190,6 +200,35 @@ async def test_validation_device_consumption_recorder_not_tracked( } +async def test_validation_device_consumption_no_last_reset(hass, mock_energy_manager): + """Test validating device based on untracked entity.""" + await mock_energy_manager.async_update( + {"device_consumption": [{"stat_consumption": "sensor.no_last_reset"}]} + ) + hass.states.async_set( + "sensor.no_last_reset", + "10.10", + { + "device_class": "energy", + "unit_of_measurement": "kWh", + "state_class": "measurement", + }, + ) + + assert (await validate.async_validate(hass)).as_dict() == { + "energy_sources": [], + "device_consumption": [ + [ + { + "type": "entity_state_class_measurement_no_last_reset", + "identifier": "sensor.no_last_reset", + "value": None, + } + ] + ], + } + + async def test_validation_solar(hass, mock_energy_manager): """Test validating missing stat for device.""" await mock_energy_manager.async_update( From ea8f624f288b3b0c7b4fb4f9b666b66be116ac1d Mon Sep 17 00:00:00 2001 From: Oscar Calvo <2091582+ocalvo@users.noreply.github.com> Date: Wed, 22 Sep 2021 21:49:08 -0700 Subject: [PATCH 535/843] Fix an issue where core process crashes when an SMS is received (#56552) --- homeassistant/components/sms/gateway.py | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/homeassistant/components/sms/gateway.py b/homeassistant/components/sms/gateway.py index 3034580d5e0..b88b81d1fb4 100644 --- a/homeassistant/components/sms/gateway.py +++ b/homeassistant/components/sms/gateway.py @@ -24,16 +24,6 @@ class Gateway: async def init_async(self): """Initialize the sms gateway asynchronously.""" await self._worker.init_async() - try: - await self._worker.set_incoming_sms_async() - except gammu.ERR_NOTSUPPORTED: - _LOGGER.warning("Falling back to pulling method for SMS notifications") - except gammu.GSMError: - _LOGGER.warning( - "GSM error, falling back to pulling method for SMS notifications" - ) - else: - await self._worker.set_incoming_callback_async(self.sms_callback) def sms_pull(self, state_machine): """Pull device. @@ -47,21 +37,6 @@ class Gateway: self.sms_read_messages(state_machine, self._first_pull) self._first_pull = False - def sms_callback(self, state_machine, callback_type, callback_data): - """Receive notification about incoming event. - - @param state_machine: state machine which invoked action - @type state_machine: gammu.StateMachine - @param callback_type: type of action, one of Call, SMS, CB, USSD - @type callback_type: string - @param data: event data - @type data: hash - """ - _LOGGER.debug( - "Received incoming event type:%s,data:%s", callback_type, callback_data - ) - self.sms_read_messages(state_machine) - def sms_read_messages(self, state_machine, force=False): """Read all received SMS messages. From 63610eadc98bc498c1820ad5553245590284e125 Mon Sep 17 00:00:00 2001 From: Ricardo Steijn <61013287+RicArch97@users.noreply.github.com> Date: Thu, 23 Sep 2021 09:23:45 +0200 Subject: [PATCH 536/843] Address Crownstone review comments (#56485) --- .../components/crownstone/__init__.py | 2 + .../components/crownstone/config_flow.py | 248 ++++++++---------- homeassistant/components/crownstone/const.py | 3 - .../components/crownstone/devices.py | 8 +- .../components/crownstone/entry_manager.py | 5 +- homeassistant/components/crownstone/light.py | 58 +--- .../components/crownstone/listeners.py | 29 +- .../components/crownstone/manifest.json | 2 +- .../components/crownstone/strings.json | 6 +- .../crownstone/translations/en.json | 6 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../components/crownstone/test_config_flow.py | 162 ++++++++---- 13 files changed, 261 insertions(+), 272 deletions(-) diff --git a/homeassistant/components/crownstone/__init__.py b/homeassistant/components/crownstone/__init__.py index bd4aae79665..92b2f4de5ca 100644 --- a/homeassistant/components/crownstone/__init__.py +++ b/homeassistant/components/crownstone/__init__.py @@ -12,6 +12,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Initiate setup for a Crownstone config entry.""" manager = CrownstoneEntryManager(hass, entry) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = manager + return await manager.async_setup() diff --git a/homeassistant/components/crownstone/config_flow.py b/homeassistant/components/crownstone/config_flow.py index 72edeef7910..86826f5f6f8 100644 --- a/homeassistant/components/crownstone/config_flow.py +++ b/homeassistant/components/crownstone/config_flow.py @@ -1,7 +1,7 @@ """Flow handler for Crownstone.""" from __future__ import annotations -from typing import Any +from typing import Any, Callable from crownstone_cloud import CrownstoneCloud from crownstone_cloud.exceptions import ( @@ -16,7 +16,7 @@ from homeassistant.components import usb from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import callback -from homeassistant.data_entry_flow import FlowResult +from homeassistant.data_entry_flow import FlowHandler, FlowResult from homeassistant.helpers import aiohttp_client from .const import ( @@ -30,75 +30,26 @@ from .const import ( MANUAL_PATH, REFRESH_LIST, ) -from .entry_manager import CrownstoneEntryManager from .helpers import list_ports_as_str +CONFIG_FLOW = "config_flow" +OPTIONS_FLOW = "options_flow" -class CrownstoneConfigFlowHandler(ConfigFlow, domain=DOMAIN): - """Handle a config flow for Crownstone.""" - VERSION = 1 +class BaseCrownstoneFlowHandler(FlowHandler): + """Represent the base flow for Crownstone.""" + cloud: CrownstoneCloud - @staticmethod - @callback - def async_get_options_flow( - config_entry: ConfigEntry, - ) -> CrownstoneOptionsFlowHandler: - """Return the Crownstone options.""" - return CrownstoneOptionsFlowHandler(config_entry) - - def __init__(self) -> None: - """Initialize the flow.""" - self.login_info: dict[str, Any] = {} + def __init__( + self, flow_type: str, create_entry_cb: Callable[..., FlowResult] + ) -> None: + """Set up flow instance.""" + self.flow_type = flow_type + self.create_entry_callback = create_entry_cb self.usb_path: str | None = None self.usb_sphere_id: str | None = None - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Handle the initial step.""" - errors: dict[str, str] = {} - if user_input is None: - return self.async_show_form( - step_id="user", - data_schema=vol.Schema( - {vol.Required(CONF_EMAIL): str, vol.Required(CONF_PASSWORD): str} - ), - ) - - self.cloud = CrownstoneCloud( - email=user_input[CONF_EMAIL], - password=user_input[CONF_PASSWORD], - clientsession=aiohttp_client.async_get_clientsession(self.hass), - ) - # Login & sync all user data - try: - await self.cloud.async_initialize() - except CrownstoneAuthenticationError as auth_error: - if auth_error.type == "LOGIN_FAILED": - errors["base"] = "invalid_auth" - elif auth_error.type == "LOGIN_FAILED_EMAIL_NOT_VERIFIED": - errors["base"] = "account_not_verified" - except CrownstoneUnknownError: - errors["base"] = "unknown_error" - - # show form again, with the errors - if errors: - return self.async_show_form( - step_id="user", - data_schema=vol.Schema( - {vol.Required(CONF_EMAIL): str, vol.Required(CONF_PASSWORD): str} - ), - errors=errors, - ) - - await self.async_set_unique_id(self.cloud.cloud_data.user_id) - self._abort_if_unique_id_configured() - - self.login_info = user_input - return await self.async_step_usb_config() - async def async_step_usb_config( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -106,19 +57,25 @@ class CrownstoneConfigFlowHandler(ConfigFlow, domain=DOMAIN): list_of_ports = await self.hass.async_add_executor_job( serial.tools.list_ports.comports ) - ports_as_string = list_ports_as_str(list_of_ports) + if self.flow_type == CONFIG_FLOW: + ports_as_string = list_ports_as_str(list_of_ports) + else: + ports_as_string = list_ports_as_str(list_of_ports, False) if user_input is not None: selection = user_input[CONF_USB_PATH] if selection == DONT_USE_USB: - return self.async_create_new_entry() + return self.create_entry_callback() if selection == MANUAL_PATH: return await self.async_step_usb_manual_config() if selection != REFRESH_LIST: - selected_port: ListPortInfo = list_of_ports[ - (ports_as_string.index(selection) - 1) - ] + if self.flow_type == OPTIONS_FLOW: + index = ports_as_string.index(selection) + else: + index = ports_as_string.index(selection) - 1 + + selected_port: ListPortInfo = list_of_ports[index] self.usb_path = await self.hass.async_add_executor_job( usb.get_serial_by_id, selected_port.device ) @@ -165,11 +122,75 @@ class CrownstoneConfigFlowHandler(ConfigFlow, domain=DOMAIN): elif user_input: self.usb_sphere_id = spheres[user_input[CONF_USB_SPHERE]] - return self.async_create_new_entry() + return self.create_entry_callback() + + +class CrownstoneConfigFlowHandler(BaseCrownstoneFlowHandler, ConfigFlow, domain=DOMAIN): + """Handle a config flow for Crownstone.""" + + VERSION = 1 + + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> CrownstoneOptionsFlowHandler: + """Return the Crownstone options.""" + return CrownstoneOptionsFlowHandler(config_entry) + + def __init__(self) -> None: + """Initialize the flow.""" + super().__init__(CONFIG_FLOW, self.async_create_new_entry) + self.login_info: dict[str, Any] = {} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is None: + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + {vol.Required(CONF_EMAIL): str, vol.Required(CONF_PASSWORD): str} + ), + ) + + self.cloud = CrownstoneCloud( + email=user_input[CONF_EMAIL], + password=user_input[CONF_PASSWORD], + clientsession=aiohttp_client.async_get_clientsession(self.hass), + ) + # Login & sync all user data + try: + await self.cloud.async_initialize() + except CrownstoneAuthenticationError as auth_error: + if auth_error.type == "LOGIN_FAILED": + errors["base"] = "invalid_auth" + elif auth_error.type == "LOGIN_FAILED_EMAIL_NOT_VERIFIED": + errors["base"] = "account_not_verified" + except CrownstoneUnknownError: + errors["base"] = "unknown_error" + + # show form again, with the errors + if errors: + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + {vol.Required(CONF_EMAIL): str, vol.Required(CONF_PASSWORD): str} + ), + errors=errors, + ) + + await self.async_set_unique_id(self.cloud.cloud_data.user_id) + self._abort_if_unique_id_configured() + + self.login_info = user_input + return await self.async_step_usb_config() def async_create_new_entry(self) -> FlowResult: """Create a new entry.""" - return self.async_create_entry( + return super().async_create_entry( title=f"Account: {self.login_info[CONF_EMAIL]}", data={ CONF_EMAIL: self.login_info[CONF_EMAIL], @@ -179,22 +200,22 @@ class CrownstoneConfigFlowHandler(ConfigFlow, domain=DOMAIN): ) -class CrownstoneOptionsFlowHandler(OptionsFlow): +class CrownstoneOptionsFlowHandler(BaseCrownstoneFlowHandler, OptionsFlow): """Handle Crownstone options.""" def __init__(self, config_entry: ConfigEntry) -> None: """Initialize Crownstone options.""" + super().__init__(OPTIONS_FLOW, self.async_create_new_entry) self.entry = config_entry self.updated_options = config_entry.options.copy() - self.spheres: dict[str, str] = {} async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Manage Crownstone options.""" - manager: CrownstoneEntryManager = self.hass.data[DOMAIN][self.entry.entry_id] + self.cloud: CrownstoneCloud = self.hass.data[DOMAIN][self.entry.entry_id].cloud - spheres = {sphere.name: sphere.cloud_id for sphere in manager.cloud.cloud_data} + spheres = {sphere.name: sphere.cloud_id for sphere in self.cloud.cloud_data} usb_path = self.entry.options.get(CONF_USB_PATH) usb_sphere = self.entry.options.get(CONF_USB_SPHERE) @@ -206,15 +227,14 @@ class CrownstoneOptionsFlowHandler(OptionsFlow): { vol.Optional( CONF_USB_SPHERE_OPTION, - default=manager.cloud.cloud_data.spheres[usb_sphere].name, + default=self.cloud.cloud_data.data[usb_sphere].name, ): vol.In(spheres.keys()) } ) if user_input is not None: if user_input[CONF_USE_USB_OPTION] and usb_path is None: - self.spheres = spheres - return await self.async_step_usb_config_option() + return await self.async_step_usb_config() if not user_input[CONF_USE_USB_OPTION] and usb_path is not None: self.updated_options[CONF_USB_PATH] = None self.updated_options[CONF_USB_SPHERE] = None @@ -223,77 +243,17 @@ class CrownstoneOptionsFlowHandler(OptionsFlow): and spheres[user_input[CONF_USB_SPHERE_OPTION]] != usb_sphere ): sphere_id = spheres[user_input[CONF_USB_SPHERE_OPTION]] - user_input[CONF_USB_SPHERE_OPTION] = sphere_id self.updated_options[CONF_USB_SPHERE] = sphere_id - return self.async_create_entry(title="", data=self.updated_options) + return self.async_create_new_entry() return self.async_show_form(step_id="init", data_schema=options_schema) - async def async_step_usb_config_option( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Set up a Crownstone USB dongle.""" - list_of_ports = await self.hass.async_add_executor_job( - serial.tools.list_ports.comports - ) - ports_as_string = list_ports_as_str(list_of_ports, False) + def async_create_new_entry(self) -> FlowResult: + """Create a new entry.""" + # these attributes will only change when a usb was configured + if self.usb_path is not None and self.usb_sphere_id is not None: + self.updated_options[CONF_USB_PATH] = self.usb_path + self.updated_options[CONF_USB_SPHERE] = self.usb_sphere_id - if user_input is not None: - selection = user_input[CONF_USB_PATH] - - if selection == MANUAL_PATH: - return await self.async_step_usb_manual_config_option() - if selection != REFRESH_LIST: - selected_port: ListPortInfo = list_of_ports[ - ports_as_string.index(selection) - ] - usb_path = await self.hass.async_add_executor_job( - usb.get_serial_by_id, selected_port.device - ) - self.updated_options[CONF_USB_PATH] = usb_path - return await self.async_step_usb_sphere_config_option() - - return self.async_show_form( - step_id="usb_config_option", - data_schema=vol.Schema( - {vol.Required(CONF_USB_PATH): vol.In(ports_as_string)} - ), - ) - - async def async_step_usb_manual_config_option( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Manually enter Crownstone USB dongle path.""" - if user_input is None: - return self.async_show_form( - step_id="usb_manual_config_option", - data_schema=vol.Schema({vol.Required(CONF_USB_MANUAL_PATH): str}), - ) - - self.updated_options[CONF_USB_PATH] = user_input[CONF_USB_MANUAL_PATH] - return await self.async_step_usb_sphere_config_option() - - async def async_step_usb_sphere_config_option( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Select a Crownstone sphere that the USB operates in.""" - # no need to select if there's only 1 option - sphere_id: str | None = None - if len(self.spheres) == 1: - sphere_id = next(iter(self.spheres.values())) - - if user_input is None and sphere_id is None: - return self.async_show_form( - step_id="usb_sphere_config_option", - data_schema=vol.Schema({CONF_USB_SPHERE: vol.In(self.spheres.keys())}), - ) - - if sphere_id: - self.updated_options[CONF_USB_SPHERE] = sphere_id - elif user_input: - self.updated_options[CONF_USB_SPHERE] = self.spheres[ - user_input[CONF_USB_SPHERE] - ] - - return self.async_create_entry(title="", data=self.updated_options) + return super().async_create_entry(title="", data=self.updated_options) diff --git a/homeassistant/components/crownstone/const.py b/homeassistant/components/crownstone/const.py index 2238701dcaf..21a14b99e86 100644 --- a/homeassistant/components/crownstone/const.py +++ b/homeassistant/components/crownstone/const.py @@ -19,9 +19,6 @@ SIG_CROWNSTONE_STATE_UPDATE: Final = "crownstone.crownstone_state_update" SIG_CROWNSTONE_UPDATE: Final = "crownstone.crownstone_update" SIG_UART_STATE_CHANGE: Final = "crownstone.uart_state_change" -# Abilities state -ABILITY_STATE: Final[dict[bool, str]] = {True: "Enabled", False: "Disabled"} - # Config flow CONF_USB_PATH: Final = "usb_path" CONF_USB_MANUAL_PATH: Final = "usb_manual_path" diff --git a/homeassistant/components/crownstone/devices.py b/homeassistant/components/crownstone/devices.py index 49965bc8fcd..91af18ab15e 100644 --- a/homeassistant/components/crownstone/devices.py +++ b/homeassistant/components/crownstone/devices.py @@ -10,13 +10,15 @@ from homeassistant.const import ( ATTR_NAME, ATTR_SW_VERSION, ) -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity import DeviceInfo, Entity from .const import CROWNSTONE_INCLUDE_TYPES, DOMAIN -class CrownstoneDevice: - """Representation of a Crownstone device.""" +class CrownstoneBaseEntity(Entity): + """Base entity class for Crownstone devices.""" + + _attr_should_poll = False def __init__(self, device: Crownstone) -> None: """Initialize the device.""" diff --git a/homeassistant/components/crownstone/entry_manager.py b/homeassistant/components/crownstone/entry_manager.py index b01316a771a..b1963462adc 100644 --- a/homeassistant/components/crownstone/entry_manager.py +++ b/homeassistant/components/crownstone/entry_manager.py @@ -20,6 +20,7 @@ from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, EVENT_HOMEASSISTANT_S from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ( CONF_USB_PATH, @@ -96,7 +97,6 @@ class CrownstoneEntryManager: # Makes HA aware of the Crownstone environment HA is placed in, a user can have multiple self.usb_sphere_id = self.config_entry.options[CONF_USB_SPHERE] - self.hass.data.setdefault(DOMAIN, {})[self.config_entry.entry_id] = self self.hass.config_entries.async_setup_platforms(self.config_entry, PLATFORMS) # HA specific listeners @@ -114,8 +114,7 @@ class CrownstoneEntryManager: async with sse_client as client: async for event in client: if event is not None: - # Make SSE updates, like ability change, available to the user - self.hass.bus.async_fire(f"{DOMAIN}_{event.type}", event.data) + async_dispatcher_send(self.hass, f"{DOMAIN}_{event.type}", event) async def async_setup_usb(self) -> None: """Attempt setup of a Crownstone usb dongle.""" diff --git a/homeassistant/components/crownstone/light.py b/homeassistant/components/crownstone/light.py index b2d4d8411b7..ff647b2fc84 100644 --- a/homeassistant/components/crownstone/light.py +++ b/homeassistant/components/crownstone/light.py @@ -1,17 +1,11 @@ """Support for Crownstone devices.""" from __future__ import annotations -from collections.abc import Mapping from functools import partial -import logging from typing import TYPE_CHECKING, Any from crownstone_cloud.cloud_models.crownstones import Crownstone -from crownstone_cloud.const import ( - DIMMING_ABILITY, - SWITCHCRAFT_ABILITY, - TAP_TO_TOGGLE_ABILITY, -) +from crownstone_cloud.const import DIMMING_ABILITY from crownstone_cloud.exceptions import CrownstoneAbilityError from crownstone_uart import CrownstoneUart @@ -22,25 +16,23 @@ from homeassistant.components.light import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( - ABILITY_STATE, CROWNSTONE_INCLUDE_TYPES, CROWNSTONE_SUFFIX, DOMAIN, SIG_CROWNSTONE_STATE_UPDATE, SIG_UART_STATE_CHANGE, ) -from .devices import CrownstoneDevice +from .devices import CrownstoneBaseEntity from .helpers import map_from_to if TYPE_CHECKING: from .entry_manager import CrownstoneEntryManager -_LOGGER = logging.getLogger(__name__) - async def async_setup_entry( hass: HomeAssistant, @@ -76,17 +68,18 @@ def hass_to_crownstone_state(value: int) -> int: return map_from_to(value, 0, 255, 0, 100) -class CrownstoneEntity(CrownstoneDevice, LightEntity): +class CrownstoneEntity(CrownstoneBaseEntity, LightEntity): """ Representation of a crownstone. Light platform is used to support dimming. """ - _attr_should_poll = False _attr_icon = "mdi:power-socket-de" - def __init__(self, crownstone_data: Crownstone, usb: CrownstoneUart = None) -> None: + def __init__( + self, crownstone_data: Crownstone, usb: CrownstoneUart | None = None + ) -> None: """Initialize the crownstone.""" super().__init__(crownstone_data) self.usb = usb @@ -94,11 +87,6 @@ class CrownstoneEntity(CrownstoneDevice, LightEntity): self._attr_name = str(self.device.name) self._attr_unique_id = f"{self.cloud_id}-{CROWNSTONE_SUFFIX}" - @property - def usb_available(self) -> bool: - """Return if this entity can use a usb dongle.""" - return self.usb is not None and self.usb.is_ready() - @property def brightness(self) -> int | None: """Return the brightness if dimming enabled.""" @@ -116,29 +104,6 @@ class CrownstoneEntity(CrownstoneDevice, LightEntity): return SUPPORT_BRIGHTNESS return 0 - @property - def extra_state_attributes(self) -> Mapping[str, Any] | None: - """State attributes for Crownstone devices.""" - attributes: dict[str, Any] = {} - # switch method - if self.usb_available: - attributes["switch_method"] = "Crownstone USB Dongle" - else: - attributes["switch_method"] = "Crownstone Cloud" - - # crownstone abilities - attributes["dimming"] = ABILITY_STATE.get( - self.device.abilities.get(DIMMING_ABILITY).is_enabled - ) - attributes["tap_to_toggle"] = ABILITY_STATE.get( - self.device.abilities.get(TAP_TO_TOGGLE_ABILITY).is_enabled - ) - attributes["switchcraft"] = ABILITY_STATE.get( - self.device.abilities.get(SWITCHCRAFT_ABILITY).is_enabled - ) - - return attributes - async def async_added_to_hass(self) -> None: """Set up a listener when this entity is added to HA.""" # new state received @@ -157,7 +122,7 @@ class CrownstoneEntity(CrownstoneDevice, LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn on this light via dongle or cloud.""" if ATTR_BRIGHTNESS in kwargs: - if self.usb_available: + if self.usb is not None and self.usb.is_ready(): await self.hass.async_add_executor_job( partial( self.usb.dim_crownstone, @@ -171,14 +136,13 @@ class CrownstoneEntity(CrownstoneDevice, LightEntity): hass_to_crownstone_state(kwargs[ATTR_BRIGHTNESS]) ) except CrownstoneAbilityError as ability_error: - _LOGGER.error(ability_error) - return + raise HomeAssistantError(ability_error) from ability_error # assume brightness is set on device self.device.state = hass_to_crownstone_state(kwargs[ATTR_BRIGHTNESS]) self.async_write_ha_state() - elif self.usb_available: + elif self.usb is not None and self.usb.is_ready(): await self.hass.async_add_executor_job( partial(self.usb.switch_crownstone, self.device.unique_id, on=True) ) @@ -192,7 +156,7 @@ class CrownstoneEntity(CrownstoneDevice, LightEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn off this device via dongle or cloud.""" - if self.usb_available: + if self.usb is not None and self.usb.is_ready(): await self.hass.async_add_executor_job( partial(self.usb.switch_crownstone, self.device.unique_id, on=False) ) diff --git a/homeassistant/components/crownstone/listeners.py b/homeassistant/components/crownstone/listeners.py index ae316bc0029..63891545cab 100644 --- a/homeassistant/components/crownstone/listeners.py +++ b/homeassistant/components/crownstone/listeners.py @@ -9,6 +9,7 @@ from __future__ import annotations from functools import partial from typing import TYPE_CHECKING, cast +from crownstone_cloud.exceptions import CrownstoneNotFoundError from crownstone_core.packets.serviceDataParsers.containers.AdvExternalCrownstoneState import ( AdvExternalCrownstoneState, ) @@ -25,8 +26,12 @@ from crownstone_sse.events import AbilityChangeEvent, SwitchStateUpdateEvent from crownstone_uart import UartEventBus, UartTopics from crownstone_uart.topics.SystemTopics import SystemTopics -from homeassistant.core import Event, callback -from homeassistant.helpers.dispatcher import async_dispatcher_send, dispatcher_send +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, + dispatcher_send, +) from .const import ( DOMAIN, @@ -42,13 +47,12 @@ if TYPE_CHECKING: @callback def async_update_crwn_state_sse( - manager: CrownstoneEntryManager, ha_event: Event + manager: CrownstoneEntryManager, switch_event: SwitchStateUpdateEvent ) -> None: """Update the state of a Crownstone when switched externally.""" - switch_event = SwitchStateUpdateEvent(ha_event.data) try: updated_crownstone = manager.cloud.get_crownstone_by_id(switch_event.cloud_id) - except KeyError: + except CrownstoneNotFoundError: return # only update on change. @@ -58,12 +62,13 @@ def async_update_crwn_state_sse( @callback -def async_update_crwn_ability(manager: CrownstoneEntryManager, ha_event: Event) -> None: +def async_update_crwn_ability( + manager: CrownstoneEntryManager, ability_event: AbilityChangeEvent +) -> None: """Update the ability information of a Crownstone.""" - ability_event = AbilityChangeEvent(ha_event.data) try: updated_crownstone = manager.cloud.get_crownstone_by_id(ability_event.cloud_id) - except KeyError: + except CrownstoneNotFoundError: return ability_type = ability_event.ability_type @@ -100,7 +105,7 @@ def update_crwn_state_uart( updated_crownstone = manager.cloud.get_crownstone_by_uid( data.crownstoneId, manager.usb_sphere_id ) - except KeyError: + except CrownstoneNotFoundError: return if data.switchState is None: @@ -117,11 +122,13 @@ def setup_sse_listeners(manager: CrownstoneEntryManager) -> None: """Set up SSE listeners.""" # save unsub function for when entry removed manager.listeners[SSE_LISTENERS] = [ - manager.hass.bus.async_listen( + async_dispatcher_connect( + manager.hass, f"{DOMAIN}_{EVENT_SWITCH_STATE_UPDATE}", partial(async_update_crwn_state_sse, manager), ), - manager.hass.bus.async_listen( + async_dispatcher_connect( + manager.hass, f"{DOMAIN}_{EVENT_ABILITY_CHANGE}", partial(async_update_crwn_ability, manager), ), diff --git a/homeassistant/components/crownstone/manifest.json b/homeassistant/components/crownstone/manifest.json index a7caa6a8d7f..4615d0b0329 100644 --- a/homeassistant/components/crownstone/manifest.json +++ b/homeassistant/components/crownstone/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/crownstone", "requirements": [ - "crownstone-cloud==1.4.7", + "crownstone-cloud==1.4.8", "crownstone-sse==2.0.2", "crownstone-uart==2.1.0", "pyserial==3.5" diff --git a/homeassistant/components/crownstone/strings.json b/homeassistant/components/crownstone/strings.json index 7437d458ea7..25c9fd10293 100644 --- a/homeassistant/components/crownstone/strings.json +++ b/homeassistant/components/crownstone/strings.json @@ -49,21 +49,21 @@ "usb_sphere_option": "Crownstone Sphere where the USB is located" } }, - "usb_config_option": { + "usb_config": { "data": { "usb_path": "[%key:common::config_flow::data::usb_path%]" }, "title": "Crownstone USB dongle configuration", "description": "Select the serial port of the Crownstone USB dongle.\n\nLook for a device with VID 10C4 and PID EA60." }, - "usb_manual_config_option": { + "usb_manual_config": { "data": { "usb_manual_path": "[%key:common::config_flow::data::usb_path%]" }, "title": "Crownstone USB dongle manual path", "description": "Manually enter the path of a Crownstone USB dongle." }, - "usb_sphere_config_option": { + "usb_sphere_config": { "data": { "usb_sphere": "Crownstone Sphere" }, diff --git a/homeassistant/components/crownstone/translations/en.json b/homeassistant/components/crownstone/translations/en.json index e8b552ba53c..09a26b9739c 100644 --- a/homeassistant/components/crownstone/translations/en.json +++ b/homeassistant/components/crownstone/translations/en.json @@ -49,21 +49,21 @@ "use_usb_option": "Use a Crownstone USB dongle for local data transmission" } }, - "usb_config_option": { + "usb_config": { "data": { "usb_path": "USB Device Path" }, "description": "Select the serial port of the Crownstone USB dongle.\n\nLook for a device with VID 10C4 and PID EA60.", "title": "Crownstone USB dongle configuration" }, - "usb_manual_config_option": { + "usb_manual_config": { "data": { "usb_manual_path": "USB Device Path" }, "description": "Manually enter the path of a Crownstone USB dongle.", "title": "Crownstone USB dongle manual path" }, - "usb_sphere_config_option": { + "usb_sphere_config": { "data": { "usb_sphere": "Crownstone Sphere" }, diff --git a/requirements_all.txt b/requirements_all.txt index 6a071858cf7..e0ec5274654 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -490,7 +490,7 @@ coronavirus==1.1.1 croniter==1.0.6 # homeassistant.components.crownstone -crownstone-cloud==1.4.7 +crownstone-cloud==1.4.8 # homeassistant.components.crownstone crownstone-sse==2.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0bef36a9971..c38966e64fd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -292,7 +292,7 @@ coronavirus==1.1.1 croniter==1.0.6 # homeassistant.components.crownstone -crownstone-cloud==1.4.7 +crownstone-cloud==1.4.8 # homeassistant.components.crownstone crownstone-sse==2.0.2 diff --git a/tests/components/crownstone/test_config_flow.py b/tests/components/crownstone/test_config_flow.py index 227657a65a2..7b05c8ba530 100644 --- a/tests/components/crownstone/test_config_flow.py +++ b/tests/components/crownstone/test_config_flow.py @@ -1,6 +1,7 @@ """Tests for the Crownstone integration.""" from __future__ import annotations +from typing import Generator, Union from unittest.mock import AsyncMock, MagicMock, patch from crownstone_cloud.cloud_models.spheres import Spheres @@ -28,14 +29,45 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry +MockFixture = Generator[Union[MagicMock, AsyncMock], None, None] -@pytest.fixture(name="crownstone_setup", autouse=True) -def crownstone_setup(): + +@pytest.fixture(name="crownstone_setup") +def crownstone_setup() -> MockFixture: """Mock Crownstone entry setup.""" with patch( "homeassistant.components.crownstone.async_setup_entry", return_value=True - ): - yield + ) as setup_mock: + yield setup_mock + + +@pytest.fixture(name="pyserial_comports") +def usb_comports() -> MockFixture: + """Mock pyserial comports.""" + with patch( + "serial.tools.list_ports.comports", + MagicMock(return_value=[get_mocked_com_port()]), + ) as comports_mock: + yield comports_mock + + +@pytest.fixture(name="usb_path") +def usb_path() -> MockFixture: + """Mock usb serial path.""" + with patch( + "homeassistant.components.usb.get_serial_by_id", + return_value="/dev/serial/by-id/crownstone-usb", + ) as usb_path_mock: + yield usb_path_mock + + +def get_mocked_crownstone_entry_manager(mocked_cloud: MagicMock): + """Get a mocked CrownstoneEntryManager instance.""" + mocked_entry_manager = MagicMock() + mocked_entry_manager.async_setup = AsyncMock(return_value=True) + mocked_entry_manager.cloud = mocked_cloud + + return mocked_entry_manager def get_mocked_crownstone_cloud(spheres: dict[str, MagicMock] | None = None): @@ -43,7 +75,7 @@ def get_mocked_crownstone_cloud(spheres: dict[str, MagicMock] | None = None): mock_cloud = MagicMock() mock_cloud.async_initialize = AsyncMock() mock_cloud.cloud_data = Spheres(MagicMock(), "account_id") - mock_cloud.cloud_data.spheres = spheres + mock_cloud.cloud_data.data = spheres return mock_cloud @@ -101,26 +133,26 @@ async def start_config_flow(hass: HomeAssistant, mocked_cloud: MagicMock): "homeassistant.components.crownstone.config_flow.CrownstoneCloud", return_value=mocked_cloud, ): - result = await hass.config_entries.flow.async_init( + return await hass.config_entries.flow.async_init( DOMAIN, context={"source": "user"}, data=mocked_login_input ) - return result - async def start_options_flow( - hass: HomeAssistant, entry_id: str, mocked_cloud: MagicMock + hass: HomeAssistant, entry_id: str, mocked_manager: MagicMock ): """Patch CrownstoneEntryManager and start the flow.""" - mocked_manager = MagicMock() - mocked_manager.cloud = mocked_cloud - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry_id] = mocked_manager + # set up integration + with patch( + "homeassistant.components.crownstone.CrownstoneEntryManager", + return_value=mocked_manager, + ): + await hass.config_entries.async_setup(entry_id) return await hass.config_entries.options.async_init(entry_id) -async def test_no_user_input(hass: HomeAssistant): +async def test_no_user_input(crownstone_setup: MockFixture, hass: HomeAssistant): """Test the flow done in the correct way.""" # test if a form is returned if no input is provided result = await hass.config_entries.flow.async_init( @@ -129,9 +161,10 @@ async def test_no_user_input(hass: HomeAssistant): # show the login form assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" + assert crownstone_setup.call_count == 0 -async def test_abort_if_configured(hass: HomeAssistant): +async def test_abort_if_configured(crownstone_setup: MockFixture, hass: HomeAssistant): """Test flow with correct login input and abort if sphere already configured.""" # create mock entry conf configured_entry_data = create_mocked_entry_data_conf( @@ -156,9 +189,12 @@ async def test_abort_if_configured(hass: HomeAssistant): # test if we abort if we try to configure the same entry assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "already_configured" + assert crownstone_setup.call_count == 0 -async def test_authentication_errors(hass: HomeAssistant): +async def test_authentication_errors( + crownstone_setup: MockFixture, hass: HomeAssistant +): """Test flow with wrong auth errors.""" cloud = get_mocked_crownstone_cloud() # side effect: auth error login failed @@ -180,9 +216,10 @@ async def test_authentication_errors(hass: HomeAssistant): assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["errors"] == {"base": "account_not_verified"} + assert crownstone_setup.call_count == 0 -async def test_unknown_error(hass: HomeAssistant): +async def test_unknown_error(crownstone_setup: MockFixture, hass: HomeAssistant): """Test flow with unknown error.""" cloud = get_mocked_crownstone_cloud() # side effect: unknown error @@ -192,9 +229,12 @@ async def test_unknown_error(hass: HomeAssistant): assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["errors"] == {"base": "unknown_error"} + assert crownstone_setup.call_count == 0 -async def test_successful_login_no_usb(hass: HomeAssistant): +async def test_successful_login_no_usb( + crownstone_setup: MockFixture, hass: HomeAssistant +): """Test a successful login without configuring a USB.""" entry_data_without_usb = create_mocked_entry_data_conf( email="example@homeassistant.com", @@ -217,16 +257,15 @@ async def test_successful_login_no_usb(hass: HomeAssistant): assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["data"] == entry_data_without_usb assert result["options"] == entry_options_without_usb + assert crownstone_setup.call_count == 1 -@patch( - "serial.tools.list_ports.comports", MagicMock(return_value=[get_mocked_com_port()]) -) -@patch( - "homeassistant.components.usb.get_serial_by_id", - return_value="/dev/serial/by-id/crownstone-usb", -) -async def test_successful_login_with_usb(serial_mock: MagicMock, hass: HomeAssistant): +async def test_successful_login_with_usb( + crownstone_setup: MockFixture, + pyserial_comports: MockFixture, + usb_path: MockFixture, + hass: HomeAssistant, +): """Test flow with correct login and usb configuration.""" entry_data_with_usb = create_mocked_entry_data_conf( email="example@homeassistant.com", @@ -243,6 +282,7 @@ async def test_successful_login_with_usb(serial_mock: MagicMock, hass: HomeAssis # should show usb form assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "usb_config" + assert pyserial_comports.call_count == 1 # create a mocked port port = get_mocked_com_port() @@ -261,7 +301,8 @@ async def test_successful_login_with_usb(serial_mock: MagicMock, hass: HomeAssis ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "usb_sphere_config" - assert serial_mock.call_count == 1 + assert pyserial_comports.call_count == 2 + assert usb_path.call_count == 1 # select a sphere result = await hass.config_entries.flow.async_configure( @@ -270,12 +311,12 @@ async def test_successful_login_with_usb(serial_mock: MagicMock, hass: HomeAssis assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["data"] == entry_data_with_usb assert result["options"] == entry_options_with_usb + assert crownstone_setup.call_count == 1 -@patch( - "serial.tools.list_ports.comports", MagicMock(return_value=[get_mocked_com_port()]) -) -async def test_successful_login_with_manual_usb_path(hass: HomeAssistant): +async def test_successful_login_with_manual_usb_path( + crownstone_setup: MockFixture, pyserial_comports: MockFixture, hass: HomeAssistant +): """Test flow with correct login and usb configuration.""" entry_data_with_manual_usb = create_mocked_entry_data_conf( email="example@homeassistant.com", @@ -292,6 +333,7 @@ async def test_successful_login_with_manual_usb_path(hass: HomeAssistant): # should show usb form assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "usb_config" + assert pyserial_comports.call_count == 1 # select manual from the list result = await hass.config_entries.flow.async_configure( @@ -300,6 +342,7 @@ async def test_successful_login_with_manual_usb_path(hass: HomeAssistant): assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "usb_manual_config" + assert pyserial_comports.call_count == 2 # enter USB path path = "/dev/crownstone-usb" @@ -312,16 +355,12 @@ async def test_successful_login_with_manual_usb_path(hass: HomeAssistant): assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["data"] == entry_data_with_manual_usb assert result["options"] == entry_options_with_manual_usb + assert crownstone_setup.call_count == 1 -@patch( - "serial.tools.list_ports.comports", MagicMock(return_value=[get_mocked_com_port()]) -) -@patch( - "homeassistant.components.usb.get_serial_by_id", - return_value="/dev/serial/by-id/crownstone-usb", -) -async def test_options_flow_setup_usb(serial_mock: MagicMock, hass: HomeAssistant): +async def test_options_flow_setup_usb( + pyserial_comports: MockFixture, usb_path: MockFixture, hass: HomeAssistant +): """Test options flow init.""" configured_entry_data = create_mocked_entry_data_conf( email="example@homeassistant.com", @@ -342,7 +381,11 @@ async def test_options_flow_setup_usb(serial_mock: MagicMock, hass: HomeAssistan entry.add_to_hass(hass) result = await start_options_flow( - hass, entry.entry_id, get_mocked_crownstone_cloud(create_mocked_spheres(2)) + hass, + entry.entry_id, + get_mocked_crownstone_entry_manager( + get_mocked_crownstone_cloud(create_mocked_spheres(2)) + ), ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM @@ -360,7 +403,8 @@ async def test_options_flow_setup_usb(serial_mock: MagicMock, hass: HomeAssistan result["flow_id"], user_input={CONF_USE_USB_OPTION: True} ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "usb_config_option" + assert result["step_id"] == "usb_config" + assert pyserial_comports.call_count == 1 # create a mocked port port = get_mocked_com_port() @@ -378,8 +422,9 @@ async def test_options_flow_setup_usb(serial_mock: MagicMock, hass: HomeAssistan result["flow_id"], user_input={CONF_USB_PATH: port_select} ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "usb_sphere_config_option" - assert serial_mock.call_count == 1 + assert result["step_id"] == "usb_sphere_config" + assert pyserial_comports.call_count == 2 + assert usb_path.call_count == 1 # select a sphere result = await hass.config_entries.options.async_configure( @@ -412,7 +457,11 @@ async def test_options_flow_remove_usb(hass: HomeAssistant): entry.add_to_hass(hass) result = await start_options_flow( - hass, entry.entry_id, get_mocked_crownstone_cloud(create_mocked_spheres(2)) + hass, + entry.entry_id, + get_mocked_crownstone_entry_manager( + get_mocked_crownstone_cloud(create_mocked_spheres(2)) + ), ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM @@ -438,10 +487,9 @@ async def test_options_flow_remove_usb(hass: HomeAssistant): ) -@patch( - "serial.tools.list_ports.comports", MagicMock(return_value=[get_mocked_com_port()]) -) -async def test_options_flow_manual_usb_path(hass: HomeAssistant): +async def test_options_flow_manual_usb_path( + pyserial_comports: MockFixture, hass: HomeAssistant +): """Test flow with correct login and usb configuration.""" configured_entry_data = create_mocked_entry_data_conf( email="example@homeassistant.com", @@ -462,7 +510,11 @@ async def test_options_flow_manual_usb_path(hass: HomeAssistant): entry.add_to_hass(hass) result = await start_options_flow( - hass, entry.entry_id, get_mocked_crownstone_cloud(create_mocked_spheres(1)) + hass, + entry.entry_id, + get_mocked_crownstone_entry_manager( + get_mocked_crownstone_cloud(create_mocked_spheres(1)) + ), ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM @@ -472,7 +524,8 @@ async def test_options_flow_manual_usb_path(hass: HomeAssistant): result["flow_id"], user_input={CONF_USE_USB_OPTION: True} ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "usb_config_option" + assert result["step_id"] == "usb_config" + assert pyserial_comports.call_count == 1 # select manual from the list result = await hass.config_entries.options.async_configure( @@ -480,7 +533,8 @@ async def test_options_flow_manual_usb_path(hass: HomeAssistant): ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "usb_manual_config_option" + assert result["step_id"] == "usb_manual_config" + assert pyserial_comports.call_count == 2 # enter USB path path = "/dev/crownstone-usb" @@ -515,7 +569,11 @@ async def test_options_flow_change_usb_sphere(hass: HomeAssistant): entry.add_to_hass(hass) result = await start_options_flow( - hass, entry.entry_id, get_mocked_crownstone_cloud(create_mocked_spheres(3)) + hass, + entry.entry_id, + get_mocked_crownstone_entry_manager( + get_mocked_crownstone_cloud(create_mocked_spheres(3)) + ), ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM From f0a4a89d212f0c8f7311be5ab9b2ec99f3c26964 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 23 Sep 2021 13:14:45 +0200 Subject: [PATCH 537/843] Add comments to recorder statistics code (#56545) * Add comments to recorder statistics code * Revert accidental change of list_statistic_ids --- .../components/recorder/statistics.py | 79 +++++++++++++++---- 1 file changed, 63 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index b3fce440108..74d27282ea8 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -7,7 +7,7 @@ import dataclasses from datetime import datetime, timedelta from itertools import groupby import logging -from typing import TYPE_CHECKING, Any, Callable +from typing import TYPE_CHECKING, Any, Callable, Literal from sqlalchemy import bindparam, func from sqlalchemy.exc import SQLAlchemyError @@ -205,7 +205,13 @@ def _update_or_add_metadata( session: scoped_session, new_metadata: StatisticMetaData, ) -> str: - """Get metadata_id for a statistic_id, add if it doesn't exist.""" + """Get metadata_id for a statistic_id. + + If the statistic_id is previously unknown, add it. If it's already known, update + metadata if needed. + + Updating metadata source is not possible. + """ statistic_id = new_metadata["statistic_id"] old_metadata_dict = _get_metadata(hass, session, [statistic_id], None) if not old_metadata_dict: @@ -250,10 +256,16 @@ def _update_or_add_metadata( def compile_hourly_statistics( instance: Recorder, session: scoped_session, start: datetime ) -> None: - """Compile hourly statistics.""" + """Compile hourly statistics. + + This will summarize 5-minute statistics for one hour: + - average, min max is computed by a database query + - sum is taken from the last 5-minute entry during the hour + """ start_time = start.replace(minute=0) end_time = start_time + timedelta(hours=1) - # Get last hour's average, min, max + + # Compute last hour's average, min, max summary: dict[str, StatisticData] = {} baked_query = instance.hass.data[STATISTICS_SHORT_TERM_BAKERY]( lambda session: session.query(*QUERY_STATISTICS_SUMMARY_MEAN) @@ -280,7 +292,7 @@ def compile_hourly_statistics( "max": _max, } - # Get last hour's sum + # Get last hour's last sum subquery = ( session.query(*QUERY_STATISTICS_SUMMARY_SUM) .filter(StatisticsShortTerm.start >= bindparam("start_time")) @@ -315,16 +327,21 @@ def compile_hourly_statistics( "sum_increase": sum_increase, } + # Insert compiled hourly statistics in the database for metadata_id, stat in summary.items(): session.add(Statistics.from_stats(metadata_id, stat)) @retryable_database_job("statistics") def compile_statistics(instance: Recorder, start: datetime) -> bool: - """Compile statistics.""" + """Compile 5-minute statistics for all integrations with a recorder platform. + + The actual calculation is delegated to the platforms. + """ start = dt_util.as_utc(start) end = start + timedelta(minutes=5) + # Return if we already have 5-minute statistics for the requested period with session_scope(session=instance.get_session()) as session: # type: ignore if session.query(StatisticsRuns).filter_by(start=start).first(): _LOGGER.debug("Statistics already compiled for %s-%s", start, end) @@ -332,6 +349,7 @@ def compile_statistics(instance: Recorder, start: datetime) -> bool: _LOGGER.debug("Compiling statistics for %s-%s", start, end) platform_stats: list[StatisticResult] = [] + # Collect statistics from all platforms implementing support for domain, platform in instance.hass.data[DOMAIN].items(): if not hasattr(platform, "compile_statistics"): continue @@ -341,6 +359,7 @@ def compile_statistics(instance: Recorder, start: datetime) -> bool: ) platform_stats.extend(platform_stat) + # Insert collected statistics in the database with session_scope(session=instance.get_session()) as session: # type: ignore for stats in platform_stats: metadata_id = _update_or_add_metadata(instance.hass, session, stats["meta"]) @@ -367,9 +386,13 @@ def _get_metadata( hass: HomeAssistant, session: scoped_session, statistic_ids: list[str] | None, - statistic_type: str | None, + statistic_type: Literal["mean"] | Literal["sum"] | None, ) -> dict[str, StatisticMetaData]: - """Fetch meta data.""" + """Fetch meta data, returns a dict of StatisticMetaData indexed by statistic_id. + + If statistic_ids is given, fetch metadata only for the listed statistics_ids. + If statistic_type is given, fetch metadata only for statistic_ids supporting it. + """ def _meta(metas: list, wanted_metadata_id: str) -> StatisticMetaData | None: meta: StatisticMetaData | None = None @@ -383,6 +406,7 @@ def _get_metadata( } return meta + # Fetch metatadata from the database baked_query = hass.data[STATISTICS_META_BAKERY]( lambda session: session.query(*QUERY_STATISTIC_META) ) @@ -394,13 +418,12 @@ def _get_metadata( baked_query += lambda q: q.filter(StatisticsMeta.has_mean.isnot(False)) elif statistic_type == "sum": baked_query += lambda q: q.filter(StatisticsMeta.has_sum.isnot(False)) - elif statistic_type is not None: - return {} result = execute(baked_query(session).params(statistic_ids=statistic_ids)) if not result: return {} metadata_ids = [metadata[0] for metadata in result] + # Prepare the result dict metadata: dict[str, StatisticMetaData] = {} for _id in metadata_ids: meta = _meta(result, _id) @@ -436,17 +459,26 @@ def _configured_unit(unit: str, units: UnitSystem) -> str: def list_statistic_ids( - hass: HomeAssistant, statistic_type: str | None = None + hass: HomeAssistant, + statistic_type: Literal["mean"] | Literal["sum"] | None = None, ) -> list[dict | None]: - """Return statistic_ids and meta data.""" + """Return all statistic_ids and unit of measurement. + + Queries the database for existing statistic_ids, as well as integrations with + a recorder platform for statistic_ids which will be added in the next statistics + period. + """ units = hass.config.units statistic_ids = {} + + # Query the database with session_scope(hass=hass) as session: metadata = _get_metadata(hass, session, None, statistic_type) for meta in metadata.values(): unit = meta["unit_of_measurement"] if unit is not None: + # Display unit according to user settings unit = _configured_unit(unit, units) meta["unit_of_measurement"] = unit @@ -455,6 +487,7 @@ def list_statistic_ids( for meta in metadata.values() } + # Query all integrations with a registered recorder platform for platform in hass.data[DOMAIN].values(): if not hasattr(platform, "list_statistic_ids"): continue @@ -462,11 +495,13 @@ def list_statistic_ids( for statistic_id, unit in platform_statistic_ids.items(): if unit is not None: + # Display unit according to user settings unit = _configured_unit(unit, units) platform_statistic_ids[statistic_id] = unit statistic_ids = {**statistic_ids, **platform_statistic_ids} + # Return a map of statistic_id to unit_of_measurement return [ {"statistic_id": _id, "unit_of_measurement": unit} for _id, unit in statistic_ids.items() @@ -481,6 +516,10 @@ def _statistics_during_period_query( base_query: Iterable, table: type[Statistics | StatisticsShortTerm], ) -> Callable: + """Prepare a database query for statistics during a given period. + + This prepares a baked query, so we don't insert the parameters yet. + """ baked_query = hass.data[bakery](lambda session: session.query(*base_query)) baked_query += lambda q: q.filter(table.start >= bindparam("start_time")) @@ -502,11 +541,16 @@ def statistics_during_period( start_time: datetime, end_time: datetime | None = None, statistic_ids: list[str] | None = None, - period: str = "hour", + period: Literal["hour"] | Literal["5minute"] = "hour", ) -> dict[str, list[dict[str, str]]]: - """Return states changes during UTC period start_time - end_time.""" + """Return statistics during UTC period start_time - end_time for the statistic_ids. + + If end_time is omitted, returns statistics newer than or equal to start_time. + If statistic_ids is omitted, returns statistics for all statistics ids. + """ metadata = None with session_scope(hass=hass) as session: + # Fetch metadata for the given (or all) statistic_ids metadata = _get_metadata(hass, session, statistic_ids, None) if not metadata: return {} @@ -535,6 +579,7 @@ def statistics_during_period( ) if not stats: return {} + # Return statistics combined with metadata return _sorted_statistics_to_dict( hass, stats, statistic_ids, metadata, True, table.duration ) @@ -543,9 +588,10 @@ def statistics_during_period( def get_last_statistics( hass: HomeAssistant, number_of_stats: int, statistic_id: str, convert_units: bool ) -> dict[str, list[dict]]: - """Return the last number_of_stats statistics for a statistic_id.""" + """Return the last number_of_stats statistics for a given statistic_id.""" statistic_ids = [statistic_id] with session_scope(hass=hass) as session: + # Fetch metadata for the given statistic_id metadata = _get_metadata(hass, session, statistic_ids, None) if not metadata: return {} @@ -571,6 +617,7 @@ def get_last_statistics( if not stats: return {} + # Return statistics combined with metadata return _sorted_statistics_to_dict( hass, stats, @@ -602,7 +649,7 @@ def _sorted_statistics_to_dict( for stat_id in statistic_ids: result[stat_id] = [] - # Append all statistic entries, and do unit conversion + # Append all statistic entries, and optionally do unit conversion for meta_id, group in groupby(stats, lambda stat: stat.metadata_id): # type: ignore unit = metadata[meta_id]["unit_of_measurement"] statistic_id = metadata[meta_id]["statistic_id"] From a6ccb1821e275d33e6392463f1f7024fc38610d7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Sep 2021 08:00:17 -0500 Subject: [PATCH 538/843] Update zeroconf to 0.36.7 (#56553) --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index da413b142b8..e38a8d92a94 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -2,7 +2,7 @@ "domain": "zeroconf", "name": "Zero-configuration networking (zeroconf)", "documentation": "https://www.home-assistant.io/integrations/zeroconf", - "requirements": ["zeroconf==0.36.6"], + "requirements": ["zeroconf==0.36.7"], "dependencies": ["network", "api"], "codeowners": ["@bdraco"], "quality_scale": "internal", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 7790e7859bc..36dd38f7473 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ sqlalchemy==1.4.23 voluptuous-serialize==2.4.0 voluptuous==0.12.1 yarl==1.6.3 -zeroconf==0.36.6 +zeroconf==0.36.7 pycryptodome>=3.6.6 diff --git a/requirements_all.txt b/requirements_all.txt index e0ec5274654..7bffd045f8f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2466,7 +2466,7 @@ youtube_dl==2021.04.26 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.36.6 +zeroconf==0.36.7 # homeassistant.components.zha zha-quirks==0.0.61 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c38966e64fd..b0ec465b85f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1398,7 +1398,7 @@ yeelight==0.7.5 youless-api==0.12 # homeassistant.components.zeroconf -zeroconf==0.36.6 +zeroconf==0.36.7 # homeassistant.components.zha zha-quirks==0.0.61 From 655dc890e49a75277830595ea43e93227ab48898 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 23 Sep 2021 15:34:34 +0200 Subject: [PATCH 539/843] Upgrade PyTurboJPEG to 1.6.1 (#56571) --- homeassistant/components/camera/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/camera/manifest.json b/homeassistant/components/camera/manifest.json index 4c3ab704e1f..a8a834b60b3 100644 --- a/homeassistant/components/camera/manifest.json +++ b/homeassistant/components/camera/manifest.json @@ -3,7 +3,7 @@ "name": "Camera", "documentation": "https://www.home-assistant.io/integrations/camera", "dependencies": ["http"], - "requirements": ["PyTurboJPEG==1.5.2"], + "requirements": ["PyTurboJPEG==1.6.1"], "after_dependencies": ["media_player"], "codeowners": [], "quality_scale": "internal" diff --git a/requirements_all.txt b/requirements_all.txt index 7bffd045f8f..a7d4509dff3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -55,7 +55,7 @@ PySocks==1.7.1 PyTransportNSW==0.1.1 # homeassistant.components.camera -PyTurboJPEG==1.5.2 +PyTurboJPEG==1.6.1 # homeassistant.components.vicare PyViCare==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b0ec465b85f..254fa8f98c2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -30,7 +30,7 @@ PyRMVtransport==0.3.2 PyTransportNSW==0.1.1 # homeassistant.components.camera -PyTurboJPEG==1.5.2 +PyTurboJPEG==1.6.1 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.13.4 From 71ab24f3503afc50c54c88ae58dfaa3187653af3 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 23 Sep 2021 16:11:29 +0200 Subject: [PATCH 540/843] Upgrade pre-commit to 2.14.1 (#56569) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index ce122687c33..4d10ef8e031 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -12,7 +12,7 @@ coverage==5.5 jsonpickle==1.4.1 mock-open==1.4.0 mypy==0.910 -pre-commit==2.14.0 +pre-commit==2.14.1 pylint==2.11.1 pipdeptree==1.0.0 pylint-strict-informational==0.1 From bdef13129436cc40aeac086d792b3341635ec641 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 23 Sep 2021 16:11:47 +0200 Subject: [PATCH 541/843] Upgrade watchdog to 2.1.5 (#56572) --- homeassistant/components/folder_watcher/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/folder_watcher/manifest.json b/homeassistant/components/folder_watcher/manifest.json index 9a7967d22cb..a8b084eb801 100644 --- a/homeassistant/components/folder_watcher/manifest.json +++ b/homeassistant/components/folder_watcher/manifest.json @@ -2,7 +2,7 @@ "domain": "folder_watcher", "name": "Folder Watcher", "documentation": "https://www.home-assistant.io/integrations/folder_watcher", - "requirements": ["watchdog==2.1.4"], + "requirements": ["watchdog==2.1.5"], "codeowners": [], "quality_scale": "internal", "iot_class": "local_polling" diff --git a/requirements_all.txt b/requirements_all.txt index a7d4509dff3..b79e3423451 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2395,7 +2395,7 @@ wallbox==0.4.4 waqiasync==1.0.0 # homeassistant.components.folder_watcher -watchdog==2.1.4 +watchdog==2.1.5 # homeassistant.components.waterfurnace waterfurnace==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 254fa8f98c2..e1571e156fd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1354,7 +1354,7 @@ wakeonlan==2.0.1 wallbox==0.4.4 # homeassistant.components.folder_watcher -watchdog==2.1.4 +watchdog==2.1.5 # homeassistant.components.whirlpool whirlpool-sixth-sense==0.15.1 From 237efcf6b1e5f6527259df21a44997bb3a252cd0 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 23 Sep 2021 16:12:13 +0200 Subject: [PATCH 542/843] Upgrade colorlog to 6.4.1 (#56573) --- homeassistant/scripts/check_config.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index 0ff339169a7..ac3208aad19 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -21,7 +21,7 @@ import homeassistant.util.yaml.loader as yaml_loader # mypy: allow-untyped-calls, allow-untyped-defs -REQUIREMENTS = ("colorlog==5.0.1",) +REQUIREMENTS = ("colorlog==6.4.1",) _LOGGER = logging.getLogger(__name__) # pylint: disable=protected-access diff --git a/requirements_all.txt b/requirements_all.txt index b79e3423451..59bd62b9bf8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -467,7 +467,7 @@ co2signal==0.4.2 coinbase==2.1.0 # homeassistant.scripts.check_config -colorlog==5.0.1 +colorlog==6.4.1 # homeassistant.components.color_extractor colorthief==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e1571e156fd..d038606e866 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -275,7 +275,7 @@ co2signal==0.4.2 coinbase==2.1.0 # homeassistant.scripts.check_config -colorlog==5.0.1 +colorlog==6.4.1 # homeassistant.components.color_extractor colorthief==0.2.1 From 442d850fc90f1959e5f10b536f5a5f4703f682bb Mon Sep 17 00:00:00 2001 From: Jc2k Date: Thu, 23 Sep 2021 15:30:15 +0100 Subject: [PATCH 543/843] Bump aiohomekit to 0.6.3 (#56574) --- homeassistant/components/homekit_controller/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index 442db645c1f..3a07ae7ec8b 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -3,7 +3,7 @@ "name": "HomeKit Controller", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/homekit_controller", - "requirements": ["aiohomekit==0.6.2"], + "requirements": ["aiohomekit==0.6.3"], "zeroconf": ["_hap._tcp.local."], "after_dependencies": ["zeroconf"], "codeowners": ["@Jc2k", "@bdraco"], diff --git a/requirements_all.txt b/requirements_all.txt index 59bd62b9bf8..1438d77ef62 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -182,7 +182,7 @@ aioguardian==1.0.8 aioharmony==0.2.7 # homeassistant.components.homekit_controller -aiohomekit==0.6.2 +aiohomekit==0.6.3 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d038606e866..a2a4cb8f14a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -121,7 +121,7 @@ aioguardian==1.0.8 aioharmony==0.2.7 # homeassistant.components.homekit_controller -aiohomekit==0.6.2 +aiohomekit==0.6.3 # homeassistant.components.emulated_hue # homeassistant.components.http From c1df49e9fc33f20706c28ba1869daf76117491fb Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 23 Sep 2021 16:33:24 +0200 Subject: [PATCH 544/843] Upgrade black to 21.9b0 (#56575) --- .pre-commit-config.yaml | 2 +- requirements_test_pre_commit.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 38ba2a503af..29a91eed6b5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,7 +5,7 @@ repos: - id: pyupgrade args: [--py38-plus] - repo: https://github.com/psf/black - rev: 21.7b0 + rev: 21.9b0 hooks: - id: black args: diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index e89785c25a8..4c57d6cabaa 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,7 +1,7 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit bandit==1.7.0 -black==21.7b0 +black==21.9b0 codespell==2.0.0 flake8-comprehensions==3.5.0 flake8-docstrings==1.6.0 From b634bd26d034169825dea58b67d2d2410c835791 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 23 Sep 2021 16:42:55 +0200 Subject: [PATCH 545/843] Use EntityDescription - kraken (#56436) --- homeassistant/components/kraken/const.py | 134 +++++++++++++++---- homeassistant/components/kraken/sensor.py | 152 +++++----------------- 2 files changed, 141 insertions(+), 145 deletions(-) diff --git a/homeassistant/components/kraken/const.py b/homeassistant/components/kraken/const.py index 2272d12ead6..669d64a49c8 100644 --- a/homeassistant/components/kraken/const.py +++ b/homeassistant/components/kraken/const.py @@ -1,17 +1,28 @@ """Constants for the kraken integration.""" - from __future__ import annotations -from typing import Dict, TypedDict +from dataclasses import dataclass +from typing import Callable, Dict, TypedDict -KrakenResponse = Dict[str, Dict[str, float]] +from homeassistant.components.sensor import SensorEntityDescription +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -class SensorType(TypedDict): - """SensorType class.""" +class KrakenResponseEntry(TypedDict): + """Dict describing a single response entry.""" - name: str - enabled_by_default: bool + ask: tuple[float, float, float] + bid: tuple[float, float, float] + last_trade_closed: tuple[float, float] + volume: tuple[float, float] + volume_weighted_average: tuple[float, float] + number_of_trades: tuple[int, int] + low: tuple[float, float] + high: tuple[float, float] + opening_price: float + + +KrakenResponse = Dict[str, KrakenResponseEntry] DEFAULT_SCAN_INTERVAL = 60 @@ -22,21 +33,94 @@ CONF_TRACKED_ASSET_PAIRS = "tracked_asset_pairs" DOMAIN = "kraken" -SENSOR_TYPES: list[SensorType] = [ - {"name": "ask", "enabled_by_default": True}, - {"name": "ask_volume", "enabled_by_default": False}, - {"name": "bid", "enabled_by_default": True}, - {"name": "bid_volume", "enabled_by_default": False}, - {"name": "volume_today", "enabled_by_default": False}, - {"name": "volume_last_24h", "enabled_by_default": False}, - {"name": "volume_weighted_average_today", "enabled_by_default": False}, - {"name": "volume_weighted_average_last_24h", "enabled_by_default": False}, - {"name": "number_of_trades_today", "enabled_by_default": False}, - {"name": "number_of_trades_last_24h", "enabled_by_default": False}, - {"name": "last_trade_closed", "enabled_by_default": False}, - {"name": "low_today", "enabled_by_default": True}, - {"name": "low_last_24h", "enabled_by_default": False}, - {"name": "high_today", "enabled_by_default": True}, - {"name": "high_last_24h", "enabled_by_default": False}, - {"name": "opening_price_today", "enabled_by_default": False}, -] + +@dataclass +class KrakenRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[DataUpdateCoordinator[KrakenResponse], str], float | int] + + +@dataclass +class KrakenSensorEntityDescription(SensorEntityDescription, KrakenRequiredKeysMixin): + """Describes Kraken sensor entity.""" + + +SENSOR_TYPES: tuple[KrakenSensorEntityDescription, ...] = ( + KrakenSensorEntityDescription( + key="ask", + value_fn=lambda x, y: x.data[y]["ask"][0], + ), + KrakenSensorEntityDescription( + key="ask_volume", + value_fn=lambda x, y: x.data[y]["ask"][1], + entity_registry_enabled_default=False, + ), + KrakenSensorEntityDescription( + key="bid", + value_fn=lambda x, y: x.data[y]["bid"][0], + ), + KrakenSensorEntityDescription( + key="bid_volume", + value_fn=lambda x, y: x.data[y]["bid"][1], + entity_registry_enabled_default=False, + ), + KrakenSensorEntityDescription( + key="volume_today", + value_fn=lambda x, y: x.data[y]["volume"][0], + entity_registry_enabled_default=False, + ), + KrakenSensorEntityDescription( + key="volume_last_24h", + value_fn=lambda x, y: x.data[y]["volume"][1], + entity_registry_enabled_default=False, + ), + KrakenSensorEntityDescription( + key="volume_weighted_average_today", + value_fn=lambda x, y: x.data[y]["volume_weighted_average"][0], + entity_registry_enabled_default=False, + ), + KrakenSensorEntityDescription( + key="volume_weighted_average_last_24h", + value_fn=lambda x, y: x.data[y]["volume_weighted_average"][1], + entity_registry_enabled_default=False, + ), + KrakenSensorEntityDescription( + key="number_of_trades_today", + value_fn=lambda x, y: x.data[y]["number_of_trades"][0], + entity_registry_enabled_default=False, + ), + KrakenSensorEntityDescription( + key="number_of_trades_last_24h", + value_fn=lambda x, y: x.data[y]["number_of_trades"][1], + entity_registry_enabled_default=False, + ), + KrakenSensorEntityDescription( + key="last_trade_closed", + value_fn=lambda x, y: x.data[y]["last_trade_closed"][0], + entity_registry_enabled_default=False, + ), + KrakenSensorEntityDescription( + key="low_today", + value_fn=lambda x, y: x.data[y]["low"][0], + ), + KrakenSensorEntityDescription( + key="low_last_24h", + value_fn=lambda x, y: x.data[y]["low"][1], + entity_registry_enabled_default=False, + ), + KrakenSensorEntityDescription( + key="high_today", + value_fn=lambda x, y: x.data[y]["high"][0], + ), + KrakenSensorEntityDescription( + key="high_last_24h", + value_fn=lambda x, y: x.data[y]["high"][1], + entity_registry_enabled_default=False, + ), + KrakenSensorEntityDescription( + key="opening_price_today", + value_fn=lambda x, y: x.data[y]["opening_price"], + entity_registry_enabled_default=False, + ), +) diff --git a/homeassistant/components/kraken/sensor.py b/homeassistant/components/kraken/sensor.py index 1b9f8ca13cc..9c2030766f7 100644 --- a/homeassistant/components/kraken/sensor.py +++ b/homeassistant/components/kraken/sensor.py @@ -2,15 +2,14 @@ from __future__ import annotations import logging +from typing import Optional from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import KrakenData @@ -19,7 +18,8 @@ from .const import ( DISPATCH_CONFIG_UPDATED, DOMAIN, SENSOR_TYPES, - SensorType, + KrakenResponse, + KrakenSensorEntityDescription, ) _LOGGER = logging.getLogger(__name__) @@ -43,7 +43,7 @@ async def async_setup_entry( ) } - sensors = [] + entities = [] for tracked_asset_pair in config_entry.options[CONF_TRACKED_ASSET_PAIRS]: # Only create new devices if ( @@ -51,15 +51,17 @@ async def async_setup_entry( ) in existing_devices: existing_devices.pop(device_name) else: - for sensor_type in SENSOR_TYPES: - sensors.append( + entities.extend( + [ KrakenSensor( hass.data[DOMAIN], tracked_asset_pair, - sensor_type, + description, ) - ) - async_add_entities(sensors, True) + for description in SENSOR_TYPES + ] + ) + async_add_entities(entities, True) # Remove devices for asset pairs which are no longer tracked for device_id in existing_devices.values(): @@ -76,57 +78,46 @@ async def async_setup_entry( ) -class KrakenSensor(CoordinatorEntity, SensorEntity): +class KrakenSensor(CoordinatorEntity[Optional[KrakenResponse]], SensorEntity): """Define a Kraken sensor.""" + entity_description: KrakenSensorEntityDescription + def __init__( self, kraken_data: KrakenData, tracked_asset_pair: str, - sensor_type: SensorType, + description: KrakenSensorEntityDescription, ) -> None: """Initialize.""" assert kraken_data.coordinator is not None super().__init__(kraken_data.coordinator) + self.entity_description = description self.tracked_asset_pair_wsname = kraken_data.tradable_asset_pairs[ tracked_asset_pair ] - self._source_asset = tracked_asset_pair.split("/")[0] + source_asset = tracked_asset_pair.split("/")[0] self._target_asset = tracked_asset_pair.split("/")[1] - self._sensor_type = sensor_type["name"] - self._enabled_by_default = sensor_type["enabled_by_default"] - self._unit_of_measurement = self._target_asset - self._device_name = f"{self._source_asset} {self._target_asset}" - self._name = "_".join( + if "number_of" not in description.key: + self._attr_native_unit_of_measurement = self._target_asset + self._device_name = f"{source_asset} {self._target_asset}" + self._attr_name = "_".join( [ tracked_asset_pair.split("/")[0], tracked_asset_pair.split("/")[1], - sensor_type["name"], + description.key, ] ) + self._attr_unique_id = self._attr_name.lower() self._received_data_at_least_once = False self._available = True - self._state = None - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return self._enabled_by_default - - @property - def name(self) -> str: - """Return the name.""" - return self._name - - @property - def unique_id(self) -> str: - """Set unique_id for sensor.""" - return self._name.lower() - - @property - def native_value(self) -> StateType: - """Return the state.""" - return self._state + self._attr_device_info = { + "identifiers": {(DOMAIN, f"{source_asset}_{self._target_asset}")}, + "name": self._device_name, + "manufacturer": "Kraken.com", + "entry_type": "service", + } async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" @@ -139,70 +130,9 @@ class KrakenSensor(CoordinatorEntity, SensorEntity): def _update_internal_state(self) -> None: try: - if self._sensor_type == "last_trade_closed": - self._state = self.coordinator.data[self.tracked_asset_pair_wsname][ - "last_trade_closed" - ][0] - if self._sensor_type == "ask": - self._state = self.coordinator.data[self.tracked_asset_pair_wsname][ - "ask" - ][0] - if self._sensor_type == "ask_volume": - self._state = self.coordinator.data[self.tracked_asset_pair_wsname][ - "ask" - ][1] - if self._sensor_type == "bid": - self._state = self.coordinator.data[self.tracked_asset_pair_wsname][ - "bid" - ][0] - if self._sensor_type == "bid_volume": - self._state = self.coordinator.data[self.tracked_asset_pair_wsname][ - "bid" - ][1] - if self._sensor_type == "volume_today": - self._state = self.coordinator.data[self.tracked_asset_pair_wsname][ - "volume" - ][0] - if self._sensor_type == "volume_last_24h": - self._state = self.coordinator.data[self.tracked_asset_pair_wsname][ - "volume" - ][1] - if self._sensor_type == "volume_weighted_average_today": - self._state = self.coordinator.data[self.tracked_asset_pair_wsname][ - "volume_weighted_average" - ][0] - if self._sensor_type == "volume_weighted_average_last_24h": - self._state = self.coordinator.data[self.tracked_asset_pair_wsname][ - "volume_weighted_average" - ][1] - if self._sensor_type == "number_of_trades_today": - self._state = self.coordinator.data[self.tracked_asset_pair_wsname][ - "number_of_trades" - ][0] - if self._sensor_type == "number_of_trades_last_24h": - self._state = self.coordinator.data[self.tracked_asset_pair_wsname][ - "number_of_trades" - ][1] - if self._sensor_type == "low_today": - self._state = self.coordinator.data[self.tracked_asset_pair_wsname][ - "low" - ][0] - if self._sensor_type == "low_last_24h": - self._state = self.coordinator.data[self.tracked_asset_pair_wsname][ - "low" - ][1] - if self._sensor_type == "high_today": - self._state = self.coordinator.data[self.tracked_asset_pair_wsname][ - "high" - ][0] - if self._sensor_type == "high_last_24h": - self._state = self.coordinator.data[self.tracked_asset_pair_wsname][ - "high" - ][1] - if self._sensor_type == "opening_price_today": - self._state = self.coordinator.data[self.tracked_asset_pair_wsname][ - "opening_price" - ] + self._attr_native_value = self.entity_description.value_fn( + self.coordinator, self.tracked_asset_pair_wsname # type: ignore[arg-type] + ) self._received_data_at_least_once = True # Received data at least one time. except TypeError: if self._received_data_at_least_once: @@ -228,29 +158,11 @@ class KrakenSensor(CoordinatorEntity, SensorEntity): return "mdi:currency-btc" return "mdi:cash" - @property - def native_unit_of_measurement(self) -> str | None: - """Return the unit the value is expressed in.""" - if "number_of" not in self._sensor_type: - return self._unit_of_measurement - return None - @property def available(self) -> bool: """Could the api be accessed during the last update call.""" return self._available and self.coordinator.last_update_success - @property - def device_info(self) -> DeviceInfo: - """Return a device description for device registry.""" - - return { - "identifiers": {(DOMAIN, f"{self._source_asset}_{self._target_asset}")}, - "name": self._device_name, - "manufacturer": "Kraken.com", - "entry_type": "service", - } - def create_device_name(tracked_asset_pair: str) -> str: """Create the device name for a given tracked asset pair.""" From 1a47fcc4e3def9e1f99348b66cb1bd0a8fa026a1 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Thu, 23 Sep 2021 08:54:06 -0600 Subject: [PATCH 546/843] Add long-term statistics for OpenUV sensors (#55417) --- homeassistant/components/openuv/sensor.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/openuv/sensor.py b/homeassistant/components/openuv/sensor.py index bb04bda4cb4..7f091bc1a79 100644 --- a/homeassistant/components/openuv/sensor.py +++ b/homeassistant/components/openuv/sensor.py @@ -1,9 +1,13 @@ """Support for OpenUV sensors.""" from __future__ import annotations -from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import TIME_MINUTES, UV_INDEX +from homeassistant.const import DEVICE_CLASS_OZONE, TIME_MINUTES, UV_INDEX from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.dt import as_local, parse_datetime @@ -46,14 +50,16 @@ SENSOR_DESCRIPTIONS = ( SensorEntityDescription( key=TYPE_CURRENT_OZONE_LEVEL, name="Current Ozone Level", - icon="mdi:vector-triangle", + device_class=DEVICE_CLASS_OZONE, native_unit_of_measurement="du", + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=TYPE_CURRENT_UV_INDEX, name="Current UV Index", icon="mdi:weather-sunny", native_unit_of_measurement=UV_INDEX, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=TYPE_CURRENT_UV_LEVEL, @@ -65,42 +71,49 @@ SENSOR_DESCRIPTIONS = ( name="Max UV Index", icon="mdi:weather-sunny", native_unit_of_measurement=UV_INDEX, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=TYPE_SAFE_EXPOSURE_TIME_1, name="Skin Type 1 Safe Exposure Time", icon="mdi:timer-outline", native_unit_of_measurement=TIME_MINUTES, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=TYPE_SAFE_EXPOSURE_TIME_2, name="Skin Type 2 Safe Exposure Time", icon="mdi:timer-outline", native_unit_of_measurement=TIME_MINUTES, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=TYPE_SAFE_EXPOSURE_TIME_3, name="Skin Type 3 Safe Exposure Time", icon="mdi:timer-outline", native_unit_of_measurement=TIME_MINUTES, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=TYPE_SAFE_EXPOSURE_TIME_4, name="Skin Type 4 Safe Exposure Time", icon="mdi:timer-outline", native_unit_of_measurement=TIME_MINUTES, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=TYPE_SAFE_EXPOSURE_TIME_5, name="Skin Type 5 Safe Exposure Time", icon="mdi:timer-outline", native_unit_of_measurement=TIME_MINUTES, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=TYPE_SAFE_EXPOSURE_TIME_6, name="Skin Type 6 Safe Exposure Time", icon="mdi:timer-outline", native_unit_of_measurement=TIME_MINUTES, + state_class=STATE_CLASS_MEASUREMENT, ), ) From 98d0c844682aa911126fad52b28295c419f4c126 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Thu, 23 Sep 2021 16:56:21 +0200 Subject: [PATCH 547/843] Enable strict typing for the tautulli integration (#55448) --- .strict-typing | 1 + homeassistant/components/tautulli/sensor.py | 70 ++++++++++++++------- mypy.ini | 11 ++++ 3 files changed, 60 insertions(+), 22 deletions(-) diff --git a/.strict-typing b/.strict-typing index df85d61c4a9..193ada1d335 100644 --- a/.strict-typing +++ b/.strict-typing @@ -104,6 +104,7 @@ homeassistant.components.switcher_kis.* homeassistant.components.synology_dsm.* homeassistant.components.systemmonitor.* homeassistant.components.tag.* +homeassistant.components.tautulli.* homeassistant.components.tcp.* homeassistant.components.tile.* homeassistant.components.tradfri.* diff --git a/homeassistant/components/tautulli/sensor.py b/homeassistant/components/tautulli/sensor.py index 054f59e9b5d..814f6c9da50 100644 --- a/homeassistant/components/tautulli/sensor.py +++ b/homeassistant/components/tautulli/sensor.py @@ -1,4 +1,8 @@ """A platform which allows you to get information from Tautulli.""" +from __future__ import annotations + +from typing import Any + from pytautulli import PyTautulli import voluptuous as vol @@ -13,8 +17,11 @@ from homeassistant.const import ( CONF_SSL, CONF_VERIFY_SSL, ) +from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import TautulliDataUpdateCoordinator @@ -42,20 +49,26 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Create the Tautulli sensor.""" - name = config.get(CONF_NAME) + name = config[CONF_NAME] host = config[CONF_HOST] - port = config.get(CONF_PORT) - path = config.get(CONF_PATH) + port = config[CONF_PORT] + path = config[CONF_PATH] api_key = config[CONF_API_KEY] - monitored_conditions = config.get(CONF_MONITORED_CONDITIONS) - user = config.get(CONF_MONITORED_USERS) + monitored_conditions = config.get(CONF_MONITORED_CONDITIONS, []) + users = config.get(CONF_MONITORED_USERS, []) use_ssl = config[CONF_SSL] - verify_ssl = config.get(CONF_VERIFY_SSL) + verify_ssl = config[CONF_VERIFY_SSL] + + session = async_get_clientsession(hass=hass, verify_ssl=verify_ssl) - session = async_get_clientsession(hass, verify_ssl) api_client = PyTautulli( api_token=api_key, hostname=host, @@ -68,9 +81,17 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= coordinator = TautulliDataUpdateCoordinator(hass=hass, api_client=api_client) - entities = [TautulliSensor(coordinator, name, monitored_conditions, user)] - - async_add_entities(entities, True) + async_add_entities( + new_entities=[ + TautulliSensor( + coordinator=coordinator, + name=name, + monitored_conditions=monitored_conditions, + usernames=users, + ) + ], + update_before_add=True, + ) class TautulliSensor(CoordinatorEntity, SensorEntity): @@ -78,37 +99,43 @@ class TautulliSensor(CoordinatorEntity, SensorEntity): coordinator: TautulliDataUpdateCoordinator - def __init__(self, coordinator, name, monitored_conditions, users): + def __init__( + self, + coordinator: TautulliDataUpdateCoordinator, + name: str, + monitored_conditions: list[str], + usernames: list[str], + ) -> None: """Initialize the Tautulli sensor.""" super().__init__(coordinator) self.monitored_conditions = monitored_conditions - self.usernames = users + self.usernames = usernames self._name = name @property - def name(self): + def name(self) -> str: """Return the name of the sensor.""" return self._name @property - def native_value(self): + def native_value(self) -> StateType: """Return the state of the sensor.""" if not self.coordinator.activity: return 0 - return self.coordinator.activity.stream_count + return self.coordinator.activity.stream_count or 0 @property - def icon(self): + def icon(self) -> str: """Return the icon of the sensor.""" return "mdi:plex" @property - def native_unit_of_measurement(self): + def native_unit_of_measurement(self) -> str: """Return the unit this state is expressed in.""" return "Watching" @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any] | None: """Return attributes for the sensor.""" if ( not self.coordinator.activity @@ -149,8 +176,7 @@ class TautulliSensor(CoordinatorEntity, SensorEntity): continue _attributes[session.username]["Activity"] = session.state - if self.monitored_conditions: - for key in self.monitored_conditions: - _attributes[session.username][key] = getattr(session, key) + for key in self.monitored_conditions: + _attributes[session.username][key] = getattr(session, key) return _attributes diff --git a/mypy.ini b/mypy.ini index cec6e51d58e..8643fb2fcd2 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1155,6 +1155,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.tautulli.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.tcp.*] check_untyped_defs = true disallow_incomplete_defs = true From 70f10338cc3e648e72ba13c31c7a77b09527f42c Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 23 Sep 2021 18:29:58 +0200 Subject: [PATCH 548/843] Use EntityDescription - solaredge_local (#56434) --- .../components/solaredge_local/sensor.py | 382 ++++++++---------- 1 file changed, 172 insertions(+), 210 deletions(-) diff --git a/homeassistant/components/solaredge_local/sensor.py b/homeassistant/components/solaredge_local/sensor.py index 9d162e919f4..d3607ecd29c 100644 --- a/homeassistant/components/solaredge_local/sensor.py +++ b/homeassistant/components/solaredge_local/sensor.py @@ -1,6 +1,9 @@ """Support for SolarEdge-local Monitoring API.""" +from __future__ import annotations + from contextlib import suppress -from copy import deepcopy +from copy import copy +from dataclasses import dataclass from datetime import timedelta import logging import statistics @@ -9,7 +12,11 @@ from requests.exceptions import ConnectTimeout, HTTPError from solaredge_local import SolarEdge import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( CONF_IP_ADDRESS, CONF_NAME, @@ -41,122 +48,134 @@ INVERTER_MODES = ( "IDLE", ) -# Supported sensor types: -# Key: ['json_key', 'name', unit, icon, attribute name] -SENSOR_TYPES = { - "current_AC_voltage": [ - "gridvoltage", - "Grid Voltage", - ELECTRIC_POTENTIAL_VOLT, - "mdi:current-ac", - None, - None, - ], - "current_DC_voltage": [ - "dcvoltage", - "DC Voltage", - ELECTRIC_POTENTIAL_VOLT, - "mdi:current-dc", - None, - None, - ], - "current_frequency": [ - "gridfrequency", - "Grid Frequency", - FREQUENCY_HERTZ, - "mdi:current-ac", - None, - None, - ], - "current_power": [ - "currentPower", - "Current Power", - POWER_WATT, - "mdi:solar-power", - None, - None, - ], - "energy_this_month": [ - "energyThisMonth", - "Energy This Month", - ENERGY_WATT_HOUR, - "mdi:solar-power", - None, - None, - ], - "energy_this_year": [ - "energyThisYear", - "Energy This Year", - ENERGY_WATT_HOUR, - "mdi:solar-power", - None, - None, - ], - "energy_today": [ - "energyToday", - "Energy Today", - ENERGY_WATT_HOUR, - "mdi:solar-power", - None, - None, - ], - "inverter_temperature": [ - "invertertemperature", - "Inverter Temperature", - TEMP_CELSIUS, - None, - "operating_mode", - DEVICE_CLASS_TEMPERATURE, - ], - "lifetime_energy": [ - "energyTotal", - "Lifetime Energy", - ENERGY_WATT_HOUR, - "mdi:solar-power", - None, - None, - ], - "optimizer_connected": [ - "optimizers", - "Optimizers Online", - "optimizers", - "mdi:solar-panel", - "optimizers_connected", - None, - ], - "optimizer_current": [ - "optimizercurrent", - "Average Optimizer Current", - ELECTRIC_CURRENT_AMPERE, - "mdi:solar-panel", - None, - None, - ], - "optimizer_power": [ - "optimizerpower", - "Average Optimizer Power", - POWER_WATT, - "mdi:solar-panel", - None, - None, - ], - "optimizer_temperature": [ - "optimizertemperature", - "Average Optimizer Temperature", - TEMP_CELSIUS, - "mdi:solar-panel", - None, - DEVICE_CLASS_TEMPERATURE, - ], - "optimizer_voltage": [ - "optimizervoltage", - "Average Optimizer Voltage", - ELECTRIC_POTENTIAL_VOLT, - "mdi:solar-panel", - None, - None, - ], -} + +@dataclass +class SolarEdgeLocalSensorEntityDescription(SensorEntityDescription): + """Describes SolarEdge-local sensor entity.""" + + extra_attribute: str | None = None + + +SENSOR_TYPES: tuple[SolarEdgeLocalSensorEntityDescription, ...] = ( + SolarEdgeLocalSensorEntityDescription( + key="gridvoltage", + name="Grid Voltage", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon="mdi:current-ac", + ), + SolarEdgeLocalSensorEntityDescription( + key="dcvoltage", + name="DC Voltage", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon="mdi:current-dc", + ), + SolarEdgeLocalSensorEntityDescription( + key="gridfrequency", + name="Grid Frequency", + native_unit_of_measurement=FREQUENCY_HERTZ, + icon="mdi:current-ac", + ), + SolarEdgeLocalSensorEntityDescription( + key="currentPower", + name="Current Power", + native_unit_of_measurement=POWER_WATT, + icon="mdi:solar-power", + ), + SolarEdgeLocalSensorEntityDescription( + key="energyThisMonth", + name="Energy This Month", + native_unit_of_measurement=ENERGY_WATT_HOUR, + icon="mdi:solar-power", + ), + SolarEdgeLocalSensorEntityDescription( + key="energyThisYear", + name="Energy This Year", + native_unit_of_measurement=ENERGY_WATT_HOUR, + icon="mdi:solar-power", + ), + SolarEdgeLocalSensorEntityDescription( + key="energyToday", + name="Energy Today", + native_unit_of_measurement=ENERGY_WATT_HOUR, + icon="mdi:solar-power", + ), + SolarEdgeLocalSensorEntityDescription( + key="energyTotal", + name="Lifetime Energy", + native_unit_of_measurement=ENERGY_WATT_HOUR, + icon="mdi:solar-power", + ), + SolarEdgeLocalSensorEntityDescription( + key="optimizers", + name="Optimizers Online", + native_unit_of_measurement="optimizers", + icon="mdi:solar-panel", + extra_attribute="optimizers_connected", + ), + SolarEdgeLocalSensorEntityDescription( + key="optimizercurrent", + name="Average Optimizer Current", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + icon="mdi:solar-panel", + ), + SolarEdgeLocalSensorEntityDescription( + key="optimizerpower", + name="Average Optimizer Power", + native_unit_of_measurement=POWER_WATT, + icon="mdi:solar-panel", + ), + SolarEdgeLocalSensorEntityDescription( + key="optimizertemperature", + name="Average Optimizer Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + icon="mdi:solar-panel", + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SolarEdgeLocalSensorEntityDescription( + key="optimizervoltage", + name="Average Optimizer Voltage", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon="mdi:solar-panel", + ), +) + +SENSOR_TYPE_INVERTER_TEMPERATURE = SolarEdgeLocalSensorEntityDescription( + key="invertertemperature", + name="Inverter Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + extra_attribute="operating_mode", + device_class=DEVICE_CLASS_TEMPERATURE, +) + +SENSOR_TYPES_ENERGY_IMPORT: tuple[SolarEdgeLocalSensorEntityDescription, ...] = ( + SolarEdgeLocalSensorEntityDescription( + key="currentPowerimport", + name="current import Power", + native_unit_of_measurement=POWER_WATT, + icon="mdi:arrow-collapse-down", + ), + SolarEdgeLocalSensorEntityDescription( + key="totalEnergyimport", + name="total import Energy", + native_unit_of_measurement=ENERGY_WATT_HOUR, + icon="mdi:counter", + ), +) + +SENSOR_TYPES_ENERGY_EXPORT: tuple[SolarEdgeLocalSensorEntityDescription, ...] = ( + SolarEdgeLocalSensorEntityDescription( + key="currentPowerexport", + name="current export Power", + native_unit_of_measurement=POWER_WATT, + icon="mdi:arrow-expand-up", + ), + SolarEdgeLocalSensorEntityDescription( + key="totalEnergyexport", + name="total export Energy", + native_unit_of_measurement=ENERGY_WATT_HOUR, + icon="mdi:counter", + ), +) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -188,133 +207,76 @@ def setup_platform(hass, config, add_entities, discovery_info=None): _LOGGER.error("Could not retrieve details from SolarEdge API") return + # Create solaredge data service which will retrieve and update the data. + data = SolarEdgeData(hass, api) + # Changing inverter temperature unit. - sensors = deepcopy(SENSOR_TYPES) + inverter_temp_description = copy(SENSOR_TYPE_INVERTER_TEMPERATURE) if status.inverters.primary.temperature.units.farenheit: - sensors["inverter_temperature"] = [ - "invertertemperature", - "Inverter Temperature", - TEMP_FAHRENHEIT, - "mdi:thermometer", - "operating_mode", - DEVICE_CLASS_TEMPERATURE, - ] + inverter_temp_description.native_unit_of_measurement = TEMP_FAHRENHEIT + + # Create entities + entities = [ + SolarEdgeSensor(platform_name, data, description) + for description in (*SENSOR_TYPES, inverter_temp_description) + ] try: if status.metersList[0]: - sensors["import_current_power"] = [ - "currentPowerimport", - "current import Power", - POWER_WATT, - "mdi:arrow-collapse-down", - None, - None, - ] - sensors["import_meter_reading"] = [ - "totalEnergyimport", - "total import Energy", - ENERGY_WATT_HOUR, - "mdi:counter", - None, - None, - ] + entities.extend( + [ + SolarEdgeSensor(platform_name, data, description) + for description in SENSOR_TYPES_ENERGY_IMPORT + ] + ) except IndexError: _LOGGER.debug("Import meter sensors are not created") try: if status.metersList[1]: - sensors["export_current_power"] = [ - "currentPowerexport", - "current export Power", - POWER_WATT, - "mdi:arrow-expand-up", - None, - None, - ] - sensors["export_meter_reading"] = [ - "totalEnergyexport", - "total export Energy", - ENERGY_WATT_HOUR, - "mdi:counter", - None, - None, - ] + entities.extend( + [ + SolarEdgeSensor(platform_name, data, description) + for description in SENSOR_TYPES_ENERGY_EXPORT + ] + ) except IndexError: _LOGGER.debug("Export meter sensors are not created") - # Create solaredge data service which will retrieve and update the data. - data = SolarEdgeData(hass, api) - - # Create a new sensor for each sensor type. - entities = [] - for sensor_info in sensors.values(): - sensor = SolarEdgeSensor( - platform_name, - data, - sensor_info[0], - sensor_info[1], - sensor_info[2], - sensor_info[3], - sensor_info[4], - sensor_info[5], - ) - entities.append(sensor) - add_entities(entities, True) class SolarEdgeSensor(SensorEntity): """Representation of an SolarEdge Monitoring API sensor.""" + entity_description: SolarEdgeLocalSensorEntityDescription + def __init__( - self, platform_name, data, json_key, name, unit, icon, attr, device_class + self, + platform_name, + data, + description: SolarEdgeLocalSensorEntityDescription, ): """Initialize the sensor.""" + self.entity_description = description self._platform_name = platform_name self._data = data - self._state = None - - self._json_key = json_key - self._name = name - self._unit_of_measurement = unit - self._icon = icon - self._attr = attr - self._attr_device_class = device_class - - @property - def name(self): - """Return the name.""" - return f"{self._platform_name} ({self._name})" - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement.""" - return self._unit_of_measurement + self._attr_name = f"{platform_name} ({description.name})" @property def extra_state_attributes(self): """Return the state attributes.""" - if self._attr: + if extra_attr := self.entity_description.extra_attribute: try: - return {self._attr: self._data.info[self._json_key]} + return {extra_attr: self._data.info[self.entity_description.key]} except KeyError: - return None + pass return None - @property - def icon(self): - """Return the sensor icon.""" - return self._icon - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - def update(self): """Get the latest data from the sensor and update the state.""" self._data.update() - self._state = self._data.data[self._json_key] + self._attr_native_value = self._data.data[self.entity_description.key] class SolarEdgeData: From cce906f968d3127537e1980faea7489ce4a758dd Mon Sep 17 00:00:00 2001 From: Brian O'Connor Date: Thu, 23 Sep 2021 13:14:15 -0400 Subject: [PATCH 549/843] Fix OpenWeatherMap dewpoint conversion (#56303) --- .../openweathermap/weather_update_coordinator.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/openweathermap/weather_update_coordinator.py b/homeassistant/components/openweathermap/weather_update_coordinator.py index db8c48aeac4..5c2633a7a33 100644 --- a/homeassistant/components/openweathermap/weather_update_coordinator.py +++ b/homeassistant/components/openweathermap/weather_update_coordinator.py @@ -18,10 +18,10 @@ from homeassistant.components.weather import ( ATTR_FORECAST_WIND_BEARING, ATTR_FORECAST_WIND_SPEED, ) -from homeassistant.const import TEMP_CELSIUS from homeassistant.helpers import sun from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt +from homeassistant.util.temperature import kelvin_to_celsius from .const import ( ATTR_API_CLOUDS, @@ -180,10 +180,10 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): return forecast - def _fmt_dewpoint(self, dewpoint): + @staticmethod + def _fmt_dewpoint(dewpoint): if dewpoint is not None: - dewpoint = dewpoint - 273.15 - return round(self.hass.config.units.temperature(dewpoint, TEMP_CELSIUS), 1) + return round(kelvin_to_celsius(dewpoint), 1) return None @staticmethod From 1cc850877fd4fa26f8896cde398e36601563da45 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Thu, 23 Sep 2021 19:35:50 +0200 Subject: [PATCH 550/843] strictly type: fan.py, light.py, switch.py. (#56379) --- homeassistant/components/modbus/fan.py | 15 ++++++++++----- homeassistant/components/modbus/light.py | 13 +++++++++---- homeassistant/components/modbus/switch.py | 13 +++++++++---- 3 files changed, 28 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/modbus/fan.py b/homeassistant/components/modbus/fan.py index cf5c9762db8..349ae0d0619 100644 --- a/homeassistant/components/modbus/fan.py +++ b/homeassistant/components/modbus/fan.py @@ -2,11 +2,13 @@ from __future__ import annotations import logging +from typing import Any from homeassistant.components.fan import FanEntity from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import get_hub from .base_platform import BaseSwitch @@ -18,8 +20,11 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_platform( - hass: HomeAssistant, config: ConfigType, async_add_entities, discovery_info=None -): + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Read configuration and create Modbus fans.""" if discovery_info is None: # pragma: no cover return @@ -39,13 +44,13 @@ class ModbusFan(BaseSwitch, FanEntity): speed: str | None = None, percentage: int | None = None, preset_mode: str | None = None, - **kwargs, + **kwargs: Any, ) -> None: """Set fan on.""" await self.async_turn(self.command_on) @property - def is_on(self): + def is_on(self) -> bool: """Return true if fan is on. This is needed due to the ongoing conversion of fan. diff --git a/homeassistant/components/modbus/light.py b/homeassistant/components/modbus/light.py index dd9a8ad754d..f0f2541ad0f 100644 --- a/homeassistant/components/modbus/light.py +++ b/homeassistant/components/modbus/light.py @@ -2,11 +2,13 @@ from __future__ import annotations import logging +from typing import Any from homeassistant.components.light import LightEntity from homeassistant.const import CONF_LIGHTS, CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import get_hub from .base_platform import BaseSwitch @@ -17,8 +19,11 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_platform( - hass: HomeAssistant, config: ConfigType, async_add_entities, discovery_info=None -): + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Read configuration and create Modbus lights.""" if discovery_info is None: # pragma: no cover return @@ -33,6 +38,6 @@ async def async_setup_platform( class ModbusLight(BaseSwitch, LightEntity): """Class representing a Modbus light.""" - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Set light on.""" await self.async_turn(self.command_on) diff --git a/homeassistant/components/modbus/switch.py b/homeassistant/components/modbus/switch.py index 55dc014420f..86cba7c36ff 100644 --- a/homeassistant/components/modbus/switch.py +++ b/homeassistant/components/modbus/switch.py @@ -2,11 +2,13 @@ from __future__ import annotations import logging +from typing import Any from homeassistant.components.switch import SwitchEntity from homeassistant.const import CONF_NAME, CONF_SWITCHES from homeassistant.core import HomeAssistant -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import get_hub from .base_platform import BaseSwitch @@ -17,8 +19,11 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_platform( - hass: HomeAssistant, config: ConfigType, async_add_entities, discovery_info=None -): + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Read configuration and create Modbus switches.""" switches = [] @@ -34,6 +39,6 @@ async def async_setup_platform( class ModbusSwitch(BaseSwitch, SwitchEntity): """Base class representing a Modbus switch.""" - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Set switch on.""" await self.async_turn(self.command_on) From 2fe8c788111ad29042c14b5376e1fd215c5cbb87 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 23 Sep 2021 19:50:30 +0200 Subject: [PATCH 551/843] Fix Toon push updates (#56583) --- homeassistant/components/toon/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/toon/manifest.json b/homeassistant/components/toon/manifest.json index 2df5cfa2e90..6a5d52d393b 100644 --- a/homeassistant/components/toon/manifest.json +++ b/homeassistant/components/toon/manifest.json @@ -3,7 +3,7 @@ "name": "Toon", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/toon", - "requirements": ["toonapi==0.2.0"], + "requirements": ["toonapi==0.2.1"], "dependencies": ["http"], "after_dependencies": ["cloud"], "codeowners": ["@frenck"], diff --git a/requirements_all.txt b/requirements_all.txt index 1438d77ef62..fe626cfe2ab 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2312,7 +2312,7 @@ tmb==0.0.4 todoist-python==8.0.0 # homeassistant.components.toon -toonapi==0.2.0 +toonapi==0.2.1 # homeassistant.components.totalconnect total_connect_client==0.57 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a2a4cb8f14a..bef4281ed90 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1295,7 +1295,7 @@ tellduslive==0.10.11 tesla-powerwall==0.3.10 # homeassistant.components.toon -toonapi==0.2.0 +toonapi==0.2.1 # homeassistant.components.totalconnect total_connect_client==0.57 From a5c6a65161c1b719e04d69165776275a0b182066 Mon Sep 17 00:00:00 2001 From: Andre Richter Date: Thu, 23 Sep 2021 19:59:28 +0200 Subject: [PATCH 552/843] Activate mypy for Vallox (#55874) --- .strict-typing | 1 + homeassistant/components/vallox/__init__.py | 60 ++++++++----- homeassistant/components/vallox/fan.py | 99 ++++++++++++--------- homeassistant/components/vallox/sensor.py | 75 +++++++++------- mypy.ini | 11 +++ 5 files changed, 151 insertions(+), 95 deletions(-) diff --git a/.strict-typing b/.strict-typing index 193ada1d335..78d6914764f 100644 --- a/.strict-typing +++ b/.strict-typing @@ -113,6 +113,7 @@ homeassistant.components.upcloud.* homeassistant.components.uptime.* homeassistant.components.uptimerobot.* homeassistant.components.vacuum.* +homeassistant.components.vallox.* homeassistant.components.water_heater.* homeassistant.components.weather.* homeassistant.components.websocket_api.* diff --git a/homeassistant/components/vallox/__init__.py b/homeassistant/components/vallox/__init__.py index b51caa1f7b4..96c83c82c36 100644 --- a/homeassistant/components/vallox/__init__.py +++ b/homeassistant/components/vallox/__init__.py @@ -1,7 +1,10 @@ """Support for Vallox ventilation units.""" +from __future__ import annotations +from datetime import datetime import ipaddress import logging +from typing import Any from vallox_websocket_api import PROFILE as VALLOX_PROFILE, Vallox from vallox_websocket_api.constants import vlxDevConstants @@ -9,10 +12,12 @@ from vallox_websocket_api.exceptions import ValloxApiException import voluptuous as vol from homeassistant.const import CONF_HOST, CONF_NAME -import homeassistant.helpers.config_validation as cv +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.typing import ConfigType, StateType from .const import ( DEFAULT_FAN_SPEED_AWAY, @@ -95,7 +100,7 @@ SERVICE_TO_METHOD = { } -async def async_setup(hass, config): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the client and boot the platforms.""" conf = config[DOMAIN] host = conf.get(CONF_HOST) @@ -113,13 +118,11 @@ async def async_setup(hass, config): DOMAIN, vallox_service, service_handler.async_handle, schema=schema ) - # The vallox hardware expects quite strict timings for websocket - # requests. Timings that machines with less processing power, like - # Raspberries, cannot live up to during the busy start phase of Home - # Asssistant. Hence, async_add_entities() for fan and sensor in respective - # code will be called with update_before_add=False to intentionally delay - # the first request, increasing chance that it is issued only when the - # machine is less busy again. + # The vallox hardware expects quite strict timings for websocket requests. Timings that machines + # with less processing power, like Raspberries, cannot live up to during the busy start phase of + # Home Asssistant. Hence, async_add_entities() for fan and sensor in respective code will be + # called with update_before_add=False to intentionally delay the first request, increasing + # chance that it is issued only when the machine is less busy again. hass.async_create_task(async_load_platform(hass, "sensor", DOMAIN, {}, config)) hass.async_create_task(async_load_platform(hass, "fan", DOMAIN, {}, config)) @@ -131,15 +134,15 @@ async def async_setup(hass, config): class ValloxStateProxy: """Helper class to reduce websocket API calls.""" - def __init__(self, hass, client): + def __init__(self, hass: HomeAssistant, client: Vallox) -> None: """Initialize the proxy.""" self._hass = hass self._client = client - self._metric_cache = {} - self._profile = None + self._metric_cache: dict[str, Any] = {} + self._profile = VALLOX_PROFILE.NONE self._valid = False - def fetch_metric(self, metric_key): + def fetch_metric(self, metric_key: str) -> StateType: """Return cached state value.""" _LOGGER.debug("Fetching metric key: %s", metric_key) @@ -149,9 +152,18 @@ class ValloxStateProxy: if metric_key not in vlxDevConstants.__dict__: raise KeyError(f"Unknown metric key: {metric_key}") - return self._metric_cache[metric_key] + value = self._metric_cache[metric_key] + if value is None: + return None - def get_profile(self): + if not isinstance(value, (str, int, float)): + raise TypeError( + f"Return value of metric {metric_key} has unexpected type {type(value)}" + ) + + return value + + def get_profile(self) -> str: """Return cached profile value.""" _LOGGER.debug("Returning profile") @@ -160,7 +172,7 @@ class ValloxStateProxy: return PROFILE_TO_STR_REPORTABLE[self._profile] - async def async_update(self, event_time): + async def async_update(self, time: datetime | None = None) -> None: """Fetch state update.""" _LOGGER.debug("Updating Vallox state cache") @@ -180,7 +192,7 @@ class ValloxStateProxy: class ValloxServiceHandler: """Services implementation.""" - def __init__(self, client, state_proxy): + def __init__(self, client: Vallox, state_proxy: ValloxStateProxy) -> None: """Initialize the proxy.""" self._client = client self._state_proxy = state_proxy @@ -245,10 +257,13 @@ class ValloxServiceHandler: _LOGGER.error("Error setting fan speed for Boost profile: %s", err) return False - async def async_handle(self, service): + async def async_handle(self, call: ServiceCall) -> None: """Dispatch a service call.""" - method = SERVICE_TO_METHOD.get(service.service) - params = service.data.copy() + method = SERVICE_TO_METHOD.get(call.service) + params = call.data.copy() + + if method is None: + return if not hasattr(self, method["method"]): _LOGGER.error("Service not implemented: %s", method["method"]) @@ -256,7 +271,6 @@ class ValloxServiceHandler: result = await getattr(self, method["method"])(**params) - # Force state_proxy to refresh device state, so that updates are - # propagated to platforms. + # Force state_proxy to refresh device state, so that updates are propagated to platforms. if result: - await self._state_proxy.async_update(None) + await self._state_proxy.async_update() diff --git a/homeassistant/components/vallox/fan.py b/homeassistant/components/vallox/fan.py index a3488ffdfb2..b8d320a7e7e 100644 --- a/homeassistant/components/vallox/fan.py +++ b/homeassistant/components/vallox/fan.py @@ -1,11 +1,19 @@ """Support for the Vallox ventilation unit fan.""" +from __future__ import annotations +from collections.abc import Mapping import logging +from typing import Any + +from vallox_websocket_api import Vallox from homeassistant.components.fan import FanEntity -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from . import ValloxStateProxy from .const import ( DOMAIN, METRIC_KEY_MODE, @@ -34,13 +42,17 @@ ATTR_PROFILE_FAN_SPEED_BOOST = { } -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up the fan device.""" if discovery_info is None: return client = hass.data[DOMAIN]["client"] - client.set_settable_address(METRIC_KEY_MODE, int) device = ValloxFan( @@ -53,39 +65,41 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class ValloxFan(FanEntity): """Representation of the fan.""" - def __init__(self, name, client, state_proxy): + def __init__( + self, name: str, client: Vallox, state_proxy: ValloxStateProxy + ) -> None: """Initialize the fan.""" self._name = name self._client = client self._state_proxy = state_proxy self._available = False - self._state = None - self._fan_speed_home = None - self._fan_speed_away = None - self._fan_speed_boost = None + self._is_on = False + self._fan_speed_home: int | None = None + self._fan_speed_away: int | None = None + self._fan_speed_boost: int | None = None @property - def should_poll(self): + def should_poll(self) -> bool: """Do not poll the device.""" return False @property - def name(self): + def name(self) -> str: """Return the name of the device.""" return self._name @property - def available(self): + def available(self) -> bool: """Return if state is known.""" return self._available @property - def is_on(self): + def is_on(self) -> bool: """Return if device is on.""" - return self._state + return self._is_on @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> Mapping[str, int | None]: """Return device specific state attributes.""" return { ATTR_PROFILE_FAN_SPEED_HOME["description"]: self._fan_speed_home, @@ -93,7 +107,7 @@ class ValloxFan(FanEntity): ATTR_PROFILE_FAN_SPEED_BOOST["description"]: self._fan_speed_boost, } - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Call to update.""" self.async_on_remove( async_dispatcher_connect( @@ -102,38 +116,42 @@ class ValloxFan(FanEntity): ) @callback - def _update_callback(self): + def _update_callback(self) -> None: """Call update method.""" self.async_schedule_update_ha_state(True) - async def async_update(self): + async def async_update(self) -> None: """Fetch state from the device.""" try: # Fetch if the whole device is in regular operation state. - self._state = self._state_proxy.fetch_metric(METRIC_KEY_MODE) == MODE_ON + self._is_on = self._state_proxy.fetch_metric(METRIC_KEY_MODE) == MODE_ON # Fetch the profile fan speeds. - self._fan_speed_home = int( - self._state_proxy.fetch_metric( - ATTR_PROFILE_FAN_SPEED_HOME["metric_key"] - ) + fan_speed_home = self._state_proxy.fetch_metric( + ATTR_PROFILE_FAN_SPEED_HOME["metric_key"] ) - self._fan_speed_away = int( - self._state_proxy.fetch_metric( - ATTR_PROFILE_FAN_SPEED_AWAY["metric_key"] - ) + fan_speed_away = self._state_proxy.fetch_metric( + ATTR_PROFILE_FAN_SPEED_AWAY["metric_key"] ) - self._fan_speed_boost = int( - self._state_proxy.fetch_metric( - ATTR_PROFILE_FAN_SPEED_BOOST["metric_key"] - ) + fan_speed_boost = self._state_proxy.fetch_metric( + ATTR_PROFILE_FAN_SPEED_BOOST["metric_key"] ) - except (OSError, KeyError) as err: + except (OSError, KeyError, TypeError) as err: self._available = False _LOGGER.error("Error updating fan: %s", err) return + self._fan_speed_home = ( + int(fan_speed_home) if isinstance(fan_speed_home, (int, float)) else None + ) + self._fan_speed_away = ( + int(fan_speed_away) if isinstance(fan_speed_away, (int, float)) else None + ) + self._fan_speed_boost = ( + int(fan_speed_boost) if isinstance(fan_speed_boost, (int, float)) else None + ) + self._available = True # @@ -145,20 +163,19 @@ class ValloxFan(FanEntity): # async def async_turn_on( self, - speed: str = None, - percentage: int = None, - preset_mode: str = None, - **kwargs, + speed: str | None = None, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, ) -> None: """Turn the device on.""" _LOGGER.debug("Turn on: %s", speed) - # Only the case speed == None equals the GUI toggle switch being - # activated. + # Only the case speed == None equals the GUI toggle switch being activated. if speed is not None: return - if self._state is True: + if self._is_on: _LOGGER.error("Already on") return @@ -172,11 +189,11 @@ class ValloxFan(FanEntity): # This state change affects other entities like sensors. Force an immediate update that can # be observed by all parties involved. - await self._state_proxy.async_update(None) + await self._state_proxy.async_update() - async def async_turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" - if self._state is False: + if not self._is_on: _LOGGER.error("Already off") return diff --git a/homeassistant/components/vallox/sensor.py b/homeassistant/components/vallox/sensor.py index e2562663ac6..74920853eb6 100644 --- a/homeassistant/components/vallox/sensor.py +++ b/homeassistant/components/vallox/sensor.py @@ -19,8 +19,10 @@ from homeassistant.const import ( PERCENTAGE, TEMP_CELSIUS, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import ValloxStateProxy from .const import DOMAIN, METRIC_KEY_MODE, MODE_ON, SIGNAL_VALLOX_STATE_UPDATE @@ -48,7 +50,7 @@ class ValloxSensor(SensorEntity): self._attr_name = f"{name} {description.name}" self._attr_available = False - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Call to update.""" self.async_on_remove( async_dispatcher_connect( @@ -57,18 +59,23 @@ class ValloxSensor(SensorEntity): ) @callback - def _update_callback(self): + def _update_callback(self) -> None: """Call update method.""" self.async_schedule_update_ha_state(True) - async def async_update(self): + async def async_update(self) -> None: """Fetch state from the ventilation unit.""" - try: - self._attr_native_value = self._state_proxy.fetch_metric( - self.entity_description.metric_key - ) + metric_key = self.entity_description.metric_key - except (OSError, KeyError) as err: + if metric_key is None: + self._attr_available = False + _LOGGER.error("Error updating sensor. Empty metric key") + return + + try: + self._attr_native_value = self._state_proxy.fetch_metric(metric_key) + + except (OSError, KeyError, TypeError) as err: self._attr_available = False _LOGGER.error("Error updating sensor: %s", err) return @@ -79,7 +86,7 @@ class ValloxSensor(SensorEntity): class ValloxProfileSensor(ValloxSensor): """Child class for profile reporting.""" - async def async_update(self): + async def async_update(self) -> None: """Fetch state from the ventilation unit.""" try: self._attr_native_value = self._state_proxy.get_profile() @@ -92,22 +99,21 @@ class ValloxProfileSensor(ValloxSensor): self._attr_available = True -# There seems to be a quirk with respect to the fan speed reporting. The device -# keeps on reporting the last valid fan speed from when the device was in -# regular operation mode, even if it left that state and has been shut off in -# the meantime. +# There seems to be a quirk with respect to the fan speed reporting. The device keeps on reporting +# the last valid fan speed from when the device was in regular operation mode, even if it left that +# state and has been shut off in the meantime. # -# Therefore, first query the overall state of the device, and report zero -# percent fan speed in case it is not in regular operation mode. +# Therefore, first query the overall state of the device, and report zero percent fan speed in case +# it is not in regular operation mode. class ValloxFanSpeedSensor(ValloxSensor): """Child class for fan speed reporting.""" - async def async_update(self): + async def async_update(self) -> None: """Fetch state from the ventilation unit.""" try: fan_on = self._state_proxy.fetch_metric(METRIC_KEY_MODE) == MODE_ON - except (OSError, KeyError) as err: + except (OSError, KeyError, TypeError) as err: self._attr_available = False _LOGGER.error("Error updating sensor: %s", err) return @@ -123,26 +129,28 @@ class ValloxFanSpeedSensor(ValloxSensor): class ValloxFilterRemainingSensor(ValloxSensor): """Child class for filter remaining time reporting.""" - async def async_update(self): + async def async_update(self) -> None: """Fetch state from the ventilation unit.""" - try: - days_remaining = int( - self._state_proxy.fetch_metric(self.entity_description.metric_key) - ) + await super().async_update() - except (OSError, KeyError) as err: - self._attr_available = False - _LOGGER.error("Error updating sensor: %s", err) + # Check if the update in the super call was a success. + if not self._attr_available: return - days_remaining_delta = timedelta(days=days_remaining) + if not isinstance(self._attr_native_value, (int, float)): + self._attr_available = False + _LOGGER.error( + "Value has unexpected type: %s", type(self._attr_native_value) + ) + return - # Since only a delta of days is received from the device, fix the - # time so the timestamp does not change with every update. + # Since only a delta of days is received from the device, fix the time so the timestamp does + # not change with every update. + days_remaining = float(self._attr_native_value) + days_remaining_delta = timedelta(days=days_remaining) now = datetime.utcnow().replace(hour=13, minute=0, second=0, microsecond=0) self._attr_native_value = (now + days_remaining_delta).isoformat() - self._attr_available = True @dataclass @@ -235,7 +243,12 @@ SENSORS: tuple[ValloxSensorEntityDescription, ...] = ( ) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up the sensors.""" if discovery_info is None: return diff --git a/mypy.ini b/mypy.ini index 8643fb2fcd2..317ed1dbc3f 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1254,6 +1254,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.vallox.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.water_heater.*] check_untyped_defs = true disallow_incomplete_defs = true From b43d377b53396661488ba9b2727a856f7b3ecc8f Mon Sep 17 00:00:00 2001 From: Kevin Worrel <37058192+dieselrabbit@users.noreply.github.com> Date: Thu, 23 Sep 2021 11:00:33 -0700 Subject: [PATCH 553/843] Create but disable-by-default RPM and GPM sensors (#56549) --- homeassistant/components/screenlogic/sensor.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/screenlogic/sensor.py b/homeassistant/components/screenlogic/sensor.py index a35e4c8f7c1..7e8a0dbf60b 100644 --- a/homeassistant/components/screenlogic/sensor.py +++ b/homeassistant/components/screenlogic/sensor.py @@ -69,15 +69,16 @@ async def async_setup_entry(hass, config_entry, async_add_entities): for pump_num, pump_data in coordinator.data[SL_DATA.KEY_PUMPS].items(): if pump_data["data"] != 0 and "currentWatts" in pump_data: for pump_key in pump_data: - # Considerations for Intelliflow VF + enabled = True + # Assumptions for Intelliflow VF if pump_data["pumpType"] == 1 and pump_key == "currentRPM": - continue - # Considerations for Intelliflow VS + enabled = False + # Assumptions for Intelliflow VS if pump_data["pumpType"] == 2 and pump_key == "currentGPM": - continue + enabled = False if pump_key in SUPPORTED_PUMP_SENSORS: entities.append( - ScreenLogicPumpSensor(coordinator, pump_num, pump_key) + ScreenLogicPumpSensor(coordinator, pump_num, pump_key, enabled) ) # IntelliChem sensors From 60bb3121b613edde77fac7fc630d5865800efcd8 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 23 Sep 2021 20:00:45 +0200 Subject: [PATCH 554/843] Upgrade apprise to 0.9.5.1 (#56577) --- homeassistant/components/apprise/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/apprise/manifest.json b/homeassistant/components/apprise/manifest.json index bf24f2fdac5..e92c826faaa 100644 --- a/homeassistant/components/apprise/manifest.json +++ b/homeassistant/components/apprise/manifest.json @@ -2,7 +2,7 @@ "domain": "apprise", "name": "Apprise", "documentation": "https://www.home-assistant.io/integrations/apprise", - "requirements": ["apprise==0.9.4"], + "requirements": ["apprise==0.9.5.1"], "codeowners": ["@caronc"], "iot_class": "cloud_push" } diff --git a/requirements_all.txt b/requirements_all.txt index fe626cfe2ab..8707d7815ab 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -297,7 +297,7 @@ apcaccess==0.0.13 apns2==0.3.0 # homeassistant.components.apprise -apprise==0.9.4 +apprise==0.9.5.1 # homeassistant.components.aprs aprslib==0.6.46 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bef4281ed90..85f07c84fbb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -203,7 +203,7 @@ androidtv[async]==0.0.60 apns2==0.3.0 # homeassistant.components.apprise -apprise==0.9.4 +apprise==0.9.5.1 # homeassistant.components.aprs aprslib==0.6.46 From fed5f5e3b96c97df8a7c051efaa28ff4e1984367 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 23 Sep 2021 20:08:47 +0200 Subject: [PATCH 555/843] Use EntityDescription - fitbit (#55925) --- homeassistant/components/fitbit/const.py | 274 +++++++++++++++++----- homeassistant/components/fitbit/sensor.py | 146 ++++++------ 2 files changed, 286 insertions(+), 134 deletions(-) diff --git a/homeassistant/components/fitbit/const.py b/homeassistant/components/fitbit/const.py index e5891758f60..1da3058c790 100644 --- a/homeassistant/components/fitbit/const.py +++ b/homeassistant/components/fitbit/const.py @@ -1,8 +1,10 @@ """Constants for the Fitbit platform.""" from __future__ import annotations +from dataclasses import dataclass from typing import Final +from homeassistant.components.sensor import SensorEntityDescription from homeassistant.const import ( CONF_CLIENT_ID, CONF_CLIENT_SECRET, @@ -43,66 +45,230 @@ DEFAULT_CONFIG: Final[dict[str, str]] = { } DEFAULT_CLOCK_FORMAT: Final = "24H" -FITBIT_RESOURCES_LIST: Final[dict[str, tuple[str, str | None, str]]] = { - "activities/activityCalories": ("Activity Calories", "cal", "fire"), - "activities/calories": ("Calories", "cal", "fire"), - "activities/caloriesBMR": ("Calories BMR", "cal", "fire"), - "activities/distance": ("Distance", "", "map-marker"), - "activities/elevation": ("Elevation", "", "walk"), - "activities/floors": ("Floors", "floors", "walk"), - "activities/heart": ("Resting Heart Rate", "bpm", "heart-pulse"), - "activities/minutesFairlyActive": ("Minutes Fairly Active", TIME_MINUTES, "walk"), - "activities/minutesLightlyActive": ("Minutes Lightly Active", TIME_MINUTES, "walk"), - "activities/minutesSedentary": ( - "Minutes Sedentary", - TIME_MINUTES, - "seat-recline-normal", + +@dataclass +class FitbitRequiredKeysMixin: + """Mixin for required keys.""" + + unit_type: str | None + + +@dataclass +class FitbitSensorEntityDescription(SensorEntityDescription, FitbitRequiredKeysMixin): + """Describes Fitbit sensor entity.""" + + +FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( + FitbitSensorEntityDescription( + key="activities/activityCalories", + name="Activity Calories", + unit_type="cal", + icon="mdi:fire", ), - "activities/minutesVeryActive": ("Minutes Very Active", TIME_MINUTES, "run"), - "activities/steps": ("Steps", "steps", "walk"), - "activities/tracker/activityCalories": ("Tracker Activity Calories", "cal", "fire"), - "activities/tracker/calories": ("Tracker Calories", "cal", "fire"), - "activities/tracker/distance": ("Tracker Distance", "", "map-marker"), - "activities/tracker/elevation": ("Tracker Elevation", "", "walk"), - "activities/tracker/floors": ("Tracker Floors", "floors", "walk"), - "activities/tracker/minutesFairlyActive": ( - "Tracker Minutes Fairly Active", - TIME_MINUTES, - "walk", + FitbitSensorEntityDescription( + key="activities/calories", + name="Calories", + unit_type="cal", + icon="mdi:fire", ), - "activities/tracker/minutesLightlyActive": ( - "Tracker Minutes Lightly Active", - TIME_MINUTES, - "walk", + FitbitSensorEntityDescription( + key="activities/caloriesBMR", + name="Calories BMR", + unit_type="cal", + icon="mdi:fire", ), - "activities/tracker/minutesSedentary": ( - "Tracker Minutes Sedentary", - TIME_MINUTES, - "seat-recline-normal", + FitbitSensorEntityDescription( + key="activities/distance", + name="Distance", + unit_type="", + icon="mdi:map-marker", ), - "activities/tracker/minutesVeryActive": ( - "Tracker Minutes Very Active", - TIME_MINUTES, - "run", + FitbitSensorEntityDescription( + key="activities/elevation", + name="Elevation", + unit_type="", + icon="mdi:walk", ), - "activities/tracker/steps": ("Tracker Steps", "steps", "walk"), - "body/bmi": ("BMI", "BMI", "human"), - "body/fat": ("Body Fat", PERCENTAGE, "human"), - "body/weight": ("Weight", "", "human"), - "devices/battery": ("Battery", None, "battery"), - "sleep/awakeningsCount": ("Awakenings Count", "times awaken", "sleep"), - "sleep/efficiency": ("Sleep Efficiency", PERCENTAGE, "sleep"), - "sleep/minutesAfterWakeup": ("Minutes After Wakeup", TIME_MINUTES, "sleep"), - "sleep/minutesAsleep": ("Sleep Minutes Asleep", TIME_MINUTES, "sleep"), - "sleep/minutesAwake": ("Sleep Minutes Awake", TIME_MINUTES, "sleep"), - "sleep/minutesToFallAsleep": ( - "Sleep Minutes to Fall Asleep", - TIME_MINUTES, - "sleep", + FitbitSensorEntityDescription( + key="activities/floors", + name="Floors", + unit_type="floors", + icon="mdi:walk", ), - "sleep/startTime": ("Sleep Start Time", None, "clock"), - "sleep/timeInBed": ("Sleep Time in Bed", TIME_MINUTES, "hotel"), -} + FitbitSensorEntityDescription( + key="activities/heart", + name="Resting Heart Rate", + unit_type="bpm", + icon="mdi:heart-pulse", + ), + FitbitSensorEntityDescription( + key="activities/minutesFairlyActive", + name="Minutes Fairly Active", + unit_type=TIME_MINUTES, + icon="mdi:walk", + ), + FitbitSensorEntityDescription( + key="activities/minutesLightlyActive", + name="Minutes Lightly Active", + unit_type=TIME_MINUTES, + icon="mdi:walk", + ), + FitbitSensorEntityDescription( + key="activities/minutesSedentary", + name="Minutes Sedentary", + unit_type=TIME_MINUTES, + icon="mdi:seat-recline-normal", + ), + FitbitSensorEntityDescription( + key="activities/minutesVeryActive", + name="Minutes Very Active", + unit_type=TIME_MINUTES, + icon="mdi:run", + ), + FitbitSensorEntityDescription( + key="activities/steps", + name="Steps", + unit_type="steps", + icon="mdi:walk", + ), + FitbitSensorEntityDescription( + key="activities/tracker/activityCalories", + name="Tracker Activity Calories", + unit_type="cal", + icon="mdi:fire", + ), + FitbitSensorEntityDescription( + key="activities/tracker/calories", + name="Tracker Calories", + unit_type="cal", + icon="mdi:fire", + ), + FitbitSensorEntityDescription( + key="activities/tracker/distance", + name="Tracker Distance", + unit_type="", + icon="mdi:map-marker", + ), + FitbitSensorEntityDescription( + key="activities/tracker/elevation", + name="Tracker Elevation", + unit_type="", + icon="mdi:walk", + ), + FitbitSensorEntityDescription( + key="activities/tracker/floors", + name="Tracker Floors", + unit_type="floors", + icon="mdi:walk", + ), + FitbitSensorEntityDescription( + key="activities/tracker/minutesFairlyActive", + name="Tracker Minutes Fairly Active", + unit_type=TIME_MINUTES, + icon="mdi:walk", + ), + FitbitSensorEntityDescription( + key="activities/tracker/minutesLightlyActive", + name="Tracker Minutes Lightly Active", + unit_type=TIME_MINUTES, + icon="mdi:walk", + ), + FitbitSensorEntityDescription( + key="activities/tracker/minutesSedentary", + name="Tracker Minutes Sedentary", + unit_type=TIME_MINUTES, + icon="mdi:seat-recline-normal", + ), + FitbitSensorEntityDescription( + key="activities/tracker/minutesVeryActive", + name="Tracker Minutes Very Active", + unit_type=TIME_MINUTES, + icon="mdi:run", + ), + FitbitSensorEntityDescription( + key="activities/tracker/steps", + name="Tracker Steps", + unit_type="steps", + icon="mdi:walk", + ), + FitbitSensorEntityDescription( + key="body/bmi", + name="BMI", + unit_type="BMI", + icon="mdi:human", + ), + FitbitSensorEntityDescription( + key="body/fat", + name="Body Fat", + unit_type=PERCENTAGE, + icon="mdi:human", + ), + FitbitSensorEntityDescription( + key="body/weight", + name="Weight", + unit_type="", + icon="mdi:human", + ), + FitbitSensorEntityDescription( + key="sleep/awakeningsCount", + name="Awakenings Count", + unit_type="times awaken", + icon="mdi:sleep", + ), + FitbitSensorEntityDescription( + key="sleep/efficiency", + name="Sleep Efficiency", + unit_type=PERCENTAGE, + icon="mdi:sleep", + ), + FitbitSensorEntityDescription( + key="sleep/minutesAfterWakeup", + name="Minutes After Wakeup", + unit_type=TIME_MINUTES, + icon="mdi:sleep", + ), + FitbitSensorEntityDescription( + key="sleep/minutesAsleep", + name="Sleep Minutes Asleep", + unit_type=TIME_MINUTES, + icon="mdi:sleep", + ), + FitbitSensorEntityDescription( + key="sleep/minutesAwake", + name="Sleep Minutes Awake", + unit_type=TIME_MINUTES, + icon="mdi:sleep", + ), + FitbitSensorEntityDescription( + key="sleep/minutesToFallAsleep", + name="Sleep Minutes to Fall Asleep", + unit_type=TIME_MINUTES, + icon="mdi:sleep", + ), + FitbitSensorEntityDescription( + key="sleep/startTime", + name="Sleep Start Time", + unit_type=None, + icon="mdi:clock", + ), + FitbitSensorEntityDescription( + key="sleep/timeInBed", + name="Sleep Time in Bed", + unit_type=TIME_MINUTES, + icon="mdi:hotel", + ), +) + +FITBIT_RESOURCE_BATTERY = FitbitSensorEntityDescription( + key="devices/battery", + name="Battery", + unit_type=None, + icon="mdi:battery", +) + +FITBIT_RESOURCES_KEYS: Final[list[str]] = [ + desc.key for desc in (*FITBIT_RESOURCES_LIST, FITBIT_RESOURCE_BATTERY) +] FITBIT_MEASUREMENTS: Final[dict[str, dict[str, str]]] = { "en_US": { diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index 0bd4ed36199..34c4f61f554 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -48,7 +48,10 @@ from .const import ( FITBIT_CONFIG_FILE, FITBIT_DEFAULT_RESOURCES, FITBIT_MEASUREMENTS, + FITBIT_RESOURCE_BATTERY, + FITBIT_RESOURCES_KEYS, FITBIT_RESOURCES_LIST, + FitbitSensorEntityDescription, ) _LOGGER: Final = logging.getLogger(__name__) @@ -61,7 +64,7 @@ PLATFORM_SCHEMA: Final = PARENT_PLATFORM_SCHEMA.extend( { vol.Optional( CONF_MONITORED_RESOURCES, default=FITBIT_DEFAULT_RESOURCES - ): vol.All(cv.ensure_list, [vol.In(FITBIT_RESOURCES_LIST)]), + ): vol.All(cv.ensure_list, [vol.In(FITBIT_RESOURCES_KEYS)]), vol.Optional(CONF_CLOCK_FORMAT, default=DEFAULT_CLOCK_FORMAT): vol.In( ["12H", "24H"] ), @@ -188,8 +191,7 @@ def setup_platform( if int(time.time()) - expires_at > 3600: authd_client.client.refresh_token() - unit_system = config.get(CONF_UNIT_SYSTEM) - if unit_system == "default": + if (unit_system := config[CONF_UNIT_SYSTEM]) == "default": authd_client.system = authd_client.user_profile_get()["user"]["locale"] if authd_client.system != "en_GB": if hass.config.units.is_metric: @@ -199,35 +201,35 @@ def setup_platform( else: authd_client.system = unit_system - dev = [] registered_devs = authd_client.get_devices() - clock_format = config.get(CONF_CLOCK_FORMAT, DEFAULT_CLOCK_FORMAT) - for resource in config.get(CONF_MONITORED_RESOURCES, FITBIT_DEFAULT_RESOURCES): - - # monitor battery for all linked FitBit devices - if resource == "devices/battery": - for dev_extra in registered_devs: - dev.append( - FitbitSensor( - authd_client, - config_path, - resource, - hass.config.units.is_metric, - clock_format, - dev_extra, - ) - ) - else: - dev.append( + clock_format = config[CONF_CLOCK_FORMAT] + monitored_resources = config[CONF_MONITORED_RESOURCES] + entities = [ + FitbitSensor( + authd_client, + config_path, + description, + hass.config.units.is_metric, + clock_format, + ) + for description in FITBIT_RESOURCES_LIST + if description.key in monitored_resources + ] + if "devices/battery" in monitored_resources: + entities.extend( + [ FitbitSensor( authd_client, config_path, - resource, + FITBIT_RESOURCE_BATTERY, hass.config.units.is_metric, clock_format, + dev_extra, ) - ) - add_entities(dev, True) + for dev_extra in registered_devs + ] + ) + add_entities(entities, True) else: oauth = FitbitOauth2Client( @@ -335,28 +337,28 @@ class FitbitAuthCallbackView(HomeAssistantView): class FitbitSensor(SensorEntity): """Implementation of a Fitbit sensor.""" + entity_description: FitbitSensorEntityDescription + def __init__( self, client: Fitbit, config_path: str, - resource_type: str, + description: FitbitSensorEntityDescription, is_metric: bool, clock_format: str, extra: dict[str, str] | None = None, ) -> None: """Initialize the Fitbit sensor.""" + self.entity_description = description self.client = client self.config_path = config_path - self.resource_type = resource_type self.is_metric = is_metric self.clock_format = clock_format self.extra = extra - self._name = FITBIT_RESOURCES_LIST[self.resource_type][0] if self.extra is not None: - self._name = f"{self.extra.get('deviceVersion')} Battery" - unit_type = FITBIT_RESOURCES_LIST[self.resource_type][1] - if unit_type == "": - split_resource = self.resource_type.split("/") + self._attr_name = f"{self.extra.get('deviceVersion')} Battery" + if (unit_type := description.unit_type) == "": + split_resource = description.key.rsplit("/", maxsplit=1)[-1] try: measurement_system = FITBIT_MEASUREMENTS[self.client.system] except KeyError: @@ -364,43 +366,24 @@ class FitbitSensor(SensorEntity): measurement_system = FITBIT_MEASUREMENTS["metric"] else: measurement_system = FITBIT_MEASUREMENTS["en_US"] - unit_type = measurement_system[split_resource[-1]] - self._unit_of_measurement = unit_type - self._state: str | None = None + unit_type = measurement_system[split_resource] + self._attr_native_unit_of_measurement = unit_type @property - def name(self) -> str: - """Return the name of the sensor.""" - return self._name - - @property - def native_value(self) -> str | None: - """Return the state of the sensor.""" - return self._state - - @property - def native_unit_of_measurement(self) -> str | None: - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement - - @property - def icon(self) -> str: + def icon(self) -> str | None: """Icon to use in the frontend, if any.""" - if self.resource_type == "devices/battery" and self.extra is not None: + if self.entity_description.key == "devices/battery" and self.extra is not None: extra_battery = self.extra.get("battery") if extra_battery is not None: battery_level = BATTERY_LEVELS.get(extra_battery) if battery_level is not None: return icon_for_battery_level(battery_level=battery_level) - fitbit_ressource = FITBIT_RESOURCES_LIST[self.resource_type] - return f"mdi:{fitbit_ressource[2]}" + return self.entity_description.icon @property def extra_state_attributes(self) -> dict[str, str | None]: """Return the state attributes.""" - attrs: dict[str, str | None] = {} - - attrs[ATTR_ATTRIBUTION] = ATTRIBUTION + attrs: dict[str, str | None] = {ATTR_ATTRIBUTION: ATTRIBUTION} if self.extra is not None: attrs["model"] = self.extra.get("deviceVersion") @@ -411,31 +394,32 @@ class FitbitSensor(SensorEntity): def update(self) -> None: """Get the latest data from the Fitbit API and update the states.""" - if self.resource_type == "devices/battery" and self.extra is not None: + resource_type = self.entity_description.key + if resource_type == "devices/battery" and self.extra is not None: registered_devs: list[dict[str, Any]] = self.client.get_devices() device_id = self.extra.get("id") self.extra = list( filter(lambda device: device.get("id") == device_id, registered_devs) )[0] - self._state = self.extra.get("battery") + self._attr_native_value = self.extra.get("battery") else: - container = self.resource_type.replace("/", "-") - response = self.client.time_series(self.resource_type, period="7d") + container = resource_type.replace("/", "-") + response = self.client.time_series(resource_type, period="7d") raw_state = response[container][-1].get("value") - if self.resource_type == "activities/distance": - self._state = format(float(raw_state), ".2f") - elif self.resource_type == "activities/tracker/distance": - self._state = format(float(raw_state), ".2f") - elif self.resource_type == "body/bmi": - self._state = format(float(raw_state), ".1f") - elif self.resource_type == "body/fat": - self._state = format(float(raw_state), ".1f") - elif self.resource_type == "body/weight": - self._state = format(float(raw_state), ".1f") - elif self.resource_type == "sleep/startTime": + if resource_type == "activities/distance": + self._attr_native_value = format(float(raw_state), ".2f") + elif resource_type == "activities/tracker/distance": + self._attr_native_value = format(float(raw_state), ".2f") + elif resource_type == "body/bmi": + self._attr_native_value = format(float(raw_state), ".1f") + elif resource_type == "body/fat": + self._attr_native_value = format(float(raw_state), ".1f") + elif resource_type == "body/weight": + self._attr_native_value = format(float(raw_state), ".1f") + elif resource_type == "sleep/startTime": if raw_state == "": - self._state = "-" + self._attr_native_value = "-" elif self.clock_format == "12H": hours, minutes = raw_state.split(":") hours, minutes = int(hours), int(minutes) @@ -445,20 +429,22 @@ class FitbitSensor(SensorEntity): hours -= 12 elif hours == 0: hours = 12 - self._state = f"{hours}:{minutes:02d} {setting}" + self._attr_native_value = f"{hours}:{minutes:02d} {setting}" else: - self._state = raw_state + self._attr_native_value = raw_state else: if self.is_metric: - self._state = raw_state + self._attr_native_value = raw_state else: try: - self._state = f"{int(raw_state):,}" + self._attr_native_value = f"{int(raw_state):,}" except TypeError: - self._state = raw_state + self._attr_native_value = raw_state - if self.resource_type == "activities/heart": - self._state = response[container][-1].get("value").get("restingHeartRate") + if resource_type == "activities/heart": + self._attr_native_value = ( + response[container][-1].get("value").get("restingHeartRate") + ) token = self.client.client.session.token config_contents = { From 750a1b84addc3e9c75de8993924a73fa4a427c2d Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 23 Sep 2021 20:09:00 +0200 Subject: [PATCH 556/843] Add date device_class to Twente Milieu sensors (#56579) --- homeassistant/components/twentemilieu/sensor.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/twentemilieu/sensor.py b/homeassistant/components/twentemilieu/sensor.py index 0069c3db93c..89c750ec865 100644 --- a/homeassistant/components/twentemilieu/sensor.py +++ b/homeassistant/components/twentemilieu/sensor.py @@ -12,7 +12,13 @@ from twentemilieu import ( from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_IDENTIFIERS, ATTR_MANUFACTURER, ATTR_NAME, CONF_ID +from homeassistant.const import ( + ATTR_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_NAME, + CONF_ID, + DEVICE_CLASS_DATE, +) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -74,6 +80,8 @@ async def async_setup_entry( class TwenteMilieuSensor(SensorEntity): """Defines a Twente Milieu sensor.""" + _attr_device_class = DEVICE_CLASS_DATE + def __init__( self, twentemilieu: TwenteMilieu, From 7fc0717ab13eadfe6bc4eb4cd175ff0da2869a61 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 23 Sep 2021 20:12:19 +0200 Subject: [PATCH 557/843] Upgrade debugpy to 1.4.3 (#56576) --- homeassistant/components/debugpy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/debugpy/manifest.json b/homeassistant/components/debugpy/manifest.json index 3ff5d087e14..8d6cab13e62 100644 --- a/homeassistant/components/debugpy/manifest.json +++ b/homeassistant/components/debugpy/manifest.json @@ -2,7 +2,7 @@ "domain": "debugpy", "name": "Remote Python Debugger", "documentation": "https://www.home-assistant.io/integrations/debugpy", - "requirements": ["debugpy==1.4.1"], + "requirements": ["debugpy==1.4.3"], "codeowners": ["@frenck"], "quality_scale": "internal", "iot_class": "local_push" diff --git a/requirements_all.txt b/requirements_all.txt index 8707d7815ab..07507251f73 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -505,7 +505,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.debugpy -debugpy==1.4.1 +debugpy==1.4.3 # homeassistant.components.decora # decora==0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 85f07c84fbb..0b4a7bdb0ce 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -307,7 +307,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.debugpy -debugpy==1.4.1 +debugpy==1.4.3 # homeassistant.components.ihc # homeassistant.components.namecheapdns From 4c40d1767a0d8188e4e6aedfeffd29e68c1939da Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Thu, 23 Sep 2021 21:44:59 +0300 Subject: [PATCH 558/843] Remove config for `Speedtest.net` (#55642) --- .../components/speedtestdotnet/__init__.py | 97 +++---------------- .../components/speedtestdotnet/config_flow.py | 33 +------ .../components/speedtestdotnet/sensor.py | 6 +- .../speedtestdotnet/test_config_flow.py | 44 +-------- 4 files changed, 18 insertions(+), 162 deletions(-) diff --git a/homeassistant/components/speedtestdotnet/__init__.py b/homeassistant/components/speedtestdotnet/__init__.py index 62f7b2dbd73..94c7f8fb039 100644 --- a/homeassistant/components/speedtestdotnet/__init__.py +++ b/homeassistant/components/speedtestdotnet/__init__.py @@ -5,18 +5,11 @@ from datetime import timedelta import logging import speedtest -import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import ( - CONF_MONITORED_CONDITIONS, - CONF_SCAN_INTERVAL, - EVENT_HOMEASSISTANT_STARTED, -) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_SCAN_INTERVAL, EVENT_HOMEASSISTANT_STARTED from homeassistant.core import CoreState, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( @@ -26,59 +19,11 @@ from .const import ( DEFAULT_SERVER, DOMAIN, PLATFORMS, - SENSOR_TYPES, SPEED_TEST_SERVICE, ) _LOGGER = logging.getLogger(__name__) -SENSOR_KEYS = [desc.key for desc in SENSOR_TYPES] - -CONFIG_SCHEMA = vol.Schema( - vol.All( - # Deprecated in Home Assistant 2021.6 - cv.deprecated(DOMAIN), - { - DOMAIN: vol.Schema( - { - vol.Optional(CONF_SERVER_ID): cv.positive_int, - vol.Optional( - CONF_SCAN_INTERVAL, - default=timedelta(minutes=DEFAULT_SCAN_INTERVAL), - ): cv.positive_time_period, - vol.Optional(CONF_MANUAL, default=False): cv.boolean, - vol.Optional( - CONF_MONITORED_CONDITIONS, default=list(SENSOR_KEYS) - ): vol.All(cv.ensure_list, [vol.In(list(SENSOR_KEYS))]), - } - ) - }, - ), - extra=vol.ALLOW_EXTRA, -) - - -def server_id_valid(server_id: str) -> bool: - """Check if server_id is valid.""" - try: - api = speedtest.Speedtest() - api.get_servers([int(server_id)]) - except (speedtest.ConfigRetrievalError, speedtest.NoMatchedServers): - return False - - return True - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Import integration from config.""" - if DOMAIN in config: - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config[DOMAIN] - ) - ) - return True - async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up the Speedtest.net component.""" @@ -145,18 +90,17 @@ class SpeedTestDataCoordinator(DataUpdateCoordinator): for servers in test_servers.values(): for server in servers: test_servers_list.append(server) - if test_servers_list: - for server in sorted( - test_servers_list, - key=lambda server: ( - server["country"], - server["name"], - server["sponsor"], - ), - ): - self.servers[ - f"{server['country']} - {server['sponsor']} - {server['name']}" - ] = server + for server in sorted( + test_servers_list, + key=lambda server: ( + server["country"], + server["name"], + server["sponsor"], + ), + ): + self.servers[ + f"{server['country']} - {server['sponsor']} - {server['name']}" + ] = server def update_data(self): """Get the latest data from speedtest.net.""" @@ -184,19 +128,6 @@ class SpeedTestDataCoordinator(DataUpdateCoordinator): except speedtest.SpeedtestException as err: raise UpdateFailed(err) from err - async def async_set_options(self): - """Set options for entry.""" - if not self.config_entry.options: - data = {**self.config_entry.data} - options = { - CONF_SCAN_INTERVAL: data.pop(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL), - CONF_MANUAL: data.pop(CONF_MANUAL, False), - CONF_SERVER_ID: str(data.pop(CONF_SERVER_ID, "")), - } - self.hass.config_entries.async_update_entry( - self.config_entry, data=data, options=options - ) - async def async_setup(self) -> None: """Set up SpeedTest.""" try: @@ -209,8 +140,6 @@ class SpeedTestDataCoordinator(DataUpdateCoordinator): """Request update.""" await self.async_request_refresh() - await self.async_set_options() - self.hass.services.async_register(DOMAIN, SPEED_TEST_SERVICE, request_update) self.config_entry.async_on_unload( diff --git a/homeassistant/components/speedtestdotnet/config_flow.py b/homeassistant/components/speedtestdotnet/config_flow.py index e5462aa9379..d82ac6bf728 100644 --- a/homeassistant/components/speedtestdotnet/config_flow.py +++ b/homeassistant/components/speedtestdotnet/config_flow.py @@ -6,11 +6,10 @@ from typing import Any import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_SCAN_INTERVAL +from homeassistant.const import CONF_SCAN_INTERVAL from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult -from . import server_id_valid from .const import ( CONF_MANUAL, CONF_SERVER_ID, @@ -47,23 +46,6 @@ class SpeedTestFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=DEFAULT_NAME, data=user_input) - async def async_step_import(self, import_config): - """Import from config.""" - if ( - CONF_SERVER_ID in import_config - and not await self.hass.async_add_executor_job( - server_id_valid, import_config[CONF_SERVER_ID] - ) - ): - return self.async_abort(reason="wrong_server_id") - - import_config[CONF_SCAN_INTERVAL] = int( - import_config[CONF_SCAN_INTERVAL].total_seconds() / 60 - ) - import_config.pop(CONF_MONITORED_CONDITIONS) - - return await self.async_step_user(user_input=import_config) - class SpeedTestOptionsFlowHandler(config_entries.OptionsFlow): """Handle SpeedTest options.""" @@ -91,21 +73,10 @@ class SpeedTestOptionsFlowHandler(config_entries.OptionsFlow): self._servers = self.hass.data[DOMAIN].servers - server = [] - if self.config_entry.options.get( - CONF_SERVER_ID - ) and not self.config_entry.options.get(CONF_SERVER_NAME): - server = [ - key - for (key, value) in self._servers.items() - if value.get("id") == self.config_entry.options[CONF_SERVER_ID] - ] - server_name = server[0] if server else DEFAULT_SERVER - options = { vol.Optional( CONF_SERVER_NAME, - default=self.config_entry.options.get(CONF_SERVER_NAME, server_name), + default=self.config_entry.options.get(CONF_SERVER_NAME, DEFAULT_SERVER), ): vol.In(self._servers.keys()), vol.Optional( CONF_SCAN_INTERVAL, diff --git a/homeassistant/components/speedtestdotnet/sensor.py b/homeassistant/components/speedtestdotnet/sensor.py index 2dc12c956de..8e2d5404438 100644 --- a/homeassistant/components/speedtestdotnet/sensor.py +++ b/homeassistant/components/speedtestdotnet/sensor.py @@ -43,7 +43,6 @@ class SpeedtestSensor(CoordinatorEntity, RestoreEntity, SensorEntity): """Implementation of a speedtest.net sensor.""" coordinator: SpeedTestDataCoordinator - _attr_icon = ICON def __init__( @@ -54,7 +53,6 @@ class SpeedtestSensor(CoordinatorEntity, RestoreEntity, SensorEntity): """Initialize the sensor.""" super().__init__(coordinator) self.entity_description = description - self._attr_name = f"{DEFAULT_NAME} {description.name}" self._attr_unique_id = description.key self._attrs = {ATTR_ATTRIBUTION: ATTRIBUTION} @@ -73,10 +71,10 @@ class SpeedtestSensor(CoordinatorEntity, RestoreEntity, SensorEntity): if self.entity_description.key == "download": self._attrs[ATTR_BYTES_RECEIVED] = self.coordinator.data[ - "bytes_received" + ATTR_BYTES_RECEIVED ] elif self.entity_description.key == "upload": - self._attrs[ATTR_BYTES_SENT] = self.coordinator.data["bytes_sent"] + self._attrs[ATTR_BYTES_SENT] = self.coordinator.data[ATTR_BYTES_SENT] return self._attrs diff --git a/tests/components/speedtestdotnet/test_config_flow.py b/tests/components/speedtestdotnet/test_config_flow.py index 727a5778603..c3c891f6784 100644 --- a/tests/components/speedtestdotnet/test_config_flow.py +++ b/tests/components/speedtestdotnet/test_config_flow.py @@ -2,8 +2,6 @@ from datetime import timedelta from unittest.mock import MagicMock -from speedtest import NoMatchedServers - from homeassistant import config_entries, data_entry_flow from homeassistant.components import speedtestdotnet from homeassistant.components.speedtestdotnet.const import ( @@ -11,9 +9,8 @@ from homeassistant.components.speedtestdotnet.const import ( CONF_SERVER_ID, CONF_SERVER_NAME, DOMAIN, - SENSOR_TYPES, ) -from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_SCAN_INTERVAL +from homeassistant.const import CONF_SCAN_INTERVAL from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -33,45 +30,6 @@ async def test_flow_works(hass: HomeAssistant) -> None: assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY -async def test_import_fails(hass: HomeAssistant, mock_api: MagicMock) -> None: - """Test import step fails if server_id is not valid.""" - - mock_api.return_value.get_servers.side_effect = NoMatchedServers - result = await hass.config_entries.flow.async_init( - speedtestdotnet.DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_SERVER_ID: "223", - CONF_MANUAL: True, - CONF_SCAN_INTERVAL: timedelta(minutes=1), - CONF_MONITORED_CONDITIONS: list(SENSOR_TYPES), - }, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "wrong_server_id" - - -async def test_import_success(hass): - """Test import step is successful if server_id is valid.""" - - result = await hass.config_entries.flow.async_init( - speedtestdotnet.DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_SERVER_ID: "1", - CONF_MANUAL: True, - CONF_SCAN_INTERVAL: timedelta(minutes=1), - CONF_MONITORED_CONDITIONS: list(SENSOR_TYPES), - }, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "SpeedTest" - assert result["data"][CONF_SERVER_ID] == "1" - assert result["data"][CONF_MANUAL] is True - assert result["data"][CONF_SCAN_INTERVAL] == 1 - - async def test_options(hass: HomeAssistant, mock_api: MagicMock) -> None: """Test updating options.""" entry = MockConfigEntry( From a94514b00dc18b1e439ed3ac2515ef2c220aa2d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Thu, 23 Sep 2021 22:19:46 +0200 Subject: [PATCH 559/843] Add Surepetcare entity class (#56430) --- .coveragerc | 1 + .../components/surepetcare/entity.py | 46 +++++++++++++++++++ .../components/surepetcare/sensor.py | 45 ++++++------------ tests/components/surepetcare/test_sensor.py | 6 +-- 4 files changed, 64 insertions(+), 34 deletions(-) create mode 100644 homeassistant/components/surepetcare/entity.py diff --git a/.coveragerc b/.coveragerc index 4caa8fd768c..70e81b377ca 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1006,6 +1006,7 @@ omit = homeassistant/components/suez_water/* homeassistant/components/supervisord/sensor.py homeassistant/components/surepetcare/__init__.py + homeassistant/components/surepetcare/entity.py homeassistant/components/surepetcare/binary_sensor.py homeassistant/components/surepetcare/sensor.py homeassistant/components/swiss_hydrological_data/sensor.py diff --git a/homeassistant/components/surepetcare/entity.py b/homeassistant/components/surepetcare/entity.py new file mode 100644 index 00000000000..8b88282ce96 --- /dev/null +++ b/homeassistant/components/surepetcare/entity.py @@ -0,0 +1,46 @@ +"""Entity for Surepetcare.""" +from __future__ import annotations + +from abc import abstractmethod + +from surepy.entities import SurepyEntity + +from homeassistant.core import callback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import SurePetcareDataCoordinator + + +class SurePetcareEntity(CoordinatorEntity): + """An implementation for Sure Petcare Entities.""" + + def __init__( + self, + surepetcare_id: int, + coordinator: SurePetcareDataCoordinator, + ) -> None: + """Initialize a Sure Petcare entity.""" + super().__init__(coordinator) + + self._id = surepetcare_id + + surepy_entity: SurepyEntity = coordinator.data[surepetcare_id] + + if surepy_entity.name: + self._device_name = surepy_entity.name.capitalize() + else: + self._device_name = surepy_entity.type.name.capitalize().replace("_", " ") + + self._device_id = f"{surepy_entity.household_id}-{surepetcare_id}" + self._update_attr(coordinator.data[surepetcare_id]) + + @abstractmethod + @callback + def _update_attr(self, surepy_entity: SurepyEntity) -> None: + """Update the state and attributes.""" + + @callback + def _handle_coordinator_update(self) -> None: + """Get the latest data and update the state.""" + self._update_attr(self.coordinator.data[self._id]) + self.async_write_ha_state() diff --git a/homeassistant/components/surepetcare/sensor.py b/homeassistant/components/surepetcare/sensor.py index a52c1d7d0ed..01d4e9f83aa 100644 --- a/homeassistant/components/surepetcare/sensor.py +++ b/homeassistant/components/surepetcare/sensor.py @@ -11,12 +11,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_VOLTAGE, DEVICE_CLASS_BATTERY, PERCENTAGE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from . import SurePetcareDataCoordinator from .const import DOMAIN, SURE_BATT_VOLTAGE_DIFF, SURE_BATT_VOLTAGE_LOW +from .entity import SurePetcareEntity _LOGGER = logging.getLogger(__name__) @@ -28,7 +26,7 @@ async def async_setup_entry( entities: list[SureBattery] = [] - coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator: SurePetcareDataCoordinator = hass.data[DOMAIN][entry.entry_id] for surepy_entity in coordinator.data.values(): @@ -43,38 +41,24 @@ async def async_setup_entry( async_add_entities(entities) -class SureBattery(CoordinatorEntity, SensorEntity): +class SureBattery(SurePetcareEntity, SensorEntity): """A sensor implementation for Sure Petcare Entities.""" - def __init__(self, _id: int, coordinator: DataUpdateCoordinator) -> None: - """Initialize a Sure Petcare sensor.""" - super().__init__(coordinator) + _attr_device_class = DEVICE_CLASS_BATTERY + _attr_native_unit_of_measurement = PERCENTAGE - self._id = _id + def __init__( + self, surepetcare_id: int, coordinator: SurePetcareDataCoordinator + ) -> None: + """Initialize a Sure Petcare battery sensor.""" + super().__init__(surepetcare_id, coordinator) - surepy_entity: SurepyEntity = coordinator.data[_id] - - self._attr_device_class = DEVICE_CLASS_BATTERY - if surepy_entity.name: - self._attr_name = f"{surepy_entity.type.name.capitalize()} {surepy_entity.name.capitalize()} Battery Level" - else: - self._attr_name = f"{surepy_entity.type.name.capitalize()} Battery Level" - self._attr_native_unit_of_measurement = PERCENTAGE - self._attr_unique_id = ( - f"{surepy_entity.household_id}-{surepy_entity.id}-battery" - ) - self._update_attr() + self._attr_name = f"{self._device_name} Battery Level" + self._attr_unique_id = f"{self._device_id}-battery" @callback - def _handle_coordinator_update(self) -> None: - """Get the latest data and update the state.""" - self._update_attr() - self.async_write_ha_state() - - @callback - def _update_attr(self) -> None: + def _update_attr(self, surepy_entity: SurepyEntity) -> None: """Update the state and attributes.""" - surepy_entity = self.coordinator.data[self._id] state = surepy_entity.raw_data()["status"] try: @@ -94,4 +78,3 @@ class SureBattery(CoordinatorEntity, SensorEntity): } else: self._attr_extra_state_attributes = {} - _LOGGER.debug("%s -> state: %s", self.name, state) diff --git a/tests/components/surepetcare/test_sensor.py b/tests/components/surepetcare/test_sensor.py index cbf69bb97dc..9edc28dc6dc 100644 --- a/tests/components/surepetcare/test_sensor.py +++ b/tests/components/surepetcare/test_sensor.py @@ -6,9 +6,9 @@ from homeassistant.setup import async_setup_component from . import HOUSEHOLD_ID, MOCK_CONFIG EXPECTED_ENTITY_IDS = { - "sensor.pet_flap_pet_flap_battery_level": f"{HOUSEHOLD_ID}-13576-battery", - "sensor.cat_flap_cat_flap_battery_level": f"{HOUSEHOLD_ID}-13579-battery", - "sensor.feeder_feeder_battery_level": f"{HOUSEHOLD_ID}-12345-battery", + "sensor.pet_flap_battery_level": f"{HOUSEHOLD_ID}-13576-battery", + "sensor.cat_flap_battery_level": f"{HOUSEHOLD_ID}-13579-battery", + "sensor.feeder_battery_level": f"{HOUSEHOLD_ID}-12345-battery", } From 6e7bc65e2e31b82becc5d5ac474712af5c019e4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Thu, 23 Sep 2021 22:20:30 +0200 Subject: [PATCH 560/843] Airthings (#56578) --- .coveragerc | 2 + CODEOWNERS | 1 + .../components/airthings/__init__.py | 61 +++++++++ .../components/airthings/config_flow.py | 67 +++++++++ homeassistant/components/airthings/const.py | 6 + .../components/airthings/manifest.json | 11 ++ homeassistant/components/airthings/sensor.py | 127 ++++++++++++++++++ .../components/airthings/strings.json | 21 +++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/airthings/__init__.py | 1 + .../components/airthings/test_config_flow.py | 117 ++++++++++++++++ 13 files changed, 421 insertions(+) create mode 100644 homeassistant/components/airthings/__init__.py create mode 100644 homeassistant/components/airthings/config_flow.py create mode 100644 homeassistant/components/airthings/const.py create mode 100644 homeassistant/components/airthings/manifest.json create mode 100644 homeassistant/components/airthings/sensor.py create mode 100644 homeassistant/components/airthings/strings.json create mode 100644 tests/components/airthings/__init__.py create mode 100644 tests/components/airthings/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 70e81b377ca..9cc1e4b9533 100644 --- a/.coveragerc +++ b/.coveragerc @@ -36,6 +36,8 @@ omit = homeassistant/components/agent_dvr/helpers.py homeassistant/components/airnow/__init__.py homeassistant/components/airnow/sensor.py + homeassistant/components/airthings/__init__.py + homeassistant/components/airthings/sensor.py homeassistant/components/airtouch4/__init__.py homeassistant/components/airtouch4/climate.py homeassistant/components/airtouch4/const.py diff --git a/CODEOWNERS b/CODEOWNERS index 76748306cfe..375842e33cd 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -29,6 +29,7 @@ homeassistant/components/aemet/* @noltari homeassistant/components/agent_dvr/* @ispysoftware homeassistant/components/airly/* @bieniu homeassistant/components/airnow/* @asymworks +homeassistant/components/airthings/* @danielhiversen homeassistant/components/airtouch4/* @LonePurpleWolf homeassistant/components/airvisual/* @bachya homeassistant/components/alarmdecoder/* @ajschmidt8 diff --git a/homeassistant/components/airthings/__init__.py b/homeassistant/components/airthings/__init__.py new file mode 100644 index 00000000000..601396d36da --- /dev/null +++ b/homeassistant/components/airthings/__init__.py @@ -0,0 +1,61 @@ +"""The Airthings integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +from airthings import Airthings, AirthingsError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONF_ID, CONF_SECRET, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS: list[str] = ["sensor"] +SCAN_INTERVAL = timedelta(minutes=6) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Airthings from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + + airthings = Airthings( + entry.data[CONF_ID], + entry.data[CONF_SECRET], + async_get_clientsession(hass), + ) + + async def _update_method(): + """Get the latest data from Airthings.""" + try: + return await airthings.update_devices() + except AirthingsError as err: + raise UpdateFailed(f"Unable to fetch data: {err}") from err + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name=DOMAIN, + update_method=_update_method, + update_interval=SCAN_INTERVAL, + ) + await coordinator.async_config_entry_first_refresh() + + hass.data[DOMAIN][entry.entry_id] = coordinator + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/airthings/config_flow.py b/homeassistant/components/airthings/config_flow.py new file mode 100644 index 00000000000..842f05d76db --- /dev/null +++ b/homeassistant/components/airthings/config_flow.py @@ -0,0 +1,67 @@ +"""Config flow for Airthings integration.""" +from __future__ import annotations + +import logging +from typing import Any + +import airthings +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_ID, CONF_SECRET, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_ID): str, + vol.Required(CONF_SECRET): str, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Airthings.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + description_placeholders={ + "url": "https://dashboard.airthings.com/integrations/api-integration", + }, + ) + + errors = {} + + try: + await airthings.get_token( + async_get_clientsession(self.hass), + user_input[CONF_ID], + user_input[CONF_SECRET], + ) + except airthings.AirthingsConnectionError: + errors["base"] = "cannot_connect" + except airthings.AirthingsAuthError: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(user_input[CONF_ID]) + self._abort_if_unique_id_configured() + + return self.async_create_entry(title="Airthings", data=user_input) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/airthings/const.py b/homeassistant/components/airthings/const.py new file mode 100644 index 00000000000..70de549141b --- /dev/null +++ b/homeassistant/components/airthings/const.py @@ -0,0 +1,6 @@ +"""Constants for the Airthings integration.""" + +DOMAIN = "airthings" + +CONF_ID = "id" +CONF_SECRET = "secret" diff --git a/homeassistant/components/airthings/manifest.json b/homeassistant/components/airthings/manifest.json new file mode 100644 index 00000000000..749a5e44992 --- /dev/null +++ b/homeassistant/components/airthings/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "airthings", + "name": "Airthings", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/airthings", + "requirements": ["airthings_cloud==0.0.1"], + "codeowners": [ + "@danielhiversen" + ], + "iot_class": "cloud_polling" +} \ No newline at end of file diff --git a/homeassistant/components/airthings/sensor.py b/homeassistant/components/airthings/sensor.py new file mode 100644 index 00000000000..b40e8b06400 --- /dev/null +++ b/homeassistant/components/airthings/sensor.py @@ -0,0 +1,127 @@ +"""Support for Airthings sensors.""" +from __future__ import annotations + +from airthings import AirthingsDevice + +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + SensorEntity, + SensorEntityDescription, + StateType, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_CO2, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_TEMPERATURE, + PERCENTAGE, + PRESSURE_MBAR, + TEMP_CELSIUS, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import DOMAIN + +SENSORS: dict[str, SensorEntityDescription] = { + "radonShortTermAvg": SensorEntityDescription( + key="radonShortTermAvg", + native_unit_of_measurement="Bq/m³", + name="Radon", + ), + "temp": SensorEntityDescription( + key="temp", + device_class=DEVICE_CLASS_TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + name="Temperature", + ), + "humidity": SensorEntityDescription( + key="humidity", + device_class=DEVICE_CLASS_HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + name="Humidity", + ), + "pressure": SensorEntityDescription( + key="pressure", + device_class=DEVICE_CLASS_PRESSURE, + native_unit_of_measurement=PRESSURE_MBAR, + name="Pressure", + ), + "battery": SensorEntityDescription( + key="battery", + device_class=DEVICE_CLASS_BATTERY, + native_unit_of_measurement=PERCENTAGE, + name="Battery", + ), + "co2": SensorEntityDescription( + key="co2", + device_class=DEVICE_CLASS_CO2, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + name="CO2", + ), + "voc": SensorEntityDescription( + key="voc", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, + name="VOC", + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Airthings sensor.""" + + coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + entities = [ + AirthingsHeaterEnergySensor( + coordinator, + airthings_device, + SENSORS[sensor_types], + ) + for airthings_device in coordinator.data.values() + for sensor_types in airthings_device.sensor_types + if sensor_types in SENSORS + ] + async_add_entities(entities) + + +class AirthingsHeaterEnergySensor(CoordinatorEntity, SensorEntity): + """Representation of a Airthings Sensor device.""" + + _attr_state_class = STATE_CLASS_MEASUREMENT + + def __init__( + self, + coordinator: DataUpdateCoordinator, + airthings_device: AirthingsDevice, + entity_description: SensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + + self.entity_description = entity_description + + self._attr_name = f"{airthings_device.name} {entity_description.name}" + self._attr_unique_id = f"{airthings_device.device_id}_{entity_description.key}" + self._id = airthings_device.device_id + self._attr_device_info = { + "identifiers": {(DOMAIN, airthings_device.device_id)}, + "name": self.name, + "manufacturer": "Airthings", + } + + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + return self.coordinator.data[self._id].sensors[self.entity_description.key] diff --git a/homeassistant/components/airthings/strings.json b/homeassistant/components/airthings/strings.json new file mode 100644 index 00000000000..32f3fbc6954 --- /dev/null +++ b/homeassistant/components/airthings/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "step": { + "user": { + "data": { + "id": "ID", + "secret": "Secret", + "description": "Login at {url} to find your credentials" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 0983da03f98..78dc71976e6 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -16,6 +16,7 @@ FLOWS = [ "agent_dvr", "airly", "airnow", + "airthings", "airtouch4", "airvisual", "alarmdecoder", diff --git a/requirements_all.txt b/requirements_all.txt index 07507251f73..be626ba7ba0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -263,6 +263,9 @@ aioymaps==1.1.0 # homeassistant.components.airly airly==1.1.0 +# homeassistant.components.airthings +airthings_cloud==0.0.1 + # homeassistant.components.airtouch4 airtouch4pyapi==1.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0b4a7bdb0ce..32badcb7b66 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -187,6 +187,9 @@ aioymaps==1.1.0 # homeassistant.components.airly airly==1.1.0 +# homeassistant.components.airthings +airthings_cloud==0.0.1 + # homeassistant.components.airtouch4 airtouch4pyapi==1.0.5 diff --git a/tests/components/airthings/__init__.py b/tests/components/airthings/__init__.py new file mode 100644 index 00000000000..e331fb2f2c6 --- /dev/null +++ b/tests/components/airthings/__init__.py @@ -0,0 +1 @@ +"""Tests for the Airthings integration.""" diff --git a/tests/components/airthings/test_config_flow.py b/tests/components/airthings/test_config_flow.py new file mode 100644 index 00000000000..ad9d44a054a --- /dev/null +++ b/tests/components/airthings/test_config_flow.py @@ -0,0 +1,117 @@ +"""Test the Airthings config flow.""" +from unittest.mock import patch + +import airthings + +from homeassistant import config_entries, setup +from homeassistant.components.airthings.const import CONF_ID, CONF_SECRET, DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM + +from tests.common import MockConfigEntry + +TEST_DATA = { + CONF_ID: "client_id", + CONF_SECRET: "secret", +} + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + with patch("airthings.get_token", return_value="test_token",), patch( + "homeassistant.components.airthings.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_DATA, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "Airthings" + assert result2["data"] == TEST_DATA + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_auth(hass: HomeAssistant) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "airthings.get_token", + side_effect=airthings.AirthingsAuthError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_DATA, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_cannot_connect(hass: HomeAssistant) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "airthings.get_token", + side_effect=airthings.AirthingsConnectionError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_DATA, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_unknown_error(hass: HomeAssistant) -> None: + """Test we handle unknown error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "airthings.get_token", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_DATA, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "unknown"} + + +async def test_flow_entry_already_exists(hass: HomeAssistant) -> None: + """Test user input for config_entry that already exists.""" + + first_entry = MockConfigEntry( + domain="airthings", + data=TEST_DATA, + unique_id=TEST_DATA[CONF_ID], + ) + first_entry.add_to_hass(hass) + + with patch("airthings.get_token", return_value="token"): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=TEST_DATA + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" From 972db29c8856cfcf436fae2ec127eabbb6d24006 Mon Sep 17 00:00:00 2001 From: RenierM26 <66512715+RenierM26@users.noreply.github.com> Date: Thu, 23 Sep 2021 22:27:34 +0200 Subject: [PATCH 561/843] Add sensor to switchbot platform (#56416) Co-authored-by: J. Nick Koston --- .coveragerc | 1 + .../components/switchbot/__init__.py | 4 +- homeassistant/components/switchbot/entity.py | 2 +- homeassistant/components/switchbot/sensor.py | 93 +++++++++++++++++++ homeassistant/components/switchbot/switch.py | 2 +- 5 files changed, 98 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/switchbot/sensor.py diff --git a/.coveragerc b/.coveragerc index 9cc1e4b9533..3525c99f960 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1019,6 +1019,7 @@ omit = homeassistant/components/switchbot/const.py homeassistant/components/switchbot/entity.py homeassistant/components/switchbot/cover.py + homeassistant/components/switchbot/sensor.py homeassistant/components/switchbot/coordinator.py homeassistant/components/switchmate/switch.py homeassistant/components/syncthing/__init__.py diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py index 2bf91dc3a55..f85b5737818 100644 --- a/homeassistant/components/switchbot/__init__.py +++ b/homeassistant/components/switchbot/__init__.py @@ -27,8 +27,8 @@ from .const import ( from .coordinator import SwitchbotDataUpdateCoordinator PLATFORMS_BY_TYPE = { - ATTR_BOT: ["switch"], - ATTR_CURTAIN: ["cover"], + ATTR_BOT: ["switch", "sensor"], + ATTR_CURTAIN: ["cover", "sensor"], } diff --git a/homeassistant/components/switchbot/entity.py b/homeassistant/components/switchbot/entity.py index 6b316789384..d6e88174d79 100644 --- a/homeassistant/components/switchbot/entity.py +++ b/homeassistant/components/switchbot/entity.py @@ -30,7 +30,7 @@ class SwitchbotEntity(CoordinatorEntity, Entity): self._attr_name = name self._attr_device_info: DeviceInfo = { "connections": {(dr.CONNECTION_NETWORK_MAC, self._mac)}, - "name": self._attr_name, + "name": name, "model": self.data["modelName"], "manufacturer": MANUFACTURER, } diff --git a/homeassistant/components/switchbot/sensor.py b/homeassistant/components/switchbot/sensor.py new file mode 100644 index 00000000000..78b078c26b4 --- /dev/null +++ b/homeassistant/components/switchbot/sensor.py @@ -0,0 +1,93 @@ +"""Support for SwitchBot sensors.""" +from __future__ import annotations + +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_MAC, + CONF_NAME, + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_SIGNAL_STRENGTH, + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DATA_COORDINATOR, DOMAIN +from .coordinator import SwitchbotDataUpdateCoordinator +from .entity import SwitchbotEntity + +PARALLEL_UPDATES = 1 + +SENSOR_TYPES: dict[str, SensorEntityDescription] = { + "rssi": SensorEntityDescription( + key="rssi", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + entity_registry_enabled_default=False, + ), + "battery": SensorEntityDescription( + key="battery", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_BATTERY, + ), + "lightLevel": SensorEntityDescription( + key="lightLevel", + native_unit_of_measurement="Level", + device_class=DEVICE_CLASS_ILLUMINANCE, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Switchbot sensor based on a config entry.""" + coordinator: SwitchbotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ + DATA_COORDINATOR + ] + + if not coordinator.data[entry.unique_id].get("data"): + return + + async_add_entities( + [ + SwitchBotSensor( + coordinator, + entry.unique_id, + sensor, + entry.data[CONF_MAC], + entry.data[CONF_NAME], + ) + for sensor in coordinator.data[entry.unique_id]["data"] + if sensor in SENSOR_TYPES + ] + ) + + +class SwitchBotSensor(SwitchbotEntity, SensorEntity): + """Representation of a Switchbot sensor.""" + + coordinator: SwitchbotDataUpdateCoordinator + + def __init__( + self, + coordinator: SwitchbotDataUpdateCoordinator, + idx: str | None, + sensor: str, + mac: str, + switchbot_name: str, + ) -> None: + """Initialize the Switchbot sensor.""" + super().__init__(coordinator, idx, mac, name=switchbot_name) + self._sensor = sensor + self._attr_unique_id = f"{idx}-{sensor}" + self._attr_name = f"{switchbot_name} {sensor.title()}" + self.entity_description = SENSOR_TYPES[sensor] + + @property + def native_value(self) -> str: + """Return the state of the sensor.""" + return self.data["data"][self._sensor] diff --git a/homeassistant/components/switchbot/switch.py b/homeassistant/components/switchbot/switch.py index d8e90fd9925..22e4bb33f1a 100644 --- a/homeassistant/components/switchbot/switch.py +++ b/homeassistant/components/switchbot/switch.py @@ -111,7 +111,7 @@ class SwitchBotBotEntity(SwitchbotEntity, SwitchEntity, RestoreEntity): ) -> None: """Initialize the Switchbot.""" super().__init__(coordinator, idx, mac, name) - self._attr_unique_id = self._mac.replace(":", "") + self._attr_unique_id = idx self._device = device async def async_added_to_hass(self) -> None: From 7ece35cd6f36f73be43da547df9bced4b5269560 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Thu, 23 Sep 2021 22:29:12 +0200 Subject: [PATCH 562/843] Assume Fritz!Smarthome device as unavailable (#56542) --- homeassistant/components/fritzbox/__init__.py | 12 ++++++++++++ tests/components/fritzbox/__init__.py | 1 + tests/components/fritzbox/test_switch.py | 16 ++++++++++++++++ 3 files changed, 29 insertions(+) diff --git a/homeassistant/components/fritzbox/__init__.py b/homeassistant/components/fritzbox/__init__.py index d9226f36c87..8d354f655f6 100644 --- a/homeassistant/components/fritzbox/__init__.py +++ b/homeassistant/components/fritzbox/__init__.py @@ -68,6 +68,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: data = {} for device in devices: device.update() + + # assume device as unavailable, see #55799 + if ( + device.has_powermeter + and device.present + and hasattr(device, "voltage") + and device.voltage <= 0 + and device.power <= 0 + and device.energy <= 0 + ): + device.present = False + data[device.ain] = device return data diff --git a/tests/components/fritzbox/__init__.py b/tests/components/fritzbox/__init__.py index da6bd982d9d..dfa266dc15b 100644 --- a/tests/components/fritzbox/__init__.py +++ b/tests/components/fritzbox/__init__.py @@ -108,6 +108,7 @@ class FritzDeviceSwitchMock(FritzDeviceBaseMock): battery_level = None device_lock = "fake_locked_device" energy = 1234 + voltage = 230 fw_version = "1.2.3" has_alarm = False has_powermeter = True diff --git a/tests/components/fritzbox/test_switch.py b/tests/components/fritzbox/test_switch.py index 27461b2790f..b44a4ffc088 100644 --- a/tests/components/fritzbox/test_switch.py +++ b/tests/components/fritzbox/test_switch.py @@ -26,6 +26,7 @@ from homeassistant.const import ( SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_ON, + STATE_UNAVAILABLE, TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant @@ -137,3 +138,18 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock): assert device.update.call_count == 2 assert fritz().login.call_count == 2 + + +async def test_assume_device_unavailable(hass: HomeAssistant, fritz: Mock): + """Test assume device as unavailable.""" + device = FritzDeviceSwitchMock() + device.voltage = 0 + device.energy = 0 + device.power = 0 + assert await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + + state = hass.states.get(ENTITY_ID) + assert state + assert state.state == STATE_UNAVAILABLE From 0b53f73fe26af8ecf34ce91bd767426bacc9d6ba Mon Sep 17 00:00:00 2001 From: Milan Meulemans Date: Thu, 23 Sep 2021 22:37:37 +0200 Subject: [PATCH 563/843] Convert Nanoleaf integration to use Async library aionanoleaf (#56548) --- .coveragerc | 1 - homeassistant/components/nanoleaf/__init__.py | 15 +-- .../components/nanoleaf/config_flow.py | 43 +++---- homeassistant/components/nanoleaf/light.py | 77 +++++++------ .../components/nanoleaf/manifest.json | 2 +- homeassistant/components/nanoleaf/util.py | 7 -- requirements_all.txt | 6 +- requirements_test_all.txt | 6 +- tests/components/nanoleaf/test_config_flow.py | 106 ++++++++---------- 9 files changed, 127 insertions(+), 136 deletions(-) delete mode 100644 homeassistant/components/nanoleaf/util.py diff --git a/.coveragerc b/.coveragerc index 3525c99f960..44f66468821 100644 --- a/.coveragerc +++ b/.coveragerc @@ -695,7 +695,6 @@ omit = homeassistant/components/nad/media_player.py homeassistant/components/nanoleaf/__init__.py homeassistant/components/nanoleaf/light.py - homeassistant/components/nanoleaf/util.py homeassistant/components/neato/__init__.py homeassistant/components/neato/api.py homeassistant/components/neato/camera.py diff --git a/homeassistant/components/nanoleaf/__init__.py b/homeassistant/components/nanoleaf/__init__.py index be61bbc65a3..313af5b0ae3 100644 --- a/homeassistant/components/nanoleaf/__init__.py +++ b/homeassistant/components/nanoleaf/__init__.py @@ -1,21 +1,22 @@ """The Nanoleaf integration.""" -from pynanoleaf.pynanoleaf import InvalidToken, Nanoleaf, Unavailable +from aionanoleaf import InvalidToken, Nanoleaf, Unavailable from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DEVICE, DOMAIN, NAME, SERIAL_NO -from .util import pynanoleaf_get_info async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Nanoleaf from a config entry.""" - nanoleaf = Nanoleaf(entry.data[CONF_HOST]) - nanoleaf.token = entry.data[CONF_TOKEN] + nanoleaf = Nanoleaf( + async_get_clientsession(hass), entry.data[CONF_HOST], entry.data[CONF_TOKEN] + ) try: - info = await hass.async_add_executor_job(pynanoleaf_get_info, nanoleaf) + await nanoleaf.get_info() except Unavailable as err: raise ConfigEntryNotReady from err except InvalidToken as err: @@ -23,8 +24,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { DEVICE: nanoleaf, - NAME: info["name"], - SERIAL_NO: info["serialNo"], + NAME: nanoleaf.name, + SERIAL_NO: nanoleaf.serial_no, } hass.async_create_task( diff --git a/homeassistant/components/nanoleaf/config_flow.py b/homeassistant/components/nanoleaf/config_flow.py index 9edfd23e6a9..d5fc023d3a1 100644 --- a/homeassistant/components/nanoleaf/config_flow.py +++ b/homeassistant/components/nanoleaf/config_flow.py @@ -5,17 +5,17 @@ import logging import os from typing import Any, Final, cast -from pynanoleaf import InvalidToken, Nanoleaf, NotAuthorizingNewTokens, Unavailable +from aionanoleaf import InvalidToken, Nanoleaf, Unauthorized, Unavailable import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import DiscoveryInfoType from homeassistant.util.json import load_json, save_json from .const import DOMAIN -from .util import pynanoleaf_get_info _LOGGER = logging.getLogger(__name__) @@ -53,9 +53,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=USER_SCHEMA, last_step=False ) self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) - self.nanoleaf = Nanoleaf(user_input[CONF_HOST]) + self.nanoleaf = Nanoleaf( + async_get_clientsession(self.hass), user_input[CONF_HOST] + ) try: - await self.hass.async_add_executor_job(self.nanoleaf.authorize) + await self.nanoleaf.authorize() except Unavailable: return self.async_show_form( step_id="user", @@ -63,7 +65,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors={"base": "cannot_connect"}, last_step=False, ) - except NotAuthorizingNewTokens: + except Unauthorized: pass except Exception: # pylint: disable=broad-except _LOGGER.exception("Unknown error connecting to Nanoleaf") @@ -81,7 +83,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): config_entries.ConfigEntry, self.hass.config_entries.async_get_entry(self.context["entry_id"]), ) - self.nanoleaf = Nanoleaf(data[CONF_HOST]) + self.nanoleaf = Nanoleaf(async_get_clientsession(self.hass), data[CONF_HOST]) self.context["title_placeholders"] = {"name": self.reauth_entry.title} return await self.async_step_link() @@ -106,7 +108,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): name = discovery_info["name"].replace(f".{discovery_info['type']}", "") await self.async_set_unique_id(name) self._abort_if_unique_id_configured({CONF_HOST: host}) - self.nanoleaf = Nanoleaf(host) # Import from discovery integration self.device_id = discovery_info["properties"]["id"] @@ -116,16 +117,19 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): load_json, self.hass.config.path(CONFIG_FILE) ), ) - self.nanoleaf.token = self.discovery_conf.get(self.device_id, {}).get( + auth_token: str | None = self.discovery_conf.get(self.device_id, {}).get( "token", # >= 2021.4 self.discovery_conf.get(host, {}).get("token"), # < 2021.4 ) - if self.nanoleaf.token is not None: + if auth_token is not None: + self.nanoleaf = Nanoleaf( + async_get_clientsession(self.hass), host, auth_token + ) _LOGGER.warning( "Importing Nanoleaf %s from the discovery integration", name ) return await self.async_setup_finish(discovery_integration_import=True) - + self.nanoleaf = Nanoleaf(async_get_clientsession(self.hass), host) self.context["title_placeholders"] = {"name": name} return await self.async_step_link() @@ -137,8 +141,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_show_form(step_id="link") try: - await self.hass.async_add_executor_job(self.nanoleaf.authorize) - except NotAuthorizingNewTokens: + await self.nanoleaf.authorize() + except Unauthorized: return self.async_show_form( step_id="link", errors={"base": "not_allowing_new_tokens"} ) @@ -153,7 +157,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.reauth_entry, data={ **self.reauth_entry.data, - CONF_TOKEN: self.nanoleaf.token, + CONF_TOKEN: self.nanoleaf.auth_token, }, ) await self.hass.config_entries.async_reload(self.reauth_entry.entry_id) @@ -167,8 +171,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.debug( "Importing Nanoleaf on %s from your configuration.yaml", config[CONF_HOST] ) - self.nanoleaf = Nanoleaf(config[CONF_HOST]) - self.nanoleaf.token = config[CONF_TOKEN] + self.nanoleaf = Nanoleaf( + async_get_clientsession(self.hass), config[CONF_HOST], config[CONF_TOKEN] + ) return await self.async_setup_finish() async def async_setup_finish( @@ -176,9 +181,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) -> FlowResult: """Finish Nanoleaf config flow.""" try: - info = await self.hass.async_add_executor_job( - pynanoleaf_get_info, self.nanoleaf - ) + await self.nanoleaf.get_info() except Unavailable: return self.async_abort(reason="cannot_connect") except InvalidToken: @@ -188,7 +191,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): "Unknown error connecting with Nanoleaf at %s", self.nanoleaf.host ) return self.async_abort(reason="unknown") - name = info["name"] + name = self.nanoleaf.name await self.async_set_unique_id(name) self._abort_if_unique_id_configured({CONF_HOST: self.nanoleaf.host}) @@ -215,6 +218,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): title=name, data={ CONF_HOST: self.nanoleaf.host, - CONF_TOKEN: self.nanoleaf.token, + CONF_TOKEN: self.nanoleaf.auth_token, }, ) diff --git a/homeassistant/components/nanoleaf/light.py b/homeassistant/components/nanoleaf/light.py index b50edf82179..0a80a3f7d60 100644 --- a/homeassistant/components/nanoleaf/light.py +++ b/homeassistant/components/nanoleaf/light.py @@ -3,7 +3,8 @@ from __future__ import annotations import logging -from pynanoleaf import Unavailable +from aiohttp import ServerDisconnectedError +from aionanoleaf import Unavailable import voluptuous as vol from homeassistant.components.light import ( @@ -153,7 +154,7 @@ class NanoleafLight(LightEntity): @property def is_on(self): """Return true if light is on.""" - return self._state + return self._light.is_on @property def hs_color(self): @@ -165,7 +166,7 @@ class NanoleafLight(LightEntity): """Flag supported features.""" return SUPPORT_NANOLEAF - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Instruct the light to turn on.""" brightness = kwargs.get(ATTR_BRIGHTNESS) hs_color = kwargs.get(ATTR_HS_COLOR) @@ -175,57 +176,61 @@ class NanoleafLight(LightEntity): if hs_color: hue, saturation = hs_color - self._light.hue = int(hue) - self._light.saturation = int(saturation) + await self._light.set_hue(int(hue)) + await self._light.set_saturation(int(saturation)) if color_temp_mired: - self._light.color_temperature = mired_to_kelvin(color_temp_mired) - + await self._light.set_color_temperature(mired_to_kelvin(color_temp_mired)) if transition: if brightness: # tune to the required brightness in n seconds - self._light.brightness_transition( - int(brightness / 2.55), int(transition) + await self._light.set_brightness( + int(brightness / 2.55), transition=int(kwargs[ATTR_TRANSITION]) ) else: # If brightness is not specified, assume full brightness - self._light.brightness_transition(100, int(transition)) + await self._light.set_brightness( + 100, transition=int(kwargs[ATTR_TRANSITION]) + ) else: # If no transition is occurring, turn on the light - self._light.on = True + await self._light.turn_on() if brightness: - self._light.brightness = int(brightness / 2.55) - + await self._light.set_brightness(int(brightness / 2.55)) if effect: if effect not in self._effects_list: raise ValueError( f"Attempting to apply effect not in the effect list: '{effect}'" ) - self._light.effect = effect + await self._light.set_effect(effect) - def turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Instruct the light to turn off.""" transition = kwargs.get(ATTR_TRANSITION) if transition: - self._light.brightness_transition(0, int(transition)) + await self._light.set_brightness(0, transition=int(transition)) else: - self._light.on = False + await self._light.turn_off() - def update(self): + async def async_update(self) -> None: """Fetch new state data for this light.""" try: - self._available = self._light.available - self._brightness = self._light.brightness - self._effects_list = self._light.effects - # Nanoleaf api returns non-existent effect named "*Solid*" when light set to solid color. - # This causes various issues with scening (see https://github.com/home-assistant/core/issues/36359). - # Until fixed at the library level, we should ensure the effect exists before saving to light properties - self._effect = ( - self._light.effect if self._light.effect in self._effects_list else None - ) - if self._effect is None: - self._color_temp = self._light.color_temperature - self._hs_color = self._light.hue, self._light.saturation - else: - self._color_temp = None - self._hs_color = None - self._state = self._light.on - except Unavailable as err: - _LOGGER.error("Could not update status for %s (%s)", self.name, err) + await self._light.get_info() + except ServerDisconnectedError: + # Retry the request once if the device disconnected + await self._light.get_info() + except Unavailable: self._available = False + return + self._available = True + self._brightness = self._light.brightness + self._effects_list = self._light.effects_list + # Nanoleaf api returns non-existent effect named "*Solid*" when light set to solid color. + # This causes various issues with scening (see https://github.com/home-assistant/core/issues/36359). + # Until fixed at the library level, we should ensure the effect exists before saving to light properties + self._effect = ( + self._light.effect if self._light.effect in self._effects_list else None + ) + if self._effect is None: + self._color_temp = self._light.color_temperature + self._hs_color = self._light.hue, self._light.saturation + else: + self._color_temp = None + self._hs_color = None + self._state = self._light.is_on diff --git a/homeassistant/components/nanoleaf/manifest.json b/homeassistant/components/nanoleaf/manifest.json index 42a9f512d3d..31576fd73a7 100644 --- a/homeassistant/components/nanoleaf/manifest.json +++ b/homeassistant/components/nanoleaf/manifest.json @@ -3,7 +3,7 @@ "name": "Nanoleaf", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nanoleaf", - "requirements": ["pynanoleaf==0.1.0"], + "requirements": ["aionanoleaf==0.0.1"], "zeroconf": ["_nanoleafms._tcp.local.", "_nanoleafapi._tcp.local."], "homekit" : { "models": [ diff --git a/homeassistant/components/nanoleaf/util.py b/homeassistant/components/nanoleaf/util.py deleted file mode 100644 index 0031622e90b..00000000000 --- a/homeassistant/components/nanoleaf/util.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Nanoleaf integration util.""" -from pynanoleaf.pynanoleaf import Nanoleaf - - -def pynanoleaf_get_info(nanoleaf_light: Nanoleaf) -> dict: - """Get Nanoleaf light info.""" - return nanoleaf_light.info diff --git a/requirements_all.txt b/requirements_all.txt index be626ba7ba0..d9ba931e894 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -218,6 +218,9 @@ aiomodernforms==0.1.8 # homeassistant.components.yamaha_musiccast aiomusiccast==0.9.2 +# homeassistant.components.nanoleaf +aionanoleaf==0.0.1 + # homeassistant.components.keyboard_remote aionotify==0.2.0 @@ -1649,9 +1652,6 @@ pymyq==3.1.4 # homeassistant.components.mysensors pymysensors==0.21.0 -# homeassistant.components.nanoleaf -pynanoleaf==0.1.0 - # homeassistant.components.nello pynello==2.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 32badcb7b66..2352fed82e0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -145,6 +145,9 @@ aiomodernforms==0.1.8 # homeassistant.components.yamaha_musiccast aiomusiccast==0.9.2 +# homeassistant.components.nanoleaf +aionanoleaf==0.0.1 + # homeassistant.components.notion aionotion==3.0.2 @@ -968,9 +971,6 @@ pymyq==3.1.4 # homeassistant.components.mysensors pymysensors==0.21.0 -# homeassistant.components.nanoleaf -pynanoleaf==0.1.0 - # homeassistant.components.netgear pynetgear==0.7.0 diff --git a/tests/components/nanoleaf/test_config_flow.py b/tests/components/nanoleaf/test_config_flow.py index 93db43e40c9..8f62830b219 100644 --- a/tests/components/nanoleaf/test_config_flow.py +++ b/tests/components/nanoleaf/test_config_flow.py @@ -1,10 +1,9 @@ """Test the Nanoleaf config flow.""" from __future__ import annotations -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch -from pynanoleaf import InvalidToken, NotAuthorizingNewTokens, Unavailable -from pynanoleaf.pynanoleaf import NanoleafError +from aionanoleaf import InvalidToken, NanoleafException, Unauthorized, Unavailable import pytest from homeassistant import config_entries @@ -23,6 +22,21 @@ TEST_DEVICE_ID = "5E:2E:EA:XX:XX:XX" TEST_OTHER_DEVICE_ID = "5E:2E:EA:YY:YY:YY" +def _mock_nanoleaf( + host: str = TEST_HOST, + auth_token: str = TEST_TOKEN, + authorize_error: Exception | None = None, + get_info_error: Exception | None = None, +): + nanoleaf = MagicMock() + nanoleaf.name = TEST_NAME + nanoleaf.host = host + nanoleaf.auth_token = auth_token + nanoleaf.authorize = AsyncMock(side_effect=authorize_error) + nanoleaf.get_info = AsyncMock(side_effect=get_info_error) + return nanoleaf + + async def test_user_unavailable_user_step_link_step(hass: HomeAssistant) -> None: """Test we handle Unavailable in user and link step.""" result = await hass.config_entries.flow.async_init( @@ -30,7 +44,7 @@ async def test_user_unavailable_user_step_link_step(hass: HomeAssistant) -> None ) with patch( "homeassistant.components.nanoleaf.config_flow.Nanoleaf.authorize", - side_effect=Unavailable("message"), + side_effect=Unavailable, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -58,7 +72,7 @@ async def test_user_unavailable_user_step_link_step(hass: HomeAssistant) -> None with patch( "homeassistant.components.nanoleaf.config_flow.Nanoleaf.authorize", - side_effect=Unavailable("message"), + side_effect=Unavailable, ): result3 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -71,8 +85,8 @@ async def test_user_unavailable_user_step_link_step(hass: HomeAssistant) -> None @pytest.mark.parametrize( "error, reason", [ - (Unavailable("message"), "cannot_connect"), - (InvalidToken("message"), "invalid_token"), + (Unavailable, "cannot_connect"), + (InvalidToken, "invalid_token"), (Exception, "unknown"), ], ) @@ -85,7 +99,6 @@ async def test_user_error_setup_finish( ) with patch( "homeassistant.components.nanoleaf.config_flow.Nanoleaf.authorize", - return_value=None, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -98,9 +111,8 @@ async def test_user_error_setup_finish( with patch( "homeassistant.components.nanoleaf.config_flow.Nanoleaf.authorize", - return_value=None, ), patch( - "homeassistant.components.nanoleaf.config_flow.pynanoleaf_get_info", + "homeassistant.components.nanoleaf.config_flow.Nanoleaf.get_info", side_effect=error, ): result3 = await hass.config_entries.flow.async_configure( @@ -117,19 +129,10 @@ async def test_user_not_authorizing_new_tokens_user_step_link_step( """Test we handle NotAuthorizingNewTokens in user step and link step.""" with patch( "homeassistant.components.nanoleaf.config_flow.Nanoleaf", + return_value=_mock_nanoleaf(authorize_error=Unauthorized()), ) as mock_nanoleaf, patch( - "homeassistant.components.nanoleaf.config_flow.pynanoleaf_get_info", - return_value={"name": TEST_NAME}, - ), patch( "homeassistant.components.nanoleaf.async_setup_entry", return_value=True ) as mock_setup_entry: - nanoleaf = mock_nanoleaf.return_value - nanoleaf.authorize.side_effect = NotAuthorizingNewTokens( - "Not authorizing new tokens" - ) - nanoleaf.host = TEST_HOST - nanoleaf.token = TEST_TOKEN - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -160,8 +163,7 @@ async def test_user_not_authorizing_new_tokens_user_step_link_step( assert result4["errors"] == {"base": "not_allowing_new_tokens"} assert result4["step_id"] == "link" - nanoleaf.authorize.side_effect = None - nanoleaf.authorize.return_value = None + mock_nanoleaf.return_value.authorize.side_effect = None result5 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -183,8 +185,8 @@ async def test_user_exception_user_step(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) with patch( - "homeassistant.components.nanoleaf.config_flow.Nanoleaf.authorize", - side_effect=Exception, + "homeassistant.components.nanoleaf.config_flow.Nanoleaf", + return_value=_mock_nanoleaf(authorize_error=Exception()), ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -198,36 +200,29 @@ async def test_user_exception_user_step(hass: HomeAssistant) -> None: assert not result2["last_step"] with patch( - "homeassistant.components.nanoleaf.config_flow.Nanoleaf.authorize", - return_value=None, - ): + "homeassistant.components.nanoleaf.config_flow.Nanoleaf", + return_value=_mock_nanoleaf(), + ) as mock_nanoleaf: result3 = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_HOST: TEST_HOST, }, ) - assert result3["step_id"] == "link" + assert result3["step_id"] == "link" + + mock_nanoleaf.return_value.authorize.side_effect = Exception() - with patch( - "homeassistant.components.nanoleaf.config_flow.Nanoleaf.authorize", - side_effect=Exception, - ): result4 = await hass.config_entries.flow.async_configure( result["flow_id"], {}, ) - assert result4["type"] == "form" - assert result4["step_id"] == "link" - assert result4["errors"] == {"base": "unknown"} + assert result4["type"] == "form" + assert result4["step_id"] == "link" + assert result4["errors"] == {"base": "unknown"} - with patch( - "homeassistant.components.nanoleaf.config_flow.Nanoleaf.authorize", - return_value=None, - ), patch( - "homeassistant.components.nanoleaf.config_flow.pynanoleaf_get_info", - side_effect=Exception, - ): + mock_nanoleaf.return_value.authorize.side_effect = None + mock_nanoleaf.return_value.get_info.side_effect = Exception() result5 = await hass.config_entries.flow.async_configure( result["flow_id"], {}, @@ -249,8 +244,7 @@ async def test_discovery_link_unavailable( ) -> None: """Test discovery and abort if device is unavailable.""" with patch( - "homeassistant.components.nanoleaf.config_flow.pynanoleaf_get_info", - return_value={"name": TEST_NAME}, + "homeassistant.components.nanoleaf.config_flow.Nanoleaf.get_info", ), patch( "homeassistant.components.nanoleaf.config_flow.load_json", return_value={}, @@ -278,7 +272,7 @@ async def test_discovery_link_unavailable( with patch( "homeassistant.components.nanoleaf.config_flow.Nanoleaf.authorize", - side_effect=Unavailable("message"), + side_effect=Unavailable, ): result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result["type"] == "abort" @@ -287,10 +281,6 @@ async def test_discovery_link_unavailable( async def test_reauth(hass: HomeAssistant) -> None: """Test Nanoleaf reauth flow.""" - nanoleaf = MagicMock() - nanoleaf.host = TEST_HOST - nanoleaf.token = TEST_TOKEN - entry = MockConfigEntry( domain=DOMAIN, unique_id=TEST_NAME, @@ -300,7 +290,7 @@ async def test_reauth(hass: HomeAssistant) -> None: with patch( "homeassistant.components.nanoleaf.config_flow.Nanoleaf", - return_value=nanoleaf, + return_value=_mock_nanoleaf(), ), patch( "homeassistant.components.nanoleaf.async_setup_entry", return_value=True, @@ -331,8 +321,8 @@ async def test_reauth(hass: HomeAssistant) -> None: async def test_import_config(hass: HomeAssistant) -> None: """Test configuration import.""" with patch( - "homeassistant.components.nanoleaf.config_flow.pynanoleaf_get_info", - return_value={"name": TEST_NAME}, + "homeassistant.components.nanoleaf.config_flow.Nanoleaf", + return_value=_mock_nanoleaf(TEST_HOST, TEST_TOKEN), ), patch( "homeassistant.components.nanoleaf.async_setup_entry", return_value=True, @@ -355,17 +345,17 @@ async def test_import_config(hass: HomeAssistant) -> None: @pytest.mark.parametrize( "error, reason", [ - (Unavailable("message"), "cannot_connect"), - (InvalidToken("message"), "invalid_token"), + (Unavailable, "cannot_connect"), + (InvalidToken, "invalid_token"), (Exception, "unknown"), ], ) async def test_import_config_error( - hass: HomeAssistant, error: NanoleafError, reason: str + hass: HomeAssistant, error: NanoleafException, reason: str ) -> None: """Test configuration import with errors in setup_finish.""" with patch( - "homeassistant.components.nanoleaf.config_flow.pynanoleaf_get_info", + "homeassistant.components.nanoleaf.config_flow.Nanoleaf.get_info", side_effect=error, ): result = await hass.config_entries.flow.async_init( @@ -432,8 +422,8 @@ async def test_import_discovery_integration( "homeassistant.components.nanoleaf.config_flow.load_json", return_value=dict(nanoleaf_conf_file), ), patch( - "homeassistant.components.nanoleaf.config_flow.pynanoleaf_get_info", - return_value={"name": TEST_NAME}, + "homeassistant.components.nanoleaf.config_flow.Nanoleaf", + return_value=_mock_nanoleaf(TEST_HOST, TEST_TOKEN), ), patch( "homeassistant.components.nanoleaf.config_flow.save_json", return_value=None, From 915afedcfc7afe3022682c3e72fe91cbc7267e22 Mon Sep 17 00:00:00 2001 From: RenierM26 <66512715+RenierM26@users.noreply.github.com> Date: Fri, 24 Sep 2021 00:10:34 +0200 Subject: [PATCH 564/843] Add binary_sensor to switchbot (#56415) Co-authored-by: J. Nick Koston --- .coveragerc | 1 + .../components/switchbot/__init__.py | 2 +- .../components/switchbot/binary_sensor.py | 75 +++++++++++++++++++ 3 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/switchbot/binary_sensor.py diff --git a/.coveragerc b/.coveragerc index 44f66468821..c961d0b749d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1014,6 +1014,7 @@ omit = homeassistant/components/swiss_public_transport/sensor.py homeassistant/components/swisscom/device_tracker.py homeassistant/components/switchbot/switch.py + homeassistant/components/switchbot/binary_sensor.py homeassistant/components/switchbot/__init__.py homeassistant/components/switchbot/const.py homeassistant/components/switchbot/entity.py diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py index f85b5737818..421f6cab866 100644 --- a/homeassistant/components/switchbot/__init__.py +++ b/homeassistant/components/switchbot/__init__.py @@ -28,7 +28,7 @@ from .coordinator import SwitchbotDataUpdateCoordinator PLATFORMS_BY_TYPE = { ATTR_BOT: ["switch", "sensor"], - ATTR_CURTAIN: ["cover", "sensor"], + ATTR_CURTAIN: ["cover", "binary_sensor", "sensor"], } diff --git a/homeassistant/components/switchbot/binary_sensor.py b/homeassistant/components/switchbot/binary_sensor.py new file mode 100644 index 00000000000..d58e244d57c --- /dev/null +++ b/homeassistant/components/switchbot/binary_sensor.py @@ -0,0 +1,75 @@ +"""Support for SwitchBot binary sensors.""" +from __future__ import annotations + +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_MAC, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DATA_COORDINATOR, DOMAIN +from .coordinator import SwitchbotDataUpdateCoordinator +from .entity import SwitchbotEntity + +PARALLEL_UPDATES = 1 + +BINARY_SENSOR_TYPES: dict[str, BinarySensorEntityDescription] = { + "calibration": BinarySensorEntityDescription( + key="calibration", + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Switchbot curtain based on a config entry.""" + coordinator: SwitchbotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ + DATA_COORDINATOR + ] + + if not coordinator.data[entry.unique_id].get("data"): + return + + async_add_entities( + [ + SwitchBotBinarySensor( + coordinator, + entry.unique_id, + binary_sensor, + entry.data[CONF_MAC], + entry.data[CONF_NAME], + ) + for binary_sensor in coordinator.data[entry.unique_id]["data"] + if binary_sensor in BINARY_SENSOR_TYPES + ] + ) + + +class SwitchBotBinarySensor(SwitchbotEntity, BinarySensorEntity): + """Representation of a Switchbot binary sensor.""" + + coordinator: SwitchbotDataUpdateCoordinator + + def __init__( + self, + coordinator: SwitchbotDataUpdateCoordinator, + idx: str | None, + binary_sensor: str, + mac: str, + switchbot_name: str, + ) -> None: + """Initialize the Switchbot sensor.""" + super().__init__(coordinator, idx, mac, name=switchbot_name) + self._sensor = binary_sensor + self._attr_unique_id = f"{idx}-{binary_sensor}" + self._attr_name = f"{switchbot_name} {binary_sensor.title()}" + self.entity_description = BINARY_SENSOR_TYPES[binary_sensor] + + @property + def is_on(self) -> bool: + """Return the state of the sensor.""" + return self.data["data"][self._sensor] From 0363c22dd89571986fa20486b310259e21d45156 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Sep 2021 22:39:20 -0500 Subject: [PATCH 565/843] Fix Sonos going offline with 13.3 firmware (#56590) --- homeassistant/components/sonos/__init__.py | 8 ++++++-- homeassistant/components/sonos/config_flow.py | 5 +++-- homeassistant/components/sonos/helpers.py | 10 ---------- homeassistant/components/sonos/speaker.py | 7 +++---- tests/components/sonos/test_config_flow.py | 1 + tests/components/sonos/test_helpers.py | 10 +--------- 6 files changed, 14 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index 6650e5f8904..aafcba744ea 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -90,6 +90,7 @@ class SonosData: self.discovery_ignored: set[str] = set() self.discovery_known: set[str] = set() self.boot_counts: dict[str, int] = {} + self.mdns_names: dict[str, str] = {} async def async_setup(hass, config): @@ -273,12 +274,12 @@ class SonosDiscoveryManager: if uid.startswith("uuid:"): uid = uid[5:] self.async_discovered_player( - "SSDP", info, discovered_ip, uid, boot_seqnum, info.get("modelName") + "SSDP", info, discovered_ip, uid, boot_seqnum, info.get("modelName"), None ) @callback def async_discovered_player( - self, source, info, discovered_ip, uid, boot_seqnum, model + self, source, info, discovered_ip, uid, boot_seqnum, model, mdns_name ): """Handle discovery via ssdp or zeroconf.""" if model in DISCOVERY_IGNORED_MODELS: @@ -287,6 +288,9 @@ class SonosDiscoveryManager: if boot_seqnum: boot_seqnum = int(boot_seqnum) self.data.boot_counts.setdefault(uid, boot_seqnum) + if mdns_name: + self.data.mdns_names[uid] = mdns_name + if uid not in self.data.discovery_known: _LOGGER.debug("New %s discovery uid=%s: %s", source, uid, info) self.data.discovery_known.add(uid) diff --git a/homeassistant/components/sonos/config_flow.py b/homeassistant/components/sonos/config_flow.py index 3bbdf2d9a26..3fa3bbb8fa8 100644 --- a/homeassistant/components/sonos/config_flow.py +++ b/homeassistant/components/sonos/config_flow.py @@ -4,7 +4,7 @@ import logging import soco from homeassistant import config_entries -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.config_entry_flow import DiscoveryFlowHandler @@ -38,13 +38,14 @@ class SonosDiscoveryFlowHandler(DiscoveryFlowHandler): return self.async_abort(reason="not_sonos_device") await self.async_set_unique_id(self._domain, raise_on_progress=False) host = discovery_info[CONF_HOST] + mdns_name = discovery_info[CONF_NAME] properties = discovery_info["properties"] boot_seqnum = properties.get("bootseq") model = properties.get("model") uid = hostname_to_uid(hostname) if discovery_manager := self.hass.data.get(DATA_SONOS_DISCOVERY_MANAGER): discovery_manager.async_discovered_player( - "Zeroconf", properties, host, uid, boot_seqnum, model + "Zeroconf", properties, host, uid, boot_seqnum, model, mdns_name ) return await self.async_step_discovery(discovery_info) diff --git a/homeassistant/components/sonos/helpers.py b/homeassistant/components/sonos/helpers.py index 0854361cd79..2e5b3a8c032 100644 --- a/homeassistant/components/sonos/helpers.py +++ b/homeassistant/components/sonos/helpers.py @@ -41,16 +41,6 @@ def soco_error(errorcodes: list[str] | None = None) -> Callable: return decorator -def uid_to_short_hostname(uid: str) -> str: - """Convert a Sonos uid to a short hostname.""" - hostname_uid = uid - if hostname_uid.startswith(UID_PREFIX): - hostname_uid = hostname_uid[len(UID_PREFIX) :] - if hostname_uid.endswith(UID_POSTFIX): - hostname_uid = hostname_uid[: -len(UID_POSTFIX)] - return f"Sonos-{hostname_uid}" - - def hostname_to_uid(hostname: str) -> str: """Convert a Sonos hostname to a uid.""" baseuid = hostname.split("-")[1].replace(".local.", "") diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index 744850380bc..35331dec051 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -59,7 +59,7 @@ from .const import ( SUBSCRIPTION_TIMEOUT, ) from .favorites import SonosFavorites -from .helpers import soco_error, uid_to_short_hostname +from .helpers import soco_error EVENT_CHARGING = { "CHARGING": True, @@ -524,11 +524,10 @@ class SonosSpeaker: self, callback_timestamp: datetime.datetime | None = None ) -> None: """Make this player unavailable when it was not seen recently.""" - if callback_timestamp: + data = self.hass.data[DATA_SONOS] + if callback_timestamp and (zcname := data.mdns_names.get(self.soco.uid)): # Called by a _seen_timer timeout, check mDNS one more time # This should not be checked in an "active" unseen scenario - hostname = uid_to_short_hostname(self.soco.uid) - zcname = f"{hostname}.{MDNS_SERVICE}" aiozeroconf = await zeroconf.async_get_async_instance(self.hass) if await aiozeroconf.async_get_service_info(MDNS_SERVICE, zcname): # We can still see the speaker via zeroconf check again later. diff --git a/tests/components/sonos/test_config_flow.py b/tests/components/sonos/test_config_flow.py index 90ffdb155ea..faf4e07ac9c 100644 --- a/tests/components/sonos/test_config_flow.py +++ b/tests/components/sonos/test_config_flow.py @@ -45,6 +45,7 @@ async def test_zeroconf_form(hass: core.HomeAssistant): context={"source": config_entries.SOURCE_ZEROCONF}, data={ "host": "192.168.4.2", + "name": "Sonos-aaa@Living Room._sonos._tcp.local.", "hostname": "Sonos-aaa", "properties": {"bootseq": "1234"}, }, diff --git a/tests/components/sonos/test_helpers.py b/tests/components/sonos/test_helpers.py index 858657e01c0..a52337f9455 100644 --- a/tests/components/sonos/test_helpers.py +++ b/tests/components/sonos/test_helpers.py @@ -1,15 +1,7 @@ """Test the sonos config flow.""" from __future__ import annotations -from homeassistant.components.sonos.helpers import ( - hostname_to_uid, - uid_to_short_hostname, -) - - -async def test_uid_to_short_hostname(): - """Test we can convert a uid to a short hostname.""" - assert uid_to_short_hostname("RINCON_347E5C0CF1E301400") == "Sonos-347E5C0CF1E3" +from homeassistant.components.sonos.helpers import hostname_to_uid async def test_uid_to_hostname(): From e73ca9bd1836c3caca2e0d1e48bbccca92a94534 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 24 Sep 2021 08:14:45 +0200 Subject: [PATCH 566/843] Alexa fix Fan support and cleanup (#56053) * del PowerLevelController, ena fan PowerController * Use AlexaRangeContoller for speed or default * Update tests * no-else-return * Avoid cases with only one preset_mode * Only report ghost_mode to Alexa - fix bug * Add some tests for patched code * pylint * pylint and tests with one preset_mode * correct ghost preset mode check in test * add tests for RangeController * ghost preset_mode locale agnostic * isort * Update homeassistant/components/alexa/capabilities.py Co-authored-by: Erik Montnemery * Update homeassistant/components/alexa/entities.py Co-authored-by: Erik Montnemery * Update homeassistant/components/alexa/entities.py Co-authored-by: Erik Montnemery * Update homeassistant/components/alexa/entities.py Co-authored-by: Erik Montnemery * Update homeassistant/components/alexa/entities.py Co-authored-by: Erik Montnemery * Update entities.py Co-authored-by: Erik Montnemery --- .../components/alexa/capabilities.py | 62 +++-- homeassistant/components/alexa/const.py | 3 + homeassistant/components/alexa/entities.py | 19 +- homeassistant/components/alexa/handlers.py | 83 +++--- tests/components/alexa/test_capabilities.py | 47 +++- tests/components/alexa/test_smart_home.py | 245 ++++++++++++++---- tests/components/alexa/test_state_report.py | 11 +- 7 files changed, 325 insertions(+), 145 deletions(-) diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index 22022250cc6..46f421963ca 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -48,6 +48,7 @@ from .const import ( API_THERMOSTAT_MODES, API_THERMOSTAT_PRESETS, DATE_FORMAT, + PRESET_MODE_NA, Inputs, ) from .errors import UnsupportedProperty @@ -391,6 +392,8 @@ class AlexaPowerController(AlexaCapability): if self.entity.domain == climate.DOMAIN: is_on = self.entity.state != climate.HVAC_MODE_OFF + elif self.entity.domain == fan.DOMAIN: + is_on = self.entity.state == fan.STATE_ON elif self.entity.domain == vacuum.DOMAIN: is_on = self.entity.state == vacuum.STATE_CLEANING elif self.entity.domain == timer.DOMAIN: @@ -1155,9 +1158,6 @@ class AlexaPowerLevelController(AlexaCapability): if name != "powerLevel": raise UnsupportedProperty(name) - if self.entity.domain == fan.DOMAIN: - return self.entity.attributes.get(fan.ATTR_PERCENTAGE) or 0 - class AlexaSecurityPanelController(AlexaCapability): """Implements Alexa.SecurityPanelController. @@ -1354,10 +1354,17 @@ class AlexaModeController(AlexaCapability): self._resource = AlexaModeResource( [AlexaGlobalCatalog.SETTING_PRESET], False ) - for preset_mode in self.entity.attributes.get(fan.ATTR_PRESET_MODES, []): + preset_modes = self.entity.attributes.get(fan.ATTR_PRESET_MODES, []) + for preset_mode in preset_modes: self._resource.add_mode( f"{fan.ATTR_PRESET_MODE}.{preset_mode}", [preset_mode] ) + # Fans with a single preset_mode completely break Alexa discovery, add a + # fake preset (see issue #53832). + if len(preset_modes) == 1: + self._resource.add_mode( + f"{fan.ATTR_PRESET_MODE}.{PRESET_MODE_NA}", [PRESET_MODE_NA] + ) return self._resource.serialize_capability_resources() # Cover Position Resources @@ -1491,6 +1498,13 @@ class AlexaRangeController(AlexaCapability): if self.instance == f"{cover.DOMAIN}.tilt": return self.entity.attributes.get(cover.ATTR_CURRENT_TILT_POSITION) + # Fan speed percentage + if self.instance == f"{fan.DOMAIN}.{fan.ATTR_PERCENTAGE}": + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if supported and fan.SUPPORT_SET_SPEED: + return self.entity.attributes.get(fan.ATTR_PERCENTAGE) + return 100 if self.entity.state == fan.STATE_ON else 0 + # Input Number Value if self.instance == f"{input_number.DOMAIN}.{input_number.ATTR_VALUE}": return float(self.entity.state) @@ -1517,28 +1531,16 @@ class AlexaRangeController(AlexaCapability): def capability_resources(self): """Return capabilityResources object.""" - # Fan Speed Resources - if self.instance == f"{fan.DOMAIN}.{fan.ATTR_SPEED}": - speed_list = self.entity.attributes[fan.ATTR_SPEED_LIST] - max_value = len(speed_list) - 1 + # Fan Speed Percentage Resources + if self.instance == f"{fan.DOMAIN}.{fan.ATTR_PERCENTAGE}": + percentage_step = self.entity.attributes.get(fan.ATTR_PERCENTAGE_STEP) self._resource = AlexaPresetResource( - labels=[AlexaGlobalCatalog.SETTING_FAN_SPEED], + labels=["Percentage", AlexaGlobalCatalog.SETTING_FAN_SPEED], min_value=0, - max_value=max_value, - precision=1, + max_value=100, + precision=percentage_step if percentage_step else 100, + unit=AlexaGlobalCatalog.UNIT_PERCENT, ) - for index, speed in enumerate(speed_list): - labels = [] - if isinstance(speed, str): - labels.append(speed.replace("_", " ")) - if index == 1: - labels.append(AlexaGlobalCatalog.VALUE_MINIMUM) - if index == max_value: - labels.append(AlexaGlobalCatalog.VALUE_MAXIMUM) - - if len(labels) > 0: - self._resource.add_preset(value=index, labels=labels) - return self._resource.serialize_capability_resources() # Cover Position Resources @@ -1651,6 +1653,20 @@ class AlexaRangeController(AlexaCapability): ) return self._semantics.serialize_semantics() + # Fan Speed Percentage + if self.instance == f"{fan.DOMAIN}.{fan.ATTR_PERCENTAGE}": + lower_labels = [AlexaSemantics.ACTION_LOWER] + raise_labels = [AlexaSemantics.ACTION_RAISE] + self._semantics = AlexaSemantics() + + self._semantics.add_action_to_directive( + lower_labels, "SetRangeValue", {"rangeValue": 0} + ) + self._semantics.add_action_to_directive( + raise_labels, "SetRangeValue", {"rangeValue": 100} + ) + return self._semantics.serialize_semantics() + return None diff --git a/homeassistant/components/alexa/const.py b/homeassistant/components/alexa/const.py index de8a4a6fdc4..0532c85dac1 100644 --- a/homeassistant/components/alexa/const.py +++ b/homeassistant/components/alexa/const.py @@ -78,6 +78,9 @@ API_THERMOSTAT_MODES = OrderedDict( API_THERMOSTAT_MODES_CUSTOM = {climate.HVAC_MODE_DRY: "DEHUMIDIFY"} API_THERMOSTAT_PRESETS = {climate.PRESET_ECO: "ECO"} +# AlexaModeController does not like a single mode for the fan preset, we add PRESET_MODE_NA if a fan has only one preset_mode +PRESET_MODE_NA = "-" + class Cause: """Possible causes for property changes. diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index 9a8e56d3551..d74f9329812 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -60,11 +60,9 @@ from .capabilities import ( AlexaLockController, AlexaModeController, AlexaMotionSensor, - AlexaPercentageController, AlexaPlaybackController, AlexaPlaybackStateReporter, AlexaPowerController, - AlexaPowerLevelController, AlexaRangeController, AlexaSceneController, AlexaSecurityPanelController, @@ -530,23 +528,32 @@ class FanCapabilities(AlexaEntity): def interfaces(self): """Yield the supported interfaces.""" yield AlexaPowerController(self.entity) - + force_range_controller = True supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - if supported & fan.SUPPORT_SET_SPEED: - yield AlexaPercentageController(self.entity) - yield AlexaPowerLevelController(self.entity) if supported & fan.SUPPORT_OSCILLATE: yield AlexaToggleController( self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_OSCILLATING}" ) + force_range_controller = False if supported & fan.SUPPORT_PRESET_MODE: yield AlexaModeController( self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_PRESET_MODE}" ) + force_range_controller = False if supported & fan.SUPPORT_DIRECTION: yield AlexaModeController( self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_DIRECTION}" ) + force_range_controller = False + + # AlexaRangeController controls the Fan Speed Percentage. + # For fans which only support on/off, no controller is added. This makes the + # fan impossible to turn on or off through Alexa, most likely due to a bug in Alexa. + # As a workaround, we add a range controller which can only be set to 0% or 100%. + if force_range_controller or supported & fan.SUPPORT_SET_SPEED: + yield AlexaRangeController( + self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_PERCENTAGE}" + ) yield AlexaEndpointHealth(self.hass, self.entity) yield Alexa(self.hass) diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index 192da955e3f..5a23f5d1bc2 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -55,6 +55,7 @@ from .const import ( API_THERMOSTAT_MODES_CUSTOM, API_THERMOSTAT_PRESETS, DATE_FORMAT, + PRESET_MODE_NA, Cause, Inputs, ) @@ -123,6 +124,8 @@ async def async_api_turn_on(hass, config, directive, context): service = SERVICE_TURN_ON if domain == cover.DOMAIN: service = cover.SERVICE_OPEN_COVER + elif domain == fan.DOMAIN: + service = fan.SERVICE_TURN_ON elif domain == vacuum.DOMAIN: supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) if not supported & vacuum.SUPPORT_TURN_ON and supported & vacuum.SUPPORT_START: @@ -157,6 +160,8 @@ async def async_api_turn_off(hass, config, directive, context): service = SERVICE_TURN_OFF if entity.domain == cover.DOMAIN: service = cover.SERVICE_CLOSE_COVER + elif domain == fan.DOMAIN: + service = fan.SERVICE_TURN_OFF elif domain == vacuum.DOMAIN: supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) if ( @@ -826,48 +831,6 @@ async def async_api_reportstate(hass, config, directive, context): return directive.response(name="StateReport") -@HANDLERS.register(("Alexa.PowerLevelController", "SetPowerLevel")) -async def async_api_set_power_level(hass, config, directive, context): - """Process a SetPowerLevel request.""" - entity = directive.entity - service = None - data = {ATTR_ENTITY_ID: entity.entity_id} - - if entity.domain == fan.DOMAIN: - service = fan.SERVICE_SET_PERCENTAGE - percentage = int(directive.payload["powerLevel"]) - data[fan.ATTR_PERCENTAGE] = percentage - - await hass.services.async_call( - entity.domain, service, data, blocking=False, context=context - ) - - return directive.response() - - -@HANDLERS.register(("Alexa.PowerLevelController", "AdjustPowerLevel")) -async def async_api_adjust_power_level(hass, config, directive, context): - """Process an AdjustPowerLevel request.""" - entity = directive.entity - percentage_delta = int(directive.payload["powerLevelDelta"]) - service = None - data = {ATTR_ENTITY_ID: entity.entity_id} - - if entity.domain == fan.DOMAIN: - service = fan.SERVICE_SET_PERCENTAGE - current = entity.attributes.get(fan.ATTR_PERCENTAGE) or 0 - - # set percentage - percentage = min(100, max(0, percentage_delta + current)) - data[fan.ATTR_PERCENTAGE] = percentage - - await hass.services.async_call( - entity.domain, service, data, blocking=False, context=context - ) - - return directive.response() - - @HANDLERS.register(("Alexa.SecurityPanelController", "Arm")) async def async_api_arm(hass, config, directive, context): """Process a Security Panel Arm request.""" @@ -962,7 +925,9 @@ async def async_api_set_mode(hass, config, directive, context): # Fan preset_mode elif instance == f"{fan.DOMAIN}.{fan.ATTR_PRESET_MODE}": preset_mode = mode.split(".")[1] - if preset_mode in entity.attributes.get(fan.ATTR_PRESET_MODES): + if preset_mode != PRESET_MODE_NA and preset_mode in entity.attributes.get( + fan.ATTR_PRESET_MODES + ): service = fan.SERVICE_SET_PRESET_MODE data[fan.ATTR_PRESET_MODE] = preset_mode else: @@ -1114,6 +1079,19 @@ async def async_api_set_range(hass, config, directive, context): service = cover.SERVICE_SET_COVER_TILT_POSITION data[cover.ATTR_TILT_POSITION] = range_value + # Fan Speed + elif instance == f"{fan.DOMAIN}.{fan.ATTR_PERCENTAGE}": + range_value = int(range_value) + if range_value == 0: + service = fan.SERVICE_TURN_OFF + else: + supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if supported and fan.SUPPORT_SET_SPEED: + service = fan.SERVICE_SET_PERCENTAGE + data[fan.ATTR_PERCENTAGE] = range_value + else: + service = fan.SERVICE_TURN_ON + # Input Number Value elif instance == f"{input_number.DOMAIN}.{input_number.ATTR_VALUE}": range_value = float(range_value) @@ -1201,6 +1179,25 @@ async def async_api_adjust_range(hass, config, directive, context): else: data[cover.ATTR_TILT_POSITION] = tilt_position + # Fan speed percentage + elif instance == f"{fan.DOMAIN}.{fan.ATTR_PERCENTAGE}": + percentage_step = entity.attributes.get(fan.ATTR_PERCENTAGE_STEP) or 20 + range_delta = ( + int(range_delta * percentage_step) + if range_delta_default + else int(range_delta) + ) + service = fan.SERVICE_SET_PERCENTAGE + current = entity.attributes.get(fan.ATTR_PERCENTAGE) + if not current: + msg = f"Unable to determine {entity.entity_id} current fan speed" + raise AlexaInvalidValueError(msg) + percentage = response_value = min(100, max(0, range_delta + current)) + if percentage: + data[fan.ATTR_PERCENTAGE] = percentage + else: + service = fan.SERVICE_TURN_OFF + # Input Number Value elif instance == f"{input_number.DOMAIN}.{input_number.ATTR_VALUE}": range_delta = float(range_delta) diff --git a/tests/components/alexa/test_capabilities.py b/tests/components/alexa/test_capabilities.py index bdc19bc792f..d4d6bec62a9 100644 --- a/tests/components/alexa/test_capabilities.py +++ b/tests/components/alexa/test_capabilities.py @@ -383,22 +383,39 @@ async def test_report_fan_speed_state(hass): "percentage": 100, }, ) - + hass.states.async_set( + "fan.speed_less_on", + "on", + { + "friendly_name": "Speedless fan on", + "supported_features": 0, + }, + ) + hass.states.async_set( + "fan.speed_less_off", + "off", + { + "friendly_name": "Speedless fan off", + "supported_features": 0, + }, + ) properties = await reported_properties(hass, "fan.off") - properties.assert_equal("Alexa.PercentageController", "percentage", 0) - properties.assert_equal("Alexa.PowerLevelController", "powerLevel", 0) + properties.assert_equal("Alexa.RangeController", "rangeValue", 0) properties = await reported_properties(hass, "fan.low_speed") - properties.assert_equal("Alexa.PercentageController", "percentage", 33) - properties.assert_equal("Alexa.PowerLevelController", "powerLevel", 33) + properties.assert_equal("Alexa.RangeController", "rangeValue", 33) properties = await reported_properties(hass, "fan.medium_speed") - properties.assert_equal("Alexa.PercentageController", "percentage", 66) - properties.assert_equal("Alexa.PowerLevelController", "powerLevel", 66) + properties.assert_equal("Alexa.RangeController", "rangeValue", 66) properties = await reported_properties(hass, "fan.high_speed") - properties.assert_equal("Alexa.PercentageController", "percentage", 100) - properties.assert_equal("Alexa.PowerLevelController", "powerLevel", 100) + properties.assert_equal("Alexa.RangeController", "rangeValue", 100) + + properties = await reported_properties(hass, "fan.speed_less_on") + properties.assert_equal("Alexa.RangeController", "rangeValue", 100) + + properties = await reported_properties(hass, "fan.speed_less_off") + properties.assert_equal("Alexa.RangeController", "rangeValue", 0) async def test_report_fan_preset_mode(hass): @@ -442,6 +459,18 @@ async def test_report_fan_preset_mode(hass): properties = await reported_properties(hass, "fan.preset_mode") properties.assert_equal("Alexa.ModeController", "mode", "preset_mode.whoosh") + hass.states.async_set( + "fan.preset_mode", + "whoosh", + { + "friendly_name": "one preset mode fan", + "supported_features": 8, + "preset_mode": "auto", + "preset_modes": ["auto"], + }, + ) + properties = await reported_properties(hass, "fan.preset_mode") + async def test_report_fan_oscillating(hass): """Test ToggleController reports fan oscillating correctly.""" diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 998be054186..71123ca27ba 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -365,14 +365,42 @@ async def test_fan(hass): assert appliance["endpointId"] == "fan#test_1" assert appliance["displayCategories"][0] == "FAN" assert appliance["friendlyName"] == "Test fan 1" + # Alexa.RangeController is added to make a van controllable when no other controllers are available capabilities = assert_endpoint_capabilities( - appliance, "Alexa.PowerController", "Alexa.EndpointHealth", "Alexa" + appliance, + "Alexa.RangeController", + "Alexa.PowerController", + "Alexa.EndpointHealth", + "Alexa", ) power_capability = get_capability(capabilities, "Alexa.PowerController") assert "capabilityResources" not in power_capability assert "configuration" not in power_capability + await assert_power_controller_works( + "fan#test_1", "fan.turn_on", "fan.turn_off", hass + ) + + await assert_request_calls_service( + "Alexa.RangeController", + "SetRangeValue", + "fan#test_1", + "fan.turn_on", + hass, + payload={"rangeValue": "100"}, + instance="fan.percentage", + ) + await assert_request_calls_service( + "Alexa.RangeController", + "SetRangeValue", + "fan#test_1", + "fan.turn_off", + hass, + payload={"rangeValue": "0"}, + instance="fan.percentage", + ) + async def test_variable_fan(hass): """Test fan discovery. @@ -396,103 +424,133 @@ async def test_variable_fan(hass): capabilities = assert_endpoint_capabilities( appliance, - "Alexa.PercentageController", + "Alexa.RangeController", "Alexa.PowerController", - "Alexa.PowerLevelController", "Alexa.EndpointHealth", "Alexa", ) - capability = get_capability(capabilities, "Alexa.PercentageController") + capability = get_capability(capabilities, "Alexa.RangeController") assert capability is not None capability = get_capability(capabilities, "Alexa.PowerController") assert capability is not None - capability = get_capability(capabilities, "Alexa.PowerLevelController") - assert capability is not None - call, _ = await assert_request_calls_service( - "Alexa.PercentageController", - "SetPercentage", + "Alexa.RangeController", + "SetRangeValue", "fan#test_2", "fan.set_percentage", hass, - payload={"percentage": "50"}, + payload={"rangeValue": "50"}, + instance="fan.percentage", ) assert call.data["percentage"] == 50 call, _ = await assert_request_calls_service( - "Alexa.PercentageController", - "SetPercentage", + "Alexa.RangeController", + "SetRangeValue", "fan#test_2", "fan.set_percentage", hass, - payload={"percentage": "33"}, + payload={"rangeValue": "33"}, + instance="fan.percentage", ) assert call.data["percentage"] == 33 call, _ = await assert_request_calls_service( - "Alexa.PercentageController", - "SetPercentage", + "Alexa.RangeController", + "SetRangeValue", "fan#test_2", "fan.set_percentage", hass, - payload={"percentage": "100"}, + payload={"rangeValue": "100"}, + instance="fan.percentage", ) assert call.data["percentage"] == 100 - await assert_percentage_changes( + await assert_range_changes( hass, - [(95, "-5"), (100, "5"), (20, "-80"), (66, "-34")], - "Alexa.PercentageController", - "AdjustPercentage", + [ + (95, -5, False), + (100, 5, False), + (20, -80, False), + (66, -34, False), + (80, -1, True), + (20, -4, True), + ], + "Alexa.RangeController", + "AdjustRangeValue", "fan#test_2", - "percentageDelta", "fan.set_percentage", "percentage", + "fan.percentage", + ) + await assert_range_changes( + hass, + [ + (0, -100, False), + ], + "Alexa.RangeController", + "AdjustRangeValue", + "fan#test_2", + "fan.turn_off", + None, + "fan.percentage", ) - call, _ = await assert_request_calls_service( - "Alexa.PowerLevelController", - "SetPowerLevel", - "fan#test_2", - "fan.set_percentage", - hass, - payload={"powerLevel": "20"}, - ) - assert call.data["percentage"] == 20 - call, _ = await assert_request_calls_service( - "Alexa.PowerLevelController", - "SetPowerLevel", - "fan#test_2", - "fan.set_percentage", - hass, - payload={"powerLevel": "50"}, - ) - assert call.data["percentage"] == 50 +async def test_variable_fan_no_current_speed(hass, caplog): + """Test fan discovery. - call, _ = await assert_request_calls_service( - "Alexa.PowerLevelController", - "SetPowerLevel", - "fan#test_2", - "fan.set_percentage", - hass, - payload={"powerLevel": "99"}, + This one has variable speed, but no current speed. + """ + device = ( + "fan.test_3", + "off", + { + "friendly_name": "Test fan 3", + "supported_features": 1, + "percentage": None, + }, ) - assert call.data["percentage"] == 99 + appliance = await discovery_test(device, hass) - await assert_percentage_changes( - hass, - [(95, "-5"), (50, "-50"), (20, "-80")], - "Alexa.PowerLevelController", - "AdjustPowerLevel", - "fan#test_2", - "powerLevelDelta", - "fan.set_percentage", - "percentage", + assert appliance["endpointId"] == "fan#test_3" + assert appliance["displayCategories"][0] == "FAN" + assert appliance["friendlyName"] == "Test fan 3" + # Alexa.RangeController is added to make a van controllable when no other controllers are available + capabilities = assert_endpoint_capabilities( + appliance, + "Alexa.RangeController", + "Alexa.PowerController", + "Alexa.EndpointHealth", + "Alexa", ) + capability = get_capability(capabilities, "Alexa.RangeController") + assert capability is not None + + capability = get_capability(capabilities, "Alexa.PowerController") + assert capability is not None + + with pytest.raises(AssertionError): + await assert_range_changes( + hass, + [ + (20, -5, False), + ], + "Alexa.RangeController", + "AdjustRangeValue", + "fan#test_3", + "fan.set_percentage", + "percentage", + "fan.percentage", + ) + assert ( + "Request Alexa.RangeController/AdjustRangeValue error INVALID_VALUE: Unable to determine fan.test_3 current fan speed" + in caplog.text + ) + caplog.clear() async def test_oscillating_fan(hass): @@ -742,6 +800,78 @@ async def test_preset_mode_fan(hass, caplog): caplog.clear() +async def test_single_preset_mode_fan(hass, caplog): + """Test fan discovery. + + This one has only preset mode. + """ + device = ( + "fan.test_8", + "off", + { + "friendly_name": "Test fan 8", + "supported_features": 8, + "preset_modes": ["auto"], + "preset_mode": "auto", + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "fan#test_8" + assert appliance["displayCategories"][0] == "FAN" + assert appliance["friendlyName"] == "Test fan 8" + + capabilities = assert_endpoint_capabilities( + appliance, + "Alexa.EndpointHealth", + "Alexa.ModeController", + "Alexa.PowerController", + "Alexa", + ) + + range_capability = get_capability(capabilities, "Alexa.ModeController") + assert range_capability is not None + assert range_capability["instance"] == "fan.preset_mode" + + properties = range_capability["properties"] + assert properties["nonControllable"] is False + assert {"name": "mode"} in properties["supported"] + + capability_resources = range_capability["capabilityResources"] + assert capability_resources is not None + assert { + "@type": "asset", + "value": {"assetId": "Alexa.Setting.Preset"}, + } in capability_resources["friendlyNames"] + + configuration = range_capability["configuration"] + assert configuration is not None + + call, _ = await assert_request_calls_service( + "Alexa.ModeController", + "SetMode", + "fan#test_8", + "fan.set_preset_mode", + hass, + payload={"mode": "preset_mode.auto"}, + instance="fan.preset_mode", + ) + assert call.data["preset_mode"] == "auto" + + with pytest.raises(AssertionError): + await assert_request_calls_service( + "Alexa.ModeController", + "SetMode", + "fan#test_8", + "fan.set_preset_mode", + hass, + payload={"mode": "preset_mode.-"}, + instance="fan.preset_mode", + ) + assert "Entity 'fan.test_8' does not support Preset '-'" in caplog.text + caplog.clear() + + async def test_lock(hass): """Test lock discovery.""" device = ("lock.test", "off", {"friendly_name": "Test lock"}) @@ -1615,7 +1745,8 @@ async def assert_range_changes( call, _ = await assert_request_calls_service( namespace, name, endpoint, service, hass, payload=payload, instance=instance ) - assert call.data[changed_parameter] == result_range + if changed_parameter: + assert call.data[changed_parameter] == result_range async def test_temp_sensor(hass): diff --git a/tests/components/alexa/test_state_report.py b/tests/components/alexa/test_state_report.py index 729d9d6e467..bd91dc8f846 100644 --- a/tests/components/alexa/test_state_report.py +++ b/tests/components/alexa/test_state_report.py @@ -97,15 +97,12 @@ async def test_report_state_instance(hass, aioclient_mock): assert report["instance"] == "fan.preset_mode" assert report["namespace"] == "Alexa.ModeController" checks += 1 - if report["name"] == "percentage": + if report["name"] == "rangeValue": assert report["value"] == 90 - assert report["namespace"] == "Alexa.PercentageController" + assert report["instance"] == "fan.percentage" + assert report["namespace"] == "Alexa.RangeController" checks += 1 - if report["name"] == "powerLevel": - assert report["value"] == 90 - assert report["namespace"] == "Alexa.PowerLevelController" - checks += 1 - assert checks == 4 + assert checks == 3 assert call_json["event"]["endpoint"]["endpointId"] == "fan#test_fan" From e62c9d338e8ca73b94665ed603fd1b5b13ea5fc4 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Fri, 24 Sep 2021 08:45:03 +0200 Subject: [PATCH 567/843] Rework Tractive integration init (#55741) * Rework integration init * Suggested chancge * Use Trackables class * Use try..except for trackable_objects * Check that the pet has tracker linked --- homeassistant/components/tractive/__init__.py | 51 +++++++++++++++++-- homeassistant/components/tractive/const.py | 3 ++ .../components/tractive/device_tracker.py | 35 ++++++------- homeassistant/components/tractive/sensor.py | 23 +++------ 4 files changed, 74 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/tractive/__init__.py b/homeassistant/components/tractive/__init__.py index 60014852895..c380471769c 100644 --- a/homeassistant/components/tractive/__init__.py +++ b/homeassistant/components/tractive/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from dataclasses import dataclass import logging import aiotractive @@ -21,9 +22,11 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ( ATTR_DAILY_GOAL, ATTR_MINUTES_ACTIVE, + CLIENT, DOMAIN, RECONNECT_INTERVAL, SERVER_UNAVAILABLE, + TRACKABLES, TRACKER_ACTIVITY_STATUS_UPDATED, TRACKER_HARDWARE_STATUS_UPDATED, TRACKER_POSITION_UPDATED, @@ -35,11 +38,21 @@ PLATFORMS = ["device_tracker", "sensor"] _LOGGER = logging.getLogger(__name__) +@dataclass +class Trackables: + """A class that describes trackables.""" + + trackable: dict | None = None + tracker_details: dict | None = None + hw_info: dict | None = None + pos_report: dict | None = None + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up tractive from a config entry.""" data = entry.data - hass.data.setdefault(DOMAIN, {}) + hass.data.setdefault(DOMAIN, {}).setdefault(entry.entry_id, {}) client = aiotractive.Tractive( data[CONF_EMAIL], data[CONF_PASSWORD], session=async_get_clientsession(hass) @@ -56,7 +69,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: tractive = TractiveClient(hass, client, creds["user_id"]) tractive.subscribe() - hass.data[DOMAIN][entry.entry_id] = tractive + try: + trackable_objects = await client.trackable_objects() + trackables = await asyncio.gather( + *(_generate_trackables(client, item) for item in trackable_objects) + ) + except aiotractive.exceptions.TractiveError as error: + await tractive.unsubscribe() + raise ConfigEntryNotReady from error + + # When the pet defined in Tractive has no tracker linked we get None as `trackable`. + # So we have to remove None values from trackables list. + trackables = [item for item in trackables if item] + + hass.data[DOMAIN][entry.entry_id][CLIENT] = tractive + hass.data[DOMAIN][entry.entry_id][TRACKABLES] = trackables hass.config_entries.async_setup_platforms(entry, PLATFORMS) @@ -70,12 +97,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def _generate_trackables(client, trackable): + """Generate trackables.""" + trackable = await trackable.details() + + # Check that the pet has tracker linked. + if not trackable["device_id"]: + return + + tracker = client.tracker(trackable["device_id"]) + + tracker_details, hw_info, pos_report = await asyncio.gather( + tracker.details(), tracker.hw_info(), tracker.pos_report() + ) + + return Trackables(trackable, tracker_details, hw_info, pos_report) + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: - tractive = hass.data[DOMAIN].pop(entry.entry_id) + tractive = hass.data[DOMAIN][entry.entry_id].pop(CLIENT) await tractive.unsubscribe() + hass.data[DOMAIN].pop(entry.entry_id) return unload_ok diff --git a/homeassistant/components/tractive/const.py b/homeassistant/components/tractive/const.py index cb525d538e4..7f1b5ddb4f2 100644 --- a/homeassistant/components/tractive/const.py +++ b/homeassistant/components/tractive/const.py @@ -9,6 +9,9 @@ RECONNECT_INTERVAL = timedelta(seconds=10) ATTR_DAILY_GOAL = "daily_goal" ATTR_MINUTES_ACTIVE = "minutes_active" +CLIENT = "client" +TRACKABLES = "trackables" + TRACKER_HARDWARE_STATUS_UPDATED = f"{DOMAIN}_tracker_hardware_status_updated" TRACKER_POSITION_UPDATED = f"{DOMAIN}_tracker_position_updated" TRACKER_ACTIVITY_STATUS_UPDATED = f"{DOMAIN}_tracker_activity_updated" diff --git a/homeassistant/components/tractive/device_tracker.py b/homeassistant/components/tractive/device_tracker.py index c1652c27b8f..1e35e41fc8a 100644 --- a/homeassistant/components/tractive/device_tracker.py +++ b/homeassistant/components/tractive/device_tracker.py @@ -1,6 +1,5 @@ """Support for Tractive device trackers.""" -import asyncio import logging from homeassistant.components.device_tracker import SOURCE_TYPE_GPS @@ -9,8 +8,10 @@ from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from .const import ( + CLIENT, DOMAIN, SERVER_UNAVAILABLE, + TRACKABLES, TRACKER_HARDWARE_STATUS_UPDATED, TRACKER_POSITION_UPDATED, ) @@ -21,31 +22,25 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass, entry, async_add_entities): """Set up Tractive device trackers.""" - client = hass.data[DOMAIN][entry.entry_id] + client = hass.data[DOMAIN][entry.entry_id][CLIENT] + trackables = hass.data[DOMAIN][entry.entry_id][TRACKABLES] - trackables = await client.trackable_objects() + entities = [] - entities = await asyncio.gather( - *(create_trackable_entity(client, trackable) for trackable in trackables) - ) + for item in trackables: + entities.append( + TractiveDeviceTracker( + client.user_id, + item.trackable, + item.tracker_details, + item.hw_info, + item.pos_report, + ) + ) async_add_entities(entities) -async def create_trackable_entity(client, trackable): - """Create an entity instance.""" - trackable = await trackable.details() - tracker = client.tracker(trackable["device_id"]) - - tracker_details, hw_info, pos_report = await asyncio.gather( - tracker.details(), tracker.hw_info(), tracker.pos_report() - ) - - return TractiveDeviceTracker( - client.user_id, trackable, tracker_details, hw_info, pos_report - ) - - class TractiveDeviceTracker(TractiveEntity, TrackerEntity): """Tractive device tracker.""" diff --git a/homeassistant/components/tractive/sensor.py b/homeassistant/components/tractive/sensor.py index ba2f330f894..9fd8ee6ac5f 100644 --- a/homeassistant/components/tractive/sensor.py +++ b/homeassistant/components/tractive/sensor.py @@ -1,7 +1,6 @@ """Support for Tractive sensors.""" from __future__ import annotations -import asyncio from dataclasses import dataclass from homeassistant.components.sensor import SensorEntity, SensorEntityDescription @@ -17,8 +16,10 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from .const import ( ATTR_DAILY_GOAL, ATTR_MINUTES_ACTIVE, + CLIENT, DOMAIN, SERVER_UNAVAILABLE, + TRACKABLES, TRACKER_ACTIVITY_STATUS_UPDATED, TRACKER_HARDWARE_STATUS_UPDATED, ) @@ -137,29 +138,21 @@ SENSOR_TYPES = ( async def async_setup_entry(hass, entry, async_add_entities): """Set up Tractive device trackers.""" - client = hass.data[DOMAIN][entry.entry_id] - - trackables = await client.trackable_objects() + client = hass.data[DOMAIN][entry.entry_id][CLIENT] + trackables = hass.data[DOMAIN][entry.entry_id][TRACKABLES] entities = [] - async def _prepare_sensor_entity(item): - """Prepare sensor entities.""" - trackable = await item.details() - tracker = client.tracker(trackable["device_id"]) - tracker_details = await tracker.details() + for item in trackables: for description in SENSOR_TYPES: - unique_id = f"{trackable['_id']}_{description.key}" entities.append( description.entity_class( client.user_id, - trackable, - tracker_details, - unique_id, + item.trackable, + item.tracker_details, + f"{item.trackable['_id']}_{description.key}", description, ) ) - await asyncio.gather(*(_prepare_sensor_entity(item) for item in trackables)) - async_add_entities(entities) From 74529980817d90cd66d9e81441d7162b183ab36c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 24 Sep 2021 09:16:50 +0200 Subject: [PATCH 568/843] Convert last_reset timestamps to UTC (#56561) * Convert last_reset timestamps to UTC * Add test * Apply suggestion from code review --- homeassistant/components/sensor/recorder.py | 21 ++- tests/components/sensor/test_recorder.py | 136 ++++++++++++++++++-- 2 files changed, 148 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 28e2f0c774b..fbf0992573f 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -312,6 +312,21 @@ def _wanted_statistics( return wanted_statistics +def _last_reset_as_utc_isoformat( + last_reset_s: str | None, entity_id: str +) -> str | None: + """Parse last_reset and convert it to UTC.""" + if last_reset_s is None: + return None + last_reset = dt_util.parse_datetime(last_reset_s) + if last_reset is None: + _LOGGER.warning( + "Ignoring invalid last reset '%s' for %s", last_reset_s, entity_id + ) + return None + return dt_util.as_utc(last_reset).isoformat() + + def compile_statistics( # noqa: C901 hass: HomeAssistant, start: datetime.datetime, end: datetime.datetime ) -> list[StatisticResult]: @@ -424,7 +439,11 @@ def compile_statistics( # noqa: C901 reset = False if ( state_class != STATE_CLASS_TOTAL_INCREASING - and (last_reset := state.attributes.get("last_reset")) + and ( + last_reset := _last_reset_as_utc_isoformat( + state.attributes.get("last_reset"), entity_id + ) + ) != old_last_reset ): if old_state is None: diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index c1278202443..609b3576570 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -50,6 +50,16 @@ GAS_SENSOR_ATTRIBUTES = { } +@pytest.fixture(autouse=True) +def set_time_zone(): + """Set the time zone for the tests.""" + # Set our timezone to CST/Regina so we can check calculations + # This keeps UTC-6 all year round + dt_util.set_default_time_zone(dt_util.get_time_zone("America/Regina")) + yield + dt_util.set_default_time_zone(dt_util.get_time_zone("UTC")) + + @pytest.mark.parametrize( "device_class,unit,native_unit,mean,min,max", [ @@ -338,14 +348,121 @@ def test_compile_hourly_sum_statistics_amount_reset_every_state_change( "unit_of_measurement": unit, "last_reset": None, } - seq = [10, 15, 15, 15, 20, 20, 20, 10] + seq = [10, 15, 15, 15, 20, 20, 20, 25] # Make sure the sequence has consecutive equal states assert seq[1] == seq[2] == seq[3] + # Make sure the first and last state differ + assert seq[0] != seq[-1] + states = {"sensor.test1": []} + + # Insert states for a 1st statistics period one = zero for i in range(len(seq)): one = one + timedelta(seconds=5) + attributes = dict(attributes) + attributes["last_reset"] = dt_util.as_local(one).isoformat() + _states = record_meter_state( + hass, one, "sensor.test1", attributes, seq[i : i + 1] + ) + states["sensor.test1"].extend(_states["sensor.test1"]) + + # Insert states for a 2nd statistics period + two = zero + timedelta(minutes=5) + for i in range(len(seq)): + two = two + timedelta(seconds=5) + attributes = dict(attributes) + attributes["last_reset"] = dt_util.as_local(two).isoformat() + _states = record_meter_state( + hass, two, "sensor.test1", attributes, seq[i : i + 1] + ) + states["sensor.test1"].extend(_states["sensor.test1"]) + + hist = history.get_significant_states( + hass, + zero - timedelta.resolution, + two + timedelta.resolution, + significant_changes_only=False, + ) + assert dict(states)["sensor.test1"] == dict(hist)["sensor.test1"] + + recorder.do_adhoc_statistics(start=zero) + recorder.do_adhoc_statistics(start=zero + timedelta(minutes=5)) + wait_recording_done(hass) + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + {"statistic_id": "sensor.test1", "unit_of_measurement": native_unit} + ] + stats = statistics_during_period(hass, zero, period="5minute") + assert stats == { + "sensor.test1": [ + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(minutes=5)), + "max": None, + "mean": None, + "min": None, + "last_reset": process_timestamp_to_utc_isoformat(dt_util.as_local(one)), + "state": approx(factor * seq[7]), + "sum": approx(factor * (sum(seq) - seq[0])), + "sum_decrease": approx(factor * 0.0), + "sum_increase": approx(factor * (sum(seq) - seq[0])), + }, + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat( + zero + timedelta(minutes=5) + ), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(minutes=10)), + "max": None, + "mean": None, + "min": None, + "last_reset": process_timestamp_to_utc_isoformat(dt_util.as_local(two)), + "state": approx(factor * seq[7]), + "sum": approx(factor * (2 * sum(seq) - seq[0])), + "sum_decrease": approx(factor * 0.0), + "sum_increase": approx(factor * (2 * sum(seq) - seq[0])), + }, + ] + } + assert "Error while processing event StatisticsTask" not in caplog.text + + +@pytest.mark.parametrize("state_class", ["measurement"]) +@pytest.mark.parametrize( + "device_class,unit,native_unit,factor", + [ + ("energy", "kWh", "kWh", 1), + ], +) +def test_compile_hourly_sum_statistics_amount_invalid_last_reset( + hass_recorder, caplog, state_class, device_class, unit, native_unit, factor +): + """Test compiling hourly statistics.""" + zero = dt_util.utcnow() + hass = hass_recorder() + recorder = hass.data[DATA_INSTANCE] + setup_component(hass, "sensor", {}) + attributes = { + "device_class": device_class, + "state_class": state_class, + "unit_of_measurement": unit, + "last_reset": None, + } + seq = [10, 15, 15, 15, 20, 20, 20, 25] + + states = {"sensor.test1": []} + + # Insert states + one = zero + for i in range(len(seq)): + one = one + timedelta(seconds=5) + attributes = dict(attributes) + attributes["last_reset"] = dt_util.as_local(one).isoformat() + if i == 3: + attributes["last_reset"] = "festivus" # not a valid time _states = record_meter_state( hass, one, "sensor.test1", attributes, seq[i : i + 1] ) @@ -375,7 +492,7 @@ def test_compile_hourly_sum_statistics_amount_reset_every_state_change( "max": None, "mean": None, "min": None, - "last_reset": process_timestamp_to_utc_isoformat(one), + "last_reset": process_timestamp_to_utc_isoformat(dt_util.as_local(one)), "state": approx(factor * seq[7]), "sum": approx(factor * (sum(seq) - seq[0])), "sum_decrease": approx(factor * 0.0), @@ -384,6 +501,7 @@ def test_compile_hourly_sum_statistics_amount_reset_every_state_change( ] } assert "Error while processing event StatisticsTask" not in caplog.text + assert "Ignoring invalid last reset 'festivus' for sensor.test1" in caplog.text @pytest.mark.parametrize("state_class", ["measurement"]) @@ -413,6 +531,8 @@ def test_compile_hourly_sum_statistics_nan_inf_state( one = zero for i in range(len(seq)): one = one + timedelta(seconds=5) + attributes = dict(attributes) + attributes["last_reset"] = dt_util.as_local(one).isoformat() _states = record_meter_state( hass, one, "sensor.test1", attributes, seq[i : i + 1] ) @@ -1685,7 +1805,11 @@ def test_compile_statistics_hourly_summary(hass_recorder, caplog): start_meter = start for j in range(len(seq)): _states = record_meter_state( - hass, start_meter, "sensor.test4", sum_attributes, seq[j : j + 1] + hass, + start_meter, + "sensor.test4", + sum_attributes, + seq[j : j + 1], ) start_meter = start + timedelta(minutes=1) states["sensor.test4"] += _states["sensor.test4"] @@ -1955,7 +2079,7 @@ def record_meter_states(hass, zero, entity_id, _attributes, seq): return four, eight, states -def record_meter_state(hass, zero, entity_id, _attributes, seq): +def record_meter_state(hass, zero, entity_id, attributes, seq): """Record test state. We inject a state update for meter sensor. @@ -1967,10 +2091,6 @@ def record_meter_state(hass, zero, entity_id, _attributes, seq): wait_recording_done(hass) return hass.states.get(entity_id) - attributes = dict(_attributes) - if "last_reset" in _attributes: - attributes["last_reset"] = zero.isoformat() - states = {entity_id: []} with patch("homeassistant.components.recorder.dt_util.utcnow", return_value=zero): states[entity_id].append(set_state(entity_id, seq[0], attributes=attributes)) From 64393b462d5213f02e218f27de3f9322586919f1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 24 Sep 2021 09:19:22 +0200 Subject: [PATCH 569/843] Add migration for 5-minute statistics (#56585) * Add migration for 5-minute statistics * Tweaks --- .../components/recorder/migration.py | 47 +++++++++++++++++-- tests/components/recorder/test_migrate.py | 2 +- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 3ceac8903a7..1a1f978be59 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -1,9 +1,10 @@ """Schema migration helpers.""" import contextlib +from datetime import timedelta import logging import sqlalchemy -from sqlalchemy import ForeignKeyConstraint, MetaData, Table, text +from sqlalchemy import ForeignKeyConstraint, MetaData, Table, func, text from sqlalchemy.exc import ( InternalError, OperationalError, @@ -21,8 +22,9 @@ from .models import ( StatisticsMeta, StatisticsRuns, StatisticsShortTerm, + process_timestamp, ) -from .statistics import get_start_time +from .statistics import _get_metadata, get_start_time from .util import session_scope _LOGGER = logging.getLogger(__name__) @@ -72,7 +74,7 @@ def migrate_schema(instance, current_version): for version in range(current_version, SCHEMA_VERSION): new_version = version + 1 _LOGGER.info("Upgrading recorder db schema to version %s", new_version) - _apply_update(instance.engine, session, new_version, current_version) + _apply_update(instance, session, new_version, current_version) session.add(SchemaChanges(schema_version=new_version)) _LOGGER.info("Upgrade to version %s done", new_version) @@ -351,8 +353,9 @@ def _drop_foreign_key_constraints(connection, engine, table, columns): ) -def _apply_update(engine, session, new_version, old_version): # noqa: C901 +def _apply_update(instance, session, new_version, old_version): # noqa: C901 """Perform operations to bring schema up to date.""" + engine = instance.engine connection = session.connection() if new_version == 1: _create_index(connection, "events", "ix_events_time_fired") @@ -543,6 +546,42 @@ def _apply_update(engine, session, new_version, old_version): # noqa: C901 StatisticsMeta.__table__.create(engine) StatisticsShortTerm.__table__.create(engine) Statistics.__table__.create(engine) + + # Block 5-minute statistics for one hour from the last run, or it will overlap + # with existing hourly statistics. Don't block on a database with no existing + # statistics. + if session.query(Statistics.id).count() and ( + last_run_string := session.query(func.max(StatisticsRuns.start)).scalar() + ): + last_run_start_time = process_timestamp(last_run_string) + if last_run_start_time: + fake_start_time = last_run_start_time + timedelta(minutes=5) + while fake_start_time < last_run_start_time + timedelta(hours=1): + session.add(StatisticsRuns(start=fake_start_time)) + fake_start_time += timedelta(minutes=5) + + # Copy last hourly statistic to the newly created 5-minute statistics table + sum_statistics = _get_metadata( + instance.hass, session, None, statistic_type="sum" + ) + for metadata_id in sum_statistics: + last_statistic = ( + session.query(Statistics) + .filter_by(metadata_id=metadata_id) + .order_by(Statistics.start.desc()) + .first() + ) + if last_statistic: + session.add( + StatisticsShortTerm( + metadata_id=last_statistic.metadata_id, + start=last_statistic.start, + last_reset=last_statistic.last_reset, + state=last_statistic.state, + sum=last_statistic.sum, + sum_increase=last_statistic.sum_increase, + ) + ) else: raise ValueError(f"No schema migration defined for version {new_version}") diff --git a/tests/components/recorder/test_migrate.py b/tests/components/recorder/test_migrate.py index ae7510ee979..42122983007 100644 --- a/tests/components/recorder/test_migrate.py +++ b/tests/components/recorder/test_migrate.py @@ -65,7 +65,7 @@ async def test_schema_update_calls(hass): assert await recorder.async_migration_in_progress(hass) is False update.assert_has_calls( [ - call(hass.data[DATA_INSTANCE].engine, ANY, version + 1, 0) + call(hass.data[DATA_INSTANCE], ANY, version + 1, 0) for version in range(0, models.SCHEMA_VERSION) ] ) From aca00667df2fc938d67667e9935431a89f77d664 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Fri, 24 Sep 2021 09:21:47 +0200 Subject: [PATCH 570/843] Add device info to Surepetcare (#56600) --- homeassistant/components/surepetcare/entity.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/homeassistant/components/surepetcare/entity.py b/homeassistant/components/surepetcare/entity.py index 8b88282ce96..f7797b4c166 100644 --- a/homeassistant/components/surepetcare/entity.py +++ b/homeassistant/components/surepetcare/entity.py @@ -9,6 +9,7 @@ from homeassistant.core import callback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import SurePetcareDataCoordinator +from .const import DOMAIN class SurePetcareEntity(CoordinatorEntity): @@ -32,6 +33,12 @@ class SurePetcareEntity(CoordinatorEntity): self._device_name = surepy_entity.type.name.capitalize().replace("_", " ") self._device_id = f"{surepy_entity.household_id}-{surepetcare_id}" + self._attr_device_info = { + "identifiers": {(DOMAIN, self._device_id)}, + "name": self._device_name, + "manufacturer": "Sure Petcare", + "model": surepy_entity.type.name.capitalize().replace("_", " "), + } self._update_attr(coordinator.data[surepetcare_id]) @abstractmethod From d7e121f3e8962af647f79996c644cf913b923ea1 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 24 Sep 2021 10:11:36 +0200 Subject: [PATCH 571/843] Upgrade pytest to 6.2.5 (#56603) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 4d10ef8e031..1dc2c49ac35 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -22,7 +22,7 @@ pytest-test-groups==1.0.3 pytest-sugar==0.9.4 pytest-timeout==1.4.2 pytest-xdist==2.2.1 -pytest==6.2.4 +pytest==6.2.5 requests_mock==1.9.2 responses==0.12.0 respx==0.17.0 From e078f4ce144e4168953143b5e7fc87cfccc83f81 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Fri, 24 Sep 2021 06:54:15 -0400 Subject: [PATCH 572/843] Move efergy api to pyefergy (#56594) * Move efergy api to pyefergy * fix permissions * tweak * tweak --- CODEOWNERS | 1 + homeassistant/components/efergy/manifest.json | 3 +- homeassistant/components/efergy/sensor.py | 73 +++++-------------- requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/efergy/test_sensor.py | 40 +++++----- .../fixtures/{ => efergy}/efergy_budget.json | 0 tests/fixtures/{ => efergy}/efergy_cost.json | 0 .../efergy_current_values_multi.json | 0 .../efergy_current_values_single.json | 0 .../fixtures/{ => efergy}/efergy_energy.json | 0 .../fixtures/{ => efergy}/efergy_instant.json | 0 12 files changed, 52 insertions(+), 71 deletions(-) rename tests/fixtures/{ => efergy}/efergy_budget.json (100%) rename tests/fixtures/{ => efergy}/efergy_cost.json (100%) rename tests/fixtures/{ => efergy}/efergy_current_values_multi.json (100%) rename tests/fixtures/{ => efergy}/efergy_current_values_single.json (100%) rename tests/fixtures/{ => efergy}/efergy_energy.json (100%) rename tests/fixtures/{ => efergy}/efergy_instant.json (100%) diff --git a/CODEOWNERS b/CODEOWNERS index 375842e33cd..6e178069816 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -134,6 +134,7 @@ homeassistant/components/ecobee/* @marthoc homeassistant/components/econet/* @vangorra @w1ll1am23 homeassistant/components/ecovacs/* @OverloadUT homeassistant/components/edl21/* @mtdcr +homeassistant/components/efergy/* @tkdrob homeassistant/components/egardia/* @jeroenterheerdt homeassistant/components/eight_sleep/* @mezz64 homeassistant/components/elgato/* @frenck diff --git a/homeassistant/components/efergy/manifest.json b/homeassistant/components/efergy/manifest.json index fe9ea7e6047..3b84d243d46 100644 --- a/homeassistant/components/efergy/manifest.json +++ b/homeassistant/components/efergy/manifest.json @@ -2,6 +2,7 @@ "domain": "efergy", "name": "Efergy", "documentation": "https://www.home-assistant.io/integrations/efergy", - "codeowners": [], + "requirements": ["pyefergy==0.0.3"], + "codeowners": ["@tkdrob"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/efergy/sensor.py b/homeassistant/components/efergy/sensor.py index e7116a360e7..338609cf342 100644 --- a/homeassistant/components/efergy/sensor.py +++ b/homeassistant/components/efergy/sensor.py @@ -1,10 +1,7 @@ """Support for Efergy sensors.""" from __future__ import annotations -import logging -from typing import Any - -import requests +from pyefergy import Efergy import voluptuous as vol from homeassistant.components.sensor import ( @@ -23,13 +20,11 @@ from homeassistant.const import ( POWER_WATT, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -_LOGGER = logging.getLogger(__name__) -_RESOURCE = "https://engage.efergy.com/mobile_proxy/" - CONF_APPTOKEN = "app_token" CONF_UTC_OFFSET = "utc_offset" @@ -93,37 +88,36 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType = None, ) -> None: """Set up the Efergy sensor.""" - app_token = config.get(CONF_APPTOKEN) - utc_offset = str(config.get(CONF_UTC_OFFSET)) + api = Efergy( + config[CONF_APPTOKEN], + async_get_clientsession(hass), + utc_offset=config[CONF_UTC_OFFSET], + ) dev = [] + sensors = await api.get_sids() for variable in config[CONF_MONITORED_VARIABLES]: if variable[CONF_TYPE] == CONF_CURRENT_VALUES: - url_string = f"{_RESOURCE}getCurrentValuesSummary?token={app_token}" - response = requests.get(url_string, timeout=10) - for sensor in response.json(): - sid = sensor["sid"] + for sensor in sensors: dev.append( EfergySensor( - app_token, - utc_offset, + api, variable[CONF_PERIOD], variable[CONF_CURRENCY], SENSOR_TYPES[variable[CONF_TYPE]], - sid=sid, + sid=sensor["sid"], ) ) dev.append( EfergySensor( - app_token, - utc_offset, + api, variable[CONF_PERIOD], variable[CONF_CURRENCY], SENSOR_TYPES[variable[CONF_TYPE]], @@ -138,8 +132,7 @@ class EfergySensor(SensorEntity): def __init__( self, - app_token: Any, - utc_offset: str, + api: Efergy, period: str, currency: str, description: SensorEntityDescription, @@ -148,41 +141,15 @@ class EfergySensor(SensorEntity): """Initialize the sensor.""" self.entity_description = description self.sid = sid + self.api = api + self.period = period if sid: self._attr_name = f"efergy_{sid}" - self.app_token = app_token - self.utc_offset = utc_offset - self.period = period if description.key == CONF_COST: self._attr_native_unit_of_measurement = f"{currency}/{period}" - def update(self) -> None: + async def async_update(self) -> None: """Get the Efergy monitor data from the web service.""" - try: - if self.entity_description.key == CONF_INSTANT: - url_string = f"{_RESOURCE}getInstant?token={self.app_token}" - response = requests.get(url_string, timeout=10) - self._attr_native_value = response.json()["reading"] - elif self.entity_description.key == CONF_AMOUNT: - url_string = f"{_RESOURCE}getEnergy?token={self.app_token}&offset={self.utc_offset}&period={self.period}" - response = requests.get(url_string, timeout=10) - self._attr_native_value = response.json()["sum"] - elif self.entity_description.key == CONF_BUDGET: - url_string = f"{_RESOURCE}getBudget?token={self.app_token}" - response = requests.get(url_string, timeout=10) - self._attr_native_value = response.json()["status"] - elif self.entity_description.key == CONF_COST: - url_string = f"{_RESOURCE}getCost?token={self.app_token}&offset={self.utc_offset}&period={self.period}" - response = requests.get(url_string, timeout=10) - self._attr_native_value = response.json()["sum"] - elif self.entity_description.key == CONF_CURRENT_VALUES: - url_string = ( - f"{_RESOURCE}getCurrentValuesSummary?token={self.app_token}" - ) - response = requests.get(url_string, timeout=10) - for sensor in response.json(): - if self.sid == sensor["sid"]: - measurement = next(iter(sensor["data"][0].values())) - self._attr_native_value = measurement - except (requests.RequestException, ValueError, KeyError): - _LOGGER.warning("Could not update status for %s", self.name) + self._attr_native_value = await self.api.async_get_reading( + self.entity_description.key, period=self.period, sid=self.sid + ) diff --git a/requirements_all.txt b/requirements_all.txt index d9ba931e894..2a064e990f8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1450,6 +1450,9 @@ pyeconet==0.1.14 # homeassistant.components.edimax pyedimax==0.2.1 +# homeassistant.components.efergy +pyefergy==0.0.3 + # homeassistant.components.eight_sleep pyeight==0.1.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2352fed82e0..4ccbc5c1d66 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -838,6 +838,9 @@ pydispatcher==2.0.5 # homeassistant.components.econet pyeconet==0.1.14 +# homeassistant.components.efergy +pyefergy==0.0.3 + # homeassistant.components.everlights pyeverlights==0.1.0 diff --git a/tests/components/efergy/test_sensor.py b/tests/components/efergy/test_sensor.py index a56e28cb3ef..97478155483 100644 --- a/tests/components/efergy/test_sensor.py +++ b/tests/components/efergy/test_sensor.py @@ -1,8 +1,10 @@ """The tests for Efergy sensor platform.""" +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from tests.common import load_fixture +from tests.test_util.aiohttp import AiohttpClientMocker token = "9p6QGJ7dpZfO3fqPTBk1fyEmjV1cGoLT" multi_sensor_token = "9r6QGF7dpZfO3fqPTBl1fyRmjV1cGoLT" @@ -28,38 +30,40 @@ MULTI_SENSOR_CONFIG = { } -def mock_responses(mock): +def mock_responses(aioclient_mock: AiohttpClientMocker): """Mock responses for Efergy.""" base_url = "https://engage.efergy.com/mobile_proxy/" - mock.get( + aioclient_mock.get( f"{base_url}getInstant?token={token}", - text=load_fixture("efergy_instant.json"), + text=load_fixture("efergy/efergy_instant.json"), ) - mock.get( + aioclient_mock.get( f"{base_url}getEnergy?token={token}&offset=300&period=day", - text=load_fixture("efergy_energy.json"), + text=load_fixture("efergy/efergy_energy.json"), ) - mock.get( + aioclient_mock.get( f"{base_url}getBudget?token={token}", - text=load_fixture("efergy_budget.json"), + text=load_fixture("efergy/efergy_budget.json"), ) - mock.get( + aioclient_mock.get( f"{base_url}getCost?token={token}&offset=300&period=day", - text=load_fixture("efergy_cost.json"), + text=load_fixture("efergy/efergy_cost.json"), ) - mock.get( + aioclient_mock.get( f"{base_url}getCurrentValuesSummary?token={token}", - text=load_fixture("efergy_current_values_single.json"), + text=load_fixture("efergy/efergy_current_values_single.json"), ) - mock.get( + aioclient_mock.get( f"{base_url}getCurrentValuesSummary?token={multi_sensor_token}", - text=load_fixture("efergy_current_values_multi.json"), + text=load_fixture("efergy/efergy_current_values_multi.json"), ) -async def test_single_sensor_readings(hass, requests_mock): +async def test_single_sensor_readings( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +): """Test for successfully setting up the Efergy platform.""" - mock_responses(requests_mock) + mock_responses(aioclient_mock) assert await async_setup_component(hass, "sensor", {"sensor": ONE_SENSOR_CONFIG}) await hass.async_block_till_done() @@ -70,9 +74,11 @@ async def test_single_sensor_readings(hass, requests_mock): assert hass.states.get("sensor.efergy_728386").state == "1628" -async def test_multi_sensor_readings(hass, requests_mock): +async def test_multi_sensor_readings( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +): """Test for multiple sensors in one household.""" - mock_responses(requests_mock) + mock_responses(aioclient_mock) assert await async_setup_component(hass, "sensor", {"sensor": MULTI_SENSOR_CONFIG}) await hass.async_block_till_done() diff --git a/tests/fixtures/efergy_budget.json b/tests/fixtures/efergy/efergy_budget.json similarity index 100% rename from tests/fixtures/efergy_budget.json rename to tests/fixtures/efergy/efergy_budget.json diff --git a/tests/fixtures/efergy_cost.json b/tests/fixtures/efergy/efergy_cost.json similarity index 100% rename from tests/fixtures/efergy_cost.json rename to tests/fixtures/efergy/efergy_cost.json diff --git a/tests/fixtures/efergy_current_values_multi.json b/tests/fixtures/efergy/efergy_current_values_multi.json similarity index 100% rename from tests/fixtures/efergy_current_values_multi.json rename to tests/fixtures/efergy/efergy_current_values_multi.json diff --git a/tests/fixtures/efergy_current_values_single.json b/tests/fixtures/efergy/efergy_current_values_single.json similarity index 100% rename from tests/fixtures/efergy_current_values_single.json rename to tests/fixtures/efergy/efergy_current_values_single.json diff --git a/tests/fixtures/efergy_energy.json b/tests/fixtures/efergy/efergy_energy.json similarity index 100% rename from tests/fixtures/efergy_energy.json rename to tests/fixtures/efergy/efergy_energy.json diff --git a/tests/fixtures/efergy_instant.json b/tests/fixtures/efergy/efergy_instant.json similarity index 100% rename from tests/fixtures/efergy_instant.json rename to tests/fixtures/efergy/efergy_instant.json From 8aff51042b4eb10bdb9d2e2ed32bf526a136ac4e Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Fri, 24 Sep 2021 20:09:44 +0200 Subject: [PATCH 573/843] Bump velbus-aio to 2021.9.4 (#56478) * Bump version, move the cache-dir to the home-assistant config * Moved the cahce into the storage dir * Bump version, 2021.9.3 will use pathlib * Bump version to 2021.9.4 * Clean config path * Remove leading slash Co-authored-by: Martin Hjelmare --- homeassistant/components/velbus/__init__.py | 5 ++++- homeassistant/components/velbus/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py index 48dce9ecf97..265167f574c 100644 --- a/homeassistant/components/velbus/__init__.py +++ b/homeassistant/components/velbus/__init__.py @@ -62,7 +62,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Establish connection with velbus.""" hass.data.setdefault(DOMAIN, {}) - controller = Velbus(entry.data[CONF_PORT]) + controller = Velbus( + entry.data[CONF_PORT], + cache_dir=hass.config.path(".storage/velbuscache/"), + ) hass.data[DOMAIN][entry.entry_id] = {} hass.data[DOMAIN][entry.entry_id]["cntrl"] = controller hass.data[DOMAIN][entry.entry_id]["tsk"] = hass.async_create_task( diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index 61a297d401b..27ffbfd10de 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -2,7 +2,7 @@ "domain": "velbus", "name": "Velbus", "documentation": "https://www.home-assistant.io/integrations/velbus", - "requirements": ["velbus-aio==2021.9.1"], + "requirements": ["velbus-aio==2021.9.4"], "config_flow": true, "codeowners": ["@Cereal2nd", "@brefra"], "iot_class": "local_push" diff --git a/requirements_all.txt b/requirements_all.txt index 2a064e990f8..90387b9ba1c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2367,7 +2367,7 @@ uvcclient==0.11.0 vallox-websocket-api==2.8.1 # homeassistant.components.velbus -velbus-aio==2021.9.1 +velbus-aio==2021.9.4 # homeassistant.components.venstar venstarcolortouch==0.14 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4ccbc5c1d66..ba8107bd30c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1338,7 +1338,7 @@ url-normalize==1.4.1 uvcclient==0.11.0 # homeassistant.components.velbus -velbus-aio==2021.9.1 +velbus-aio==2021.9.4 # homeassistant.components.venstar venstarcolortouch==0.14 From 3c80e051001de30a2f00f81b6884889ce295bdd5 Mon Sep 17 00:00:00 2001 From: RenierM26 <66512715+RenierM26@users.noreply.github.com> Date: Fri, 24 Sep 2021 20:13:45 +0200 Subject: [PATCH 574/843] Set Switchbot _attr_is_closed on init (#56611) * self._attr_is_closed needs to be set initially. * Set _attr_is_closed on init. --- homeassistant/components/switchbot/cover.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/switchbot/cover.py b/homeassistant/components/switchbot/cover.py index e28049eeb95..5582b8b4ed6 100644 --- a/homeassistant/components/switchbot/cover.py +++ b/homeassistant/components/switchbot/cover.py @@ -77,6 +77,7 @@ class SwitchBotCurtainEntity(SwitchbotEntity, CoverEntity, RestoreEntity): """Initialize the Switchbot.""" super().__init__(coordinator, idx, mac, name) self._attr_unique_id = idx + self._attr_is_closed = None self._device = device async def async_added_to_hass(self) -> None: From ac576a2bc60685ed271301a4ce2735c371e07b23 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Fri, 24 Sep 2021 20:15:21 +0200 Subject: [PATCH 575/843] update SIA package (#56615) --- homeassistant/components/sia/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sia/manifest.json b/homeassistant/components/sia/manifest.json index eaeb4547167..438d63a1830 100644 --- a/homeassistant/components/sia/manifest.json +++ b/homeassistant/components/sia/manifest.json @@ -3,7 +3,7 @@ "name": "SIA Alarm Systems", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sia", - "requirements": ["pysiaalarm==3.0.0"], + "requirements": ["pysiaalarm==3.0.1"], "codeowners": ["@eavanvalkenburg"], "iot_class": "local_push" } diff --git a/requirements_all.txt b/requirements_all.txt index 90387b9ba1c..08278e35679 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1791,7 +1791,7 @@ pysesame2==1.0.1 pysher==1.0.1 # homeassistant.components.sia -pysiaalarm==3.0.0 +pysiaalarm==3.0.1 # homeassistant.components.signal_messenger pysignalclirestapi==0.3.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ba8107bd30c..66dc0116709 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1053,7 +1053,7 @@ pyserial-asyncio==0.5 pyserial==3.5 # homeassistant.components.sia -pysiaalarm==3.0.0 +pysiaalarm==3.0.1 # homeassistant.components.signal_messenger pysignalclirestapi==0.3.4 From a7d56d1c3f3e839612bcb744a13566d3be047663 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Fri, 24 Sep 2021 14:18:14 -0400 Subject: [PATCH 576/843] Bump goalzero to 0.2.0 (#56613) * Bump goalzero to 0.1.8 * bump * recheck * bump --- homeassistant/components/goalzero/__init__.py | 3 +-- homeassistant/components/goalzero/config_flow.py | 5 +---- homeassistant/components/goalzero/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/goalzero/__init__.py b/homeassistant/components/goalzero/__init__.py index 04a9d6aaa86..7a179c46210 100644 --- a/homeassistant/components/goalzero/__init__.py +++ b/homeassistant/components/goalzero/__init__.py @@ -48,8 +48,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: name = entry.data[CONF_NAME] host = entry.data[CONF_HOST] - session = async_get_clientsession(hass) - api = Yeti(host, hass.loop, session) + api = Yeti(host, async_get_clientsession(hass)) try: await api.init_connect() except exceptions.ConnectError as ex: diff --git a/homeassistant/components/goalzero/config_flow.py b/homeassistant/components/goalzero/config_flow.py index cc2c4a9874f..f192c71cbf8 100644 --- a/homeassistant/components/goalzero/config_flow.py +++ b/homeassistant/components/goalzero/config_flow.py @@ -104,14 +104,11 @@ class GoalZeroFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): async def _async_try_connect(self, host: str) -> tuple[str | None, str | None]: """Try connecting to Goal Zero Yeti.""" try: - session = async_get_clientsession(self.hass) - api = Yeti(host, self.hass.loop, session) + api = Yeti(host, async_get_clientsession(self.hass)) await api.sysinfo() except exceptions.ConnectError: - _LOGGER.error("Error connecting to device at %s", host) return None, "cannot_connect" except exceptions.InvalidHost: - _LOGGER.error("Invalid host at %s", host) return None, "invalid_host" except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") diff --git a/homeassistant/components/goalzero/manifest.json b/homeassistant/components/goalzero/manifest.json index b4a9415d01d..b19cb884353 100644 --- a/homeassistant/components/goalzero/manifest.json +++ b/homeassistant/components/goalzero/manifest.json @@ -3,7 +3,7 @@ "name": "Goal Zero Yeti", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/goalzero", - "requirements": ["goalzero==0.1.7"], + "requirements": ["goalzero==0.2.0"], "dhcp": [ {"hostname": "yeti*"} ], diff --git a/requirements_all.txt b/requirements_all.txt index 08278e35679..e256bcdc41e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -718,7 +718,7 @@ glances_api==0.2.0 gntp==1.0.3 # homeassistant.components.goalzero -goalzero==0.1.7 +goalzero==0.2.0 # homeassistant.components.google google-api-python-client==1.6.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 66dc0116709..dc571668553 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -426,7 +426,7 @@ gios==2.0.0 glances_api==0.2.0 # homeassistant.components.goalzero -goalzero==0.1.7 +goalzero==0.2.0 # homeassistant.components.google google-api-python-client==1.6.4 From 0ea5f2559431962582241acb406a81b77bd35d40 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Fri, 24 Sep 2021 12:23:19 -0600 Subject: [PATCH 577/843] Add ability to re-auth Notion (#55616) --- homeassistant/components/notion/__init__.py | 18 +-- .../components/notion/config_flow.py | 99 ++++++++++++---- homeassistant/components/notion/strings.json | 12 +- .../components/notion/translations/en.json | 12 +- tests/components/notion/test_config_flow.py | 109 +++++++++++++----- 5 files changed, 186 insertions(+), 64 deletions(-) diff --git a/homeassistant/components/notion/__init__.py b/homeassistant/components/notion/__init__.py index c06a08560e9..c9f4131be1a 100644 --- a/homeassistant/components/notion/__init__.py +++ b/homeassistant/components/notion/__init__.py @@ -11,7 +11,7 @@ from aionotion.errors import InvalidCredentialsError, NotionError from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ATTRIBUTION, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import ( aiohttp_client, config_validation as cv, @@ -52,12 +52,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: client = await async_get_client( entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], session=session ) - except InvalidCredentialsError: - LOGGER.error("Invalid username and/or password") - return False + except InvalidCredentialsError as err: + raise ConfigEntryAuthFailed("Invalid username and/or password") from err except NotionError as err: - LOGGER.error("Config entry failed: %s", err) - raise ConfigEntryNotReady from err + raise ConfigEntryNotReady("Config entry failed to load") from err async def async_update() -> dict[str, dict[str, Any]]: """Get the latest data from the Notion API.""" @@ -70,14 +68,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: results = await asyncio.gather(*tasks.values(), return_exceptions=True) for attr, result in zip(tasks, results): + if isinstance(result, InvalidCredentialsError): + raise ConfigEntryAuthFailed( + "Invalid username and/or password" + ) from result if isinstance(result, NotionError): raise UpdateFailed( f"There was a Notion error while updating {attr}: {result}" - ) + ) from result if isinstance(result, Exception): raise UpdateFailed( f"There was an unknown error while updating {attr}: {result}" - ) + ) from result for item in result: if attr == "bridges" and item["id"] not in data["bridges"]: diff --git a/homeassistant/components/notion/config_flow.py b/homeassistant/components/notion/config_flow.py index ad6d8eb9519..84fe69eb61a 100644 --- a/homeassistant/components/notion/config_flow.py +++ b/homeassistant/components/notion/config_flow.py @@ -1,16 +1,31 @@ """Config flow to configure the Notion integration.""" from __future__ import annotations +from typing import TYPE_CHECKING, Any + from aionotion import async_get_client -from aionotion.errors import NotionError +from aionotion.errors import InvalidCredentialsError, NotionError import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN +from .const import DOMAIN, LOGGER + +AUTH_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) +RE_AUTH_SCHEMA = vol.Schema( + { + vol.Required(CONF_PASSWORD): str, + } +) class NotionFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @@ -20,33 +35,77 @@ class NotionFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize the config flow.""" - self.data_schema = vol.Schema( - {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} - ) + self._password: str | None = None + self._username: str | None = None - async def _show_form(self, errors: dict[str, str] | None = None) -> FlowResult: - """Show the form to the user.""" - return self.async_show_form( - step_id="user", data_schema=self.data_schema, errors=errors or {} - ) + async def _async_verify(self, step_id: str, schema: vol.Schema) -> FlowResult: + """Attempt to authenticate the provided credentials.""" + if TYPE_CHECKING: + assert self._username + assert self._password + + session = aiohttp_client.async_get_clientsession(self.hass) + + try: + await async_get_client(self._username, self._password, session=session) + except InvalidCredentialsError: + return self.async_show_form( + step_id=step_id, + data_schema=schema, + errors={"base": "invalid_auth"}, + description_placeholders={CONF_USERNAME: self._username}, + ) + except NotionError as err: + LOGGER.error("Unknown Notion error: %s", err) + return self.async_show_form( + step_id=step_id, + data_schema=schema, + errors={"base": "unknown"}, + description_placeholders={CONF_USERNAME: self._username}, + ) + + data = {CONF_USERNAME: self._username, CONF_PASSWORD: self._password} + + if existing_entry := await self.async_set_unique_id(self._username): + self.hass.config_entries.async_update_entry(existing_entry, data=data) + self.hass.async_create_task( + self.hass.config_entries.async_reload(existing_entry.entry_id) + ) + return self.async_abort(reason="reauth_successful") + + return self.async_create_entry(title=self._username, data=data) + + async def async_step_reauth(self, config: ConfigType) -> FlowResult: + """Handle configuration by re-auth.""" + self._username = config[CONF_USERNAME] + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle re-auth completion.""" + if not user_input: + return self.async_show_form( + step_id="reauth_confirm", + data_schema=RE_AUTH_SCHEMA, + description_placeholders={CONF_USERNAME: self._username}, + ) + + self._password = user_input[CONF_PASSWORD] + + return await self._async_verify("reauth_confirm", RE_AUTH_SCHEMA) async def async_step_user( self, user_input: dict[str, str] | None = None ) -> FlowResult: """Handle the start of the config flow.""" if not user_input: - return await self._show_form() + return self.async_show_form(step_id="user", data_schema=AUTH_SCHEMA) await self.async_set_unique_id(user_input[CONF_USERNAME]) self._abort_if_unique_id_configured() - session = aiohttp_client.async_get_clientsession(self.hass) + self._username = user_input[CONF_USERNAME] + self._password = user_input[CONF_PASSWORD] - try: - await async_get_client( - user_input[CONF_USERNAME], user_input[CONF_PASSWORD], session=session - ) - except NotionError: - return await self._show_form({"base": "invalid_auth"}) - - return self.async_create_entry(title=user_input[CONF_USERNAME], data=user_input) + return await self._async_verify("user", AUTH_SCHEMA) diff --git a/homeassistant/components/notion/strings.json b/homeassistant/components/notion/strings.json index 401f0095e30..49721568ff2 100644 --- a/homeassistant/components/notion/strings.json +++ b/homeassistant/components/notion/strings.json @@ -1,6 +1,13 @@ { "config": { "step": { + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "Please re-enter the password for {username}.", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } + }, "user": { "title": "Fill in your information", "data": { @@ -11,10 +18,11 @@ }, "error": { "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "no_devices": "No devices found in account" + "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } } } diff --git a/homeassistant/components/notion/translations/en.json b/homeassistant/components/notion/translations/en.json index a31befc0e95..0eb4689bce6 100644 --- a/homeassistant/components/notion/translations/en.json +++ b/homeassistant/components/notion/translations/en.json @@ -1,13 +1,21 @@ { "config": { "abort": { - "already_configured": "Account is already configured" + "already_configured": "Account is already configured", + "reauth_successful": "Re-authentication was successful" }, "error": { "invalid_auth": "Invalid authentication", - "no_devices": "No devices found in account" + "unknown": "Unexpected error" }, "step": { + "reauth_confirm": { + "data": { + "password": "Password" + }, + "description": "Please re-enter the password for {username}.", + "title": "Reauthenticate Integration" + }, "user": { "data": { "password": "Password", diff --git a/tests/components/notion/test_config_flow.py b/tests/components/notion/test_config_flow.py index d9ed37d516c..bda70bd6af9 100644 --- a/tests/components/notion/test_config_flow.py +++ b/tests/components/notion/test_config_flow.py @@ -1,29 +1,31 @@ """Define tests for the Notion config flow.""" from unittest.mock import AsyncMock, patch -import aionotion +from aionotion.errors import InvalidCredentialsError, NotionError import pytest from homeassistant import data_entry_flow -from homeassistant.components.notion import DOMAIN, config_flow -from homeassistant.config_entries import SOURCE_USER +from homeassistant.components.notion import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from tests.common import MockConfigEntry -@pytest.fixture -def mock_client(): - """Define a fixture for a client creation coroutine.""" +@pytest.fixture(name="client") +def client_fixture(): + """Define a fixture for an aionotion client.""" return AsyncMock(return_value=None) -@pytest.fixture -def mock_aionotion(mock_client): - """Mock the aionotion library.""" - with patch("homeassistant.components.notion.config_flow.async_get_client") as mock_: - mock_.side_effect = mock_client - yield mock_ +@pytest.fixture(name="client_login") +def client_login_fixture(client): + """Define a fixture for patching the aiowatttime coroutine to get a client.""" + with patch( + "homeassistant.components.notion.config_flow.async_get_client" + ) as mock_client: + mock_client.side_effect = client + yield mock_client async def test_duplicate_error(hass): @@ -37,47 +39,90 @@ async def test_duplicate_error(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=conf ) + await hass.async_block_till_done() assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "already_configured" -@pytest.mark.parametrize( - "mock_client", [AsyncMock(side_effect=aionotion.errors.NotionError)] -) -async def test_invalid_credentials(hass, mock_aionotion): - """Test that an invalid API/App Key throws an error.""" +@pytest.mark.parametrize("client", [AsyncMock(side_effect=NotionError)]) +async def test_generic_notion_error(client_login, hass): + """Test that a generic aionotion error is handled correctly.""" conf = {CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"} - flow = config_flow.NotionFlowHandler() - flow.hass = hass - flow.context = {"source": SOURCE_USER} + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=conf + ) + + assert result["errors"] == {"base": "unknown"} + + +@pytest.mark.parametrize("client", [AsyncMock(side_effect=InvalidCredentialsError)]) +async def test_invalid_credentials(client_login, hass): + """Test that invalid credentials throw an error.""" + conf = {CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"} + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=conf + ) + await hass.async_block_till_done() - result = await flow.async_step_user(user_input=conf) assert result["errors"] == {"base": "invalid_auth"} -async def test_show_form(hass): - """Test that the form is served with no input.""" - flow = config_flow.NotionFlowHandler() - flow.hass = hass - flow.context = {"source": SOURCE_USER} +async def test_step_reauth(client_login, hass): + """Test that the reauth step works.""" + MockConfigEntry( + domain=DOMAIN, + unique_id="user@email.com", + data={CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"}, + ).add_to_hass(hass) - result = await flow.async_step_user(user_input=None) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH}, + data={CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"}, + ) + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.notion.async_setup_entry", return_value=True + ), patch("homeassistant.config_entries.ConfigEntries.async_reload"): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PASSWORD: "password"} + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "reauth_successful" + assert len(hass.config_entries.async_entries()) == 1 + + +async def test_show_form(client_login, hass): + """Test that the form is served with no input.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + await hass.async_block_till_done() assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" -async def test_step_user(hass, mock_aionotion): +async def test_step_user(client_login, hass): """Test that the user step works.""" conf = {CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"} - flow = config_flow.NotionFlowHandler() - flow.hass = hass - flow.context = {"source": SOURCE_USER} + with patch("homeassistant.components.notion.async_setup_entry", return_value=True): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=conf + ) + await hass.async_block_till_done() - result = await flow.async_step_user(user_input=conf) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["title"] == "user@host.com" assert result["data"] == { From 5d3d6fa1cd5b58d9e56b0b4876170746c235dd8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Arnauts?= Date: Fri, 24 Sep 2021 22:26:56 +0200 Subject: [PATCH 578/843] Add `state_class` and use `SensorEntityDescription` for comfoconnect (#54066) * Add state_class=measurement and use SensorEntityDescriptions * Use attributes from entity_description * Improvements * Adress remarks * Revert changes to fan * move method * Fix tests * Revert fan/__init__.py * Revert key change * Set default percentage in turn_on --- .../components/comfoconnect/__init__.py | 1 - homeassistant/components/comfoconnect/fan.py | 41 +- .../components/comfoconnect/sensor.py | 450 +++++++++--------- tests/components/comfoconnect/test_sensor.py | 17 +- 4 files changed, 255 insertions(+), 254 deletions(-) diff --git a/homeassistant/components/comfoconnect/__init__.py b/homeassistant/components/comfoconnect/__init__.py index 2a132837388..22e39e373e4 100644 --- a/homeassistant/components/comfoconnect/__init__.py +++ b/homeassistant/components/comfoconnect/__init__.py @@ -90,7 +90,6 @@ class ComfoConnectBridge: def __init__(self, hass, bridge, name, token, friendly_name, pin): """Initialize the ComfoConnect bridge.""" - self.data = {} self.name = name self.hass = hass self.unique_id = bridge.uuid.hex() diff --git a/homeassistant/components/comfoconnect/fan.py b/homeassistant/components/comfoconnect/fan.py index 53bc242ba2f..0cd1738a94a 100644 --- a/homeassistant/components/comfoconnect/fan.py +++ b/homeassistant/components/comfoconnect/fan.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging import math +from typing import Any from pycomfoconnect import ( CMD_FAN_MODE_AWAY, @@ -38,18 +39,19 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the ComfoConnect fan platform.""" ccb = hass.data[DOMAIN] - add_entities([ComfoConnectFan(ccb.name, ccb)], True) + add_entities([ComfoConnectFan(ccb)], True) class ComfoConnectFan(FanEntity): """Representation of the ComfoConnect fan platform.""" - def __init__(self, name, ccb: ComfoConnectBridge) -> None: + current_speed = None + + def __init__(self, ccb: ComfoConnectBridge) -> None: """Initialize the ComfoConnect fan.""" self._ccb = ccb - self._name = name - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register for sensor updates.""" _LOGGER.debug("Registering for fan speed") self.async_on_remove( @@ -68,7 +70,7 @@ class ComfoConnectFan(FanEntity): _LOGGER.debug( "Handle update for fan speed (%d): %s", SENSOR_FAN_SPEED_MODE, value ) - self._ccb.data[SENSOR_FAN_SPEED_MODE] = value + self.current_speed = value self.schedule_update_ha_state() @property @@ -84,7 +86,7 @@ class ComfoConnectFan(FanEntity): @property def name(self): """Return the name of the fan.""" - return self._name + return self._ccb.name @property def icon(self): @@ -99,10 +101,9 @@ class ComfoConnectFan(FanEntity): @property def percentage(self) -> int | None: """Return the current speed percentage.""" - speed = self._ccb.data.get(SENSOR_FAN_SPEED_MODE) - if speed is None: + if self.current_speed is None: return None - return ranged_value_to_percentage(SPEED_RANGE, speed) + return ranged_value_to_percentage(SPEED_RANGE, self.current_speed) @property def speed_count(self) -> int: @@ -110,28 +111,30 @@ class ComfoConnectFan(FanEntity): return int_states_in_range(SPEED_RANGE) def turn_on( - self, speed: str = None, percentage=None, preset_mode=None, **kwargs + self, + speed: str | None = None, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs, ) -> None: """Turn on the fan.""" - self.set_percentage(percentage) + if percentage is None: + self.set_percentage(1) # Set fan speed to low + else: + self.set_percentage(percentage) - def turn_off(self, **kwargs) -> None: + def turn_off(self, **kwargs: Any) -> None: """Turn off the fan (to away).""" self.set_percentage(0) - def set_percentage(self, percentage: int): + def set_percentage(self, percentage: int) -> None: """Set fan speed percentage.""" _LOGGER.debug("Changing fan speed percentage to %s", percentage) - if percentage is None: - cmd = CMD_FAN_MODE_LOW - elif percentage == 0: + if percentage == 0: cmd = CMD_FAN_MODE_AWAY else: speed = math.ceil(percentage_to_ranged_value(SPEED_RANGE, percentage)) cmd = CMD_MAPPING[speed] self._ccb.comfoconnect.cmd_rmi_request(cmd) - - # Update current mode - self.schedule_update_ha_state() diff --git a/homeassistant/components/comfoconnect/sensor.py b/homeassistant/components/comfoconnect/sensor.py index a6a625bab99..14590fc1445 100644 --- a/homeassistant/components/comfoconnect/sensor.py +++ b/homeassistant/components/comfoconnect/sensor.py @@ -1,4 +1,5 @@ """Platform to control a Zehnder ComfoAir Q350/450/600 ventilation unit.""" +from dataclasses import dataclass import logging from pycomfoconnect import ( @@ -26,11 +27,14 @@ from pycomfoconnect import ( ) import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ICON, - ATTR_ID, CONF_RESOURCES, DEVICE_CLASS_ENERGY, DEVICE_CLASS_HUMIDITY, @@ -72,187 +76,216 @@ ATTR_SUPPLY_TEMPERATURE = "supply_temperature" _LOGGER = logging.getLogger(__name__) -ATTR_LABEL = "label" -ATTR_MULTIPLIER = "multiplier" -ATTR_UNIT = "unit" -SENSOR_TYPES = { - ATTR_CURRENT_TEMPERATURE: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_LABEL: "Inside Temperature", - ATTR_UNIT: TEMP_CELSIUS, - ATTR_ICON: None, - ATTR_ID: SENSOR_TEMPERATURE_EXTRACT, - ATTR_MULTIPLIER: 0.1, - }, - ATTR_CURRENT_HUMIDITY: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, - ATTR_LABEL: "Inside Humidity", - ATTR_UNIT: PERCENTAGE, - ATTR_ICON: None, - ATTR_ID: SENSOR_HUMIDITY_EXTRACT, - }, - ATTR_CURRENT_RMOT: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_LABEL: "Current RMOT", - ATTR_UNIT: TEMP_CELSIUS, - ATTR_ICON: None, - ATTR_ID: SENSOR_CURRENT_RMOT, - ATTR_MULTIPLIER: 0.1, - }, - ATTR_OUTSIDE_TEMPERATURE: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_LABEL: "Outside Temperature", - ATTR_UNIT: TEMP_CELSIUS, - ATTR_ICON: None, - ATTR_ID: SENSOR_TEMPERATURE_OUTDOOR, - ATTR_MULTIPLIER: 0.1, - }, - ATTR_OUTSIDE_HUMIDITY: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, - ATTR_LABEL: "Outside Humidity", - ATTR_UNIT: PERCENTAGE, - ATTR_ICON: None, - ATTR_ID: SENSOR_HUMIDITY_OUTDOOR, - }, - ATTR_SUPPLY_TEMPERATURE: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_LABEL: "Supply Temperature", - ATTR_UNIT: TEMP_CELSIUS, - ATTR_ICON: None, - ATTR_ID: SENSOR_TEMPERATURE_SUPPLY, - ATTR_MULTIPLIER: 0.1, - }, - ATTR_SUPPLY_HUMIDITY: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, - ATTR_LABEL: "Supply Humidity", - ATTR_UNIT: PERCENTAGE, - ATTR_ICON: None, - ATTR_ID: SENSOR_HUMIDITY_SUPPLY, - }, - ATTR_SUPPLY_FAN_SPEED: { - ATTR_DEVICE_CLASS: None, - ATTR_LABEL: "Supply Fan Speed", - ATTR_UNIT: "rpm", - ATTR_ICON: "mdi:fan", - ATTR_ID: SENSOR_FAN_SUPPLY_SPEED, - }, - ATTR_SUPPLY_FAN_DUTY: { - ATTR_DEVICE_CLASS: None, - ATTR_LABEL: "Supply Fan Duty", - ATTR_UNIT: PERCENTAGE, - ATTR_ICON: "mdi:fan", - ATTR_ID: SENSOR_FAN_SUPPLY_DUTY, - }, - ATTR_EXHAUST_FAN_SPEED: { - ATTR_DEVICE_CLASS: None, - ATTR_LABEL: "Exhaust Fan Speed", - ATTR_UNIT: "rpm", - ATTR_ICON: "mdi:fan", - ATTR_ID: SENSOR_FAN_EXHAUST_SPEED, - }, - ATTR_EXHAUST_FAN_DUTY: { - ATTR_DEVICE_CLASS: None, - ATTR_LABEL: "Exhaust Fan Duty", - ATTR_UNIT: PERCENTAGE, - ATTR_ICON: "mdi:fan", - ATTR_ID: SENSOR_FAN_EXHAUST_DUTY, - }, - ATTR_EXHAUST_TEMPERATURE: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_LABEL: "Exhaust Temperature", - ATTR_UNIT: TEMP_CELSIUS, - ATTR_ICON: None, - ATTR_ID: SENSOR_TEMPERATURE_EXHAUST, - ATTR_MULTIPLIER: 0.1, - }, - ATTR_EXHAUST_HUMIDITY: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, - ATTR_LABEL: "Exhaust Humidity", - ATTR_UNIT: PERCENTAGE, - ATTR_ICON: None, - ATTR_ID: SENSOR_HUMIDITY_EXHAUST, - }, - ATTR_AIR_FLOW_SUPPLY: { - ATTR_DEVICE_CLASS: None, - ATTR_LABEL: "Supply airflow", - ATTR_UNIT: VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR, - ATTR_ICON: "mdi:fan", - ATTR_ID: SENSOR_FAN_SUPPLY_FLOW, - }, - ATTR_AIR_FLOW_EXHAUST: { - ATTR_DEVICE_CLASS: None, - ATTR_LABEL: "Exhaust airflow", - ATTR_UNIT: VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR, - ATTR_ICON: "mdi:fan", - ATTR_ID: SENSOR_FAN_EXHAUST_FLOW, - }, - ATTR_BYPASS_STATE: { - ATTR_DEVICE_CLASS: None, - ATTR_LABEL: "Bypass State", - ATTR_UNIT: PERCENTAGE, - ATTR_ICON: "mdi:camera-iris", - ATTR_ID: SENSOR_BYPASS_STATE, - }, - ATTR_DAYS_TO_REPLACE_FILTER: { - ATTR_DEVICE_CLASS: None, - ATTR_LABEL: "Days to replace filter", - ATTR_UNIT: TIME_DAYS, - ATTR_ICON: "mdi:calendar", - ATTR_ID: SENSOR_DAYS_TO_REPLACE_FILTER, - }, - ATTR_POWER_CURRENT: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, - ATTR_LABEL: "Power usage", - ATTR_UNIT: POWER_WATT, - ATTR_ICON: None, - ATTR_ID: SENSOR_POWER_CURRENT, - }, - ATTR_POWER_TOTAL: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - ATTR_LABEL: "Power total", - ATTR_UNIT: ENERGY_KILO_WATT_HOUR, - ATTR_ICON: None, - ATTR_ID: SENSOR_POWER_TOTAL, - }, - ATTR_PREHEATER_POWER_CURRENT: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, - ATTR_LABEL: "Preheater power usage", - ATTR_UNIT: POWER_WATT, - ATTR_ICON: None, - ATTR_ID: SENSOR_PREHEATER_POWER_CURRENT, - }, - ATTR_PREHEATER_POWER_TOTAL: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - ATTR_LABEL: "Preheater power total", - ATTR_UNIT: ENERGY_KILO_WATT_HOUR, - ATTR_ICON: None, - ATTR_ID: SENSOR_PREHEATER_POWER_TOTAL, - }, -} +@dataclass +class ComfoconnectRequiredKeysMixin: + """Mixin for required keys.""" + + sensor_id: int + + +@dataclass +class ComfoconnectSensorEntityDescription( + SensorEntityDescription, ComfoconnectRequiredKeysMixin +): + """Describes Comfoconnect sensor entity.""" + + multiplier: float = 1 + + +SENSOR_TYPES = ( + ComfoconnectSensorEntityDescription( + key=ATTR_CURRENT_TEMPERATURE, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + name="Inside temperature", + native_unit_of_measurement=TEMP_CELSIUS, + sensor_id=SENSOR_TEMPERATURE_EXTRACT, + multiplier=0.1, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_CURRENT_HUMIDITY, + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + name="Inside humidity", + native_unit_of_measurement=PERCENTAGE, + sensor_id=SENSOR_HUMIDITY_EXTRACT, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_CURRENT_RMOT, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + name="Current RMOT", + native_unit_of_measurement=TEMP_CELSIUS, + sensor_id=SENSOR_CURRENT_RMOT, + multiplier=0.1, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_OUTSIDE_TEMPERATURE, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + name="Outside temperature", + native_unit_of_measurement=TEMP_CELSIUS, + sensor_id=SENSOR_TEMPERATURE_OUTDOOR, + multiplier=0.1, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_OUTSIDE_HUMIDITY, + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + name="Outside humidity", + native_unit_of_measurement=PERCENTAGE, + sensor_id=SENSOR_HUMIDITY_OUTDOOR, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_SUPPLY_TEMPERATURE, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + name="Supply temperature", + native_unit_of_measurement=TEMP_CELSIUS, + sensor_id=SENSOR_TEMPERATURE_SUPPLY, + multiplier=0.1, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_SUPPLY_HUMIDITY, + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + name="Supply humidity", + native_unit_of_measurement=PERCENTAGE, + sensor_id=SENSOR_HUMIDITY_SUPPLY, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_SUPPLY_FAN_SPEED, + state_class=STATE_CLASS_MEASUREMENT, + name="Supply fan speed", + native_unit_of_measurement="rpm", + icon="mdi:fan-plus", + sensor_id=SENSOR_FAN_SUPPLY_SPEED, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_SUPPLY_FAN_DUTY, + state_class=STATE_CLASS_MEASUREMENT, + name="Supply fan duty", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:fan-plus", + sensor_id=SENSOR_FAN_SUPPLY_DUTY, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_EXHAUST_FAN_SPEED, + state_class=STATE_CLASS_MEASUREMENT, + name="Exhaust fan speed", + native_unit_of_measurement="rpm", + icon="mdi:fan-minus", + sensor_id=SENSOR_FAN_EXHAUST_SPEED, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_EXHAUST_FAN_DUTY, + state_class=STATE_CLASS_MEASUREMENT, + name="Exhaust fan duty", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:fan-minus", + sensor_id=SENSOR_FAN_EXHAUST_DUTY, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_EXHAUST_TEMPERATURE, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + name="Exhaust temperature", + native_unit_of_measurement=TEMP_CELSIUS, + sensor_id=SENSOR_TEMPERATURE_EXHAUST, + multiplier=0.1, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_EXHAUST_HUMIDITY, + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + name="Exhaust humidity", + native_unit_of_measurement=PERCENTAGE, + sensor_id=SENSOR_HUMIDITY_EXHAUST, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_AIR_FLOW_SUPPLY, + state_class=STATE_CLASS_MEASUREMENT, + name="Supply airflow", + native_unit_of_measurement=VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR, + icon="mdi:fan-plus", + sensor_id=SENSOR_FAN_SUPPLY_FLOW, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_AIR_FLOW_EXHAUST, + state_class=STATE_CLASS_MEASUREMENT, + name="Exhaust airflow", + native_unit_of_measurement=VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR, + icon="mdi:fan-minus", + sensor_id=SENSOR_FAN_EXHAUST_FLOW, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_BYPASS_STATE, + state_class=STATE_CLASS_MEASUREMENT, + name="Bypass state", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:camera-iris", + sensor_id=SENSOR_BYPASS_STATE, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_DAYS_TO_REPLACE_FILTER, + name="Days to replace filter", + native_unit_of_measurement=TIME_DAYS, + icon="mdi:calendar", + sensor_id=SENSOR_DAYS_TO_REPLACE_FILTER, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_POWER_CURRENT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + name="Power usage", + native_unit_of_measurement=POWER_WATT, + sensor_id=SENSOR_POWER_CURRENT, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_POWER_TOTAL, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + name="Energy total", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + sensor_id=SENSOR_POWER_TOTAL, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_PREHEATER_POWER_CURRENT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + name="Preheater power usage", + native_unit_of_measurement=POWER_WATT, + sensor_id=SENSOR_PREHEATER_POWER_CURRENT, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_PREHEATER_POWER_TOTAL, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + name="Preheater energy total", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + sensor_id=SENSOR_PREHEATER_POWER_TOTAL, + ), +) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional(CONF_RESOURCES, default=[]): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] + cv.ensure_list, [vol.In([desc.key for desc in SENSOR_TYPES])] ) } ) def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the ComfoConnect fan platform.""" + """Set up the ComfoConnect sensor platform.""" ccb = hass.data[DOMAIN] - sensors = [] - for resource in config[CONF_RESOURCES]: - sensors.append( - ComfoConnectSensor( - name=f"{ccb.name} {SENSOR_TYPES[resource][ATTR_LABEL]}", - ccb=ccb, - sensor_type=resource, - ) - ) + sensors = [ + ComfoConnectSensor(ccb=ccb, description=description) + for description in SENSOR_TYPES + if description.key in config[CONF_RESOURCES] + ] add_entities(sensors, True) @@ -260,76 +293,47 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class ComfoConnectSensor(SensorEntity): """Representation of a ComfoConnect sensor.""" - def __init__(self, name, ccb: ComfoConnectBridge, sensor_type) -> None: + _attr_should_poll = False + entity_description: ComfoconnectSensorEntityDescription + + def __init__( + self, + ccb: ComfoConnectBridge, + description: ComfoconnectSensorEntityDescription, + ) -> None: """Initialize the ComfoConnect sensor.""" self._ccb = ccb - self._sensor_type = sensor_type - self._sensor_id = SENSOR_TYPES[self._sensor_type][ATTR_ID] - self._name = name + self.entity_description = description + self._attr_name = f"{ccb.name} {description.name}" + self._attr_unique_id = f"{ccb.unique_id}-{description.key}" - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register for sensor updates.""" _LOGGER.debug( - "Registering for sensor %s (%d)", self._sensor_type, self._sensor_id + "Registering for sensor %s (%d)", + self.entity_description.key, + self.entity_description.sensor_id, ) self.async_on_remove( async_dispatcher_connect( self.hass, - SIGNAL_COMFOCONNECT_UPDATE_RECEIVED.format(self._sensor_id), + SIGNAL_COMFOCONNECT_UPDATE_RECEIVED.format( + self.entity_description.sensor_id + ), self._handle_update, ) ) await self.hass.async_add_executor_job( - self._ccb.comfoconnect.register_sensor, self._sensor_id + self._ccb.comfoconnect.register_sensor, self.entity_description.sensor_id ) def _handle_update(self, value): """Handle update callbacks.""" _LOGGER.debug( "Handle update for sensor %s (%d): %s", - self._sensor_type, - self._sensor_id, + self.entity_description.key, + self.entity_description.sensor_id, value, ) - self._ccb.data[self._sensor_id] = round( - value * SENSOR_TYPES[self._sensor_type].get(ATTR_MULTIPLIER, 1), 2 - ) + self._attr_native_value = round(value * self.entity_description.multiplier, 2) self.schedule_update_ha_state() - - @property - def native_value(self): - """Return the state of the entity.""" - try: - return self._ccb.data[self._sensor_id] - except KeyError: - return None - - @property - def should_poll(self) -> bool: - """Do not poll.""" - return False - - @property - def unique_id(self): - """Return a unique_id for this entity.""" - return f"{self._ccb.unique_id}-{self._sensor_type}" - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def icon(self): - """Return the icon to use in the frontend.""" - return SENSOR_TYPES[self._sensor_type][ATTR_ICON] - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of this entity.""" - return SENSOR_TYPES[self._sensor_type][ATTR_UNIT] - - @property - def device_class(self): - """Return the device_class.""" - return SENSOR_TYPES[self._sensor_type][ATTR_DEVICE_CLASS] diff --git a/tests/components/comfoconnect/test_sensor.py b/tests/components/comfoconnect/test_sensor.py index 3ae078cccef..578712aa1dc 100644 --- a/tests/components/comfoconnect/test_sensor.py +++ b/tests/components/comfoconnect/test_sensor.py @@ -54,40 +54,35 @@ async def test_sensors(hass, setup_sensor): """Test the sensors.""" state = hass.states.get("sensor.comfoairq_inside_humidity") assert state is not None - - assert state.name == "ComfoAirQ Inside Humidity" + assert state.name == "ComfoAirQ Inside humidity" assert state.attributes.get("unit_of_measurement") == "%" assert state.attributes.get("device_class") == "humidity" assert state.attributes.get("icon") is None state = hass.states.get("sensor.comfoairq_inside_temperature") assert state is not None - - assert state.name == "ComfoAirQ Inside Temperature" + assert state.name == "ComfoAirQ Inside temperature" assert state.attributes.get("unit_of_measurement") == "°C" assert state.attributes.get("device_class") == "temperature" assert state.attributes.get("icon") is None state = hass.states.get("sensor.comfoairq_supply_fan_duty") assert state is not None - - assert state.name == "ComfoAirQ Supply Fan Duty" + assert state.name == "ComfoAirQ Supply fan duty" assert state.attributes.get("unit_of_measurement") == "%" assert state.attributes.get("device_class") is None - assert state.attributes.get("icon") == "mdi:fan" + assert state.attributes.get("icon") == "mdi:fan-plus" state = hass.states.get("sensor.comfoairq_power_usage") assert state is not None - assert state.name == "ComfoAirQ Power usage" assert state.attributes.get("unit_of_measurement") == "W" assert state.attributes.get("device_class") == "power" assert state.attributes.get("icon") is None - state = hass.states.get("sensor.comfoairq_preheater_power_total") + state = hass.states.get("sensor.comfoairq_preheater_energy_total") assert state is not None - - assert state.name == "ComfoAirQ Preheater power total" + assert state.name == "ComfoAirQ Preheater energy total" assert state.attributes.get("unit_of_measurement") == "kWh" assert state.attributes.get("device_class") == "energy" assert state.attributes.get("icon") is None From b1f4ccfd6b69dde1a8ca0ec73db34428e7c76c8e Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sat, 25 Sep 2021 04:43:37 -0400 Subject: [PATCH 579/843] Reuse zwave_js device when replacing removed node with same node (#56599) * Reuse zwave_js device when a removed node is replaced with the same node * Ensure change is backwards compatible with servers that don't include replaced * Remove lambda * Add assertions to remove type ignores * fix tests by always copying state and setting manufacturer/label attributes --- homeassistant/components/zwave_js/__init__.py | 56 +++++++++---- tests/components/zwave_js/test_init.py | 84 ++++++++++++++++++- tests/components/zwave_js/test_migrate.py | 38 ++++++--- 3 files changed, 151 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index 71c5b2bf592..7a8f284787f 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio from collections import defaultdict +from typing import Callable from async_timeout import timeout from zwave_js_server.client import Client as ZwaveClient @@ -97,11 +98,22 @@ def register_node_in_dev_reg( dev_reg: device_registry.DeviceRegistry, client: ZwaveClient, node: ZwaveNode, + remove_device_func: Callable[[device_registry.DeviceEntry], None], ) -> device_registry.DeviceEntry: """Register node in dev reg.""" + device_id = get_device_id(client, node) + # If a device already exists but it doesn't match the new node, it means the node + # was replaced with a different device and the device needs to be removeed so the + # new device can be created. Otherwise if the device exists and the node is the same, + # the node was replaced with the same device model and we can reuse the device. + if (device := dev_reg.async_get_device({device_id})) and ( + device.model != node.device_config.label + or device.manufacturer != node.device_config.manufacturer + ): + remove_device_func(device) params = { "config_entry_id": entry.entry_id, - "identifiers": {get_device_id(client, node)}, + "identifiers": {device_id}, "sw_version": node.firmware_version, "name": node.name or node.device_config.description or f"Node {node.node_id}", "model": node.device_config.label, @@ -135,6 +147,14 @@ async def async_setup_entry( # noqa: C901 registered_unique_ids: dict[str, dict[str, set[str]]] = defaultdict(dict) discovered_value_ids: dict[str, set[str]] = defaultdict(set) + @callback + def remove_device(device: device_registry.DeviceEntry) -> None: + """Remove device from registry.""" + # note: removal of entity registry entry is handled by core + dev_reg.async_remove_device(device.id) + registered_unique_ids.pop(device.id, None) + discovered_value_ids.pop(device.id, None) + async def async_handle_discovery_info( device: device_registry.DeviceEntry, disc_info: ZwaveDiscoveryInfo, @@ -188,7 +208,9 @@ async def async_setup_entry( # noqa: C901 """Handle node ready event.""" LOGGER.debug("Processing node %s", node) # register (or update) node in device registry - device = register_node_in_dev_reg(hass, entry, dev_reg, client, node) + device = register_node_in_dev_reg( + hass, entry, dev_reg, client, node, remove_device + ) # We only want to create the defaultdict once, even on reinterviews if device.id not in registered_unique_ids: registered_unique_ids[device.id] = defaultdict(set) @@ -265,7 +287,7 @@ async def async_setup_entry( # noqa: C901 ) # we do submit the node to device registry so user has # some visual feedback that something is (in the process of) being added - register_node_in_dev_reg(hass, entry, dev_reg, client, node) + register_node_in_dev_reg(hass, entry, dev_reg, client, node, remove_device) async def async_on_value_added( value_updates_disc_info: dict[str, ZwaveDiscoveryInfo], value: Value @@ -293,20 +315,24 @@ async def async_setup_entry( # noqa: C901 ) @callback - def async_on_node_removed(node: ZwaveNode) -> None: + def async_on_node_removed(event: dict) -> None: """Handle node removed event.""" + node: ZwaveNode = event["node"] + replaced: bool = event.get("replaced", False) # grab device in device registry attached to this node dev_id = get_device_id(client, node) device = dev_reg.async_get_device({dev_id}) - # note: removal of entity registry entry is handled by core - dev_reg.async_remove_device(device.id) # type: ignore - registered_unique_ids.pop(device.id, None) # type: ignore - discovered_value_ids.pop(device.id, None) # type: ignore + # We assert because we know the device exists + assert device + if not replaced: + remove_device(device) @callback def async_on_value_notification(notification: ValueNotification) -> None: """Relay stateless value notification events from Z-Wave nodes to hass.""" device = dev_reg.async_get_device({get_device_id(client, notification.node)}) + # We assert because we know the device exists + assert device raw_value = value = notification.value if notification.metadata.states: value = notification.metadata.states.get(str(value), value) @@ -317,7 +343,7 @@ async def async_setup_entry( # noqa: C901 ATTR_NODE_ID: notification.node.node_id, ATTR_HOME_ID: client.driver.controller.home_id, ATTR_ENDPOINT: notification.endpoint, - ATTR_DEVICE_ID: device.id, # type: ignore + ATTR_DEVICE_ID: device.id, ATTR_COMMAND_CLASS: notification.command_class, ATTR_COMMAND_CLASS_NAME: notification.command_class_name, ATTR_LABEL: notification.metadata.label, @@ -336,11 +362,13 @@ async def async_setup_entry( # noqa: C901 ) -> None: """Relay stateless notification events from Z-Wave nodes to hass.""" device = dev_reg.async_get_device({get_device_id(client, notification.node)}) + # We assert because we know the device exists + assert device event_data = { ATTR_DOMAIN: DOMAIN, ATTR_NODE_ID: notification.node.node_id, ATTR_HOME_ID: client.driver.controller.home_id, - ATTR_DEVICE_ID: device.id, # type: ignore + ATTR_DEVICE_ID: device.id, ATTR_COMMAND_CLASS: notification.command_class, } @@ -379,6 +407,8 @@ async def async_setup_entry( # noqa: C901 disc_info = value_updates_disc_info[value.value_id] device = dev_reg.async_get_device({get_device_id(client, value.node)}) + # We assert because we know the device exists + assert device unique_id = get_unique_id( client.driver.controller.home_id, disc_info.primary_value.value_id @@ -394,7 +424,7 @@ async def async_setup_entry( # noqa: C901 { ATTR_NODE_ID: value.node.node_id, ATTR_HOME_ID: client.driver.controller.home_id, - ATTR_DEVICE_ID: device.id, # type: ignore + ATTR_DEVICE_ID: device.id, ATTR_ENTITY_ID: entity_id, ATTR_COMMAND_CLASS: value.command_class, ATTR_COMMAND_CLASS_NAME: value.command_class_name, @@ -500,9 +530,7 @@ async def async_setup_entry( # noqa: C901 # listen for nodes being removed from the mesh # NOTE: This will not remove nodes that were removed when HA was not running entry.async_on_unload( - client.driver.controller.on( - "node removed", lambda event: async_on_node_removed(event["node"]) - ) + client.driver.controller.on("node removed", async_on_node_removed) ) platform_task = hass.async_create_task(start_platforms()) diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 5fed86c4d81..2e6a64f456e 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -161,7 +161,7 @@ async def test_new_entity_on_value_added(hass, multisensor_6, client, integratio async def test_on_node_added_ready(hass, multisensor_6_state, client, integration): """Test we handle a ready node added event.""" dev_reg = dr.async_get(hass) - node = Node(client, multisensor_6_state) + node = Node(client, deepcopy(multisensor_6_state)) event = {"node": node} air_temperature_device_id = f"{client.driver.controller.home_id}-{node.node_id}" @@ -656,3 +656,85 @@ async def test_suggested_area(hass, client, eaton_rf9640_dimmer): entity = ent_reg.async_get(EATON_RF9640_ENTITY) assert dev_reg.async_get(entity.device_id).area_id is not None + + +async def test_node_removed(hass, multisensor_6_state, client, integration): + """Test that device gets removed when node gets removed.""" + dev_reg = dr.async_get(hass) + node = Node(client, deepcopy(multisensor_6_state)) + device_id = f"{client.driver.controller.home_id}-{node.node_id}" + event = {"node": node} + + client.driver.controller.emit("node added", event) + await hass.async_block_till_done() + old_device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + assert old_device.id + + event = {"node": node, "replaced": False} + + client.driver.controller.emit("node removed", event) + await hass.async_block_till_done() + # Assert device has been removed + assert not dev_reg.async_get(old_device.id) + + +async def test_replace_same_node(hass, multisensor_6_state, client, integration): + """Test when a node is replaced with itself that the device remains.""" + dev_reg = dr.async_get(hass) + node = Node(client, deepcopy(multisensor_6_state)) + device_id = f"{client.driver.controller.home_id}-{node.node_id}" + event = {"node": node} + + client.driver.controller.emit("node added", event) + await hass.async_block_till_done() + old_device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + assert old_device.id + + event = {"node": node, "replaced": True} + + client.driver.controller.emit("node removed", event) + await hass.async_block_till_done() + # Assert device has remained + assert dev_reg.async_get(old_device.id) + + event = {"node": node} + + client.driver.controller.emit("node added", event) + await hass.async_block_till_done() + # Assert device has remained + assert dev_reg.async_get(old_device.id) + + +async def test_replace_different_node( + hass, multisensor_6_state, hank_binary_switch_state, client, integration +): + """Test when a node is replaced with a different node.""" + hank_binary_switch_state = deepcopy(hank_binary_switch_state) + multisensor_6_state = deepcopy(multisensor_6_state) + hank_binary_switch_state["nodeId"] = multisensor_6_state["nodeId"] + dev_reg = dr.async_get(hass) + old_node = Node(client, multisensor_6_state) + device_id = f"{client.driver.controller.home_id}-{old_node.node_id}" + new_node = Node(client, hank_binary_switch_state) + event = {"node": old_node} + + client.driver.controller.emit("node added", event) + await hass.async_block_till_done() + device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + assert device + + event = {"node": old_node, "replaced": True} + + client.driver.controller.emit("node removed", event) + await hass.async_block_till_done() + # Device should still be there after the node was removed + assert device + + event = {"node": new_node} + + client.driver.controller.emit("node added", event) + await hass.async_block_till_done() + device = dev_reg.async_get(device.id) + # assert device is new + assert device + assert device.manufacturer == "HANK Electronics Ltd." diff --git a/tests/components/zwave_js/test_migrate.py b/tests/components/zwave_js/test_migrate.py index a1f60c31fce..37c53700d95 100644 --- a/tests/components/zwave_js/test_migrate.py +++ b/tests/components/zwave_js/test_migrate.py @@ -1,4 +1,6 @@ """Test the Z-Wave JS migration module.""" +import copy + import pytest from zwave_js_server.model.node import Node @@ -48,7 +50,7 @@ async def test_unique_id_migration_dupes( assert entity_entry.unique_id == old_unique_id_2 # Add a ready node, unique ID should be migrated - node = Node(client, multisensor_6_state) + node = Node(client, copy.deepcopy(multisensor_6_state)) event = {"node": node} client.driver.controller.emit("node added", event) @@ -91,7 +93,7 @@ async def test_unique_id_migration(hass, multisensor_6_state, client, integratio assert entity_entry.unique_id == old_unique_id # Add a ready node, unique ID should be migrated - node = Node(client, multisensor_6_state) + node = Node(client, copy.deepcopy(multisensor_6_state)) event = {"node": node} client.driver.controller.emit("node added", event) @@ -135,7 +137,7 @@ async def test_unique_id_migration_property_key( assert entity_entry.unique_id == old_unique_id # Add a ready node, unique ID should be migrated - node = Node(client, hank_binary_switch_state) + node = Node(client, copy.deepcopy(hank_binary_switch_state)) event = {"node": node} client.driver.controller.emit("node added", event) @@ -170,7 +172,7 @@ async def test_unique_id_migration_notification_binary_sensor( assert entity_entry.unique_id == old_unique_id # Add a ready node, unique ID should be migrated - node = Node(client, multisensor_6_state) + node = Node(client, copy.deepcopy(multisensor_6_state)) event = {"node": node} client.driver.controller.emit("node added", event) @@ -187,12 +189,15 @@ async def test_old_entity_migration( hass, hank_binary_switch_state, client, integration ): """Test old entity on a different endpoint is migrated to a new one.""" - node = Node(client, hank_binary_switch_state) + node = Node(client, copy.deepcopy(hank_binary_switch_state)) ent_reg = er.async_get(hass) dev_reg = dr.async_get(hass) device = dev_reg.async_get_or_create( - config_entry_id=integration.entry_id, identifiers={get_device_id(client, node)} + config_entry_id=integration.entry_id, + identifiers={get_device_id(client, node)}, + manufacturer=hank_binary_switch_state["deviceConfig"]["manufacturer"], + model=hank_binary_switch_state["deviceConfig"]["label"], ) SENSOR_NAME = "sensor.smart_plug_with_two_usb_ports_value_electric_consumed" @@ -230,12 +235,15 @@ async def test_different_endpoint_migration_status_sensor( hass, hank_binary_switch_state, client, integration ): """Test that the different endpoint migration logic skips over the status sensor.""" - node = Node(client, hank_binary_switch_state) + node = Node(client, copy.deepcopy(hank_binary_switch_state)) ent_reg = er.async_get(hass) dev_reg = dr.async_get(hass) device = dev_reg.async_get_or_create( - config_entry_id=integration.entry_id, identifiers={get_device_id(client, node)} + config_entry_id=integration.entry_id, + identifiers={get_device_id(client, node)}, + manufacturer=hank_binary_switch_state["deviceConfig"]["manufacturer"], + model=hank_binary_switch_state["deviceConfig"]["label"], ) SENSOR_NAME = "sensor.smart_plug_with_two_usb_ports_status_sensor" @@ -271,12 +279,15 @@ async def test_skip_old_entity_migration_for_multiple( hass, hank_binary_switch_state, client, integration ): """Test that multiple entities of the same value but on a different endpoint get skipped.""" - node = Node(client, hank_binary_switch_state) + node = Node(client, copy.deepcopy(hank_binary_switch_state)) ent_reg = er.async_get(hass) dev_reg = dr.async_get(hass) device = dev_reg.async_get_or_create( - config_entry_id=integration.entry_id, identifiers={get_device_id(client, node)} + config_entry_id=integration.entry_id, + identifiers={get_device_id(client, node)}, + manufacturer=hank_binary_switch_state["deviceConfig"]["manufacturer"], + model=hank_binary_switch_state["deviceConfig"]["label"], ) SENSOR_NAME = "sensor.smart_plug_with_two_usb_ports_value_electric_consumed" @@ -328,12 +339,15 @@ async def test_old_entity_migration_notification_binary_sensor( hass, multisensor_6_state, client, integration ): """Test old entity on a different endpoint is migrated to a new one for a notification binary sensor.""" - node = Node(client, multisensor_6_state) + node = Node(client, copy.deepcopy(multisensor_6_state)) ent_reg = er.async_get(hass) dev_reg = dr.async_get(hass) device = dev_reg.async_get_or_create( - config_entry_id=integration.entry_id, identifiers={get_device_id(client, node)} + config_entry_id=integration.entry_id, + identifiers={get_device_id(client, node)}, + manufacturer=multisensor_6_state["deviceConfig"]["manufacturer"], + model=multisensor_6_state["deviceConfig"]["label"], ) entity_name = NOTIFICATION_MOTION_BINARY_SENSOR.split(".")[1] From 5c1f55ffa4fe3da209bfaf057abec2ef7303d9e3 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Sat, 25 Sep 2021 11:17:26 +0200 Subject: [PATCH 580/843] =?UTF-8?q?Bump=20fj=C3=A4r=C3=A5skupan=20to=201.0?= =?UTF-8?q?.1=20(#56628)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- homeassistant/components/fjaraskupan/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fjaraskupan/manifest.json b/homeassistant/components/fjaraskupan/manifest.json index 68158776afe..d9cd5640848 100644 --- a/homeassistant/components/fjaraskupan/manifest.json +++ b/homeassistant/components/fjaraskupan/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/fjaraskupan", "requirements": [ - "fjaraskupan==1.0.0" + "fjaraskupan==1.0.1" ], "codeowners": [ "@elupus" diff --git a/requirements_all.txt b/requirements_all.txt index e256bcdc41e..cf1e68ef44d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -644,7 +644,7 @@ fitbit==0.3.1 fixerio==1.0.0a0 # homeassistant.components.fjaraskupan -fjaraskupan==1.0.0 +fjaraskupan==1.0.1 # homeassistant.components.flipr flipr-api==1.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dc571668553..8eb74e5189b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -370,7 +370,7 @@ faadelays==0.0.7 feedparser==6.0.2 # homeassistant.components.fjaraskupan -fjaraskupan==1.0.0 +fjaraskupan==1.0.1 # homeassistant.components.flipr flipr-api==1.4.1 From f0de6dc21a59ac469186f0327327088e6f60aaa4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Sat, 25 Sep 2021 11:17:55 +0200 Subject: [PATCH 581/843] Use SurePetcareEntity for surepetcare binary sensor (#56601) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Use SurePetcareEntity for surepetcare binary sensor Signed-off-by: Daniel Hjelseth Høyer * tests Signed-off-by: Daniel Hjelseth Høyer --- .../components/surepetcare/binary_sensor.py | 61 +++++-------------- .../surepetcare/test_binary_sensor.py | 10 +-- 2 files changed, 19 insertions(+), 52 deletions(-) diff --git a/homeassistant/components/surepetcare/binary_sensor.py b/homeassistant/components/surepetcare/binary_sensor.py index e0903230697..a75addb11d3 100644 --- a/homeassistant/components/surepetcare/binary_sensor.py +++ b/homeassistant/components/surepetcare/binary_sensor.py @@ -1,8 +1,6 @@ """Support for Sure PetCare Flaps/Pets binary sensors.""" from __future__ import annotations -from abc import abstractmethod -import logging from typing import cast from surepy.entities import SurepyEntity @@ -17,14 +15,10 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from . import SurePetcareDataCoordinator from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) +from .entity import SurePetcareEntity async def async_setup_entry( @@ -34,7 +28,7 @@ async def async_setup_entry( entities: list[SurePetcareBinarySensor] = [] - coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator: SurePetcareDataCoordinator = hass.data[DOMAIN][entry.entry_id] for surepy_entity in coordinator.data.values(): @@ -54,41 +48,19 @@ async def async_setup_entry( async_add_entities(entities) -class SurePetcareBinarySensor(CoordinatorEntity, BinarySensorEntity): +class SurePetcareBinarySensor(SurePetcareEntity, BinarySensorEntity): """A binary sensor implementation for Sure Petcare Entities.""" def __init__( self, - _id: int, - coordinator: DataUpdateCoordinator, + surepetcare_id: int, + coordinator: SurePetcareDataCoordinator, ) -> None: """Initialize a Sure Petcare binary sensor.""" - super().__init__(coordinator) + super().__init__(surepetcare_id, coordinator) - self._id = _id - - surepy_entity: SurepyEntity = coordinator.data[_id] - - # cover special case where a device has no name set - if surepy_entity.name: - name = surepy_entity.name - else: - name = f"Unnamed {surepy_entity.type.name.capitalize()}" - - self._attr_name = f"{surepy_entity.type.name.capitalize()} {name.capitalize()}" - self._attr_unique_id = f"{surepy_entity.household_id}-{_id}" - self._update_attr(coordinator.data[_id]) - - @abstractmethod - @callback - def _update_attr(self, surepy_entity: SurepyEntity) -> None: - """Update the state and attributes.""" - - @callback - def _handle_coordinator_update(self) -> None: - """Get the latest data and update the state.""" - self._update_attr(self.coordinator.data[self._id]) - self.async_write_ha_state() + self._attr_name = self._device_name + self._attr_unique_id = self._device_id class Hub(SurePetcareBinarySensor): @@ -115,7 +87,6 @@ class Hub(SurePetcareBinarySensor): } else: self._attr_extra_state_attributes = {} - _LOGGER.debug("%s -> state: %s", self.name, state) class Pet(SurePetcareBinarySensor): @@ -139,7 +110,6 @@ class Pet(SurePetcareBinarySensor): } else: self._attr_extra_state_attributes = {} - _LOGGER.debug("%s -> state: %s", self.name, state) class DeviceConnectivity(SurePetcareBinarySensor): @@ -149,15 +119,13 @@ class DeviceConnectivity(SurePetcareBinarySensor): def __init__( self, - _id: int, - coordinator: DataUpdateCoordinator, + surepetcare_id: int, + coordinator: SurePetcareDataCoordinator, ) -> None: """Initialize a Sure Petcare Device.""" - super().__init__(_id, coordinator) - self._attr_name = f"{self.name}_connectivity" - self._attr_unique_id = ( - f"{self.coordinator.data[self._id].household_id}-{self._id}-connectivity" - ) + super().__init__(surepetcare_id, coordinator) + self._attr_name = f"{self._device_name} Connectivity" + self._attr_unique_id = f"{self._device_id}-connectivity" @callback def _update_attr(self, surepy_entity: SurepyEntity) -> None: @@ -170,4 +138,3 @@ class DeviceConnectivity(SurePetcareBinarySensor): } else: self._attr_extra_state_attributes = {} - _LOGGER.debug("%s -> state: %s", self.name, state) diff --git a/tests/components/surepetcare/test_binary_sensor.py b/tests/components/surepetcare/test_binary_sensor.py index cd0445dd6d5..aa78628b355 100644 --- a/tests/components/surepetcare/test_binary_sensor.py +++ b/tests/components/surepetcare/test_binary_sensor.py @@ -7,11 +7,11 @@ from homeassistant.setup import async_setup_component from . import HOUSEHOLD_ID, HUB_ID, MOCK_CONFIG EXPECTED_ENTITY_IDS = { - "binary_sensor.pet_flap_pet_flap_connectivity": f"{HOUSEHOLD_ID}-13576-connectivity", - "binary_sensor.cat_flap_cat_flap_connectivity": f"{HOUSEHOLD_ID}-13579-connectivity", - "binary_sensor.feeder_feeder_connectivity": f"{HOUSEHOLD_ID}-12345-connectivity", - "binary_sensor.pet_pet": f"{HOUSEHOLD_ID}-24680", - "binary_sensor.hub_hub": f"{HOUSEHOLD_ID}-{HUB_ID}", + "binary_sensor.pet_flap_connectivity": f"{HOUSEHOLD_ID}-13576-connectivity", + "binary_sensor.cat_flap_connectivity": f"{HOUSEHOLD_ID}-13579-connectivity", + "binary_sensor.feeder_connectivity": f"{HOUSEHOLD_ID}-12345-connectivity", + "binary_sensor.pet": f"{HOUSEHOLD_ID}-24680", + "binary_sensor.hub": f"{HOUSEHOLD_ID}-{HUB_ID}", } From 57aaf55678edaa1f063f5a6a639d272e14e83f9d Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 25 Sep 2021 11:48:31 +0200 Subject: [PATCH 582/843] Upgrade pipdeptree to 2.1.0 (#56637) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 1dc2c49ac35..79bc512bea8 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -14,7 +14,7 @@ mock-open==1.4.0 mypy==0.910 pre-commit==2.14.1 pylint==2.11.1 -pipdeptree==1.0.0 +pipdeptree==2.1.0 pylint-strict-informational==0.1 pytest-aiohttp==0.3.0 pytest-cov==2.12.1 From 917254e956b0bcb54e8ba43704f088e3b0497db0 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 25 Sep 2021 11:48:47 +0200 Subject: [PATCH 583/843] Upgrade pyupgrade to v2.27.0 (#56638) --- .pre-commit-config.yaml | 2 +- requirements_test_pre_commit.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 29a91eed6b5..0a9424e53b8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v2.23.3 + rev: v2.27.0 hooks: - id: pyupgrade args: [--py38-plus] diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 4c57d6cabaa..aa3279cea18 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -12,5 +12,5 @@ mccabe==0.6.1 pycodestyle==2.7.0 pydocstyle==6.0.0 pyflakes==2.3.1 -pyupgrade==2.23.3 +pyupgrade==2.27.0 yamllint==1.26.1 From 8bc8081e811117f62c8a94fc1bbd43fd6da04521 Mon Sep 17 00:00:00 2001 From: MatthewFlamm <39341281+MatthewFlamm@users.noreply.github.com> Date: Sat, 25 Sep 2021 05:58:07 -0400 Subject: [PATCH 584/843] Add state_class_measurement to nws (#56629) --- homeassistant/components/nws/const.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/nws/const.py b/homeassistant/components/nws/const.py index 32018bc40bb..1bef625eaf5 100644 --- a/homeassistant/components/nws/const.py +++ b/homeassistant/components/nws/const.py @@ -4,7 +4,10 @@ from __future__ import annotations from dataclasses import dataclass from datetime import timedelta -from homeassistant.components.sensor import SensorEntityDescription +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + SensorEntityDescription, +) from homeassistant.components.weather import ( ATTR_CONDITION_CLOUDY, ATTR_CONDITION_EXCEPTIONAL, @@ -113,6 +116,7 @@ SENSOR_TYPES: tuple[NWSSensorEntityDescription, ...] = ( name="Dew Point", icon=None, device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, native_unit_of_measurement=TEMP_CELSIUS, unit_convert=TEMP_CELSIUS, ), @@ -121,6 +125,7 @@ SENSOR_TYPES: tuple[NWSSensorEntityDescription, ...] = ( name="Temperature", icon=None, device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, native_unit_of_measurement=TEMP_CELSIUS, unit_convert=TEMP_CELSIUS, ), @@ -129,6 +134,7 @@ SENSOR_TYPES: tuple[NWSSensorEntityDescription, ...] = ( name="Wind Chill", icon=None, device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, native_unit_of_measurement=TEMP_CELSIUS, unit_convert=TEMP_CELSIUS, ), @@ -137,6 +143,7 @@ SENSOR_TYPES: tuple[NWSSensorEntityDescription, ...] = ( name="Heat Index", icon=None, device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, native_unit_of_measurement=TEMP_CELSIUS, unit_convert=TEMP_CELSIUS, ), @@ -145,6 +152,7 @@ SENSOR_TYPES: tuple[NWSSensorEntityDescription, ...] = ( name="Relative Humidity", icon=None, device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, native_unit_of_measurement=PERCENTAGE, unit_convert=PERCENTAGE, ), @@ -153,6 +161,7 @@ SENSOR_TYPES: tuple[NWSSensorEntityDescription, ...] = ( name="Wind Speed", icon="mdi:weather-windy", device_class=None, + state_class=STATE_CLASS_MEASUREMENT, native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, unit_convert=SPEED_MILES_PER_HOUR, ), @@ -161,6 +170,7 @@ SENSOR_TYPES: tuple[NWSSensorEntityDescription, ...] = ( name="Wind Gust", icon="mdi:weather-windy", device_class=None, + state_class=STATE_CLASS_MEASUREMENT, native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, unit_convert=SPEED_MILES_PER_HOUR, ), @@ -169,6 +179,7 @@ SENSOR_TYPES: tuple[NWSSensorEntityDescription, ...] = ( name="Wind Direction", icon="mdi:compass-rose", device_class=None, + state_class=None, # statistics currently doesn't handle circular statistics native_unit_of_measurement=DEGREE, unit_convert=DEGREE, ), @@ -177,6 +188,7 @@ SENSOR_TYPES: tuple[NWSSensorEntityDescription, ...] = ( name="Barometric Pressure", icon=None, device_class=DEVICE_CLASS_PRESSURE, + state_class=STATE_CLASS_MEASUREMENT, native_unit_of_measurement=PRESSURE_PA, unit_convert=PRESSURE_INHG, ), @@ -185,6 +197,7 @@ SENSOR_TYPES: tuple[NWSSensorEntityDescription, ...] = ( name="Sea Level Pressure", icon=None, device_class=DEVICE_CLASS_PRESSURE, + state_class=STATE_CLASS_MEASUREMENT, native_unit_of_measurement=PRESSURE_PA, unit_convert=PRESSURE_INHG, ), @@ -193,6 +206,7 @@ SENSOR_TYPES: tuple[NWSSensorEntityDescription, ...] = ( name="Visibility", icon="mdi:eye", device_class=None, + state_class=STATE_CLASS_MEASUREMENT, native_unit_of_measurement=LENGTH_METERS, unit_convert=LENGTH_MILES, ), From 754ff7e3cbf39d52e387b1837b57c5b63ca98dfb Mon Sep 17 00:00:00 2001 From: Matt Zimmerman Date: Sat, 25 Sep 2021 03:06:41 -0700 Subject: [PATCH 585/843] Update python-smarttub to 0.0.27 (#56626) --- homeassistant/components/smarttub/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smarttub/manifest.json b/homeassistant/components/smarttub/manifest.json index 42858f69b39..713f2a7f7a1 100644 --- a/homeassistant/components/smarttub/manifest.json +++ b/homeassistant/components/smarttub/manifest.json @@ -5,7 +5,7 @@ "documentation": "https://www.home-assistant.io/integrations/smarttub", "dependencies": [], "codeowners": ["@mdz"], - "requirements": ["python-smarttub==0.0.25"], + "requirements": ["python-smarttub==0.0.27"], "quality_scale": "platinum", "iot_class": "cloud_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index cf1e68ef44d..6263ffa14c1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1923,7 +1923,7 @@ python-qbittorrent==0.4.2 python-ripple-api==0.0.3 # homeassistant.components.smarttub -python-smarttub==0.0.25 +python-smarttub==0.0.27 # homeassistant.components.sochain python-sochain-api==0.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8eb74e5189b..4e99f3d0d30 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1107,7 +1107,7 @@ python-openzwave-mqtt[mqtt-client]==1.4.0 python-picnic-api==1.1.0 # homeassistant.components.smarttub -python-smarttub==0.0.25 +python-smarttub==0.0.27 # homeassistant.components.songpal python-songpal==0.12 From 23e1c663d4a2c1d854dd26dc2be3ffb587c9f6f2 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 25 Sep 2021 12:07:29 +0200 Subject: [PATCH 586/843] Upgrade pytest-xdist to 2.4.0 (#56606) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 79bc512bea8..9017150b645 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -21,7 +21,7 @@ pytest-cov==2.12.1 pytest-test-groups==1.0.3 pytest-sugar==0.9.4 pytest-timeout==1.4.2 -pytest-xdist==2.2.1 +pytest-xdist==2.4.0 pytest==6.2.5 requests_mock==1.9.2 responses==0.12.0 From 83f1116432e14cfb8d629e0837b6f14e1c062a0c Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 25 Sep 2021 12:39:21 +0200 Subject: [PATCH 587/843] Upgrade numpy to 1.21.2 (#56640) --- homeassistant/components/compensation/manifest.json | 2 +- homeassistant/components/iqvia/manifest.json | 2 +- homeassistant/components/opencv/manifest.json | 2 +- homeassistant/components/tensorflow/manifest.json | 2 +- homeassistant/components/trend/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/compensation/manifest.json b/homeassistant/components/compensation/manifest.json index 3ae79664953..e68541973b1 100644 --- a/homeassistant/components/compensation/manifest.json +++ b/homeassistant/components/compensation/manifest.json @@ -2,7 +2,7 @@ "domain": "compensation", "name": "Compensation", "documentation": "https://www.home-assistant.io/integrations/compensation", - "requirements": ["numpy==1.21.1"], + "requirements": ["numpy==1.21.2"], "codeowners": ["@Petro31"], "iot_class": "calculated" } diff --git a/homeassistant/components/iqvia/manifest.json b/homeassistant/components/iqvia/manifest.json index e8914507657..7c0194a4896 100644 --- a/homeassistant/components/iqvia/manifest.json +++ b/homeassistant/components/iqvia/manifest.json @@ -3,7 +3,7 @@ "name": "IQVIA", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/iqvia", - "requirements": ["numpy==1.21.1", "pyiqvia==1.1.0"], + "requirements": ["numpy==1.21.2", "pyiqvia==1.1.0"], "codeowners": ["@bachya"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/opencv/manifest.json b/homeassistant/components/opencv/manifest.json index b7b8024009b..047acf66bb8 100644 --- a/homeassistant/components/opencv/manifest.json +++ b/homeassistant/components/opencv/manifest.json @@ -2,7 +2,7 @@ "domain": "opencv", "name": "OpenCV", "documentation": "https://www.home-assistant.io/integrations/opencv", - "requirements": ["numpy==1.21.1", "opencv-python-headless==4.5.2.54"], + "requirements": ["numpy==1.21.2", "opencv-python-headless==4.5.2.54"], "codeowners": [], "iot_class": "local_push" } diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json index b3162a19364..391eeb1cf87 100644 --- a/homeassistant/components/tensorflow/manifest.json +++ b/homeassistant/components/tensorflow/manifest.json @@ -6,7 +6,7 @@ "tensorflow==2.3.0", "tf-models-official==2.3.0", "pycocotools==2.0.1", - "numpy==1.21.1", + "numpy==1.21.2", "pillow==8.2.0" ], "codeowners": [], diff --git a/homeassistant/components/trend/manifest.json b/homeassistant/components/trend/manifest.json index c6502bf0850..340edcb6626 100644 --- a/homeassistant/components/trend/manifest.json +++ b/homeassistant/components/trend/manifest.json @@ -2,7 +2,7 @@ "domain": "trend", "name": "Trend", "documentation": "https://www.home-assistant.io/integrations/trend", - "requirements": ["numpy==1.21.1"], + "requirements": ["numpy==1.21.2"], "codeowners": [], "quality_scale": "internal", "iot_class": "local_push" diff --git a/requirements_all.txt b/requirements_all.txt index 6263ffa14c1..8ad0faab94a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1094,7 +1094,7 @@ numato-gpio==0.10.0 # homeassistant.components.opencv # homeassistant.components.tensorflow # homeassistant.components.trend -numpy==1.21.1 +numpy==1.21.2 # homeassistant.components.oasa_telematics oasatelematics==0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4e99f3d0d30..754ab0661ee 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -641,7 +641,7 @@ numato-gpio==0.10.0 # homeassistant.components.opencv # homeassistant.components.tensorflow # homeassistant.components.trend -numpy==1.21.1 +numpy==1.21.2 # homeassistant.components.google oauth2client==4.0.0 From fabf5204be374dd80c86af92b6076df063ac9a12 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 25 Sep 2021 12:53:41 +0200 Subject: [PATCH 588/843] Ignore config directory symlink in development (#56639) --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index bdc4c24c5b0..d6f7198fcd4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -config/* +/config config2/* tests/testing_config/deps From d4ebcf2ba5f127c86e9b5801e0c0d3532e732e9c Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Sat, 25 Sep 2021 12:22:51 -0600 Subject: [PATCH 589/843] Simplify state update for Flu Near You (#56650) --- homeassistant/components/flunearyou/sensor.py | 25 +++++-------------- 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/flunearyou/sensor.py b/homeassistant/components/flunearyou/sensor.py index 72c0a7b1118..e96578fab97 100644 --- a/homeassistant/components/flunearyou/sensor.py +++ b/homeassistant/components/flunearyou/sensor.py @@ -130,7 +130,7 @@ async def async_setup_entry( for description in USER_SENSOR_DESCRIPTIONS ] ) - async_add_entities(sensors) + async_add_entities(sensors, True) class FluNearYouSensor(CoordinatorEntity, SensorEntity): @@ -156,26 +156,14 @@ class FluNearYouSensor(CoordinatorEntity, SensorEntity): @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" - self.update_from_latest_data() - self.async_write_ha_state() - - async def async_added_to_hass(self) -> None: - """Register callbacks.""" - await super().async_added_to_hass() - self.update_from_latest_data() - - @callback - def update_from_latest_data(self) -> None: - """Update the sensor.""" - raise NotImplementedError + self.async_schedule_update_ha_state(force_refresh=True) class CdcSensor(FluNearYouSensor): """Define a sensor for CDC reports.""" - @callback - def update_from_latest_data(self) -> None: - """Update the sensor.""" + async def async_update(self) -> None: + """Update the state.""" self._attr_extra_state_attributes.update( { ATTR_REPORTED_DATE: self.coordinator.data["week_date"], @@ -188,9 +176,8 @@ class CdcSensor(FluNearYouSensor): class UserSensor(FluNearYouSensor): """Define a sensor for user reports.""" - @callback - def update_from_latest_data(self) -> None: - """Update the sensor.""" + async def async_update(self) -> None: + """Update the state.""" self._attr_extra_state_attributes.update( { ATTR_CITY: self.coordinator.data["local"]["city"].split("(")[0], From 8db0bd3c0ef7f02538d8db496f73e7dd8dcbf71f Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Sat, 25 Sep 2021 20:54:55 +0200 Subject: [PATCH 590/843] Fix state_class for deCONZ power sensors (#56586) * Fix state_class for power sensors Rewrite entity descriptions for binary sensor and sensor platforms * Remove icon if device_class is specified --- .../components/deconz/binary_sensor.py | 37 ++++++-- .../components/deconz/deconz_device.py | 8 -- homeassistant/components/deconz/sensor.py | 95 ++++++++++--------- 3 files changed, 81 insertions(+), 59 deletions(-) diff --git a/homeassistant/components/deconz/binary_sensor.py b/homeassistant/components/deconz/binary_sensor.py index 571ad5dc68b..7dd569388e1 100644 --- a/homeassistant/components/deconz/binary_sensor.py +++ b/homeassistant/components/deconz/binary_sensor.py @@ -11,6 +11,7 @@ from homeassistant.components.binary_sensor import ( DEVICE_CLASS_VIBRATION, DOMAIN, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.const import ATTR_TEMPERATURE from homeassistant.core import callback @@ -24,13 +25,31 @@ ATTR_ORIENTATION = "orientation" ATTR_TILTANGLE = "tiltangle" ATTR_VIBRATIONSTRENGTH = "vibrationstrength" -DEVICE_CLASS = { - CarbonMonoxide: DEVICE_CLASS_GAS, - Fire: DEVICE_CLASS_SMOKE, - OpenClose: DEVICE_CLASS_OPENING, - Presence: DEVICE_CLASS_MOTION, - Vibration: DEVICE_CLASS_VIBRATION, - Water: DEVICE_CLASS_MOISTURE, +ENTITY_DESCRIPTIONS = { + CarbonMonoxide: BinarySensorEntityDescription( + key="carbonmonoxide", + device_class=DEVICE_CLASS_GAS, + ), + Fire: BinarySensorEntityDescription( + key="fire", + device_class=DEVICE_CLASS_SMOKE, + ), + OpenClose: BinarySensorEntityDescription( + key="openclose", + device_class=DEVICE_CLASS_OPENING, + ), + Presence: BinarySensorEntityDescription( + key="presence", + device_class=DEVICE_CLASS_MOTION, + ), + Vibration: BinarySensorEntityDescription( + key="vibration", + device_class=DEVICE_CLASS_VIBRATION, + ), + Water: BinarySensorEntityDescription( + key="water", + device_class=DEVICE_CLASS_MOISTURE, + ), } @@ -84,7 +103,9 @@ class DeconzBinarySensor(DeconzDevice, BinarySensorEntity): def __init__(self, device, gateway): """Initialize deCONZ binary sensor.""" super().__init__(device, gateway) - self._attr_device_class = DEVICE_CLASS.get(type(self._device)) + + if entity_description := ENTITY_DESCRIPTIONS.get(type(device)): + self.entity_description = entity_description @callback def async_update_callback(self, force_update=False): diff --git a/homeassistant/components/deconz/deconz_device.py b/homeassistant/components/deconz/deconz_device.py index dfaba153070..06b86ee214a 100644 --- a/homeassistant/components/deconz/deconz_device.py +++ b/homeassistant/components/deconz/deconz_device.py @@ -59,14 +59,6 @@ class DeconzDevice(DeconzBase, Entity): self._attr_name = self._device.name - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry. - - Daylight is a virtual sensor from deCONZ that should never be enabled by default. - """ - return self._device.type != "Daylight" - async def async_added_to_hass(self): """Subscribe to device events.""" self._device.register_callback(self.async_update_callback) diff --git a/homeassistant/components/deconz/sensor.py b/homeassistant/components/deconz/sensor.py index b5e2e60b8d5..5c826e61516 100644 --- a/homeassistant/components/deconz/sensor.py +++ b/homeassistant/components/deconz/sensor.py @@ -19,6 +19,7 @@ from homeassistant.components.sensor import ( STATE_CLASS_MEASUREMENT, STATE_CLASS_TOTAL_INCREASING, SensorEntity, + SensorEntityDescription, ) from homeassistant.const import ( ATTR_TEMPERATURE, @@ -52,35 +53,53 @@ ATTR_POWER = "power" ATTR_DAYLIGHT = "daylight" ATTR_EVENT_ID = "event_id" -DEVICE_CLASS = { - Consumption: DEVICE_CLASS_ENERGY, - Humidity: DEVICE_CLASS_HUMIDITY, - LightLevel: DEVICE_CLASS_ILLUMINANCE, - Power: DEVICE_CLASS_POWER, - Pressure: DEVICE_CLASS_PRESSURE, - Temperature: DEVICE_CLASS_TEMPERATURE, -} - -ICON = { - Daylight: "mdi:white-balance-sunny", - Pressure: "mdi:gauge", - Temperature: "mdi:thermometer", -} - -STATE_CLASS = { - Consumption: STATE_CLASS_TOTAL_INCREASING, - Humidity: STATE_CLASS_MEASUREMENT, - Pressure: STATE_CLASS_MEASUREMENT, - Temperature: STATE_CLASS_MEASUREMENT, -} - -UNIT_OF_MEASUREMENT = { - Consumption: ENERGY_KILO_WATT_HOUR, - Humidity: PERCENTAGE, - LightLevel: LIGHT_LUX, - Power: POWER_WATT, - Pressure: PRESSURE_HPA, - Temperature: TEMP_CELSIUS, +ENTITY_DESCRIPTIONS = { + Battery: SensorEntityDescription( + key="battery", + device_class=DEVICE_CLASS_BATTERY, + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=PERCENTAGE, + ), + Consumption: SensorEntityDescription( + key="consumption", + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + Daylight: SensorEntityDescription( + key="daylight", + icon="mdi:white-balance-sunny", + entity_registry_enabled_default=False, + ), + Humidity: SensorEntityDescription( + key="humidity", + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=PERCENTAGE, + ), + LightLevel: SensorEntityDescription( + key="lightlevel", + device_class=DEVICE_CLASS_ILLUMINANCE, + unit_of_measurement=LIGHT_LUX, + ), + Power: SensorEntityDescription( + key="power", + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=POWER_WATT, + ), + Pressure: SensorEntityDescription( + key="pressure", + device_class=DEVICE_CLASS_PRESSURE, + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=PRESSURE_HPA, + ), + Temperature: SensorEntityDescription( + key="temperature", + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=TEMP_CELSIUS, + ), } @@ -157,12 +176,8 @@ class DeconzSensor(DeconzDevice, SensorEntity): """Initialize deCONZ binary sensor.""" super().__init__(device, gateway) - self._attr_device_class = DEVICE_CLASS.get(type(self._device)) - self._attr_icon = ICON.get(type(self._device)) - self._attr_state_class = STATE_CLASS.get(type(self._device)) - self._attr_native_unit_of_measurement = UNIT_OF_MEASUREMENT.get( - type(self._device) - ) + if entity_description := ENTITY_DESCRIPTIONS.get(type(device)): + self.entity_description = entity_description @callback def async_update_callback(self, force_update=False): @@ -214,16 +229,13 @@ class DeconzTemperature(DeconzDevice, SensorEntity): Extra temperature sensor on certain Xiaomi devices. """ - _attr_device_class = DEVICE_CLASS_TEMPERATURE - _attr_state_class = STATE_CLASS_MEASUREMENT - _attr_native_unit_of_measurement = TEMP_CELSIUS - TYPE = DOMAIN def __init__(self, device, gateway): """Initialize deCONZ temperature sensor.""" super().__init__(device, gateway) + self.entity_description = ENTITY_DESCRIPTIONS[Temperature] self._attr_name = f"{self._device.name} Temperature" @property @@ -247,16 +259,13 @@ class DeconzTemperature(DeconzDevice, SensorEntity): class DeconzBattery(DeconzDevice, SensorEntity): """Battery class for when a device is only represented as an event.""" - _attr_device_class = DEVICE_CLASS_BATTERY - _attr_state_class = STATE_CLASS_MEASUREMENT - _attr_native_unit_of_measurement = PERCENTAGE - TYPE = DOMAIN def __init__(self, device, gateway): """Initialize deCONZ battery level sensor.""" super().__init__(device, gateway) + self.entity_description = ENTITY_DESCRIPTIONS[Battery] self._attr_name = f"{self._device.name} Battery Level" @callback From aeba3a703f4d31bfb0b669c5c72ec5a954c549b6 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Sat, 25 Sep 2021 17:19:17 -0600 Subject: [PATCH 591/843] Revert "Simplify state update for Flu Near You (#56650)" (#56662) This reverts commit d4ebcf2ba5f127c86e9b5801e0c0d3532e732e9c. --- homeassistant/components/flunearyou/sensor.py | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/flunearyou/sensor.py b/homeassistant/components/flunearyou/sensor.py index e96578fab97..72c0a7b1118 100644 --- a/homeassistant/components/flunearyou/sensor.py +++ b/homeassistant/components/flunearyou/sensor.py @@ -130,7 +130,7 @@ async def async_setup_entry( for description in USER_SENSOR_DESCRIPTIONS ] ) - async_add_entities(sensors, True) + async_add_entities(sensors) class FluNearYouSensor(CoordinatorEntity, SensorEntity): @@ -156,14 +156,26 @@ class FluNearYouSensor(CoordinatorEntity, SensorEntity): @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" - self.async_schedule_update_ha_state(force_refresh=True) + self.update_from_latest_data() + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + await super().async_added_to_hass() + self.update_from_latest_data() + + @callback + def update_from_latest_data(self) -> None: + """Update the sensor.""" + raise NotImplementedError class CdcSensor(FluNearYouSensor): """Define a sensor for CDC reports.""" - async def async_update(self) -> None: - """Update the state.""" + @callback + def update_from_latest_data(self) -> None: + """Update the sensor.""" self._attr_extra_state_attributes.update( { ATTR_REPORTED_DATE: self.coordinator.data["week_date"], @@ -176,8 +188,9 @@ class CdcSensor(FluNearYouSensor): class UserSensor(FluNearYouSensor): """Define a sensor for user reports.""" - async def async_update(self) -> None: - """Update the state.""" + @callback + def update_from_latest_data(self) -> None: + """Update the sensor.""" self._attr_extra_state_attributes.update( { ATTR_CITY: self.coordinator.data["local"]["city"].split("(")[0], From 01e03a223b9bf6dde816058bc7cc489aa7dea2dc Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Sun, 26 Sep 2021 03:12:54 -0600 Subject: [PATCH 592/843] Simplify native value and attributes properties for Flu Near You (#56665) --- homeassistant/components/flunearyou/sensor.py | 96 +++++++++---------- 1 file changed, 47 insertions(+), 49 deletions(-) diff --git a/homeassistant/components/flunearyou/sensor.py b/homeassistant/components/flunearyou/sensor.py index 72c0a7b1118..1a7aba5966b 100644 --- a/homeassistant/components/flunearyou/sensor.py +++ b/homeassistant/components/flunearyou/sensor.py @@ -1,6 +1,9 @@ """Support for user- and CDC-based flu info sensors from Flu Near You.""" from __future__ import annotations +from collections.abc import Mapping +from typing import Any, Union, cast + from homeassistant.components.sensor import ( STATE_CLASS_MEASUREMENT, SensorEntity, @@ -13,8 +16,9 @@ from homeassistant.const import ( CONF_LATITUDE, CONF_LONGITUDE, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -136,6 +140,8 @@ async def async_setup_entry( class FluNearYouSensor(CoordinatorEntity, SensorEntity): """Define a base Flu Near You sensor.""" + DEFAULT_EXTRA_STATE_ATTRIBUTES = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} + def __init__( self, coordinator: DataUpdateCoordinator, @@ -145,7 +151,6 @@ class FluNearYouSensor(CoordinatorEntity, SensorEntity): """Initialize the sensor.""" super().__init__(coordinator) - self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} self._attr_unique_id = ( f"{entry.data[CONF_LATITUDE]}," f"{entry.data[CONF_LONGITUDE]}_{description.key}" @@ -153,68 +158,61 @@ class FluNearYouSensor(CoordinatorEntity, SensorEntity): self._entry = entry self.entity_description = description - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - self.update_from_latest_data() - self.async_write_ha_state() - - async def async_added_to_hass(self) -> None: - """Register callbacks.""" - await super().async_added_to_hass() - self.update_from_latest_data() - - @callback - def update_from_latest_data(self) -> None: - """Update the sensor.""" - raise NotImplementedError - class CdcSensor(FluNearYouSensor): """Define a sensor for CDC reports.""" - @callback - def update_from_latest_data(self) -> None: - """Update the sensor.""" - self._attr_extra_state_attributes.update( - { - ATTR_REPORTED_DATE: self.coordinator.data["week_date"], - ATTR_STATE: self.coordinator.data["name"], - } + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return entity specific state attributes.""" + return { + **self.DEFAULT_EXTRA_STATE_ATTRIBUTES, + ATTR_REPORTED_DATE: self.coordinator.data["week_date"], + ATTR_STATE: self.coordinator.data["name"], + } + + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + return cast( + Union[str, None], self.coordinator.data[self.entity_description.key] ) - self._attr_native_value = self.coordinator.data[self.entity_description.key] class UserSensor(FluNearYouSensor): """Define a sensor for user reports.""" - @callback - def update_from_latest_data(self) -> None: - """Update the sensor.""" - self._attr_extra_state_attributes.update( - { - ATTR_CITY: self.coordinator.data["local"]["city"].split("(")[0], - ATTR_REPORTED_LATITUDE: self.coordinator.data["local"]["latitude"], - ATTR_REPORTED_LONGITUDE: self.coordinator.data["local"]["longitude"], - ATTR_STATE: self.coordinator.data["state"]["name"], - ATTR_ZIP_CODE: self.coordinator.data["local"]["zip"], - } - ) + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return entity specific state attributes.""" + attrs = { + **self.DEFAULT_EXTRA_STATE_ATTRIBUTES, + ATTR_CITY: self.coordinator.data["local"]["city"].split("(")[0], + ATTR_REPORTED_LATITUDE: self.coordinator.data["local"]["latitude"], + ATTR_REPORTED_LONGITUDE: self.coordinator.data["local"]["longitude"], + ATTR_STATE: self.coordinator.data["state"]["name"], + ATTR_ZIP_CODE: self.coordinator.data["local"]["zip"], + } if self.entity_description.key in self.coordinator.data["state"]["data"]: states_key = self.entity_description.key elif self.entity_description.key in EXTENDED_SENSOR_TYPE_MAPPING: states_key = EXTENDED_SENSOR_TYPE_MAPPING[self.entity_description.key] - self._attr_extra_state_attributes[ - ATTR_STATE_REPORTS_THIS_WEEK - ] = self.coordinator.data["state"]["data"][states_key] - self._attr_extra_state_attributes[ - ATTR_STATE_REPORTS_LAST_WEEK - ] = self.coordinator.data["state"]["last_week_data"][states_key] + attrs[ATTR_STATE_REPORTS_THIS_WEEK] = self.coordinator.data["state"]["data"][ + states_key + ] + attrs[ATTR_STATE_REPORTS_LAST_WEEK] = self.coordinator.data["state"][ + "last_week_data" + ][states_key] + return attrs + + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" if self.entity_description.key == SENSOR_TYPE_USER_TOTAL: - self._attr_native_value = sum( + value = sum( v for k, v in self.coordinator.data["local"].items() if k @@ -227,6 +225,6 @@ class UserSensor(FluNearYouSensor): ) ) else: - self._attr_native_value = self.coordinator.data["local"][ - self.entity_description.key - ] + value = self.coordinator.data["local"][self.entity_description.key] + + return cast(int, value) From d8387744ec5676cde9581b1cfef8c6b642c3bf09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Sun, 26 Sep 2021 12:48:27 +0200 Subject: [PATCH 593/843] Correct the device name for Airthings (#56655) --- homeassistant/components/airthings/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/airthings/sensor.py b/homeassistant/components/airthings/sensor.py index b40e8b06400..7ec273c0549 100644 --- a/homeassistant/components/airthings/sensor.py +++ b/homeassistant/components/airthings/sensor.py @@ -117,7 +117,7 @@ class AirthingsHeaterEnergySensor(CoordinatorEntity, SensorEntity): self._id = airthings_device.device_id self._attr_device_info = { "identifiers": {(DOMAIN, airthings_device.device_id)}, - "name": self.name, + "name": airthings_device.name, "manufacturer": "Airthings", } From a0a359e2ef949d6a236e6b46337d4e28a9464970 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sun, 26 Sep 2021 14:47:29 +0200 Subject: [PATCH 594/843] Upgrade ciso8601 to 2.2.0 (#56678) --- homeassistant/package_constraints.txt | 2 +- requirements.txt | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 36dd38f7473..e8823643244 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -11,7 +11,7 @@ awesomeversion==21.8.1 backports.zoneinfo;python_version<"3.9" bcrypt==3.1.7 certifi>=2020.12.5 -ciso8601==2.1.3 +ciso8601==2.2.0 cryptography==3.4.8 defusedxml==0.7.1 emoji==1.2.0 diff --git a/requirements.txt b/requirements.txt index a9d9bc0d238..affe27d7743 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,7 @@ awesomeversion==21.8.1 backports.zoneinfo;python_version<"3.9" bcrypt==3.1.7 certifi>=2020.12.5 -ciso8601==2.1.3 +ciso8601==2.2.0 httpx==0.19.0 jinja2==3.0.1 PyJWT==2.1.0 diff --git a/setup.py b/setup.py index 38febf7c3a0..13768c4de31 100755 --- a/setup.py +++ b/setup.py @@ -40,7 +40,7 @@ REQUIRES = [ 'backports.zoneinfo;python_version<"3.9"', "bcrypt==3.1.7", "certifi>=2020.12.5", - "ciso8601==2.1.3", + "ciso8601==2.2.0", "httpx==0.19.0", "jinja2==3.0.1", "PyJWT==2.1.0", From bfefe82605fb2333a6e076ec90e5e76d8a213f31 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 26 Sep 2021 14:47:47 +0200 Subject: [PATCH 595/843] Upgrade pre-commit to 2.15.0 (#56677) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 9017150b645..8f593777d82 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -12,7 +12,7 @@ coverage==5.5 jsonpickle==1.4.1 mock-open==1.4.0 mypy==0.910 -pre-commit==2.14.1 +pre-commit==2.15.0 pylint==2.11.1 pipdeptree==2.1.0 pylint-strict-informational==0.1 From 65bce33a6382729467ea35028ae261c425af13b2 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sun, 26 Sep 2021 15:09:13 +0200 Subject: [PATCH 596/843] Upgrade emoji to 1.5.0 (#56684) --- homeassistant/components/mobile_app/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/mobile_app/manifest.json b/homeassistant/components/mobile_app/manifest.json index a59f9bf28cf..253ff16a34e 100644 --- a/homeassistant/components/mobile_app/manifest.json +++ b/homeassistant/components/mobile_app/manifest.json @@ -3,7 +3,7 @@ "name": "Mobile App", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/mobile_app", - "requirements": ["PyNaCl==1.4.0", "emoji==1.2.0"], + "requirements": ["PyNaCl==1.4.0", "emoji==1.5.0"], "dependencies": ["http", "webhook", "person", "tag", "websocket_api"], "after_dependencies": ["cloud", "camera", "notify"], "codeowners": ["@robbiet480"], diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e8823643244..84d1923cfd4 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -14,7 +14,7 @@ certifi>=2020.12.5 ciso8601==2.2.0 cryptography==3.4.8 defusedxml==0.7.1 -emoji==1.2.0 +emoji==1.5.0 hass-nabucasa==0.50.0 home-assistant-frontend==20210922.0 httpx==0.19.0 diff --git a/requirements_all.txt b/requirements_all.txt index 8ad0faab94a..420ccaaa435 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -580,7 +580,7 @@ eliqonline==1.2.2 elkm1-lib==0.8.10 # homeassistant.components.mobile_app -emoji==1.2.0 +emoji==1.5.0 # homeassistant.components.emulated_roku emulated_roku==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 754ab0661ee..fae816ac909 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -346,7 +346,7 @@ elgato==2.1.1 elkm1-lib==0.8.10 # homeassistant.components.mobile_app -emoji==1.2.0 +emoji==1.5.0 # homeassistant.components.emulated_roku emulated_roku==0.2.1 From e35e584b6021068509d0178ed5f99673fd3ec9b3 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 26 Sep 2021 18:19:36 +0200 Subject: [PATCH 597/843] Use EntityDescription - sht31 (#56435) * Use EntityDescription - sht31 * Add device_class for humidity * Fix monitored conditions * Add pylint disable --- homeassistant/components/sht31/sensor.py | 122 ++++++++++++----------- 1 file changed, 62 insertions(+), 60 deletions(-) diff --git a/homeassistant/components/sht31/sensor.py b/homeassistant/components/sht31/sensor.py index e5f77700409..1415c4856b6 100644 --- a/homeassistant/components/sht31/sensor.py +++ b/homeassistant/components/sht31/sensor.py @@ -1,16 +1,24 @@ """Support for Sensirion SHT31 temperature and humidity sensor.""" +from __future__ import annotations +from dataclasses import dataclass from datetime import timedelta import logging import math +from typing import Callable from Adafruit_SHT31 import SHT31 import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( CONF_MONITORED_CONDITIONS, CONF_NAME, + DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, PERCENTAGE, TEMP_CELSIUS, @@ -25,9 +33,40 @@ CONF_I2C_ADDRESS = "i2c_address" DEFAULT_NAME = "SHT31" DEFAULT_I2C_ADDRESS = 0x44 -SENSOR_TEMPERATURE = "temperature" -SENSOR_HUMIDITY = "humidity" -SENSOR_TYPES = (SENSOR_TEMPERATURE, SENSOR_HUMIDITY) + +@dataclass +class SHT31RequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[SHTClient], float | None] + + +@dataclass +class SHT31SensorEntityDescription(SensorEntityDescription, SHT31RequiredKeysMixin): + """Describes SHT31 sensor entity.""" + + +SENSOR_TYPES = ( + SHT31SensorEntityDescription( + key="temperature", + name="Temperature", + device_class=DEVICE_CLASS_TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + value_fn=lambda sensor: sensor.temperature, + ), + SHT31SensorEntityDescription( + key="humidity", + name="Humidity", + device_class=DEVICE_CLASS_HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda sensor: ( + round(val) # pylint: disable=undefined-variable + if (val := sensor.humidity) + else None + ), + ), +) +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=10) @@ -36,8 +75,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Optional(CONF_I2C_ADDRESS, default=DEFAULT_I2C_ADDRESS): vol.All( vol.Coerce(int), vol.Range(min=0x44, max=0x45) ), - vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] + vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSOR_KEYS): vol.All( + cv.ensure_list, [vol.In(SENSOR_KEYS)] ), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, } @@ -46,8 +85,9 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the sensor platform.""" - - i2c_address = config.get(CONF_I2C_ADDRESS) + name = config[CONF_NAME] + monitored_conditions = config[CONF_MONITORED_CONDITIONS] + i2c_address = config[CONF_I2C_ADDRESS] sensor = SHT31(address=i2c_address) try: @@ -58,17 +98,13 @@ def setup_platform(hass, config, add_entities, discovery_info=None): return sensor_client = SHTClient(sensor) - sensor_classes = { - SENSOR_TEMPERATURE: SHTSensorTemperature, - SENSOR_HUMIDITY: SHTSensorHumidity, - } + entities = [ + SHTSensor(sensor_client, name, description) + for description in SENSOR_TYPES + if description.key in monitored_conditions + ] - devs = [] - for sensor_type, sensor_class in sensor_classes.items(): - name = f"{config.get(CONF_NAME)} {sensor_type.capitalize()}" - devs.append(sensor_class(sensor_client, name)) - - add_entities(devs) + add_entities(entities) class SHTClient: @@ -77,8 +113,8 @@ class SHTClient: def __init__(self, adafruit_sht): """Initialize the sensor.""" self.adafruit_sht = adafruit_sht - self.temperature = None - self.humidity = None + self.temperature: float | None = None + self.humidity: float | None = None @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): @@ -94,50 +130,16 @@ class SHTClient: class SHTSensor(SensorEntity): """An abstract SHTSensor, can be either temperature or humidity.""" - def __init__(self, sensor, name): + entity_description: SHT31SensorEntityDescription + + def __init__(self, sensor, name, description: SHT31SensorEntityDescription): """Initialize the sensor.""" + self.entity_description = description self._sensor = sensor - self._name = name - self._state = None - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state + self._attr_name = f"{name} {description.name}" def update(self): """Fetch temperature and humidity from the sensor.""" self._sensor.update() - - -class SHTSensorTemperature(SHTSensor): - """Representation of a temperature sensor.""" - - _attr_device_class = DEVICE_CLASS_TEMPERATURE - _attr_native_unit_of_measurement = TEMP_CELSIUS - - def update(self): - """Fetch temperature from the sensor.""" - super().update() - self._state = self._sensor.temperature - - -class SHTSensorHumidity(SHTSensor): - """Representation of a humidity sensor.""" - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement.""" - return PERCENTAGE - - def update(self): - """Fetch humidity from the sensor.""" - super().update() - humidity = self._sensor.humidity - if humidity is not None: - self._state = round(humidity) + self._attr_native_value = self.entity_description.value_fn(self._sensor) From f74291ccb6b26e3ca75cd97f6640b52795b593a0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Sep 2021 11:50:57 -0500 Subject: [PATCH 598/843] Expose the ability to move an entity/device between config entries (#56661) --- homeassistant/helpers/device_registry.py | 2 ++ homeassistant/helpers/entity_registry.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index b22b1740a4f..f3051e9dfd7 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -332,6 +332,7 @@ class DeviceRegistry: new_identifiers: set[tuple[str, str]] | UndefinedType = UNDEFINED, sw_version: str | None | UndefinedType = UNDEFINED, via_device_id: str | None | UndefinedType = UNDEFINED, + add_config_entry_id: str | UndefinedType = UNDEFINED, remove_config_entry_id: str | UndefinedType = UNDEFINED, disabled_by: str | None | UndefinedType = UNDEFINED, suggested_area: str | None | UndefinedType = UNDEFINED, @@ -347,6 +348,7 @@ class DeviceRegistry: new_identifiers=new_identifiers, sw_version=sw_version, via_device_id=via_device_id, + add_config_entry_id=add_config_entry_id, remove_config_entry_id=remove_config_entry_id, disabled_by=disabled_by, suggested_area=suggested_area, diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 69415030a87..5d1c495904b 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -381,6 +381,7 @@ class EntityRegistry: *, name: str | None | UndefinedType = UNDEFINED, icon: str | None | UndefinedType = UNDEFINED, + config_entry_id: str | None | UndefinedType = UNDEFINED, area_id: str | None | UndefinedType = UNDEFINED, new_entity_id: str | UndefinedType = UNDEFINED, new_unique_id: str | UndefinedType = UNDEFINED, @@ -391,6 +392,7 @@ class EntityRegistry: entity_id, name=name, icon=icon, + config_entry_id=config_entry_id, area_id=area_id, new_entity_id=new_entity_id, new_unique_id=new_unique_id, From 26f73779ccd822edfd2de4ab0921fab47081f153 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Sep 2021 11:51:34 -0500 Subject: [PATCH 599/843] Avoid enabling ipv6 dual stack for zeroconf on unsupported platforms (#56584) --- homeassistant/components/zeroconf/__init__.py | 38 ++++++++-- tests/components/zeroconf/test_init.py | 76 ++++++++++++++++--- 2 files changed, 96 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index 17cfb9d05de..4afb0a3c24d 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -5,9 +5,10 @@ import asyncio from collections.abc import Coroutine from contextlib import suppress import fnmatch -from ipaddress import IPv6Address, ip_address +from ipaddress import IPv4Address, IPv6Address, ip_address import logging import socket +import sys from typing import Any, TypedDict, cast import voluptuous as vol @@ -131,18 +132,31 @@ async def _async_get_instance(hass: HomeAssistant, **zcargs: Any) -> HaAsyncZero return aio_zc +@callback +def _async_zc_has_functional_dual_stack() -> bool: + """Return true for platforms that not support IP_ADD_MEMBERSHIP on an AF_INET6 socket. + + Zeroconf only supports a single listen socket at this time. + """ + return not sys.platform.startswith("freebsd") and not sys.platform.startswith( + "darwin" + ) + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Zeroconf and make Home Assistant discoverable.""" - zc_args: dict = {} + zc_args: dict = {"ip_version": IPVersion.V4Only} adapters = await network.async_get_adapters(hass) - ipv6 = True - if not any(adapter["enabled"] and adapter["ipv6"] for adapter in adapters): - ipv6 = False - zc_args["ip_version"] = IPVersion.V4Only - else: - zc_args["ip_version"] = IPVersion.All + ipv6 = False + if _async_zc_has_functional_dual_stack(): + if any(adapter["enabled"] and adapter["ipv6"] for adapter in adapters): + ipv6 = True + zc_args["ip_version"] = IPVersion.All + elif not any(adapter["enabled"] and adapter["ipv4"] for adapter in adapters): + zc_args["ip_version"] = IPVersion.V6Only + ipv6 = True if not ipv6 and network.async_only_default_interface_enabled(adapters): zc_args["interfaces"] = InterfaceChoice.Default @@ -152,6 +166,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: for source_ip in await network.async_get_enabled_source_ips(hass) if not source_ip.is_loopback and not (isinstance(source_ip, IPv6Address) and source_ip.is_global) + and not ( + isinstance(source_ip, IPv6Address) + and zc_args["ip_version"] == IPVersion.V4Only + ) + and not ( + isinstance(source_ip, IPv4Address) + and zc_args["ip_version"] == IPVersion.V6Only + ) ] aio_zc = await _async_get_instance(hass, **zc_args) diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index b8ce28b6259..0d3c5fc7792 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -779,11 +779,13 @@ _ADAPTERS_WITH_MANUAL_CONFIG = [ ] -async def test_async_detect_interfaces_setting_empty_route(hass, mock_async_zeroconf): - """Test without default interface config and the route returns nothing.""" - with patch("homeassistant.components.zeroconf.HaZeroconf") as mock_zc, patch.object( - hass.config_entries.flow, "async_init" - ), patch.object( +async def test_async_detect_interfaces_setting_empty_route_linux( + hass, mock_async_zeroconf +): + """Test without default interface config and the route returns nothing on linux.""" + with patch("homeassistant.components.zeroconf.sys.platform", "linux"), patch( + "homeassistant.components.zeroconf.HaZeroconf" + ) as mock_zc, patch.object(hass.config_entries.flow, "async_init"), patch.object( zeroconf, "HaAsyncServiceBrowser", side_effect=service_update_mock ), patch( "homeassistant.components.zeroconf.network.async_get_adapters", @@ -807,6 +809,33 @@ async def test_async_detect_interfaces_setting_empty_route(hass, mock_async_zero ) +async def test_async_detect_interfaces_setting_empty_route_freebsd( + hass, mock_async_zeroconf +): + """Test without default interface config and the route returns nothing on freebsd.""" + with patch("homeassistant.components.zeroconf.sys.platform", "freebsd"), patch( + "homeassistant.components.zeroconf.HaZeroconf" + ) as mock_zc, patch.object(hass.config_entries.flow, "async_init"), patch.object( + zeroconf, "HaAsyncServiceBrowser", side_effect=service_update_mock + ), patch( + "homeassistant.components.zeroconf.network.async_get_adapters", + return_value=_ADAPTERS_WITH_MANUAL_CONFIG, + ), patch( + "homeassistant.components.zeroconf.AsyncServiceInfo", + side_effect=get_service_info_mock, + ): + assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + assert mock_zc.mock_calls[0] == call( + interfaces=[ + "192.168.1.5", + "172.16.1.5", + ], + ip_version=IPVersion.V4Only, + ) + + async def test_get_announced_addresses(hass, mock_async_zeroconf): """Test addresses for mDNS announcement.""" expected = { @@ -848,11 +877,13 @@ _ADAPTER_WITH_DEFAULT_ENABLED_AND_IPV6 = [ ] -async def test_async_detect_interfaces_explicitly_set_ipv6(hass, mock_async_zeroconf): - """Test interfaces are explicitly set when IPv6 is present.""" - with patch("homeassistant.components.zeroconf.HaZeroconf") as mock_zc, patch.object( - hass.config_entries.flow, "async_init" - ), patch.object( +async def test_async_detect_interfaces_explicitly_set_ipv6_linux( + hass, mock_async_zeroconf +): + """Test interfaces are explicitly set when IPv6 is present on linux.""" + with patch("homeassistant.components.zeroconf.sys.platform", "linux"), patch( + "homeassistant.components.zeroconf.HaZeroconf" + ) as mock_zc, patch.object(hass.config_entries.flow, "async_init"), patch.object( zeroconf, "HaAsyncServiceBrowser", side_effect=service_update_mock ), patch( "homeassistant.components.zeroconf.network.async_get_adapters", @@ -871,6 +902,31 @@ async def test_async_detect_interfaces_explicitly_set_ipv6(hass, mock_async_zero ) +async def test_async_detect_interfaces_explicitly_set_ipv6_freebsd( + hass, mock_async_zeroconf +): + """Test interfaces are explicitly set when IPv6 is present on freebsd.""" + with patch("homeassistant.components.zeroconf.sys.platform", "freebsd"), patch( + "homeassistant.components.zeroconf.HaZeroconf" + ) as mock_zc, patch.object(hass.config_entries.flow, "async_init"), patch.object( + zeroconf, "HaAsyncServiceBrowser", side_effect=service_update_mock + ), patch( + "homeassistant.components.zeroconf.network.async_get_adapters", + return_value=_ADAPTER_WITH_DEFAULT_ENABLED_AND_IPV6, + ), patch( + "homeassistant.components.zeroconf.AsyncServiceInfo", + side_effect=get_service_info_mock, + ): + assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + assert mock_zc.mock_calls[0] == call( + interfaces=InterfaceChoice.Default, + ip_version=IPVersion.V4Only, + ) + + async def test_no_name(hass, mock_async_zeroconf): """Test fallback to Home for mDNS announcement if the name is missing.""" hass.config.location_name = "" From 52410ff0d7be54785ac1fab31c7501bb875ec2cc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Sep 2021 11:54:43 -0500 Subject: [PATCH 600/843] Ensure yeelight can be unloaded when device is offline (#56464) --- homeassistant/components/yeelight/__init__.py | 20 ++++++++++--------- tests/components/yeelight/test_init.py | 20 +++++++++++++++++++ 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py index 19fe5c550ad..a4ff947191e 100644 --- a/homeassistant/components/yeelight/__init__.py +++ b/homeassistant/components/yeelight/__init__.py @@ -303,18 +303,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - data_config_entries = hass.data[DOMAIN][DATA_CONFIG_ENTRIES] - entry_data = data_config_entries[entry.entry_id] - - if entry_data[DATA_PLATFORMS_LOADED]: - if not await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - return False - if entry.data.get(CONF_ID): # discovery scanner = YeelightScanner.async_get(hass) scanner.async_unregister_callback(entry.data[CONF_ID]) + data_config_entries = hass.data[DOMAIN][DATA_CONFIG_ENTRIES] + if entry.entry_id not in data_config_entries: + # Device not online + return True + + entry_data = data_config_entries[entry.entry_id] + unload_ok = True + if entry_data[DATA_PLATFORMS_LOADED]: + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if DATA_DEVICE in entry_data: device = entry_data[DATA_DEVICE] _LOGGER.debug("Shutting down Yeelight Listener") @@ -322,8 +325,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.debug("Yeelight Listener stopped") data_config_entries.pop(entry.entry_id) - - return True + return unload_ok @callback diff --git a/tests/components/yeelight/test_init.py b/tests/components/yeelight/test_init.py index 4b3ac8e0e83..cee798308c4 100644 --- a/tests/components/yeelight/test_init.py +++ b/tests/components/yeelight/test_init.py @@ -366,6 +366,26 @@ async def test_async_listen_error_late_discovery(hass, caplog): assert config_entry.options[CONF_MODEL] == MODEL +async def test_unload_before_discovery(hass, caplog): + """Test unloading before discovery.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=CONFIG_ENTRY_DATA) + config_entry.add_to_hass(hass) + + mocked_bulb = _mocked_bulb(cannot_connect=True) + + with _patch_discovery(no_device=True), patch( + f"{MODULE}.AsyncBulb", return_value=mocked_bulb + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.NOT_LOADED + + async def test_async_listen_error_has_host_with_id(hass: HomeAssistant): """Test the async listen error.""" config_entry = MockConfigEntry( From 2326e3ed9430208464ad3410910ee1aa445d10ef Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sun, 26 Sep 2021 18:59:00 +0200 Subject: [PATCH 601/843] Upgrade voluptuous to 0.12.2 (#56680) --- homeassistant/package_constraints.txt | 2 +- requirements.txt | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 84d1923cfd4..4998d2c9012 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -31,7 +31,7 @@ requests==2.25.1 scapy==2.4.5 sqlalchemy==1.4.23 voluptuous-serialize==2.4.0 -voluptuous==0.12.1 +voluptuous==0.12.2 yarl==1.6.3 zeroconf==0.36.7 diff --git a/requirements.txt b/requirements.txt index affe27d7743..a91b0fc693c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,6 +18,6 @@ pip>=8.0.3,<20.3 python-slugify==4.0.1 pyyaml==5.4.1 requests==2.25.1 -voluptuous==0.12.1 +voluptuous==0.12.2 voluptuous-serialize==2.4.0 yarl==1.6.3 diff --git a/setup.py b/setup.py index 13768c4de31..a402ef9cebf 100755 --- a/setup.py +++ b/setup.py @@ -50,7 +50,7 @@ REQUIRES = [ "python-slugify==4.0.1", "pyyaml==5.4.1", "requests==2.25.1", - "voluptuous==0.12.1", + "voluptuous==0.12.2", "voluptuous-serialize==2.4.0", "yarl==1.6.3", ] From 8716aa011a66e0483bc4c7a4cbe70c897d56024e Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sun, 26 Sep 2021 14:22:41 -0400 Subject: [PATCH 602/843] Add support for multilevel switch CC select entities (#56656) * Add support for multilevel switch CC select entities * Use state names from docs and include more device identifiers from device DB * black * pylint * type fix * Add failure scenario test * Update homeassistant/components/zwave_js/select.py Co-authored-by: Martin Hjelmare Co-authored-by: Martin Hjelmare --- .../components/zwave_js/discovery.py | 25 +- .../zwave_js/discovery_data_template.py | 11 +- homeassistant/components/zwave_js/select.py | 40 ++ tests/components/zwave_js/conftest.py | 14 + tests/components/zwave_js/test_discovery.py | 12 + tests/components/zwave_js/test_select.py | 73 ++++ .../zwave_js/fortrezz_ssa1_siren_state.json | 350 ++++++++++++++++++ 7 files changed, 518 insertions(+), 7 deletions(-) create mode 100644 tests/fixtures/zwave_js/fortrezz_ssa1_siren_state.json diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index c32e74c5b5f..7fa15f9f95a 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -291,7 +291,7 @@ DISCOVERY_SCHEMAS = [ type={"number"}, ), data_template=DynamicCurrentTempClimateDataTemplate( - { + lookup_table={ # Internal Sensor "A": ZwaveValueID( THERMOSTAT_CURRENT_TEMP_PROPERTY, @@ -321,7 +321,7 @@ DISCOVERY_SCHEMAS = [ endpoint=4, ), }, - ZwaveValueID(2, CommandClass.CONFIGURATION, endpoint=0), + dependent_value=ZwaveValueID(2, CommandClass.CONFIGURATION, endpoint=0), ), ), # Heatit Z-TRM2fx @@ -338,7 +338,7 @@ DISCOVERY_SCHEMAS = [ type={"number"}, ), data_template=DynamicCurrentTempClimateDataTemplate( - { + lookup_table={ # External Sensor "A2": ZwaveValueID( THERMOSTAT_CURRENT_TEMP_PROPERTY, @@ -357,7 +357,24 @@ DISCOVERY_SCHEMAS = [ endpoint=3, ), }, - ZwaveValueID(2, CommandClass.CONFIGURATION, endpoint=0), + dependent_value=ZwaveValueID(2, CommandClass.CONFIGURATION, endpoint=0), + ), + ), + # FortrezZ SSA1/SSA2 + ZWaveDiscoverySchema( + platform="select", + hint="multilevel_switch", + manufacturer_id={0x0084}, + product_id={0x0107, 0x0108, 0x010B, 0x0205}, + product_type={0x0311, 0x0313, 0x0341, 0x0343}, + primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, + data_template=BaseDiscoverySchemaDataTemplate( + { + 0: "Off", + 33: "Strobe ONLY", + 66: "Siren ONLY", + 99: "Siren & Strobe FULL Alarm", + }, ), ), # ====== START OF CONFIG PARAMETER SPECIFIC MAPPING SCHEMAS ======= diff --git a/homeassistant/components/zwave_js/discovery_data_template.py b/homeassistant/components/zwave_js/discovery_data_template.py index f294294625a..7b76465d60e 100644 --- a/homeassistant/components/zwave_js/discovery_data_template.py +++ b/homeassistant/components/zwave_js/discovery_data_template.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections.abc import Iterable -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any from zwave_js_server.const import CommandClass @@ -92,9 +92,12 @@ class ZwaveValueID: property_key: str | int | None = None +@dataclass class BaseDiscoverySchemaDataTemplate: """Base class for discovery schema data templates.""" + static_data: Any | None = None + def resolve_data(self, value: ZwaveValue) -> Any: """ Resolve helper class data for a discovered value. @@ -141,11 +144,13 @@ class BaseDiscoverySchemaDataTemplate: class DynamicCurrentTempClimateDataTemplate(BaseDiscoverySchemaDataTemplate): """Data template class for Z-Wave JS Climate entities with dynamic current temps.""" - lookup_table: dict[str | int, ZwaveValueID] - dependent_value: ZwaveValueID + lookup_table: dict[str | int, ZwaveValueID] = field(default_factory=dict) + dependent_value: ZwaveValueID | None = None def resolve_data(self, value: ZwaveValue) -> dict[str, Any]: """Resolve helper class data for a discovered value.""" + if not self.lookup_table or not self.dependent_value: + raise ValueError("Invalid discovery data template") data: dict[str, Any] = { "lookup_table": {}, "dependent_value": self._get_value_from_id( diff --git a/homeassistant/components/zwave_js/select.py b/homeassistant/components/zwave_js/select.py index fae87fd24de..9ec4d02bfec 100644 --- a/homeassistant/components/zwave_js/select.py +++ b/homeassistant/components/zwave_js/select.py @@ -1,6 +1,8 @@ """Support for Z-Wave controls using the select platform.""" from __future__ import annotations +from typing import Dict, cast + from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import CommandClass from zwave_js_server.const.command_class.sound_switch import ToneID @@ -30,6 +32,10 @@ async def async_setup_entry( entities: list[ZWaveBaseEntity] = [] if info.platform_hint == "Default tone": entities.append(ZwaveDefaultToneSelectEntity(config_entry, client, info)) + elif info.platform_hint == "multilevel_switch": + entities.append( + ZwaveMultilevelSwitchSelectEntity(config_entry, client, info) + ) else: entities.append(ZwaveSelectEntity(config_entry, client, info)) async_add_entities(entities) @@ -126,3 +132,37 @@ class ZwaveDefaultToneSelectEntity(ZWaveBaseEntity, SelectEntity): if val == option ) await self.info.node.async_set_value(self.info.primary_value, int(key)) + + +class ZwaveMultilevelSwitchSelectEntity(ZWaveBaseEntity, SelectEntity): + """Representation of a Z-Wave Multilevel Switch CC select entity.""" + + def __init__( + self, config_entry: ConfigEntry, client: ZwaveClient, info: ZwaveDiscoveryInfo + ) -> None: + """Initialize a ZwaveSelectEntity entity.""" + super().__init__(config_entry, client, info) + self._target_value = self.get_zwave_value("targetValue") + assert self.info.platform_data_template + self._lookup_map = cast( + Dict[int, str], self.info.platform_data_template.static_data + ) + + # Entity class attributes + self._attr_options = list(self._lookup_map.values()) + + @property + def current_option(self) -> str | None: + """Return the selected entity option to represent the entity state.""" + if self.info.primary_value.value is None: + return None + return str( + self._lookup_map.get( + int(self.info.primary_value.value), self.info.primary_value.value + ) + ) + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + key = next(key for key, val in self._lookup_map.items() if val == option) + await self.info.node.async_set_value(self._target_value, int(key)) diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 6634fdf759d..422f4b55c16 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -455,6 +455,12 @@ def lock_popp_electric_strike_lock_control_state_fixture(): ) +@pytest.fixture(name="fortrezz_ssa1_siren_state", scope="session") +def fortrezz_ssa1_siren_state_fixture(): + """Load the fortrezz ssa1 siren node state fixture data.""" + return json.loads(load_fixture("zwave_js/fortrezz_ssa1_siren_state.json")) + + @pytest.fixture(name="client") def mock_client_fixture(controller_state, version_state, log_config_state): """Mock a client.""" @@ -859,6 +865,14 @@ def lock_popp_electric_strike_lock_control_fixture( return node +@pytest.fixture(name="fortrezz_ssa1_siren") +def fortrezz_ssa1_siren_fixture(client, fortrezz_ssa1_siren_state): + """Mock a fortrezz ssa1 siren node.""" + node = Node(client, copy.deepcopy(fortrezz_ssa1_siren_state)) + client.driver.controller.nodes[node.node_id] = node + return node + + @pytest.fixture(name="firmware_file") def firmware_file_fixture(): """Return mock firmware file stream.""" diff --git a/tests/components/zwave_js/test_discovery.py b/tests/components/zwave_js/test_discovery.py index 9758d3b0f44..ad176d0168e 100644 --- a/tests/components/zwave_js/test_discovery.py +++ b/tests/components/zwave_js/test_discovery.py @@ -6,6 +6,9 @@ from homeassistant.components.zwave_js.discovery import ( ZWaveDiscoverySchema, ZWaveValueDiscoverySchema, ) +from homeassistant.components.zwave_js.discovery_data_template import ( + DynamicCurrentTempClimateDataTemplate, +) async def test_iblinds_v2(hass, client, iblinds_v2, integration): @@ -76,3 +79,12 @@ async def test_firmware_version_range_exception(hass): ZWaveValueDiscoverySchema(command_class=1), firmware_version_range=FirmwareVersionRange(), ) + + +async def test_dynamic_climate_data_discovery_template_failure(hass, multisensor_6): + """Test that initing a DynamicCurrentTempClimateDataTemplate with no data raises.""" + node = multisensor_6 + with pytest.raises(ValueError): + DynamicCurrentTempClimateDataTemplate().resolve_data( + node.values[f"{node.node_id}-49-0-Ultraviolet"] + ) diff --git a/tests/components/zwave_js/test_select.py b/tests/components/zwave_js/test_select.py index 43f44f0bba0..5ed0804723c 100644 --- a/tests/components/zwave_js/test_select.py +++ b/tests/components/zwave_js/test_select.py @@ -5,6 +5,7 @@ from homeassistant.const import STATE_UNKNOWN DEFAULT_TONE_SELECT_ENTITY = "select.indoor_siren_6_default_tone_2" PROTECTION_SELECT_ENTITY = "select.family_room_combo_local_protection_state" +MULTILEVEL_SWITCH_SELECT_ENTITY = "select.front_door_siren" async def test_default_tone_select(hass, client, aeotec_zw164_siren, integration): @@ -199,3 +200,75 @@ async def test_protection_select(hass, client, inovelli_lzw36, integration): state = hass.states.get(PROTECTION_SELECT_ENTITY) assert state.state == STATE_UNKNOWN + + +async def test_multilevel_switch_select(hass, client, fortrezz_ssa1_siren, integration): + """Test Multilevel Switch CC based select entity.""" + node = fortrezz_ssa1_siren + state = hass.states.get(MULTILEVEL_SWITCH_SELECT_ENTITY) + + assert state + assert state.state == "Off" + attr = state.attributes + assert attr["options"] == [ + "Off", + "Strobe ONLY", + "Siren ONLY", + "Siren & Strobe FULL Alarm", + ] + + # Test select option with string value + await hass.services.async_call( + "select", + "select_option", + {"entity_id": MULTILEVEL_SWITCH_SELECT_ENTITY, "option": "Strobe ONLY"}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == node.node_id + assert args["valueId"] == { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": True, + "writeable": True, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 99, + }, + } + assert args["value"] == 33 + + client.async_send_command.reset_mock() + + # Test value update from value updated event + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Multilevel Switch", + "commandClass": 38, + "endpoint": 0, + "property": "currentValue", + "newValue": 33, + "prevValue": 0, + "propertyName": "currentValue", + }, + }, + ) + node.receive_event(event) + + state = hass.states.get(MULTILEVEL_SWITCH_SELECT_ENTITY) + assert state.state == "Strobe ONLY" diff --git a/tests/fixtures/zwave_js/fortrezz_ssa1_siren_state.json b/tests/fixtures/zwave_js/fortrezz_ssa1_siren_state.json new file mode 100644 index 00000000000..d8973f2688e --- /dev/null +++ b/tests/fixtures/zwave_js/fortrezz_ssa1_siren_state.json @@ -0,0 +1,350 @@ +{ + "nodeId": 80, + "index": 0, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "isSecure": false, + "manufacturerId": 132, + "productId": 267, + "productType": 787, + "firmwareVersion": "1.11", + "name": "Front Door Siren", + "location": "Outside", + "deviceConfig": { + "filename": "/data/db/devices/0x0084/ssa1_ssa2.json", + "isEmbedded": true, + "manufacturer": "FortrezZ LLC", + "manufacturerId": 132, + "label": "SSA1/SSA2", + "description": "Siren and Strobe Alarm", + "devices": [ + { + "productType": 785, + "productId": 267 + }, + { + "productType": 787, + "productId": 264 + }, + { + "productType": 787, + "productId": 267 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "paramInformation": { + "_map": {} + } + }, + "label": "SSA1/SSA2", + "interviewAttempts": 1, + "endpoints": [ + { + "nodeId": 80, + "index": 0, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 0, + "label": "Unused" + }, + "mandatorySupportedCCs": [32, 38], + "mandatoryControlledCCs": [] + } + } + ], + "values": [ + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 99 + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Transition duration" + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current value", + "min": 0, + "max": 99 + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Up", + "propertyName": "Up", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Perform a level change (Up)", + "ccSpecific": { + "switchType": 2 + } + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Down", + "propertyName": "Down", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Perform a level change (Down)", + "ccSpecific": { + "switchType": 2 + } + } + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535 + }, + "value": 132 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535 + }, + "value": 787 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535 + }, + "value": 267 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + } + }, + "value": 6 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 1, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version" + }, + "value": "2.97" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 1, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions" + }, + "value": ["1.11"] + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 1, + "propertyName": "Delay before accept of Basic Set Off", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Delay, from the time the siren-strobe turns on", + "label": "Delay before accept of Basic Set Off", + "default": 0, + "min": 0, + "max": 255, + "unit": "Seconds", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + } + ], + "isFrequentListening": false, + "maxDataRate": 40000, + "supportedDataRates": [40000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 0, + "label": "Unused" + }, + "mandatorySupportedCCs": [32, 38], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 38, + "name": "Multilevel Switch", + "version": 1, + "isSecure": false + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 1, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 1, + "isSecure": false + }, + { + "id": 113, + "name": "Notification", + "version": 2, + "isSecure": false + }, + { + "id": 112, + "name": "Configuration", + "version": 1, + "isSecure": false + } + ], + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0084:0x0313:0x010b:1.11", + "statistics": { + "commandsTX": 12, + "commandsRX": 64, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 2 + } +} From f268227d64bfaf8d827124f5300b80d123fbd91e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Sep 2021 14:47:03 -0500 Subject: [PATCH 603/843] Implement retry and backoff strategy for requirements install (#56580) Co-authored-by: Paulus Schoutsen --- homeassistant/helpers/check_config.py | 2 + homeassistant/requirements.py | 54 +++++++++++++++++---- tests/test_requirements.py | 70 ++++++++++++++++++++++++++- 3 files changed, 115 insertions(+), 11 deletions(-) diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py index 00f952013b5..83505fc8356 100644 --- a/homeassistant/helpers/check_config.py +++ b/homeassistant/helpers/check_config.py @@ -26,6 +26,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.typing import ConfigType from homeassistant.requirements import ( RequirementsNotFound, + async_clear_install_history, async_get_integration_with_requirements, ) import homeassistant.util.yaml.loader as yaml_loader @@ -71,6 +72,7 @@ async def async_check_ha_config_file( # noqa: C901 This method is a coroutine. """ result = HomeAssistantConfig() + async_clear_install_history(hass) def _pack_error( package: str, component: str, config: ConfigType, message: str diff --git a/homeassistant/requirements.py b/homeassistant/requirements.py index 67d0ede96bc..9fdeac7ac75 100644 --- a/homeassistant/requirements.py +++ b/homeassistant/requirements.py @@ -3,10 +3,11 @@ from __future__ import annotations import asyncio from collections.abc import Iterable +import logging import os from typing import Any, cast -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.typing import UNDEFINED, UndefinedType from homeassistant.loader import Integration, IntegrationNotFound, async_get_integration @@ -15,9 +16,11 @@ import homeassistant.util.package as pkg_util # mypy: disallow-any-generics PIP_TIMEOUT = 60 # The default is too low when the internet connection is satellite or high latency +MAX_INSTALL_FAILURES = 3 DATA_PIP_LOCK = "pip_lock" DATA_PKG_CACHE = "pkg_cache" DATA_INTEGRATIONS_WITH_REQS = "integrations_with_reqs" +DATA_INSTALL_FAILURE_HISTORY = "install_failure_history" CONSTRAINT_FILE = "package_constraints.txt" DISCOVERY_INTEGRATIONS: dict[str, Iterable[str]] = { "dhcp": ("dhcp",), @@ -25,6 +28,7 @@ DISCOVERY_INTEGRATIONS: dict[str, Iterable[str]] = { "ssdp": ("ssdp",), "zeroconf": ("zeroconf", "homekit"), } +_LOGGER = logging.getLogger(__name__) class RequirementsNotFound(HomeAssistantError): @@ -135,6 +139,13 @@ async def _async_process_integration( raise result +@callback +def async_clear_install_history(hass: HomeAssistant) -> None: + """Forget the install history.""" + if install_failure_history := hass.data.get(DATA_INSTALL_FAILURE_HISTORY): + install_failure_history.clear() + + async def async_process_requirements( hass: HomeAssistant, name: str, requirements: list[str] ) -> None: @@ -146,22 +157,47 @@ async def async_process_requirements( pip_lock = hass.data.get(DATA_PIP_LOCK) if pip_lock is None: pip_lock = hass.data[DATA_PIP_LOCK] = asyncio.Lock() + install_failure_history = hass.data.get(DATA_INSTALL_FAILURE_HISTORY) + if install_failure_history is None: + install_failure_history = hass.data[DATA_INSTALL_FAILURE_HISTORY] = set() kwargs = pip_kwargs(hass.config.config_dir) async with pip_lock: for req in requirements: - if pkg_util.is_installed(req): - continue + await _async_process_requirements( + hass, name, req, install_failure_history, kwargs + ) - def _install(req: str, kwargs: dict[str, Any]) -> bool: - """Install requirement.""" - return pkg_util.install_package(req, **kwargs) - ret = await hass.async_add_executor_job(_install, req, kwargs) +async def _async_process_requirements( + hass: HomeAssistant, + name: str, + req: str, + install_failure_history: set[str], + kwargs: Any, +) -> None: + """Install a requirement and save failures.""" + if pkg_util.is_installed(req): + return - if not ret: - raise RequirementsNotFound(name, [req]) + if req in install_failure_history: + _LOGGER.info( + "Multiple attempts to install %s failed, install will be retried after next configuration check or restart", + req, + ) + raise RequirementsNotFound(name, [req]) + + def _install(req: str, kwargs: dict[str, Any]) -> bool: + """Install requirement.""" + return pkg_util.install_package(req, **kwargs) + + for _ in range(MAX_INSTALL_FAILURES): + if await hass.async_add_executor_job(_install, req, kwargs): + return + + install_failure_history.add(req) + raise RequirementsNotFound(name, [req]) def pip_kwargs(config_dir: str | None) -> dict[str, Any]: diff --git a/tests/test_requirements.py b/tests/test_requirements.py index 82ce10872bf..27ce0e1e742 100644 --- a/tests/test_requirements.py +++ b/tests/test_requirements.py @@ -8,6 +8,7 @@ from homeassistant import loader, setup from homeassistant.requirements import ( CONSTRAINT_FILE, RequirementsNotFound, + async_clear_install_history, async_get_integration_with_requirements, async_process_requirements, ) @@ -89,7 +90,7 @@ async def test_install_missing_package(hass): ) as mock_inst, pytest.raises(RequirementsNotFound): await async_process_requirements(hass, "test_component", ["hello==1.0.0"]) - assert len(mock_inst.mock_calls) == 1 + assert len(mock_inst.mock_calls) == 3 async def test_get_integration_with_requirements(hass): @@ -188,9 +189,13 @@ async def test_get_integration_with_requirements_pip_install_fails_two_passes(ha "test-comp==1.0.0", ] - assert len(mock_inst.mock_calls) == 3 + assert len(mock_inst.mock_calls) == 7 assert sorted(mock_call[1][0] for mock_call in mock_inst.mock_calls) == [ "test-comp-after-dep==1.0.0", + "test-comp-after-dep==1.0.0", + "test-comp-after-dep==1.0.0", + "test-comp-dep==1.0.0", + "test-comp-dep==1.0.0", "test-comp-dep==1.0.0", "test-comp==1.0.0", ] @@ -215,6 +220,67 @@ async def test_get_integration_with_requirements_pip_install_fails_two_passes(ha "test-comp==1.0.0", ] + # On another attempt we remember failures and don't try again + assert len(mock_inst.mock_calls) == 1 + assert sorted(mock_call[1][0] for mock_call in mock_inst.mock_calls) == [ + "test-comp==1.0.0" + ] + + # Now clear the history and so we try again + async_clear_install_history(hass) + + with pytest.raises(RequirementsNotFound), patch( + "homeassistant.util.package.is_installed", return_value=False + ) as mock_is_installed, patch( + "homeassistant.util.package.install_package", side_effect=_mock_install_package + ) as mock_inst: + + integration = await async_get_integration_with_requirements( + hass, "test_component" + ) + assert integration + assert integration.domain == "test_component" + + assert len(mock_is_installed.mock_calls) == 3 + assert sorted(mock_call[1][0] for mock_call in mock_is_installed.mock_calls) == [ + "test-comp-after-dep==1.0.0", + "test-comp-dep==1.0.0", + "test-comp==1.0.0", + ] + + assert len(mock_inst.mock_calls) == 7 + assert sorted(mock_call[1][0] for mock_call in mock_inst.mock_calls) == [ + "test-comp-after-dep==1.0.0", + "test-comp-after-dep==1.0.0", + "test-comp-after-dep==1.0.0", + "test-comp-dep==1.0.0", + "test-comp-dep==1.0.0", + "test-comp-dep==1.0.0", + "test-comp==1.0.0", + ] + + # Now clear the history and mock success + async_clear_install_history(hass) + + with patch( + "homeassistant.util.package.is_installed", return_value=False + ) as mock_is_installed, patch( + "homeassistant.util.package.install_package", return_value=True + ) as mock_inst: + + integration = await async_get_integration_with_requirements( + hass, "test_component" + ) + assert integration + assert integration.domain == "test_component" + + assert len(mock_is_installed.mock_calls) == 3 + assert sorted(mock_call[1][0] for mock_call in mock_is_installed.mock_calls) == [ + "test-comp-after-dep==1.0.0", + "test-comp-dep==1.0.0", + "test-comp==1.0.0", + ] + assert len(mock_inst.mock_calls) == 3 assert sorted(mock_call[1][0] for mock_call in mock_inst.mock_calls) == [ "test-comp-after-dep==1.0.0", From 6399730d2f34917fa76402e96dab65dcb4a4a4b4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Sep 2021 16:30:39 -0500 Subject: [PATCH 604/843] Optimize SSDP matching (#56622) * Optimize SSDP matching * tweak * remove * remove dupe --- homeassistant/components/ssdp/__init__.py | 70 ++++++++++++++++++----- 1 file changed, 55 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index ce2901d4f1a..af8f5915f57 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -56,6 +56,7 @@ ATTR_UPNP_UDN = "UDN" ATTR_UPNP_UPC = "UPC" ATTR_UPNP_PRESENTATION_URL = "presentationURL" +PRIMARY_MATCH_KEYS = [ATTR_UPNP_MANUFACTURER, "st", ATTR_UPNP_DEVICE_TYPE] DISCOVERY_MAPPING = { "usn": ATTR_SSDP_USN, @@ -124,7 +125,10 @@ async def async_get_discovery_info_by_udn( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the SSDP integration.""" - scanner = hass.data[DOMAIN] = Scanner(hass) + integration_matchers = IntegrationMatchers() + integration_matchers.async_setup(await async_get_ssdp(hass)) + + scanner = hass.data[DOMAIN] = Scanner(hass, integration_matchers) asyncio.create_task(scanner.async_start()) @@ -156,10 +160,57 @@ def _async_headers_match( return True +class IntegrationMatchers: + """Optimized integration matching.""" + + def __init__(self) -> None: + """Init optimized integration matching.""" + self._match_by_key: dict[ + str, dict[str, list[tuple[str, dict[str, str]]]] + ] | None = None + + @core_callback + def async_setup( + self, integration_matchers: dict[str, list[dict[str, str]]] + ) -> None: + """Build matchers by key. + + Here we convert the primary match keys into their own + dicts so we can do lookups of the primary match + key to find the match dict. + """ + self._match_by_key = {} + for key in PRIMARY_MATCH_KEYS: + matchers_by_key = self._match_by_key[key] = {} + for domain, matchers in integration_matchers.items(): + for matcher in matchers: + if match_value := matcher.get(key): + matchers_by_key.setdefault(match_value, []).append( + (domain, matcher) + ) + + @core_callback + def async_matching_domains(self, info_with_desc: CaseInsensitiveDict) -> set[str]: + """Find domains matching the passed CaseInsensitiveDict.""" + assert self._match_by_key is not None + domains = set() + for key, matchers_by_key in self._match_by_key.items(): + if not (match_value := info_with_desc.get(key)): + continue + for domain, matcher in matchers_by_key.get(match_value, []): + if domain in domains: + continue + if all(info_with_desc.get(k) == v for (k, v) in matcher.items()): + domains.add(domain) + return domains + + class Scanner: """Class to manage SSDP searching and SSDP advertisements.""" - def __init__(self, hass: HomeAssistant) -> None: + def __init__( + self, hass: HomeAssistant, integration_matchers: IntegrationMatchers + ) -> None: """Initialize class.""" self.hass = hass self._cancel_scan: Callable[[], None] | None = None @@ -167,7 +218,7 @@ class Scanner: self._callbacks: list[tuple[SsdpCallback, dict[str, str]]] = [] self._flow_dispatcher: FlowDispatcher | None = None self._description_cache: DescriptionCache | None = None - self._integration_matchers: dict[str, list[dict[str, str]]] | None = None + self.integration_matchers = integration_matchers @property def _ssdp_devices(self) -> list[SsdpDevice]: @@ -271,7 +322,6 @@ class Scanner: requester = AiohttpSessionRequester(session, True, 10) self._description_cache = DescriptionCache(requester) self._flow_dispatcher = FlowDispatcher(self.hass) - self._integration_matchers = await async_get_ssdp(self.hass) await self._async_start_ssdp_listeners() @@ -323,16 +373,6 @@ class Scanner: if _async_headers_match(combined_headers, match_dict) ] - @core_callback - def _async_matching_domains(self, info_with_desc: CaseInsensitiveDict) -> set[str]: - assert self._integration_matchers is not None - domains = set() - for domain, matchers in self._integration_matchers.items(): - for matcher in matchers: - if all(info_with_desc.get(k) == v for (k, v) in matcher.items()): - domains.add(domain) - return domains - async def _ssdp_listener_callback( self, ssdp_device: SsdpDevice, dst: DeviceOrServiceType, source: SsdpSource ) -> None: @@ -351,7 +391,7 @@ class Scanner: ssdp_change = SSDP_SOURCE_SSDP_CHANGE_MAPPING[source] await _async_process_callbacks(callbacks, discovery_info, ssdp_change) - for domain in self._async_matching_domains(info_with_desc): + for domain in self.integration_matchers.async_matching_domains(info_with_desc): _LOGGER.debug("Discovered %s at %s", domain, location) flow: SSDPFlow = { From 26e031984b2bc21f9d9ff368fc1699cfbb81c15a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Sep 2021 17:16:03 -0500 Subject: [PATCH 605/843] Ensure sonos always gets ssdp callbacks from searches (#56591) --- .../components/dlna_dmr/manifest.json | 2 +- homeassistant/components/ssdp/__init__.py | 27 ++++++++++++++----- homeassistant/components/ssdp/manifest.json | 2 +- homeassistant/components/upnp/manifest.json | 2 +- .../components/yeelight/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 8 files changed, 27 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index f844bdf987b..1295a1d221b 100644 --- a/homeassistant/components/dlna_dmr/manifest.json +++ b/homeassistant/components/dlna_dmr/manifest.json @@ -2,7 +2,7 @@ "domain": "dlna_dmr", "name": "DLNA Digital Media Renderer", "documentation": "https://www.home-assistant.io/integrations/dlna_dmr", - "requirements": ["async-upnp-client==0.21.2"], + "requirements": ["async-upnp-client==0.22.1"], "dependencies": ["network"], "codeowners": [], "iot_class": "local_push" diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index af8f5915f57..edb64b780c3 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -71,13 +71,13 @@ SsdpCallback = Callable[[Mapping[str, Any], SsdpChange], Awaitable] SSDP_SOURCE_SSDP_CHANGE_MAPPING: Mapping[SsdpSource, SsdpChange] = { - SsdpSource.SEARCH: SsdpChange.ALIVE, + SsdpSource.SEARCH_ALIVE: SsdpChange.ALIVE, + SsdpSource.SEARCH_CHANGED: SsdpChange.ALIVE, SsdpSource.ADVERTISEMENT_ALIVE: SsdpChange.ALIVE, SsdpSource.ADVERTISEMENT_BYEBYE: SsdpChange.BYEBYE, SsdpSource.ADVERTISEMENT_UPDATE: SsdpChange.UPDATE, } - _LOGGER = logging.getLogger(__name__) @@ -374,26 +374,39 @@ class Scanner: ] async def _ssdp_listener_callback( - self, ssdp_device: SsdpDevice, dst: DeviceOrServiceType, source: SsdpSource + self, + ssdp_device: SsdpDevice, + dst: DeviceOrServiceType, + source: SsdpSource, ) -> None: """Handle a device/service change.""" _LOGGER.debug( - "Change, ssdp_device: %s, dst: %s, source: %s", ssdp_device, dst, source + "SSDP: ssdp_device: %s, dst: %s, source: %s", ssdp_device, dst, source ) location = ssdp_device.location info_desc = await self._async_get_description_dict(location) or {} combined_headers = ssdp_device.combined_headers(dst) info_with_desc = CaseInsensitiveDict(combined_headers, **info_desc) - discovery_info = discovery_info_from_headers_and_description(info_with_desc) callbacks = self._async_get_matching_callbacks(combined_headers) + matching_domains: set[str] = set() + + # If there are no changes from a search, do not trigger a config flow + if source != SsdpSource.SEARCH_ALIVE: + matching_domains = self.integration_matchers.async_matching_domains( + info_with_desc + ) + + if not callbacks and not matching_domains: + return + + discovery_info = discovery_info_from_headers_and_description(info_with_desc) ssdp_change = SSDP_SOURCE_SSDP_CHANGE_MAPPING[source] await _async_process_callbacks(callbacks, discovery_info, ssdp_change) - for domain in self.integration_matchers.async_matching_domains(info_with_desc): + for domain in matching_domains: _LOGGER.debug("Discovered %s at %s", domain, location) - flow: SSDPFlow = { "domain": domain, "context": {"source": config_entries.SOURCE_SSDP}, diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index 1e7dec03d7d..0fecbc01282 100644 --- a/homeassistant/components/ssdp/manifest.json +++ b/homeassistant/components/ssdp/manifest.json @@ -4,7 +4,7 @@ "documentation": "https://www.home-assistant.io/integrations/ssdp", "requirements": [ "defusedxml==0.7.1", - "async-upnp-client==0.21.2" + "async-upnp-client==0.22.1" ], "dependencies": ["network"], "after_dependencies": ["zeroconf"], diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index fa71fa67751..7cf45673292 100644 --- a/homeassistant/components/upnp/manifest.json +++ b/homeassistant/components/upnp/manifest.json @@ -3,7 +3,7 @@ "name": "UPnP/IGD", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/upnp", - "requirements": ["async-upnp-client==0.21.2"], + "requirements": ["async-upnp-client==0.22.1"], "dependencies": ["network", "ssdp"], "codeowners": ["@StevenLooman","@ehendrix23"], "ssdp": [ diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index d0f1eee2828..90e575fb404 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -2,7 +2,7 @@ "domain": "yeelight", "name": "Yeelight", "documentation": "https://www.home-assistant.io/integrations/yeelight", - "requirements": ["yeelight==0.7.5", "async-upnp-client==0.21.2"], + "requirements": ["yeelight==0.7.5", "async-upnp-client==0.22.1"], "codeowners": ["@rytilahti", "@zewelor", "@shenxn", "@starkillerOG"], "config_flow": true, "dependencies": ["network"], diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 4998d2c9012..2fce5d7cc7a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -4,7 +4,7 @@ aiodiscover==1.4.2 aiohttp==3.7.4.post0 aiohttp_cors==0.7.0 astral==2.2 -async-upnp-client==0.21.2 +async-upnp-client==0.22.1 async_timeout==3.0.1 attrs==21.2.0 awesomeversion==21.8.1 diff --git a/requirements_all.txt b/requirements_all.txt index 420ccaaa435..48880661cfe 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -327,7 +327,7 @@ asterisk_mbox==0.5.0 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.21.2 +async-upnp-client==0.22.1 # homeassistant.components.supla asyncpysupla==0.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fae816ac909..bd537b46431 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -221,7 +221,7 @@ arcam-fmj==0.7.0 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.21.2 +async-upnp-client==0.22.1 # homeassistant.components.aurora auroranoaa==0.0.2 From 7ab6c82ad21ba3135f3af556fcf25dcfb8a56eba Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Sep 2021 19:49:43 -0500 Subject: [PATCH 606/843] Drop defusedxml dep from ssdp manifest (#56699) --- homeassistant/components/ssdp/manifest.json | 5 +---- homeassistant/package_constraints.txt | 1 - requirements_all.txt | 1 - requirements_test_all.txt | 1 - 4 files changed, 1 insertion(+), 7 deletions(-) diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index 0fecbc01282..465c1ce3cbf 100644 --- a/homeassistant/components/ssdp/manifest.json +++ b/homeassistant/components/ssdp/manifest.json @@ -2,10 +2,7 @@ "domain": "ssdp", "name": "Simple Service Discovery Protocol (SSDP)", "documentation": "https://www.home-assistant.io/integrations/ssdp", - "requirements": [ - "defusedxml==0.7.1", - "async-upnp-client==0.22.1" - ], + "requirements": ["async-upnp-client==0.22.1"], "dependencies": ["network"], "after_dependencies": ["zeroconf"], "codeowners": [], diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 2fce5d7cc7a..878943152fa 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -13,7 +13,6 @@ bcrypt==3.1.7 certifi>=2020.12.5 ciso8601==2.2.0 cryptography==3.4.8 -defusedxml==0.7.1 emoji==1.5.0 hass-nabucasa==0.50.0 home-assistant-frontend==20210922.0 diff --git a/requirements_all.txt b/requirements_all.txt index 48880661cfe..967fc71aa4c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -522,7 +522,6 @@ debugpy==1.4.3 # homeassistant.components.ihc # homeassistant.components.namecheapdns # homeassistant.components.ohmconnect -# homeassistant.components.ssdp defusedxml==0.7.1 # homeassistant.components.deluge diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bd537b46431..fcea1527eb8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -318,7 +318,6 @@ debugpy==1.4.3 # homeassistant.components.ihc # homeassistant.components.namecheapdns # homeassistant.components.ohmconnect -# homeassistant.components.ssdp defusedxml==0.7.1 # homeassistant.components.denonavr From 115d34f55ace4d2116ccedaa09cc6aaf72c7e5b4 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 27 Sep 2021 04:19:30 +0200 Subject: [PATCH 607/843] Set certifi to >=2021.5.30 (#56679) Co-authored-by: J. Nick Koston --- homeassistant/package_constraints.txt | 2 +- requirements.txt | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 878943152fa..688adaa00e2 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -10,7 +10,7 @@ attrs==21.2.0 awesomeversion==21.8.1 backports.zoneinfo;python_version<"3.9" bcrypt==3.1.7 -certifi>=2020.12.5 +certifi>=2021.5.30 ciso8601==2.2.0 cryptography==3.4.8 emoji==1.5.0 diff --git a/requirements.txt b/requirements.txt index a91b0fc693c..10ad988a743 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,7 +8,7 @@ attrs==21.2.0 awesomeversion==21.8.1 backports.zoneinfo;python_version<"3.9" bcrypt==3.1.7 -certifi>=2020.12.5 +certifi>=2021.5.30 ciso8601==2.2.0 httpx==0.19.0 jinja2==3.0.1 diff --git a/setup.py b/setup.py index a402ef9cebf..104554728ed 100755 --- a/setup.py +++ b/setup.py @@ -39,7 +39,7 @@ REQUIRES = [ "awesomeversion==21.8.1", 'backports.zoneinfo;python_version<"3.9"', "bcrypt==3.1.7", - "certifi>=2020.12.5", + "certifi>=2021.5.30", "ciso8601==2.2.0", "httpx==0.19.0", "jinja2==3.0.1", From 44a4507b5153506c10390de5ca4a38063847c589 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 27 Sep 2021 04:44:28 +0200 Subject: [PATCH 608/843] Upgrade requests to 2.26.0 (#56683) Co-authored-by: J. Nick Koston --- homeassistant/package_constraints.txt | 2 +- requirements.txt | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 688adaa00e2..2cf735c435d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -26,7 +26,7 @@ pyserial==3.5 python-slugify==4.0.1 pyudev==0.22.0 pyyaml==5.4.1 -requests==2.25.1 +requests==2.26.0 scapy==2.4.5 sqlalchemy==1.4.23 voluptuous-serialize==2.4.0 diff --git a/requirements.txt b/requirements.txt index 10ad988a743..edb9253b7f7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,7 +17,7 @@ cryptography==3.4.8 pip>=8.0.3,<20.3 python-slugify==4.0.1 pyyaml==5.4.1 -requests==2.25.1 +requests==2.26.0 voluptuous==0.12.2 voluptuous-serialize==2.4.0 yarl==1.6.3 diff --git a/setup.py b/setup.py index 104554728ed..464aa484fcb 100755 --- a/setup.py +++ b/setup.py @@ -49,7 +49,7 @@ REQUIRES = [ "pip>=8.0.3,<20.3", "python-slugify==4.0.1", "pyyaml==5.4.1", - "requests==2.25.1", + "requests==2.26.0", "voluptuous==0.12.2", "voluptuous-serialize==2.4.0", "yarl==1.6.3", From 55c9abc58d10a6167a703264a91c2b4d171eb691 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 27 Sep 2021 07:02:46 +0200 Subject: [PATCH 609/843] Upgrade discord.py to 1.7.3 (#56686) --- homeassistant/components/discord/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/discord/manifest.json b/homeassistant/components/discord/manifest.json index c475c502f60..0da186e7924 100644 --- a/homeassistant/components/discord/manifest.json +++ b/homeassistant/components/discord/manifest.json @@ -2,7 +2,7 @@ "domain": "discord", "name": "Discord", "documentation": "https://www.home-assistant.io/integrations/discord", - "requirements": ["discord.py==1.7.2"], + "requirements": ["discord.py==1.7.3"], "codeowners": [], "iot_class": "cloud_push" } diff --git a/requirements_all.txt b/requirements_all.txt index 967fc71aa4c..e8c5cb2bb5c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -540,7 +540,7 @@ directv==0.4.0 discogs_client==2.3.0 # homeassistant.components.discord -discord.py==1.7.2 +discord.py==1.7.3 # homeassistant.components.digitalloggers dlipower==0.7.165 From 8884e9691ec686b8fc204845978be03e5eaffd35 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 27 Sep 2021 07:03:09 +0200 Subject: [PATCH 610/843] Upgrade TwitterAPI to 2.7.5 (#56687) --- homeassistant/components/twitter/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/twitter/manifest.json b/homeassistant/components/twitter/manifest.json index 077e72f5485..ffd42b8b0fe 100644 --- a/homeassistant/components/twitter/manifest.json +++ b/homeassistant/components/twitter/manifest.json @@ -2,7 +2,7 @@ "domain": "twitter", "name": "Twitter", "documentation": "https://www.home-assistant.io/integrations/twitter", - "requirements": ["TwitterAPI==2.7.3"], + "requirements": ["TwitterAPI==2.7.5"], "codeowners": [], "iot_class": "cloud_push" } diff --git a/requirements_all.txt b/requirements_all.txt index e8c5cb2bb5c..8a9717e796b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -76,7 +76,7 @@ RtmAPI==0.7.2 TravisPy==0.3.5 # homeassistant.components.twitter -TwitterAPI==2.7.3 +TwitterAPI==2.7.5 # homeassistant.components.tof # VL53L1X2==0.1.5 From 01bd3ff1385759bd2c1dd99354421910e29da8dc Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 27 Sep 2021 07:03:29 +0200 Subject: [PATCH 611/843] Upgrade sendgrid to 6.8.2 (#56688) --- homeassistant/components/sendgrid/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sendgrid/manifest.json b/homeassistant/components/sendgrid/manifest.json index 216ea5f625b..d31feb5a8e4 100644 --- a/homeassistant/components/sendgrid/manifest.json +++ b/homeassistant/components/sendgrid/manifest.json @@ -2,7 +2,7 @@ "domain": "sendgrid", "name": "SendGrid", "documentation": "https://www.home-assistant.io/integrations/sendgrid", - "requirements": ["sendgrid==6.7.0"], + "requirements": ["sendgrid==6.8.2"], "codeowners": [], "iot_class": "cloud_push" } diff --git a/requirements_all.txt b/requirements_all.txt index 8a9717e796b..d8515b0b88d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2118,7 +2118,7 @@ screenlogicpy==0.4.1 scsgate==0.1.0 # homeassistant.components.sendgrid -sendgrid==6.7.0 +sendgrid==6.8.2 # homeassistant.components.sensehat sense-hat==2.2.0 From 0fce9f39b3d37ebbb9b1815232051fa5f5daf8ad Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Sep 2021 00:32:25 -0500 Subject: [PATCH 612/843] Avoid checking if a package is installed if it already failed (#56698) --- homeassistant/requirements.py | 6 +++--- tests/test_requirements.py | 4 +--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/homeassistant/requirements.py b/homeassistant/requirements.py index 9fdeac7ac75..aad4a6b1f46 100644 --- a/homeassistant/requirements.py +++ b/homeassistant/requirements.py @@ -178,9 +178,6 @@ async def _async_process_requirements( kwargs: Any, ) -> None: """Install a requirement and save failures.""" - if pkg_util.is_installed(req): - return - if req in install_failure_history: _LOGGER.info( "Multiple attempts to install %s failed, install will be retried after next configuration check or restart", @@ -188,6 +185,9 @@ async def _async_process_requirements( ) raise RequirementsNotFound(name, [req]) + if pkg_util.is_installed(req): + return + def _install(req: str, kwargs: dict[str, Any]) -> bool: """Install requirement.""" return pkg_util.install_package(req, **kwargs) diff --git a/tests/test_requirements.py b/tests/test_requirements.py index 27ce0e1e742..7e4dba42c2f 100644 --- a/tests/test_requirements.py +++ b/tests/test_requirements.py @@ -213,10 +213,8 @@ async def test_get_integration_with_requirements_pip_install_fails_two_passes(ha assert integration assert integration.domain == "test_component" - assert len(mock_is_installed.mock_calls) == 3 + assert len(mock_is_installed.mock_calls) == 1 assert sorted(mock_call[1][0] for mock_call in mock_is_installed.mock_calls) == [ - "test-comp-after-dep==1.0.0", - "test-comp-dep==1.0.0", "test-comp==1.0.0", ] From 0f313ec73c71a209e58c9c3da5c2c42397fb8047 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Sep 2021 08:37:54 +0200 Subject: [PATCH 613/843] Bump home-assistant/builder from 2021.07.0 to 2021.09.0 (#56704) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 25d4d0ca8a0..d8558c6fdff 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -133,7 +133,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build base image - uses: home-assistant/builder@2021.07.0 + uses: home-assistant/builder@2021.09.0 with: args: | $BUILD_ARGS \ @@ -186,7 +186,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build base image - uses: home-assistant/builder@2021.07.0 + uses: home-assistant/builder@2021.09.0 with: args: | $BUILD_ARGS \ From 6c2674734ae49e1c74ea7bcdda87d19c9eaf8523 Mon Sep 17 00:00:00 2001 From: Michael Chisholm Date: Mon, 27 Sep 2021 16:39:22 +1000 Subject: [PATCH 614/843] SSDP starts config flow only for alive devices (#56551) Co-authored-by: J. Nick Koston --- homeassistant/components/ssdp/__init__.py | 4 ++ tests/components/ssdp/test_init.py | 71 +++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index edb64b780c3..3bfde32e50f 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -405,6 +405,10 @@ class Scanner: ssdp_change = SSDP_SOURCE_SSDP_CHANGE_MAPPING[source] await _async_process_callbacks(callbacks, discovery_info, ssdp_change) + # Config flows should only be created for alive/update messages from alive devices + if ssdp_change == SsdpChange.BYEBYE: + return + for domain in matching_domains: _LOGGER.debug("Discovered %s at %s", domain, location) flow: SSDPFlow = { diff --git a/tests/components/ssdp/test_init.py b/tests/components/ssdp/test_init.py index ef12d2b53f7..86a9b3eea21 100644 --- a/tests/components/ssdp/test_init.py +++ b/tests/components/ssdp/test_init.py @@ -240,6 +240,77 @@ async def test_scan_not_all_match(mock_get_ssdp, hass, aioclient_mock, mock_flow assert not mock_flow_init.mock_calls +@pytest.mark.usefixtures("mock_get_source_ip") +@patch( + "homeassistant.components.ssdp.async_get_ssdp", + return_value={"mock-domain": [{"deviceType": "Paulus"}]}, +) +async def test_flow_start_only_alive( + mock_get_ssdp, hass, aioclient_mock, mock_flow_init +): + """Test config flow is only started for alive devices.""" + aioclient_mock.get( + "http://1.1.1.1", + text=""" + + + Paulus + + + """, + ) + ssdp_listener = await init_ssdp_component(hass) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + # Search should start a flow + mock_ssdp_search_response = _ssdp_headers( + { + "st": "mock-st", + "location": "http://1.1.1.1", + "usn": "uuid:mock-udn::mock-st", + } + ) + await ssdp_listener._on_search(mock_ssdp_search_response) + await hass.async_block_till_done() + + mock_flow_init.assert_awaited_once_with( + "mock-domain", context={"source": config_entries.SOURCE_SSDP}, data=ANY + ) + + # ssdp:alive advertisement should start a flow + mock_flow_init.reset_mock() + mock_ssdp_advertisement = _ssdp_headers( + { + "location": "http://1.1.1.1", + "usn": "uuid:mock-udn::mock-st", + "nt": "upnp:rootdevice", + "nts": "ssdp:alive", + } + ) + await ssdp_listener._on_alive(mock_ssdp_advertisement) + await hass.async_block_till_done() + mock_flow_init.assert_awaited_once_with( + "mock-domain", context={"source": config_entries.SOURCE_SSDP}, data=ANY + ) + + # ssdp:byebye advertisement should not start a flow + mock_flow_init.reset_mock() + mock_ssdp_advertisement["nts"] = "ssdp:byebye" + await ssdp_listener._on_byebye(mock_ssdp_advertisement) + await hass.async_block_till_done() + mock_flow_init.assert_not_called() + + # ssdp:update advertisement should start a flow + mock_flow_init.reset_mock() + mock_ssdp_advertisement["nts"] = "ssdp:update" + await ssdp_listener._on_update(mock_ssdp_advertisement) + await hass.async_block_till_done() + mock_flow_init.assert_awaited_once_with( + "mock-domain", context={"source": config_entries.SOURCE_SSDP}, data=ANY + ) + + @patch( # XXX TODO: Isn't this duplicate with mock_get_source_ip? "homeassistant.components.ssdp.Scanner._async_build_source_set", return_value={IPv4Address("192.168.1.1")}, From e4dc6462375fa0d6d622bb6e5121f803f6a5a39a Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 27 Sep 2021 08:48:21 +0200 Subject: [PATCH 615/843] Upgrade praw to 7.4.0 (#56682) --- homeassistant/components/reddit/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reddit/manifest.json b/homeassistant/components/reddit/manifest.json index 0b5f539bcce..631414ad344 100644 --- a/homeassistant/components/reddit/manifest.json +++ b/homeassistant/components/reddit/manifest.json @@ -2,7 +2,7 @@ "domain": "reddit", "name": "Reddit", "documentation": "https://www.home-assistant.io/integrations/reddit", - "requirements": ["praw==7.2.0"], + "requirements": ["praw==7.4.0"], "codeowners": [], "iot_class": "cloud_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index d8515b0b88d..599c8e83dfa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1239,7 +1239,7 @@ pmsensor==0.4 poolsense==0.0.8 # homeassistant.components.reddit -praw==7.2.0 +praw==7.4.0 # homeassistant.components.islamic_prayer_times prayer_times_calculator==0.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fcea1527eb8..42f2a03343f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -723,7 +723,7 @@ pmsensor==0.4 poolsense==0.0.8 # homeassistant.components.reddit -praw==7.2.0 +praw==7.4.0 # homeassistant.components.islamic_prayer_times prayer_times_calculator==0.0.5 From 14a1bb423c036a285f7570483fcb318910b2440f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 27 Sep 2021 10:47:57 +0200 Subject: [PATCH 616/843] Add is_number template filter and function (#56705) --- homeassistant/helpers/template.py | 13 ++++++++++++ tests/helpers/test_template.py | 35 +++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 3580af3e2bd..94323c14f56 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -1370,6 +1370,17 @@ def forgiving_float(value): return value +def is_number(value): + """Try to convert value to a float.""" + try: + fvalue = float(value) + except (ValueError, TypeError): + return False + if math.isnan(fvalue) or math.isinf(fvalue): + return False + return True + + def regex_match(value, find="", ignorecase=False): """Match value using regex.""" if not isinstance(value, str): @@ -1575,6 +1586,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.filters["bitwise_and"] = bitwise_and self.filters["bitwise_or"] = bitwise_or self.filters["ord"] = ord + self.filters["is_number"] = is_number self.globals["log"] = logarithm self.globals["sin"] = sine self.globals["cos"] = cosine @@ -1597,6 +1609,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["urlencode"] = urlencode self.globals["max"] = max self.globals["min"] = min + self.globals["is_number"] = is_number self.tests["match"] = regex_match self.tests["search"] = regex_search diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index efdcebf70e1..0ac59c68d2d 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -220,6 +220,41 @@ def test_float(hass): ) +@pytest.mark.parametrize( + "value, expected", + [ + (0, True), + (0.0, True), + ("0", True), + ("0.0", True), + (True, True), + (False, True), + ("True", False), + ("False", False), + (None, False), + ("None", False), + ("horse", False), + (math.pi, True), + (math.nan, False), + (math.inf, False), + ("nan", False), + ("inf", False), + ], +) +def test_isnumber(hass, value, expected): + """Test is_number.""" + assert ( + template.Template("{{ is_number(value) }}", hass).async_render({"value": value}) + == expected + ) + assert ( + template.Template("{{ value | is_number }}", hass).async_render( + {"value": value} + ) + == expected + ) + + def test_rounding_value(hass): """Test rounding value.""" hass.states.async_set("sensor.temperature", 12.78) From 4f5d6b8ba1adde381878e88f3fee621b7b882928 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 27 Sep 2021 11:20:43 +0200 Subject: [PATCH 617/843] Upgrade sentry-sdk to 1.4.1 (#56707) --- homeassistant/components/sentry/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sentry/manifest.json b/homeassistant/components/sentry/manifest.json index d6ddf61f19a..8f6ebb57603 100644 --- a/homeassistant/components/sentry/manifest.json +++ b/homeassistant/components/sentry/manifest.json @@ -3,7 +3,7 @@ "name": "Sentry", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sentry", - "requirements": ["sentry-sdk==1.3.0"], + "requirements": ["sentry-sdk==1.4.1"], "codeowners": ["@dcramer", "@frenck"], "iot_class": "cloud_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index 599c8e83dfa..56a792430de 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2128,7 +2128,7 @@ sense-hat==2.2.0 sense_energy==0.9.2 # homeassistant.components.sentry -sentry-sdk==1.3.0 +sentry-sdk==1.4.1 # homeassistant.components.sharkiq sharkiqpy==0.1.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 42f2a03343f..1077c4f6ae1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1209,7 +1209,7 @@ screenlogicpy==0.4.1 sense_energy==0.9.2 # homeassistant.components.sentry -sentry-sdk==1.3.0 +sentry-sdk==1.4.1 # homeassistant.components.sharkiq sharkiqpy==0.1.8 From 56b94d68092357588e46c3099dadc7a0b251e448 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Mon, 27 Sep 2021 03:58:51 -0600 Subject: [PATCH 618/843] Simplify native value property for WattTime (#56664) --- homeassistant/components/watttime/sensor.py | 24 ++++++--------------- 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/watttime/sensor.py b/homeassistant/components/watttime/sensor.py index f44249ecde1..4453044e0d2 100644 --- a/homeassistant/components/watttime/sensor.py +++ b/homeassistant/components/watttime/sensor.py @@ -17,8 +17,9 @@ from homeassistant.const import ( MASS_POUNDS, PERCENTAGE, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -115,20 +116,7 @@ class RealtimeEmissionsSensor(CoordinatorEntity, SensorEntity): self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}" self.entity_description = description - @callback - def _handle_coordinator_update(self) -> None: - """Respond to a DataUpdateCoordinator update.""" - self.update_from_latest_data() - self.async_write_ha_state() - - async def async_added_to_hass(self) -> None: - """Handle entity which will be added.""" - await super().async_added_to_hass() - self.update_from_latest_data() - - @callback - def update_from_latest_data(self) -> None: - """Update the state.""" - self._attr_native_value = self.coordinator.data[ - self.entity_description.data_key - ] + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + return self.coordinator.data[self.entity_description.data_key] From efe467217aee939982da62fe83100d61ed21eb27 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 27 Sep 2021 12:01:17 +0200 Subject: [PATCH 619/843] Don't round in energy cost sensor (#56258) --- homeassistant/components/energy/sensor.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index 387a08141a2..0c4c5eeb3b9 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -221,7 +221,6 @@ class EnergyCostSensor(SensorEntity): self._attr_state_class = STATE_CLASS_TOTAL self._config = config self._last_energy_sensor_state: State | None = None - self._cur_value = 0.0 # add_finished is set when either of async_added_to_hass or add_to_platform_abort # is called self.add_finished = asyncio.Event() @@ -229,7 +228,6 @@ class EnergyCostSensor(SensorEntity): def _reset(self, energy_state: State) -> None: """Reset the cost sensor.""" self._attr_native_value = 0.0 - self._cur_value = 0.0 self._attr_last_reset = dt_util.utcnow() self._last_energy_sensor_state = energy_state self.async_write_ha_state() @@ -339,8 +337,8 @@ class EnergyCostSensor(SensorEntity): self._reset(energy_state_copy) # Update with newly incurred cost old_energy_value = float(self._last_energy_sensor_state.state) - self._cur_value += (energy - old_energy_value) * energy_price - self._attr_native_value = round(self._cur_value, 2) + cur_value = cast(float, self._attr_native_value) + self._attr_native_value = cur_value + (energy - old_energy_value) * energy_price self._last_energy_sensor_state = energy_state From ca6b53c16dd49d8f6ebacfd29cb94e8a2706bf1c Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Mon, 27 Sep 2021 12:04:29 +0200 Subject: [PATCH 620/843] Remove UniFi config entry reference from device when removing last entity of said device (#56501) --- .../components/unifi/unifi_entity_base.py | 23 ++++++++-- tests/components/unifi/test_device_tracker.py | 43 +++++++++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifi/unifi_entity_base.py b/homeassistant/components/unifi/unifi_entity_base.py index 03c63ce4e84..9d2d8071fca 100644 --- a/homeassistant/components/unifi/unifi_entity_base.py +++ b/homeassistant/components/unifi/unifi_entity_base.py @@ -83,6 +83,7 @@ class UniFiBase(Entity): Remove entity if no entry in entity registry exist. Remove entity registry entry if no entry in device registry exist. Remove device registry entry if there is only one linked entity (this entity). + Remove config entry reference from device registry entry if there is more than one config entry. Remove entity registry entry if there are more than one entity linked to the device registry entry. """ if self.key not in keys: @@ -102,17 +103,33 @@ class UniFiBase(Entity): if ( len( - async_entries_for_device( + entries_for_device := async_entries_for_device( entity_registry, entity_entry.device_id, include_disabled_entities=True, ) ) - == 1 - ): + ) == 1: device_registry.async_remove_device(device_entry.id) return + if ( + len( + entries_for_device_from_this_config_entry := [ + entry_for_device + for entry_for_device in entries_for_device + if entry_for_device.config_entry_id + == self.controller.config_entry.entry_id + ] + ) + != len(entries_for_device) + and len(entries_for_device_from_this_config_entry) == 1 + ): + device_registry.async_update_device( + entity_entry.device_id, + remove_config_entry_id=self.controller.config_entry.entry_id, + ) + entity_registry.async_remove(self.entity_id) @property diff --git a/tests/components/unifi/test_device_tracker.py b/tests/components/unifi/test_device_tracker.py index d583cad86c3..4ba690dc444 100644 --- a/tests/components/unifi/test_device_tracker.py +++ b/tests/components/unifi/test_device_tracker.py @@ -381,6 +381,49 @@ async def test_remove_clients(hass, aioclient_mock, mock_unifi_websocket): assert hass.states.get("device_tracker.client_2") +async def test_remove_client_but_keep_device_entry( + hass, aioclient_mock, mock_unifi_websocket +): + """Test that unifi entity base remove config entry id from a multi integration device registry entry.""" + client_1 = { + "essid": "ssid", + "hostname": "client_1", + "is_wired": False, + "last_seen": 1562600145, + "mac": "00:00:00:00:00:01", + } + await setup_unifi_integration(hass, aioclient_mock, clients_response=[client_1]) + + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id="other", + connections={("mac", "00:00:00:00:00:01")}, + ) + + entity_registry = er.async_get(hass) + other_entity = entity_registry.async_get_or_create( + TRACKER_DOMAIN, + "other", + "unique_id", + device_id=device_entry.id, + ) + assert len(device_entry.config_entries) == 2 + + mock_unifi_websocket( + data={ + "meta": {"message": MESSAGE_CLIENT_REMOVED}, + "data": [client_1], + } + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 0 + + device_entry = device_registry.async_get(other_entity.device_id) + assert len(device_entry.config_entries) == 1 + + async def test_controller_state_change(hass, aioclient_mock, mock_unifi_websocket): """Verify entities state reflect on controller becoming unavailable.""" client = { From 931cf4eaabd2774a1fbe1322fa99a2b0b1a14e56 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Mon, 27 Sep 2021 05:07:14 -0500 Subject: [PATCH 621/843] Improve Sonos handling of TuneIn stations (#56479) --- homeassistant/components/sonos/speaker.py | 56 ++++++++++++++--------- 1 file changed, 34 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index 35331dec051..77bb5d0c869 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -1050,25 +1050,32 @@ class SonosSpeaker: def update_media_radio(self, variables: dict | None) -> None: """Update state when streaming radio.""" self.media.clear_position() + radio_title = None - try: - album_art_uri = variables["current_track_meta_data"].album_art_uri - self.media.image_url = self.media.library.build_album_art_full_uri( - album_art_uri - ) - except (TypeError, KeyError, AttributeError): - pass + if current_track_metadata := variables.get("current_track_meta_data"): + if album_art_uri := getattr(current_track_metadata, "album_art_uri", None): + self.media.image_url = self.media.library.build_album_art_full_uri( + album_art_uri + ) + if not self.media.artist: + self.media.artist = getattr(current_track_metadata, "creator", None) - if not self.media.artist: - try: - self.media.artist = variables["current_track_meta_data"].creator - except (TypeError, KeyError, AttributeError): - pass + # A missing artist implies metadata is incomplete, try a different method + if not self.media.artist: + radio_show = None + stream_content = None + if current_track_metadata.radio_show: + radio_show = current_track_metadata.radio_show.split(",")[0] + if not current_track_metadata.stream_content.startswith( + ("ZPSTR_", "TYPE=") + ): + stream_content = current_track_metadata.stream_content + radio_title = " • ".join(filter(None, [radio_show, stream_content])) - # Radios without tagging can have part of the radio URI as title. - # In this case we try to use the radio name instead. - try: - uri_meta_data = variables["enqueued_transport_uri_meta_data"] + if radio_title: + # Prefer the radio title created above + self.media.title = radio_title + elif uri_meta_data := variables.get("enqueued_transport_uri_meta_data"): if isinstance(uri_meta_data, DidlAudioBroadcast) and ( self.soco.music_source_from_uri(self.media.title) == MUSIC_SRC_RADIO or ( @@ -1080,18 +1087,23 @@ class SonosSpeaker: ) ) ): + # Fall back to the radio channel name as a last resort self.media.title = uri_meta_data.title - except (TypeError, KeyError, AttributeError): - pass media_info = self.soco.get_current_media_info() - self.media.channel = media_info["channel"] # Check if currently playing radio station is in favorites - for fav in self.favorites: - if fav.reference.get_uri() == media_info["uri"]: - self.media.source_name = fav.title + fav = next( + ( + fav + for fav in self.favorites + if fav.reference.get_uri() == media_info["uri"] + ), + None, + ) + if fav: + self.media.source_name = fav.title def update_media_music(self, track_info: dict) -> None: """Update state when playing music tracks.""" From 58f465f27119d90fb43f97efc68558630e331fe1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 27 Sep 2021 12:17:09 +0200 Subject: [PATCH 622/843] Don't reset meter when last_reset is set to None (#56609) --- homeassistant/components/sensor/recorder.py | 1 + tests/components/sensor/test_recorder.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index fbf0992573f..69db5be912f 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -445,6 +445,7 @@ def compile_statistics( # noqa: C901 ) ) != old_last_reset + and last_reset is not None ): if old_state is None: _LOGGER.info( diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 609b3576570..0860fbef525 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -494,9 +494,9 @@ def test_compile_hourly_sum_statistics_amount_invalid_last_reset( "min": None, "last_reset": process_timestamp_to_utc_isoformat(dt_util.as_local(one)), "state": approx(factor * seq[7]), - "sum": approx(factor * (sum(seq) - seq[0])), + "sum": approx(factor * (sum(seq) - seq[0] - seq[3])), "sum_decrease": approx(factor * 0.0), - "sum_increase": approx(factor * (sum(seq) - seq[0])), + "sum_increase": approx(factor * (sum(seq) - seq[0] - seq[3])), }, ] } From b612e16120ba06429e9fbb48abd6d8b1b15789af Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 27 Sep 2021 12:23:26 +0200 Subject: [PATCH 623/843] Add current and latest firmware info to Synology_dsm (#56460) --- homeassistant/components/synology_dsm/binary_sensor.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/homeassistant/components/synology_dsm/binary_sensor.py b/homeassistant/components/synology_dsm/binary_sensor.py index fc518d6c662..7f0704790e1 100644 --- a/homeassistant/components/synology_dsm/binary_sensor.py +++ b/homeassistant/components/synology_dsm/binary_sensor.py @@ -1,6 +1,7 @@ """Support for Synology DSM binary sensors.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any from homeassistant.components.binary_sensor import BinarySensorEntity @@ -129,3 +130,11 @@ class SynoDSMUpgradeBinarySensor(SynoDSMBinarySensor): def available(self) -> bool: """Return True if entity is available.""" return bool(self._api.upgrade) + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return firmware details.""" + return { + "installed_version": self._api.information.version_string, + "latest_available_version": self._api.upgrade.available_version, + } From 50f97b26eba340f5df5dca6b581bc38b21059708 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Mon, 27 Sep 2021 12:25:05 +0200 Subject: [PATCH 624/843] Strictly type modbus climate.py (#56380) --- homeassistant/components/modbus/climate.py | 23 +++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index 0a89610a2f5..1b1a09cf9cb 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -1,6 +1,7 @@ """Support for Generic Modbus Thermostats.""" from __future__ import annotations +from datetime import datetime import logging import struct from typing import Any @@ -12,7 +13,6 @@ from homeassistant.components.climate.const import ( ) from homeassistant.const import ( CONF_NAME, - CONF_STRUCTURE, CONF_TEMPERATURE_UNIT, PRECISION_TENTHS, PRECISION_WHOLE, @@ -20,6 +20,7 @@ from homeassistant.const import ( TEMP_FAHRENHEIT, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -50,9 +51,9 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - async_add_entities, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, -): +) -> None: """Read configuration and create Modbus climate.""" if discovery_info is None: return @@ -77,7 +78,6 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): super().__init__(hub, config) self._target_temperature_register = config[CONF_TARGET_TEMP] self._unit = config[CONF_TEMPERATURE_UNIT] - self._structure = config[CONF_STRUCTURE] self._attr_supported_features = SUPPORT_TARGET_TEMPERATURE self._attr_hvac_mode = HVAC_MODE_AUTO @@ -95,7 +95,7 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): self._attr_target_temperature_step = config[CONF_TARGET_TEMP] self._attr_target_temperature_step = config[CONF_STEP] - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await self.async_base_added_to_hass() state = await self.async_get_last_state() @@ -107,12 +107,12 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): # Home Assistant expects this method. # We'll keep it here to avoid getting exceptions. - async def async_set_temperature(self, **kwargs): + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" if ATTR_TEMPERATURE not in kwargs: return target_temperature = ( - float(kwargs.get(ATTR_TEMPERATURE)) - self._offset + float(kwargs[ATTR_TEMPERATURE]) - self._offset ) / self._scale if self._data_type in ( DATA_TYPE_INT16, @@ -138,7 +138,7 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): self._attr_available = result is not None await self.async_update() - async def async_update(self, now=None): + async def async_update(self, now: datetime | None = None) -> None: """Update Target & Current Temperature.""" # remark "now" is a dummy parameter to avoid problems with # async_track_time_interval @@ -156,7 +156,9 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): self._call_active = False self.async_write_ha_state() - async def _async_read_register(self, register_type, register) -> float | None: + async def _async_read_register( + self, register_type: str, register: int + ) -> float | None: """Read register using the Modbus hub slave.""" result = await self._hub.async_pymodbus_call( self._slave, register, self._count, register_type @@ -172,7 +174,6 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): self._lazy_errors = self._lazy_error_count self._value = self.unpack_structure_result(result.registers) self._attr_available = True - - if self._value is None: + if not self._value: return None return float(self._value) From 6da548b56a0dc5366af214d383f3d3a268798dc6 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Mon, 27 Sep 2021 12:26:25 +0200 Subject: [PATCH 625/843] Strictly type modbus cover.py (#56381) --- homeassistant/components/modbus/cover.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/modbus/cover.py b/homeassistant/components/modbus/cover.py index ca4f25ca1cc..805f07bad40 100644 --- a/homeassistant/components/modbus/cover.py +++ b/homeassistant/components/modbus/cover.py @@ -1,6 +1,7 @@ """Support for Modbus covers.""" from __future__ import annotations +from datetime import datetime import logging from typing import Any @@ -16,6 +17,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -41,9 +43,9 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - async_add_entities, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, -): +) -> None: """Read configuration and create Modbus cover.""" if discovery_info is None: # pragma: no cover return @@ -97,7 +99,7 @@ class ModbusCover(BasePlatform, CoverEntity, RestoreEntity): self._address = self._status_register self._input_type = self._status_register_type - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await self.async_base_added_to_hass() state = await self.async_get_last_state() @@ -112,7 +114,7 @@ class ModbusCover(BasePlatform, CoverEntity, RestoreEntity): } self._set_attr_state(convert[state.state]) - def _set_attr_state(self, value): + def _set_attr_state(self, value: str | bool | int) -> None: """Convert received value to HA state.""" self._attr_is_opening = value == self._state_opening self._attr_is_closing = value == self._state_closing @@ -134,7 +136,7 @@ class ModbusCover(BasePlatform, CoverEntity, RestoreEntity): self._attr_available = result is not None await self.async_update() - async def async_update(self, now=None): + async def async_update(self, now: datetime | None = None) -> None: """Update the state of the cover.""" # remark "now" is a dummy parameter to avoid problems with # async_track_time_interval From f2debf5c0122fca25476eacfdde7ed42df023421 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 27 Sep 2021 12:27:03 +0200 Subject: [PATCH 626/843] Remove unnecessary extra attribute from Pi-hole sensors (#56076) --- homeassistant/components/pi_hole/const.py | 2 -- homeassistant/components/pi_hole/sensor.py | 6 ------ 2 files changed, 8 deletions(-) diff --git a/homeassistant/components/pi_hole/const.py b/homeassistant/components/pi_hole/const.py index f1ec1c6efd6..37167cb873a 100644 --- a/homeassistant/components/pi_hole/const.py +++ b/homeassistant/components/pi_hole/const.py @@ -22,8 +22,6 @@ DEFAULT_STATISTICS_ONLY = True SERVICE_DISABLE = "disable" SERVICE_DISABLE_ATTR_DURATION = "duration" -ATTR_BLOCKED_DOMAINS = "domains_blocked" - MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) DATA_KEY_API = "api" diff --git a/homeassistant/components/pi_hole/sensor.py b/homeassistant/components/pi_hole/sensor.py index 0e231868647..656bd8a652b 100644 --- a/homeassistant/components/pi_hole/sensor.py +++ b/homeassistant/components/pi_hole/sensor.py @@ -14,7 +14,6 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import PiHoleEntity from .const import ( - ATTR_BLOCKED_DOMAINS, DATA_KEY_API, DATA_KEY_COORDINATOR, DOMAIN as PIHOLE_DOMAIN, @@ -69,8 +68,3 @@ class PiHoleSensor(PiHoleEntity, SensorEntity): return round(self.api.data[self.entity_description.key], 2) except TypeError: return self.api.data[self.entity_description.key] - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return the state attributes of the Pi-hole.""" - return {ATTR_BLOCKED_DOMAINS: self.api.data["domains_being_blocked"]} From 83b1b3e92c75584fa70693e8bd14e69c6118a808 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 27 Sep 2021 12:28:58 +0200 Subject: [PATCH 627/843] Use EntityDescription - tellduslive (#55928) --- .../components/tellduslive/sensor.py | 124 +++++++++++------- 1 file changed, 79 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/tellduslive/sensor.py b/homeassistant/components/tellduslive/sensor.py index 35fc6809523..729b6052507 100644 --- a/homeassistant/components/tellduslive/sensor.py +++ b/homeassistant/components/tellduslive/sensor.py @@ -1,6 +1,8 @@ """Support for Tellstick Net/Telstick Live sensors.""" +from __future__ import annotations + from homeassistant.components import sensor, tellduslive -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import ( DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, @@ -31,29 +33,72 @@ SENSOR_TYPE_LUMINANCE = "lum" SENSOR_TYPE_DEW_POINT = "dewp" SENSOR_TYPE_BAROMETRIC_PRESSURE = "barpress" -SENSOR_TYPES = { - SENSOR_TYPE_TEMPERATURE: [ - "Temperature", - TEMP_CELSIUS, - None, - DEVICE_CLASS_TEMPERATURE, - ], - SENSOR_TYPE_HUMIDITY: ["Humidity", PERCENTAGE, None, DEVICE_CLASS_HUMIDITY], - SENSOR_TYPE_RAINRATE: [ - "Rain rate", - PRECIPITATION_MILLIMETERS_PER_HOUR, - "mdi:water", - None, - ], - SENSOR_TYPE_RAINTOTAL: ["Rain total", LENGTH_MILLIMETERS, "mdi:water", None], - SENSOR_TYPE_WINDDIRECTION: ["Wind direction", "", "", None], - SENSOR_TYPE_WINDAVERAGE: ["Wind average", SPEED_METERS_PER_SECOND, "", None], - SENSOR_TYPE_WINDGUST: ["Wind gust", SPEED_METERS_PER_SECOND, "", None], - SENSOR_TYPE_UV: ["UV", UV_INDEX, "", None], - SENSOR_TYPE_WATT: ["Power", POWER_WATT, "", None], - SENSOR_TYPE_LUMINANCE: ["Luminance", LIGHT_LUX, None, DEVICE_CLASS_ILLUMINANCE], - SENSOR_TYPE_DEW_POINT: ["Dew Point", TEMP_CELSIUS, None, DEVICE_CLASS_TEMPERATURE], - SENSOR_TYPE_BAROMETRIC_PRESSURE: ["Barometric Pressure", "kPa", "", None], +SENSOR_TYPES: dict[str, SensorEntityDescription] = { + SENSOR_TYPE_TEMPERATURE: SensorEntityDescription( + key=SENSOR_TYPE_TEMPERATURE, + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SENSOR_TYPE_HUMIDITY: SensorEntityDescription( + key=SENSOR_TYPE_HUMIDITY, + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SENSOR_TYPE_RAINRATE: SensorEntityDescription( + key=SENSOR_TYPE_RAINRATE, + name="Rain rate", + native_unit_of_measurement=PRECIPITATION_MILLIMETERS_PER_HOUR, + icon="mdi:water", + ), + SENSOR_TYPE_RAINTOTAL: SensorEntityDescription( + key=SENSOR_TYPE_RAINTOTAL, + name="Rain total", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:water", + ), + SENSOR_TYPE_WINDDIRECTION: SensorEntityDescription( + key=SENSOR_TYPE_WINDDIRECTION, + name="Wind direction", + ), + SENSOR_TYPE_WINDAVERAGE: SensorEntityDescription( + key=SENSOR_TYPE_WINDAVERAGE, + name="Wind average", + native_unit_of_measurement=SPEED_METERS_PER_SECOND, + ), + SENSOR_TYPE_WINDGUST: SensorEntityDescription( + key=SENSOR_TYPE_WINDGUST, + name="Wind gust", + native_unit_of_measurement=SPEED_METERS_PER_SECOND, + ), + SENSOR_TYPE_UV: SensorEntityDescription( + key=SENSOR_TYPE_UV, + name="UV", + native_unit_of_measurement=UV_INDEX, + ), + SENSOR_TYPE_WATT: SensorEntityDescription( + key=SENSOR_TYPE_WATT, + name="Power", + native_unit_of_measurement=POWER_WATT, + ), + SENSOR_TYPE_LUMINANCE: SensorEntityDescription( + key=SENSOR_TYPE_LUMINANCE, + name="Luminance", + native_unit_of_measurement=LIGHT_LUX, + device_class=DEVICE_CLASS_ILLUMINANCE, + ), + SENSOR_TYPE_DEW_POINT: SensorEntityDescription( + key=SENSOR_TYPE_DEW_POINT, + name="Dew Point", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SENSOR_TYPE_BAROMETRIC_PRESSURE: SensorEntityDescription( + key=SENSOR_TYPE_BAROMETRIC_PRESSURE, + name="Barometric Pressure", + native_unit_of_measurement="kPa", + ), } @@ -75,6 +120,12 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class TelldusLiveSensor(TelldusLiveEntity, SensorEntity): """Representation of a Telldus Live sensor.""" + def __init__(self, client, device_id): + """Initialize TelldusLiveSensor.""" + super().__init__(client, device_id) + if desc := SENSOR_TYPES.get(self._type): + self.entity_description = desc + @property def device_id(self): """Return id of the device.""" @@ -108,7 +159,10 @@ class TelldusLiveSensor(TelldusLiveEntity, SensorEntity): @property def name(self): """Return the name of the sensor.""" - return "{} {}".format(super().name, self.quantity_name or "").strip() + quantity_name = ( + self.entity_description.name if hasattr(self, "entity_description") else "" + ) + return "{} {}".format(super().name, quantity_name or "").strip() @property def native_value(self): @@ -123,26 +177,6 @@ class TelldusLiveSensor(TelldusLiveEntity, SensorEntity): return self._value_as_luminance return self._value - @property - def quantity_name(self): - """Name of quantity.""" - return SENSOR_TYPES[self._type][0] if self._type in SENSOR_TYPES else None - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement.""" - return SENSOR_TYPES[self._type][1] if self._type in SENSOR_TYPES else None - - @property - def icon(self): - """Return the icon.""" - return SENSOR_TYPES[self._type][2] if self._type in SENSOR_TYPES else None - - @property - def device_class(self): - """Return the device class.""" - return SENSOR_TYPES[self._type][3] if self._type in SENSOR_TYPES else None - @property def unique_id(self) -> str: """Return a unique ID.""" From 4ce7166afd45cbc405b467d0a87f612e3c28095d Mon Sep 17 00:00:00 2001 From: Marius <33354141+Mariusthvdb@users.noreply.github.com> Date: Mon, 27 Sep 2021 12:50:14 +0200 Subject: [PATCH 628/843] Add node sensor status icons (#56137) Co-authored-by: kpine Co-authored-by: Raman Gupta <7243222+raman325@users.noreply.github.com> --- homeassistant/components/zwave_js/sensor.py | 15 ++++++++++++++- tests/components/zwave_js/test_sensor.py | 7 +++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 944c6979298..7da7ba9ab9b 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -8,7 +8,7 @@ from typing import cast import voluptuous as vol from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const import CommandClass, ConfigurationValueType +from zwave_js_server.const import CommandClass, ConfigurationValueType, NodeStatus from zwave_js_server.const.command_class.meter import ( RESET_METER_OPTION_TARGET_VALUE, RESET_METER_OPTION_TYPE, @@ -80,6 +80,14 @@ from .helpers import get_device_id LOGGER = logging.getLogger(__name__) +STATUS_ICON: dict[NodeStatus, str] = { + NodeStatus.ALIVE: "mdi:heart-pulse", + NodeStatus.ASLEEP: "mdi:sleep", + NodeStatus.AWAKE: "mdi:eye", + NodeStatus.DEAD: "mdi:robot-dead", + NodeStatus.UNKNOWN: "mdi:help-rhombus", +} + @dataclass class ZwaveSensorEntityDescription(SensorEntityDescription): @@ -480,6 +488,11 @@ class ZWaveNodeStatusSensor(SensorEntity): self._attr_native_value = self.node.status.name.lower() self.async_write_ha_state() + @property + def icon(self) -> str | None: + """Icon of the entity.""" + return STATUS_ICON[self.node.status] + async def async_added_to_hass(self) -> None: """Call when entity is added.""" # Add value_changed callbacks. diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index fe17b071175..a18bead36c2 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -20,6 +20,7 @@ from homeassistant.components.zwave_js.const import ( from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, + ATTR_ICON, DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, DEVICE_CLASS_HUMIDITY, @@ -168,24 +169,30 @@ async def test_node_status_sensor(hass, client, lock_id_lock_as_id150, integrati ) node.receive_event(event) assert hass.states.get(NODE_STATUS_ENTITY).state == "dead" + assert hass.states.get(NODE_STATUS_ENTITY).attributes[ATTR_ICON] == "mdi:robot-dead" event = Event( "wake up", data={"source": "node", "event": "wake up", "nodeId": node.node_id} ) node.receive_event(event) assert hass.states.get(NODE_STATUS_ENTITY).state == "awake" + assert hass.states.get(NODE_STATUS_ENTITY).attributes[ATTR_ICON] == "mdi:eye" event = Event( "sleep", data={"source": "node", "event": "sleep", "nodeId": node.node_id} ) node.receive_event(event) assert hass.states.get(NODE_STATUS_ENTITY).state == "asleep" + assert hass.states.get(NODE_STATUS_ENTITY).attributes[ATTR_ICON] == "mdi:sleep" event = Event( "alive", data={"source": "node", "event": "alive", "nodeId": node.node_id} ) node.receive_event(event) assert hass.states.get(NODE_STATUS_ENTITY).state == "alive" + assert ( + hass.states.get(NODE_STATUS_ENTITY).attributes[ATTR_ICON] == "mdi:heart-pulse" + ) # Disconnect the client and make sure the entity is still available await client.disconnect() From 4d433e18ac501f5465fa78ba7cb134bd6c8c90ee Mon Sep 17 00:00:00 2001 From: Fredrik Oterholt Date: Mon, 27 Sep 2021 13:27:02 +0200 Subject: [PATCH 629/843] Add more sensor types for airthings devices (#56706) * add additional sensor types for airthings devices * remove "out of ten" unit * change unit on rssi * remove device class for light * disable by default --- homeassistant/components/airthings/sensor.py | 37 ++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/homeassistant/components/airthings/sensor.py b/homeassistant/components/airthings/sensor.py index 7ec273c0549..4aab2307d9a 100644 --- a/homeassistant/components/airthings/sensor.py +++ b/homeassistant/components/airthings/sensor.py @@ -11,15 +11,20 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, DEVICE_CLASS_BATTERY, DEVICE_CLASS_CO2, DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_PM1, + DEVICE_CLASS_PM25, DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_SIGNAL_STRENGTH, DEVICE_CLASS_TEMPERATURE, PERCENTAGE, PRESSURE_MBAR, + SIGNAL_STRENGTH_DECIBELS, TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant @@ -72,6 +77,38 @@ SENSORS: dict[str, SensorEntityDescription] = { native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, name="VOC", ), + "light": SensorEntityDescription( + key="light", + native_unit_of_measurement=PERCENTAGE, + name="Light", + ), + "virusRisk": SensorEntityDescription( + key="virusRisk", + name="Virus Risk", + ), + "mold": SensorEntityDescription( + key="mold", + name="Mold", + ), + "rssi": SensorEntityDescription( + key="rssi", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + name="RSSI", + entity_registry_enabled_default=False, + ), + "pm1": SensorEntityDescription( + key="pm1", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=DEVICE_CLASS_PM1, + name="PM1", + ), + "pm25": SensorEntityDescription( + key="pm25", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=DEVICE_CLASS_PM25, + name="PM25", + ), } From 70cc6295b5fd21ac59f10967822bef3d264b132d Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 27 Sep 2021 17:35:09 +0200 Subject: [PATCH 630/843] Use EntityDescription - hydrawise (#55924) --- .../components/hydrawise/__init__.py | 60 ++------------- .../components/hydrawise/binary_sensor.py | 77 +++++++++++++------ homeassistant/components/hydrawise/sensor.py | 71 ++++++++++------- homeassistant/components/hydrawise/switch.py | 75 +++++++++++------- 4 files changed, 148 insertions(+), 135 deletions(-) diff --git a/homeassistant/components/hydrawise/__init__.py b/homeassistant/components/hydrawise/__init__.py index 1f1b2c03157..56ebdc0d88c 100644 --- a/homeassistant/components/hydrawise/__init__.py +++ b/homeassistant/components/hydrawise/__init__.py @@ -6,22 +6,11 @@ from hydrawiser.core import Hydrawiser from requests.exceptions import ConnectTimeout, HTTPError import voluptuous as vol -from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_CONNECTIVITY, - DEVICE_CLASS_MOISTURE, -) -from homeassistant.components.sensor import DEVICE_CLASS_TIMESTAMP -from homeassistant.components.switch import DEVICE_CLASS_SWITCH -from homeassistant.const import ( - ATTR_ATTRIBUTION, - CONF_ACCESS_TOKEN, - CONF_SCAN_INTERVAL, - TIME_MINUTES, -) +from homeassistant.const import ATTR_ATTRIBUTION, CONF_ACCESS_TOKEN, CONF_SCAN_INTERVAL from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.event import track_time_interval _LOGGER = logging.getLogger(__name__) @@ -39,27 +28,6 @@ DATA_HYDRAWISE = "hydrawise" DOMAIN = "hydrawise" DEFAULT_WATERING_TIME = 15 -DEVICE_MAP_INDEX = [ - "KEY_INDEX", - "ICON_INDEX", - "DEVICE_CLASS_INDEX", - "UNIT_OF_MEASURE_INDEX", -] -DEVICE_MAP = { - "auto_watering": ["Automatic Watering", None, DEVICE_CLASS_SWITCH, None], - "is_watering": ["Watering", None, DEVICE_CLASS_MOISTURE, None], - "manual_watering": ["Manual Watering", None, DEVICE_CLASS_SWITCH, None], - "next_cycle": ["Next Cycle", None, DEVICE_CLASS_TIMESTAMP, None], - "status": ["Status", None, DEVICE_CLASS_CONNECTIVITY, None], - "watering_time": ["Watering Time", "mdi:water-pump", None, TIME_MINUTES], -} - -BINARY_SENSORS = ["is_watering", "status"] - -SENSORS = ["next_cycle", "watering_time"] - -SWITCHES = ["auto_watering", "manual_watering"] - SCAN_INTERVAL = timedelta(seconds=30) SIGNAL_UPDATE_HYDRAWISE = "hydrawise_update" @@ -118,17 +86,11 @@ class HydrawiseHub: class HydrawiseEntity(Entity): """Entity class for Hydrawise devices.""" - def __init__(self, data, sensor_type): + def __init__(self, data, description: EntityDescription): """Initialize the Hydrawise entity.""" + self.entity_description = description self.data = data - self._sensor_type = sensor_type - self._name = f"{self.data['name']} {DEVICE_MAP[self._sensor_type][DEVICE_MAP_INDEX.index('KEY_INDEX')]}" - self._state = None - - @property - def name(self): - """Return the name of the sensor.""" - return self._name + self._attr_name = f"{self.data['name']} {description.name}" async def async_added_to_hass(self): """Register callbacks.""" @@ -147,15 +109,3 @@ class HydrawiseEntity(Entity): def extra_state_attributes(self): """Return the state attributes.""" return {ATTR_ATTRIBUTION: ATTRIBUTION, "identifier": self.data.get("relay")} - - @property - def device_class(self): - """Return the device class of the sensor type.""" - return DEVICE_MAP[self._sensor_type][ - DEVICE_MAP_INDEX.index("DEVICE_CLASS_INDEX") - ] - - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return DEVICE_MAP[self._sensor_type][DEVICE_MAP_INDEX.index("ICON_INDEX")] diff --git a/homeassistant/components/hydrawise/binary_sensor.py b/homeassistant/components/hydrawise/binary_sensor.py index e39ffce73a9..7a673a1e7ae 100644 --- a/homeassistant/components/hydrawise/binary_sensor.py +++ b/homeassistant/components/hydrawise/binary_sensor.py @@ -1,20 +1,46 @@ """Support for Hydrawise sprinkler binary sensors.""" +from __future__ import annotations + import logging import voluptuous as vol -from homeassistant.components.binary_sensor import PLATFORM_SCHEMA, BinarySensorEntity +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_CONNECTIVITY, + DEVICE_CLASS_MOISTURE, + PLATFORM_SCHEMA, + BinarySensorEntity, + BinarySensorEntityDescription, +) from homeassistant.const import CONF_MONITORED_CONDITIONS import homeassistant.helpers.config_validation as cv -from . import BINARY_SENSORS, DATA_HYDRAWISE, HydrawiseEntity +from . import DATA_HYDRAWISE, HydrawiseEntity _LOGGER = logging.getLogger(__name__) +BINARY_SENSOR_STATUS = BinarySensorEntityDescription( + key="status", + name="Status", + device_class=DEVICE_CLASS_CONNECTIVITY, +) + +BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( + BinarySensorEntityDescription( + key="is_watering", + name="Watering", + device_class=DEVICE_CLASS_MOISTURE, + ), +) + +BINARY_SENSOR_KEYS: list[str] = [ + desc.key for desc in (BINARY_SENSOR_STATUS, *BINARY_SENSOR_TYPES) +] + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { - vol.Optional(CONF_MONITORED_CONDITIONS, default=BINARY_SENSORS): vol.All( - cv.ensure_list, [vol.In(BINARY_SENSORS)] + vol.Optional(CONF_MONITORED_CONDITIONS, default=BINARY_SENSOR_KEYS): vol.All( + cv.ensure_list, [vol.In(BINARY_SENSOR_KEYS)] ) } ) @@ -23,35 +49,36 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( def setup_platform(hass, config, add_entities, discovery_info=None): """Set up a sensor for a Hydrawise device.""" hydrawise = hass.data[DATA_HYDRAWISE].data + monitored_conditions = config[CONF_MONITORED_CONDITIONS] - sensors = [] - for sensor_type in config.get(CONF_MONITORED_CONDITIONS): - if sensor_type == "status": - sensors.append( - HydrawiseBinarySensor(hydrawise.current_controller, sensor_type) - ) - else: - # create a sensor for each zone - for zone in hydrawise.relays: - sensors.append(HydrawiseBinarySensor(zone, sensor_type)) + entities = [] + if BINARY_SENSOR_STATUS.key in monitored_conditions: + entities.append( + HydrawiseBinarySensor(hydrawise.current_controller, BINARY_SENSOR_STATUS) + ) - add_entities(sensors, True) + # create a sensor for each zone + entities.extend( + [ + HydrawiseBinarySensor(zone, description) + for zone in hydrawise.relays + for description in BINARY_SENSOR_TYPES + if description.key in monitored_conditions + ] + ) + + add_entities(entities, True) class HydrawiseBinarySensor(HydrawiseEntity, BinarySensorEntity): """A sensor implementation for Hydrawise device.""" - @property - def is_on(self): - """Return true if the binary sensor is on.""" - return self._state - def update(self): """Get the latest data and updates the state.""" - _LOGGER.debug("Updating Hydrawise binary sensor: %s", self._name) + _LOGGER.debug("Updating Hydrawise binary sensor: %s", self.name) mydata = self.hass.data[DATA_HYDRAWISE].data - if self._sensor_type == "status": - self._state = mydata.status == "All good!" - elif self._sensor_type == "is_watering": + if self.entity_description.key == "status": + self._attr_is_on = mydata.status == "All good!" + elif self.entity_description.key == "is_watering": relay_data = mydata.relays[self.data["relay"] - 1] - self._state = relay_data["timestr"] == "Now" + self._attr_is_on = relay_data["timestr"] == "Now" diff --git a/homeassistant/components/hydrawise/sensor.py b/homeassistant/components/hydrawise/sensor.py index 0e9afb6d729..f8c02309569 100644 --- a/homeassistant/components/hydrawise/sensor.py +++ b/homeassistant/components/hydrawise/sensor.py @@ -1,21 +1,47 @@ """Support for Hydrawise sprinkler sensors.""" +from __future__ import annotations + import logging import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import CONF_MONITORED_CONDITIONS +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.const import ( + CONF_MONITORED_CONDITIONS, + DEVICE_CLASS_TIMESTAMP, + TIME_MINUTES, +) import homeassistant.helpers.config_validation as cv from homeassistant.util import dt -from . import DATA_HYDRAWISE, DEVICE_MAP, DEVICE_MAP_INDEX, SENSORS, HydrawiseEntity +from . import DATA_HYDRAWISE, HydrawiseEntity _LOGGER = logging.getLogger(__name__) +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="next_cycle", + name="Next Cycle", + device_class=DEVICE_CLASS_TIMESTAMP, + ), + SensorEntityDescription( + key="watering_time", + name="Watering Time", + icon="mdi:water-pump", + native_unit_of_measurement=TIME_MINUTES, + ), +) + +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { - vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSORS): vol.All( - cv.ensure_list, [vol.In(SENSORS)] + vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSOR_KEYS): vol.All( + cv.ensure_list, [vol.In(SENSOR_KEYS)] ) } ) @@ -27,43 +53,34 @@ WATERING_TIME_ICON = "mdi:water-pump" def setup_platform(hass, config, add_entities, discovery_info=None): """Set up a sensor for a Hydrawise device.""" hydrawise = hass.data[DATA_HYDRAWISE].data + monitored_conditions = config[CONF_MONITORED_CONDITIONS] - sensors = [] - for sensor_type in config.get(CONF_MONITORED_CONDITIONS): - for zone in hydrawise.relays: - sensors.append(HydrawiseSensor(zone, sensor_type)) + entities = [ + HydrawiseSensor(zone, description) + for zone in hydrawise.relays + for description in SENSOR_TYPES + if description.key in monitored_conditions + ] - add_entities(sensors, True) + add_entities(entities, True) class HydrawiseSensor(HydrawiseEntity, SensorEntity): """A sensor implementation for Hydrawise device.""" - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def native_unit_of_measurement(self): - """Return the units of measurement.""" - return DEVICE_MAP[self._sensor_type][ - DEVICE_MAP_INDEX.index("UNIT_OF_MEASURE_INDEX") - ] - def update(self): """Get the latest data and updates the states.""" mydata = self.hass.data[DATA_HYDRAWISE].data - _LOGGER.debug("Updating Hydrawise sensor: %s", self._name) + _LOGGER.debug("Updating Hydrawise sensor: %s", self.name) relay_data = mydata.relays[self.data["relay"] - 1] - if self._sensor_type == "watering_time": + if self.entity_description.key == "watering_time": if relay_data["timestr"] == "Now": - self._state = int(relay_data["run"] / 60) + self._attr_native_value = int(relay_data["run"] / 60) else: - self._state = 0 + self._attr_native_value = 0 else: # _sensor_type == 'next_cycle' next_cycle = min(relay_data["time"], TWO_YEAR_SECONDS) _LOGGER.debug("New cycle time: %s", next_cycle) - self._state = dt.utc_from_timestamp( + self._attr_native_value = dt.utc_from_timestamp( dt.as_timestamp(dt.now()) + next_cycle ).isoformat() diff --git a/homeassistant/components/hydrawise/switch.py b/homeassistant/components/hydrawise/switch.py index a385e504d7f..8b3707ad5a0 100644 --- a/homeassistant/components/hydrawise/switch.py +++ b/homeassistant/components/hydrawise/switch.py @@ -1,9 +1,16 @@ """Support for Hydrawise cloud switches.""" +from __future__ import annotations + import logging import voluptuous as vol -from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchEntity +from homeassistant.components.switch import ( + DEVICE_CLASS_SWITCH, + PLATFORM_SCHEMA, + SwitchEntity, + SwitchEntityDescription, +) from homeassistant.const import CONF_MONITORED_CONDITIONS import homeassistant.helpers.config_validation as cv @@ -12,16 +19,30 @@ from . import ( CONF_WATERING_TIME, DATA_HYDRAWISE, DEFAULT_WATERING_TIME, - SWITCHES, HydrawiseEntity, ) _LOGGER = logging.getLogger(__name__) +SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( + SwitchEntityDescription( + key="auto_watering", + name="Automatic Watering", + device_class=DEVICE_CLASS_SWITCH, + ), + SwitchEntityDescription( + key="manual_watering", + name="Manual Watering", + device_class=DEVICE_CLASS_SWITCH, + ), +) + +SWITCH_KEYS: list[str] = [desc.key for desc in SWITCH_TYPES] + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { - vol.Optional(CONF_MONITORED_CONDITIONS, default=SWITCHES): vol.All( - cv.ensure_list, [vol.In(SWITCHES)] + vol.Optional(CONF_MONITORED_CONDITIONS, default=SWITCH_KEYS): vol.All( + cv.ensure_list, [vol.In(SWITCH_KEYS)] ), vol.Optional(CONF_WATERING_TIME, default=DEFAULT_WATERING_TIME): vol.All( vol.In(ALLOWED_WATERING_TIME) @@ -33,57 +54,55 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( def setup_platform(hass, config, add_entities, discovery_info=None): """Set up a sensor for a Hydrawise device.""" hydrawise = hass.data[DATA_HYDRAWISE].data + monitored_conditions = config[CONF_MONITORED_CONDITIONS] + default_watering_timer = config[CONF_WATERING_TIME] - default_watering_timer = config.get(CONF_WATERING_TIME) + entities = [ + HydrawiseSwitch(zone, description, default_watering_timer) + for zone in hydrawise.relays + for description in SWITCH_TYPES + if description.key in monitored_conditions + ] - sensors = [] - for sensor_type in config.get(CONF_MONITORED_CONDITIONS): - # Create a switch for each zone - for zone in hydrawise.relays: - sensors.append(HydrawiseSwitch(default_watering_timer, zone, sensor_type)) - - add_entities(sensors, True) + add_entities(entities, True) class HydrawiseSwitch(HydrawiseEntity, SwitchEntity): """A switch implementation for Hydrawise device.""" - def __init__(self, default_watering_timer, *args): + def __init__( + self, data, description: SwitchEntityDescription, default_watering_timer + ): """Initialize a switch for Hydrawise device.""" - super().__init__(*args) + super().__init__(data, description) self._default_watering_timer = default_watering_timer - @property - def is_on(self): - """Return true if device is on.""" - return self._state - def turn_on(self, **kwargs): """Turn the device on.""" relay_data = self.data["relay"] - 1 - if self._sensor_type == "manual_watering": + if self.entity_description.key == "manual_watering": self.hass.data[DATA_HYDRAWISE].data.run_zone( self._default_watering_timer, relay_data ) - elif self._sensor_type == "auto_watering": + elif self.entity_description.key == "auto_watering": self.hass.data[DATA_HYDRAWISE].data.suspend_zone(0, relay_data) def turn_off(self, **kwargs): """Turn the device off.""" relay_data = self.data["relay"] - 1 - if self._sensor_type == "manual_watering": + if self.entity_description.key == "manual_watering": self.hass.data[DATA_HYDRAWISE].data.run_zone(0, relay_data) - elif self._sensor_type == "auto_watering": + elif self.entity_description.key == "auto_watering": self.hass.data[DATA_HYDRAWISE].data.suspend_zone(365, relay_data) def update(self): """Update device state.""" relay_data = self.data["relay"] - 1 mydata = self.hass.data[DATA_HYDRAWISE].data - _LOGGER.debug("Updating Hydrawise switch: %s", self._name) - if self._sensor_type == "manual_watering": - self._state = mydata.relays[relay_data]["timestr"] == "Now" - elif self._sensor_type == "auto_watering": - self._state = (mydata.relays[relay_data]["timestr"] != "") and ( + _LOGGER.debug("Updating Hydrawise switch: %s", self.name) + if self.entity_description.key == "manual_watering": + self._attr_is_on = mydata.relays[relay_data]["timestr"] == "Now" + elif self.entity_description.key == "auto_watering": + self._attr_is_on = (mydata.relays[relay_data]["timestr"] != "") and ( mydata.relays[relay_data]["timestr"] != "Now" ) From 805e73f78c8dc1ab77565c5cee6946df1193fc2c Mon Sep 17 00:00:00 2001 From: Michael Chisholm Date: Tue, 28 Sep 2021 01:36:47 +1000 Subject: [PATCH 631/843] Add UPNP device connection for Sonos (#56702) --- homeassistant/components/sonos/entity.py | 5 ++++- homeassistant/components/sonos/speaker.py | 1 + tests/components/sonos/conftest.py | 1 + tests/components/sonos/test_media_player.py | 5 ++++- 4 files changed, 10 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sonos/entity.py b/homeassistant/components/sonos/entity.py index dadec82a939..5730679dbd9 100644 --- a/homeassistant/components/sonos/entity.py +++ b/homeassistant/components/sonos/entity.py @@ -95,7 +95,10 @@ class SonosEntity(Entity): "name": self.speaker.zone_name, "model": self.speaker.model_name.replace("Sonos ", ""), "sw_version": self.speaker.version, - "connections": {(dr.CONNECTION_NETWORK_MAC, self.speaker.mac_address)}, + "connections": { + (dr.CONNECTION_NETWORK_MAC, self.speaker.mac_address), + (dr.CONNECTION_UPNP, f"uuid:{self.speaker.uid}"), + }, "manufacturer": "Sonos", "suggested_area": self.speaker.zone_name, } diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index 77bb5d0c869..b47d1444384 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -177,6 +177,7 @@ class SonosSpeaker: # Device information self.mac_address = speaker_info["mac_address"] self.model_name = speaker_info["model_name"] + self.uid = speaker_info["uid"] self.version = speaker_info["display_version"] self.zone_name = speaker_info["zone_name"] diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index b81934e2593..d970c8923ef 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -160,6 +160,7 @@ def speaker_info_fixture(): """Create speaker_info fixture.""" return { "zone_name": "Zone A", + "uid": "RINCON_test", "model_name": "Model Name", "software_version": "49.2-64250", "mac_address": "00-11-22-33-44-55", diff --git a/tests/components/sonos/test_media_player.py b/tests/components/sonos/test_media_player.py index 0e4af2071b2..4de0f37d333 100644 --- a/tests/components/sonos/test_media_player.py +++ b/tests/components/sonos/test_media_player.py @@ -77,7 +77,10 @@ async def test_device_registry(hass, config_entry, config, soco): ) assert reg_device.model == "Model Name" assert reg_device.sw_version == "13.1" - assert reg_device.connections == {(dr.CONNECTION_NETWORK_MAC, "00:11:22:33:44:55")} + assert reg_device.connections == { + (dr.CONNECTION_NETWORK_MAC, "00:11:22:33:44:55"), + (dr.CONNECTION_UPNP, "uuid:RINCON_test"), + } assert reg_device.manufacturer == "Sonos" assert reg_device.suggested_area == "Zone A" assert reg_device.name == "Zone A" From eae828a15a9a0171e903d8b221a0fce01b7eeb0b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 27 Sep 2021 17:42:13 +0200 Subject: [PATCH 632/843] Upgrade lupupy to 0.0.21 (#56636) --- homeassistant/components/lupusec/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/lupusec/manifest.json b/homeassistant/components/lupusec/manifest.json index 163789d19bd..6541925a5e4 100644 --- a/homeassistant/components/lupusec/manifest.json +++ b/homeassistant/components/lupusec/manifest.json @@ -2,7 +2,7 @@ "domain": "lupusec", "name": "Lupus Electronics LUPUSEC", "documentation": "https://www.home-assistant.io/integrations/lupusec", - "requirements": ["lupupy==0.0.18"], + "requirements": ["lupupy==0.0.21"], "codeowners": ["@majuss"], "iot_class": "local_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index 56a792430de..d7a4ea5c001 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -957,7 +957,7 @@ london-tube-status==0.2 luftdaten==0.6.5 # homeassistant.components.lupusec -lupupy==0.0.18 +lupupy==0.0.21 # homeassistant.components.lw12wifi lw12==0.9.2 From 4d7e3cde5a4c9dd3bf44bae27e9b5c6092358ac8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 27 Sep 2021 17:45:52 +0200 Subject: [PATCH 633/843] Minor cleanup and test coverage improvement for MQTT (#55265) --- homeassistant/components/mqtt/__init__.py | 14 +-- tests/components/mqtt/test_init.py | 145 ++++++++++++++++++++-- 2 files changed, 137 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index ec5f5f6d1af..36402380b33 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -38,7 +38,7 @@ from homeassistant.core import ( ServiceCall, callback, ) -from homeassistant.exceptions import HomeAssistantError, Unauthorized +from homeassistant.exceptions import HomeAssistantError, TemplateError, Unauthorized from homeassistant.helpers import config_validation as cv, event, template from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send from homeassistant.helpers.typing import ConfigType, ServiceDataType @@ -153,16 +153,6 @@ MQTT_WILL_BIRTH_SCHEMA = vol.Schema( ) -def embedded_broker_deprecated(value): - """Warn user that embedded MQTT broker is deprecated.""" - _LOGGER.warning( - "The embedded MQTT broker has been deprecated and will stop working" - "after June 5th, 2019. Use an external broker instead. For" - "instructions, see https://www.home-assistant.io/docs/mqtt/broker" - ) - return value - - CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.All( @@ -495,7 +485,7 @@ async def async_setup_entry(hass, entry): payload = template.Template(payload_template, hass).async_render( parse_result=False ) - except template.jinja2.TemplateError as exc: + except (template.jinja2.TemplateError, TemplateError) as exc: _LOGGER.error( "Unable to publish to %s: rendering payload template of " "%s failed because %s", diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index ab0c58fb3b6..dfdd316cda9 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -12,13 +12,13 @@ from homeassistant.components import mqtt, websocket_api from homeassistant.components.mqtt import debug_info from homeassistant.components.mqtt.mixins import MQTT_ENTITY_DEVICE_INFO_SCHEMA from homeassistant.const import ( - ATTR_DOMAIN, - ATTR_SERVICE, EVENT_CALL_SERVICE, + EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, TEMP_CELSIUS, ) -from homeassistant.core import callback +from homeassistant.core import CoreState, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow @@ -97,21 +97,35 @@ async def test_publish_calls_service(hass, mqtt_mock, calls, record_calls): hass.bus.async_listen_once(EVENT_CALL_SERVICE, record_calls) mqtt.async_publish(hass, "test-topic", "test-payload") - await hass.async_block_till_done() assert len(calls) == 1 assert calls[0][0].data["service_data"][mqtt.ATTR_TOPIC] == "test-topic" assert calls[0][0].data["service_data"][mqtt.ATTR_PAYLOAD] == "test-payload" + assert mqtt.ATTR_QOS not in calls[0][0].data["service_data"] + assert mqtt.ATTR_RETAIN not in calls[0][0].data["service_data"] + + hass.bus.async_listen_once(EVENT_CALL_SERVICE, record_calls) + + mqtt.async_publish(hass, "test-topic", "test-payload", 2, True) + await hass.async_block_till_done() + + assert len(calls) == 2 + assert calls[1][0].data["service_data"][mqtt.ATTR_TOPIC] == "test-topic" + assert calls[1][0].data["service_data"][mqtt.ATTR_PAYLOAD] == "test-payload" + assert calls[1][0].data["service_data"][mqtt.ATTR_QOS] == 2 + assert calls[1][0].data["service_data"][mqtt.ATTR_RETAIN] is True async def test_service_call_without_topic_does_not_publish(hass, mqtt_mock): """Test the service call if topic is missing.""" - hass.bus.fire( - EVENT_CALL_SERVICE, - {ATTR_DOMAIN: mqtt.DOMAIN, ATTR_SERVICE: mqtt.SERVICE_PUBLISH}, - ) - await hass.async_block_till_done() + with pytest.raises(vol.Invalid): + await hass.services.async_call( + mqtt.DOMAIN, + mqtt.SERVICE_PUBLISH, + {}, + blocking=True, + ) assert not mqtt_mock.async_publish.called @@ -120,10 +134,37 @@ async def test_service_call_with_template_payload_renders_template(hass, mqtt_mo If 'payload_template' is provided and 'payload' is not, then render it. """ - mqtt.async_publish_template(hass, "test/topic", "{{ 1+1 }}") + mqtt.publish_template(hass, "test/topic", "{{ 1+1 }}") await hass.async_block_till_done() assert mqtt_mock.async_publish.called assert mqtt_mock.async_publish.call_args[0][1] == "2" + mqtt_mock.reset_mock() + + mqtt.async_publish_template(hass, "test/topic", "{{ 2+2 }}") + await hass.async_block_till_done() + assert mqtt_mock.async_publish.called + assert mqtt_mock.async_publish.call_args[0][1] == "4" + mqtt_mock.reset_mock() + + await hass.services.async_call( + mqtt.DOMAIN, + mqtt.SERVICE_PUBLISH, + {mqtt.ATTR_TOPIC: "test/topic", mqtt.ATTR_PAYLOAD_TEMPLATE: "{{ 4+4 }}"}, + blocking=True, + ) + assert mqtt_mock.async_publish.called + assert mqtt_mock.async_publish.call_args[0][1] == "8" + + +async def test_service_call_with_bad_template(hass, mqtt_mock): + """Test the service call with a bad template does not publish.""" + await hass.services.async_call( + mqtt.DOMAIN, + mqtt.SERVICE_PUBLISH, + {mqtt.ATTR_TOPIC: "test/topic", mqtt.ATTR_PAYLOAD_TEMPLATE: "{{ 1 | bad }}"}, + blocking=True, + ) + assert not mqtt_mock.async_publish.called async def test_service_call_with_payload_doesnt_render_template(hass, mqtt_mock): @@ -340,6 +381,34 @@ async def test_subscribe_topic(hass, mqtt_mock, calls, record_calls): assert len(calls) == 1 +async def test_subscribe_topic_non_async(hass, mqtt_mock, calls, record_calls): + """Test the subscription of a topic using the non-async function.""" + unsub = await hass.async_add_executor_job( + mqtt.subscribe, hass, "test-topic", record_calls + ) + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, "test-topic", "test-payload") + + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0][0].topic == "test-topic" + assert calls[0][0].payload == "test-payload" + + await hass.async_add_executor_job(unsub) + + async_fire_mqtt_message(hass, "test-topic", "test-payload") + + await hass.async_block_till_done() + assert len(calls) == 1 + + +async def test_subscribe_bad_topic(hass, mqtt_mock, calls, record_calls): + """Test the subscription of a topic.""" + with pytest.raises(HomeAssistantError): + await mqtt.async_subscribe(hass, 55, record_calls) + + async def test_subscribe_deprecated(hass, mqtt_mock): """Test the subscription of a topic using deprecated callback signature.""" calls = [] @@ -833,6 +902,62 @@ async def test_no_birth_message(hass, mqtt_client_mock, mqtt_mock): mqtt_client_mock.publish.assert_not_called() +@pytest.mark.parametrize( + "mqtt_config", + [ + { + mqtt.CONF_BROKER: "mock-broker", + mqtt.CONF_BIRTH_MESSAGE: { + mqtt.ATTR_TOPIC: "homeassistant/status", + mqtt.ATTR_PAYLOAD: "online", + }, + } + ], +) +async def test_delayed_birth_message(hass, mqtt_client_mock, mqtt_config): + """Test sending birth message does not happen until Home Assistant starts.""" + hass.state = CoreState.starting + birth = asyncio.Event() + + result = await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: mqtt_config}) + assert result + await hass.async_block_till_done() + + # Workaround: asynctest==0.13 fails on @functools.lru_cache + spec = dir(hass.data["mqtt"]) + spec.remove("_matching_subscriptions") + + mqtt_component_mock = MagicMock( + return_value=hass.data["mqtt"], + spec_set=spec, + wraps=hass.data["mqtt"], + ) + mqtt_component_mock._mqttc = mqtt_client_mock + + hass.data["mqtt"] = mqtt_component_mock + mqtt_mock = hass.data["mqtt"] + mqtt_mock.reset_mock() + + async def wait_birth(topic, payload, qos): + """Handle birth message.""" + birth.set() + + with patch("homeassistant.components.mqtt.DISCOVERY_COOLDOWN", 0.1): + await mqtt.async_subscribe(hass, "homeassistant/status", wait_birth) + mqtt_mock._mqtt_on_connect(None, None, 0, 0) + await hass.async_block_till_done() + with pytest.raises(asyncio.TimeoutError): + await asyncio.wait_for(birth.wait(), 0.2) + assert not mqtt_client_mock.publish.called + assert not birth.is_set() + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await birth.wait() + mqtt_client_mock.publish.assert_called_with( + "homeassistant/status", "online", 0, False + ) + + @pytest.mark.parametrize( "mqtt_config", [ From e5642a86484949f24d5dd764d00cc2e2b6771100 Mon Sep 17 00:00:00 2001 From: Steffen Zimmermann Date: Mon, 27 Sep 2021 18:28:20 +0200 Subject: [PATCH 634/843] Add state_class measurements in wiffi integration (#54279) * add support for state_class measurements in wiffi integration * use new STATE_CLASS_TOTAL_INCREASING for metered entities like - amount of rainfall per hour/day - rainfall hours per day - sunshine hours per day * Update homeassistant/components/wiffi/sensor.py Co-authored-by: Greg Co-authored-by: Greg --- homeassistant/components/wiffi/__init__.py | 8 ++++++++ homeassistant/components/wiffi/sensor.py | 10 ++++++++++ 2 files changed, 18 insertions(+) diff --git a/homeassistant/components/wiffi/__init__.py b/homeassistant/components/wiffi/__init__.py index 55b13921c1c..e155a48fd72 100644 --- a/homeassistant/components/wiffi/__init__.py +++ b/homeassistant/components/wiffi/__init__.py @@ -222,3 +222,11 @@ class WiffiEntity(Entity): ): self._value = None self.async_write_ha_state() + + def _is_measurement_entity(self): + """Measurement entities have a value in present time.""" + return not self._name.endswith("_gestern") and not self._is_metered_entity() + + def _is_metered_entity(self): + """Metered entities have a value that keeps increasing until reset.""" + return self._name.endswith("_pro_h") or self._name.endswith("_heute") diff --git a/homeassistant/components/wiffi/sensor.py b/homeassistant/components/wiffi/sensor.py index b9bcd317b46..c16ae3c6aca 100644 --- a/homeassistant/components/wiffi/sensor.py +++ b/homeassistant/components/wiffi/sensor.py @@ -5,6 +5,8 @@ from homeassistant.components.sensor import ( DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_PRESSURE, DEVICE_CLASS_TEMPERATURE, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntity, ) from homeassistant.const import DEGREE, PRESSURE_MBAR, TEMP_CELSIUS @@ -70,6 +72,12 @@ class NumberEntity(WiffiEntity, SensorEntity): metric.unit_of_measurement, metric.unit_of_measurement ) self._value = metric.value + + if self._is_measurement_entity(): + self._attr_state_class = STATE_CLASS_MEASUREMENT + elif self._is_metered_entity(): + self._attr_state_class = STATE_CLASS_TOTAL_INCREASING + self.reset_expiration_date() @property @@ -97,7 +105,9 @@ class NumberEntity(WiffiEntity, SensorEntity): self._unit_of_measurement = UOM_MAP.get( metric.unit_of_measurement, metric.unit_of_measurement ) + self._value = metric.value + self.async_write_ha_state() From fe66d6295c1ca9593edad2a5e64dacbce475454e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 27 Sep 2021 19:31:40 +0200 Subject: [PATCH 635/843] Improve migration to recorder schema version 21 (#56204) --- homeassistant/components/recorder/migration.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 1a1f978be59..0e66585d86e 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -502,14 +502,24 @@ def _apply_update(instance, session, new_version, old_version): # noqa: C901 ], ) elif new_version == 21: + if engine.dialect.name in ["mysql", "oracle", "postgresql"]: + data_type = "DOUBLE PRECISION" + else: + data_type = "FLOAT" _add_columns( connection, "statistics", - ["sum_increase DOUBLE PRECISION"], + [f"sum_increase {data_type}"], ) # Try to change the character set of the statistic_meta table if engine.dialect.name == "mysql": for table in ("events", "states", "statistics_meta"): + _LOGGER.warning( + "Updating character set and collation of table %s to utf8mb4. " + "Note: this can take several minutes on large databases and slow " + "computers. Please be patient!", + table, + ) with contextlib.suppress(SQLAlchemyError): connection.execute( text( From 71ce8583783bf1cf9dfb52a24f20af8521e92d5a Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 27 Sep 2021 19:37:12 +0200 Subject: [PATCH 636/843] Use EntityDescription - toon (#55035) --- .../components/toon/binary_sensor.py | 166 +++++-- homeassistant/components/toon/const.py | 354 ------------- homeassistant/components/toon/models.py | 10 + homeassistant/components/toon/sensor.py | 464 ++++++++++++++---- homeassistant/components/toon/switch.py | 81 +-- 5 files changed, 558 insertions(+), 517 deletions(-) diff --git a/homeassistant/components/toon/binary_sensor.py b/homeassistant/components/toon/binary_sensor.py index 9983dc4bee6..30f5459c175 100644 --- a/homeassistant/components/toon/binary_sensor.py +++ b/homeassistant/components/toon/binary_sensor.py @@ -1,27 +1,25 @@ """Support for Toon binary sensors.""" from __future__ import annotations -from homeassistant.components.binary_sensor import BinarySensorEntity +from dataclasses import dataclass + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_CONNECTIVITY, + DEVICE_CLASS_PROBLEM, + BinarySensorEntity, + BinarySensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import ( - ATTR_DEFAULT_ENABLED, - ATTR_DEVICE_CLASS, - ATTR_ICON, - ATTR_INVERTED, - ATTR_MEASUREMENT, - ATTR_NAME, - ATTR_SECTION, - BINARY_SENSOR_ENTITIES, - DOMAIN, -) +from .const import DOMAIN from .coordinator import ToonDataUpdateCoordinator from .models import ( ToonBoilerDeviceEntity, ToonBoilerModuleDeviceEntity, ToonDisplayDeviceEntity, ToonEntity, + ToonRequiredKeysMixin, ) @@ -31,64 +29,51 @@ async def async_setup_entry( """Set up a Toon binary sensor based on a config entry.""" coordinator = hass.data[DOMAIN][entry.entry_id] - sensors = [ - ToonBoilerModuleBinarySensor( - coordinator, key="thermostat_info_boiler_connected_None" - ), - ToonDisplayBinarySensor(coordinator, key="thermostat_program_overridden"), + entities = [ + description.cls(coordinator, description) + for description in BINARY_SENSOR_ENTITIES ] - if coordinator.data.thermostat.have_opentherm_boiler: - sensors.extend( + entities.extend( [ - ToonBoilerBinarySensor(coordinator, key=key) - for key in ( - "thermostat_info_ot_communication_error_0", - "thermostat_info_error_found_255", - "thermostat_info_burner_info_None", - "thermostat_info_burner_info_1", - "thermostat_info_burner_info_2", - "thermostat_info_burner_info_3", - ) + description.cls(coordinator, description) + for description in BINARY_SENSOR_ENTITIES_BOILER ] ) - async_add_entities(sensors, True) + async_add_entities(entities, True) class ToonBinarySensor(ToonEntity, BinarySensorEntity): """Defines an Toon binary sensor.""" - def __init__(self, coordinator: ToonDataUpdateCoordinator, *, key: str) -> None: + entity_description: ToonBinarySensorEntityDescription + + def __init__( + self, + coordinator: ToonDataUpdateCoordinator, + description: ToonBinarySensorEntityDescription, + ) -> None: """Initialize the Toon sensor.""" super().__init__(coordinator) - self.key = key + self.entity_description = description - sensor = BINARY_SENSOR_ENTITIES[key] - self._attr_name = sensor[ATTR_NAME] - self._attr_icon = sensor.get(ATTR_ICON) - self._attr_entity_registry_enabled_default = sensor.get( - ATTR_DEFAULT_ENABLED, True - ) - self._attr_device_class = sensor.get(ATTR_DEVICE_CLASS) self._attr_unique_id = ( # This unique ID is a bit ugly and contains unneeded information. # It is here for legacy / backward compatible reasons. - f"{DOMAIN}_{coordinator.data.agreement.agreement_id}_binary_sensor_{key}" + f"{DOMAIN}_{coordinator.data.agreement.agreement_id}_binary_sensor_{description.key}" ) @property def is_on(self) -> bool | None: """Return the status of the binary sensor.""" - section = getattr( - self.coordinator.data, BINARY_SENSOR_ENTITIES[self.key][ATTR_SECTION] - ) - value = getattr(section, BINARY_SENSOR_ENTITIES[self.key][ATTR_MEASUREMENT]) + section = getattr(self.coordinator.data, self.entity_description.section) + value = getattr(section, self.entity_description.measurement) if value is None: return None - if BINARY_SENSOR_ENTITIES[self.key].get(ATTR_INVERTED, False): + if self.entity_description.inverted: return not value return value @@ -104,3 +89,96 @@ class ToonDisplayBinarySensor(ToonBinarySensor, ToonDisplayDeviceEntity): class ToonBoilerModuleBinarySensor(ToonBinarySensor, ToonBoilerModuleDeviceEntity): """Defines a Boiler module binary sensor.""" + + +@dataclass +class ToonBinarySensorRequiredKeysMixin(ToonRequiredKeysMixin): + """Mixin for binary sensor required keys.""" + + cls: type[ToonBinarySensor] + + +@dataclass +class ToonBinarySensorEntityDescription( + BinarySensorEntityDescription, ToonBinarySensorRequiredKeysMixin +): + """Describes Toon binary sensor entity.""" + + inverted: bool = False + + +BINARY_SENSOR_ENTITIES = ( + ToonBinarySensorEntityDescription( + key="thermostat_info_boiler_connected_None", + name="Boiler Module Connection", + section="thermostat", + measurement="boiler_module_connected", + device_class=DEVICE_CLASS_CONNECTIVITY, + entity_registry_enabled_default=False, + cls=ToonBoilerModuleBinarySensor, + ), + ToonBinarySensorEntityDescription( + key="thermostat_program_overridden", + name="Thermostat Program Override", + section="thermostat", + measurement="program_overridden", + icon="mdi:gesture-tap", + cls=ToonDisplayBinarySensor, + ), +) + +BINARY_SENSOR_ENTITIES_BOILER: tuple[ToonBinarySensorEntityDescription, ...] = ( + ToonBinarySensorEntityDescription( + key="thermostat_info_burner_info_1", + name="Boiler Heating", + section="thermostat", + measurement="heating", + icon="mdi:fire", + entity_registry_enabled_default=False, + cls=ToonBoilerBinarySensor, + ), + ToonBinarySensorEntityDescription( + key="thermostat_info_burner_info_2", + name="Hot Tap Water", + section="thermostat", + measurement="hot_tapwater", + icon="mdi:water-pump", + cls=ToonBoilerBinarySensor, + ), + ToonBinarySensorEntityDescription( + key="thermostat_info_burner_info_3", + name="Boiler Preheating", + section="thermostat", + measurement="pre_heating", + icon="mdi:fire", + entity_registry_enabled_default=False, + cls=ToonBoilerBinarySensor, + ), + ToonBinarySensorEntityDescription( + key="thermostat_info_burner_info_None", + name="Boiler Burner", + section="thermostat", + measurement="burner", + icon="mdi:fire", + cls=ToonBoilerBinarySensor, + ), + ToonBinarySensorEntityDescription( + key="thermostat_info_error_found_255", + name="Boiler Status", + section="thermostat", + measurement="error_found", + device_class=DEVICE_CLASS_PROBLEM, + icon="mdi:alert", + cls=ToonBoilerBinarySensor, + ), + ToonBinarySensorEntityDescription( + key="thermostat_info_ot_communication_error_0", + name="OpenTherm Connection", + section="thermostat", + measurement="opentherm_communication_error", + device_class=DEVICE_CLASS_PROBLEM, + icon="mdi:check-network-outline", + entity_registry_enabled_default=False, + cls=ToonBoilerBinarySensor, + ), +) diff --git a/homeassistant/components/toon/const.py b/homeassistant/components/toon/const.py index 678b3400b88..bf70c54e5e0 100644 --- a/homeassistant/components/toon/const.py +++ b/homeassistant/components/toon/const.py @@ -1,31 +1,6 @@ """Constants for the Toon integration.""" from datetime import timedelta -from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_CONNECTIVITY, - DEVICE_CLASS_PROBLEM, -) -from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, - DEVICE_CLASS_ENERGY, - DEVICE_CLASS_POWER, - DEVICE_CLASS_TEMPERATURE, - STATE_CLASS_MEASUREMENT, - STATE_CLASS_TOTAL_INCREASING, -) -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ICON, - ATTR_NAME, - ATTR_UNIT_OF_MEASUREMENT, - DEVICE_CLASS_GAS, - ENERGY_KILO_WATT_HOUR, - PERCENTAGE, - POWER_WATT, - TEMP_CELSIUS, - VOLUME_CUBIC_METERS, -) - DOMAIN = "toon" CONF_AGREEMENT = "agreement" @@ -41,332 +16,3 @@ CURRENCY_EUR = "EUR" VOLUME_CM3 = "CM3" VOLUME_LHOUR = "L/H" VOLUME_LMIN = "L/MIN" - -ATTR_DEFAULT_ENABLED = "default_enabled" -ATTR_INVERTED = "inverted" -ATTR_MEASUREMENT = "measurement" -ATTR_SECTION = "section" - -BINARY_SENSOR_ENTITIES = { - "thermostat_info_boiler_connected_None": { - ATTR_NAME: "Boiler Module Connection", - ATTR_SECTION: "thermostat", - ATTR_MEASUREMENT: "boiler_module_connected", - ATTR_DEVICE_CLASS: DEVICE_CLASS_CONNECTIVITY, - ATTR_DEFAULT_ENABLED: False, - }, - "thermostat_info_burner_info_1": { - ATTR_NAME: "Boiler Heating", - ATTR_SECTION: "thermostat", - ATTR_MEASUREMENT: "heating", - ATTR_ICON: "mdi:fire", - ATTR_DEFAULT_ENABLED: False, - }, - "thermostat_info_burner_info_2": { - ATTR_NAME: "Hot Tap Water", - ATTR_SECTION: "thermostat", - ATTR_MEASUREMENT: "hot_tapwater", - ATTR_ICON: "mdi:water-pump", - }, - "thermostat_info_burner_info_3": { - ATTR_NAME: "Boiler Preheating", - ATTR_SECTION: "thermostat", - ATTR_MEASUREMENT: "pre_heating", - ATTR_ICON: "mdi:fire", - ATTR_DEFAULT_ENABLED: False, - }, - "thermostat_info_burner_info_None": { - ATTR_NAME: "Boiler Burner", - ATTR_SECTION: "thermostat", - ATTR_MEASUREMENT: "burner", - ATTR_ICON: "mdi:fire", - }, - "thermostat_info_error_found_255": { - ATTR_NAME: "Boiler Status", - ATTR_SECTION: "thermostat", - ATTR_MEASUREMENT: "error_found", - ATTR_DEVICE_CLASS: DEVICE_CLASS_PROBLEM, - ATTR_ICON: "mdi:alert", - }, - "thermostat_info_ot_communication_error_0": { - ATTR_NAME: "OpenTherm Connection", - ATTR_SECTION: "thermostat", - ATTR_MEASUREMENT: "opentherm_communication_error", - ATTR_DEVICE_CLASS: DEVICE_CLASS_PROBLEM, - ATTR_ICON: "mdi:check-network-outline", - ATTR_DEFAULT_ENABLED: False, - }, - "thermostat_program_overridden": { - ATTR_NAME: "Thermostat Program Override", - ATTR_SECTION: "thermostat", - ATTR_MEASUREMENT: "program_overridden", - ATTR_ICON: "mdi:gesture-tap", - }, -} - -SENSOR_ENTITIES = { - "current_display_temperature": { - ATTR_NAME: "Temperature", - ATTR_SECTION: "thermostat", - ATTR_MEASUREMENT: "current_display_temperature", - ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_DEFAULT_ENABLED: False, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - "gas_average": { - ATTR_NAME: "Average Gas Usage", - ATTR_SECTION: "gas_usage", - ATTR_MEASUREMENT: "average", - ATTR_UNIT_OF_MEASUREMENT: VOLUME_CM3, - ATTR_ICON: "mdi:gas-cylinder", - }, - "gas_average_daily": { - ATTR_NAME: "Average Daily Gas Usage", - ATTR_SECTION: "gas_usage", - ATTR_MEASUREMENT: "day_average", - ATTR_DEVICE_CLASS: DEVICE_CLASS_GAS, - ATTR_UNIT_OF_MEASUREMENT: VOLUME_CUBIC_METERS, - ATTR_DEFAULT_ENABLED: False, - }, - "gas_daily_usage": { - ATTR_NAME: "Gas Usage Today", - ATTR_SECTION: "gas_usage", - ATTR_MEASUREMENT: "day_usage", - ATTR_DEVICE_CLASS: DEVICE_CLASS_GAS, - ATTR_UNIT_OF_MEASUREMENT: VOLUME_CUBIC_METERS, - }, - "gas_daily_cost": { - ATTR_NAME: "Gas Cost Today", - ATTR_SECTION: "gas_usage", - ATTR_MEASUREMENT: "day_cost", - ATTR_UNIT_OF_MEASUREMENT: CURRENCY_EUR, - ATTR_ICON: "mdi:gas-cylinder", - }, - "gas_meter_reading": { - ATTR_NAME: "Gas Meter", - ATTR_SECTION: "gas_usage", - ATTR_MEASUREMENT: "meter", - ATTR_UNIT_OF_MEASUREMENT: VOLUME_CUBIC_METERS, - ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, - ATTR_DEVICE_CLASS: DEVICE_CLASS_GAS, - ATTR_DEFAULT_ENABLED: False, - }, - "gas_value": { - ATTR_NAME: "Current Gas Usage", - ATTR_SECTION: "gas_usage", - ATTR_MEASUREMENT: "current", - ATTR_UNIT_OF_MEASUREMENT: VOLUME_CM3, - ATTR_ICON: "mdi:gas-cylinder", - }, - "power_average": { - ATTR_NAME: "Average Power Usage", - ATTR_SECTION: "power_usage", - ATTR_MEASUREMENT: "average", - ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, - ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, - ATTR_DEFAULT_ENABLED: False, - }, - "power_average_daily": { - ATTR_NAME: "Average Daily Energy Usage", - ATTR_SECTION: "power_usage", - ATTR_MEASUREMENT: "day_average", - ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, - ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - ATTR_DEFAULT_ENABLED: False, - }, - "power_daily_cost": { - ATTR_NAME: "Energy Cost Today", - ATTR_SECTION: "power_usage", - ATTR_MEASUREMENT: "day_cost", - ATTR_UNIT_OF_MEASUREMENT: CURRENCY_EUR, - ATTR_ICON: "mdi:power-plug", - }, - "power_daily_value": { - ATTR_NAME: "Energy Usage Today", - ATTR_SECTION: "power_usage", - ATTR_MEASUREMENT: "day_usage", - ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, - ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - }, - "power_meter_reading": { - ATTR_NAME: "Electricity Meter Feed IN Tariff 1", - ATTR_SECTION: "power_usage", - ATTR_MEASUREMENT: "meter_high", - ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, - ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, - ATTR_DEFAULT_ENABLED: False, - }, - "power_meter_reading_low": { - ATTR_NAME: "Electricity Meter Feed IN Tariff 2", - ATTR_SECTION: "power_usage", - ATTR_MEASUREMENT: "meter_low", - ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, - ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, - ATTR_DEFAULT_ENABLED: False, - }, - "power_value": { - ATTR_NAME: "Current Power Usage", - ATTR_SECTION: "power_usage", - ATTR_MEASUREMENT: "current", - ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, - ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - "solar_meter_reading_produced": { - ATTR_NAME: "Electricity Meter Feed OUT Tariff 1", - ATTR_SECTION: "power_usage", - ATTR_MEASUREMENT: "meter_produced_high", - ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, - ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, - ATTR_DEFAULT_ENABLED: False, - }, - "solar_meter_reading_low_produced": { - ATTR_NAME: "Electricity Meter Feed OUT Tariff 2", - ATTR_SECTION: "power_usage", - ATTR_MEASUREMENT: "meter_produced_low", - ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, - ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, - ATTR_DEFAULT_ENABLED: False, - }, - "solar_value": { - ATTR_NAME: "Current Solar Power Production", - ATTR_SECTION: "power_usage", - ATTR_MEASUREMENT: "current_solar", - ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, - ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - "solar_maximum": { - ATTR_NAME: "Max Solar Power Production Today", - ATTR_SECTION: "power_usage", - ATTR_MEASUREMENT: "day_max_solar", - ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, - ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, - }, - "solar_produced": { - ATTR_NAME: "Solar Power Production to Grid", - ATTR_SECTION: "power_usage", - ATTR_MEASUREMENT: "current_produced", - ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, - ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, - ATTR_STATE_CLASS: ATTR_MEASUREMENT, - }, - "power_usage_day_produced_solar": { - ATTR_NAME: "Solar Energy Produced Today", - ATTR_SECTION: "power_usage", - ATTR_MEASUREMENT: "day_produced_solar", - ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, - ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - }, - "power_usage_day_to_grid_usage": { - ATTR_NAME: "Energy Produced To Grid Today", - ATTR_SECTION: "power_usage", - ATTR_MEASUREMENT: "day_to_grid_usage", - ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, - ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - ATTR_DEFAULT_ENABLED: False, - }, - "power_usage_day_from_grid_usage": { - ATTR_NAME: "Energy Usage From Grid Today", - ATTR_SECTION: "power_usage", - ATTR_MEASUREMENT: "day_from_grid_usage", - ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, - ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - ATTR_DEFAULT_ENABLED: False, - }, - "solar_average_produced": { - ATTR_NAME: "Average Solar Power Production to Grid", - ATTR_SECTION: "power_usage", - ATTR_MEASUREMENT: "average_produced", - ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, - ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, - ATTR_DEFAULT_ENABLED: False, - }, - "thermostat_info_current_modulation_level": { - ATTR_NAME: "Boiler Modulation Level", - ATTR_SECTION: "thermostat", - ATTR_MEASUREMENT: "current_modulation_level", - ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, - ATTR_ICON: "mdi:percent", - ATTR_DEFAULT_ENABLED: False, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - "power_usage_current_covered_by_solar": { - ATTR_NAME: "Current Power Usage Covered By Solar", - ATTR_SECTION: "power_usage", - ATTR_MEASUREMENT: "current_covered_by_solar", - ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, - ATTR_ICON: "mdi:solar-power", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - "water_average": { - ATTR_NAME: "Average Water Usage", - ATTR_SECTION: "water_usage", - ATTR_MEASUREMENT: "average", - ATTR_UNIT_OF_MEASUREMENT: VOLUME_LMIN, - ATTR_ICON: "mdi:water", - ATTR_DEFAULT_ENABLED: False, - }, - "water_average_daily": { - ATTR_NAME: "Average Daily Water Usage", - ATTR_SECTION: "water_usage", - ATTR_MEASUREMENT: "day_average", - ATTR_UNIT_OF_MEASUREMENT: VOLUME_CUBIC_METERS, - ATTR_ICON: "mdi:water", - ATTR_DEFAULT_ENABLED: False, - }, - "water_daily_usage": { - ATTR_NAME: "Water Usage Today", - ATTR_SECTION: "water_usage", - ATTR_MEASUREMENT: "day_usage", - ATTR_UNIT_OF_MEASUREMENT: VOLUME_CUBIC_METERS, - ATTR_ICON: "mdi:water", - ATTR_DEFAULT_ENABLED: False, - }, - "water_meter_reading": { - ATTR_NAME: "Water Meter", - ATTR_SECTION: "water_usage", - ATTR_MEASUREMENT: "meter", - ATTR_UNIT_OF_MEASUREMENT: VOLUME_CUBIC_METERS, - ATTR_ICON: "mdi:water", - ATTR_DEFAULT_ENABLED: False, - ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, - }, - "water_value": { - ATTR_NAME: "Current Water Usage", - ATTR_SECTION: "water_usage", - ATTR_MEASUREMENT: "current", - ATTR_UNIT_OF_MEASUREMENT: VOLUME_LMIN, - ATTR_ICON: "mdi:water-pump", - ATTR_DEFAULT_ENABLED: False, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - "water_daily_cost": { - ATTR_NAME: "Water Cost Today", - ATTR_SECTION: "water_usage", - ATTR_MEASUREMENT: "day_cost", - ATTR_UNIT_OF_MEASUREMENT: CURRENCY_EUR, - ATTR_ICON: "mdi:water-pump", - ATTR_DEFAULT_ENABLED: False, - }, -} - -SWITCH_ENTITIES = { - "thermostat_holiday_mode": { - ATTR_NAME: "Holiday Mode", - ATTR_SECTION: "thermostat", - ATTR_MEASUREMENT: "holiday_mode", - ATTR_ICON: "mdi:airport", - }, - "thermostat_program": { - ATTR_NAME: "Thermostat Program", - ATTR_SECTION: "thermostat", - ATTR_MEASUREMENT: "program", - ATTR_ICON: "mdi:calendar-clock", - }, -} diff --git a/homeassistant/components/toon/models.py b/homeassistant/components/toon/models.py index 7fb45af4d53..a95a8f622a8 100644 --- a/homeassistant/components/toon/models.py +++ b/homeassistant/components/toon/models.py @@ -1,6 +1,8 @@ """DataUpdate Coordinator, and base Entity and Device models for Toon.""" from __future__ import annotations +from dataclasses import dataclass + from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -115,3 +117,11 @@ class ToonBoilerDeviceEntity(ToonEntity): "identifiers": {(DOMAIN, agreement_id, "boiler")}, "via_device": (DOMAIN, agreement_id, "boiler_module"), } + + +@dataclass +class ToonRequiredKeysMixin: + """Mixin for required keys.""" + + section: str + measurement: str diff --git a/homeassistant/components/toon/sensor.py b/homeassistant/components/toon/sensor.py index 4522e34943c..cf7546c3fa6 100644 --- a/homeassistant/components/toon/sensor.py +++ b/homeassistant/components/toon/sensor.py @@ -1,21 +1,29 @@ """Support for Toon sensors.""" from __future__ import annotations -from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorEntity +from dataclasses import dataclass + +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_GAS, + DEVICE_CLASS_POWER, + DEVICE_CLASS_TEMPERATURE, + ENERGY_KILO_WATT_HOUR, + PERCENTAGE, + POWER_WATT, + TEMP_CELSIUS, + VOLUME_CUBIC_METERS, +) from homeassistant.core import HomeAssistant -from .const import ( - ATTR_DEFAULT_ENABLED, - ATTR_DEVICE_CLASS, - ATTR_ICON, - ATTR_MEASUREMENT, - ATTR_NAME, - ATTR_SECTION, - ATTR_UNIT_OF_MEASUREMENT, - DOMAIN, - SENSOR_ENTITIES, -) +from .const import CURRENCY_EUR, DOMAIN, VOLUME_CM3, VOLUME_LMIN from .coordinator import ToonDataUpdateCoordinator from .models import ( ToonBoilerDeviceEntity, @@ -23,6 +31,7 @@ from .models import ( ToonElectricityMeterDeviceEntity, ToonEntity, ToonGasMeterDeviceEntity, + ToonRequiredKeysMixin, ToonSolarDeviceEntity, ToonWaterMeterDeviceEntity, ) @@ -34,112 +43,54 @@ async def async_setup_entry( """Set up Toon sensors based on a config entry.""" coordinator = hass.data[DOMAIN][entry.entry_id] - sensors = [ - ToonElectricityMeterDeviceSensor(coordinator, key=key) - for key in ( - "power_average_daily", - "power_average", - "power_daily_cost", - "power_daily_value", - "power_meter_reading_low", - "power_meter_reading", - "power_value", - "solar_meter_reading_low_produced", - "solar_meter_reading_produced", - ) + entities = [ + description.cls(coordinator, description) for description in SENSOR_ENTITIES ] - sensors.extend( - [ToonDisplayDeviceSensor(coordinator, key="current_display_temperature")] - ) - - sensors.extend( - [ - ToonGasMeterDeviceSensor(coordinator, key=key) - for key in ( - "gas_average_daily", - "gas_average", - "gas_daily_cost", - "gas_daily_usage", - "gas_meter_reading", - "gas_value", - ) - ] - ) - - sensors.extend( - [ - ToonWaterMeterDeviceSensor(coordinator, key=key) - for key in ( - "water_average_daily", - "water_average", - "water_daily_cost", - "water_daily_usage", - "water_meter_reading", - "water_value", - ) - ] - ) - if coordinator.data.agreement.is_toon_solar: - sensors.extend( + entities.extend( [ - ToonSolarDeviceSensor(coordinator, key=key) - for key in ( - "solar_value", - "solar_maximum", - "solar_produced", - "solar_average_produced", - "power_usage_day_produced_solar", - "power_usage_day_from_grid_usage", - "power_usage_day_to_grid_usage", - "power_usage_current_covered_by_solar", - ) + description.cls(coordinator, description) + for description in SENSOR_ENTITIES_SOLAR ] ) if coordinator.data.thermostat.have_opentherm_boiler: - sensors.extend( + entities.extend( [ - ToonBoilerDeviceSensor( - coordinator, key="thermostat_info_current_modulation_level" - ) + description.cls(coordinator, description) + for description in SENSOR_ENTITIES_BOILER ] ) - async_add_entities(sensors, True) + async_add_entities(entities, True) class ToonSensor(ToonEntity, SensorEntity): """Defines a Toon sensor.""" - def __init__(self, coordinator: ToonDataUpdateCoordinator, *, key: str) -> None: + entity_description: ToonSensorEntityDescription + + def __init__( + self, + coordinator: ToonDataUpdateCoordinator, + description: ToonSensorEntityDescription, + ) -> None: """Initialize the Toon sensor.""" - self.key = key + self.entity_description = description super().__init__(coordinator) - sensor = SENSOR_ENTITIES[key] - self._attr_entity_registry_enabled_default = sensor.get( - ATTR_DEFAULT_ENABLED, True - ) - self._attr_icon = sensor.get(ATTR_ICON) - self._attr_name = sensor[ATTR_NAME] - self._attr_state_class = sensor.get(ATTR_STATE_CLASS) - self._attr_native_unit_of_measurement = sensor[ATTR_UNIT_OF_MEASUREMENT] - self._attr_device_class = sensor.get(ATTR_DEVICE_CLASS) self._attr_unique_id = ( # This unique ID is a bit ugly and contains unneeded information. # It is here for legacy / backward compatible reasons. - f"{DOMAIN}_{coordinator.data.agreement.agreement_id}_sensor_{key}" + f"{DOMAIN}_{coordinator.data.agreement.agreement_id}_sensor_{description.key}" ) @property def native_value(self) -> str | None: """Return the state of the sensor.""" - section = getattr( - self.coordinator.data, SENSOR_ENTITIES[self.key][ATTR_SECTION] - ) - return getattr(section, SENSOR_ENTITIES[self.key][ATTR_MEASUREMENT]) + section = getattr(self.coordinator.data, self.entity_description.section) + return getattr(section, self.entity_description.measurement) class ToonElectricityMeterDeviceSensor(ToonSensor, ToonElectricityMeterDeviceEntity): @@ -164,3 +115,336 @@ class ToonBoilerDeviceSensor(ToonSensor, ToonBoilerDeviceEntity): class ToonDisplayDeviceSensor(ToonSensor, ToonDisplayDeviceEntity): """Defines a Display sensor.""" + + +@dataclass +class ToonSensorRequiredKeysMixin(ToonRequiredKeysMixin): + """Mixin for sensor required keys.""" + + cls: type[ToonSensor] + + +@dataclass +class ToonSensorEntityDescription(SensorEntityDescription, ToonSensorRequiredKeysMixin): + """Describes Toon sensor entity.""" + + +SENSOR_ENTITIES: tuple[ToonSensorEntityDescription, ...] = ( + ToonSensorEntityDescription( + key="current_display_temperature", + name="Temperature", + section="thermostat", + measurement="current_display_temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + entity_registry_enabled_default=False, + state_class=STATE_CLASS_MEASUREMENT, + cls=ToonDisplayDeviceSensor, + ), + ToonSensorEntityDescription( + key="gas_average", + name="Average Gas Usage", + section="gas_usage", + measurement="average", + native_unit_of_measurement=VOLUME_CM3, + icon="mdi:gas-cylinder", + cls=ToonGasMeterDeviceSensor, + ), + ToonSensorEntityDescription( + key="gas_average_daily", + name="Average Daily Gas Usage", + section="gas_usage", + measurement="day_average", + device_class=DEVICE_CLASS_GAS, + native_unit_of_measurement=VOLUME_CUBIC_METERS, + entity_registry_enabled_default=False, + cls=ToonGasMeterDeviceSensor, + ), + ToonSensorEntityDescription( + key="gas_daily_usage", + name="Gas Usage Today", + section="gas_usage", + measurement="day_usage", + device_class=DEVICE_CLASS_GAS, + native_unit_of_measurement=VOLUME_CUBIC_METERS, + cls=ToonGasMeterDeviceSensor, + ), + ToonSensorEntityDescription( + key="gas_daily_cost", + name="Gas Cost Today", + section="gas_usage", + measurement="day_cost", + native_unit_of_measurement=CURRENCY_EUR, + icon="mdi:gas-cylinder", + cls=ToonGasMeterDeviceSensor, + ), + ToonSensorEntityDescription( + key="gas_meter_reading", + name="Gas Meter", + section="gas_usage", + measurement="meter", + native_unit_of_measurement=VOLUME_CUBIC_METERS, + state_class=STATE_CLASS_TOTAL_INCREASING, + device_class=DEVICE_CLASS_GAS, + entity_registry_enabled_default=False, + cls=ToonGasMeterDeviceSensor, + ), + ToonSensorEntityDescription( + key="gas_value", + name="Current Gas Usage", + section="gas_usage", + measurement="current", + native_unit_of_measurement=VOLUME_CM3, + icon="mdi:gas-cylinder", + cls=ToonGasMeterDeviceSensor, + ), + ToonSensorEntityDescription( + key="power_average", + name="Average Power Usage", + section="power_usage", + measurement="average", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + entity_registry_enabled_default=False, + cls=ToonElectricityMeterDeviceSensor, + ), + ToonSensorEntityDescription( + key="power_average_daily", + name="Average Daily Energy Usage", + section="power_usage", + measurement="day_average", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + entity_registry_enabled_default=False, + cls=ToonElectricityMeterDeviceSensor, + ), + ToonSensorEntityDescription( + key="power_daily_cost", + name="Energy Cost Today", + section="power_usage", + measurement="day_cost", + native_unit_of_measurement=CURRENCY_EUR, + icon="mdi:power-plug", + cls=ToonElectricityMeterDeviceSensor, + ), + ToonSensorEntityDescription( + key="power_daily_value", + name="Energy Usage Today", + section="power_usage", + measurement="day_usage", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + cls=ToonElectricityMeterDeviceSensor, + ), + ToonSensorEntityDescription( + key="power_meter_reading", + name="Electricity Meter Feed IN Tariff 1", + section="power_usage", + measurement="meter_high", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_registry_enabled_default=False, + cls=ToonElectricityMeterDeviceSensor, + ), + ToonSensorEntityDescription( + key="power_meter_reading_low", + name="Electricity Meter Feed IN Tariff 2", + section="power_usage", + measurement="meter_low", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_registry_enabled_default=False, + cls=ToonElectricityMeterDeviceSensor, + ), + ToonSensorEntityDescription( + key="power_value", + name="Current Power Usage", + section="power_usage", + measurement="current", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + cls=ToonElectricityMeterDeviceSensor, + ), + ToonSensorEntityDescription( + key="solar_meter_reading_produced", + name="Electricity Meter Feed OUT Tariff 1", + section="power_usage", + measurement="meter_produced_high", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_registry_enabled_default=False, + cls=ToonElectricityMeterDeviceSensor, + ), + ToonSensorEntityDescription( + key="solar_meter_reading_low_produced", + name="Electricity Meter Feed OUT Tariff 2", + section="power_usage", + measurement="meter_produced_low", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_registry_enabled_default=False, + cls=ToonElectricityMeterDeviceSensor, + ), + ToonSensorEntityDescription( + key="water_average", + name="Average Water Usage", + section="water_usage", + measurement="average", + native_unit_of_measurement=VOLUME_LMIN, + icon="mdi:water", + entity_registry_enabled_default=False, + cls=ToonWaterMeterDeviceSensor, + ), + ToonSensorEntityDescription( + key="water_average_daily", + name="Average Daily Water Usage", + section="water_usage", + measurement="day_average", + native_unit_of_measurement=VOLUME_CUBIC_METERS, + icon="mdi:water", + entity_registry_enabled_default=False, + cls=ToonWaterMeterDeviceSensor, + ), + ToonSensorEntityDescription( + key="water_daily_usage", + name="Water Usage Today", + section="water_usage", + measurement="day_usage", + native_unit_of_measurement=VOLUME_CUBIC_METERS, + icon="mdi:water", + entity_registry_enabled_default=False, + cls=ToonWaterMeterDeviceSensor, + ), + ToonSensorEntityDescription( + key="water_meter_reading", + name="Water Meter", + section="water_usage", + measurement="meter", + native_unit_of_measurement=VOLUME_CUBIC_METERS, + icon="mdi:water", + entity_registry_enabled_default=False, + state_class=STATE_CLASS_TOTAL_INCREASING, + cls=ToonWaterMeterDeviceSensor, + ), + ToonSensorEntityDescription( + key="water_value", + name="Current Water Usage", + section="water_usage", + measurement="current", + native_unit_of_measurement=VOLUME_LMIN, + icon="mdi:water-pump", + entity_registry_enabled_default=False, + state_class=STATE_CLASS_MEASUREMENT, + cls=ToonWaterMeterDeviceSensor, + ), + ToonSensorEntityDescription( + key="water_daily_cost", + name="Water Cost Today", + section="water_usage", + measurement="day_cost", + native_unit_of_measurement=CURRENCY_EUR, + icon="mdi:water-pump", + entity_registry_enabled_default=False, + cls=ToonWaterMeterDeviceSensor, + ), +) + +SENSOR_ENTITIES_SOLAR: tuple[ToonSensorEntityDescription, ...] = ( + ToonSensorEntityDescription( + key="solar_value", + name="Current Solar Power Production", + section="power_usage", + measurement="current_solar", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + cls=ToonSolarDeviceSensor, + ), + ToonSensorEntityDescription( + key="solar_maximum", + name="Max Solar Power Production Today", + section="power_usage", + measurement="day_max_solar", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + cls=ToonSolarDeviceSensor, + ), + ToonSensorEntityDescription( + key="solar_produced", + name="Solar Power Production to Grid", + section="power_usage", + measurement="current_produced", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + cls=ToonSolarDeviceSensor, + ), + ToonSensorEntityDescription( + key="power_usage_day_produced_solar", + name="Solar Energy Produced Today", + section="power_usage", + measurement="day_produced_solar", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + cls=ToonSolarDeviceSensor, + ), + ToonSensorEntityDescription( + key="power_usage_day_to_grid_usage", + name="Energy Produced To Grid Today", + section="power_usage", + measurement="day_to_grid_usage", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + entity_registry_enabled_default=False, + cls=ToonSolarDeviceSensor, + ), + ToonSensorEntityDescription( + key="power_usage_day_from_grid_usage", + name="Energy Usage From Grid Today", + section="power_usage", + measurement="day_from_grid_usage", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + entity_registry_enabled_default=False, + cls=ToonSolarDeviceSensor, + ), + ToonSensorEntityDescription( + key="solar_average_produced", + name="Average Solar Power Production to Grid", + section="power_usage", + measurement="average_produced", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + entity_registry_enabled_default=False, + cls=ToonSolarDeviceSensor, + ), + ToonSensorEntityDescription( + key="power_usage_current_covered_by_solar", + name="Current Power Usage Covered By Solar", + section="power_usage", + measurement="current_covered_by_solar", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:solar-power", + state_class=STATE_CLASS_MEASUREMENT, + cls=ToonSolarDeviceSensor, + ), +) + +SENSOR_ENTITIES_BOILER: tuple[ToonSensorEntityDescription, ...] = ( + ToonSensorEntityDescription( + key="thermostat_info_current_modulation_level", + name="Boiler Modulation Level", + section="thermostat", + measurement="current_modulation_level", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:percent", + entity_registry_enabled_default=False, + state_class=STATE_CLASS_MEASUREMENT, + cls=ToonBoilerDeviceSensor, + ), +) diff --git a/homeassistant/components/toon/switch.py b/homeassistant/components/toon/switch.py index 06ca9c6631b..de68b35befd 100644 --- a/homeassistant/components/toon/switch.py +++ b/homeassistant/components/toon/switch.py @@ -1,4 +1,7 @@ """Support for Toon switches.""" +from __future__ import annotations + +from dataclasses import dataclass from typing import Any from toonapi import ( @@ -8,21 +11,14 @@ from toonapi import ( PROGRAM_STATE_ON, ) -from homeassistant.components.switch import SwitchEntity +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import ( - ATTR_ICON, - ATTR_MEASUREMENT, - ATTR_NAME, - ATTR_SECTION, - DOMAIN, - SWITCH_ENTITIES, -) +from .const import DOMAIN from .coordinator import ToonDataUpdateCoordinator from .helpers import toon_exception_handler -from .models import ToonDisplayDeviceEntity, ToonEntity +from .models import ToonDisplayDeviceEntity, ToonEntity, ToonRequiredKeysMixin async def async_setup_entry( @@ -32,39 +28,38 @@ async def async_setup_entry( coordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( - [ToonProgramSwitch(coordinator), ToonHolidayModeSwitch(coordinator)] + [description.cls(coordinator, description) for description in SWITCH_ENTITIES] ) class ToonSwitch(ToonEntity, SwitchEntity): """Defines an Toon switch.""" - def __init__(self, coordinator: ToonDataUpdateCoordinator, *, key: str) -> None: + entity_description: ToonSwitchEntityDescription + + def __init__( + self, + coordinator: ToonDataUpdateCoordinator, + description: ToonSwitchEntityDescription, + ) -> None: """Initialize the Toon switch.""" - self.key = key + self.entity_description = description super().__init__(coordinator) - switch = SWITCH_ENTITIES[key] - self._attr_icon = switch[ATTR_ICON] - self._attr_name = switch[ATTR_NAME] - self._attr_unique_id = f"{coordinator.data.agreement.agreement_id}_{key}" + self._attr_unique_id = ( + f"{coordinator.data.agreement.agreement_id}_{description.key}" + ) @property def is_on(self) -> bool: """Return the status of the binary sensor.""" - section = getattr( - self.coordinator.data, SWITCH_ENTITIES[self.key][ATTR_SECTION] - ) - return getattr(section, SWITCH_ENTITIES[self.key][ATTR_MEASUREMENT]) + section = getattr(self.coordinator.data, self.entity_description.section) + return getattr(section, self.entity_description.measurement) class ToonProgramSwitch(ToonSwitch, ToonDisplayDeviceEntity): """Defines a Toon program switch.""" - def __init__(self, coordinator: ToonDataUpdateCoordinator) -> None: - """Initialize the Toon program switch.""" - super().__init__(coordinator, key="thermostat_program") - @toon_exception_handler async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the Toon program switch.""" @@ -83,10 +78,6 @@ class ToonProgramSwitch(ToonSwitch, ToonDisplayDeviceEntity): class ToonHolidayModeSwitch(ToonSwitch, ToonDisplayDeviceEntity): """Defines a Toon Holiday mode switch.""" - def __init__(self, coordinator: ToonDataUpdateCoordinator) -> None: - """Initialize the Toon holiday switch.""" - super().__init__(coordinator, key="thermostat_holiday_mode") - @toon_exception_handler async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the Toon holiday mode switch.""" @@ -100,3 +91,35 @@ class ToonHolidayModeSwitch(ToonSwitch, ToonDisplayDeviceEntity): await self.coordinator.toon.set_active_state( ACTIVE_STATE_HOLIDAY, PROGRAM_STATE_OFF ) + + +@dataclass +class ToonSwitchRequiredKeysMixin(ToonRequiredKeysMixin): + """Mixin for switch required keys.""" + + cls: type[ToonSwitch] + + +@dataclass +class ToonSwitchEntityDescription(SwitchEntityDescription, ToonSwitchRequiredKeysMixin): + """Describes Toon switch entity.""" + + +SWITCH_ENTITIES: tuple[ToonSwitchEntityDescription, ...] = ( + ToonSwitchEntityDescription( + key="thermostat_holiday_mode", + name="Holiday Mode", + section="thermostat", + measurement="holiday_mode", + icon="mdi:airport", + cls=ToonHolidayModeSwitch, + ), + ToonSwitchEntityDescription( + key="thermostat_program", + name="Thermostat Program", + section="thermostat", + measurement="program", + icon="mdi:calendar-clock", + cls=ToonProgramSwitch, + ), +) From 0dcd8b32abaf87245ddf67996944c2e657ecba48 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 27 Sep 2021 19:40:55 +0200 Subject: [PATCH 637/843] Use EntityDescription - meteo_france (#55677) --- .../components/meteo_france/const.py | 248 +++++++++--------- .../components/meteo_france/sensor.py | 134 ++++------ 2 files changed, 179 insertions(+), 203 deletions(-) diff --git a/homeassistant/components/meteo_france/const.py b/homeassistant/components/meteo_france/const.py index a2e9eeb2799..09b48bc1b3e 100644 --- a/homeassistant/components/meteo_france/const.py +++ b/homeassistant/components/meteo_france/const.py @@ -1,5 +1,9 @@ """Meteo-France component constants.""" +from __future__ import annotations +from dataclasses import dataclass + +from homeassistant.components.sensor import SensorEntityDescription from homeassistant.components.weather import ( ATTR_CONDITION_CLEAR_NIGHT, ATTR_CONDITION_CLOUDY, @@ -47,127 +51,131 @@ FORECAST_MODE = [FORECAST_MODE_HOURLY, FORECAST_MODE_DAILY] ATTR_NEXT_RAIN_1_HOUR_FORECAST = "1_hour_forecast" ATTR_NEXT_RAIN_DT_REF = "forecast_time_ref" -ENTITY_NAME = "name" -ENTITY_UNIT = "unit" -ENTITY_ICON = "icon" -ENTITY_DEVICE_CLASS = "device_class" -ENTITY_ENABLE = "enable" -ENTITY_API_DATA_PATH = "data_path" -SENSOR_TYPES = { - "pressure": { - ENTITY_NAME: "Pressure", - ENTITY_UNIT: PRESSURE_HPA, - ENTITY_ICON: None, - ENTITY_DEVICE_CLASS: DEVICE_CLASS_PRESSURE, - ENTITY_ENABLE: False, - ENTITY_API_DATA_PATH: "current_forecast:sea_level", - }, - "rain_chance": { - ENTITY_NAME: "Rain chance", - ENTITY_UNIT: PERCENTAGE, - ENTITY_ICON: "mdi:weather-rainy", - ENTITY_DEVICE_CLASS: None, - ENTITY_ENABLE: True, - ENTITY_API_DATA_PATH: "probability_forecast:rain:3h", - }, - "snow_chance": { - ENTITY_NAME: "Snow chance", - ENTITY_UNIT: PERCENTAGE, - ENTITY_ICON: "mdi:weather-snowy", - ENTITY_DEVICE_CLASS: None, - ENTITY_ENABLE: True, - ENTITY_API_DATA_PATH: "probability_forecast:snow:3h", - }, - "freeze_chance": { - ENTITY_NAME: "Freeze chance", - ENTITY_UNIT: PERCENTAGE, - ENTITY_ICON: "mdi:snowflake", - ENTITY_DEVICE_CLASS: None, - ENTITY_ENABLE: True, - ENTITY_API_DATA_PATH: "probability_forecast:freezing", - }, - "wind_gust": { - ENTITY_NAME: "Wind gust", - ENTITY_UNIT: SPEED_KILOMETERS_PER_HOUR, - ENTITY_ICON: "mdi:weather-windy-variant", - ENTITY_DEVICE_CLASS: None, - ENTITY_ENABLE: False, - ENTITY_API_DATA_PATH: "current_forecast:wind:gust", - }, - "wind_speed": { - ENTITY_NAME: "Wind speed", - ENTITY_UNIT: SPEED_KILOMETERS_PER_HOUR, - ENTITY_ICON: "mdi:weather-windy", - ENTITY_DEVICE_CLASS: None, - ENTITY_ENABLE: False, - ENTITY_API_DATA_PATH: "current_forecast:wind:speed", - }, - "next_rain": { - ENTITY_NAME: "Next rain", - ENTITY_UNIT: None, - ENTITY_ICON: None, - ENTITY_DEVICE_CLASS: DEVICE_CLASS_TIMESTAMP, - ENTITY_ENABLE: True, - ENTITY_API_DATA_PATH: None, - }, - "temperature": { - ENTITY_NAME: "Temperature", - ENTITY_UNIT: TEMP_CELSIUS, - ENTITY_ICON: None, - ENTITY_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ENTITY_ENABLE: False, - ENTITY_API_DATA_PATH: "current_forecast:T:value", - }, - "uv": { - ENTITY_NAME: "UV", - ENTITY_UNIT: UV_INDEX, - ENTITY_ICON: "mdi:sunglasses", - ENTITY_DEVICE_CLASS: None, - ENTITY_ENABLE: True, - ENTITY_API_DATA_PATH: "today_forecast:uv", - }, - "weather_alert": { - ENTITY_NAME: "Weather alert", - ENTITY_UNIT: None, - ENTITY_ICON: "mdi:weather-cloudy-alert", - ENTITY_DEVICE_CLASS: None, - ENTITY_ENABLE: True, - ENTITY_API_DATA_PATH: None, - }, - "precipitation": { - ENTITY_NAME: "Daily precipitation", - ENTITY_UNIT: LENGTH_MILLIMETERS, - ENTITY_ICON: "mdi:cup-water", - ENTITY_DEVICE_CLASS: None, - ENTITY_ENABLE: True, - ENTITY_API_DATA_PATH: "today_forecast:precipitation:24h", - }, - "cloud": { - ENTITY_NAME: "Cloud cover", - ENTITY_UNIT: PERCENTAGE, - ENTITY_ICON: "mdi:weather-partly-cloudy", - ENTITY_DEVICE_CLASS: None, - ENTITY_ENABLE: True, - ENTITY_API_DATA_PATH: "current_forecast:clouds", - }, - "original_condition": { - ENTITY_NAME: "Original condition", - ENTITY_UNIT: None, - ENTITY_ICON: None, - ENTITY_DEVICE_CLASS: None, - ENTITY_ENABLE: False, - ENTITY_API_DATA_PATH: "current_forecast:weather:desc", - }, - "daily_original_condition": { - ENTITY_NAME: "Daily original condition", - ENTITY_UNIT: None, - ENTITY_ICON: None, - ENTITY_DEVICE_CLASS: None, - ENTITY_ENABLE: False, - ENTITY_API_DATA_PATH: "today_forecast:weather12H:desc", - }, -} +@dataclass +class MeteoFranceRequiredKeysMixin: + """Mixin for required keys.""" + + data_path: str + + +@dataclass +class MeteoFranceSensorEntityDescription( + SensorEntityDescription, MeteoFranceRequiredKeysMixin +): + """Describes Meteo-France sensor entity.""" + + +SENSOR_TYPES: tuple[MeteoFranceSensorEntityDescription, ...] = ( + MeteoFranceSensorEntityDescription( + key="pressure", + name="Pressure", + native_unit_of_measurement=PRESSURE_HPA, + device_class=DEVICE_CLASS_PRESSURE, + entity_registry_enabled_default=False, + data_path="current_forecast:sea_level", + ), + MeteoFranceSensorEntityDescription( + key="wind_gust", + name="Wind gust", + native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, + icon="mdi:weather-windy-variant", + entity_registry_enabled_default=False, + data_path="current_forecast:wind:gust", + ), + MeteoFranceSensorEntityDescription( + key="wind_speed", + name="Wind speed", + native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, + icon="mdi:weather-windy", + entity_registry_enabled_default=False, + data_path="current_forecast:wind:speed", + ), + MeteoFranceSensorEntityDescription( + key="temperature", + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + entity_registry_enabled_default=False, + data_path="current_forecast:T:value", + ), + MeteoFranceSensorEntityDescription( + key="uv", + name="UV", + native_unit_of_measurement=UV_INDEX, + icon="mdi:sunglasses", + data_path="today_forecast:uv", + ), + MeteoFranceSensorEntityDescription( + key="precipitation", + name="Daily precipitation", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:cup-water", + data_path="today_forecast:precipitation:24h", + ), + MeteoFranceSensorEntityDescription( + key="cloud", + name="Cloud cover", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:weather-partly-cloudy", + data_path="current_forecast:clouds", + ), + MeteoFranceSensorEntityDescription( + key="original_condition", + name="Original condition", + entity_registry_enabled_default=False, + data_path="current_forecast:weather:desc", + ), + MeteoFranceSensorEntityDescription( + key="daily_original_condition", + name="Daily original condition", + entity_registry_enabled_default=False, + data_path="today_forecast:weather12H:desc", + ), +) + +SENSOR_TYPES_RAIN: tuple[MeteoFranceSensorEntityDescription, ...] = ( + MeteoFranceSensorEntityDescription( + key="next_rain", + name="Next rain", + device_class=DEVICE_CLASS_TIMESTAMP, + data_path="", + ), +) + +SENSOR_TYPES_ALERT: tuple[MeteoFranceSensorEntityDescription, ...] = ( + MeteoFranceSensorEntityDescription( + key="weather_alert", + name="Weather alert", + icon="mdi:weather-cloudy-alert", + data_path="", + ), +) + +SENSOR_TYPES_PROBABILITY: tuple[MeteoFranceSensorEntityDescription, ...] = ( + MeteoFranceSensorEntityDescription( + key="rain_chance", + name="Rain chance", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:weather-rainy", + data_path="probability_forecast:rain:3h", + ), + MeteoFranceSensorEntityDescription( + key="snow_chance", + name="Snow chance", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:weather-snowy", + data_path="probability_forecast:snow:3h", + ), + MeteoFranceSensorEntityDescription( + key="freeze_chance", + name="Freeze chance", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:snowflake", + data_path="probability_forecast:freezing", + ), +) + CONDITION_CLASSES = { ATTR_CONDITION_CLEAR_NIGHT: ["Nuit Claire", "Nuit claire"], diff --git a/homeassistant/components/meteo_france/sensor.py b/homeassistant/components/meteo_france/sensor.py index df006c78194..9f24cf02a2c 100644 --- a/homeassistant/components/meteo_france/sensor.py +++ b/homeassistant/components/meteo_france/sensor.py @@ -1,6 +1,4 @@ """Support for Meteo-France raining forecast sensor.""" -import logging - from meteofrance_api.helpers import ( get_warning_text_status_from_indice_color, readeable_phenomenoms_dict, @@ -24,19 +22,15 @@ from .const import ( COORDINATOR_FORECAST, COORDINATOR_RAIN, DOMAIN, - ENTITY_API_DATA_PATH, - ENTITY_DEVICE_CLASS, - ENTITY_ENABLE, - ENTITY_ICON, - ENTITY_NAME, - ENTITY_UNIT, MANUFACTURER, MODEL, SENSOR_TYPES, + SENSOR_TYPES_ALERT, + SENSOR_TYPES_PROBABILITY, + SENSOR_TYPES_RAIN, + MeteoFranceSensorEntityDescription, ) -_LOGGER = logging.getLogger(__name__) - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities @@ -46,56 +40,51 @@ async def async_setup_entry( coordinator_rain = hass.data[DOMAIN][entry.entry_id][COORDINATOR_RAIN] coordinator_alert = hass.data[DOMAIN][entry.entry_id][COORDINATOR_ALERT] - entities = [] - for sensor_type in SENSOR_TYPES: - if sensor_type == "next_rain": - if coordinator_rain: - entities.append(MeteoFranceRainSensor(sensor_type, coordinator_rain)) - - elif sensor_type == "weather_alert": - if coordinator_alert: - entities.append(MeteoFranceAlertSensor(sensor_type, coordinator_alert)) - - elif sensor_type in ("rain_chance", "freeze_chance", "snow_chance"): - if coordinator_forecast.data.probability_forecast: - entities.append(MeteoFranceSensor(sensor_type, coordinator_forecast)) - else: - _LOGGER.warning( - "Sensor %s skipped for %s as data is missing in the API", - sensor_type, - coordinator_forecast.data.position["name"], - ) - - else: - entities.append(MeteoFranceSensor(sensor_type, coordinator_forecast)) - - async_add_entities( - entities, - False, + entities = [ + MeteoFranceSensor(coordinator_forecast, description) + for description in SENSOR_TYPES + ] + entities.extend( + [ + MeteoFranceRainSensor(coordinator_rain, description) + for description in SENSOR_TYPES_RAIN + ] ) + entities.extend( + [ + MeteoFranceAlertSensor(coordinator_alert, description) + for description in SENSOR_TYPES_ALERT + ] + ) + if coordinator_forecast.data.probability_forecast: + entities.extend( + [ + MeteoFranceSensor(coordinator_forecast, description) + for description in SENSOR_TYPES_PROBABILITY + ] + ) + + async_add_entities(entities, False) class MeteoFranceSensor(CoordinatorEntity, SensorEntity): """Representation of a Meteo-France sensor.""" - def __init__(self, sensor_type: str, coordinator: DataUpdateCoordinator) -> None: + entity_description: MeteoFranceSensorEntityDescription + + def __init__( + self, + coordinator: DataUpdateCoordinator, + description: MeteoFranceSensorEntityDescription, + ) -> None: """Initialize the Meteo-France sensor.""" super().__init__(coordinator) - self._type = sensor_type - if hasattr(self.coordinator.data, "position"): - city_name = self.coordinator.data.position["name"] - self._name = f"{city_name} {SENSOR_TYPES[self._type][ENTITY_NAME]}" - self._unique_id = f"{self.coordinator.data.position['lat']},{self.coordinator.data.position['lon']}_{self._type}" - - @property - def unique_id(self): - """Return the unique id.""" - return self._unique_id - - @property - def name(self): - """Return the name.""" - return self._name + self.entity_description = description + if hasattr(coordinator.data, "position"): + city_name = coordinator.data.position["name"] + self._attr_name = f"{city_name} {description.name}" + self._attr_unique_id = f"{coordinator.data.position['lat']},{coordinator.data.position['lon']}_{description.key}" + self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} @property def device_info(self): @@ -111,7 +100,7 @@ class MeteoFranceSensor(CoordinatorEntity, SensorEntity): @property def native_value(self): """Return the state.""" - path = SENSOR_TYPES[self._type][ENTITY_API_DATA_PATH].split(":") + path = self.entity_description.data_path.split(":") data = getattr(self.coordinator.data, path[0]) # Specific case for probability forecast @@ -129,36 +118,11 @@ class MeteoFranceSensor(CoordinatorEntity, SensorEntity): else: value = data[path[1]] - if self._type in ("wind_speed", "wind_gust"): + if self.entity_description.key in ("wind_speed", "wind_gust"): # convert API wind speed from m/s to km/h value = round(value * 3.6) return value - @property - def native_unit_of_measurement(self): - """Return the unit of measurement.""" - return SENSOR_TYPES[self._type][ENTITY_UNIT] - - @property - def icon(self): - """Return the icon.""" - return SENSOR_TYPES[self._type][ENTITY_ICON] - - @property - def device_class(self): - """Return the device class.""" - return SENSOR_TYPES[self._type][ENTITY_DEVICE_CLASS] - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return SENSOR_TYPES[self._type][ENTITY_ENABLE] - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return {ATTR_ATTRIBUTION: ATTRIBUTION} - class MeteoFranceRainSensor(MeteoFranceSensor): """Representation of a Meteo-France rain sensor.""" @@ -194,12 +158,16 @@ class MeteoFranceRainSensor(MeteoFranceSensor): class MeteoFranceAlertSensor(MeteoFranceSensor): """Representation of a Meteo-France alert sensor.""" - def __init__(self, sensor_type: str, coordinator: DataUpdateCoordinator) -> None: + def __init__( + self, + coordinator: DataUpdateCoordinator, + description: MeteoFranceSensorEntityDescription, + ) -> None: """Initialize the Meteo-France sensor.""" - super().__init__(sensor_type, coordinator) + super().__init__(coordinator, description) dept_code = self.coordinator.data.domain_id - self._name = f"{dept_code} {SENSOR_TYPES[self._type][ENTITY_NAME]}" - self._unique_id = self._name + self._attr_name = f"{dept_code} {description.name}" + self._attr_unique_id = self._attr_name @property def native_value(self): From f0e0b41f77b9ab41707cc297294e7b52efb51887 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Mon, 27 Sep 2021 14:20:15 -0400 Subject: [PATCH 638/843] Use entity attributes for vizio integration (#56093) --- .../components/vizio/media_player.py | 203 ++++++------------ tests/components/vizio/test_media_player.py | 33 +-- 2 files changed, 83 insertions(+), 153 deletions(-) diff --git a/homeassistant/components/vizio/media_player.py b/homeassistant/components/vizio/media_player.py index 05caae0ec08..c60ae4582ad 100644 --- a/homeassistant/components/vizio/media_player.py +++ b/homeassistant/components/vizio/media_player.py @@ -33,7 +33,6 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -142,16 +141,9 @@ class VizioDevice(MediaPlayerEntity): self._config_entry = config_entry self._apps_coordinator = apps_coordinator - self._name = name - self._state = None - self._volume_level = None self._volume_step = config_entry.options[CONF_VOLUME_STEP] - self._is_volume_muted = None self._current_input = None - self._current_app = None self._current_app_config = None - self._current_sound_mode = None - self._available_sound_modes = [] self._available_inputs = [] self._available_apps = [] self._all_apps = apps_coordinator.data if apps_coordinator else None @@ -159,14 +151,19 @@ class VizioDevice(MediaPlayerEntity): self._additional_app_configs = config_entry.data.get(CONF_APPS, {}).get( CONF_ADDITIONAL_CONFIGS, [] ) - self._device_class = device_class - self._supported_commands = SUPPORTED_COMMANDS[device_class] self._device = device self._max_volume = float(self._device.get_max_volume()) - self._icon = ICON[device_class] - self._available = True - self._model = None - self._sw_version = None + + # Entity class attributes that will change with each update (we only include + # the ones that are initialized differently from the defaults) + self._attr_sound_mode_list = [] + self._attr_supported_features = SUPPORTED_COMMANDS[device_class] + + # Entity class attributes that will not change + self._attr_name = name + self._attr_icon = ICON[device_class] + self._attr_unique_id = self._config_entry.unique_id + self._attr_device_class = device_class def _apps_list(self, apps: list[str]) -> list[str]: """Return process apps list based on configured filters.""" @@ -183,64 +180,67 @@ class VizioDevice(MediaPlayerEntity): is_on = await self._device.get_power_state(log_api_exception=False) if is_on is None: - if self._available: + if self._attr_available: _LOGGER.warning( "Lost connection to %s", self._config_entry.data[CONF_HOST] ) - self._available = False + self._attr_available = False return - if not self._available: + if not self._attr_available: _LOGGER.info( "Restored connection to %s", self._config_entry.data[CONF_HOST] ) - self._available = True + self._attr_available = True - if not self._model: - self._model = await self._device.get_model_name(log_api_exception=False) - - if not self._sw_version: - self._sw_version = await self._device.get_version(log_api_exception=False) + if not self._attr_device_info: + self._attr_device_info = { + "identifiers": {(DOMAIN, self._attr_unique_id)}, + "name": self._attr_name, + "manufacturer": "VIZIO", + "model": await self._device.get_model_name(log_api_exception=False), + "sw_version": await self._device.get_version(log_api_exception=False), + } if not is_on: - self._state = STATE_OFF - self._volume_level = None - self._is_volume_muted = None + self._attr_state = STATE_OFF + self._attr_volume_level = None + self._attr_is_volume_muted = None self._current_input = None - self._current_app = None + self._attr_app_name = None self._current_app_config = None - self._current_sound_mode = None + self._attr_sound_mode = None return - self._state = STATE_ON + self._attr_state = STATE_ON audio_settings = await self._device.get_all_settings( VIZIO_AUDIO_SETTINGS, log_api_exception=False ) if audio_settings: - self._volume_level = float(audio_settings[VIZIO_VOLUME]) / self._max_volume + self._attr_volume_level = ( + float(audio_settings[VIZIO_VOLUME]) / self._max_volume + ) if VIZIO_MUTE in audio_settings: - self._is_volume_muted = ( + self._attr_is_volume_muted = ( audio_settings[VIZIO_MUTE].lower() == VIZIO_MUTE_ON ) else: - self._is_volume_muted = None + self._attr_is_volume_muted = None if VIZIO_SOUND_MODE in audio_settings: - self._supported_commands |= SUPPORT_SELECT_SOUND_MODE - self._current_sound_mode = audio_settings[VIZIO_SOUND_MODE] - if not self._available_sound_modes: - self._available_sound_modes = ( - await self._device.get_setting_options( - VIZIO_AUDIO_SETTINGS, - VIZIO_SOUND_MODE, - log_api_exception=False, - ) + self._attr_supported_features |= SUPPORT_SELECT_SOUND_MODE + self._attr_sound_mode = audio_settings[VIZIO_SOUND_MODE] + if not self._attr_sound_mode_list: + self._attr_sound_mode_list = await self._device.get_setting_options( + VIZIO_AUDIO_SETTINGS, + VIZIO_SOUND_MODE, + log_api_exception=False, ) else: # Explicitly remove SUPPORT_SELECT_SOUND_MODE from supported features - self._supported_commands &= ~SUPPORT_SELECT_SOUND_MODE + self._attr_supported_features &= ~SUPPORT_SELECT_SOUND_MODE input_ = await self._device.get_current_input(log_api_exception=False) if input_: @@ -255,7 +255,7 @@ class VizioDevice(MediaPlayerEntity): self._available_inputs = [input_.name for input_ in inputs] # Return before setting app variables if INPUT_APPS isn't in available inputs - if self._device_class == DEVICE_CLASS_SPEAKER or not any( + if self._attr_device_class == DEVICE_CLASS_SPEAKER or not any( app for app in INPUT_APPS if app in self._available_inputs ): return @@ -268,13 +268,13 @@ class VizioDevice(MediaPlayerEntity): log_api_exception=False ) - self._current_app = find_app_name( + self._attr_app_name = find_app_name( self._current_app_config, [APP_HOME, *self._all_apps, *self._additional_app_configs], ) - if self._current_app == NO_APP_RUNNING: - self._current_app = None + if self._attr_app_name == NO_APP_RUNNING: + self._attr_app_name = None def _get_additional_app_names(self) -> list[dict[str, Any]]: """Return list of additional apps that were included in configuration.yaml.""" @@ -331,46 +331,16 @@ class VizioDevice(MediaPlayerEntity): self._all_apps = self._apps_coordinator.data self.async_write_ha_state() - if self._device_class == DEVICE_CLASS_TV: + if self._attr_device_class == DEVICE_CLASS_TV: self.async_on_remove( self._apps_coordinator.async_add_listener(apps_list_update) ) - @property - def available(self) -> bool: - """Return the availabiliity of the device.""" - return self._available - - @property - def state(self) -> str | None: - """Return the state of the device.""" - return self._state - - @property - def name(self) -> str: - """Return the name of the device.""" - return self._name - - @property - def icon(self) -> str: - """Return the icon of the device.""" - return self._icon - - @property - def volume_level(self) -> float | None: - """Return the volume level of the device.""" - return self._volume_level - - @property - def is_volume_muted(self): - """Boolean if volume is currently muted.""" - return self._is_volume_muted - @property def source(self) -> str | None: """Return current input of the device.""" - if self._current_app is not None and self._current_input in INPUT_APPS: - return self._current_app + if self._attr_app_name is not None and self._current_input in INPUT_APPS: + return self._attr_app_name return self._current_input @@ -378,7 +348,7 @@ class VizioDevice(MediaPlayerEntity): def source_list(self) -> list[str]: """Return list of available inputs of the device.""" # If Smartcast app is in input list, and the app list has been retrieved, - # show the combination with , otherwise just return inputs + # show the combination with, otherwise just return inputs if self._available_apps: return [ *( @@ -408,50 +378,9 @@ class VizioDevice(MediaPlayerEntity): return None - @property - def app_name(self) -> str | None: - """Return the friendly name of the current app.""" - return self._current_app - - @property - def supported_features(self) -> int: - """Flag device features that are supported.""" - return self._supported_commands - - @property - def unique_id(self) -> str: - """Return the unique id of the device.""" - return self._config_entry.unique_id - - @property - def device_info(self) -> DeviceInfo: - """Return device registry information.""" - return { - "identifiers": {(DOMAIN, self._config_entry.unique_id)}, - "name": self.name, - "manufacturer": "VIZIO", - "model": self._model, - "sw_version": self._sw_version, - } - - @property - def device_class(self) -> str: - """Return device class for entity.""" - return self._device_class - - @property - def sound_mode(self) -> str | None: - """Name of the current sound mode.""" - return self._current_sound_mode - - @property - def sound_mode_list(self) -> list[str] | None: - """List of available sound modes.""" - return self._available_sound_modes - async def async_select_sound_mode(self, sound_mode): """Select sound mode.""" - if sound_mode in self._available_sound_modes: + if sound_mode in self._attr_sound_mode_list: await self._device.set_setting( VIZIO_AUDIO_SETTINGS, VIZIO_SOUND_MODE, @@ -471,10 +400,10 @@ class VizioDevice(MediaPlayerEntity): """Mute the volume.""" if mute: await self._device.mute_on(log_api_exception=False) - self._is_volume_muted = True + self._attr_is_volume_muted = True else: await self._device.mute_off(log_api_exception=False) - self._is_volume_muted = False + self._attr_is_volume_muted = False async def async_media_previous_track(self) -> None: """Send previous channel command.""" @@ -506,29 +435,29 @@ class VizioDevice(MediaPlayerEntity): """Increase volume of the device.""" await self._device.vol_up(num=self._volume_step, log_api_exception=False) - if self._volume_level is not None: - self._volume_level = min( - 1.0, self._volume_level + self._volume_step / self._max_volume + if self._attr_volume_level is not None: + self._attr_volume_level = min( + 1.0, self._attr_volume_level + self._volume_step / self._max_volume ) async def async_volume_down(self) -> None: """Decrease volume of the device.""" await self._device.vol_down(num=self._volume_step, log_api_exception=False) - if self._volume_level is not None: - self._volume_level = max( - 0.0, self._volume_level - self._volume_step / self._max_volume + if self._attr_volume_level is not None: + self._attr_volume_level = max( + 0.0, self._attr_volume_level - self._volume_step / self._max_volume ) async def async_set_volume_level(self, volume: float) -> None: """Set volume level.""" - if self._volume_level is not None: - if volume > self._volume_level: - num = int(self._max_volume * (volume - self._volume_level)) + if self._attr_volume_level is not None: + if volume > self._attr_volume_level: + num = int(self._max_volume * (volume - self._attr_volume_level)) await self._device.vol_up(num=num, log_api_exception=False) - self._volume_level = volume + self._attr_volume_level = volume - elif volume < self._volume_level: - num = int(self._max_volume * (self._volume_level - volume)) + elif volume < self._attr_volume_level: + num = int(self._max_volume * (self._attr_volume_level - volume)) await self._device.vol_down(num=num, log_api_exception=False) - self._volume_level = volume + self._attr_volume_level = volume diff --git a/tests/components/vizio/test_media_player.py b/tests/components/vizio/test_media_player.py index c137f112976..7a030ade53f 100644 --- a/tests/components/vizio/test_media_player.py +++ b/tests/components/vizio/test_media_player.py @@ -38,6 +38,7 @@ from homeassistant.components.media_player import ( SERVICE_VOLUME_SET, SERVICE_VOLUME_UP, ) +from homeassistant.components.media_player.const import ATTR_INPUT_SOURCE_LIST from homeassistant.components.vizio import validate_apps from homeassistant.components.vizio.const import ( CONF_ADDITIONAL_CONFIGS, @@ -102,8 +103,8 @@ def _get_ha_power_state(vizio_power_state: bool | None) -> str: def _assert_sources_and_volume(attr: dict[str, Any], vizio_device_class: str) -> None: """Assert source list, source, and volume level based on attr dict and device class.""" - assert attr["source_list"] == INPUT_LIST - assert attr["source"] == CURRENT_INPUT + assert attr[ATTR_INPUT_SOURCE_LIST] == INPUT_LIST + assert attr[ATTR_INPUT_SOURCE] == CURRENT_INPUT assert ( attr["volume_level"] == float(int(MAX_VOLUME[vizio_device_class] / 2)) @@ -236,7 +237,7 @@ def _assert_source_list_with_apps( if app_to_remove in list_to_test: list_to_test.remove(app_to_remove) - assert attr["source_list"] == list_to_test + assert attr[ATTR_INPUT_SOURCE_LIST] == list_to_test async def _test_service( @@ -533,8 +534,8 @@ async def test_setup_with_apps( ): attr = hass.states.get(ENTITY_ID).attributes _assert_source_list_with_apps(list(INPUT_LIST_WITH_APPS + APP_NAME_LIST), attr) - assert CURRENT_APP in attr["source_list"] - assert attr["source"] == CURRENT_APP + assert CURRENT_APP in attr[ATTR_INPUT_SOURCE_LIST] + assert attr[ATTR_INPUT_SOURCE] == CURRENT_APP assert attr["app_name"] == CURRENT_APP assert "app_id" not in attr @@ -561,8 +562,8 @@ async def test_setup_with_apps_include( ): attr = hass.states.get(ENTITY_ID).attributes _assert_source_list_with_apps(list(INPUT_LIST_WITH_APPS + [CURRENT_APP]), attr) - assert CURRENT_APP in attr["source_list"] - assert attr["source"] == CURRENT_APP + assert CURRENT_APP in attr[ATTR_INPUT_SOURCE_LIST] + assert attr[ATTR_INPUT_SOURCE] == CURRENT_APP assert attr["app_name"] == CURRENT_APP assert "app_id" not in attr @@ -579,8 +580,8 @@ async def test_setup_with_apps_exclude( ): attr = hass.states.get(ENTITY_ID).attributes _assert_source_list_with_apps(list(INPUT_LIST_WITH_APPS + [CURRENT_APP]), attr) - assert CURRENT_APP in attr["source_list"] - assert attr["source"] == CURRENT_APP + assert CURRENT_APP in attr[ATTR_INPUT_SOURCE_LIST] + assert attr[ATTR_INPUT_SOURCE] == CURRENT_APP assert attr["app_name"] == CURRENT_APP assert "app_id" not in attr @@ -598,7 +599,7 @@ async def test_setup_with_apps_additional_apps_config( ADDITIONAL_APP_CONFIG["config"], ): attr = hass.states.get(ENTITY_ID).attributes - assert attr["source_list"].count(CURRENT_APP) == 1 + assert attr[ATTR_INPUT_SOURCE_LIST].count(CURRENT_APP) == 1 _assert_source_list_with_apps( list( INPUT_LIST_WITH_APPS @@ -613,8 +614,8 @@ async def test_setup_with_apps_additional_apps_config( ), attr, ) - assert ADDITIONAL_APP_CONFIG["name"] in attr["source_list"] - assert attr["source"] == ADDITIONAL_APP_CONFIG["name"] + assert ADDITIONAL_APP_CONFIG["name"] in attr[ATTR_INPUT_SOURCE_LIST] + assert attr[ATTR_INPUT_SOURCE] == ADDITIONAL_APP_CONFIG["name"] assert attr["app_name"] == ADDITIONAL_APP_CONFIG["name"] assert "app_id" not in attr @@ -673,7 +674,7 @@ async def test_setup_with_unknown_app_config( ): attr = hass.states.get(ENTITY_ID).attributes _assert_source_list_with_apps(list(INPUT_LIST_WITH_APPS + APP_NAME_LIST), attr) - assert attr["source"] == UNKNOWN_APP + assert attr[ATTR_INPUT_SOURCE] == UNKNOWN_APP assert attr["app_name"] == UNKNOWN_APP assert attr["app_id"] == UNKNOWN_APP_CONFIG @@ -690,7 +691,7 @@ async def test_setup_with_no_running_app( ): attr = hass.states.get(ENTITY_ID).attributes _assert_source_list_with_apps(list(INPUT_LIST_WITH_APPS + APP_NAME_LIST), attr) - assert attr["source"] == "CAST" + assert attr[ATTR_INPUT_SOURCE] == "CAST" assert "app_id" not in attr assert "app_name" not in attr @@ -735,7 +736,7 @@ async def test_apps_update( ): # Check source list, remove TV inputs, and verify that the integration is # using the default APPS list - sources = hass.states.get(ENTITY_ID).attributes["source_list"] + sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] apps = list(set(sources) - set(INPUT_LIST)) assert len(apps) == len(APPS) @@ -747,6 +748,6 @@ async def test_apps_update( await hass.async_block_till_done() # Check source list, remove TV inputs, and verify that the integration is # now using the APP_LIST list - sources = hass.states.get(ENTITY_ID).attributes["source_list"] + sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] apps = list(set(sources) - set(INPUT_LIST)) assert len(apps) == len(APP_LIST) From 2f4e99266251d1ff1c8f3f1ad0bcdab67ab6fc82 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 27 Sep 2021 20:26:10 +0200 Subject: [PATCH 639/843] Use EntityDescription - daikin (#55929) --- homeassistant/components/daikin/const.py | 76 --------- homeassistant/components/daikin/sensor.py | 195 ++++++++++++---------- 2 files changed, 107 insertions(+), 164 deletions(-) diff --git a/homeassistant/components/daikin/const.py b/homeassistant/components/daikin/const.py index b03c8eb113d..e0222d308ea 100644 --- a/homeassistant/components/daikin/const.py +++ b/homeassistant/components/daikin/const.py @@ -1,21 +1,4 @@ """Constants for Daikin.""" -from homeassistant.const import ( - CONF_DEVICE_CLASS, - CONF_ICON, - CONF_NAME, - CONF_TYPE, - CONF_UNIT_OF_MEASUREMENT, - DEVICE_CLASS_ENERGY, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_POWER, - DEVICE_CLASS_TEMPERATURE, - ENERGY_KILO_WATT_HOUR, - FREQUENCY_HERTZ, - PERCENTAGE, - POWER_KILO_WATT, - TEMP_CELSIUS, -) - DOMAIN = "daikin" ATTR_TARGET_TEMPERATURE = "target_temperature" @@ -31,65 +14,6 @@ ATTR_COMPRESSOR_FREQUENCY = "compressor_frequency" ATTR_STATE_ON = "on" ATTR_STATE_OFF = "off" -SENSOR_TYPE_TEMPERATURE = "temperature" -SENSOR_TYPE_HUMIDITY = "humidity" -SENSOR_TYPE_POWER = "power" -SENSOR_TYPE_ENERGY = "energy" -SENSOR_TYPE_FREQUENCY = "frequency" - -SENSOR_TYPES = { - ATTR_INSIDE_TEMPERATURE: { - CONF_NAME: "Inside Temperature", - CONF_TYPE: SENSOR_TYPE_TEMPERATURE, - CONF_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - CONF_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, - }, - ATTR_OUTSIDE_TEMPERATURE: { - CONF_NAME: "Outside Temperature", - CONF_TYPE: SENSOR_TYPE_TEMPERATURE, - CONF_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - CONF_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, - }, - ATTR_HUMIDITY: { - CONF_NAME: "Humidity", - CONF_TYPE: SENSOR_TYPE_HUMIDITY, - CONF_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, - CONF_UNIT_OF_MEASUREMENT: PERCENTAGE, - }, - ATTR_TARGET_HUMIDITY: { - CONF_NAME: "Target Humidity", - CONF_TYPE: SENSOR_TYPE_HUMIDITY, - CONF_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, - CONF_UNIT_OF_MEASUREMENT: PERCENTAGE, - }, - ATTR_TOTAL_POWER: { - CONF_NAME: "Total Power Consumption", - CONF_TYPE: SENSOR_TYPE_POWER, - CONF_DEVICE_CLASS: DEVICE_CLASS_POWER, - CONF_UNIT_OF_MEASUREMENT: POWER_KILO_WATT, - }, - ATTR_COOL_ENERGY: { - CONF_NAME: "Cool Energy Consumption", - CONF_TYPE: SENSOR_TYPE_ENERGY, - CONF_ICON: "mdi:snowflake", - CONF_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - CONF_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, - }, - ATTR_HEAT_ENERGY: { - CONF_NAME: "Heat Energy Consumption", - CONF_TYPE: SENSOR_TYPE_ENERGY, - CONF_ICON: "mdi:fire", - CONF_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - CONF_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, - }, - ATTR_COMPRESSOR_FREQUENCY: { - CONF_NAME: "Compressor Frequency", - CONF_TYPE: SENSOR_TYPE_FREQUENCY, - CONF_ICON: "mdi:fan", - CONF_UNIT_OF_MEASUREMENT: FREQUENCY_HERTZ, - }, -} - CONF_UUID = "uuid" KEY_MAC = "mac" diff --git a/homeassistant/components/daikin/sensor.py b/homeassistant/components/daikin/sensor.py index 0defa633387..1b590b261b7 100644 --- a/homeassistant/components/daikin/sensor.py +++ b/homeassistant/components/daikin/sensor.py @@ -1,11 +1,22 @@ """Support for Daikin AC sensors.""" -from homeassistant.components.sensor import SensorEntity +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable + +from pydaikin.daikin_base import Appliance + +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import ( - CONF_DEVICE_CLASS, - CONF_ICON, - CONF_NAME, - CONF_TYPE, - CONF_UNIT_OF_MEASUREMENT, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_TEMPERATURE, + ENERGY_KILO_WATT_HOUR, + FREQUENCY_HERTZ, + PERCENTAGE, + POWER_KILO_WATT, + TEMP_CELSIUS, ) from . import DOMAIN as DAIKIN_DOMAIN, DaikinApi @@ -18,12 +29,80 @@ from .const import ( ATTR_OUTSIDE_TEMPERATURE, ATTR_TARGET_HUMIDITY, ATTR_TOTAL_POWER, - SENSOR_TYPE_ENERGY, - SENSOR_TYPE_FREQUENCY, - SENSOR_TYPE_HUMIDITY, - SENSOR_TYPE_POWER, - SENSOR_TYPE_TEMPERATURE, - SENSOR_TYPES, +) + + +@dataclass +class DaikinRequiredKeysMixin: + """Mixin for required keys.""" + + value_func: Callable[[Appliance], float | None] + + +@dataclass +class DaikinSensorEntityDescription(SensorEntityDescription, DaikinRequiredKeysMixin): + """Describes Daikin sensor entity.""" + + +SENSOR_TYPES: tuple[DaikinSensorEntityDescription, ...] = ( + DaikinSensorEntityDescription( + key=ATTR_INSIDE_TEMPERATURE, + name="Inside Temperature", + device_class=DEVICE_CLASS_TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + value_func=lambda device: device.inside_temperature, + ), + DaikinSensorEntityDescription( + key=ATTR_OUTSIDE_TEMPERATURE, + name="Outside Temperature", + device_class=DEVICE_CLASS_TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + value_func=lambda device: device.outside_temperature, + ), + DaikinSensorEntityDescription( + key=ATTR_HUMIDITY, + name="Humidity", + device_class=DEVICE_CLASS_HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + value_func=lambda device: device.humidity, + ), + DaikinSensorEntityDescription( + key=ATTR_TARGET_HUMIDITY, + name="Target Humidity", + device_class=DEVICE_CLASS_HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + value_func=lambda device: device.humidity, + ), + DaikinSensorEntityDescription( + key=ATTR_TOTAL_POWER, + name="Total Power Consumption", + device_class=DEVICE_CLASS_POWER, + native_unit_of_measurement=POWER_KILO_WATT, + value_func=lambda device: round(device.current_total_power_consumption, 2), + ), + DaikinSensorEntityDescription( + key=ATTR_COOL_ENERGY, + name="Cool Energy Consumption", + icon="mdi:snowflake", + device_class=DEVICE_CLASS_ENERGY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + value_func=lambda device: round(device.last_hour_cool_energy_consumption, 2), + ), + DaikinSensorEntityDescription( + key=ATTR_HEAT_ENERGY, + name="Heat Energy Consumption", + icon="mdi:fire", + device_class=DEVICE_CLASS_ENERGY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + value_func=lambda device: round(device.last_hour_heat_energy_consumption, 2), + ), + DaikinSensorEntityDescription( + key=ATTR_COMPRESSOR_FREQUENCY, + name="Compressor Frequency", + icon="mdi:fan", + native_unit_of_measurement=FREQUENCY_HERTZ, + value_func=lambda device: device.compressor_frequency, + ), ) @@ -50,60 +129,37 @@ async def async_setup_entry(hass, entry, async_add_entities): sensors.append(ATTR_TARGET_HUMIDITY) if daikin_api.device.support_compressor_frequency: sensors.append(ATTR_COMPRESSOR_FREQUENCY) - async_add_entities([DaikinSensor.factory(daikin_api, sensor) for sensor in sensors]) + + entities = [ + DaikinSensor(daikin_api, description) + for description in SENSOR_TYPES + if description.key in sensors + ] + async_add_entities(entities) class DaikinSensor(SensorEntity): """Representation of a Sensor.""" - @staticmethod - def factory(api: DaikinApi, monitored_state: str): - """Initialize any DaikinSensor.""" - cls = { - SENSOR_TYPE_TEMPERATURE: DaikinClimateSensor, - SENSOR_TYPE_HUMIDITY: DaikinClimateSensor, - SENSOR_TYPE_POWER: DaikinPowerSensor, - SENSOR_TYPE_ENERGY: DaikinPowerSensor, - SENSOR_TYPE_FREQUENCY: DaikinClimateSensor, - }[SENSOR_TYPES[monitored_state][CONF_TYPE]] - return cls(api, monitored_state) + entity_description: DaikinSensorEntityDescription - def __init__(self, api: DaikinApi, monitored_state: str) -> None: + def __init__( + self, api: DaikinApi, description: DaikinSensorEntityDescription + ) -> None: """Initialize the sensor.""" + self.entity_description = description self._api = api - self._sensor = SENSOR_TYPES[monitored_state] - self._name = f"{api.name} {self._sensor[CONF_NAME]}" - self._device_attribute = monitored_state + self._attr_name = f"{api.name} {description.name}" @property def unique_id(self): """Return a unique ID.""" - return f"{self._api.device.mac}-{self._device_attribute}" + return f"{self._api.device.mac}-{self.entity_description.key}" @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def native_value(self): + def native_value(self) -> float | None: """Return the state of the sensor.""" - raise NotImplementedError - - @property - def device_class(self): - """Return the class of this device.""" - return self._sensor.get(CONF_DEVICE_CLASS) - - @property - def icon(self): - """Return the icon of this device.""" - return self._sensor.get(CONF_ICON) - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement.""" - return self._sensor[CONF_UNIT_OF_MEASUREMENT] + return self.entity_description.value_func(self._api.device) async def async_update(self): """Retrieve latest state.""" @@ -113,40 +169,3 @@ class DaikinSensor(SensorEntity): def device_info(self): """Return a device description for device registry.""" return self._api.device_info - - -class DaikinClimateSensor(DaikinSensor): - """Representation of a Climate Sensor.""" - - @property - def native_value(self): - """Return the internal state of the sensor.""" - if self._device_attribute == ATTR_INSIDE_TEMPERATURE: - return self._api.device.inside_temperature - if self._device_attribute == ATTR_OUTSIDE_TEMPERATURE: - return self._api.device.outside_temperature - - if self._device_attribute == ATTR_HUMIDITY: - return self._api.device.humidity - if self._device_attribute == ATTR_TARGET_HUMIDITY: - return self._api.device.target_humidity - - if self._device_attribute == ATTR_COMPRESSOR_FREQUENCY: - return self._api.device.compressor_frequency - - return None - - -class DaikinPowerSensor(DaikinSensor): - """Representation of a power/energy consumption sensor.""" - - @property - def native_value(self): - """Return the state of the sensor.""" - if self._device_attribute == ATTR_TOTAL_POWER: - return round(self._api.device.current_total_power_consumption, 2) - if self._device_attribute == ATTR_COOL_ENERGY: - return round(self._api.device.last_hour_cool_energy_consumption, 2) - if self._device_attribute == ATTR_HEAT_ENERGY: - return round(self._api.device.last_hour_heat_energy_consumption, 2) - return None From 806fdc0095e40fdff85508719d9f0e058475cde3 Mon Sep 17 00:00:00 2001 From: Bastien Gautier Date: Mon, 27 Sep 2021 20:39:43 +0200 Subject: [PATCH 640/843] Patch coinbase (#56426) * Add Clover Finance coin to coinbase * Add Fetch.ai coin to coinbase * Fix typo --- homeassistant/components/coinbase/const.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/coinbase/const.py b/homeassistant/components/coinbase/const.py index 7fba24a4813..486da82dfcd 100644 --- a/homeassistant/components/coinbase/const.py +++ b/homeassistant/components/coinbase/const.py @@ -70,6 +70,7 @@ WALLETS = { "CHZ": "CHZ", "CLF": "CLF", "CLP": "CLP", + "CLV": "CLV", "CNH": "CNH", "CNY": "CNY", "COMP": "COMP", @@ -99,6 +100,7 @@ WALLETS = { "ETH": "ETH", "ETH2": "ETH2", "EUR": "EUR", + "FET": "FET", "FIL": "FIL", "FJD": "FJD", "FKP": "FKP", @@ -312,6 +314,7 @@ RATES = { "CHF": "CHF", "CLF": "CLF", "CLP": "CLP", + "CLV": "CLV", "CNH": "CNH", "CNY": "CNY", "COMP": "COMP", @@ -338,6 +341,7 @@ RATES = { "ETH": "ETH", "ETH2": "ETH2", "EUR": "EUR", + "FET": "FET", "FIL": "FIL", "FJD": "FJD", "FKP": "FKP", From 7a2bc130b77cd1d98883bf609867c1780c626d13 Mon Sep 17 00:00:00 2001 From: Hung Nguyen Date: Mon, 27 Sep 2021 12:10:09 -0700 Subject: [PATCH 641/843] Bump elkm1-lib to 1.0.0 (#56703) --- homeassistant/components/elkm1/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/elkm1/manifest.json b/homeassistant/components/elkm1/manifest.json index 3f72ecfd7a7..3b341d90669 100644 --- a/homeassistant/components/elkm1/manifest.json +++ b/homeassistant/components/elkm1/manifest.json @@ -2,7 +2,7 @@ "domain": "elkm1", "name": "Elk-M1 Control", "documentation": "https://www.home-assistant.io/integrations/elkm1", - "requirements": ["elkm1-lib==0.8.10"], + "requirements": ["elkm1-lib==1.0.0"], "codeowners": ["@gwww", "@bdraco"], "config_flow": true, "iot_class": "local_push" diff --git a/requirements_all.txt b/requirements_all.txt index d7a4ea5c001..ed0ea8541a8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -576,7 +576,7 @@ elgato==2.1.1 eliqonline==1.2.2 # homeassistant.components.elkm1 -elkm1-lib==0.8.10 +elkm1-lib==1.0.0 # homeassistant.components.mobile_app emoji==1.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1077c4f6ae1..b5cdb954240 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -342,7 +342,7 @@ dynalite_devices==0.1.46 elgato==2.1.1 # homeassistant.components.elkm1 -elkm1-lib==0.8.10 +elkm1-lib==1.0.0 # homeassistant.components.mobile_app emoji==1.5.0 From b40d229369d69bd4acec4f8bde0bf26129b852a1 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 27 Sep 2021 21:11:55 +0200 Subject: [PATCH 642/843] Rework TPLink integration to use python-kasa (#56701) Co-authored-by: J. Nick Koston Co-authored-by: Teemu R. Co-authored-by: Martin Hjelmare --- .coveragerc | 2 - .strict-typing | 1 + homeassistant/components/tplink/__init__.py | 330 ++---- homeassistant/components/tplink/common.py | 186 ---- .../components/tplink/config_flow.py | 184 +++- homeassistant/components/tplink/const.py | 32 +- .../components/tplink/coordinator.py | 57 + homeassistant/components/tplink/entity.py | 63 ++ homeassistant/components/tplink/light.py | 556 ++-------- homeassistant/components/tplink/manifest.json | 11 +- homeassistant/components/tplink/migration.py | 109 ++ homeassistant/components/tplink/sensor.py | 152 +-- homeassistant/components/tplink/strings.json | 21 +- homeassistant/components/tplink/switch.py | 103 +- .../components/tplink/translations/en.json | 23 +- homeassistant/generated/dhcp.py | 10 + mypy.ini | 14 +- requirements_all.txt | 6 +- requirements_test_all.txt | 6 +- script/hassfest/mypy_config.py | 1 - tests/components/tplink/__init__.py | 105 ++ tests/components/tplink/conftest.py | 27 +- tests/components/tplink/test_config_flow.py | 477 +++++++++ tests/components/tplink/test_init.py | 438 +------- tests/components/tplink/test_light.py | 974 +++++------------- tests/components/tplink/test_migration.py | 241 +++++ tests/components/tplink/test_sensor.py | 122 +++ tests/components/tplink/test_switch.py | 107 ++ 28 files changed, 2140 insertions(+), 2218 deletions(-) delete mode 100644 homeassistant/components/tplink/common.py create mode 100644 homeassistant/components/tplink/coordinator.py create mode 100644 homeassistant/components/tplink/entity.py create mode 100644 homeassistant/components/tplink/migration.py create mode 100644 tests/components/tplink/test_config_flow.py create mode 100644 tests/components/tplink/test_migration.py create mode 100644 tests/components/tplink/test_sensor.py create mode 100644 tests/components/tplink/test_switch.py diff --git a/.coveragerc b/.coveragerc index c961d0b749d..0a54f15724c 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1096,8 +1096,6 @@ omit = homeassistant/components/totalconnect/binary_sensor.py homeassistant/components/totalconnect/const.py homeassistant/components/touchline/climate.py - homeassistant/components/tplink/common.py - homeassistant/components/tplink/switch.py homeassistant/components/tplink_lte/* homeassistant/components/traccar/device_tracker.py homeassistant/components/traccar/const.py diff --git a/.strict-typing b/.strict-typing index 78d6914764f..2a982a16afc 100644 --- a/.strict-typing +++ b/.strict-typing @@ -107,6 +107,7 @@ homeassistant.components.tag.* homeassistant.components.tautulli.* homeassistant.components.tcp.* homeassistant.components.tile.* +homeassistant.components.tplink.* homeassistant.components.tradfri.* homeassistant.components.tts.* homeassistant.components.upcloud.* diff --git a/homeassistant/components/tplink/__init__.py b/homeassistant/components/tplink/__init__.py index aad934b2600..9b21e532776 100644 --- a/homeassistant/components/tplink/__init__.py +++ b/homeassistant/components/tplink/__init__.py @@ -1,198 +1,136 @@ """Component to embed TP-Link smart home devices.""" from __future__ import annotations -from datetime import timedelta -import logging -import time from typing import Any -from pyHS100.smartdevice import SmartDevice, SmartDeviceException -from pyHS100.smartplug import SmartPlug +from kasa import SmartDevice, SmartDeviceException +from kasa.discover import Discover import voluptuous as vol from homeassistant import config_entries -from homeassistant.components.switch import ATTR_CURRENT_POWER_W, ATTR_TODAY_ENERGY_KWH -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_VOLTAGE, - CONF_ALIAS, - CONF_DEVICE_ID, - CONF_HOST, - CONF_MAC, - CONF_STATE, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.event import async_track_time_interval +from homeassistant.config_entries import ConfigEntry, ConfigEntryNotReady +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.typing import ConfigType -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .common import SmartDevices, async_discover_devices, get_static_devices from .const import ( - ATTR_CONFIG, - ATTR_CURRENT_A, - ATTR_TOTAL_ENERGY_KWH, CONF_DIMMER, CONF_DISCOVERY, - CONF_EMETER_PARAMS, CONF_LIGHT, - CONF_MODEL, CONF_STRIP, - CONF_SW_VERSION, CONF_SWITCH, - COORDINATORS, + DOMAIN, PLATFORMS, - UNAVAILABLE_DEVICES, - UNAVAILABLE_RETRY_DELAY, ) - -_LOGGER = logging.getLogger(__name__) - -DOMAIN = "tplink" +from .coordinator import TPLinkDataUpdateCoordinator +from .migration import ( + async_migrate_entities_devices, + async_migrate_legacy_entries, + async_migrate_yaml_entries, +) TPLINK_HOST_SCHEMA = vol.Schema({vol.Required(CONF_HOST): cv.string}) CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Optional(CONF_LIGHT, default=[]): vol.All( - cv.ensure_list, [TPLINK_HOST_SCHEMA] - ), - vol.Optional(CONF_SWITCH, default=[]): vol.All( - cv.ensure_list, [TPLINK_HOST_SCHEMA] - ), - vol.Optional(CONF_STRIP, default=[]): vol.All( - cv.ensure_list, [TPLINK_HOST_SCHEMA] - ), - vol.Optional(CONF_DIMMER, default=[]): vol.All( - cv.ensure_list, [TPLINK_HOST_SCHEMA] - ), - vol.Optional(CONF_DISCOVERY, default=True): cv.boolean, - } - ) - }, + vol.All( + cv.deprecated(DOMAIN), + { + DOMAIN: vol.Schema( + { + vol.Optional(CONF_LIGHT, default=[]): vol.All( + cv.ensure_list, [TPLINK_HOST_SCHEMA] + ), + vol.Optional(CONF_SWITCH, default=[]): vol.All( + cv.ensure_list, [TPLINK_HOST_SCHEMA] + ), + vol.Optional(CONF_STRIP, default=[]): vol.All( + cv.ensure_list, [TPLINK_HOST_SCHEMA] + ), + vol.Optional(CONF_DIMMER, default=[]): vol.All( + cv.ensure_list, [TPLINK_HOST_SCHEMA] + ), + vol.Optional(CONF_DISCOVERY, default=True): cv.boolean, + } + ) + }, + ), extra=vol.ALLOW_EXTRA, ) +@callback +def async_trigger_discovery( + hass: HomeAssistant, + discovered_devices: dict[str, SmartDevice], +) -> None: + """Trigger config flows for discovered devices.""" + for formatted_mac, device in discovered_devices.items(): + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DISCOVERY}, + data={ + CONF_NAME: device.alias, + CONF_HOST: device.host, + CONF_MAC: formatted_mac, + }, + ) + ) + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the TP-Link component.""" conf = config.get(DOMAIN) - hass.data[DOMAIN] = {} - hass.data[DOMAIN][ATTR_CONFIG] = conf + legacy_entry = None + config_entries_by_mac = {} + for entry in hass.config_entries.async_entries(DOMAIN): + if async_entry_is_legacy(entry): + legacy_entry = entry + elif entry.unique_id: + config_entries_by_mac[entry.unique_id] = entry + + discovered_devices = { + dr.format_mac(device.mac): device + for device in (await Discover.discover()).values() + } + hosts_by_mac = {mac: device.host for mac, device in discovered_devices.items()} + + if legacy_entry: + async_migrate_legacy_entries( + hass, hosts_by_mac, config_entries_by_mac, legacy_entry + ) if conf is not None: - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT} - ) - ) + async_migrate_yaml_entries(hass, conf) + + if discovered_devices: + async_trigger_discovery(hass, discovered_devices) return True async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up TPLink from a config entry.""" - config_data = hass.data[DOMAIN].get(ATTR_CONFIG) - if config_data is None and entry.data: - config_data = entry.data - elif config_data is not None: - hass.config_entries.async_update_entry(entry, data=config_data) + if async_entry_is_legacy(entry): + return True - device_registry = dr.async_get(hass) - tplink_devices = dr.async_entries_for_config_entry(device_registry, entry.entry_id) - device_count = len(tplink_devices) - hass_data: dict[str, Any] = hass.data[DOMAIN] - - # These will contain the initialized devices - hass_data[CONF_LIGHT] = [] - hass_data[CONF_SWITCH] = [] - hass_data[UNAVAILABLE_DEVICES] = [] - lights: list[SmartDevice] = hass_data[CONF_LIGHT] - switches: list[SmartPlug] = hass_data[CONF_SWITCH] - unavailable_devices: list[SmartDevice] = hass_data[UNAVAILABLE_DEVICES] - - # Add static devices - static_devices = SmartDevices() - if config_data is not None: - static_devices = get_static_devices(config_data) - - lights.extend(static_devices.lights) - switches.extend(static_devices.switches) - - # Add discovered devices - if config_data is None or config_data[CONF_DISCOVERY]: - discovered_devices = await async_discover_devices( - hass, static_devices, device_count - ) - - lights.extend(discovered_devices.lights) - switches.extend(discovered_devices.switches) - - if lights: - _LOGGER.debug( - "Got %s lights: %s", len(lights), ", ".join(d.host for d in lights) - ) - - if switches: - _LOGGER.debug( - "Got %s switches: %s", - len(switches), - ", ".join(d.host for d in switches), - ) - - async def async_retry_devices(self) -> None: - """Retry unavailable devices.""" - unavailable_devices: list[SmartDevice] = hass_data[UNAVAILABLE_DEVICES] - _LOGGER.debug( - "retry during setup unavailable devices: %s", - [d.host for d in unavailable_devices], - ) - - for device in unavailable_devices: - try: - await hass.async_add_executor_job(device.get_sysinfo) - except SmartDeviceException: - continue - _LOGGER.debug( - "at least one device is available again, so reload integration" - ) - await hass.config_entries.async_reload(entry.entry_id) + legacy_entry: ConfigEntry | None = None + for config_entry in hass.config_entries.async_entries(DOMAIN): + if async_entry_is_legacy(config_entry): + legacy_entry = config_entry break - # prepare DataUpdateCoordinators - hass_data[COORDINATORS] = {} - for switch in switches: + if legacy_entry is not None: + await async_migrate_entities_devices(hass, legacy_entry.entry_id, entry) - try: - info = await hass.async_add_executor_job(switch.get_sysinfo) - except SmartDeviceException: - _LOGGER.warning( - "Device at '%s' not reachable during setup, will retry later", - switch.host, - ) - unavailable_devices.append(switch) - continue - - hass_data[COORDINATORS][ - switch.context or switch.mac - ] = coordinator = SmartPlugDataUpdateCoordinator(hass, switch, info["alias"]) - await coordinator.async_config_entry_first_refresh() - - if unavailable_devices: - entry.async_on_unload( - async_track_time_interval( - hass, async_retry_devices, UNAVAILABLE_RETRY_DELAY - ) - ) - unavailable_devices_hosts = [d.host for d in unavailable_devices] - hass_data[CONF_SWITCH] = [ - s for s in switches if s.host not in unavailable_devices_hosts - ] + try: + device: SmartDevice = await Discover.discover_single(entry.data[CONF_HOST]) + except SmartDeviceException as ex: + raise ConfigEntryNotReady from ex + hass.data[DOMAIN][entry.entry_id] = TPLinkDataUpdateCoordinator(hass, device) hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True @@ -200,81 +138,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) hass_data: dict[str, Any] = hass.data[DOMAIN] - if unload_ok: - hass_data.clear() - + if entry.entry_id not in hass_data: + return True + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass_data.pop(entry.entry_id) return unload_ok -class SmartPlugDataUpdateCoordinator(DataUpdateCoordinator): - """DataUpdateCoordinator to gather data for specific SmartPlug.""" +@callback +def async_entry_is_legacy(entry: ConfigEntry) -> bool: + """Check if a config entry is the legacy shared one.""" + return entry.unique_id is None or entry.unique_id == DOMAIN - def __init__( - self, - hass: HomeAssistant, - smartplug: SmartPlug, - alias: str, - ) -> None: - """Initialize DataUpdateCoordinator to gather data for specific SmartPlug.""" - self.smartplug = smartplug - update_interval = timedelta(seconds=30) - super().__init__( - hass, - _LOGGER, - name=alias, - update_interval=update_interval, - ) - - def _update_data(self) -> dict: - """Fetch all device and sensor data from api.""" - try: - info = self.smartplug.sys_info - data = { - CONF_HOST: self.smartplug.host, - CONF_MAC: info["mac"], - CONF_MODEL: info["model"], - CONF_SW_VERSION: info["sw_ver"], - } - if self.smartplug.context is None: - data[CONF_ALIAS] = info["alias"] - data[CONF_DEVICE_ID] = info["mac"] - data[CONF_STATE] = bool(info["relay_state"]) - else: - plug_from_context = next( - c - for c in self.smartplug.sys_info["children"] - if c["id"] == self.smartplug.context - ) - data[CONF_ALIAS] = plug_from_context["alias"] - data[CONF_DEVICE_ID] = self.smartplug.context - data[CONF_STATE] = plug_from_context["state"] == 1 - - # Check if the device has emeter - if "ENE" in info["feature"]: - emeter_readings = self.smartplug.get_emeter_realtime() - data[CONF_EMETER_PARAMS] = { - ATTR_CURRENT_POWER_W: round(float(emeter_readings["power"]), 2), - ATTR_TOTAL_ENERGY_KWH: round(float(emeter_readings["total"]), 3), - ATTR_VOLTAGE: round(float(emeter_readings["voltage"]), 1), - ATTR_CURRENT_A: round(float(emeter_readings["current"]), 2), - } - emeter_statics = self.smartplug.get_emeter_daily() - if emeter_statics.get(int(time.strftime("%e"))): - data[CONF_EMETER_PARAMS][ATTR_TODAY_ENERGY_KWH] = round( - float(emeter_statics[int(time.strftime("%e"))]), 3 - ) - else: - # today's consumption not available, when device was off all the day - data[CONF_EMETER_PARAMS][ATTR_TODAY_ENERGY_KWH] = 0.0 - except SmartDeviceException as ex: - raise UpdateFailed(ex) from ex - - self.name = data[CONF_ALIAS] - return data - - async def _async_update_data(self) -> dict: - """Fetch all device and sensor data from api.""" - return await self.hass.async_add_executor_job(self._update_data) +def legacy_device_id(device: SmartDevice) -> str: + """Convert the device id so it matches what was used in the original version.""" + device_id: str = device.device_id + # Plugs are prefixed with the mac in python-kasa but not + # in pyHS100 so we need to strip off the mac + if "_" not in device_id: + return device_id + return device_id.split("_")[1] diff --git a/homeassistant/components/tplink/common.py b/homeassistant/components/tplink/common.py deleted file mode 100644 index 6f6fb0a14c2..00000000000 --- a/homeassistant/components/tplink/common.py +++ /dev/null @@ -1,186 +0,0 @@ -"""Common code for tplink.""" -from __future__ import annotations - -import logging -from typing import Callable - -from pyHS100 import ( - Discover, - SmartBulb, - SmartDevice, - SmartDeviceException, - SmartPlug, - SmartStrip, -) - -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import Entity - -from .const import ( - CONF_DIMMER, - CONF_LIGHT, - CONF_STRIP, - CONF_SWITCH, - DOMAIN as TPLINK_DOMAIN, - MAX_DISCOVERY_RETRIES, -) - -_LOGGER = logging.getLogger(__name__) - - -class SmartDevices: - """Hold different kinds of devices.""" - - def __init__( - self, lights: list[SmartDevice] = None, switches: list[SmartDevice] = None - ) -> None: - """Initialize device holder.""" - self._lights = lights or [] - self._switches = switches or [] - - @property - def lights(self) -> list[SmartDevice]: - """Get the lights.""" - return self._lights - - @property - def switches(self) -> list[SmartDevice]: - """Get the switches.""" - return self._switches - - def has_device_with_host(self, host: str) -> bool: - """Check if a devices exists with a specific host.""" - for device in self.lights + self.switches: - if device.host == host: - return True - - return False - - -async def async_get_discoverable_devices(hass: HomeAssistant) -> dict[str, SmartDevice]: - """Return if there are devices that can be discovered.""" - - def discover() -> dict[str, SmartDevice]: - return Discover.discover() - - return await hass.async_add_executor_job(discover) - - -async def async_discover_devices( - hass: HomeAssistant, existing_devices: SmartDevices, target_device_count: int -) -> SmartDevices: - """Get devices through discovery.""" - - lights = [] - switches = [] - - def process_devices() -> None: - for dev in devices.values(): - # If this device already exists, ignore dynamic setup. - if existing_devices.has_device_with_host(dev.host): - continue - - if isinstance(dev, SmartStrip): - for plug in dev.plugs.values(): - switches.append(plug) - elif isinstance(dev, SmartPlug): - try: - if dev.is_dimmable: # Dimmers act as lights - lights.append(dev) - else: - switches.append(dev) - except SmartDeviceException as ex: - _LOGGER.error("Unable to connect to device %s: %s", dev.host, ex) - - elif isinstance(dev, SmartBulb): - lights.append(dev) - else: - _LOGGER.error("Unknown smart device type: %s", type(dev)) - - devices: dict[str, SmartDevice] = {} - for attempt in range(1, MAX_DISCOVERY_RETRIES + 1): - _LOGGER.debug( - "Discovering tplink devices, attempt %s of %s", - attempt, - MAX_DISCOVERY_RETRIES, - ) - discovered_devices = await async_get_discoverable_devices(hass) - _LOGGER.info( - "Discovered %s TP-Link of expected %s smart home device(s)", - len(discovered_devices), - target_device_count, - ) - for device_ip in discovered_devices: - devices[device_ip] = discovered_devices[device_ip] - - if len(discovered_devices) >= target_device_count: - _LOGGER.info( - "Discovered at least as many devices on the network as exist in our device registry, no need to retry" - ) - break - - _LOGGER.info( - "Found %s unique TP-Link smart home device(s) after %s discovery attempts", - len(devices), - attempt, - ) - await hass.async_add_executor_job(process_devices) - - return SmartDevices(lights, switches) - - -def get_static_devices(config_data) -> SmartDevices: - """Get statically defined devices in the config.""" - _LOGGER.debug("Getting static devices") - lights = [] - switches = [] - - for type_ in (CONF_LIGHT, CONF_SWITCH, CONF_STRIP, CONF_DIMMER): - for entry in config_data[type_]: - host = entry["host"] - try: - if type_ == CONF_LIGHT: - lights.append(SmartBulb(host)) - elif type_ == CONF_SWITCH: - switches.append(SmartPlug(host)) - elif type_ == CONF_STRIP: - for plug in SmartStrip(host).plugs.values(): - switches.append(plug) - # Dimmers need to be defined as smart plugs to work correctly. - elif type_ == CONF_DIMMER: - lights.append(SmartPlug(host)) - except SmartDeviceException as sde: - _LOGGER.error( - "Failed to setup device %s due to %s; not retrying", host, sde - ) - return SmartDevices(lights, switches) - - -def add_available_devices( - hass: HomeAssistant, device_type: str, device_class: Callable -) -> list[Entity]: - """Get sysinfo for all devices.""" - - devices: list[SmartDevice] = hass.data[TPLINK_DOMAIN][device_type] - - if f"{device_type}_remaining" in hass.data[TPLINK_DOMAIN]: - devices: list[SmartDevice] = hass.data[TPLINK_DOMAIN][ - f"{device_type}_remaining" - ] - - entities_ready: list[Entity] = [] - devices_unavailable: list[SmartDevice] = [] - for device in devices: - try: - device.get_sysinfo() - entities_ready.append(device_class(device)) - except SmartDeviceException as ex: - devices_unavailable.append(device) - _LOGGER.warning( - "Unable to communicate with device %s: %s", - device.host, - ex, - ) - - hass.data[TPLINK_DOMAIN][f"{device_type}_remaining"] = devices_unavailable - return entities_ready diff --git a/homeassistant/components/tplink/config_flow.py b/homeassistant/components/tplink/config_flow.py index 033d80cf407..e8f1fb0a702 100644 --- a/homeassistant/components/tplink/config_flow.py +++ b/homeassistant/components/tplink/config_flow.py @@ -1,11 +1,181 @@ """Config flow for TP-Link.""" -from homeassistant.helpers import config_entry_flow +from __future__ import annotations -from .common import async_get_discoverable_devices +import logging +from typing import Any + +from kasa import SmartDevice, SmartDeviceException +from kasa.discover import Discover +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.dhcp import IP_ADDRESS, MAC_ADDRESS +from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MAC, CONF_NAME +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.typing import DiscoveryInfoType + +from . import async_entry_is_legacy from .const import DOMAIN -config_entry_flow.register_discovery_flow( - DOMAIN, - "TP-Link Smart Home", - async_get_discoverable_devices, -) +_LOGGER = logging.getLogger(__name__) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for tplink.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self._discovered_devices: dict[str, SmartDevice] = {} + self._discovered_device: SmartDevice | None = None + + async def async_step_dhcp(self, discovery_info: DiscoveryInfoType) -> FlowResult: + """Handle discovery via dhcp.""" + return await self._async_handle_discovery( + discovery_info[IP_ADDRESS], discovery_info[MAC_ADDRESS] + ) + + async def async_step_discovery( + self, discovery_info: DiscoveryInfoType + ) -> FlowResult: + """Handle discovery.""" + return await self._async_handle_discovery( + discovery_info[CONF_HOST], discovery_info[CONF_MAC] + ) + + async def _async_handle_discovery(self, host: str, mac: str) -> FlowResult: + """Handle any discovery.""" + await self.async_set_unique_id(dr.format_mac(mac)) + self._abort_if_unique_id_configured(updates={CONF_HOST: host}) + self._async_abort_entries_match({CONF_HOST: host}) + self.context[CONF_HOST] = host + for progress in self._async_in_progress(): + if progress.get("context", {}).get(CONF_HOST) == host: + return self.async_abort(reason="already_in_progress") + + try: + self._discovered_device = await self._async_try_connect( + host, raise_on_progress=True + ) + except SmartDeviceException: + return self.async_abort(reason="cannot_connect") + return await self.async_step_discovery_confirm() + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm discovery.""" + assert self._discovered_device is not None + if user_input is not None: + return self._async_create_entry_from_device(self._discovered_device) + + self._set_confirm_only() + placeholders = { + "name": self._discovered_device.alias, + "model": self._discovered_device.model, + "host": self._discovered_device.host, + } + self.context["title_placeholders"] = placeholders + return self.async_show_form( + step_id="discovery_confirm", description_placeholders=placeholders + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors = {} + if user_input is not None: + host = user_input[CONF_HOST] + if not host: + return await self.async_step_pick_device() + try: + device = await self._async_try_connect(host, raise_on_progress=False) + except SmartDeviceException: + errors["base"] = "cannot_connect" + else: + return self._async_create_entry_from_device(device) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Optional(CONF_HOST, default=""): str}), + errors=errors, + ) + + async def async_step_pick_device( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the step to pick discovered device.""" + if user_input is not None: + mac = user_input[CONF_DEVICE] + await self.async_set_unique_id(mac, raise_on_progress=False) + return self._async_create_entry_from_device(self._discovered_devices[mac]) + + configured_devices = { + entry.unique_id + for entry in self._async_current_entries() + if not async_entry_is_legacy(entry) + } + self._discovered_devices = { + dr.format_mac(device.mac): device + for device in (await Discover.discover()).values() + } + devices_name = { + formatted_mac: f"{device.alias} {device.model} ({device.host}) {formatted_mac}" + for formatted_mac, device in self._discovered_devices.items() + if formatted_mac not in configured_devices + } + # Check if there is at least one device + if not devices_name: + return self.async_abort(reason="no_devices_found") + return self.async_show_form( + step_id="pick_device", + data_schema=vol.Schema({vol.Required(CONF_DEVICE): vol.In(devices_name)}), + ) + + async def async_step_migration(self, migration_input: dict[str, Any]) -> FlowResult: + """Handle migration from legacy config entry to per device config entry.""" + mac = migration_input[CONF_MAC] + await self.async_set_unique_id(dr.format_mac(mac), raise_on_progress=False) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=migration_input[CONF_NAME], + data={ + CONF_HOST: migration_input[CONF_HOST], + }, + ) + + @callback + def _async_create_entry_from_device(self, device: SmartDevice) -> FlowResult: + """Create a config entry from a smart device.""" + self._abort_if_unique_id_configured(updates={CONF_HOST: device.host}) + return self.async_create_entry( + title=f"{device.alias} {device.model}", + data={ + CONF_HOST: device.host, + }, + ) + + async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: + """Handle import step.""" + host = user_input[CONF_HOST] + try: + device = await self._async_try_connect(host, raise_on_progress=False) + except SmartDeviceException: + _LOGGER.error("Failed to import %s: cannot connect", host) + return self.async_abort(reason="cannot_connect") + return self._async_create_entry_from_device(device) + + async def _async_try_connect( + self, host: str, raise_on_progress: bool = True + ) -> SmartDevice: + """Try to connect.""" + self._async_abort_entries_match({CONF_HOST: host}) + device: SmartDevice = await Discover.discover_single(host) + await self.async_set_unique_id( + dr.format_mac(device.mac), raise_on_progress=raise_on_progress + ) + return device diff --git a/homeassistant/components/tplink/const.py b/homeassistant/components/tplink/const.py index 60e06fd1ffe..6d4fcbea75d 100644 --- a/homeassistant/components/tplink/const.py +++ b/homeassistant/components/tplink/const.py @@ -1,28 +1,20 @@ """Const for TP-Link.""" from __future__ import annotations -import datetime +from typing import Final DOMAIN = "tplink" -COORDINATORS = "coordinators" -UNAVAILABLE_DEVICES = "unavailable_devices" -UNAVAILABLE_RETRY_DELAY = datetime.timedelta(seconds=300) -MIN_TIME_BETWEEN_UPDATES = datetime.timedelta(seconds=8) -MAX_DISCOVERY_RETRIES = 4 +ATTR_CURRENT_A: Final = "current_a" +ATTR_CURRENT_POWER_W: Final = "current_power_w" +ATTR_TODAY_ENERGY_KWH: Final = "today_energy_kwh" +ATTR_TOTAL_ENERGY_KWH: Final = "total_energy_kwh" -ATTR_CONFIG = "config" -ATTR_TOTAL_ENERGY_KWH = "total_energy_kwh" -ATTR_CURRENT_A = "current_a" +CONF_DIMMER: Final = "dimmer" +CONF_DISCOVERY: Final = "discovery" +CONF_LIGHT: Final = "light" +CONF_STRIP: Final = "strip" +CONF_SWITCH: Final = "switch" +CONF_SENSOR: Final = "sensor" -CONF_MODEL = "model" -CONF_SW_VERSION = "sw_ver" -CONF_EMETER_PARAMS = "emeter_params" -CONF_DIMMER = "dimmer" -CONF_DISCOVERY = "discovery" -CONF_LIGHT = "light" -CONF_STRIP = "strip" -CONF_SWITCH = "switch" -CONF_SENSOR = "sensor" - -PLATFORMS = [CONF_LIGHT, CONF_SENSOR, CONF_SWITCH] +PLATFORMS: Final = [CONF_LIGHT, CONF_SENSOR, CONF_SWITCH] diff --git a/homeassistant/components/tplink/coordinator.py b/homeassistant/components/tplink/coordinator.py new file mode 100644 index 00000000000..2b33f817c63 --- /dev/null +++ b/homeassistant/components/tplink/coordinator.py @@ -0,0 +1,57 @@ +"""Component to embed TP-Link smart home devices.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +from kasa import SmartDevice, SmartDeviceException + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.debounce import Debouncer +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +_LOGGER = logging.getLogger(__name__) + +REQUEST_REFRESH_DELAY = 0.35 + + +class TPLinkDataUpdateCoordinator(DataUpdateCoordinator): + """DataUpdateCoordinator to gather data for a specific TPLink device.""" + + def __init__( + self, + hass: HomeAssistant, + device: SmartDevice, + ) -> None: + """Initialize DataUpdateCoordinator to gather data for specific SmartPlug.""" + self.device = device + self.update_children = True + update_interval = timedelta(seconds=10) + super().__init__( + hass, + _LOGGER, + name=device.host, + update_interval=update_interval, + # We don't want an immediate refresh since the device + # takes a moment to reflect the state change + request_refresh_debouncer=Debouncer( + hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False + ), + ) + + async def async_request_refresh_without_children(self) -> None: + """Request a refresh without the children.""" + # If the children do get updated this is ok as this is an + # optimization to reduce the number of requests on the device + # when we do not need it. + self.update_children = False + await self.async_request_refresh() + + async def _async_update_data(self) -> None: + """Fetch all device and sensor data from api.""" + try: + await self.device.update(update_children=self.update_children) + except SmartDeviceException as ex: + raise UpdateFailed(ex) from ex + finally: + self.update_children = True diff --git a/homeassistant/components/tplink/entity.py b/homeassistant/components/tplink/entity.py new file mode 100644 index 00000000000..b331f70c5bb --- /dev/null +++ b/homeassistant/components/tplink/entity.py @@ -0,0 +1,63 @@ +"""Common code for tplink.""" +from __future__ import annotations + +from typing import Any, Callable, TypeVar, cast + +from kasa import SmartDevice + +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import TPLinkDataUpdateCoordinator + +WrapFuncType = TypeVar("WrapFuncType", bound=Callable[..., Any]) + + +def async_refresh_after(func: WrapFuncType) -> WrapFuncType: + """Define a wrapper to refresh after.""" + + async def _async_wrap( + self: CoordinatedTPLinkEntity, *args: Any, **kwargs: Any + ) -> None: + await func(self, *args, **kwargs) + await self.coordinator.async_request_refresh_without_children() + + return cast(WrapFuncType, _async_wrap) + + +class CoordinatedTPLinkEntity(CoordinatorEntity): + """Common base class for all coordinated tplink entities.""" + + coordinator: TPLinkDataUpdateCoordinator + + def __init__( + self, device: SmartDevice, coordinator: TPLinkDataUpdateCoordinator + ) -> None: + """Initialize the switch.""" + super().__init__(coordinator) + self.device: SmartDevice = device + self._attr_unique_id = self.device.device_id + + @property + def name(self) -> str: + """Return the name of the Smart Plug.""" + return cast(str, self.device.alias) + + @property + def device_info(self) -> DeviceInfo: + """Return information about the device.""" + return { + "name": self.device.alias, + "model": self.device.model, + "manufacturer": "TP-Link", + "identifiers": {(DOMAIN, str(self.device.device_id))}, + "connections": {(dr.CONNECTION_NETWORK_MAC, self.device.mac)}, + "sw_version": self.device.hw_info["sw_ver"], + } + + @property + def is_on(self) -> bool: + """Return true if switch is on.""" + return bool(self.device.is_on) diff --git a/homeassistant/components/tplink/light.py b/homeassistant/components/tplink/light.py index 17e2b03b790..f1d936ecdfe 100644 --- a/homeassistant/components/tplink/light.py +++ b/homeassistant/components/tplink/light.py @@ -1,552 +1,154 @@ """Support for TPLink lights.""" from __future__ import annotations -import asyncio -from collections.abc import Mapping -from datetime import timedelta import logging -import re -import time -from typing import Any, NamedTuple, cast +from typing import Any -from pyHS100 import SmartBulb, SmartDeviceException +from kasa import SmartDevice from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, - SUPPORT_BRIGHTNESS, - SUPPORT_COLOR, - SUPPORT_COLOR_TEMP, + ATTR_TRANSITION, + COLOR_MODE_BRIGHTNESS, + COLOR_MODE_COLOR_TEMP, + COLOR_MODE_HS, + COLOR_MODE_ONOFF, + SUPPORT_TRANSITION, LightEntity, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, PlatformNotReady -import homeassistant.helpers.device_registry as dr -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.color import ( color_temperature_kelvin_to_mired as kelvin_to_mired, color_temperature_mired_to_kelvin as mired_to_kelvin, ) -import homeassistant.util.dt as dt_util -from . import CONF_LIGHT, DOMAIN as TPLINK_DOMAIN -from .common import add_available_devices - -PARALLEL_UPDATES = 0 -SCAN_INTERVAL = timedelta(seconds=5) -CURRENT_POWER_UPDATE_INTERVAL = timedelta(seconds=60) -HISTORICAL_POWER_UPDATE_INTERVAL = timedelta(minutes=60) +from .const import DOMAIN +from .coordinator import TPLinkDataUpdateCoordinator +from .entity import CoordinatedTPLinkEntity, async_refresh_after _LOGGER = logging.getLogger(__name__) -ATTR_CURRENT_POWER_W = "current_power_w" -ATTR_DAILY_ENERGY_KWH = "daily_energy_kwh" -ATTR_MONTHLY_ENERGY_KWH = "monthly_energy_kwh" - -LIGHT_STATE_DFT_ON = "dft_on_state" -LIGHT_STATE_DFT_IGNORE = "ignore_default" -LIGHT_STATE_ON_OFF = "on_off" -LIGHT_STATE_RELAY_STATE = "relay_state" -LIGHT_STATE_BRIGHTNESS = "brightness" -LIGHT_STATE_COLOR_TEMP = "color_temp" -LIGHT_STATE_HUE = "hue" -LIGHT_STATE_SATURATION = "saturation" -LIGHT_STATE_ERROR_MSG = "err_msg" - -LIGHT_SYSINFO_MAC = "mac" -LIGHT_SYSINFO_ALIAS = "alias" -LIGHT_SYSINFO_MODEL = "model" -LIGHT_SYSINFO_IS_DIMMABLE = "is_dimmable" -LIGHT_SYSINFO_IS_VARIABLE_COLOR_TEMP = "is_variable_color_temp" -LIGHT_SYSINFO_IS_COLOR = "is_color" - -MAX_ATTEMPTS = 300 -SLEEP_TIME = 2 - - -class ColorTempRange(NamedTuple): - """Color temperature range (in Kelvin).""" - - min: int - max: int - - -TPLINK_KELVIN: dict[str, ColorTempRange] = { - "LB130": ColorTempRange(2500, 9000), - "LB120": ColorTempRange(2700, 6500), - "LB230": ColorTempRange(2500, 9000), - "KB130": ColorTempRange(2500, 9000), - "KL130": ColorTempRange(2500, 9000), - "KL125": ColorTempRange(2500, 6500), - r"KL120\(EU\)": ColorTempRange(2700, 6500), - r"KL120\(US\)": ColorTempRange(2700, 5000), - r"KL430\(US\)": ColorTempRange(2500, 9000), -} - -FALLBACK_MIN_COLOR = 2700 -FALLBACK_MAX_COLOR = 5000 - async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up lights.""" - entities = await hass.async_add_executor_job( - add_available_devices, hass, CONF_LIGHT, TPLinkSmartBulb - ) - - if entities: - async_add_entities(entities, update_before_add=True) - - if hass.data[TPLINK_DOMAIN][f"{CONF_LIGHT}_remaining"]: - raise PlatformNotReady + """Set up switches.""" + coordinator: TPLinkDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + device = coordinator.device + if device.is_bulb or device.is_light_strip or device.is_dimmer: + async_add_entities([TPLinkSmartBulb(device, coordinator)]) -def brightness_to_percentage(byt): - """Convert brightness from absolute 0..255 to percentage.""" - return round((byt * 100.0) / 255.0) - - -def brightness_from_percentage(percent): - """Convert percentage to absolute value 0..255.""" - return round((percent * 255.0) / 100.0) - - -class LightState(NamedTuple): - """Light state.""" - - state: bool - brightness: int - color_temp: float - hs: tuple[int, int] - - def to_param(self): - """Return a version that we can send to the bulb.""" - color_temp = None - if self.color_temp: - color_temp = mired_to_kelvin(self.color_temp) - - return { - LIGHT_STATE_ON_OFF: 1 if self.state else 0, - LIGHT_STATE_DFT_IGNORE: 1 if self.state else 0, - LIGHT_STATE_BRIGHTNESS: brightness_to_percentage(self.brightness), - LIGHT_STATE_COLOR_TEMP: color_temp, - LIGHT_STATE_HUE: self.hs[0] if self.hs else 0, - LIGHT_STATE_SATURATION: self.hs[1] if self.hs else 0, - } - - -class LightFeatures(NamedTuple): - """Light features.""" - - sysinfo: dict[str, Any] - mac: str - alias: str - model: str - supported_features: int - min_mireds: float - max_mireds: float - has_emeter: bool - - -class TPLinkSmartBulb(LightEntity): +class TPLinkSmartBulb(CoordinatedTPLinkEntity, LightEntity): """Representation of a TPLink Smart Bulb.""" - def __init__(self, smartbulb: SmartBulb) -> None: - """Initialize the bulb.""" - self.smartbulb = smartbulb - self._light_features = cast(LightFeatures, None) - self._light_state = cast(LightState, None) - self._is_available = True - self._is_setting_light_state = False - self._last_current_power_update = None - self._last_historical_power_update = None - self._emeter_params = {} + coordinator: TPLinkDataUpdateCoordinator - self._host = None - self._alias = None - - @property - def unique_id(self) -> str | None: - """Return a unique ID.""" - return self._light_features.mac - - @property - def name(self) -> str | None: - """Return the name of the Smart Bulb.""" - return self._light_features.alias - - @property - def device_info(self) -> DeviceInfo: - """Return information about the device.""" - return { - "name": self._light_features.alias, - "model": self._light_features.model, - "manufacturer": "TP-Link", - "connections": {(dr.CONNECTION_NETWORK_MAC, self._light_features.mac)}, - "sw_version": self._light_features.sysinfo["sw_ver"], - } - - @property - def available(self) -> bool: - """Return if bulb is available.""" - return self._is_available - - @property - def extra_state_attributes(self) -> Mapping[str, Any] | None: - """Return the state attributes of the device.""" - return self._emeter_params + def __init__( + self, + device: SmartDevice, + coordinator: TPLinkDataUpdateCoordinator, + ) -> None: + """Initialize the switch.""" + super().__init__(device, coordinator) + # For backwards compat with pyHS100 + self._attr_unique_id = self.device.mac.replace(":", "").upper() + @async_refresh_after async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" - if ATTR_BRIGHTNESS in kwargs: - brightness = int(kwargs[ATTR_BRIGHTNESS]) - elif self._light_state.brightness is not None: - brightness = self._light_state.brightness - else: - brightness = 255 + transition = kwargs.get(ATTR_TRANSITION) + if (brightness := kwargs.get(ATTR_BRIGHTNESS)) is not None: + brightness = round((brightness * 100.0) / 255.0) + # Handle turning to temp mode if ATTR_COLOR_TEMP in kwargs: - color_tmp = int(kwargs[ATTR_COLOR_TEMP]) - else: - color_tmp = self._light_state.color_temp + color_tmp = mired_to_kelvin(int(kwargs[ATTR_COLOR_TEMP])) + _LOGGER.debug("Changing color temp to %s", color_tmp) + await self.device.set_color_temp( + color_tmp, brightness=brightness, transition=transition + ) + return + # Handling turning to hs color mode if ATTR_HS_COLOR in kwargs: # TP-Link requires integers. - hue_sat = tuple(int(val) for val in kwargs[ATTR_HS_COLOR]) + hue, sat = tuple(int(val) for val in kwargs[ATTR_HS_COLOR]) + await self.device.set_hsv(hue, sat, brightness, transition=transition) + return - # TP-Link cannot have both color temp and hue_sat - color_tmp = 0 + # Fallback to adjusting brightness or turning the bulb on + if brightness is not None: + await self.device.set_brightness(brightness, transition=transition) else: - hue_sat = self._light_state.hs - - await self._async_set_light_state_retry( - self._light_state, - self._light_state._replace( - state=True, - brightness=brightness, - color_temp=color_tmp, - hs=hue_sat, - ), - ) + await self.device.turn_on(transition=transition) + @async_refresh_after async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" - await self._async_set_light_state_retry( - self._light_state, - self._light_state._replace(state=False), - ) + await self.device.turn_off(transition=kwargs.get(ATTR_TRANSITION)) @property def min_mireds(self) -> int: """Return minimum supported color temperature.""" - return self._light_features.min_mireds + return kelvin_to_mired(self.device.valid_temperature_range.max) @property def max_mireds(self) -> int: """Return maximum supported color temperature.""" - return self._light_features.max_mireds + return kelvin_to_mired(self.device.valid_temperature_range.min) @property def color_temp(self) -> int | None: """Return the color temperature of this light in mireds for HA.""" - return self._light_state.color_temp + return kelvin_to_mired(self.device.color_temp) @property def brightness(self) -> int | None: """Return the brightness of this light between 0..255.""" - return self._light_state.brightness + return round((self.device.brightness * 255.0) / 100.0) @property - def hs_color(self) -> tuple[float, float] | None: + def hs_color(self) -> tuple[int, int] | None: """Return the color.""" - return self._light_state.hs - - @property - def is_on(self) -> bool: - """Return True if device is on.""" - return self._light_state.state - - def attempt_update(self, update_attempt: int) -> bool: - """Attempt to get details the TP-Link bulb.""" - # State is currently being set, ignore. - if self._is_setting_light_state: - return False - - try: - if not self._light_features: - self._light_features = self._get_light_features() - self._alias = self._light_features.alias - self._host = self.smartbulb.host - self._light_state = self._get_light_state() - return True - - except (SmartDeviceException, OSError) as ex: - if update_attempt == 0: - _LOGGER.debug( - "Retrying in %s seconds for %s|%s due to: %s", - SLEEP_TIME, - self._host, - self._alias, - ex, - ) - return False + hue, saturation, _ = self.device.hsv + return hue, saturation @property def supported_features(self) -> int: """Flag supported features.""" - return self._light_features.supported_features + return SUPPORT_TRANSITION - def _get_valid_temperature_range(self) -> ColorTempRange: - """Return the device-specific white temperature range (in Kelvin). + @property + def supported_color_modes(self) -> set[str] | None: + """Return list of available color modes.""" + modes = set() + if self.device.is_variable_color_temp: + modes.add(COLOR_MODE_COLOR_TEMP) + if self.device.is_color: + modes.add(COLOR_MODE_HS) + if self.device.is_dimmable: + modes.add(COLOR_MODE_BRIGHTNESS) - :return: White temperature range in Kelvin (minimum, maximum) - """ - model = self.smartbulb.sys_info[LIGHT_SYSINFO_MODEL] - for obj, temp_range in TPLINK_KELVIN.items(): - if re.match(obj, model): - return temp_range - # pyHS100 is abandoned, but some bulb definitions aren't present - # use "safe" values for something that advertises color temperature - return ColorTempRange(FALLBACK_MIN_COLOR, FALLBACK_MAX_COLOR) + if not modes: + modes.add(COLOR_MODE_ONOFF) - def _get_light_features(self) -> LightFeatures: - """Determine all supported features in one go.""" - sysinfo = self.smartbulb.sys_info - supported_features = 0 - # Calling api here as it reformats - mac = self.smartbulb.mac - alias = sysinfo[LIGHT_SYSINFO_ALIAS] - model = sysinfo[LIGHT_SYSINFO_MODEL] - min_mireds = None - max_mireds = None - has_emeter = self.smartbulb.has_emeter + return modes - if sysinfo.get(LIGHT_SYSINFO_IS_DIMMABLE) or LIGHT_STATE_BRIGHTNESS in sysinfo: - supported_features += SUPPORT_BRIGHTNESS - if sysinfo.get(LIGHT_SYSINFO_IS_VARIABLE_COLOR_TEMP): - supported_features += SUPPORT_COLOR_TEMP - color_temp_range = self._get_valid_temperature_range() - min_mireds = kelvin_to_mired(color_temp_range.max) - max_mireds = kelvin_to_mired(color_temp_range.min) - if sysinfo.get(LIGHT_SYSINFO_IS_COLOR): - supported_features += SUPPORT_COLOR + @property + def color_mode(self) -> str | None: + """Return the active color mode.""" + if self.device.is_color: + if self.device.color_temp: + return COLOR_MODE_COLOR_TEMP + return COLOR_MODE_HS + if self.device.is_variable_color_temp: + return COLOR_MODE_COLOR_TEMP - return LightFeatures( - sysinfo=sysinfo, - mac=mac, - alias=alias, - model=model, - supported_features=supported_features, - min_mireds=min_mireds, - max_mireds=max_mireds, - has_emeter=has_emeter, - ) - - def _light_state_from_params(self, light_state_params: Any) -> LightState: - brightness = None - color_temp = None - hue_saturation = None - light_features = self._light_features - - state = bool(light_state_params[LIGHT_STATE_ON_OFF]) - - if not state and LIGHT_STATE_DFT_ON in light_state_params: - light_state_params = light_state_params[LIGHT_STATE_DFT_ON] - - if light_features.supported_features & SUPPORT_BRIGHTNESS: - brightness = brightness_from_percentage( - light_state_params[LIGHT_STATE_BRIGHTNESS] - ) - - if ( - light_features.supported_features & SUPPORT_COLOR_TEMP - and light_state_params.get(LIGHT_STATE_COLOR_TEMP) is not None - and light_state_params[LIGHT_STATE_COLOR_TEMP] != 0 - ): - color_temp = kelvin_to_mired(light_state_params[LIGHT_STATE_COLOR_TEMP]) - - if color_temp is None and light_features.supported_features & SUPPORT_COLOR: - hue_saturation = ( - light_state_params[LIGHT_STATE_HUE], - light_state_params[LIGHT_STATE_SATURATION], - ) - - return LightState( - state=state, - brightness=brightness, - color_temp=color_temp, - hs=hue_saturation, - ) - - def _get_light_state(self) -> LightState: - """Get the light state.""" - self._update_emeter() - return self._light_state_from_params(self._get_device_state()) - - def _update_emeter(self) -> None: - if not self._light_features.has_emeter: - return - - now = dt_util.utcnow() - if ( - not self._last_current_power_update - or self._last_current_power_update + CURRENT_POWER_UPDATE_INTERVAL < now - ): - self._last_current_power_update = now - self._emeter_params[ATTR_CURRENT_POWER_W] = round( - float(self.smartbulb.current_consumption()), 1 - ) - - if ( - not self._last_historical_power_update - or self._last_historical_power_update + HISTORICAL_POWER_UPDATE_INTERVAL - < now - ): - self._last_historical_power_update = now - daily_statistics = self.smartbulb.get_emeter_daily() - monthly_statistics = self.smartbulb.get_emeter_monthly() - try: - self._emeter_params[ATTR_DAILY_ENERGY_KWH] = round( - float(daily_statistics[int(time.strftime("%d"))]), 3 - ) - self._emeter_params[ATTR_MONTHLY_ENERGY_KWH] = round( - float(monthly_statistics[int(time.strftime("%m"))]), 3 - ) - except KeyError: - # device returned no daily/monthly history - pass - - async def _async_set_light_state_retry( - self, old_light_state: LightState, new_light_state: LightState - ) -> None: - """Set the light state with retry.""" - # Tell the device to set the states. - if not _light_state_diff(old_light_state, new_light_state): - # Nothing to do, avoid the executor - return - - self._is_setting_light_state = True - try: - light_state_params = await self.hass.async_add_executor_job( - self._set_light_state, old_light_state, new_light_state - ) - self._is_available = True - self._is_setting_light_state = False - if LIGHT_STATE_ERROR_MSG in light_state_params: - raise HomeAssistantError(light_state_params[LIGHT_STATE_ERROR_MSG]) - # Some devices do not report the new state in their responses, so we skip - # set here and wait for the next poll to update the values. See #47600 - if LIGHT_STATE_ON_OFF in light_state_params: - self._light_state = self._light_state_from_params(light_state_params) - return - except (SmartDeviceException, OSError): - pass - - try: - _LOGGER.debug("Retrying setting light state") - light_state_params = await self.hass.async_add_executor_job( - self._set_light_state, old_light_state, new_light_state - ) - self._is_available = True - if LIGHT_STATE_ERROR_MSG in light_state_params: - raise HomeAssistantError(light_state_params[LIGHT_STATE_ERROR_MSG]) - self._light_state = self._light_state_from_params(light_state_params) - except (SmartDeviceException, OSError) as ex: - self._is_available = False - _LOGGER.warning("Could not set data for %s: %s", self.smartbulb.host, ex) - - self._is_setting_light_state = False - - def _set_light_state( - self, old_light_state: LightState, new_light_state: LightState - ) -> None: - """Set the light state.""" - diff = _light_state_diff(old_light_state, new_light_state) - - if not diff: - return - - return self._set_device_state(diff) - - def _get_device_state(self) -> dict: - """State of the bulb or smart dimmer switch.""" - if isinstance(self.smartbulb, SmartBulb): - return self.smartbulb.get_light_state() - - sysinfo = self.smartbulb.sys_info - # Its not really a bulb, its a dimmable SmartPlug (aka Wall Switch) - return { - LIGHT_STATE_ON_OFF: sysinfo[LIGHT_STATE_RELAY_STATE], - LIGHT_STATE_BRIGHTNESS: sysinfo.get(LIGHT_STATE_BRIGHTNESS, 0), - LIGHT_STATE_COLOR_TEMP: 0, - LIGHT_STATE_HUE: 0, - LIGHT_STATE_SATURATION: 0, - } - - def _set_device_state(self, state): - """Set state of the bulb or smart dimmer switch.""" - if isinstance(self.smartbulb, SmartBulb): - return self.smartbulb.set_light_state(state) - - # Its not really a bulb, its a dimmable SmartPlug (aka Wall Switch) - if LIGHT_STATE_BRIGHTNESS in state: - # Brightness of 0 is accepted by the - # device but the underlying library rejects it - # so we turn off instead. - if state[LIGHT_STATE_BRIGHTNESS]: - self.smartbulb.brightness = state[LIGHT_STATE_BRIGHTNESS] - else: - self.smartbulb.state = self.smartbulb.SWITCH_STATE_OFF - elif LIGHT_STATE_ON_OFF in state: - if state[LIGHT_STATE_ON_OFF]: - self.smartbulb.state = self.smartbulb.SWITCH_STATE_ON - else: - self.smartbulb.state = self.smartbulb.SWITCH_STATE_OFF - - return self._get_device_state() - - async def async_update(self) -> None: - """Update the TP-Link bulb's state.""" - for update_attempt in range(MAX_ATTEMPTS): - is_ready = await self.hass.async_add_executor_job( - self.attempt_update, update_attempt - ) - - if is_ready: - self._is_available = True - if update_attempt > 0: - _LOGGER.debug( - "Device %s|%s responded after %s attempts", - self._host, - self._alias, - update_attempt, - ) - break - await asyncio.sleep(SLEEP_TIME) - else: - if self._is_available: - _LOGGER.warning( - "Could not read state for %s|%s", - self._host, - self._alias, - ) - self._is_available = False - - -def _light_state_diff( - old_light_state: LightState, new_light_state: LightState -) -> dict[str, Any]: - old_state_param = old_light_state.to_param() - new_state_param = new_light_state.to_param() - - return { - key: value - for key, value in new_state_param.items() - if new_state_param.get(key) != old_state_param.get(key) - } + return COLOR_MODE_BRIGHTNESS diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json index fa8c32c35d7..22745e92ce7 100644 --- a/homeassistant/components/tplink/manifest.json +++ b/homeassistant/components/tplink/manifest.json @@ -3,8 +3,9 @@ "name": "TP-Link Kasa Smart", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/tplink", - "requirements": ["pyHS100==0.3.5.2"], + "requirements": ["python-kasa==0.4.0"], "codeowners": ["@rytilahti", "@thegardenmonkey"], + "quality_scale": "platinum", "iot_class": "local_polling", "dhcp": [ { @@ -27,6 +28,10 @@ "hostname": "hs*", "macaddress": "B09575*" }, + { + "hostname": "hs*", + "macaddress": "C006C3*" + }, { "hostname": "k[lp]*", "macaddress": "1C3BF3*" @@ -47,6 +52,10 @@ "hostname": "k[lp]*", "macaddress": "B09575*" }, + { + "hostname": "k[lp]*", + "macaddress": "C006C3*" + }, { "hostname": "lb*", "macaddress": "1C3BF3*" diff --git a/homeassistant/components/tplink/migration.py b/homeassistant/components/tplink/migration.py new file mode 100644 index 00000000000..af81323d39f --- /dev/null +++ b/homeassistant/components/tplink/migration.py @@ -0,0 +1,109 @@ +"""Component to embed TP-Link smart home devices.""" +from __future__ import annotations + +from datetime import datetime + +from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_HOST, + CONF_MAC, + CONF_NAME, + EVENT_HOMEASSISTANT_STARTED, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.typing import ConfigType + +from .const import CONF_DIMMER, CONF_LIGHT, CONF_STRIP, CONF_SWITCH, DOMAIN + + +async def async_cleanup_legacy_entry( + hass: HomeAssistant, + legacy_entry_id: str, +) -> None: + """Cleanup the legacy entry if the migration is successful.""" + entity_registry = er.async_get(hass) + if not er.async_entries_for_config_entry(entity_registry, legacy_entry_id): + await hass.config_entries.async_remove(legacy_entry_id) + + +@callback +def async_migrate_legacy_entries( + hass: HomeAssistant, + hosts_by_mac: dict[str, str], + config_entries_by_mac: dict[str, ConfigEntry], + legacy_entry: ConfigEntry, +) -> None: + """Migrate the legacy config entries to have an entry per device.""" + device_registry = dr.async_get(hass) + for dev_entry in dr.async_entries_for_config_entry( + device_registry, legacy_entry.entry_id + ): + for connection_type, mac in dev_entry.connections: + if ( + connection_type != dr.CONNECTION_NETWORK_MAC + or mac in config_entries_by_mac + ): + continue + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": "migration"}, + data={ + CONF_HOST: hosts_by_mac.get(mac), + CONF_MAC: mac, + CONF_NAME: dev_entry.name or f"TP-Link device {mac}", + }, + ) + ) + + async def _async_cleanup_legacy_entry(_now: datetime) -> None: + await async_cleanup_legacy_entry(hass, legacy_entry.entry_id) + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _async_cleanup_legacy_entry) + + +@callback +def async_migrate_yaml_entries(hass: HomeAssistant, conf: ConfigType) -> None: + """Migrate yaml to config entries.""" + for device_type in (CONF_LIGHT, CONF_SWITCH, CONF_STRIP, CONF_DIMMER): + for device in conf.get(device_type, []): + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_HOST: device[CONF_HOST], + }, + ) + ) + + +async def async_migrate_entities_devices( + hass: HomeAssistant, legacy_entry_id: str, new_entry: ConfigEntry +) -> None: + """Move entities and devices to the new config entry.""" + migrated_devices = [] + device_registry = dr.async_get(hass) + for dev_entry in dr.async_entries_for_config_entry( + device_registry, legacy_entry_id + ): + for connection_type, value in dev_entry.connections: + if ( + connection_type == dr.CONNECTION_NETWORK_MAC + and value == new_entry.unique_id + ): + migrated_devices.append(dev_entry.id) + device_registry.async_update_device( + dev_entry.id, add_config_entry_id=new_entry.entry_id + ) + + entity_registry = er.async_get(hass) + for reg_entity in er.async_entries_for_config_entry( + entity_registry, legacy_entry_id + ): + if reg_entity.device_id in migrated_devices: + entity_registry.async_update_entity( + reg_entity.entity_id, config_entry_id=new_entry.entry_id + ) diff --git a/homeassistant/components/tplink/sensor.py b/homeassistant/components/tplink/sensor.py index 4d2ed5eee30..0afcf96dba5 100644 --- a/homeassistant/components/tplink/sensor.py +++ b/homeassistant/components/tplink/sensor.py @@ -1,9 +1,10 @@ """Support for TPLink HS100/HS110/HS200 smart switch energy sensors.""" from __future__ import annotations -from typing import Any, Final +from dataclasses import dataclass +from typing import cast -from pyHS100 import SmartPlug +from kasa import SmartDevice from homeassistant.components.sensor import ( STATE_CLASS_MEASUREMENT, @@ -11,13 +12,9 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.components.tplink import SmartPlugDataUpdateCoordinator from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_VOLTAGE, - CONF_ALIAS, - CONF_DEVICE_ID, - CONF_MAC, DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, @@ -28,65 +25,86 @@ from homeassistant.const import ( POWER_WATT, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.device_registry as dr -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from . import legacy_device_id from .const import ( - CONF_EMETER_PARAMS, - CONF_MODEL, - CONF_SW_VERSION, - CONF_SWITCH, - COORDINATORS, - DOMAIN as TPLINK_DOMAIN, + ATTR_CURRENT_A, + ATTR_CURRENT_POWER_W, + ATTR_TODAY_ENERGY_KWH, + ATTR_TOTAL_ENERGY_KWH, + DOMAIN, ) +from .coordinator import TPLinkDataUpdateCoordinator +from .entity import CoordinatedTPLinkEntity -ATTR_CURRENT_A = "current_a" -ATTR_CURRENT_POWER_W = "current_power_w" -ATTR_TODAY_ENERGY_KWH = "today_energy_kwh" -ATTR_TOTAL_ENERGY_KWH = "total_energy_kwh" -ENERGY_SENSORS: Final[list[SensorEntityDescription]] = [ - SensorEntityDescription( +@dataclass +class TPLinkSensorEntityDescription(SensorEntityDescription): + """Describes TPLink sensor entity.""" + + emeter_attr: str | None = None + + +ENERGY_SENSORS: tuple[TPLinkSensorEntityDescription, ...] = ( + TPLinkSensorEntityDescription( key=ATTR_CURRENT_POWER_W, native_unit_of_measurement=POWER_WATT, device_class=DEVICE_CLASS_POWER, state_class=STATE_CLASS_MEASUREMENT, name="Current Consumption", + emeter_attr="power", ), - SensorEntityDescription( + TPLinkSensorEntityDescription( key=ATTR_TOTAL_ENERGY_KWH, native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, state_class=STATE_CLASS_TOTAL_INCREASING, name="Total Consumption", + emeter_attr="total", ), - SensorEntityDescription( + TPLinkSensorEntityDescription( key=ATTR_TODAY_ENERGY_KWH, native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, state_class=STATE_CLASS_TOTAL_INCREASING, name="Today's Consumption", ), - SensorEntityDescription( + TPLinkSensorEntityDescription( key=ATTR_VOLTAGE, native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, device_class=DEVICE_CLASS_VOLTAGE, state_class=STATE_CLASS_MEASUREMENT, name="Voltage", + emeter_attr="voltage", ), - SensorEntityDescription( + TPLinkSensorEntityDescription( key=ATTR_CURRENT_A, native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, device_class=DEVICE_CLASS_CURRENT, state_class=STATE_CLASS_MEASUREMENT, name="Current", + emeter_attr="current", ), -] +) + + +def async_emeter_from_device( + device: SmartDevice, description: TPLinkSensorEntityDescription +) -> float | None: + """Map a sensor key to the device attribute.""" + if attr := description.emeter_attr: + val = getattr(device.emeter_realtime, attr) + if val is None: + return None + return cast(float, val) + + # ATTR_TODAY_ENERGY_KWH + if (emeter_today := device.emeter_today) is not None: + return cast(float, emeter_today) + # today's consumption not available, when device was off all the day + # bulb's do not report this information, so filter it out + return None if device.is_bulb else 0.0 async def async_setup_entry( @@ -94,62 +112,58 @@ async def async_setup_entry( config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up switches.""" + """Set up sensors.""" + coordinator: TPLinkDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] entities: list[SmartPlugSensor] = [] - coordinators: list[SmartPlugDataUpdateCoordinator] = hass.data[TPLINK_DOMAIN][ - COORDINATORS - ] - switches: list[SmartPlug] = hass.data[TPLINK_DOMAIN][CONF_SWITCH] - for switch in switches: - coordinator: SmartPlugDataUpdateCoordinator = coordinators[ - switch.context or switch.mac + parent = coordinator.device + if not parent.has_emeter: + return + + def _async_sensors_for_device(device: SmartDevice) -> list[SmartPlugSensor]: + return [ + SmartPlugSensor(device, coordinator, description) + for description in ENERGY_SENSORS + if async_emeter_from_device(device, description) is not None ] - if not switch.has_emeter and coordinator.data.get(CONF_EMETER_PARAMS) is None: - continue - for description in ENERGY_SENSORS: - if coordinator.data[CONF_EMETER_PARAMS].get(description.key) is not None: - entities.append(SmartPlugSensor(switch, coordinator, description)) + + if parent.is_strip: + # Historically we only add the children if the device is a strip + for child in parent.children: + entities.extend(_async_sensors_for_device(child)) + else: + entities.extend(_async_sensors_for_device(parent)) async_add_entities(entities) -class SmartPlugSensor(CoordinatorEntity, SensorEntity): +class SmartPlugSensor(CoordinatedTPLinkEntity, SensorEntity): """Representation of a TPLink Smart Plug energy sensor.""" + coordinator: TPLinkDataUpdateCoordinator + entity_description: TPLinkSensorEntityDescription + def __init__( self, - smartplug: SmartPlug, - coordinator: DataUpdateCoordinator, - description: SensorEntityDescription, + device: SmartDevice, + coordinator: TPLinkDataUpdateCoordinator, + description: TPLinkSensorEntityDescription, ) -> None: """Initialize the switch.""" - super().__init__(coordinator) - self.smartplug = smartplug + super().__init__(device, coordinator) self.entity_description = description - self._attr_name = f"{coordinator.data[CONF_ALIAS]} {description.name}" + self._attr_unique_id = ( + f"{legacy_device_id(self.device)}_{self.entity_description.key}" + ) @property - def data(self) -> dict[str, Any]: - """Return data from DataUpdateCoordinator.""" - return self.coordinator.data + def name(self) -> str: + """Return the name of the Smart Plug. + + Overridden to include the description. + """ + return f"{self.device.alias} {self.entity_description.name}" @property def native_value(self) -> float | None: """Return the sensors state.""" - return self.data[CONF_EMETER_PARAMS][self.entity_description.key] - - @property - def unique_id(self) -> str | None: - """Return a unique ID.""" - return f"{self.data[CONF_DEVICE_ID]}_{self.entity_description.key}" - - @property - def device_info(self) -> DeviceInfo: - """Return information about the device.""" - return { - "name": self.data[CONF_ALIAS], - "model": self.data[CONF_MODEL], - "manufacturer": "TP-Link", - "connections": {(dr.CONNECTION_NETWORK_MAC, self.data[CONF_MAC])}, - "sw_version": self.data[CONF_SW_VERSION], - } + return async_emeter_from_device(self.device, self.entity_description) diff --git a/homeassistant/components/tplink/strings.json b/homeassistant/components/tplink/strings.json index a10c44b9252..4f3b34beb9c 100644 --- a/homeassistant/components/tplink/strings.json +++ b/homeassistant/components/tplink/strings.json @@ -1,12 +1,27 @@ { "config": { + "flow_title": "{name} {model} ({host})", "step": { - "confirm": { - "description": "Do you want to setup TP-Link smart devices?" + "user": { + "description": "If you leave the host empty, discovery will be used to find devices.", + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + }, + "pick_device": { + "data": { + "device": "Device" + } + }, + "discovery_confirm": { + "description": "Do you want to setup {name} {model} ({host})?" } }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, "abort": { - "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" } } diff --git a/homeassistant/components/tplink/switch.py b/homeassistant/components/tplink/switch.py index 2d5a379198d..f0d299e21c8 100644 --- a/homeassistant/components/tplink/switch.py +++ b/homeassistant/components/tplink/switch.py @@ -1,31 +1,22 @@ """Support for TPLink HS100/HS110/HS200 smart switch.""" from __future__ import annotations -from asyncio import sleep +import logging from typing import Any -from pyHS100 import SmartPlug +from kasa import SmartDevice from homeassistant.components.switch import SwitchEntity -from homeassistant.components.tplink import SmartPlugDataUpdateCoordinator from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ALIAS, CONF_DEVICE_ID, CONF_MAC, CONF_STATE from homeassistant.core import HomeAssistant -import homeassistant.helpers.device_registry as dr -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) -from .const import ( - CONF_MODEL, - CONF_SW_VERSION, - CONF_SWITCH, - COORDINATORS, - DOMAIN as TPLINK_DOMAIN, -) +from . import legacy_device_id +from .const import DOMAIN +from .coordinator import TPLinkDataUpdateCoordinator +from .entity import CoordinatedTPLinkEntity, async_refresh_after + +_LOGGER = logging.getLogger(__name__) async def async_setup_entry( @@ -34,71 +25,43 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up switches.""" - entities: list[SmartPlugSwitch] = [] - coordinators: list[SmartPlugDataUpdateCoordinator] = hass.data[TPLINK_DOMAIN][ - COORDINATORS - ] - switches: list[SmartPlug] = hass.data[TPLINK_DOMAIN][CONF_SWITCH] - for switch in switches: - coordinator = coordinators[switch.context or switch.mac] - entities.append(SmartPlugSwitch(switch, coordinator)) + coordinator: TPLinkDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + device = coordinator.device + if not device.is_plug and not device.is_strip: + return + entities = [] + if device.is_strip: + # Historically we only add the children if the device is a strip + _LOGGER.debug("Initializing strip with %s sockets", len(device.children)) + for child in device.children: + entities.append(SmartPlugSwitch(child, coordinator)) + else: + entities.append(SmartPlugSwitch(device, coordinator)) async_add_entities(entities) -class SmartPlugSwitch(CoordinatorEntity, SwitchEntity): +class SmartPlugSwitch(CoordinatedTPLinkEntity, SwitchEntity): """Representation of a TPLink Smart Plug switch.""" + coordinator: TPLinkDataUpdateCoordinator + def __init__( - self, smartplug: SmartPlug, coordinator: DataUpdateCoordinator + self, + device: SmartDevice, + coordinator: TPLinkDataUpdateCoordinator, ) -> None: """Initialize the switch.""" - super().__init__(coordinator) - self.smartplug = smartplug - - @property - def data(self) -> dict[str, Any]: - """Return data from DataUpdateCoordinator.""" - return self.coordinator.data - - @property - def unique_id(self) -> str | None: - """Return a unique ID.""" - return self.data[CONF_DEVICE_ID] - - @property - def name(self) -> str | None: - """Return the name of the Smart Plug.""" - return self.data[CONF_ALIAS] - - @property - def device_info(self) -> DeviceInfo: - """Return information about the device.""" - return { - "name": self.data[CONF_ALIAS], - "model": self.data[CONF_MODEL], - "manufacturer": "TP-Link", - "connections": {(dr.CONNECTION_NETWORK_MAC, self.data[CONF_MAC])}, - "sw_version": self.data[CONF_SW_VERSION], - } - - @property - def is_on(self) -> bool | None: - """Return true if switch is on.""" - return self.data[CONF_STATE] + super().__init__(device, coordinator) + # For backwards compat with pyHS100 + self._attr_unique_id = legacy_device_id(device) + @async_refresh_after async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" - await self.hass.async_add_executor_job(self.smartplug.turn_on) - # Workaround for delayed device state update on HS210: #55190 - if "HS210" in self.device_info["model"]: - await sleep(0.5) - await self.coordinator.async_refresh() + await self.device.turn_on() + @async_refresh_after async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" - await self.hass.async_add_executor_job(self.smartplug.turn_off) - # Workaround for delayed device state update on HS210: #55190 - if "HS210" in self.device_info["model"]: - await sleep(0.5) - await self.coordinator.async_refresh() + await self.device.turn_off() diff --git a/homeassistant/components/tplink/translations/en.json b/homeassistant/components/tplink/translations/en.json index 1105f6a383b..0697974e708 100644 --- a/homeassistant/components/tplink/translations/en.json +++ b/homeassistant/components/tplink/translations/en.json @@ -1,12 +1,27 @@ { "config": { "abort": { - "no_devices_found": "No devices found on the network", - "single_instance_allowed": "Already configured. Only a single configuration possible." + "already_configured": "Device is already configured", + "no_devices_found": "No devices found on the network" }, + "error": { + "cannot_connect": "Failed to connect" + }, + "flow_title": "{name} {model} ({host})", "step": { - "confirm": { - "description": "Do you want to setup TP-Link smart devices?" + "discovery_confirm": { + "description": "Do you want to setup {name} {model} ({host})?" + }, + "pick_device": { + "data": { + "device": "Device" + } + }, + "user": { + "data": { + "host": "Host" + }, + "description": "If you leave the host empty, discovery will be used to find devices." } } } diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 3e00f8f5605..8db0a496f8c 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -289,6 +289,11 @@ DHCP = [ "hostname": "hs*", "macaddress": "B09575*" }, + { + "domain": "tplink", + "hostname": "hs*", + "macaddress": "C006C3*" + }, { "domain": "tplink", "hostname": "k[lp]*", @@ -314,6 +319,11 @@ DHCP = [ "hostname": "k[lp]*", "macaddress": "B09575*" }, + { + "domain": "tplink", + "hostname": "k[lp]*", + "macaddress": "C006C3*" + }, { "domain": "tplink", "hostname": "lb*", diff --git a/mypy.ini b/mypy.ini index 317ed1dbc3f..53afb687afe 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1188,6 +1188,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.tplink.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.tradfri.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1689,9 +1700,6 @@ ignore_errors = true [mypy-homeassistant.components.toon.*] ignore_errors = true -[mypy-homeassistant.components.tplink.*] -ignore_errors = true - [mypy-homeassistant.components.unifi.*] ignore_errors = true diff --git a/requirements_all.txt b/requirements_all.txt index ed0ea8541a8..805d29da384 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1307,9 +1307,6 @@ pyCEC==0.5.1 # homeassistant.components.control4 pyControl4==0.0.6 -# homeassistant.components.tplink -pyHS100==0.3.5.2 - # homeassistant.components.met_eireann pyMetEireann==2021.8.0 @@ -1894,6 +1891,9 @@ python-join-api==0.0.6 # homeassistant.components.juicenet python-juicenet==1.0.2 +# homeassistant.components.tplink +python-kasa==0.4.0 + # homeassistant.components.lirc # python-lirc==1.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b5cdb954240..7db1f7d5127 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -758,9 +758,6 @@ py17track==3.2.1 # homeassistant.components.control4 pyControl4==0.0.6 -# homeassistant.components.tplink -pyHS100==0.3.5.2 - # homeassistant.components.met_eireann pyMetEireann==2021.8.0 @@ -1093,6 +1090,9 @@ python-izone==1.1.6 # homeassistant.components.juicenet python-juicenet==1.0.2 +# homeassistant.components.tplink +python-kasa==0.4.0 + # homeassistant.components.xiaomi_miio python-miio==0.5.8 diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 6114030f2b2..375c55fe84b 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -126,7 +126,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.telegram_bot.*", "homeassistant.components.template.*", "homeassistant.components.toon.*", - "homeassistant.components.tplink.*", "homeassistant.components.unifi.*", "homeassistant.components.upnp.*", "homeassistant.components.vera.*", diff --git a/tests/components/tplink/__init__.py b/tests/components/tplink/__init__.py index 865c6c1d97a..f49f93258a3 100644 --- a/tests/components/tplink/__init__.py +++ b/tests/components/tplink/__init__.py @@ -1 +1,106 @@ """Tests for the TP-Link component.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +from kasa import SmartBulb, SmartPlug, SmartStrip +from kasa.exceptions import SmartDeviceException + +MODULE = "homeassistant.components.tplink" +MODULE_CONFIG_FLOW = "homeassistant.components.tplink.config_flow" +IP_ADDRESS = "127.0.0.1" +ALIAS = "My Bulb" +MODEL = "HS100" +MAC_ADDRESS = "aa:bb:cc:dd:ee:ff" +DEFAULT_ENTRY_TITLE = f"{ALIAS} {MODEL}" + + +def _mocked_bulb() -> SmartBulb: + bulb = MagicMock(auto_spec=SmartBulb) + bulb.update = AsyncMock() + bulb.mac = MAC_ADDRESS + bulb.alias = ALIAS + bulb.model = MODEL + bulb.host = IP_ADDRESS + bulb.brightness = 50 + bulb.color_temp = 4000 + bulb.is_color = True + bulb.is_strip = False + bulb.is_plug = False + bulb.hsv = (10, 30, 5) + bulb.device_id = MAC_ADDRESS + bulb.valid_temperature_range.min = 4000 + bulb.valid_temperature_range.max = 9000 + bulb.hw_info = {"sw_ver": "1.0.0"} + bulb.turn_off = AsyncMock() + bulb.turn_on = AsyncMock() + bulb.set_brightness = AsyncMock() + bulb.set_hsv = AsyncMock() + bulb.set_color_temp = AsyncMock() + return bulb + + +def _mocked_plug() -> SmartPlug: + plug = MagicMock(auto_spec=SmartPlug) + plug.update = AsyncMock() + plug.mac = MAC_ADDRESS + plug.alias = "My Plug" + plug.model = MODEL + plug.host = IP_ADDRESS + plug.is_light_strip = False + plug.is_bulb = False + plug.is_dimmer = False + plug.is_strip = False + plug.is_plug = True + plug.device_id = MAC_ADDRESS + plug.hw_info = {"sw_ver": "1.0.0"} + plug.turn_off = AsyncMock() + plug.turn_on = AsyncMock() + return plug + + +def _mocked_strip() -> SmartStrip: + strip = MagicMock(auto_spec=SmartStrip) + strip.update = AsyncMock() + strip.mac = MAC_ADDRESS + strip.alias = "My Strip" + strip.model = MODEL + strip.host = IP_ADDRESS + strip.is_light_strip = False + strip.is_bulb = False + strip.is_dimmer = False + strip.is_strip = True + strip.is_plug = True + strip.device_id = MAC_ADDRESS + strip.hw_info = {"sw_ver": "1.0.0"} + strip.turn_off = AsyncMock() + strip.turn_on = AsyncMock() + plug0 = _mocked_plug() + plug0.alias = "Plug0" + plug0.device_id = "bb:bb:cc:dd:ee:ff_PLUG0DEVICEID" + plug0.mac = "bb:bb:cc:dd:ee:ff" + plug1 = _mocked_plug() + plug1.device_id = "cc:bb:cc:dd:ee:ff_PLUG1DEVICEID" + plug1.mac = "cc:bb:cc:dd:ee:ff" + plug1.alias = "Plug1" + strip.children = [plug0, plug1] + return strip + + +def _patch_discovery(device=None, no_device=False): + async def _discovery(*_): + if no_device: + return {} + return {IP_ADDRESS: _mocked_bulb()} + + return patch("homeassistant.components.tplink.Discover.discover", new=_discovery) + + +def _patch_single_discovery(device=None, no_device=False): + async def _discover_single(*_): + if no_device: + raise SmartDeviceException + return device if device else _mocked_bulb() + + return patch( + "homeassistant.components.tplink.Discover.discover_single", new=_discover_single + ) diff --git a/tests/components/tplink/conftest.py b/tests/components/tplink/conftest.py index 61b242c5d2e..1963b595176 100644 --- a/tests/components/tplink/conftest.py +++ b/tests/components/tplink/conftest.py @@ -1,2 +1,27 @@ """tplink conftest.""" -from tests.components.light.conftest import mock_light_profiles # noqa: F401 + +import pytest + +from . import _patch_discovery + +from tests.common import mock_device_registry, mock_registry + + +@pytest.fixture +def mock_discovery(): + """Mock python-kasa discovery.""" + with _patch_discovery() as mock_discover: + mock_discover.return_value = {} + yield mock_discover + + +@pytest.fixture(name="device_reg") +def device_reg_fixture(hass): + """Return an empty, loaded, registry.""" + return mock_device_registry(hass) + + +@pytest.fixture(name="entity_reg") +def entity_reg_fixture(hass): + """Return an empty, loaded, registry.""" + return mock_registry(hass) diff --git a/tests/components/tplink/test_config_flow.py b/tests/components/tplink/test_config_flow.py new file mode 100644 index 00000000000..3c875f623dd --- /dev/null +++ b/tests/components/tplink/test_config_flow.py @@ -0,0 +1,477 @@ +"""Test the tplink config flow.""" +from unittest.mock import patch + +import pytest + +from homeassistant import config_entries, setup +from homeassistant.components.tplink import DOMAIN +from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MAC, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import RESULT_TYPE_ABORT, RESULT_TYPE_FORM + +from . import ( + ALIAS, + DEFAULT_ENTRY_TITLE, + IP_ADDRESS, + MAC_ADDRESS, + MODULE, + _patch_discovery, + _patch_single_discovery, +) + +from tests.common import MockConfigEntry + + +async def test_discovery(hass: HomeAssistant): + """Test setting up discovery.""" + with _patch_discovery(), _patch_single_discovery(): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await hass.async_block_till_done() + assert result["type"] == "form" + assert result["step_id"] == "user" + assert not result["errors"] + + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + assert result2["type"] == "form" + assert result2["step_id"] == "pick_device" + assert not result2["errors"] + + # test we can try again + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["step_id"] == "user" + assert not result["errors"] + + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + assert result2["type"] == "form" + assert result2["step_id"] == "pick_device" + assert not result2["errors"] + + with _patch_discovery(), _patch_single_discovery(), patch( + f"{MODULE}.async_setup", return_value=True + ) as mock_setup, patch( + f"{MODULE}.async_setup_entry", return_value=True + ) as mock_setup_entry: + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_DEVICE: MAC_ADDRESS}, + ) + await hass.async_block_till_done() + + assert result3["type"] == "create_entry" + assert result3["title"] == DEFAULT_ENTRY_TITLE + assert result3["data"] == {CONF_HOST: IP_ADDRESS} + mock_setup.assert_called_once() + mock_setup_entry.assert_called_once() + + # ignore configured devices + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["step_id"] == "user" + assert not result["errors"] + + with _patch_discovery(), _patch_single_discovery(): + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result2["type"] == "abort" + assert result2["reason"] == "no_devices_found" + + +async def test_discovery_with_existing_device_present(hass: HomeAssistant): + """Test setting up discovery.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.2"}, unique_id="dd:dd:dd:dd:dd:dd" + ) + config_entry.add_to_hass(hass) + + with _patch_discovery(), _patch_single_discovery(no_device=True): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["step_id"] == "user" + assert not result["errors"] + + with _patch_discovery(), _patch_single_discovery(): + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result2["type"] == "form" + assert result2["step_id"] == "pick_device" + assert not result2["errors"] + + # Now abort and make sure we can start over + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["step_id"] == "user" + assert not result["errors"] + + with _patch_discovery(), _patch_single_discovery(): + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result2["type"] == "form" + assert result2["step_id"] == "pick_device" + assert not result2["errors"] + + with _patch_discovery(), _patch_single_discovery(), patch( + f"{MODULE}.async_setup_entry", return_value=True + ) as mock_setup_entry: + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_DEVICE: MAC_ADDRESS} + ) + assert result3["type"] == "create_entry" + assert result3["title"] == DEFAULT_ENTRY_TITLE + assert result3["data"] == { + CONF_HOST: IP_ADDRESS, + } + await hass.async_block_till_done() + + mock_setup_entry.assert_called_once() + + # ignore configured devices + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["step_id"] == "user" + assert not result["errors"] + + with _patch_discovery(), _patch_single_discovery(): + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result2["type"] == "abort" + assert result2["reason"] == "no_devices_found" + + +async def test_discovery_no_device(hass: HomeAssistant): + """Test discovery without device.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with _patch_discovery(no_device=True), _patch_single_discovery(): + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result2["type"] == "abort" + assert result2["reason"] == "no_devices_found" + + +async def test_import(hass: HomeAssistant): + """Test import from yaml.""" + config = { + CONF_HOST: IP_ADDRESS, + } + + # Cannot connect + with _patch_discovery(no_device=True), _patch_single_discovery(no_device=True): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=config + ) + await hass.async_block_till_done() + + assert result["type"] == "abort" + assert result["reason"] == "cannot_connect" + + # Success + with _patch_discovery(), _patch_single_discovery(), patch( + f"{MODULE}.async_setup", return_value=True + ) as mock_setup, patch( + f"{MODULE}.async_setup_entry", return_value=True + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=config + ) + await hass.async_block_till_done() + + assert result["type"] == "create_entry" + assert result["title"] == DEFAULT_ENTRY_TITLE + assert result["data"] == { + CONF_HOST: IP_ADDRESS, + } + mock_setup.assert_called_once() + mock_setup_entry.assert_called_once() + + # Duplicate + with _patch_discovery(), _patch_single_discovery(): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=config + ) + await hass.async_block_till_done() + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + + +async def test_manual(hass: HomeAssistant): + """Test manually setup.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["step_id"] == "user" + assert not result["errors"] + + # Cannot connect (timeout) + with _patch_discovery(no_device=True), _patch_single_discovery(no_device=True): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: IP_ADDRESS} + ) + await hass.async_block_till_done() + + assert result2["type"] == "form" + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "cannot_connect"} + + # Success + with _patch_discovery(), _patch_single_discovery(), patch( + f"{MODULE}.async_setup", return_value=True + ), patch(f"{MODULE}.async_setup_entry", return_value=True): + result4 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: IP_ADDRESS} + ) + await hass.async_block_till_done() + assert result4["type"] == "create_entry" + assert result4["title"] == DEFAULT_ENTRY_TITLE + assert result4["data"] == { + CONF_HOST: IP_ADDRESS, + } + + # Duplicate + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + with _patch_discovery(no_device=True), _patch_single_discovery(no_device=True): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: IP_ADDRESS} + ) + await hass.async_block_till_done() + + assert result2["type"] == "abort" + assert result2["reason"] == "already_configured" + + +async def test_manual_no_capabilities(hass: HomeAssistant): + """Test manually setup without successful get_capabilities.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["step_id"] == "user" + assert not result["errors"] + + with _patch_discovery(no_device=True), _patch_single_discovery(), patch( + f"{MODULE}.async_setup", return_value=True + ), patch(f"{MODULE}.async_setup_entry", return_value=True): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: IP_ADDRESS} + ) + await hass.async_block_till_done() + + assert result["type"] == "create_entry" + assert result["data"] == { + CONF_HOST: IP_ADDRESS, + } + + +async def test_discovered_by_discovery_and_dhcp(hass): + """Test we get the form with discovery and abort for dhcp source when we get both.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + with _patch_discovery(), _patch_single_discovery(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DISCOVERY}, + data={CONF_HOST: IP_ADDRESS, CONF_MAC: MAC_ADDRESS, CONF_NAME: ALIAS}, + ) + await hass.async_block_till_done() + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + with _patch_discovery(), _patch_single_discovery(): + result2 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data={"ip": IP_ADDRESS, "macaddress": MAC_ADDRESS, "hostname": ALIAS}, + ) + await hass.async_block_till_done() + assert result2["type"] == RESULT_TYPE_ABORT + assert result2["reason"] == "already_in_progress" + + with _patch_discovery(), _patch_single_discovery(): + result3 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data={"ip": IP_ADDRESS, "macaddress": "00:00:00:00:00:00"}, + ) + await hass.async_block_till_done() + assert result3["type"] == RESULT_TYPE_ABORT + assert result3["reason"] == "already_in_progress" + + with _patch_discovery(no_device=True), _patch_single_discovery(no_device=True): + result3 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data={"ip": "1.2.3.5", "macaddress": "00:00:00:00:00:01"}, + ) + await hass.async_block_till_done() + assert result3["type"] == RESULT_TYPE_ABORT + assert result3["reason"] == "cannot_connect" + + +@pytest.mark.parametrize( + "source, data", + [ + ( + config_entries.SOURCE_DHCP, + {"ip": IP_ADDRESS, "macaddress": MAC_ADDRESS, "hostname": ALIAS}, + ), + ( + config_entries.SOURCE_DISCOVERY, + {CONF_HOST: IP_ADDRESS, CONF_MAC: MAC_ADDRESS, CONF_NAME: ALIAS}, + ), + ], +) +async def test_discovered_by_dhcp_or_discovery(hass, source, data): + """Test we can setup when discovered from dhcp or discovery.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + with _patch_discovery(), _patch_single_discovery(): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": source}, data=data + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + with _patch_discovery(), _patch_single_discovery(), patch( + f"{MODULE}.async_setup", return_value=True + ) as mock_async_setup, patch( + f"{MODULE}.async_setup_entry", return_value=True + ) as mock_async_setup_entry: + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["data"] == { + CONF_HOST: IP_ADDRESS, + } + assert mock_async_setup.called + assert mock_async_setup_entry.called + + +@pytest.mark.parametrize( + "source, data", + [ + ( + config_entries.SOURCE_DHCP, + {"ip": IP_ADDRESS, "macaddress": MAC_ADDRESS, "hostname": ALIAS}, + ), + ( + config_entries.SOURCE_DISCOVERY, + {CONF_HOST: IP_ADDRESS, CONF_MAC: MAC_ADDRESS, CONF_NAME: ALIAS}, + ), + ], +) +async def test_discovered_by_dhcp_or_discovery_failed_to_get_device(hass, source, data): + """Test we abort if we cannot get the unique id when discovered from dhcp.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + with _patch_discovery(no_device=True), _patch_single_discovery(no_device=True): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": source}, data=data + ) + await hass.async_block_till_done() + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "cannot_connect" + + +async def test_migration_device_online(hass: HomeAssistant): + """Test migration from single config entry.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + config = {CONF_MAC: MAC_ADDRESS, CONF_NAME: ALIAS, CONF_HOST: IP_ADDRESS} + + with _patch_discovery(), _patch_single_discovery(), patch( + f"{MODULE}.async_setup_entry", return_value=True + ) as mock_setup_entry: + await setup.async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "migration"}, data=config + ) + await hass.async_block_till_done() + + assert result["type"] == "create_entry" + assert result["title"] == ALIAS + assert result["data"] == { + CONF_HOST: IP_ADDRESS, + } + assert len(mock_setup_entry.mock_calls) == 2 + + # Duplicate + with _patch_discovery(), _patch_single_discovery(): + await setup.async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "migration"}, data=config + ) + await hass.async_block_till_done() + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + + +async def test_migration_device_offline(hass: HomeAssistant): + """Test migration from single config entry.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + config = {CONF_MAC: MAC_ADDRESS, CONF_NAME: ALIAS, CONF_HOST: None} + + with _patch_discovery(no_device=True), _patch_single_discovery( + no_device=True + ), patch(f"{MODULE}.async_setup_entry", return_value=True) as mock_setup_entry: + await setup.async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "migration"}, data=config + ) + await hass.async_block_till_done() + + assert result["type"] == "create_entry" + assert result["title"] == ALIAS + new_entry = result["result"] + assert result["data"] == { + CONF_HOST: None, + } + assert len(mock_setup_entry.mock_calls) == 2 + + # Ensure a manual import updates the missing host + config = {CONF_HOST: IP_ADDRESS} + with _patch_discovery(no_device=True), _patch_single_discovery(): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=config + ) + await hass.async_block_till_done() + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + assert new_entry.data[CONF_HOST] == IP_ADDRESS diff --git a/tests/components/tplink/test_init.py b/tests/components/tplink/test_init.py index d96d6846939..c3f7e814ed6 100644 --- a/tests/components/tplink/test_init.py +++ b/tests/components/tplink/test_init.py @@ -1,69 +1,22 @@ """Tests for the TP-Link component.""" from __future__ import annotations -import time -from typing import Any -from unittest.mock import MagicMock, patch +from unittest.mock import patch -from pyHS100 import SmartBulb, SmartDevice, SmartDeviceException, SmartPlug, smartstrip -from pyHS100.smartdevice import EmeterStatus -import pytest - -from homeassistant import config_entries, data_entry_flow from homeassistant.components import tplink -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN -from homeassistant.components.tplink.common import SmartDevices -from homeassistant.components.tplink.const import ( - CONF_DIMMER, - CONF_DISCOVERY, - CONF_LIGHT, - CONF_SW_VERSION, - CONF_SWITCH, - UNAVAILABLE_RETRY_DELAY, -) -from homeassistant.components.tplink.sensor import ENERGY_SENSORS +from homeassistant.components.tplink.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_HOST -from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component -from homeassistant.util import dt, slugify -from tests.common import MockConfigEntry, async_fire_time_changed, mock_coro -from tests.components.tplink.consts import ( - SMARTPLUG_HS100_DATA, - SMARTPLUG_HS110_DATA, - SMARTSTRIP_KP303_DATA, -) +from . import IP_ADDRESS, MAC_ADDRESS, _patch_discovery, _patch_single_discovery - -async def test_creating_entry_tries_discover(hass): - """Test setting up does discovery.""" - with patch( - "homeassistant.components.tplink.async_setup_entry", - return_value=mock_coro(True), - ) as mock_setup, patch( - "homeassistant.components.tplink.common.Discover.discover", - return_value={"host": 1234}, - ): - result = await hass.config_entries.flow.async_init( - tplink.DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - # Confirmation form - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - - await hass.async_block_till_done() - - assert len(mock_setup.mock_calls) == 1 +from tests.common import MockConfigEntry async def test_configuring_tplink_causes_discovery(hass): """Test that specifying empty config does discovery.""" - with patch("homeassistant.components.tplink.common.Discover.discover") as discover: + with patch("homeassistant.components.tplink.Discover.discover") as discover: discover.return_value = {"host": 1234} await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() @@ -71,371 +24,28 @@ async def test_configuring_tplink_causes_discovery(hass): assert len(discover.mock_calls) == 1 -@pytest.mark.parametrize( - "name,cls,platform", - [ - ("pyHS100.SmartPlug", SmartPlug, "switch"), - ("pyHS100.SmartBulb", SmartBulb, "light"), - ], -) -@pytest.mark.parametrize("count", [1, 2, 3]) -async def test_configuring_device_types(hass, name, cls, platform, count): - """Test that light or switch platform list is filled correctly.""" - with patch( - "homeassistant.components.tplink.common.Discover.discover" - ) as discover, patch( - "homeassistant.components.tplink.common.SmartDevice._query_helper" - ), patch( - "homeassistant.components.tplink.light.async_setup_entry", - return_value=True, - ): - discovery_data = { - f"123.123.123.{c}": cls("123.123.123.123") for c in range(count) - } - discover.return_value = discovery_data +async def test_config_entry_reload(hass): + """Test that a config entry can be reloaded.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + with _patch_discovery(), _patch_single_discovery(): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() - - assert len(discover.mock_calls) == 1 - assert len(hass.data[tplink.DOMAIN][platform]) == count - - -class UnknownSmartDevice(SmartDevice): - """Dummy class for testing.""" - - @property - def has_emeter(self) -> bool: - """Do nothing.""" - - def turn_off(self) -> None: - """Do nothing.""" - - def turn_on(self) -> None: - """Do nothing.""" - - @property - def is_on(self) -> bool: - """Do nothing.""" - - @property - def state_information(self) -> dict[str, Any]: - """Do nothing.""" - - -async def test_configuring_devices_from_multiple_sources(hass): - """Test static and discover devices are not duplicated.""" - with patch( - "homeassistant.components.tplink.common.Discover.discover" - ) as discover, patch( - "homeassistant.components.tplink.common.SmartDevice._query_helper" - ), patch( - "homeassistant.config_entries.ConfigEntries.async_forward_entry_setup" - ): - discover_device_fail = SmartPlug("123.123.123.123") - discover_device_fail.get_sysinfo = MagicMock(side_effect=SmartDeviceException()) - - discover.return_value = { - "123.123.123.1": SmartBulb("123.123.123.1"), - "123.123.123.2": SmartPlug("123.123.123.2"), - "123.123.123.3": SmartBulb("123.123.123.3"), - "123.123.123.4": SmartPlug("123.123.123.4"), - "123.123.123.123": discover_device_fail, - "123.123.123.124": UnknownSmartDevice("123.123.123.124"), - } - - await async_setup_component( - hass, - tplink.DOMAIN, - { - tplink.DOMAIN: { - CONF_LIGHT: [{CONF_HOST: "123.123.123.1"}], - CONF_SWITCH: [{CONF_HOST: "123.123.123.2"}], - CONF_DIMMER: [{CONF_HOST: "123.123.123.22"}], - } - }, - ) + assert already_migrated_config_entry.state == ConfigEntryState.LOADED + await hass.config_entries.async_unload(already_migrated_config_entry.entry_id) await hass.async_block_till_done() - - assert len(discover.mock_calls) == 1 - assert len(hass.data[tplink.DOMAIN][CONF_LIGHT]) == 3 - assert len(hass.data[tplink.DOMAIN][CONF_SWITCH]) == 2 + assert already_migrated_config_entry.state == ConfigEntryState.NOT_LOADED -async def test_is_dimmable(hass): - """Test that is_dimmable switches are correctly added as lights.""" - with patch( - "homeassistant.components.tplink.common.Discover.discover" - ) as discover, patch( - "homeassistant.components.tplink.light.async_setup_entry", - return_value=mock_coro(True), - ) as setup, patch( - "homeassistant.components.tplink.common.SmartDevice._query_helper" - ), patch( - "homeassistant.components.tplink.common.SmartPlug.is_dimmable", True - ): - dimmable_switch = SmartPlug("123.123.123.123") - discover.return_value = {"host": dimmable_switch} - +async def test_config_entry_retry(hass): + """Test that a config entry can be retried.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: IP_ADDRESS}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + with _patch_discovery(no_device=True), _patch_single_discovery(no_device=True): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() - - assert len(discover.mock_calls) == 1 - assert len(setup.mock_calls) == 1 - assert len(hass.data[tplink.DOMAIN][CONF_LIGHT]) == 1 - assert not hass.data[tplink.DOMAIN][CONF_SWITCH] - - -async def test_configuring_discovery_disabled(hass): - """Test that discover does not get called when disabled.""" - with patch( - "homeassistant.components.tplink.async_setup_entry", - return_value=mock_coro(True), - ) as mock_setup, patch( - "homeassistant.components.tplink.common.Discover.discover", return_value=[] - ) as discover: - await async_setup_component( - hass, tplink.DOMAIN, {tplink.DOMAIN: {tplink.CONF_DISCOVERY: False}} - ) - await hass.async_block_till_done() - - assert discover.call_count == 0 - assert mock_setup.call_count == 1 - - -async def test_platforms_are_initialized(hass: HomeAssistant): - """Test that platforms are initialized per configuration array.""" - config = { - tplink.DOMAIN: { - CONF_DISCOVERY: False, - CONF_LIGHT: [{CONF_HOST: "123.123.123.123"}], - CONF_SWITCH: [{CONF_HOST: "321.321.321.321"}], - } - } - - with patch("homeassistant.components.tplink.common.Discover.discover"), patch( - "homeassistant.components.tplink.get_static_devices" - ) as get_static_devices, patch( - "homeassistant.components.tplink.common.SmartDevice._query_helper" - ), patch( - "homeassistant.components.tplink.light.async_setup_entry", - return_value=mock_coro(True), - ), patch( - "homeassistant.components.tplink.common.SmartPlug.is_dimmable", - False, - ): - - light = SmartBulb("123.123.123.123") - switch = SmartPlug("321.321.321.321") - switch.get_sysinfo = MagicMock(return_value=SMARTPLUG_HS110_DATA["sysinfo"]) - switch.get_emeter_realtime = MagicMock( - return_value=EmeterStatus(SMARTPLUG_HS110_DATA["realtime"]) - ) - switch.get_emeter_daily = MagicMock( - return_value={int(time.strftime("%e")): 1.123} - ) - get_static_devices.return_value = SmartDevices([light], [switch]) - - # patching is_dimmable is necessray to avoid misdetection as light. - await async_setup_component(hass, tplink.DOMAIN, config) - await hass.async_block_till_done() - - state = hass.states.get(f"switch.{switch.alias}") - assert state - assert state.name == switch.alias - - for description in ENERGY_SENSORS: - state = hass.states.get( - f"sensor.{switch.alias}_{slugify(description.name)}" - ) - assert state - assert state.state is not None - assert state.name == f"{switch.alias} {description.name}" - - device_registry = dr.async_get(hass) - assert len(device_registry.devices) == 1 - device = next(iter(device_registry.devices.values())) - assert device.name == switch.alias - assert device.model == switch.model - assert device.connections == {(dr.CONNECTION_NETWORK_MAC, switch.mac.lower())} - assert device.sw_version == switch.sys_info[CONF_SW_VERSION] - - -async def test_smartplug_without_consumption_sensors(hass: HomeAssistant): - """Test that platforms are initialized per configuration array.""" - config = { - tplink.DOMAIN: { - CONF_DISCOVERY: False, - CONF_SWITCH: [{CONF_HOST: "321.321.321.321"}], - } - } - - with patch("homeassistant.components.tplink.common.Discover.discover"), patch( - "homeassistant.components.tplink.get_static_devices" - ) as get_static_devices, patch( - "homeassistant.components.tplink.common.SmartDevice._query_helper" - ), patch( - "homeassistant.components.tplink.light.async_setup_entry", - return_value=mock_coro(True), - ), patch( - "homeassistant.components.tplink.common.SmartPlug.is_dimmable", False - ): - - switch = SmartPlug("321.321.321.321") - switch.get_sysinfo = MagicMock(return_value=SMARTPLUG_HS100_DATA["sysinfo"]) - get_static_devices.return_value = SmartDevices([], [switch]) - - await async_setup_component(hass, tplink.DOMAIN, config) - await hass.async_block_till_done() - - entities = hass.states.async_entity_ids(SWITCH_DOMAIN) - assert len(entities) == 1 - - entities = hass.states.async_entity_ids(SENSOR_DOMAIN) - assert len(entities) == 0 - - -async def test_smartstrip_device(hass: HomeAssistant): - """Test discover a SmartStrip devices.""" - config = { - tplink.DOMAIN: { - CONF_DISCOVERY: True, - } - } - - class SmartStrip(smartstrip.SmartStrip): - """Moked SmartStrip class.""" - - def get_sysinfo(self): - return SMARTSTRIP_KP303_DATA["sysinfo"] - - with patch( - "homeassistant.components.tplink.common.Discover.discover" - ) as discover, patch( - "homeassistant.components.tplink.common.SmartDevice._query_helper" - ), patch( - "homeassistant.components.tplink.common.SmartPlug.get_sysinfo", - return_value=SMARTSTRIP_KP303_DATA["sysinfo"], - ): - - strip = SmartStrip("123.123.123.123") - discover.return_value = {"123.123.123.123": strip} - - assert await async_setup_component(hass, tplink.DOMAIN, config) - await hass.async_block_till_done() - - entities = hass.states.async_entity_ids(SWITCH_DOMAIN) - assert len(entities) == 3 - - -async def test_no_config_creates_no_entry(hass): - """Test for when there is no tplink in config.""" - with patch( - "homeassistant.components.tplink.async_setup_entry", - return_value=mock_coro(True), - ) as mock_setup: - await async_setup_component(hass, tplink.DOMAIN, {}) - await hass.async_block_till_done() - - assert mock_setup.call_count == 0 - - -async def test_not_available_at_startup(hass: HomeAssistant): - """Test when configured devices are not available.""" - config = { - tplink.DOMAIN: { - CONF_DISCOVERY: False, - CONF_SWITCH: [{CONF_HOST: "321.321.321.321"}], - } - } - - with patch("homeassistant.components.tplink.common.Discover.discover"), patch( - "homeassistant.components.tplink.get_static_devices" - ) as get_static_devices, patch( - "homeassistant.components.tplink.common.SmartDevice._query_helper" - ), patch( - "homeassistant.components.tplink.light.async_setup_entry", - return_value=mock_coro(True), - ), patch( - "homeassistant.components.tplink.common.SmartPlug.is_dimmable", False - ): - - switch = SmartPlug("321.321.321.321") - switch.get_sysinfo = MagicMock(side_effect=SmartDeviceException()) - get_static_devices.return_value = SmartDevices([], [switch]) - - # run setup while device unreachable - await async_setup_component(hass, tplink.DOMAIN, config) - await hass.async_block_till_done() - - entries = hass.config_entries.async_entries(tplink.DOMAIN) - assert len(entries) == 1 - assert entries[0].state is config_entries.ConfigEntryState.LOADED - - entities = hass.states.async_entity_ids(SWITCH_DOMAIN) - assert len(entities) == 0 - - # retrying with still unreachable device - async_fire_time_changed(hass, dt.utcnow() + UNAVAILABLE_RETRY_DELAY) - await hass.async_block_till_done() - - entries = hass.config_entries.async_entries(tplink.DOMAIN) - assert len(entries) == 1 - assert entries[0].state is config_entries.ConfigEntryState.LOADED - - entities = hass.states.async_entity_ids(SWITCH_DOMAIN) - assert len(entities) == 0 - - # retrying with now reachable device - switch.get_sysinfo = MagicMock(return_value=SMARTPLUG_HS100_DATA["sysinfo"]) - async_fire_time_changed(hass, dt.utcnow() + UNAVAILABLE_RETRY_DELAY) - await hass.async_block_till_done() - - entries = hass.config_entries.async_entries(tplink.DOMAIN) - assert len(entries) == 1 - assert entries[0].state is config_entries.ConfigEntryState.LOADED - - entities = hass.states.async_entity_ids(SWITCH_DOMAIN) - assert len(entities) == 1 - - -@pytest.mark.parametrize("platform", ["switch", "light"]) -async def test_unload(hass, platform): - """Test that the async_unload_entry works.""" - # As we have currently no configuration, we just to pass the domain here. - entry = MockConfigEntry(domain=tplink.DOMAIN) - entry.add_to_hass(hass) - - with patch( - "homeassistant.components.tplink.get_static_devices" - ) as get_static_devices, patch( - "homeassistant.components.tplink.common.SmartDevice._query_helper" - ), patch( - f"homeassistant.components.tplink.{platform}.async_setup_entry", - return_value=mock_coro(True), - ) as async_setup_entry: - config = { - tplink.DOMAIN: { - platform: [{CONF_HOST: "123.123.123.123"}], - CONF_DISCOVERY: False, - } - } - - light = SmartBulb("123.123.123.123") - switch = SmartPlug("321.321.321.321") - switch.get_sysinfo = MagicMock(return_value=SMARTPLUG_HS110_DATA["sysinfo"]) - switch.get_emeter_realtime = MagicMock( - return_value=EmeterStatus(SMARTPLUG_HS110_DATA["realtime"]) - ) - if platform == "light": - get_static_devices.return_value = SmartDevices([light], []) - elif platform == "switch": - get_static_devices.return_value = SmartDevices([], [switch]) - - assert await async_setup_component(hass, tplink.DOMAIN, config) - await hass.async_block_till_done() - - assert len(async_setup_entry.mock_calls) == 1 - assert tplink.DOMAIN in hass.data - - assert await tplink.async_unload_entry(hass, entry) - assert not hass.data[tplink.DOMAIN] + assert already_migrated_config_entry.state == ConfigEntryState.SETUP_RETRY diff --git a/tests/components/tplink/test_light.py b/tests/components/tplink/test_light.py index 1854e714902..6881faac9a2 100644 --- a/tests/components/tplink/test_light.py +++ b/tests/components/tplink/test_light.py @@ -1,752 +1,266 @@ """Tests for light platform.""" -from datetime import timedelta -import logging -from typing import Callable, NamedTuple -from unittest.mock import Mock, PropertyMock, patch -from pyHS100 import SmartDeviceException import pytest from homeassistant.components import tplink -from homeassistant.components.homeassistant import ( - DOMAIN as HA_DOMAIN, - SERVICE_UPDATE_ENTITY, -) from homeassistant.components.light import ( ATTR_BRIGHTNESS, + ATTR_COLOR_MODE, ATTR_COLOR_TEMP, ATTR_HS_COLOR, + ATTR_MAX_MIREDS, + ATTR_MIN_MIREDS, + ATTR_RGB_COLOR, + ATTR_SUPPORTED_COLOR_MODES, + ATTR_XY_COLOR, DOMAIN as LIGHT_DOMAIN, ) -from homeassistant.components.tplink.const import ( - CONF_DIMMER, - CONF_DISCOVERY, - CONF_LIGHT, -) -from homeassistant.components.tplink.light import SLEEP_TIME -from homeassistant.const import ( - ATTR_ENTITY_ID, - CONF_HOST, - SERVICE_TURN_OFF, - SERVICE_TURN_ON, -) +from homeassistant.components.tplink.const import DOMAIN +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from homeassistant.util.dt import utcnow -from tests.common import async_fire_time_changed +from . import MAC_ADDRESS, _mocked_bulb, _patch_discovery, _patch_single_discovery + +from tests.common import MockConfigEntry -class LightMockData(NamedTuple): - """Mock light data.""" - - sys_info: dict - light_state: dict - set_light_state: Callable[[dict], None] - set_light_state_mock: Mock - get_light_state_mock: Mock - current_consumption_mock: Mock - get_sysinfo_mock: Mock - get_emeter_daily_mock: Mock - get_emeter_monthly_mock: Mock - - -class SmartSwitchMockData(NamedTuple): - """Mock smart switch data.""" - - sys_info: dict - state_mock: Mock - brightness_mock: Mock - get_sysinfo_mock: Mock - - -@pytest.fixture(name="unknown_light_mock_data") -def unknown_light_mock_data_fixture() -> None: - """Create light mock data.""" - sys_info = { - "sw_ver": "1.2.3", - "hw_ver": "2.3.4", - "mac": "aa:bb:cc:dd:ee:ff", - "mic_mac": "00:11:22:33:44", - "type": "light", - "hwId": "1234", - "fwId": "4567", - "oemId": "891011", - "dev_name": "light1", - "rssi": 11, - "latitude": "0", - "longitude": "0", - "is_color": True, - "is_dimmable": True, - "is_variable_color_temp": True, - "model": "Foo", - "alias": "light1", - } - light_state = { - "on_off": True, - "dft_on_state": { - "brightness": 12, - "color_temp": 3200, - "hue": 110, - "saturation": 90, - }, - "brightness": 13, - "color_temp": 3300, - "hue": 110, - "saturation": 90, - } - - def set_light_state(state) -> None: - nonlocal light_state - drt_on_state = light_state["dft_on_state"] - drt_on_state.update(state.get("dft_on_state", {})) - - light_state.update(state) - light_state["dft_on_state"] = drt_on_state - return light_state - - set_light_state_patch = patch( - "homeassistant.components.tplink.common.SmartBulb.set_light_state", - side_effect=set_light_state, +async def test_color_light(hass: HomeAssistant) -> None: + """Test a light.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={}, unique_id=MAC_ADDRESS ) - get_light_state_patch = patch( - "homeassistant.components.tplink.common.SmartBulb.get_light_state", - return_value=light_state, - ) - current_consumption_patch = patch( - "homeassistant.components.tplink.common.SmartDevice.current_consumption", - return_value=3.23, - ) - get_sysinfo_patch = patch( - "homeassistant.components.tplink.common.SmartDevice.get_sysinfo", - return_value=sys_info, - ) - get_emeter_daily_patch = patch( - "homeassistant.components.tplink.common.SmartDevice.get_emeter_daily", - return_value={ - 1: 1.01, - 2: 1.02, - 3: 1.03, - 4: 1.04, - 5: 1.05, - 6: 1.06, - 7: 1.07, - 8: 1.08, - 9: 1.09, - 10: 1.10, - 11: 1.11, - 12: 1.12, - }, - ) - get_emeter_monthly_patch = patch( - "homeassistant.components.tplink.common.SmartDevice.get_emeter_monthly", - return_value={ - 1: 2.01, - 2: 2.02, - 3: 2.03, - 4: 2.04, - 5: 2.05, - 6: 2.06, - 7: 2.07, - 8: 2.08, - 9: 2.09, - 10: 2.10, - 11: 2.11, - 12: 2.12, - }, - ) - - with set_light_state_patch as set_light_state_mock, get_light_state_patch as get_light_state_mock, current_consumption_patch as current_consumption_mock, get_sysinfo_patch as get_sysinfo_mock, get_emeter_daily_patch as get_emeter_daily_mock, get_emeter_monthly_patch as get_emeter_monthly_mock: - yield LightMockData( - sys_info=sys_info, - light_state=light_state, - set_light_state=set_light_state, - set_light_state_mock=set_light_state_mock, - get_light_state_mock=get_light_state_mock, - current_consumption_mock=current_consumption_mock, - get_sysinfo_mock=get_sysinfo_mock, - get_emeter_daily_mock=get_emeter_daily_mock, - get_emeter_monthly_mock=get_emeter_monthly_mock, - ) - - -@pytest.fixture(name="light_mock_data") -def light_mock_data_fixture() -> None: - """Create light mock data.""" - sys_info = { - "sw_ver": "1.2.3", - "hw_ver": "2.3.4", - "mac": "aa:bb:cc:dd:ee:ff", - "mic_mac": "00:11:22:33:44", - "type": "light", - "hwId": "1234", - "fwId": "4567", - "oemId": "891011", - "dev_name": "light1", - "rssi": 11, - "latitude": "0", - "longitude": "0", - "is_color": True, - "is_dimmable": True, - "is_variable_color_temp": True, - "model": "LB120", - "alias": "light1", - } - - light_state = { - "on_off": True, - "dft_on_state": { - "brightness": 12, - "color_temp": 3200, - "hue": 110, - "saturation": 90, - }, - "brightness": 13, - "color_temp": 3300, - "hue": 110, - "saturation": 90, - } - - def set_light_state(state) -> None: - nonlocal light_state - drt_on_state = light_state["dft_on_state"] - drt_on_state.update(state.get("dft_on_state", {})) - - light_state.update(state) - light_state["dft_on_state"] = drt_on_state - return light_state - - set_light_state_patch = patch( - "homeassistant.components.tplink.common.SmartBulb.set_light_state", - side_effect=set_light_state, - ) - get_light_state_patch = patch( - "homeassistant.components.tplink.common.SmartBulb.get_light_state", - return_value=light_state, - ) - current_consumption_patch = patch( - "homeassistant.components.tplink.common.SmartDevice.current_consumption", - return_value=3.23, - ) - get_sysinfo_patch = patch( - "homeassistant.components.tplink.common.SmartDevice.get_sysinfo", - return_value=sys_info, - ) - get_emeter_daily_patch = patch( - "homeassistant.components.tplink.common.SmartDevice.get_emeter_daily", - return_value={ - 1: 1.01, - 2: 1.02, - 3: 1.03, - 4: 1.04, - 5: 1.05, - 6: 1.06, - 7: 1.07, - 8: 1.08, - 9: 1.09, - 10: 1.10, - 11: 1.11, - 12: 1.12, - }, - ) - get_emeter_monthly_patch = patch( - "homeassistant.components.tplink.common.SmartDevice.get_emeter_monthly", - return_value={ - 1: 2.01, - 2: 2.02, - 3: 2.03, - 4: 2.04, - 5: 2.05, - 6: 2.06, - 7: 2.07, - 8: 2.08, - 9: 2.09, - 10: 2.10, - 11: 2.11, - 12: 2.12, - }, - ) - - with set_light_state_patch as set_light_state_mock, get_light_state_patch as get_light_state_mock, current_consumption_patch as current_consumption_mock, get_sysinfo_patch as get_sysinfo_mock, get_emeter_daily_patch as get_emeter_daily_mock, get_emeter_monthly_patch as get_emeter_monthly_mock: - yield LightMockData( - sys_info=sys_info, - light_state=light_state, - set_light_state=set_light_state, - set_light_state_mock=set_light_state_mock, - get_light_state_mock=get_light_state_mock, - current_consumption_mock=current_consumption_mock, - get_sysinfo_mock=get_sysinfo_mock, - get_emeter_daily_mock=get_emeter_daily_mock, - get_emeter_monthly_mock=get_emeter_monthly_mock, - ) - - -@pytest.fixture(name="dimmer_switch_mock_data") -def dimmer_switch_mock_data_fixture() -> None: - """Create dimmer switch mock data.""" - sys_info = { - "sw_ver": "1.2.3", - "hw_ver": "2.3.4", - "mac": "aa:bb:cc:dd:ee:ff", - "mic_mac": "00:11:22:33:44", - "type": "switch", - "hwId": "1234", - "fwId": "4567", - "oemId": "891011", - "dev_name": "dimmer1", - "rssi": 11, - "latitude": "0", - "longitude": "0", - "is_color": False, - "is_dimmable": True, - "is_variable_color_temp": False, - "model": "HS220", - "alias": "dimmer1", - "feature": ":", - "relay_state": 1, - "brightness": 13, - } - - def state(*args, **kwargs): - nonlocal sys_info - if len(args) == 0: - return sys_info["relay_state"] - if args[0] == "ON": - sys_info["relay_state"] = 1 - else: - sys_info["relay_state"] = 0 - - def brightness(*args, **kwargs): - nonlocal sys_info - if len(args) == 0: - return sys_info["brightness"] - if sys_info["brightness"] == 0: - sys_info["relay_state"] = 0 - else: - sys_info["relay_state"] = 1 - sys_info["brightness"] = args[0] - - get_sysinfo_patch = patch( - "homeassistant.components.tplink.common.SmartDevice.get_sysinfo", - return_value=sys_info, - ) - state_patch = patch( - "homeassistant.components.tplink.common.SmartPlug.state", - new_callable=PropertyMock, - side_effect=state, - ) - brightness_patch = patch( - "homeassistant.components.tplink.common.SmartPlug.brightness", - new_callable=PropertyMock, - side_effect=brightness, - ) - with brightness_patch as brightness_mock, state_patch as state_mock, get_sysinfo_patch as get_sysinfo_mock: - yield SmartSwitchMockData( - sys_info=sys_info, - brightness_mock=brightness_mock, - state_mock=state_mock, - get_sysinfo_mock=get_sysinfo_mock, - ) - - -async def update_entity(hass: HomeAssistant, entity_id: str) -> None: - """Run an update action for an entity.""" - await hass.services.async_call( - HA_DOMAIN, - SERVICE_UPDATE_ENTITY, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - await hass.async_block_till_done() - - -async def test_smartswitch( - hass: HomeAssistant, dimmer_switch_mock_data: SmartSwitchMockData -) -> None: - """Test function.""" - sys_info = dimmer_switch_mock_data.sys_info - - await async_setup_component(hass, HA_DOMAIN, {}) - await hass.async_block_till_done() - - await async_setup_component( - hass, - tplink.DOMAIN, - { - tplink.DOMAIN: { - CONF_DISCOVERY: False, - CONF_DIMMER: [{CONF_HOST: "123.123.123.123"}], - } - }, - ) - await hass.async_block_till_done() - - assert hass.states.get("light.dimmer1") - - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.dimmer1"}, - blocking=True, - ) - await hass.async_block_till_done() - await update_entity(hass, "light.dimmer1") - - assert hass.states.get("light.dimmer1").state == "off" - assert sys_info["relay_state"] == 0 - - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.dimmer1", ATTR_BRIGHTNESS: 50}, - blocking=True, - ) - await hass.async_block_till_done() - await update_entity(hass, "light.dimmer1") - - state = hass.states.get("light.dimmer1") - assert state.state == "on" - assert state.attributes["brightness"] == 51 - assert sys_info["relay_state"] == 1 - - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.dimmer1", ATTR_BRIGHTNESS: 55}, - blocking=True, - ) - await hass.async_block_till_done() - await update_entity(hass, "light.dimmer1") - - state = hass.states.get("light.dimmer1") - assert state.state == "on" - assert state.attributes["brightness"] == 56 - assert sys_info["brightness"] == 22 - - sys_info["relay_state"] = 0 - sys_info["brightness"] = 66 - - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.dimmer1"}, - blocking=True, - ) - await hass.async_block_till_done() - await update_entity(hass, "light.dimmer1") - - state = hass.states.get("light.dimmer1") - assert state.state == "off" - - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.dimmer1"}, - blocking=True, - ) - await hass.async_block_till_done() - await update_entity(hass, "light.dimmer1") - - state = hass.states.get("light.dimmer1") - assert state.state == "on" - assert state.attributes["brightness"] == 168 - assert sys_info["brightness"] == 66 - - -async def test_unknown_light( - hass: HomeAssistant, unknown_light_mock_data: LightMockData -) -> None: - """Test function.""" - await async_setup_component(hass, HA_DOMAIN, {}) - await hass.async_block_till_done() - - await async_setup_component( - hass, - tplink.DOMAIN, - { - tplink.DOMAIN: { - CONF_DISCOVERY: False, - CONF_LIGHT: [{CONF_HOST: "123.123.123.123"}], - } - }, - ) - await hass.async_block_till_done() - - state = hass.states.get("light.light1") - assert state.state == "on" - assert state.attributes["min_mireds"] == 200 - assert state.attributes["max_mireds"] == 370 - - -async def test_light(hass: HomeAssistant, light_mock_data: LightMockData) -> None: - """Test function.""" - light_state = light_mock_data.light_state - set_light_state = light_mock_data.set_light_state - - await async_setup_component(hass, HA_DOMAIN, {}) - await hass.async_block_till_done() - - await async_setup_component( - hass, - tplink.DOMAIN, - { - tplink.DOMAIN: { - CONF_DISCOVERY: False, - CONF_LIGHT: [{CONF_HOST: "123.123.123.123"}], - } - }, - ) - await hass.async_block_till_done() - - assert hass.states.get("light.light1") - - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.light1"}, - blocking=True, - ) - await hass.async_block_till_done() - await update_entity(hass, "light.light1") - - assert hass.states.get("light.light1").state == "off" - assert light_state["on_off"] == 0 - - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.light1", ATTR_COLOR_TEMP: 222, ATTR_BRIGHTNESS: 50}, - blocking=True, - ) - await hass.async_block_till_done() - await update_entity(hass, "light.light1") - - state = hass.states.get("light.light1") - assert state.state == "on" - assert state.attributes["brightness"] == 51 - assert state.attributes["color_temp"] == 222 - assert "hs_color" in state.attributes - assert light_state["on_off"] == 1 - - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.light1", ATTR_BRIGHTNESS: 55, ATTR_HS_COLOR: (23, 27)}, - blocking=True, - ) - await hass.async_block_till_done() - await update_entity(hass, "light.light1") - - state = hass.states.get("light.light1") - assert state.state == "on" - assert state.attributes["brightness"] == 56 - assert state.attributes["hs_color"] == (23, 27) - assert "color_temp" not in state.attributes - assert light_state["brightness"] == 22 - assert light_state["hue"] == 23 - assert light_state["saturation"] == 27 - - light_state["on_off"] = 0 - light_state["dft_on_state"]["on_off"] = 0 - light_state["brightness"] = 66 - light_state["dft_on_state"]["brightness"] = 66 - light_state["color_temp"] = 6400 - light_state["dft_on_state"]["color_temp"] = 123 - light_state["hue"] = 77 - light_state["dft_on_state"]["hue"] = 77 - light_state["saturation"] = 78 - light_state["dft_on_state"]["saturation"] = 78 - - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.light1"}, - blocking=True, - ) - await hass.async_block_till_done() - await update_entity(hass, "light.light1") - - state = hass.states.get("light.light1") - assert state.state == "off" - - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.light1"}, - blocking=True, - ) - await hass.async_block_till_done() - await update_entity(hass, "light.light1") - - state = hass.states.get("light.light1") - assert state.state == "on" - assert state.attributes["brightness"] == 168 - assert state.attributes["color_temp"] == 156 - assert "hs_color" in state.attributes - assert light_state["brightness"] == 66 - assert light_state["hue"] == 77 - assert light_state["saturation"] == 78 - - set_light_state({"brightness": 91, "dft_on_state": {"brightness": 91}}) - await update_entity(hass, "light.light1") - - state = hass.states.get("light.light1") - assert state.attributes["brightness"] == 232 - - -async def test_get_light_state_retry( - hass: HomeAssistant, light_mock_data: LightMockData -) -> None: - """Test function.""" - # Setup test for retries for sysinfo. - get_sysinfo_call_count = 0 - - def get_sysinfo_side_effect(): - nonlocal get_sysinfo_call_count - get_sysinfo_call_count += 1 - - # Need to fail on the 2nd call because the first call is used to - # determine if the device is online during the light platform's - # setup hook. - if get_sysinfo_call_count == 2: - raise SmartDeviceException() - - return light_mock_data.sys_info - - light_mock_data.get_sysinfo_mock.side_effect = get_sysinfo_side_effect - - # Setup test for retries of setting state information. - set_state_call_count = 0 - - def set_light_state_side_effect(state_data: dict): - nonlocal set_state_call_count, light_mock_data - set_state_call_count += 1 - - if set_state_call_count == 1: - raise SmartDeviceException() - - return light_mock_data.set_light_state(state_data) - - light_mock_data.set_light_state_mock.side_effect = set_light_state_side_effect - - # Setup component. - await async_setup_component(hass, HA_DOMAIN, {}) - await hass.async_block_till_done() - - await async_setup_component( - hass, - tplink.DOMAIN, - { - tplink.DOMAIN: { - CONF_DISCOVERY: False, - CONF_LIGHT: [{CONF_HOST: "123.123.123.123"}], - } - }, - ) - await hass.async_block_till_done() - - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.light1"}, - blocking=True, - ) - await hass.async_block_till_done() - await update_entity(hass, "light.light1") - - assert light_mock_data.get_sysinfo_mock.call_count > 1 - assert light_mock_data.get_light_state_mock.call_count > 1 - assert light_mock_data.set_light_state_mock.call_count > 1 - - assert light_mock_data.get_sysinfo_mock.call_count < 40 - assert light_mock_data.get_light_state_mock.call_count < 40 - assert light_mock_data.set_light_state_mock.call_count < 10 - - -async def test_update_failure( - hass: HomeAssistant, light_mock_data: LightMockData, caplog -): - """Test that update failures are logged.""" - - await async_setup_component(hass, HA_DOMAIN, {}) - await hass.async_block_till_done() - - await async_setup_component( - hass, - tplink.DOMAIN, - { - tplink.DOMAIN: { - CONF_DISCOVERY: False, - CONF_LIGHT: [{CONF_HOST: "123.123.123.123"}], - } - }, - ) - await hass.async_block_till_done() - caplog.clear() - caplog.set_level(logging.WARNING) - await hass.helpers.entity_component.async_update_entity("light.light1") - assert caplog.text == "" - - with patch("homeassistant.components.tplink.light.MAX_ATTEMPTS", 0): - caplog.clear() - caplog.set_level(logging.WARNING) - await hass.helpers.entity_component.async_update_entity("light.light1") - assert "Could not read state for 123.123.123.123|light1" in caplog.text - - get_state_call_count = 0 - - def get_light_state_side_effect(): - nonlocal get_state_call_count - get_state_call_count += 1 - - if get_state_call_count == 1: - raise SmartDeviceException() - - return light_mock_data.light_state - - light_mock_data.get_light_state_mock.side_effect = get_light_state_side_effect - - with patch("homeassistant.components.tplink.light", MAX_ATTEMPTS=2, SLEEP_TIME=0): - caplog.clear() - caplog.set_level(logging.DEBUG) - - await update_entity(hass, "light.light1") - assert ( - f"Retrying in {SLEEP_TIME} seconds for 123.123.123.123|light1" - in caplog.text - ) - assert "Device 123.123.123.123|light1 responded after " in caplog.text - - -async def test_async_setup_entry_unavailable( - hass: HomeAssistant, light_mock_data: LightMockData, caplog -): - """Test unavailable devices trigger a later retry.""" - caplog.clear() - caplog.set_level(logging.WARNING) - - with patch( - "homeassistant.components.tplink.common.SmartDevice.get_sysinfo", - side_effect=SmartDeviceException, - ): - await async_setup_component(hass, HA_DOMAIN, {}) + already_migrated_config_entry.add_to_hass(hass) + bulb = _mocked_bulb() + bulb.color_temp = None + with _patch_discovery(device=bulb), _patch_single_discovery(device=bulb): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() - await async_setup_component( - hass, - tplink.DOMAIN, - { - tplink.DOMAIN: { - CONF_DISCOVERY: False, - CONF_LIGHT: [{CONF_HOST: "123.123.123.123"}], - } - }, - ) + entity_id = "light.my_bulb" + state = hass.states.get(entity_id) + assert state.state == "on" + attributes = state.attributes + assert attributes[ATTR_BRIGHTNESS] == 128 + assert attributes[ATTR_COLOR_MODE] == "hs" + assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["brightness", "color_temp", "hs"] + assert attributes[ATTR_MIN_MIREDS] == 111 + assert attributes[ATTR_MAX_MIREDS] == 250 + assert attributes[ATTR_HS_COLOR] == (10, 30) + assert attributes[ATTR_RGB_COLOR] == (255, 191, 178) + assert attributes[ATTR_XY_COLOR] == (0.42, 0.336) + + await hass.services.async_call( + LIGHT_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + bulb.turn_off.assert_called_once() + + await hass.services.async_call( + LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + bulb.turn_on.assert_called_once() + bulb.turn_on.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 100}, + blocking=True, + ) + bulb.set_brightness.assert_called_with(39, transition=None) + bulb.set_brightness.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP: 150}, + blocking=True, + ) + bulb.set_color_temp.assert_called_with(6666, brightness=None, transition=None) + bulb.set_color_temp.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP: 150}, + blocking=True, + ) + bulb.set_color_temp.assert_called_with(6666, brightness=None, transition=None) + bulb.set_color_temp.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_HS_COLOR: (10, 30)}, + blocking=True, + ) + bulb.set_hsv.assert_called_with(10, 30, None, transition=None) + bulb.set_hsv.reset_mock() + + +@pytest.mark.parametrize("is_color", [True, False]) +async def test_color_temp_light(hass: HomeAssistant, is_color: bool) -> None: + """Test a light.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + bulb = _mocked_bulb() + bulb.is_color = is_color + bulb.color_temp = 4000 + bulb.is_variable_color_temp = True + + with _patch_discovery(device=bulb), _patch_single_discovery(device=bulb): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() - assert not hass.states.get("light.light1") - future = utcnow() + timedelta(seconds=30) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - assert hass.states.get("light.light1") + entity_id = "light.my_bulb" + + state = hass.states.get(entity_id) + assert state.state == "on" + attributes = state.attributes + assert attributes[ATTR_BRIGHTNESS] == 128 + assert attributes[ATTR_COLOR_MODE] == "color_temp" + if bulb.is_color: + assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [ + "brightness", + "color_temp", + "hs", + ] + else: + assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["brightness", "color_temp"] + assert attributes[ATTR_MIN_MIREDS] == 111 + assert attributes[ATTR_MAX_MIREDS] == 250 + assert attributes[ATTR_COLOR_TEMP] == 250 + + await hass.services.async_call( + LIGHT_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + bulb.turn_off.assert_called_once() + + await hass.services.async_call( + LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + bulb.turn_on.assert_called_once() + bulb.turn_on.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 100}, + blocking=True, + ) + bulb.set_brightness.assert_called_with(39, transition=None) + bulb.set_brightness.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP: 150}, + blocking=True, + ) + bulb.set_color_temp.assert_called_with(6666, brightness=None, transition=None) + bulb.set_color_temp.reset_mock() + + +async def test_brightness_only_light(hass: HomeAssistant) -> None: + """Test a light.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + bulb = _mocked_bulb() + bulb.is_color = False + bulb.is_variable_color_temp = False + + with _patch_discovery(device=bulb), _patch_single_discovery(device=bulb): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "light.my_bulb" + + state = hass.states.get(entity_id) + assert state.state == "on" + attributes = state.attributes + assert attributes[ATTR_BRIGHTNESS] == 128 + assert attributes[ATTR_COLOR_MODE] == "brightness" + assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["brightness"] + + await hass.services.async_call( + LIGHT_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + bulb.turn_off.assert_called_once() + + await hass.services.async_call( + LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + bulb.turn_on.assert_called_once() + bulb.turn_on.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 100}, + blocking=True, + ) + bulb.set_brightness.assert_called_with(39, transition=None) + bulb.set_brightness.reset_mock() + + +async def test_on_off_light(hass: HomeAssistant) -> None: + """Test a light.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + bulb = _mocked_bulb() + bulb.is_color = False + bulb.is_variable_color_temp = False + bulb.is_dimmable = False + + with _patch_discovery(device=bulb), _patch_single_discovery(device=bulb): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "light.my_bulb" + + state = hass.states.get(entity_id) + assert state.state == "on" + attributes = state.attributes + assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["onoff"] + + await hass.services.async_call( + LIGHT_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + bulb.turn_off.assert_called_once() + + await hass.services.async_call( + LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + bulb.turn_on.assert_called_once() + bulb.turn_on.reset_mock() + + +async def test_off_at_start_light(hass: HomeAssistant) -> None: + """Test a light.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + bulb = _mocked_bulb() + bulb.is_color = False + bulb.is_variable_color_temp = False + bulb.is_dimmable = False + bulb.is_on = False + + with _patch_discovery(device=bulb), _patch_single_discovery(device=bulb): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "light.my_bulb" + + state = hass.states.get(entity_id) + assert state.state == "off" + attributes = state.attributes + assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["onoff"] diff --git a/tests/components/tplink/test_migration.py b/tests/components/tplink/test_migration.py new file mode 100644 index 00000000000..6cd82448ca2 --- /dev/null +++ b/tests/components/tplink/test_migration.py @@ -0,0 +1,241 @@ +"""Test the tplink config flow.""" + +from homeassistant import setup +from homeassistant.components.tplink import CONF_DISCOVERY, CONF_SWITCH, DOMAIN +from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STARTED +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.device_registry import DeviceRegistry +from homeassistant.helpers.entity_registry import EntityRegistry + +from . import ALIAS, IP_ADDRESS, MAC_ADDRESS, _patch_discovery, _patch_single_discovery + +from tests.common import MockConfigEntry + + +async def test_migration_device_online_end_to_end( + hass: HomeAssistant, device_reg: DeviceRegistry, entity_reg: EntityRegistry +): + """Test migration from single config entry.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + device = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, MAC_ADDRESS)}, + name=ALIAS, + ) + switch_entity_reg = entity_reg.async_get_or_create( + config_entry=config_entry, + platform=DOMAIN, + domain="switch", + unique_id=MAC_ADDRESS, + original_name=ALIAS, + device_id=device.id, + ) + light_entity_reg = entity_reg.async_get_or_create( + config_entry=config_entry, + platform=DOMAIN, + domain="light", + unique_id=dr.format_mac(MAC_ADDRESS), + original_name=ALIAS, + device_id=device.id, + ) + power_sensor_entity_reg = entity_reg.async_get_or_create( + config_entry=config_entry, + platform=DOMAIN, + domain="sensor", + unique_id=f"{MAC_ADDRESS}_sensor", + original_name=ALIAS, + device_id=device.id, + ) + + with _patch_discovery(), _patch_single_discovery(): + await setup.async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + migrated_entry = None + for entry in hass.config_entries.async_entries(DOMAIN): + if entry.unique_id == DOMAIN: + migrated_entry = entry + break + + assert migrated_entry is not None + + assert device.config_entries == {migrated_entry.entry_id} + assert light_entity_reg.config_entry_id == migrated_entry.entry_id + assert switch_entity_reg.config_entry_id == migrated_entry.entry_id + assert power_sensor_entity_reg.config_entry_id == migrated_entry.entry_id + assert er.async_entries_for_config_entry(entity_reg, config_entry) == [] + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + legacy_entry = None + for entry in hass.config_entries.async_entries(DOMAIN): + if entry.unique_id == DOMAIN: + legacy_entry = entry + break + + assert legacy_entry is None + + +async def test_migration_device_online_end_to_end_after_downgrade( + hass: HomeAssistant, device_reg: DeviceRegistry, entity_reg: EntityRegistry +): + """Test migration from single config entry can happen again after a downgrade.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: IP_ADDRESS}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + device = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, MAC_ADDRESS)}, + name=ALIAS, + ) + light_entity_reg = entity_reg.async_get_or_create( + config_entry=config_entry, + platform=DOMAIN, + domain="light", + unique_id=MAC_ADDRESS, + original_name=ALIAS, + device_id=device.id, + ) + power_sensor_entity_reg = entity_reg.async_get_or_create( + config_entry=config_entry, + platform=DOMAIN, + domain="sensor", + unique_id=f"{MAC_ADDRESS}_sensor", + original_name=ALIAS, + device_id=device.id, + ) + + with _patch_discovery(), _patch_single_discovery(): + await setup.async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + assert device.config_entries == {config_entry.entry_id} + assert light_entity_reg.config_entry_id == config_entry.entry_id + assert power_sensor_entity_reg.config_entry_id == config_entry.entry_id + assert er.async_entries_for_config_entry(entity_reg, config_entry) == [] + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + legacy_entry = None + for entry in hass.config_entries.async_entries(DOMAIN): + if entry.unique_id == DOMAIN: + legacy_entry = entry + break + + assert legacy_entry is None + + +async def test_migration_device_online_end_to_end_ignores_other_devices( + hass: HomeAssistant, device_reg: DeviceRegistry, entity_reg: EntityRegistry +): + """Test migration from single config entry.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + other_domain_config_entry = MockConfigEntry( + domain="other_domain", data={}, unique_id="other_domain" + ) + other_domain_config_entry.add_to_hass(hass) + device = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, MAC_ADDRESS)}, + name=ALIAS, + ) + other_device = device_reg.async_get_or_create( + config_entry_id=other_domain_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "556655665566")}, + name=ALIAS, + ) + light_entity_reg = entity_reg.async_get_or_create( + config_entry=config_entry, + platform=DOMAIN, + domain="light", + unique_id=MAC_ADDRESS, + original_name=ALIAS, + device_id=device.id, + ) + power_sensor_entity_reg = entity_reg.async_get_or_create( + config_entry=config_entry, + platform=DOMAIN, + domain="sensor", + unique_id=f"{MAC_ADDRESS}_sensor", + original_name=ALIAS, + device_id=device.id, + ) + ignored_entity_reg = entity_reg.async_get_or_create( + config_entry=other_domain_config_entry, + platform=DOMAIN, + domain="sensor", + unique_id="00:00:00:00:00:00_sensor", + original_name=ALIAS, + device_id=device.id, + ) + garbage_entity_reg = entity_reg.async_get_or_create( + config_entry=config_entry, + platform=DOMAIN, + domain="sensor", + unique_id="garbage", + original_name=ALIAS, + device_id=other_device.id, + ) + + with _patch_discovery(), _patch_single_discovery(): + await setup.async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + migrated_entry = None + for entry in hass.config_entries.async_entries(DOMAIN): + if entry.unique_id == DOMAIN: + migrated_entry = entry + break + + assert migrated_entry is not None + + assert device.config_entries == {migrated_entry.entry_id} + assert light_entity_reg.config_entry_id == migrated_entry.entry_id + assert power_sensor_entity_reg.config_entry_id == migrated_entry.entry_id + assert ignored_entity_reg.config_entry_id == other_domain_config_entry.entry_id + assert garbage_entity_reg.config_entry_id == config_entry.entry_id + + assert er.async_entries_for_config_entry(entity_reg, config_entry) == [] + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + legacy_entry = None + for entry in hass.config_entries.async_entries(DOMAIN): + if entry.unique_id == DOMAIN: + legacy_entry = entry + break + + assert legacy_entry is not None + + +async def test_migrate_from_yaml(hass: HomeAssistant): + """Test migrate from yaml.""" + config = { + DOMAIN: { + CONF_DISCOVERY: False, + CONF_SWITCH: [{CONF_HOST: IP_ADDRESS}], + } + } + with _patch_discovery(), _patch_single_discovery(): + await setup.async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + migrated_entry = None + for entry in hass.config_entries.async_entries(DOMAIN): + if entry.unique_id == MAC_ADDRESS: + migrated_entry = entry + break + + assert migrated_entry is not None + assert migrated_entry.data[CONF_HOST] == IP_ADDRESS diff --git a/tests/components/tplink/test_sensor.py b/tests/components/tplink/test_sensor.py new file mode 100644 index 00000000000..565c5b51ef5 --- /dev/null +++ b/tests/components/tplink/test_sensor.py @@ -0,0 +1,122 @@ +"""Tests for light platform.""" + +from unittest.mock import Mock + +from homeassistant.components import tplink +from homeassistant.components.tplink.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from . import ( + MAC_ADDRESS, + _mocked_bulb, + _mocked_plug, + _patch_discovery, + _patch_single_discovery, +) + +from tests.common import MockConfigEntry + + +async def test_color_light_with_an_emeter(hass: HomeAssistant) -> None: + """Test a light with an emeter.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + bulb = _mocked_bulb() + bulb.color_temp = None + bulb.has_emeter = True + bulb.emeter_realtime = Mock( + power=None, + total=None, + voltage=None, + current=5, + ) + bulb.emeter_today = 5000 + with _patch_discovery(device=bulb), _patch_single_discovery(device=bulb): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + await hass.async_block_till_done() + + expected = { + "sensor.my_bulb_today_s_consumption": 5000, + "sensor.my_bulb_current": 5, + } + entity_id = "light.my_bulb" + state = hass.states.get(entity_id) + assert state.state == "on" + for sensor_entity_id, value in expected.items(): + assert hass.states.get(sensor_entity_id).state == str(value) + + not_expected = { + "sensor.my_bulb_current_consumption", + "sensor.my_bulb_total_consumption", + "sensor.my_bulb_voltage", + } + for sensor_entity_id in not_expected: + assert hass.states.get(sensor_entity_id) is None + + +async def test_plug_with_an_emeter(hass: HomeAssistant) -> None: + """Test a plug with an emeter.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + plug = _mocked_plug() + plug.color_temp = None + plug.has_emeter = True + plug.emeter_realtime = Mock( + power=100, + total=30, + voltage=121, + current=5, + ) + plug.emeter_today = None + with _patch_discovery(device=plug), _patch_single_discovery(device=plug): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + await hass.async_block_till_done() + + expected = { + "sensor.my_plug_current_consumption": 100, + "sensor.my_plug_total_consumption": 30, + "sensor.my_plug_today_s_consumption": 0.0, + "sensor.my_plug_voltage": 121, + "sensor.my_plug_current": 5, + } + entity_id = "switch.my_plug" + state = hass.states.get(entity_id) + assert state.state == "on" + for sensor_entity_id, value in expected.items(): + assert hass.states.get(sensor_entity_id).state == str(value) + + +async def test_color_light_no_emeter(hass: HomeAssistant) -> None: + """Test a light without an emeter.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + bulb = _mocked_bulb() + bulb.color_temp = None + bulb.has_emeter = False + with _patch_discovery(device=bulb), _patch_single_discovery(device=bulb): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + await hass.async_block_till_done() + + entity_id = "light.my_bulb" + state = hass.states.get(entity_id) + assert state.state == "on" + + not_expected = [ + "sensor.my_bulb_current_consumption" + "sensor.my_bulb_total_consumption" + "sensor.my_bulb_today_s_consumption" + "sensor.my_bulb_voltage" + "sensor.my_bulb_current" + ] + for sensor_entity_id in not_expected: + assert hass.states.get(sensor_entity_id) is None diff --git a/tests/components/tplink/test_switch.py b/tests/components/tplink/test_switch.py new file mode 100644 index 00000000000..f62051b2328 --- /dev/null +++ b/tests/components/tplink/test_switch.py @@ -0,0 +1,107 @@ +"""Tests for switch platform.""" + +from datetime import timedelta +from unittest.mock import AsyncMock + +from kasa import SmartDeviceException + +from homeassistant.components import tplink +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.components.tplink.const import DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, STATE_ON, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + +from . import ( + MAC_ADDRESS, + _mocked_plug, + _mocked_strip, + _patch_discovery, + _patch_single_discovery, +) + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_plug(hass: HomeAssistant) -> None: + """Test a smart plug.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + plug = _mocked_plug() + with _patch_discovery(device=plug), _patch_single_discovery(device=plug): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "switch.my_plug" + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + await hass.services.async_call( + SWITCH_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + plug.turn_off.assert_called_once() + plug.turn_off.reset_mock() + + await hass.services.async_call( + SWITCH_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + plug.turn_on.assert_called_once() + plug.turn_on.reset_mock() + + +async def test_plug_update_fails(hass: HomeAssistant) -> None: + """Test a smart plug update failure.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + plug = _mocked_plug() + with _patch_discovery(device=plug), _patch_single_discovery(device=plug): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "switch.my_plug" + state = hass.states.get(entity_id) + assert state.state == STATE_ON + plug.update = AsyncMock(side_effect=SmartDeviceException) + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE + + +async def test_strip(hass: HomeAssistant) -> None: + """Test a smart strip.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + plug = _mocked_strip() + with _patch_discovery(device=plug), _patch_single_discovery(device=plug): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + + # Verify we only create entities for the children + # since this is what the previous version did + assert hass.states.get("switch.my_strip") is None + + for plug_id in range(2): + entity_id = f"switch.plug{plug_id}" + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + await hass.services.async_call( + SWITCH_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + plug.children[plug_id].turn_off.assert_called_once() + plug.children[plug_id].turn_off.reset_mock() + + await hass.services.async_call( + SWITCH_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + plug.children[plug_id].turn_on.assert_called_once() + plug.children[plug_id].turn_on.reset_mock() From b15f11f46ac0d5e29a4c6646b8ed8706b5253313 Mon Sep 17 00:00:00 2001 From: RenierM26 <66512715+RenierM26@users.noreply.github.com> Date: Mon, 27 Sep 2021 22:12:40 +0200 Subject: [PATCH 643/843] Discover Switchbot MAC in config flow (#56616) * Update config_flow.py * Switchbot Config_flow discover mac instead of needing to type it. * Do not show already configured devices in config flow, abort if no unconfigured devices. * Apply suggestions from code review Co-authored-by: J. Nick Koston * Move MAC to top of config flow form dict. * Update homeassistant/components/switchbot/config_flow.py Co-authored-by: J. Nick Koston --- .../components/switchbot/config_flow.py | 82 +++++++++++-------- .../components/switchbot/strings.json | 10 +-- .../components/switchbot/translations/en.json | 10 +-- tests/components/switchbot/conftest.py | 40 ++++++--- .../components/switchbot/test_config_flow.py | 73 ++--------------- 5 files changed, 90 insertions(+), 125 deletions(-) diff --git a/homeassistant/components/switchbot/config_flow.py b/homeassistant/components/switchbot/config_flow.py index f222c28acd6..eba40d46058 100644 --- a/homeassistant/components/switchbot/config_flow.py +++ b/homeassistant/components/switchbot/config_flow.py @@ -30,19 +30,15 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -def _btle_connect(mac: str) -> dict: +def _btle_connect() -> dict: """Scan for BTLE advertisement data.""" - # Try to find switchbot mac in nearby devices, - # by scanning for btle devices. - switchbots = GetSwitchbotDevices() - switchbots.discover() - switchbot_device = switchbots.get_device_data(mac=mac) + switchbot_devices = GetSwitchbotDevices().discover() - if not switchbot_device: + if not switchbot_devices: raise NotConnectedError("Failed to discover switchbot") - return switchbot_device + return switchbot_devices class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN): @@ -50,11 +46,8 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 - async def _validate_mac(self, data: dict) -> FlowResult: - """Try to connect to Switchbot device and create entry if successful.""" - await self.async_set_unique_id(data[CONF_MAC].replace(":", "")) - self._abort_if_unique_id_configured() - + async def _get_switchbots(self) -> dict: + """Try to discover nearby Switchbot devices.""" # asyncio.lock prevents btle adapter exceptions if there are multiple calls to this method. # store asyncio.lock in hass data if not present. if DOMAIN not in self.hass.data: @@ -64,17 +57,11 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN): connect_lock = self.hass.data[DOMAIN][BTLE_LOCK] - # Validate bluetooth device mac. + # Discover switchbots nearby. async with connect_lock: - _btle_adv_data = await self.hass.async_add_executor_job( - _btle_connect, data[CONF_MAC] - ) + _btle_adv_data = await self.hass.async_add_executor_job(_btle_connect) - if _btle_adv_data["modelName"] in SUPPORTED_MODEL_TYPES: - data[CONF_SENSOR_TYPE] = SUPPORTED_MODEL_TYPES[_btle_adv_data["modelName"]] - return self.async_create_entry(title=data[CONF_NAME], data=data) - - return self.async_abort(reason="switchbot_unsupported_type") + return _btle_adv_data @staticmethod @callback @@ -84,36 +71,59 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN): """Get the options flow for this handler.""" return SwitchbotOptionsFlowHandler(config_entry) + def __init__(self): + """Initialize the config flow.""" + self._discovered_devices = {} + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle a flow initiated by the user.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: - user_input[CONF_MAC] = user_input[CONF_MAC].replace("-", ":").lower() + await self.async_set_unique_id(user_input[CONF_MAC].replace(":", "")) + self._abort_if_unique_id_configured() - # abort if already configured. - for item in self._async_current_entries(): - if item.data.get(CONF_MAC) == user_input[CONF_MAC]: - return self.async_abort(reason="already_configured_device") + user_input[CONF_SENSOR_TYPE] = SUPPORTED_MODEL_TYPES[ + self._discovered_devices[self.unique_id]["modelName"] + ] - try: - return await self._validate_mac(user_input) + return self.async_create_entry(title=user_input[CONF_NAME], data=user_input) - except NotConnectedError: - errors["base"] = "cannot_connect" + try: + self._discovered_devices = await self._get_switchbots() - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") - return self.async_abort(reason="unknown") + except NotConnectedError: + return self.async_abort(reason="cannot_connect") + + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") + + # Get devices already configured. + configured_devices = { + item.data[CONF_MAC] + for item in self._async_current_entries(include_ignore=False) + } + + # Get supported devices not yet configured. + unconfigured_devices = { + device["mac_address"]: f"{device['mac_address']} {device['modelName']}" + for device in self._discovered_devices.values() + if device["modelName"] in SUPPORTED_MODEL_TYPES + and device["mac_address"] not in configured_devices + } + + if not unconfigured_devices: + return self.async_abort(reason="no_unconfigured_devices") data_schema = vol.Schema( { + vol.Required(CONF_MAC): vol.In(unconfigured_devices), vol.Required(CONF_NAME): str, vol.Optional(CONF_PASSWORD): str, - vol.Required(CONF_MAC): str, } ) diff --git a/homeassistant/components/switchbot/strings.json b/homeassistant/components/switchbot/strings.json index 970dc9f47ce..8c308083982 100644 --- a/homeassistant/components/switchbot/strings.json +++ b/homeassistant/components/switchbot/strings.json @@ -5,18 +5,18 @@ "user": { "title": "Setup Switchbot device", "data": { + "mac": "Device MAC address", "name": "[%key:common::config_flow::data::name%]", - "password": "[%key:common::config_flow::data::password%]", - "mac": "Device MAC address" + "password": "[%key:common::config_flow::data::password%]" } } }, - "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" - }, + "error": {}, "abort": { "already_configured_device": "[%key:common::config_flow::abort::already_configured_device%]", + "no_unconfigured_devices": "No unconfigured devices found.", "unknown": "[%key:common::config_flow::error::unknown%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "switchbot_unsupported_type": "Unsupported Switchbot Type." } }, diff --git a/homeassistant/components/switchbot/translations/en.json b/homeassistant/components/switchbot/translations/en.json index a9800265297..5f2c49e74f5 100644 --- a/homeassistant/components/switchbot/translations/en.json +++ b/homeassistant/components/switchbot/translations/en.json @@ -2,20 +2,20 @@ "config": { "abort": { "already_configured_device": "Device is already configured", + "no_unconfigured_devices": "No unconfigured devices found.", "unknown": "Unexpected error", + "cannot_connect": "Failed to connect", "switchbot_unsupported_type": "Unsupported Switchbot Type." }, - "error": { - "cannot_connect": "Failed to connect" - }, + "error": {}, "flow_title": "{name}", "step": { "user": { "data": { + "mac": "Mac", "name": "Name", - "password": "Password", - "mac": "Mac" + "password": "Password" }, "title": "Setup Switchbot device" } diff --git a/tests/components/switchbot/conftest.py b/tests/components/switchbot/conftest.py index 1b9019ddfde..8e90547a18f 100644 --- a/tests/components/switchbot/conftest.py +++ b/tests/components/switchbot/conftest.py @@ -12,18 +12,35 @@ class MocGetSwitchbotDevices: """Get switchbot devices class constructor.""" self._interface = interface self._all_services_data = { - "mac_address": "e7:89:43:99:99:99", - "Flags": "06", - "Manufacturer": "5900e78943d9fe7c", - "Complete 128b Services": "cba20d00-224d-11e6-9fb8-0002a5d5c51b", - "data": { - "switchMode": "true", - "isOn": "true", - "battery": 91, - "rssi": -71, + "e78943999999": { + "mac_address": "e7:89:43:99:99:99", + "Flags": "06", + "Manufacturer": "5900e78943d9fe7c", + "Complete 128b Services": "cba20d00-224d-11e6-9fb8-0002a5d5c51b", + "data": { + "switchMode": "true", + "isOn": "true", + "battery": 91, + "rssi": -71, + }, + "model": "H", + "modelName": "WoHand", + }, + "e78943909090": { + "mac_address": "e7:89:43:90:90:90", + "Flags": "06", + "Manufacturer": "5900e78943d9fe7c", + "Complete 128b Services": "cba20d00-224d-11e6-9fb8-0002a5d5c51b", + "data": { + "calibration": True, + "battery": 74, + "position": 100, + "lightLevel": 2, + "rssi": -73, + }, + "model": "c", + "modelName": "WoCurtain", }, - "model": "H", - "modelName": "WoHand", } self._curtain_all_services_data = { "mac_address": "e7:89:43:90:90:90", @@ -90,6 +107,5 @@ def switchbot_config_flow(hass): instance = mock_switchbot.return_value instance.discover = MagicMock(return_value=True) - instance.get_device_data = MagicMock(return_value=True) yield mock_switchbot diff --git a/tests/components/switchbot/test_config_flow.py b/tests/components/switchbot/test_config_flow.py index a8f13a8796c..fad0769a7b8 100644 --- a/tests/components/switchbot/test_config_flow.py +++ b/tests/components/switchbot/test_config_flow.py @@ -19,8 +19,6 @@ from homeassistant.setup import async_setup_component from . import ( USER_INPUT, USER_INPUT_CURTAIN, - USER_INPUT_INVALID, - USER_INPUT_UNSUPPORTED_DEVICE, YAML_CONFIG, _patch_async_setup_entry, init_integration, @@ -58,24 +56,6 @@ async def test_user_form_valid_mac(hass): assert len(mock_setup_entry.mock_calls) == 1 - # test duplicate device creation fails. - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - assert result["type"] == RESULT_TYPE_FORM - assert result["step_id"] == "user" - assert result["errors"] == {} - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - USER_INPUT, - ) - await hass.async_block_till_done() - - assert result["type"] == RESULT_TYPE_ABORT - assert result["reason"] == "already_configured_device" - # test curtain device creation. result = await hass.config_entries.flow.async_init( @@ -103,47 +83,13 @@ async def test_user_form_valid_mac(hass): assert len(mock_setup_entry.mock_calls) == 1 - -async def test_user_form_unsupported_device(hass): - """Test the user initiated form for unsupported device type.""" - await async_setup_component(hass, "persistent_notification", {}) + # tests abort if no unconfigured devices are found. result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] == RESULT_TYPE_FORM - assert result["step_id"] == "user" - assert result["errors"] == {} - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - USER_INPUT_UNSUPPORTED_DEVICE, - ) - await hass.async_block_till_done() - assert result["type"] == RESULT_TYPE_ABORT - assert result["reason"] == "switchbot_unsupported_type" - - -async def test_user_form_invalid_device(hass): - """Test the user initiated form for invalid device type.""" - await async_setup_component(hass, "persistent_notification", {}) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - assert result["type"] == RESULT_TYPE_FORM - assert result["step_id"] == "user" - assert result["errors"] == {} - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - USER_INPUT_INVALID, - ) - await hass.async_block_till_done() - - assert result["type"] == RESULT_TYPE_FORM - assert result["errors"] == {"base": "cannot_connect"} + assert result["reason"] == "no_unconfigured_devices" async def test_async_step_import(hass): @@ -175,20 +121,13 @@ async def test_user_form_exception(hass, switchbot_config_flow): DOMAIN, context={"source": SOURCE_USER} ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - USER_INPUT, - ) - - assert result["type"] == RESULT_TYPE_FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": "cannot_connect"} + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "cannot_connect" switchbot_config_flow.side_effect = Exception - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - USER_INPUT, + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] == RESULT_TYPE_ABORT From a28fd7d61b392d001b338f3bff2d5d68f5d50b4f Mon Sep 17 00:00:00 2001 From: Michael Chisholm Date: Tue, 28 Sep 2021 06:47:01 +1000 Subject: [PATCH 644/843] Config-flow for DLNA-DMR integration (#55267) * Modernize dlna_dmr component: configflow, test, types * Support config-flow with ssdp discovery * Add unit tests * Enforce strict typing * Gracefully handle network devices (dis)appearing * Fix Aiohttp mock response headers type to match actual response class * Fixes from code review * Fixes from code review * Import device config in flow if unavailable at hass start * Support SSDP advertisements * Ignore bad BOOTID, fix ssdp:byebye handling * Only listen for events on interface connected to device * Release all listeners when entities are removed * Warn about deprecated dlna_dmr configuration * Use sublogger for dlna_dmr.config_flow for easier filtering * Tests for dlna_dmr.data module * Rewrite DMR tests for HA style * Fix DMR strings: "Digital Media *Renderer*" * Update DMR entity state and device info when changed * Replace deprecated async_upnp_client State with TransportState * supported_features are dynamic, based on current device state * Cleanup fully when subscription fails * Log warnings when device connection fails unexpectedly * Set PARALLEL_UPDATES to unlimited * Fix spelling * Fixes from code review * Simplify has & can checks to just can, which includes has * Treat transitioning state as playing (not idle) to reduce UI jerking * Test if device is usable * Handle ssdp:update messages properly * Fix _remove_ssdp_callbacks being shared by all DlnaDmrEntity instances * Fix tests for transitioning state * Mock DmrDevice.is_profile_device (added to support embedded devices) * Use ST & NT SSDP headers to find DMR devices, not deviceType The deviceType is extracted from the device's description XML, and will not be what we want when dealing with embedded devices. * Use UDN from SSDP headers, not device description, as unique_id The SSDP headers have the UDN of the embedded device that we're interested in, whereas the device description (`ATTR_UPNP_UDN`) field will always be for the root device. * Fix DMR string English localization * Test config flow with UDN from SSDP headers * Bump async-upnp-client==0.22.1, fix flake8 error * fix test for remapping * DMR HA Device connections based on root and embedded UDN * DmrDevice's UpnpDevice is now named profile_device * Use device type from SSDP headers, not device description * Mark dlna_dmr constants as Final * Use embedded device UDN and type for unique ID when connected via URL * More informative connection error messages * Also match SSDP messages on NT headers The NT header is to ssdp:alive messages what ST is to M-SEARCH responses. * Bump async-upnp-client==0.22.2 * fix merge * Bump async-upnp-client==0.22.3 Co-authored-by: Steven Looman Co-authored-by: J. Nick Koston --- .coveragerc | 1 - .strict-typing | 1 + CODEOWNERS | 1 + .../components/discovery/__init__.py | 5 +- homeassistant/components/dlna_dmr/__init__.py | 55 + .../components/dlna_dmr/config_flow.py | 340 +++++ homeassistant/components/dlna_dmr/const.py | 16 + homeassistant/components/dlna_dmr/data.py | 126 ++ .../components/dlna_dmr/manifest.json | 27 +- .../components/dlna_dmr/media_player.py | 691 ++++++--- .../components/dlna_dmr/strings.json | 44 + .../components/dlna_dmr/translations/en.json | 44 + homeassistant/components/ssdp/__init__.py | 8 +- homeassistant/components/ssdp/manifest.json | 2 +- homeassistant/components/upnp/manifest.json | 2 +- .../components/yeelight/manifest.json | 2 +- homeassistant/generated/config_flows.py | 1 + homeassistant/generated/ssdp.py | 20 + homeassistant/package_constraints.txt | 2 +- mypy.ini | 11 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/dlna_dmr/__init__.py | 1 + tests/components/dlna_dmr/conftest.py | 141 ++ tests/components/dlna_dmr/test_config_flow.py | 624 ++++++++ tests/components/dlna_dmr/test_data.py | 121 ++ tests/components/dlna_dmr/test_init.py | 59 + .../components/dlna_dmr/test_media_player.py | 1338 +++++++++++++++++ tests/components/ssdp/test_init.py | 10 +- tests/test_util/aiohttp.py | 3 +- 30 files changed, 3443 insertions(+), 257 deletions(-) create mode 100644 homeassistant/components/dlna_dmr/config_flow.py create mode 100644 homeassistant/components/dlna_dmr/const.py create mode 100644 homeassistant/components/dlna_dmr/data.py create mode 100644 homeassistant/components/dlna_dmr/strings.json create mode 100644 homeassistant/components/dlna_dmr/translations/en.json create mode 100644 tests/components/dlna_dmr/__init__.py create mode 100644 tests/components/dlna_dmr/conftest.py create mode 100644 tests/components/dlna_dmr/test_config_flow.py create mode 100644 tests/components/dlna_dmr/test_data.py create mode 100644 tests/components/dlna_dmr/test_init.py create mode 100644 tests/components/dlna_dmr/test_media_player.py diff --git a/.coveragerc b/.coveragerc index 0a54f15724c..e21ea37fe06 100644 --- a/.coveragerc +++ b/.coveragerc @@ -212,7 +212,6 @@ omit = homeassistant/components/dlib_face_detect/image_processing.py homeassistant/components/dlib_face_identify/image_processing.py homeassistant/components/dlink/switch.py - homeassistant/components/dlna_dmr/media_player.py homeassistant/components/dnsip/sensor.py homeassistant/components/dominos/* homeassistant/components/doods/* diff --git a/.strict-typing b/.strict-typing index 2a982a16afc..091ee3b8b2c 100644 --- a/.strict-typing +++ b/.strict-typing @@ -31,6 +31,7 @@ homeassistant.components.crownstone.* homeassistant.components.device_automation.* homeassistant.components.device_tracker.* homeassistant.components.devolo_home_control.* +homeassistant.components.dlna_dmr.* homeassistant.components.dnsip.* homeassistant.components.dsmr.* homeassistant.components.dunehd.* diff --git a/CODEOWNERS b/CODEOWNERS index 6e178069816..cea06b6b361 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -122,6 +122,7 @@ homeassistant/components/dhcp/* @bdraco homeassistant/components/dht/* @thegardenmonkey homeassistant/components/digital_ocean/* @fabaff homeassistant/components/discogs/* @thibmaek +homeassistant/components/dlna_dmr/* @StevenLooman @chishm homeassistant/components/doorbird/* @oblogic7 @bdraco homeassistant/components/dsmr/* @Robbie1221 @frenck homeassistant/components/dsmr_reader/* @depl0y diff --git a/homeassistant/components/discovery/__init__.py b/homeassistant/components/discovery/__init__.py index 99106ef63a8..3a925fb0579 100644 --- a/homeassistant/components/discovery/__init__.py +++ b/homeassistant/components/discovery/__init__.py @@ -1,4 +1,6 @@ """Starts a service to scan in intervals for new devices.""" +from __future__ import annotations + from datetime import timedelta import json import logging @@ -56,7 +58,7 @@ SERVICE_HANDLERS = { "lg_smart_device": ("media_player", "lg_soundbar"), } -OPTIONAL_SERVICE_HANDLERS = {SERVICE_DLNA_DMR: ("media_player", "dlna_dmr")} +OPTIONAL_SERVICE_HANDLERS: dict[str, tuple[str, str | None]] = {} MIGRATED_SERVICE_HANDLERS = [ SERVICE_APPLE_TV, @@ -64,6 +66,7 @@ MIGRATED_SERVICE_HANDLERS = [ "deconz", SERVICE_DAIKIN, "denonavr", + SERVICE_DLNA_DMR, "esphome", "google_cast", SERVICE_HASS_IOS_APP, diff --git a/homeassistant/components/dlna_dmr/__init__.py b/homeassistant/components/dlna_dmr/__init__.py index f38456ec6ee..536567336fd 100644 --- a/homeassistant/components/dlna_dmr/__init__.py +++ b/homeassistant/components/dlna_dmr/__init__.py @@ -1 +1,56 @@ """The dlna_dmr component.""" +from __future__ import annotations + +from homeassistant import config_entries +from homeassistant.components.media_player.const import DOMAIN as MEDIA_PLAYER_DOMAIN +from homeassistant.const import CONF_PLATFORM, CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType + +from .const import DOMAIN, LOGGER + +PLATFORMS = [MEDIA_PLAYER_DOMAIN] + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up DLNA component.""" + if MEDIA_PLAYER_DOMAIN not in config: + return True + + for entry_config in config[MEDIA_PLAYER_DOMAIN]: + if entry_config.get(CONF_PLATFORM) != DOMAIN: + continue + LOGGER.warning( + "Configuring dlna_dmr via yaml is deprecated; the configuration for" + " %s has been migrated to a config entry and can be safely removed", + entry_config.get(CONF_URL), + ) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=entry_config, + ) + ) + + return True + + +async def async_setup_entry( + hass: HomeAssistant, entry: config_entries.ConfigEntry +) -> bool: + """Set up a DLNA DMR device from a config entry.""" + LOGGER.debug("Setting up config entry: %s", entry.unique_id) + + # Forward setup to the appropriate platform + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_unload_entry( + hass: HomeAssistant, config_entry: config_entries.ConfigEntry +) -> bool: + """Unload a config entry.""" + # Forward to the same platform as async_setup_entry did + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) diff --git a/homeassistant/components/dlna_dmr/config_flow.py b/homeassistant/components/dlna_dmr/config_flow.py new file mode 100644 index 00000000000..53513d593f5 --- /dev/null +++ b/homeassistant/components/dlna_dmr/config_flow.py @@ -0,0 +1,340 @@ +"""Config flow for DLNA DMR.""" +from __future__ import annotations + +from collections.abc import Callable +import logging +from pprint import pformat +from typing import Any, Mapping, Optional +from urllib.parse import urlparse + +from async_upnp_client.client import UpnpError +from async_upnp_client.profiles.dlna import DmrDevice +from async_upnp_client.profiles.profile import find_device_of_type +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components import ssdp +from homeassistant.const import CONF_DEVICE_ID, CONF_NAME, CONF_TYPE, CONF_URL +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import IntegrationError +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import DiscoveryInfoType + +from .const import ( + CONF_CALLBACK_URL_OVERRIDE, + CONF_LISTEN_PORT, + CONF_POLL_AVAILABILITY, + DOMAIN, +) +from .data import get_domain_data + +LOGGER = logging.getLogger(__name__) + +FlowInput = Optional[Mapping[str, Any]] + + +class ConnectError(IntegrationError): + """Error occurred when trying to connect to a device.""" + + +class DlnaDmrFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a DLNA DMR config flow. + + The Unique Device Name (UDN) of the DMR device is used as the unique_id for + config entries and for entities. This UDN may differ from the root UDN if + the DMR is an embedded device. + """ + + VERSION = 1 + + def __init__(self) -> None: + """Initialize flow.""" + self._discoveries: list[Mapping[str, str]] = [] + + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> config_entries.OptionsFlow: + """Define the config flow to handle options.""" + return DlnaDmrOptionsFlowHandler(config_entry) + + async def async_step_user(self, user_input: FlowInput = None) -> FlowResult: + """Handle a flow initialized by the user: manual URL entry. + + Discovered devices will already be displayed, no need to prompt user + with them here. + """ + LOGGER.debug("async_step_user: user_input: %s", user_input) + + errors = {} + if user_input is not None: + try: + discovery = await self._async_connect(user_input[CONF_URL]) + except ConnectError as err: + errors["base"] = err.args[0] + else: + # If unmigrated config was imported earlier then use it + import_data = get_domain_data(self.hass).unmigrated_config.get( + user_input[CONF_URL] + ) + if import_data is not None: + return await self.async_step_import(import_data) + # Device setup manually, assume we don't get SSDP broadcast notifications + options = {CONF_POLL_AVAILABILITY: True} + return await self._async_create_entry_from_discovery(discovery, options) + + data_schema = vol.Schema({CONF_URL: str}) + return self.async_show_form( + step_id="user", data_schema=data_schema, errors=errors + ) + + async def async_step_import(self, import_data: FlowInput = None) -> FlowResult: + """Import a new DLNA DMR device from a config entry. + + This flow is triggered by `async_setup`. If no device has been + configured before, find any matching device and create a config_entry + for it. Otherwise, do nothing. + """ + LOGGER.debug("async_step_import: import_data: %s", import_data) + + if not import_data or CONF_URL not in import_data: + LOGGER.debug("Entry not imported: incomplete_config") + return self.async_abort(reason="incomplete_config") + + self._async_abort_entries_match({CONF_URL: import_data[CONF_URL]}) + + location = import_data[CONF_URL] + self._discoveries = await self._async_get_discoveries() + + poll_availability = True + + # Find the device in the list of unconfigured devices + for discovery in self._discoveries: + if discovery[ssdp.ATTR_SSDP_LOCATION] == location: + # Device found via SSDP, it shouldn't need polling + poll_availability = False + LOGGER.debug( + "Entry %s found via SSDP, with UDN %s", + import_data[CONF_URL], + discovery[ssdp.ATTR_SSDP_UDN], + ) + break + else: + # Not in discoveries. Try connecting directly. + try: + discovery = await self._async_connect(location) + except ConnectError as err: + LOGGER.debug( + "Entry %s not imported: %s", import_data[CONF_URL], err.args[0] + ) + # Store the config to apply if the device is added later + get_domain_data(self.hass).unmigrated_config[location] = import_data + return self.async_abort(reason=err.args[0]) + + # Set options from the import_data, except listen_ip which is no longer used + options = { + CONF_LISTEN_PORT: import_data.get(CONF_LISTEN_PORT), + CONF_CALLBACK_URL_OVERRIDE: import_data.get(CONF_CALLBACK_URL_OVERRIDE), + CONF_POLL_AVAILABILITY: poll_availability, + } + + # Override device name if it's set in the YAML + if CONF_NAME in import_data: + discovery = dict(discovery) + discovery[ssdp.ATTR_UPNP_FRIENDLY_NAME] = import_data[CONF_NAME] + + LOGGER.debug("Entry %s ready for import", import_data[CONF_URL]) + return await self._async_create_entry_from_discovery(discovery, options) + + async def async_step_ssdp(self, discovery_info: DiscoveryInfoType) -> FlowResult: + """Handle a flow initialized by SSDP discovery.""" + LOGGER.debug("async_step_ssdp: discovery_info %s", pformat(discovery_info)) + + self._discoveries = [discovery_info] + + udn = discovery_info[ssdp.ATTR_SSDP_UDN] + location = discovery_info[ssdp.ATTR_SSDP_LOCATION] + + # Abort if already configured, but update the last-known location + await self.async_set_unique_id(udn) + self._abort_if_unique_id_configured( + updates={CONF_URL: location}, reload_on_update=False + ) + + # If the device needs migration because it wasn't turned on when HA + # started, silently migrate it now. + import_data = get_domain_data(self.hass).unmigrated_config.get(location) + if import_data is not None: + return await self.async_step_import(import_data) + + parsed_url = urlparse(location) + name = discovery_info.get(ssdp.ATTR_UPNP_FRIENDLY_NAME) or parsed_url.hostname + self.context["title_placeholders"] = {"name": name} + + return await self.async_step_confirm() + + async def async_step_confirm(self, user_input: FlowInput = None) -> FlowResult: + """Allow the user to confirm adding the device. + + Also check that the device is still available, otherwise when it is + added to HA it won't report the correct DeviceInfo. + """ + LOGGER.debug("async_step_confirm: %s", user_input) + + errors = {} + if user_input is not None: + discovery = self._discoveries[0] + try: + await self._async_connect(discovery[ssdp.ATTR_SSDP_LOCATION]) + except ConnectError as err: + errors["base"] = err.args[0] + else: + return await self._async_create_entry_from_discovery(discovery) + + self._set_confirm_only() + return self.async_show_form(step_id="confirm", errors=errors) + + async def _async_create_entry_from_discovery( + self, + discovery: Mapping[str, Any], + options: Mapping[str, Any] | None = None, + ) -> FlowResult: + """Create an entry from discovery.""" + LOGGER.debug("_async_create_entry_from_discovery: discovery: %s", discovery) + + location = discovery[ssdp.ATTR_SSDP_LOCATION] + udn = discovery[ssdp.ATTR_SSDP_UDN] + + # Abort if already configured, but update the last-known location + await self.async_set_unique_id(udn) + self._abort_if_unique_id_configured(updates={CONF_URL: location}) + + parsed_url = urlparse(location) + title = discovery.get(ssdp.ATTR_UPNP_FRIENDLY_NAME) or parsed_url.hostname + + data = { + CONF_URL: discovery[ssdp.ATTR_SSDP_LOCATION], + CONF_DEVICE_ID: discovery[ssdp.ATTR_SSDP_UDN], + CONF_TYPE: discovery.get(ssdp.ATTR_SSDP_NT) or discovery[ssdp.ATTR_SSDP_ST], + } + return self.async_create_entry(title=title, data=data, options=options) + + async def _async_get_discoveries(self) -> list[Mapping[str, str]]: + """Get list of unconfigured DLNA devices discovered by SSDP.""" + LOGGER.debug("_get_discoveries") + + # Get all compatible devices from ssdp's cache + discoveries: list[Mapping[str, str]] = [] + for udn_st in DmrDevice.DEVICE_TYPES: + st_discoveries = await ssdp.async_get_discovery_info_by_st( + self.hass, udn_st + ) + discoveries.extend(st_discoveries) + + # Filter out devices already configured + current_unique_ids = { + entry.unique_id for entry in self._async_current_entries() + } + discoveries = [ + disc + for disc in discoveries + if disc[ssdp.ATTR_SSDP_UDN] not in current_unique_ids + ] + + return discoveries + + async def _async_connect(self, location: str) -> dict[str, str]: + """Connect to a device to confirm it works and get discovery information. + + Raises ConnectError if something goes wrong. + """ + LOGGER.debug("_async_connect(location=%s)", location) + domain_data = get_domain_data(self.hass) + try: + device = await domain_data.upnp_factory.async_create_device(location) + except UpnpError as err: + raise ConnectError("could_not_connect") from err + + try: + device = find_device_of_type(device, DmrDevice.DEVICE_TYPES) + except UpnpError as err: + raise ConnectError("not_dmr") from err + + discovery = { + ssdp.ATTR_SSDP_LOCATION: location, + ssdp.ATTR_SSDP_UDN: device.udn, + ssdp.ATTR_SSDP_ST: device.device_type, + ssdp.ATTR_UPNP_FRIENDLY_NAME: device.name, + } + + return discovery + + +class DlnaDmrOptionsFlowHandler(config_entries.OptionsFlow): + """Handle a DLNA DMR options flow. + + Configures the single instance and updates the existing config entry. + """ + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize.""" + self.config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage the options.""" + errors: dict[str, str] = {} + # Don't modify existing (read-only) options -- copy and update instead + options = dict(self.config_entry.options) + + if user_input is not None: + LOGGER.debug("user_input: %s", user_input) + listen_port = user_input.get(CONF_LISTEN_PORT) or None + callback_url_override = user_input.get(CONF_CALLBACK_URL_OVERRIDE) or None + + try: + # Cannot use cv.url validation in the schema itself so apply + # extra validation here + if callback_url_override: + cv.url(callback_url_override) + except vol.Invalid: + errors["base"] = "invalid_url" + + options[CONF_LISTEN_PORT] = listen_port + options[CONF_CALLBACK_URL_OVERRIDE] = callback_url_override + options[CONF_POLL_AVAILABILITY] = user_input[CONF_POLL_AVAILABILITY] + + # Save if there's no errors, else fall through and show the form again + if not errors: + return self.async_create_entry(title="", data=options) + + fields = {} + + def _add_with_suggestion(key: str, validator: Callable) -> None: + """Add a field to with a suggested, not default, value.""" + suggested_value = options.get(key) + if suggested_value is None: + fields[vol.Optional(key)] = validator + else: + fields[ + vol.Optional(key, description={"suggested_value": suggested_value}) + ] = validator + + # listen_port can be blank or 0 for "bind any free port" + _add_with_suggestion(CONF_LISTEN_PORT, cv.port) + _add_with_suggestion(CONF_CALLBACK_URL_OVERRIDE, str) + fields[ + vol.Required( + CONF_POLL_AVAILABILITY, + default=options.get(CONF_POLL_AVAILABILITY, False), + ) + ] = bool + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema(fields), + errors=errors, + ) diff --git a/homeassistant/components/dlna_dmr/const.py b/homeassistant/components/dlna_dmr/const.py new file mode 100644 index 00000000000..7b081469ca8 --- /dev/null +++ b/homeassistant/components/dlna_dmr/const.py @@ -0,0 +1,16 @@ +"""Constants for the DLNA DMR component.""" + +import logging +from typing import Final + +LOGGER = logging.getLogger(__package__) + +DOMAIN: Final = "dlna_dmr" + +CONF_LISTEN_PORT: Final = "listen_port" +CONF_CALLBACK_URL_OVERRIDE: Final = "callback_url_override" +CONF_POLL_AVAILABILITY: Final = "poll_availability" + +DEFAULT_NAME: Final = "DLNA Digital Media Renderer" + +CONNECT_TIMEOUT: Final = 10 diff --git a/homeassistant/components/dlna_dmr/data.py b/homeassistant/components/dlna_dmr/data.py new file mode 100644 index 00000000000..8d4693dd435 --- /dev/null +++ b/homeassistant/components/dlna_dmr/data.py @@ -0,0 +1,126 @@ +"""Data used by this integration.""" +from __future__ import annotations + +import asyncio +from collections import defaultdict +from collections.abc import Mapping +from typing import Any, NamedTuple, cast + +from async_upnp_client import UpnpEventHandler, UpnpFactory, UpnpRequester +from async_upnp_client.aiohttp import AiohttpNotifyServer, AiohttpSessionRequester + +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant +from homeassistant.helpers import aiohttp_client + +from .const import DOMAIN, LOGGER + + +class EventListenAddr(NamedTuple): + """Unique identifier for an event listener.""" + + host: str | None # Specific local IP(v6) address for listening on + port: int # Listening port, 0 means use an ephemeral port + callback_url: str | None + + +class DlnaDmrData: + """Storage class for domain global data.""" + + lock: asyncio.Lock + requester: UpnpRequester + upnp_factory: UpnpFactory + event_notifiers: dict[EventListenAddr, AiohttpNotifyServer] + event_notifier_refs: defaultdict[EventListenAddr, int] + stop_listener_remove: CALLBACK_TYPE | None = None + unmigrated_config: dict[str, Mapping[str, Any]] + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize global data.""" + self.lock = asyncio.Lock() + session = aiohttp_client.async_get_clientsession(hass, verify_ssl=False) + self.requester = AiohttpSessionRequester(session, with_sleep=False) + self.upnp_factory = UpnpFactory(self.requester, non_strict=True) + self.event_notifiers = {} + self.event_notifier_refs = defaultdict(int) + self.unmigrated_config = {} + + async def async_cleanup_event_notifiers(self, event: Event) -> None: + """Clean up resources when Home Assistant is stopped.""" + del event # unused + LOGGER.debug("Cleaning resources in DlnaDmrData") + async with self.lock: + tasks = (server.stop_server() for server in self.event_notifiers.values()) + asyncio.gather(*tasks) + self.event_notifiers = {} + self.event_notifier_refs = defaultdict(int) + + async def async_get_event_notifier( + self, listen_addr: EventListenAddr, hass: HomeAssistant + ) -> UpnpEventHandler: + """Return existing event notifier for the listen_addr, or create one. + + Only one event notify server is kept for each listen_addr. Must call + async_release_event_notifier when done to cleanup resources. + """ + LOGGER.debug("Getting event handler for %s", listen_addr) + + async with self.lock: + # Stop all servers when HA shuts down, to release resources on devices + if not self.stop_listener_remove: + self.stop_listener_remove = hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, self.async_cleanup_event_notifiers + ) + + # Always increment the reference counter, for existing or new event handlers + self.event_notifier_refs[listen_addr] += 1 + + # Return an existing event handler if we can + if listen_addr in self.event_notifiers: + return self.event_notifiers[listen_addr].event_handler + + # Start event handler + server = AiohttpNotifyServer( + requester=self.requester, + listen_port=listen_addr.port, + listen_host=listen_addr.host, + callback_url=listen_addr.callback_url, + loop=hass.loop, + ) + await server.start_server() + LOGGER.debug("Started event handler at %s", server.callback_url) + + self.event_notifiers[listen_addr] = server + + return server.event_handler + + async def async_release_event_notifier(self, listen_addr: EventListenAddr) -> None: + """Indicate that the event notifier for listen_addr is not used anymore. + + This is called once by each caller of async_get_event_notifier, and will + stop the listening server when all users are done. + """ + async with self.lock: + assert self.event_notifier_refs[listen_addr] > 0 + self.event_notifier_refs[listen_addr] -= 1 + + # Shutdown the server when it has no more users + if self.event_notifier_refs[listen_addr] == 0: + server = self.event_notifiers.pop(listen_addr) + await server.stop_server() + + # Remove the cleanup listener when there's nothing left to cleanup + if not self.event_notifiers: + assert self.stop_listener_remove is not None + self.stop_listener_remove() + self.stop_listener_remove = None + + +def get_domain_data(hass: HomeAssistant) -> DlnaDmrData: + """Obtain this integration's domain data, creating it if needed.""" + if DOMAIN in hass.data: + return cast(DlnaDmrData, hass.data[DOMAIN]) + + data = DlnaDmrData(hass) + hass.data[DOMAIN] = data + return data diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index 1295a1d221b..2e802ee876f 100644 --- a/homeassistant/components/dlna_dmr/manifest.json +++ b/homeassistant/components/dlna_dmr/manifest.json @@ -1,9 +1,30 @@ { "domain": "dlna_dmr", "name": "DLNA Digital Media Renderer", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/dlna_dmr", - "requirements": ["async-upnp-client==0.22.1"], - "dependencies": ["network"], - "codeowners": [], + "requirements": ["async-upnp-client==0.22.3"], + "dependencies": ["network", "ssdp"], + "ssdp": [ + { + "st": "urn:schemas-upnp-org:device:MediaRenderer:1" + }, + { + "st": "urn:schemas-upnp-org:device:MediaRenderer:2" + }, + { + "st": "urn:schemas-upnp-org:device:MediaRenderer:3" + }, + { + "nt": "urn:schemas-upnp-org:device:MediaRenderer:1" + }, + { + "nt": "urn:schemas-upnp-org:device:MediaRenderer:2" + }, + { + "nt": "urn:schemas-upnp-org:device:MediaRenderer:3" + } + ], + "codeowners": ["@StevenLooman", "@chishm"], "iot_class": "local_push" } diff --git a/homeassistant/components/dlna_dmr/media_player.py b/homeassistant/components/dlna_dmr/media_player.py index 36f62155b2d..d7db104ee42 100644 --- a/homeassistant/components/dlna_dmr/media_player.py +++ b/homeassistant/components/dlna_dmr/media_player.py @@ -2,16 +2,19 @@ from __future__ import annotations import asyncio -from datetime import timedelta +from collections.abc import Mapping, Sequence +from datetime import datetime, timedelta import functools -import logging +from typing import Any, Callable, TypeVar, cast -import aiohttp -from async_upnp_client import UpnpFactory -from async_upnp_client.aiohttp import AiohttpNotifyServer, AiohttpSessionRequester -from async_upnp_client.profiles.dlna import DeviceState, DmrDevice +from async_upnp_client import UpnpError, UpnpService, UpnpStateVariable +from async_upnp_client.const import NotificationSubType +from async_upnp_client.profiles.dlna import DmrDevice, TransportState +from async_upnp_client.utils import async_get_local_ip import voluptuous as vol +from homeassistant import config_entries +from homeassistant.components import ssdp from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity from homeassistant.components.media_player.const import ( SUPPORT_NEXT_TRACK, @@ -24,298 +27,499 @@ from homeassistant.components.media_player.const import ( SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, ) -from homeassistant.components.network import async_get_source_ip -from homeassistant.components.network.const import PUBLIC_TARGET_IP from homeassistant.const import ( + CONF_DEVICE_ID, CONF_NAME, + CONF_TYPE, CONF_URL, - EVENT_HOMEASSISTANT_STOP, STATE_IDLE, STATE_OFF, STATE_ON, STATE_PAUSED, STATE_PLAYING, + STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers import device_registry, entity_registry import homeassistant.helpers.config_validation as cv -import homeassistant.util.dt as dt_util +from homeassistant.helpers.entity_platform import AddEntitiesCallback -_LOGGER = logging.getLogger(__name__) +from .const import ( + CONF_CALLBACK_URL_OVERRIDE, + CONF_LISTEN_PORT, + CONF_POLL_AVAILABILITY, + DEFAULT_NAME, + DOMAIN, + LOGGER as _LOGGER, +) +from .data import EventListenAddr, get_domain_data -DLNA_DMR_DATA = "dlna_dmr" - -DEFAULT_NAME = "DLNA Digital Media Renderer" -DEFAULT_LISTEN_PORT = 8301 +PARALLEL_UPDATES = 0 +# Configuration via YAML is deprecated in favour of config flow CONF_LISTEN_IP = "listen_ip" -CONF_LISTEN_PORT = "listen_port" -CONF_CALLBACK_URL_OVERRIDE = "callback_url_override" - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_URL): cv.string, - vol.Optional(CONF_LISTEN_IP): cv.string, - vol.Optional(CONF_LISTEN_PORT, default=DEFAULT_LISTEN_PORT): cv.port, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_CALLBACK_URL_OVERRIDE): cv.url, - } +PLATFORM_SCHEMA = vol.All( + cv.deprecated(CONF_URL), + cv.deprecated(CONF_LISTEN_IP), + cv.deprecated(CONF_LISTEN_PORT), + cv.deprecated(CONF_NAME), + cv.deprecated(CONF_CALLBACK_URL_OVERRIDE), + PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_URL): cv.string, + vol.Optional(CONF_LISTEN_IP): cv.string, + vol.Optional(CONF_LISTEN_PORT): cv.port, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_CALLBACK_URL_OVERRIDE): cv.url, + } + ), ) - -def catch_request_errors(): - """Catch asyncio.TimeoutError, aiohttp.ClientError errors.""" - - def call_wrapper(func): - """Call wrapper for decorator.""" - - @functools.wraps(func) - async def wrapper(self, *args, **kwargs): - """Catch asyncio.TimeoutError, aiohttp.ClientError errors.""" - try: - return await func(self, *args, **kwargs) - except (asyncio.TimeoutError, aiohttp.ClientError): - _LOGGER.error("Error during call %s", func.__name__) - - return wrapper - - return call_wrapper +Func = TypeVar("Func", bound=Callable[..., Any]) -async def async_start_event_handler( +def catch_request_errors(func: Func) -> Func: + """Catch UpnpError errors.""" + + @functools.wraps(func) + async def wrapper(self: "DlnaDmrEntity", *args: Any, **kwargs: Any) -> Any: + """Catch UpnpError errors and check availability before and after request.""" + if not self.available: + _LOGGER.warning( + "Device disappeared when trying to call service %s", func.__name__ + ) + return + try: + return await func(self, *args, **kwargs) + except UpnpError as err: + self.check_available = True + _LOGGER.error("Error during call %s: %r", func.__name__, err) + + return cast(Func, wrapper) + + +async def async_setup_entry( hass: HomeAssistant, - server_host: str, - server_port: int, - requester, - callback_url_override: str | None = None, -): - """Register notify view.""" - hass_data = hass.data[DLNA_DMR_DATA] - if "event_handler" in hass_data: - return hass_data["event_handler"] + entry: config_entries.ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the DlnaDmrEntity from a config entry.""" + del hass # Unused + _LOGGER.debug("media_player.async_setup_entry %s (%s)", entry.entry_id, entry.title) - # start event handler - server = AiohttpNotifyServer( - requester, - listen_port=server_port, - listen_host=server_host, - callback_url=callback_url_override, + # Create our own device-wrapping entity + entity = DlnaDmrEntity( + udn=entry.data[CONF_DEVICE_ID], + device_type=entry.data[CONF_TYPE], + name=entry.title, + event_port=entry.options.get(CONF_LISTEN_PORT) or 0, + event_callback_url=entry.options.get(CONF_CALLBACK_URL_OVERRIDE), + poll_availability=entry.options.get(CONF_POLL_AVAILABILITY, False), + location=entry.data[CONF_URL], ) - await server.start_server() - _LOGGER.info("UPNP/DLNA event handler listening, url: %s", server.callback_url) - hass_data["notify_server"] = server - hass_data["event_handler"] = server.event_handler - # register for graceful shutdown - async def async_stop_server(event): - """Stop server.""" - _LOGGER.debug("Stopping UPNP/DLNA event handler") - await server.stop_server() + entry.async_on_unload( + entry.add_update_listener(entity.async_config_update_listener) + ) - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop_server) - - return hass_data["event_handler"] + async_add_entities([entity]) -async def async_setup_platform( - hass: HomeAssistant, config, async_add_entities, discovery_info=None -): - """Set up DLNA DMR platform.""" - if config.get(CONF_URL) is not None: - url = config[CONF_URL] - name = config.get(CONF_NAME) - elif discovery_info is not None: - url = discovery_info["ssdp_description"] - name = discovery_info.get("name") +class DlnaDmrEntity(MediaPlayerEntity): + """Representation of a DLNA DMR device as a HA entity.""" - if DLNA_DMR_DATA not in hass.data: - hass.data[DLNA_DMR_DATA] = {} + udn: str + device_type: str - if "lock" not in hass.data[DLNA_DMR_DATA]: - hass.data[DLNA_DMR_DATA]["lock"] = asyncio.Lock() + _event_addr: EventListenAddr + poll_availability: bool + # Last known URL for the device, used when adding this entity to hass to try + # to connect before SSDP has rediscovered it, or when SSDP discovery fails. + location: str - # build upnp/aiohttp requester - session = async_get_clientsession(hass) - requester = AiohttpSessionRequester(session, True) + _device_lock: asyncio.Lock # Held when connecting or disconnecting the device + _device: DmrDevice | None = None + _remove_ssdp_callbacks: list[Callable] + check_available: bool = False - # ensure event handler has been started - async with hass.data[DLNA_DMR_DATA]["lock"]: - server_host = config.get(CONF_LISTEN_IP) - if server_host is None: - server_host = await async_get_source_ip(hass, PUBLIC_TARGET_IP) - server_port = config.get(CONF_LISTEN_PORT, DEFAULT_LISTEN_PORT) - callback_url_override = config.get(CONF_CALLBACK_URL_OVERRIDE) - event_handler = await async_start_event_handler( - hass, server_host, server_port, requester, callback_url_override + # Track BOOTID in SSDP advertisements for device changes + _bootid: int | None = None + + # DMR devices need polling for track position information. async_update will + # determine whether further device polling is required. + _attr_should_poll = True + + def __init__( + self, + udn: str, + device_type: str, + name: str, + event_port: int, + event_callback_url: str | None, + poll_availability: bool, + location: str, + ) -> None: + """Initialize DLNA DMR entity.""" + self.udn = udn + self.device_type = device_type + self._attr_name = name + self._event_addr = EventListenAddr(None, event_port, event_callback_url) + self.poll_availability = poll_availability + self.location = location + self._device_lock = asyncio.Lock() + self._remove_ssdp_callbacks = [] + + async def async_added_to_hass(self) -> None: + """Handle addition.""" + # Try to connect to the last known location, but don't worry if not available + if not self._device: + try: + await self._device_connect(self.location) + except UpnpError as err: + _LOGGER.debug("Couldn't connect immediately: %r", err) + + # Get SSDP notifications for only this device + self._remove_ssdp_callbacks.append( + await ssdp.async_register_callback( + self.hass, self.async_ssdp_callback, {"USN": self.usn} + ) ) - # create upnp device - factory = UpnpFactory(requester, non_strict=True) - try: - upnp_device = await factory.async_create_device(url) - except (asyncio.TimeoutError, aiohttp.ClientError) as err: - raise PlatformNotReady() from err + # async_upnp_client.SsdpListener only reports byebye once for each *UDN* + # (device name) which often is not the USN (service within the device) + # that we're interested in. So also listen for byebye advertisements for + # the UDN, which is reported in the _udn field of the combined_headers. + self._remove_ssdp_callbacks.append( + await ssdp.async_register_callback( + self.hass, + self.async_ssdp_callback, + {"_udn": self.udn, "NTS": NotificationSubType.SSDP_BYEBYE}, + ) + ) - # wrap with DmrDevice - dlna_device = DmrDevice(upnp_device, event_handler) + async def async_will_remove_from_hass(self) -> None: + """Handle removal.""" + for callback in self._remove_ssdp_callbacks: + callback() + self._remove_ssdp_callbacks.clear() + await self._device_disconnect() - # create our own device - device = DlnaDmrDevice(dlna_device, name) - _LOGGER.debug("Adding device: %s", device) - async_add_entities([device], True) - - -class DlnaDmrDevice(MediaPlayerEntity): - """Representation of a DLNA DMR device.""" - - def __init__(self, dmr_device, name=None): - """Initialize DLNA DMR device.""" - self._device = dmr_device - self._name = name - - self._available = False - self._subscription_renew_time = None - - async def async_added_to_hass(self): - """Handle addition.""" - self._device.on_event = self._on_event - - # Register unsubscribe on stop - bus = self.hass.bus - bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._async_on_hass_stop) - - @property - def available(self): - """Device is available.""" - return self._available - - async def _async_on_hass_stop(self, event): - """Event handler on Home Assistant stop.""" - async with self.hass.data[DLNA_DMR_DATA]["lock"]: - await self._device.async_unsubscribe_services() - - async def async_update(self): - """Retrieve the latest data.""" - was_available = self._available + async def async_ssdp_callback( + self, info: Mapping[str, Any], change: ssdp.SsdpChange + ) -> None: + """Handle notification from SSDP of device state change.""" + _LOGGER.debug( + "SSDP %s notification of device %s at %s", + change, + info[ssdp.ATTR_SSDP_USN], + info.get(ssdp.ATTR_SSDP_LOCATION), + ) try: - await self._device.async_update() - self._available = True - except (asyncio.TimeoutError, aiohttp.ClientError): - self._available = False - _LOGGER.debug("Device unavailable") + bootid_str = info[ssdp.ATTR_SSDP_BOOTID] + bootid: int | None = int(bootid_str, 10) + except (KeyError, ValueError): + bootid = None + + if change == ssdp.SsdpChange.UPDATE: + # This is an announcement that bootid is about to change + if self._bootid is not None and self._bootid == bootid: + # Store the new value (because our old value matches) so that we + # can ignore subsequent ssdp:alive messages + try: + next_bootid_str = info[ssdp.ATTR_SSDP_NEXTBOOTID] + self._bootid = int(next_bootid_str, 10) + except (KeyError, ValueError): + pass + # Nothing left to do until ssdp:alive comes through return - # do we need to (re-)subscribe? - now = dt_util.utcnow() - should_renew = ( - self._subscription_renew_time and now >= self._subscription_renew_time - ) - if should_renew or not was_available and self._available: - try: - timeout = await self._device.async_subscribe_services() - self._subscription_renew_time = dt_util.utcnow() + timeout / 2 - except (asyncio.TimeoutError, aiohttp.ClientError): - self._available = False - _LOGGER.debug("Could not (re)subscribe") + if self._bootid is not None and self._bootid != bootid and self._device: + # Device has rebooted, drop existing connection and maybe reconnect + await self._device_disconnect() + self._bootid = bootid - def _on_event(self, service, state_variables): + if change == ssdp.SsdpChange.BYEBYE and self._device: + # Device is going away, disconnect + await self._device_disconnect() + + if change == ssdp.SsdpChange.ALIVE and not self._device: + location = info[ssdp.ATTR_SSDP_LOCATION] + try: + await self._device_connect(location) + except UpnpError as err: + _LOGGER.warning( + "Failed connecting to recently alive device at %s: %r", + location, + err, + ) + + # Device could have been de/re-connected, state probably changed + self.schedule_update_ha_state() + + async def async_config_update_listener( + self, hass: HomeAssistant, entry: config_entries.ConfigEntry + ) -> None: + """Handle options update by modifying self in-place.""" + del hass # Unused + _LOGGER.debug( + "Updating: %s with data=%s and options=%s", + self.name, + entry.data, + entry.options, + ) + self.location = entry.data[CONF_URL] + self.poll_availability = entry.options.get(CONF_POLL_AVAILABILITY, False) + + new_port = entry.options.get(CONF_LISTEN_PORT) or 0 + new_callback_url = entry.options.get(CONF_CALLBACK_URL_OVERRIDE) + + if ( + new_port == self._event_addr.port + and new_callback_url == self._event_addr.callback_url + ): + return + + # Changes to eventing requires a device reconnect for it to update correctly + await self._device_disconnect() + # Update _event_addr after disconnecting, to stop the right event listener + self._event_addr = self._event_addr._replace( + port=new_port, callback_url=new_callback_url + ) + try: + await self._device_connect(self.location) + except UpnpError as err: + _LOGGER.warning("Couldn't (re)connect after config change: %r", err) + + # Device was de/re-connected, state might have changed + self.schedule_update_ha_state() + + async def _device_connect(self, location: str) -> None: + """Connect to the device now that it's available.""" + _LOGGER.debug("Connecting to device at %s", location) + + async with self._device_lock: + if self._device: + _LOGGER.debug("Trying to connect when device already connected") + return + + domain_data = get_domain_data(self.hass) + + # Connect to the base UPNP device + upnp_device = await domain_data.upnp_factory.async_create_device(location) + + # Create/get event handler that is reachable by the device, using + # the connection's local IP to listen only on the relevant interface + _, event_ip = await async_get_local_ip(location, self.hass.loop) + self._event_addr = self._event_addr._replace(host=event_ip) + event_handler = await domain_data.async_get_event_notifier( + self._event_addr, self.hass + ) + + # Create profile wrapper + self._device = DmrDevice(upnp_device, event_handler) + + self.location = location + + # Subscribe to event notifications + try: + self._device.on_event = self._on_event + await self._device.async_subscribe_services(auto_resubscribe=True) + except UpnpError as err: + # Don't leave the device half-constructed + self._device.on_event = None + self._device = None + await domain_data.async_release_event_notifier(self._event_addr) + _LOGGER.debug("Error while subscribing during device connect: %r", err) + raise + + if ( + not self.registry_entry + or not self.registry_entry.config_entry_id + or self.registry_entry.device_id + ): + return + + # Create linked HA DeviceEntry now the information is known. + dev_reg = device_registry.async_get(self.hass) + device_entry = dev_reg.async_get_or_create( + config_entry_id=self.registry_entry.config_entry_id, + # Connections are based on the root device's UDN, and the DMR + # embedded device's UDN. They may be the same, if the DMR is the + # root device. + connections={ + ( + device_registry.CONNECTION_UPNP, + self._device.profile_device.root_device.udn, + ), + (device_registry.CONNECTION_UPNP, self._device.udn), + }, + identifiers={(DOMAIN, self.unique_id)}, + default_manufacturer=self._device.manufacturer, + default_model=self._device.model_name, + default_name=self._device.name, + ) + + # Update entity registry to link to the device + ent_reg = entity_registry.async_get(self.hass) + ent_reg.async_get_or_create( + self.registry_entry.domain, + self.registry_entry.platform, + self.unique_id, + device_id=device_entry.id, + ) + + async def _device_disconnect(self) -> None: + """Destroy connections to the device now that it's not available. + + Also call when removing this entity from hass to clean up connections. + """ + async with self._device_lock: + if not self._device: + _LOGGER.debug("Disconnecting from device that's not connected") + return + + _LOGGER.debug("Disconnecting from %s", self._device.name) + + self._device.on_event = None + old_device = self._device + self._device = None + await old_device.async_unsubscribe_services() + + domain_data = get_domain_data(self.hass) + await domain_data.async_release_event_notifier(self._event_addr) + + @property + def available(self) -> bool: + """Device is available when we have a connection to it.""" + return self._device is not None and self._device.profile_device.available + + async def async_update(self) -> None: + """Retrieve the latest data.""" + if not self._device: + if not self.poll_availability: + return + try: + await self._device_connect(self.location) + except UpnpError: + return + + assert self._device is not None + + try: + do_ping = self.poll_availability or self.check_available + await self._device.async_update(do_ping=do_ping) + except UpnpError: + _LOGGER.debug("Device unavailable") + await self._device_disconnect() + return + finally: + self.check_available = False + + def _on_event( + self, service: UpnpService, state_variables: Sequence[UpnpStateVariable] + ) -> None: """State variable(s) changed, let home-assistant know.""" + del service # Unused + if not state_variables: + # Indicates a failure to resubscribe, check if device is still available + self.check_available = True self.schedule_update_ha_state() @property - def supported_features(self): - """Flag media player features that are supported.""" + def supported_features(self) -> int: + """Flag media player features that are supported at this moment. + + Supported features may change as the device enters different states. + """ + if not self._device: + return 0 + supported_features = 0 if self._device.has_volume_level: supported_features |= SUPPORT_VOLUME_SET if self._device.has_volume_mute: supported_features |= SUPPORT_VOLUME_MUTE - if self._device.has_play: + if self._device.can_play: supported_features |= SUPPORT_PLAY - if self._device.has_pause: + if self._device.can_pause: supported_features |= SUPPORT_PAUSE - if self._device.has_stop: + if self._device.can_stop: supported_features |= SUPPORT_STOP - if self._device.has_previous: + if self._device.can_previous: supported_features |= SUPPORT_PREVIOUS_TRACK - if self._device.has_next: + if self._device.can_next: supported_features |= SUPPORT_NEXT_TRACK if self._device.has_play_media: supported_features |= SUPPORT_PLAY_MEDIA - if self._device.has_seek_rel_time: + if self._device.can_seek_rel_time: supported_features |= SUPPORT_SEEK return supported_features @property - def volume_level(self): + def volume_level(self) -> float | None: """Volume level of the media player (0..1).""" - if self._device.has_volume_level: - return self._device.volume_level - return 0 + if not self._device or not self._device.has_volume_level: + return None + return self._device.volume_level - @catch_request_errors() - async def async_set_volume_level(self, volume): + @catch_request_errors + async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" + assert self._device is not None await self._device.async_set_volume_level(volume) @property - def is_volume_muted(self): + def is_volume_muted(self) -> bool | None: """Boolean if volume is currently muted.""" + if not self._device: + return None return self._device.is_volume_muted - @catch_request_errors() - async def async_mute_volume(self, mute): + @catch_request_errors + async def async_mute_volume(self, mute: bool) -> None: """Mute the volume.""" + assert self._device is not None desired_mute = bool(mute) await self._device.async_mute_volume(desired_mute) - @catch_request_errors() - async def async_media_pause(self): + @catch_request_errors + async def async_media_pause(self) -> None: """Send pause command.""" - if not self._device.can_pause: - _LOGGER.debug("Cannot do Pause") - return - + assert self._device is not None await self._device.async_pause() - @catch_request_errors() - async def async_media_play(self): + @catch_request_errors + async def async_media_play(self) -> None: """Send play command.""" - if not self._device.can_play: - _LOGGER.debug("Cannot do Play") - return - + assert self._device is not None await self._device.async_play() - @catch_request_errors() - async def async_media_stop(self): + @catch_request_errors + async def async_media_stop(self) -> None: """Send stop command.""" - if not self._device.can_stop: - _LOGGER.debug("Cannot do Stop") - return - + assert self._device is not None await self._device.async_stop() - @catch_request_errors() - async def async_media_seek(self, position): + @catch_request_errors + async def async_media_seek(self, position: int | float) -> None: """Send seek command.""" - if not self._device.can_seek_rel_time: - _LOGGER.debug("Cannot do Seek/rel_time") - return - + assert self._device is not None time = timedelta(seconds=position) await self._device.async_seek_rel_time(time) - @catch_request_errors() - async def async_play_media(self, media_type, media_id, **kwargs): + @catch_request_errors + async def async_play_media( + self, media_type: str, media_id: str, **kwargs: Any + ) -> None: """Play a piece of media.""" _LOGGER.debug("Playing media: %s, %s, %s", media_type, media_id, kwargs) title = "Home Assistant" + assert self._device is not None + # Stop current playing media if self._device.can_stop: await self.async_media_stop() @@ -325,81 +529,90 @@ class DlnaDmrDevice(MediaPlayerEntity): await self._device.async_wait_for_can_play() # If already playing, no need to call Play - if self._device.state == DeviceState.PLAYING: + if self._device.transport_state == TransportState.PLAYING: return # Play it await self.async_media_play() - @catch_request_errors() - async def async_media_previous_track(self): + @catch_request_errors + async def async_media_previous_track(self) -> None: """Send previous track command.""" - if not self._device.can_previous: - _LOGGER.debug("Cannot do Previous") - return - + assert self._device is not None await self._device.async_previous() - @catch_request_errors() - async def async_media_next_track(self): + @catch_request_errors + async def async_media_next_track(self) -> None: """Send next track command.""" - if not self._device.can_next: - _LOGGER.debug("Cannot do Next") - return - + assert self._device is not None await self._device.async_next() @property - def media_title(self): + def media_title(self) -> str | None: """Title of current playing media.""" + if not self._device: + return None return self._device.media_title @property - def media_image_url(self): + def media_image_url(self) -> str | None: """Image url of current playing media.""" + if not self._device: + return None return self._device.media_image_url @property - def state(self): + def state(self) -> str: """State of the player.""" - if not self._available: + if not self._device or not self.available: return STATE_OFF - - if self._device.state is None: + if self._device.transport_state is None: return STATE_ON - if self._device.state == DeviceState.PLAYING: + if self._device.transport_state in ( + TransportState.PLAYING, + TransportState.TRANSITIONING, + ): return STATE_PLAYING - if self._device.state == DeviceState.PAUSED: + if self._device.transport_state in ( + TransportState.PAUSED_PLAYBACK, + TransportState.PAUSED_RECORDING, + ): return STATE_PAUSED + if self._device.transport_state == TransportState.VENDOR_DEFINED: + return STATE_UNKNOWN return STATE_IDLE @property - def media_duration(self): + def media_duration(self) -> int | None: """Duration of current playing media in seconds.""" + if not self._device: + return None return self._device.media_duration @property - def media_position(self): + def media_position(self) -> int | None: """Position of current playing media in seconds.""" + if not self._device: + return None return self._device.media_position @property - def media_position_updated_at(self): + def media_position_updated_at(self) -> datetime | None: """When was the position of the current playing media valid. Returns value from homeassistant.util.dt.utcnow(). """ + if not self._device: + return None return self._device.media_position_updated_at @property - def name(self) -> str: - """Return the name of the device.""" - if self._name: - return self._name - return self._device.name + def unique_id(self) -> str: + """Report the UDN (Unique Device Name) as this entity's unique ID.""" + return self.udn @property - def unique_id(self) -> str: - """Return an unique ID.""" - return self._device.udn + def usn(self) -> str: + """Get the USN based on the UDN (Unique Device Name) and device type.""" + return f"{self.udn}::{self.device_type}" diff --git a/homeassistant/components/dlna_dmr/strings.json b/homeassistant/components/dlna_dmr/strings.json new file mode 100644 index 00000000000..27e96b465db --- /dev/null +++ b/homeassistant/components/dlna_dmr/strings.json @@ -0,0 +1,44 @@ +{ + "config": { + "flow_title": "{name}", + "step": { + "user": { + "title": "DLNA Digital Media Renderer", + "description": "URL to a device description XML file", + "data": { + "url": "[%key:common::config_flow::data::url%]" + } + }, + "confirm": { + "description": "[%key:common::config_flow::description::confirm_setup%]" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "could_not_connect": "Failed to connect to DLNA device", + "discovery_error": "Failed to discover a matching DLNA device", + "incomplete_config": "Configuration is missing a required variable", + "non_unique_id": "Multiple devices found with the same unique ID", + "not_dmr": "Device is not a Digital Media Renderer" + }, + "error": { + "could_not_connect": "Failed to connect to DLNA device", + "not_dmr": "Device is not a Digital Media Renderer" + } + }, + "options": { + "step": { + "init": { + "title": "DLNA Digital Media Renderer configuration", + "data": { + "listen_port": "Event listener port (random if not set)", + "callback_url_override": "Event listener callback URL", + "poll_availability": "Poll for device availability" + } + } + }, + "error": { + "invalid_url": "Invalid URL" + } + } +} diff --git a/homeassistant/components/dlna_dmr/translations/en.json b/homeassistant/components/dlna_dmr/translations/en.json new file mode 100644 index 00000000000..94bbd365e18 --- /dev/null +++ b/homeassistant/components/dlna_dmr/translations/en.json @@ -0,0 +1,44 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "could_not_connect": "Failed to connect to DLNA device", + "discovery_error": "Failed to discover a matching DLNA device", + "incomplete_config": "Configuration is missing a required variable", + "non_unique_id": "Multiple devices found with the same unique ID", + "not_dmr": "Device is not a Digital Media Renderer" + }, + "error": { + "could_not_connect": "Failed to connect to DLNA device", + "not_dmr": "Device is not a Digital Media Renderer" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "Do you want to start set up?" + }, + "user": { + "data": { + "url": "URL" + }, + "description": "URL to a device description XML file", + "title": "DLNA Digital Media Renderer" + } + } + }, + "options": { + "error": { + "invalid_url": "Invalid URL" + }, + "step": { + "init": { + "data": { + "callback_url_override": "Event listener callback URL", + "listen_port": "Event listener port (random if not set)", + "poll_availability": "Poll for device availability" + }, + "title": "DLNA Digital Media Renderer configuration" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index 3bfde32e50f..b06f1b34493 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -39,9 +39,13 @@ IPV4_BROADCAST = IPv4Address("255.255.255.255") # Attributes for accessing info from SSDP response ATTR_SSDP_LOCATION = "ssdp_location" ATTR_SSDP_ST = "ssdp_st" +ATTR_SSDP_NT = "ssdp_nt" +ATTR_SSDP_UDN = "ssdp_udn" ATTR_SSDP_USN = "ssdp_usn" ATTR_SSDP_EXT = "ssdp_ext" ATTR_SSDP_SERVER = "ssdp_server" +ATTR_SSDP_BOOTID = "BOOTID.UPNP.ORG" +ATTR_SSDP_NEXTBOOTID = "NEXTBOOTID.UPNP.ORG" # Attributes for accessing info from retrieved UPnP device description ATTR_UPNP_DEVICE_TYPE = "deviceType" ATTR_UPNP_FRIENDLY_NAME = "friendlyName" @@ -56,7 +60,7 @@ ATTR_UPNP_UDN = "UDN" ATTR_UPNP_UPC = "UPC" ATTR_UPNP_PRESENTATION_URL = "presentationURL" -PRIMARY_MATCH_KEYS = [ATTR_UPNP_MANUFACTURER, "st", ATTR_UPNP_DEVICE_TYPE] +PRIMARY_MATCH_KEYS = [ATTR_UPNP_MANUFACTURER, "st", ATTR_UPNP_DEVICE_TYPE, "nt"] DISCOVERY_MAPPING = { "usn": ATTR_SSDP_USN, @@ -64,6 +68,8 @@ DISCOVERY_MAPPING = { "server": ATTR_SSDP_SERVER, "st": ATTR_SSDP_ST, "location": ATTR_SSDP_LOCATION, + "_udn": ATTR_SSDP_UDN, + "nt": ATTR_SSDP_NT, } SsdpChange = Enum("SsdpChange", "ALIVE BYEBYE UPDATE") diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index 465c1ce3cbf..6590e6fa756 100644 --- a/homeassistant/components/ssdp/manifest.json +++ b/homeassistant/components/ssdp/manifest.json @@ -2,7 +2,7 @@ "domain": "ssdp", "name": "Simple Service Discovery Protocol (SSDP)", "documentation": "https://www.home-assistant.io/integrations/ssdp", - "requirements": ["async-upnp-client==0.22.1"], + "requirements": ["async-upnp-client==0.22.3"], "dependencies": ["network"], "after_dependencies": ["zeroconf"], "codeowners": [], diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index 7cf45673292..fb5912657c0 100644 --- a/homeassistant/components/upnp/manifest.json +++ b/homeassistant/components/upnp/manifest.json @@ -3,7 +3,7 @@ "name": "UPnP/IGD", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/upnp", - "requirements": ["async-upnp-client==0.22.1"], + "requirements": ["async-upnp-client==0.22.3"], "dependencies": ["network", "ssdp"], "codeowners": ["@StevenLooman","@ehendrix23"], "ssdp": [ diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index 90e575fb404..3ecece7414f 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -2,7 +2,7 @@ "domain": "yeelight", "name": "Yeelight", "documentation": "https://www.home-assistant.io/integrations/yeelight", - "requirements": ["yeelight==0.7.5", "async-upnp-client==0.22.1"], + "requirements": ["yeelight==0.7.5", "async-upnp-client==0.22.3"], "codeowners": ["@rytilahti", "@zewelor", "@shenxn", "@starkillerOG"], "config_flow": true, "dependencies": ["network"], diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 78dc71976e6..c69815d5e6c 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -61,6 +61,7 @@ FLOWS = [ "dexcom", "dialogflow", "directv", + "dlna_dmr", "doorbird", "dsmr", "dunehd", diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index e5e823b404a..b058f972229 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -83,6 +83,26 @@ SSDP = { "manufacturer": "DIRECTV" } ], + "dlna_dmr": [ + { + "st": "urn:schemas-upnp-org:device:MediaRenderer:1" + }, + { + "st": "urn:schemas-upnp-org:device:MediaRenderer:2" + }, + { + "st": "urn:schemas-upnp-org:device:MediaRenderer:3" + }, + { + "nt": "urn:schemas-upnp-org:device:MediaRenderer:1" + }, + { + "nt": "urn:schemas-upnp-org:device:MediaRenderer:2" + }, + { + "nt": "urn:schemas-upnp-org:device:MediaRenderer:3" + } + ], "fritz": [ { "st": "urn:schemas-upnp-org:device:fritzbox:1" diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 2cf735c435d..8b0802b26a8 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -4,7 +4,7 @@ aiodiscover==1.4.2 aiohttp==3.7.4.post0 aiohttp_cors==0.7.0 astral==2.2 -async-upnp-client==0.22.1 +async-upnp-client==0.22.3 async_timeout==3.0.1 attrs==21.2.0 awesomeversion==21.8.1 diff --git a/mypy.ini b/mypy.ini index 53afb687afe..1d84658657d 100644 --- a/mypy.ini +++ b/mypy.ini @@ -352,6 +352,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.dlna_dmr.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.dnsip.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 805d29da384..518bbf0868b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -327,7 +327,7 @@ asterisk_mbox==0.5.0 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.22.1 +async-upnp-client==0.22.3 # homeassistant.components.supla asyncpysupla==0.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7db1f7d5127..4272bb87506 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -221,7 +221,7 @@ arcam-fmj==0.7.0 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.22.1 +async-upnp-client==0.22.3 # homeassistant.components.aurora auroranoaa==0.0.2 diff --git a/tests/components/dlna_dmr/__init__.py b/tests/components/dlna_dmr/__init__.py new file mode 100644 index 00000000000..a1f4ccc2ba7 --- /dev/null +++ b/tests/components/dlna_dmr/__init__.py @@ -0,0 +1 @@ +"""Tests for the DLNA component.""" diff --git a/tests/components/dlna_dmr/conftest.py b/tests/components/dlna_dmr/conftest.py new file mode 100644 index 00000000000..521a1c22fa5 --- /dev/null +++ b/tests/components/dlna_dmr/conftest.py @@ -0,0 +1,141 @@ +"""Fixtures for DLNA tests.""" +from __future__ import annotations + +from collections.abc import Iterable +from socket import AddressFamily # pylint: disable=no-name-in-module +from unittest.mock import Mock, create_autospec, patch, seal + +from async_upnp_client import UpnpDevice, UpnpFactory +import pytest + +from homeassistant.components.dlna_dmr.const import DOMAIN as DLNA_DOMAIN +from homeassistant.components.dlna_dmr.data import DlnaDmrData +from homeassistant.const import CONF_DEVICE_ID, CONF_TYPE, CONF_URL +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +MOCK_DEVICE_BASE_URL = "http://192.88.99.4" +MOCK_DEVICE_LOCATION = MOCK_DEVICE_BASE_URL + "/dmr_description.xml" +MOCK_DEVICE_NAME = "Test Renderer Device" +MOCK_DEVICE_TYPE = "urn:schemas-upnp-org:device:MediaRenderer:1" +MOCK_DEVICE_UDN = "uuid:7cc6da13-7f5d-4ace-9729-58b275c52f1e" +MOCK_DEVICE_USN = f"{MOCK_DEVICE_UDN}::{MOCK_DEVICE_TYPE}" + +LOCAL_IP = "192.88.99.1" +EVENT_CALLBACK_URL = "http://192.88.99.1/notify" + +NEW_DEVICE_LOCATION = "http://192.88.99.7" + "/dmr_description.xml" + + +@pytest.fixture +def domain_data_mock(hass: HomeAssistant) -> Iterable[Mock]: + """Mock the global data used by this component. + + This includes network clients and library object factories. Mocking it + prevents network use. + """ + domain_data = create_autospec(DlnaDmrData, instance=True) + domain_data.upnp_factory = create_autospec( + UpnpFactory, spec_set=True, instance=True + ) + + upnp_device = create_autospec(UpnpDevice, instance=True) + upnp_device.name = MOCK_DEVICE_NAME + upnp_device.udn = MOCK_DEVICE_UDN + upnp_device.device_url = MOCK_DEVICE_LOCATION + upnp_device.device_type = "urn:schemas-upnp-org:device:MediaRenderer:1" + upnp_device.available = True + upnp_device.parent_device = None + upnp_device.root_device = upnp_device + upnp_device.all_devices = [upnp_device] + seal(upnp_device) + domain_data.upnp_factory.async_create_device.return_value = upnp_device + + domain_data.unmigrated_config = {} + + with patch.dict(hass.data, {DLNA_DOMAIN: domain_data}): + yield domain_data + + # Make sure the event notifiers are released + assert ( + domain_data.async_get_event_notifier.await_count + == domain_data.async_release_event_notifier.await_count + ) + + +@pytest.fixture +def config_entry_mock() -> Iterable[MockConfigEntry]: + """Mock a config entry for this platform.""" + mock_entry = MockConfigEntry( + unique_id=MOCK_DEVICE_UDN, + domain=DLNA_DOMAIN, + data={ + CONF_URL: MOCK_DEVICE_LOCATION, + CONF_DEVICE_ID: MOCK_DEVICE_UDN, + CONF_TYPE: MOCK_DEVICE_TYPE, + }, + title=MOCK_DEVICE_NAME, + options={}, + ) + yield mock_entry + + +@pytest.fixture +def dmr_device_mock(domain_data_mock: Mock) -> Iterable[Mock]: + """Mock the async_upnp_client DMR device, initially connected.""" + with patch( + "homeassistant.components.dlna_dmr.media_player.DmrDevice", autospec=True + ) as constructor: + device = constructor.return_value + device.on_event = None + device.profile_device = ( + domain_data_mock.upnp_factory.async_create_device.return_value + ) + device.media_image_url = "http://192.88.99.20:8200/AlbumArt/2624-17620.jpg" + device.udn = "device_udn" + device.manufacturer = "device_manufacturer" + device.model_name = "device_model_name" + device.name = "device_name" + + yield device + + # Make sure the device is disconnected + assert ( + device.async_subscribe_services.await_count + == device.async_unsubscribe_services.await_count + ) + + assert device.on_event is None + + +@pytest.fixture(name="skip_notifications", autouse=True) +def skip_notifications_fixture() -> Iterable[None]: + """Skip notification calls.""" + with patch("homeassistant.components.persistent_notification.async_create"), patch( + "homeassistant.components.persistent_notification.async_dismiss" + ): + yield + + +@pytest.fixture(autouse=True) +def ssdp_scanner_mock() -> Iterable[Mock]: + """Mock the SSDP module.""" + with patch("homeassistant.components.ssdp.Scanner", autospec=True) as mock_scanner: + reg_callback = mock_scanner.return_value.async_register_callback + reg_callback.return_value = Mock(return_value=None) + yield mock_scanner.return_value + assert ( + reg_callback.call_count == reg_callback.return_value.call_count + ), "Not all callbacks unregistered" + + +@pytest.fixture(autouse=True) +def async_get_local_ip_mock() -> Iterable[Mock]: + """Mock the async_get_local_ip utility function to prevent network access.""" + with patch( + "homeassistant.components.dlna_dmr.media_player.async_get_local_ip", + autospec=True, + ) as func: + func.return_value = AddressFamily.AF_INET, LOCAL_IP + yield func diff --git a/tests/components/dlna_dmr/test_config_flow.py b/tests/components/dlna_dmr/test_config_flow.py new file mode 100644 index 00000000000..1bf93781be1 --- /dev/null +++ b/tests/components/dlna_dmr/test_config_flow.py @@ -0,0 +1,624 @@ +"""Test the DLNA config flow.""" +from __future__ import annotations + +from unittest.mock import Mock + +from async_upnp_client import UpnpDevice, UpnpError +import pytest + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components import ssdp +from homeassistant.components.dlna_dmr.const import ( + CONF_CALLBACK_URL_OVERRIDE, + CONF_LISTEN_PORT, + CONF_POLL_AVAILABILITY, + DOMAIN as DLNA_DOMAIN, +) +from homeassistant.const import ( + CONF_DEVICE_ID, + CONF_NAME, + CONF_PLATFORM, + CONF_TYPE, + CONF_URL, +) +from homeassistant.core import HomeAssistant + +from .conftest import ( + MOCK_DEVICE_LOCATION, + MOCK_DEVICE_NAME, + MOCK_DEVICE_TYPE, + MOCK_DEVICE_UDN, + NEW_DEVICE_LOCATION, +) + +from tests.common import MockConfigEntry + +# Auto-use the domain_data_mock and dmr_device_mock fixtures for every test in this module +pytestmark = [ + pytest.mark.usefixtures("domain_data_mock"), + pytest.mark.usefixtures("dmr_device_mock"), +] + +WRONG_DEVICE_TYPE = "urn:schemas-upnp-org:device:InternetGatewayDevice:1" + +IMPORTED_DEVICE_NAME = "Imported DMR device" + +MOCK_CONFIG_IMPORT_DATA = { + CONF_PLATFORM: DLNA_DOMAIN, + CONF_URL: MOCK_DEVICE_LOCATION, +} + +MOCK_ROOT_DEVICE_UDN = "ROOT_DEVICE" +MOCK_ROOT_DEVICE_TYPE = "ROOT_DEVICE_TYPE" + +MOCK_DISCOVERY = { + ssdp.ATTR_SSDP_LOCATION: MOCK_DEVICE_LOCATION, + ssdp.ATTR_SSDP_UDN: MOCK_DEVICE_UDN, + ssdp.ATTR_SSDP_ST: MOCK_DEVICE_TYPE, + ssdp.ATTR_UPNP_UDN: MOCK_ROOT_DEVICE_UDN, + ssdp.ATTR_UPNP_DEVICE_TYPE: MOCK_ROOT_DEVICE_TYPE, + ssdp.ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, +} + + +async def test_user_flow(hass: HomeAssistant) -> None: + """Test user-init'd config flow with user entering a valid URL.""" + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_URL: MOCK_DEVICE_LOCATION} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == MOCK_DEVICE_NAME + assert result["data"] == { + CONF_URL: MOCK_DEVICE_LOCATION, + CONF_DEVICE_ID: MOCK_DEVICE_UDN, + CONF_TYPE: MOCK_DEVICE_TYPE, + } + assert result["options"] == {CONF_POLL_AVAILABILITY: True} + + # Wait for platform to be fully setup + await hass.async_block_till_done() + + # Remove the device to clean up all resources, completing its life cycle + entry_id = result["result"].entry_id + assert await hass.config_entries.async_remove(entry_id) == { + "require_restart": False + } + + +async def test_user_flow_uncontactable( + hass: HomeAssistant, domain_data_mock: Mock +) -> None: + """Test user-init'd config flow with user entering an uncontactable URL.""" + # Device is not contactable + domain_data_mock.upnp_factory.async_create_device.side_effect = UpnpError + + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_URL: MOCK_DEVICE_LOCATION} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "could_not_connect"} + assert result["step_id"] == "user" + + +async def test_user_flow_embedded_st( + hass: HomeAssistant, domain_data_mock: Mock +) -> None: + """Test user-init'd flow for device with an embedded DMR.""" + # Device is the wrong type + upnp_device = domain_data_mock.upnp_factory.async_create_device.return_value + upnp_device.udn = MOCK_ROOT_DEVICE_UDN + upnp_device.device_type = MOCK_ROOT_DEVICE_TYPE + upnp_device.name = "ROOT_DEVICE_NAME" + embedded_device = Mock(spec=UpnpDevice) + embedded_device.udn = MOCK_DEVICE_UDN + embedded_device.device_type = MOCK_DEVICE_TYPE + embedded_device.name = MOCK_DEVICE_NAME + upnp_device.all_devices.append(embedded_device) + + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_URL: MOCK_DEVICE_LOCATION} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == MOCK_DEVICE_NAME + assert result["data"] == { + CONF_URL: MOCK_DEVICE_LOCATION, + CONF_DEVICE_ID: MOCK_DEVICE_UDN, + CONF_TYPE: MOCK_DEVICE_TYPE, + } + assert result["options"] == {CONF_POLL_AVAILABILITY: True} + + # Wait for platform to be fully setup + await hass.async_block_till_done() + + # Remove the device to clean up all resources, completing its life cycle + entry_id = result["result"].entry_id + assert await hass.config_entries.async_remove(entry_id) == { + "require_restart": False + } + + +async def test_user_flow_wrong_st(hass: HomeAssistant, domain_data_mock: Mock) -> None: + """Test user-init'd config flow with user entering a URL for the wrong device.""" + # Device has a sub device of the right type + upnp_device = domain_data_mock.upnp_factory.async_create_device.return_value + upnp_device.device_type = WRONG_DEVICE_TYPE + + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_URL: MOCK_DEVICE_LOCATION} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "not_dmr"} + assert result["step_id"] == "user" + + +async def test_import_flow_invalid(hass: HomeAssistant, domain_data_mock: Mock) -> None: + """Test import flow of invalid YAML config.""" + # Missing CONF_URL + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_PLATFORM: DLNA_DOMAIN}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "incomplete_config" + + # Device is not contactable + domain_data_mock.upnp_factory.async_create_device.side_effect = UpnpError + + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_PLATFORM: DLNA_DOMAIN, CONF_URL: MOCK_DEVICE_LOCATION}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "could_not_connect" + + # Device is the wrong type + domain_data_mock.upnp_factory.async_create_device.side_effect = None + upnp_device = domain_data_mock.upnp_factory.async_create_device.return_value + upnp_device.device_type = WRONG_DEVICE_TYPE + + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_PLATFORM: DLNA_DOMAIN, CONF_URL: MOCK_DEVICE_LOCATION}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "not_dmr" + + +async def test_import_flow_ssdp_discovered( + hass: HomeAssistant, ssdp_scanner_mock: Mock +) -> None: + """Test import of YAML config with a device also found via SSDP.""" + ssdp_scanner_mock.async_get_discovery_info_by_st.side_effect = [ + [MOCK_DISCOVERY], + [], + [], + ] + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=MOCK_CONFIG_IMPORT_DATA, + ) + await hass.async_block_till_done() + + assert ssdp_scanner_mock.async_get_discovery_info_by_st.call_count >= 1 + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == MOCK_DEVICE_NAME + assert result["data"] == { + CONF_URL: MOCK_DEVICE_LOCATION, + CONF_DEVICE_ID: MOCK_DEVICE_UDN, + CONF_TYPE: MOCK_DEVICE_TYPE, + } + assert result["options"] == { + CONF_LISTEN_PORT: None, + CONF_CALLBACK_URL_OVERRIDE: None, + CONF_POLL_AVAILABILITY: False, + } + entry_id = result["result"].entry_id + + # The config entry should not be duplicated when dlna_dmr is restarted + ssdp_scanner_mock.async_get_discovery_info_by_st.side_effect = [ + [MOCK_DISCOVERY], + [], + [], + ] + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=MOCK_CONFIG_IMPORT_DATA, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + # Wait for platform to be fully setup + await hass.async_block_till_done() + + # Remove the device to clean up all resources, completing its life cycle + assert await hass.config_entries.async_remove(entry_id) == { + "require_restart": False + } + + +async def test_import_flow_direct_connect( + hass: HomeAssistant, ssdp_scanner_mock: Mock +) -> None: + """Test import of YAML config with a device *not found* via SSDP.""" + ssdp_scanner_mock.async_get_discovery_info_by_st.return_value = [] + + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=MOCK_CONFIG_IMPORT_DATA, + ) + await hass.async_block_till_done() + + assert ssdp_scanner_mock.async_get_discovery_info_by_st.call_count >= 1 + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == MOCK_DEVICE_NAME + assert result["data"] == { + CONF_URL: MOCK_DEVICE_LOCATION, + CONF_DEVICE_ID: MOCK_DEVICE_UDN, + CONF_TYPE: MOCK_DEVICE_TYPE, + } + assert result["options"] == { + CONF_LISTEN_PORT: None, + CONF_CALLBACK_URL_OVERRIDE: None, + CONF_POLL_AVAILABILITY: True, + } + entry_id = result["result"].entry_id + + # The config entry should not be duplicated when dlna_dmr is restarted + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=MOCK_CONFIG_IMPORT_DATA, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + # Remove the device to clean up all resources, completing its life cycle + assert await hass.config_entries.async_remove(entry_id) == { + "require_restart": False + } + + +async def test_import_flow_options( + hass: HomeAssistant, ssdp_scanner_mock: Mock +) -> None: + """Test import of YAML config with options set.""" + ssdp_scanner_mock.async_get_discovery_info_by_st.return_value = [] + + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_PLATFORM: DLNA_DOMAIN, + CONF_URL: MOCK_DEVICE_LOCATION, + CONF_NAME: IMPORTED_DEVICE_NAME, + CONF_LISTEN_PORT: 2222, + CONF_CALLBACK_URL_OVERRIDE: "http://override/callback", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == IMPORTED_DEVICE_NAME + assert result["data"] == { + CONF_URL: MOCK_DEVICE_LOCATION, + CONF_DEVICE_ID: MOCK_DEVICE_UDN, + CONF_TYPE: MOCK_DEVICE_TYPE, + } + assert result["options"] == { + CONF_LISTEN_PORT: 2222, + CONF_CALLBACK_URL_OVERRIDE: "http://override/callback", + CONF_POLL_AVAILABILITY: True, + } + + # Wait for platform to be fully setup + await hass.async_block_till_done() + + # Remove the device to clean up all resources, completing its life cycle + entry_id = result["result"].entry_id + assert await hass.config_entries.async_remove(entry_id) == { + "require_restart": False + } + + +async def test_import_flow_deferred_ssdp( + hass: HomeAssistant, domain_data_mock: Mock, ssdp_scanner_mock: Mock +) -> None: + """Test YAML import of unavailable device later found via SSDP.""" + # Attempted import at hass start fails because device is unavailable + ssdp_scanner_mock.async_get_discovery_info_by_st.side_effect = [ + [], + [], + [], + ] + domain_data_mock.upnp_factory.async_create_device.side_effect = UpnpError + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_PLATFORM: DLNA_DOMAIN, + CONF_URL: MOCK_DEVICE_LOCATION, + CONF_NAME: IMPORTED_DEVICE_NAME, + CONF_LISTEN_PORT: 2222, + CONF_CALLBACK_URL_OVERRIDE: "http://override/callback", + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "could_not_connect" + + # Device becomes available then discovered via SSDP, import now occurs automatically + ssdp_scanner_mock.async_get_discovery_info_by_st.side_effect = [ + [MOCK_DISCOVERY], + [], + [], + ] + domain_data_mock.upnp_factory.async_create_device.side_effect = None + + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_DISCOVERY, + ) + await hass.async_block_till_done() + + assert hass.config_entries.flow.async_progress(include_uninitialized=True) == [] + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == IMPORTED_DEVICE_NAME + assert result["data"] == { + CONF_URL: MOCK_DEVICE_LOCATION, + CONF_DEVICE_ID: MOCK_DEVICE_UDN, + CONF_TYPE: MOCK_DEVICE_TYPE, + } + assert result["options"] == { + CONF_LISTEN_PORT: 2222, + CONF_CALLBACK_URL_OVERRIDE: "http://override/callback", + CONF_POLL_AVAILABILITY: False, + } + + # Remove the device to clean up all resources, completing its life cycle + entry_id = result["result"].entry_id + assert await hass.config_entries.async_remove(entry_id) == { + "require_restart": False + } + + +async def test_import_flow_deferred_user( + hass: HomeAssistant, domain_data_mock: Mock, ssdp_scanner_mock: Mock +) -> None: + """Test YAML import of unavailable device later added by user.""" + # Attempted import at hass start fails because device is unavailable + ssdp_scanner_mock.async_get_discovery_info_by_st.return_value = [] + domain_data_mock.upnp_factory.async_create_device.side_effect = UpnpError + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_PLATFORM: DLNA_DOMAIN, + CONF_URL: MOCK_DEVICE_LOCATION, + CONF_NAME: IMPORTED_DEVICE_NAME, + CONF_LISTEN_PORT: 2222, + CONF_CALLBACK_URL_OVERRIDE: "http://override/callback", + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "could_not_connect" + + # Device becomes available then added by user, use all imported settings + domain_data_mock.upnp_factory.async_create_device.side_effect = None + + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_URL: MOCK_DEVICE_LOCATION} + ) + await hass.async_block_till_done() + + assert hass.config_entries.flow.async_progress(include_uninitialized=True) == [] + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == IMPORTED_DEVICE_NAME + assert result["data"] == { + CONF_URL: MOCK_DEVICE_LOCATION, + CONF_DEVICE_ID: MOCK_DEVICE_UDN, + CONF_TYPE: MOCK_DEVICE_TYPE, + } + assert result["options"] == { + CONF_LISTEN_PORT: 2222, + CONF_CALLBACK_URL_OVERRIDE: "http://override/callback", + CONF_POLL_AVAILABILITY: True, + } + + # Remove the device to clean up all resources, completing its life cycle + entry_id = result["result"].entry_id + assert await hass.config_entries.async_remove(entry_id) == { + "require_restart": False + } + + +async def test_ssdp_flow_success(hass: HomeAssistant) -> None: + """Test that SSDP discovery with an available device works.""" + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_DISCOVERY, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + assert result["step_id"] == "confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == MOCK_DEVICE_NAME + assert result["data"] == { + CONF_URL: MOCK_DEVICE_LOCATION, + CONF_DEVICE_ID: MOCK_DEVICE_UDN, + CONF_TYPE: MOCK_DEVICE_TYPE, + } + assert result["options"] == {} + + # Remove the device to clean up all resources, completing its life cycle + entry_id = result["result"].entry_id + assert await hass.config_entries.async_remove(entry_id) == { + "require_restart": False + } + + +async def test_ssdp_flow_unavailable( + hass: HomeAssistant, domain_data_mock: Mock +) -> None: + """Test that SSDP discovery with an unavailable device gives an error message. + + This may occur if the device is turned on, discovered, then turned off + before the user attempts to add it. + """ + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_DISCOVERY, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + assert result["step_id"] == "confirm" + + domain_data_mock.upnp_factory.async_create_device.side_effect = UpnpError + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "could_not_connect"} + assert result["step_id"] == "confirm" + + +async def test_ssdp_flow_existing( + hass: HomeAssistant, config_entry_mock: MockConfigEntry +) -> None: + """Test that SSDP discovery of existing config entry updates the URL.""" + config_entry_mock.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data={ + ssdp.ATTR_SSDP_LOCATION: NEW_DEVICE_LOCATION, + ssdp.ATTR_SSDP_UDN: MOCK_DEVICE_UDN, + ssdp.ATTR_UPNP_UDN: MOCK_ROOT_DEVICE_UDN, + ssdp.ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, + ssdp.ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + assert config_entry_mock.data[CONF_URL] == NEW_DEVICE_LOCATION + + +async def test_ssdp_flow_upnp_udn( + hass: HomeAssistant, config_entry_mock: MockConfigEntry +) -> None: + """Test that SSDP discovery ignores the root device's UDN.""" + config_entry_mock.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data={ + ssdp.ATTR_SSDP_LOCATION: NEW_DEVICE_LOCATION, + ssdp.ATTR_SSDP_UDN: MOCK_DEVICE_UDN, + ssdp.ATTR_SSDP_ST: MOCK_DEVICE_TYPE, + ssdp.ATTR_UPNP_UDN: "DIFFERENT_ROOT_DEVICE", + ssdp.ATTR_UPNP_DEVICE_TYPE: "DIFFERENT_ROOT_DEVICE_TYPE", + ssdp.ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + assert config_entry_mock.data[CONF_URL] == NEW_DEVICE_LOCATION + + +async def test_options_flow( + hass: HomeAssistant, config_entry_mock: MockConfigEntry +) -> None: + """Test config flow options.""" + config_entry_mock.add_to_hass(hass) + result = await hass.config_entries.options.async_init(config_entry_mock.entry_id) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + assert result["errors"] == {} + + # Invalid URL for callback (can't be validated automatically by voluptuous) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_CALLBACK_URL_OVERRIDE: "Bad url", + CONF_POLL_AVAILABILITY: False, + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + assert result["errors"] == {"base": "invalid_url"} + + # Good data for all fields + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_LISTEN_PORT: 2222, + CONF_CALLBACK_URL_OVERRIDE: "http://override/callback", + CONF_POLL_AVAILABILITY: True, + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"] == { + CONF_LISTEN_PORT: 2222, + CONF_CALLBACK_URL_OVERRIDE: "http://override/callback", + CONF_POLL_AVAILABILITY: True, + } diff --git a/tests/components/dlna_dmr/test_data.py b/tests/components/dlna_dmr/test_data.py new file mode 100644 index 00000000000..b4b9fcc76f2 --- /dev/null +++ b/tests/components/dlna_dmr/test_data.py @@ -0,0 +1,121 @@ +"""Tests for the DLNA DMR data module.""" +from __future__ import annotations + +from collections.abc import Iterable +from unittest.mock import ANY, Mock, patch + +from async_upnp_client import UpnpEventHandler +from async_upnp_client.aiohttp import AiohttpNotifyServer +import pytest + +from homeassistant.components.dlna_dmr.const import DOMAIN +from homeassistant.components.dlna_dmr.data import EventListenAddr, get_domain_data +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import Event, HomeAssistant + + +@pytest.fixture +def aiohttp_notify_servers_mock() -> Iterable[Mock]: + """Construct mock AiohttpNotifyServer on demand, eliminating network use. + + This fixture provides a list of the constructed servers. + """ + with patch( + "homeassistant.components.dlna_dmr.data.AiohttpNotifyServer" + ) as mock_constructor: + servers = [] + + def make_server(*_args, **_kwargs): + server = Mock(spec=AiohttpNotifyServer) + servers.append(server) + server.event_handler = Mock(spec=UpnpEventHandler) + return server + + mock_constructor.side_effect = make_server + + yield mock_constructor + + # Every server must be stopped if it was started + for server in servers: + assert server.start_server.call_count == server.stop_server.call_count + + +async def test_get_domain_data(hass: HomeAssistant) -> None: + """Test the get_domain_data function returns the same data every time.""" + assert DOMAIN not in hass.data + domain_data = get_domain_data(hass) + assert domain_data is not None + assert get_domain_data(hass) is domain_data + + +async def test_event_notifier( + hass: HomeAssistant, aiohttp_notify_servers_mock: Mock +) -> None: + """Test getting and releasing event notifiers.""" + domain_data = get_domain_data(hass) + + listen_addr = EventListenAddr(None, 0, None) + event_notifier = await domain_data.async_get_event_notifier(listen_addr, hass) + assert event_notifier is not None + + # Check that the parameters were passed through to the AiohttpNotifyServer + aiohttp_notify_servers_mock.assert_called_with( + requester=ANY, listen_port=0, listen_host=None, callback_url=None, loop=ANY + ) + + # Same address should give same notifier + listen_addr_2 = EventListenAddr(None, 0, None) + event_notifier_2 = await domain_data.async_get_event_notifier(listen_addr_2, hass) + assert event_notifier_2 is event_notifier + + # Different address should give different notifier + listen_addr_3 = EventListenAddr( + "192.88.99.4", 9999, "http://192.88.99.4:9999/notify" + ) + event_notifier_3 = await domain_data.async_get_event_notifier(listen_addr_3, hass) + assert event_notifier_3 is not None + assert event_notifier_3 is not event_notifier + + # Check that the parameters were passed through to the AiohttpNotifyServer + aiohttp_notify_servers_mock.assert_called_with( + requester=ANY, + listen_port=9999, + listen_host="192.88.99.4", + callback_url="http://192.88.99.4:9999/notify", + loop=ANY, + ) + + # There should be 2 notifiers total, one with 2 references, and a stop callback + assert set(domain_data.event_notifiers.keys()) == {listen_addr, listen_addr_3} + assert domain_data.event_notifier_refs == {listen_addr: 2, listen_addr_3: 1} + assert domain_data.stop_listener_remove is not None + + # Releasing notifiers should delete them when they have not more references + await domain_data.async_release_event_notifier(listen_addr) + assert set(domain_data.event_notifiers.keys()) == {listen_addr, listen_addr_3} + assert domain_data.event_notifier_refs == {listen_addr: 1, listen_addr_3: 1} + assert domain_data.stop_listener_remove is not None + + await domain_data.async_release_event_notifier(listen_addr) + assert set(domain_data.event_notifiers.keys()) == {listen_addr_3} + assert domain_data.event_notifier_refs == {listen_addr: 0, listen_addr_3: 1} + assert domain_data.stop_listener_remove is not None + + await domain_data.async_release_event_notifier(listen_addr_3) + assert set(domain_data.event_notifiers.keys()) == set() + assert domain_data.event_notifier_refs == {listen_addr: 0, listen_addr_3: 0} + assert domain_data.stop_listener_remove is None + + +async def test_cleanup_event_notifiers(hass: HomeAssistant) -> None: + """Test cleanup function clears all event notifiers.""" + domain_data = get_domain_data(hass) + await domain_data.async_get_event_notifier(EventListenAddr(None, 0, None), hass) + await domain_data.async_get_event_notifier( + EventListenAddr(None, 0, "different"), hass + ) + + await domain_data.async_cleanup_event_notifiers(Event(EVENT_HOMEASSISTANT_STOP)) + + assert not domain_data.event_notifiers + assert not domain_data.event_notifier_refs diff --git a/tests/components/dlna_dmr/test_init.py b/tests/components/dlna_dmr/test_init.py new file mode 100644 index 00000000000..91aec7310ab --- /dev/null +++ b/tests/components/dlna_dmr/test_init.py @@ -0,0 +1,59 @@ +"""Tests for the DLNA DMR __init__ module.""" + +from unittest.mock import Mock + +from async_upnp_client import UpnpError + +from homeassistant.components.dlna_dmr.const import ( + CONF_LISTEN_PORT, + DOMAIN as DLNA_DOMAIN, +) +from homeassistant.components.media_player.const import DOMAIN as MEDIA_PLAYER_DOMAIN +from homeassistant.const import CONF_NAME, CONF_PLATFORM, CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType +from homeassistant.setup import async_setup_component + +from .conftest import MOCK_DEVICE_LOCATION + + +async def test_import_flow_started(hass: HomeAssistant, domain_data_mock: Mock) -> None: + """Test import flow of YAML config is started if there's config data.""" + mock_config: ConfigType = { + MEDIA_PLAYER_DOMAIN: [ + { + CONF_PLATFORM: DLNA_DOMAIN, + CONF_URL: MOCK_DEVICE_LOCATION, + CONF_LISTEN_PORT: 1234, + }, + { + CONF_PLATFORM: "other_domain", + CONF_URL: MOCK_DEVICE_LOCATION, + CONF_NAME: "another device", + }, + ] + } + + # Device is not available yet + domain_data_mock.upnp_factory.async_create_device.side_effect = UpnpError + + # Run the setup + await async_setup_component(hass, DLNA_DOMAIN, mock_config) + await hass.async_block_till_done() + + # Check config_flow has completed + assert hass.config_entries.flow.async_progress(include_uninitialized=True) == [] + + # Check device contact attempt was made + domain_data_mock.upnp_factory.async_create_device.assert_awaited_once_with( + MOCK_DEVICE_LOCATION + ) + + # Check the device is added to the unmigrated configs + assert domain_data_mock.unmigrated_config == { + MOCK_DEVICE_LOCATION: { + CONF_PLATFORM: DLNA_DOMAIN, + CONF_URL: MOCK_DEVICE_LOCATION, + CONF_LISTEN_PORT: 1234, + } + } diff --git a/tests/components/dlna_dmr/test_media_player.py b/tests/components/dlna_dmr/test_media_player.py new file mode 100644 index 00000000000..99bdc14b553 --- /dev/null +++ b/tests/components/dlna_dmr/test_media_player.py @@ -0,0 +1,1338 @@ +"""Tests for the DLNA DMR media_player module.""" +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterable +from datetime import timedelta +from types import MappingProxyType +from unittest.mock import ANY, DEFAULT, Mock, patch + +from async_upnp_client.exceptions import UpnpConnectionError, UpnpError +from async_upnp_client.profiles.dlna import TransportState +import pytest + +from homeassistant import const as ha_const +from homeassistant.components import ssdp +from homeassistant.components.dlna_dmr import media_player +from homeassistant.components.dlna_dmr.const import ( + CONF_CALLBACK_URL_OVERRIDE, + CONF_LISTEN_PORT, + CONF_POLL_AVAILABILITY, + DOMAIN as DLNA_DOMAIN, +) +from homeassistant.components.dlna_dmr.data import EventListenAddr +from homeassistant.components.media_player import ATTR_TO_PROPERTY, const as mp_const +from homeassistant.components.media_player.const import DOMAIN as MP_DOMAIN +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import async_get as async_get_dr +from homeassistant.helpers.entity_component import async_update_entity +from homeassistant.helpers.entity_registry import ( + async_entries_for_config_entry, + async_get as async_get_er, +) +from homeassistant.setup import async_setup_component + +from .conftest import ( + LOCAL_IP, + MOCK_DEVICE_LOCATION, + MOCK_DEVICE_NAME, + MOCK_DEVICE_UDN, + MOCK_DEVICE_USN, + NEW_DEVICE_LOCATION, +) + +from tests.common import MockConfigEntry + +# Auto-use the domain_data_mock fixture for every test in this module +pytestmark = pytest.mark.usefixtures("domain_data_mock") + + +async def setup_mock_component(hass: HomeAssistant, mock_entry: MockConfigEntry) -> str: + """Set up a mock DlnaDmrEntity with the given configuration.""" + mock_entry.add_to_hass(hass) + assert await async_setup_component(hass, DLNA_DOMAIN, {}) is True + await hass.async_block_till_done() + + entries = async_entries_for_config_entry(async_get_er(hass), mock_entry.entry_id) + assert len(entries) == 1 + entity_id = entries[0].entity_id + + return entity_id + + +@pytest.fixture +async def mock_entity_id( + hass: HomeAssistant, + config_entry_mock: MockConfigEntry, + dmr_device_mock: Mock, +) -> AsyncIterable[str]: + """Fixture to set up a mock DlnaDmrEntity in a connected state. + + Yields the entity ID. Cleans up the entity after the test is complete. + """ + entity_id = await setup_mock_component(hass, config_entry_mock) + + yield entity_id + + # Unload config entry to clean up + assert await hass.config_entries.async_remove(config_entry_mock.entry_id) == { + "require_restart": False + } + + +@pytest.fixture +async def mock_disconnected_entity_id( + hass: HomeAssistant, + domain_data_mock: Mock, + config_entry_mock: MockConfigEntry, + dmr_device_mock: Mock, +) -> AsyncIterable[str]: + """Fixture to set up a mock DlnaDmrEntity in a disconnected state. + + Yields the entity ID. Cleans up the entity after the test is complete. + """ + # Cause the connection attempt to fail + domain_data_mock.upnp_factory.async_create_device.side_effect = UpnpConnectionError + + entity_id = await setup_mock_component(hass, config_entry_mock) + + yield entity_id + + # Unload config entry to clean up + assert await hass.config_entries.async_remove(config_entry_mock.entry_id) == { + "require_restart": False + } + + +async def test_setup_entry_no_options( + hass: HomeAssistant, + domain_data_mock: Mock, + ssdp_scanner_mock: Mock, + config_entry_mock: MockConfigEntry, + dmr_device_mock: Mock, +) -> None: + """Test async_setup_entry creates a DlnaDmrEntity when no options are set. + + Check that the device is constructed properly as part of the test. + """ + config_entry_mock.options = MappingProxyType({}) + mock_entity_id = await setup_mock_component(hass, config_entry_mock) + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + + # Check device was created from the supplied URL + domain_data_mock.upnp_factory.async_create_device.assert_awaited_once_with( + MOCK_DEVICE_LOCATION + ) + # Check event notifiers are acquired + domain_data_mock.async_get_event_notifier.assert_awaited_once_with( + EventListenAddr(LOCAL_IP, 0, None), hass + ) + # Check UPnP services are subscribed + dmr_device_mock.async_subscribe_services.assert_awaited_once_with( + auto_resubscribe=True + ) + assert dmr_device_mock.on_event is not None + # Check SSDP notifications are registered + ssdp_scanner_mock.async_register_callback.assert_any_call( + ANY, {"USN": MOCK_DEVICE_USN} + ) + ssdp_scanner_mock.async_register_callback.assert_any_call( + ANY, {"_udn": MOCK_DEVICE_UDN, "NTS": "ssdp:byebye"} + ) + # Quick check of the state to verify the entity has a connected DmrDevice + assert mock_state.state == media_player.STATE_IDLE + # Check the name matches that supplied + assert mock_state.name == MOCK_DEVICE_NAME + + # Check that an update retrieves state from the device, but does not ping, + # because poll_availability is False + await async_update_entity(hass, mock_entity_id) + dmr_device_mock.async_update.assert_awaited_with(do_ping=False) + + # Unload config entry to clean up + assert await hass.config_entries.async_remove(config_entry_mock.entry_id) == { + "require_restart": False + } + + # Confirm SSDP notifications unregistered + assert ssdp_scanner_mock.async_register_callback.return_value.call_count == 2 + + # Confirm the entity has disconnected from the device + domain_data_mock.async_release_event_notifier.assert_awaited_once() + dmr_device_mock.async_unsubscribe_services.assert_awaited_once() + assert dmr_device_mock.on_event is None + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_UNAVAILABLE + + +async def test_setup_entry_with_options( + hass: HomeAssistant, + domain_data_mock: Mock, + ssdp_scanner_mock: Mock, + config_entry_mock: MockConfigEntry, + dmr_device_mock: Mock, +) -> None: + """Test setting options leads to a DlnaDmrEntity with custom event_handler. + + Check that the device is constructed properly as part of the test. + """ + config_entry_mock.options = MappingProxyType( + { + CONF_LISTEN_PORT: 2222, + CONF_CALLBACK_URL_OVERRIDE: "http://192.88.99.10/events", + CONF_POLL_AVAILABILITY: True, + } + ) + mock_entity_id = await setup_mock_component(hass, config_entry_mock) + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + + # Check device was created from the supplied URL + domain_data_mock.upnp_factory.async_create_device.assert_awaited_once_with( + MOCK_DEVICE_LOCATION + ) + # Check event notifiers are acquired with the configured port and callback URL + domain_data_mock.async_get_event_notifier.assert_awaited_once_with( + EventListenAddr(LOCAL_IP, 2222, "http://192.88.99.10/events"), hass + ) + # Check UPnP services are subscribed + dmr_device_mock.async_subscribe_services.assert_awaited_once_with( + auto_resubscribe=True + ) + assert dmr_device_mock.on_event is not None + # Check SSDP notifications are registered + ssdp_scanner_mock.async_register_callback.assert_any_call( + ANY, {"USN": MOCK_DEVICE_USN} + ) + ssdp_scanner_mock.async_register_callback.assert_any_call( + ANY, {"_udn": MOCK_DEVICE_UDN, "NTS": "ssdp:byebye"} + ) + # Quick check of the state to verify the entity has a connected DmrDevice + assert mock_state.state == media_player.STATE_IDLE + # Check the name matches that supplied + assert mock_state.name == MOCK_DEVICE_NAME + + # Check that an update retrieves state from the device, and also pings it, + # because poll_availability is True + await async_update_entity(hass, mock_entity_id) + dmr_device_mock.async_update.assert_awaited_with(do_ping=True) + + # Unload config entry to clean up + assert await hass.config_entries.async_remove(config_entry_mock.entry_id) == { + "require_restart": False + } + + # Confirm SSDP notifications unregistered + assert ssdp_scanner_mock.async_register_callback.return_value.call_count == 2 + + # Confirm the entity has disconnected from the device + domain_data_mock.async_release_event_notifier.assert_awaited_once() + dmr_device_mock.async_unsubscribe_services.assert_awaited_once() + assert dmr_device_mock.on_event is None + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_UNAVAILABLE + + +async def test_event_subscribe_failure( + hass: HomeAssistant, + domain_data_mock: Mock, + config_entry_mock: MockConfigEntry, + dmr_device_mock: Mock, +) -> None: + """Test _device_connect aborts when async_subscribe_services fails.""" + dmr_device_mock.async_subscribe_services.side_effect = UpnpError + + mock_entity_id = await setup_mock_component(hass, config_entry_mock) + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + + # Device should not be connected + assert mock_state.state == ha_const.STATE_UNAVAILABLE + + # Device should not be unsubscribed + dmr_device_mock.async_unsubscribe_services.assert_not_awaited() + + # Clear mocks for tear down checks + dmr_device_mock.async_subscribe_services.reset_mock() + + # Unload config entry to clean up + assert await hass.config_entries.async_remove(config_entry_mock.entry_id) == { + "require_restart": False + } + + +async def test_available_device( + hass: HomeAssistant, + dmr_device_mock: Mock, + mock_entity_id: str, +) -> None: + """Test a DlnaDmrEntity with a connected DmrDevice.""" + # Check hass device information is filled in + dev_reg = async_get_dr(hass) + device = dev_reg.async_get_device(identifiers={(DLNA_DOMAIN, MOCK_DEVICE_UDN)}) + assert device is not None + # Device properties are set in dmr_device_mock before the entity gets constructed + assert device.manufacturer == "device_manufacturer" + assert device.model == "device_model_name" + assert device.name == "device_name" + + # Check entity state gets updated when device changes state + for (dev_state, ent_state) in [ + (None, ha_const.STATE_ON), + (TransportState.STOPPED, ha_const.STATE_IDLE), + (TransportState.PLAYING, ha_const.STATE_PLAYING), + (TransportState.TRANSITIONING, ha_const.STATE_PLAYING), + (TransportState.PAUSED_PLAYBACK, ha_const.STATE_PAUSED), + (TransportState.PAUSED_RECORDING, ha_const.STATE_PAUSED), + (TransportState.RECORDING, ha_const.STATE_IDLE), + (TransportState.NO_MEDIA_PRESENT, ha_const.STATE_IDLE), + (TransportState.VENDOR_DEFINED, ha_const.STATE_UNKNOWN), + ]: + dmr_device_mock.profile_device.available = True + dmr_device_mock.transport_state = dev_state + await async_update_entity(hass, mock_entity_id) + entity_state = hass.states.get(mock_entity_id) + assert entity_state is not None + assert entity_state.state == ent_state + + dmr_device_mock.profile_device.available = False + dmr_device_mock.transport_state = TransportState.PLAYING + await async_update_entity(hass, mock_entity_id) + entity_state = hass.states.get(mock_entity_id) + assert entity_state is not None + assert entity_state.state == ha_const.STATE_UNAVAILABLE + + dmr_device_mock.profile_device.available = True + await async_update_entity(hass, mock_entity_id) + + # Check attributes come directly from the device + entity_state = hass.states.get(mock_entity_id) + assert entity_state is not None + attrs = entity_state.attributes + assert attrs is not None + + assert attrs[mp_const.ATTR_MEDIA_VOLUME_LEVEL] is dmr_device_mock.volume_level + assert attrs[mp_const.ATTR_MEDIA_VOLUME_MUTED] is dmr_device_mock.is_volume_muted + assert attrs[mp_const.ATTR_MEDIA_DURATION] is dmr_device_mock.media_duration + assert attrs[mp_const.ATTR_MEDIA_POSITION] is dmr_device_mock.media_position + assert ( + attrs[mp_const.ATTR_MEDIA_POSITION_UPDATED_AT] + is dmr_device_mock.media_position_updated_at + ) + assert attrs[mp_const.ATTR_MEDIA_TITLE] is dmr_device_mock.media_title + # Entity picture is cached, won't correspond to remote image + assert isinstance(attrs[ha_const.ATTR_ENTITY_PICTURE], str) + + # Check supported feature flags, one at a time. + # tuple(async_upnp_client feature check property, HA feature flag) + FEATURE_FLAGS: list[tuple[str, int]] = [ + ("has_volume_level", mp_const.SUPPORT_VOLUME_SET), + ("has_volume_mute", mp_const.SUPPORT_VOLUME_MUTE), + ("can_play", mp_const.SUPPORT_PLAY), + ("can_pause", mp_const.SUPPORT_PAUSE), + ("can_stop", mp_const.SUPPORT_STOP), + ("can_previous", mp_const.SUPPORT_PREVIOUS_TRACK), + ("can_next", mp_const.SUPPORT_NEXT_TRACK), + ("has_play_media", mp_const.SUPPORT_PLAY_MEDIA), + ("can_seek_rel_time", mp_const.SUPPORT_SEEK), + ] + # Clear all feature properties + for feat_prop, _ in FEATURE_FLAGS: + setattr(dmr_device_mock, feat_prop, False) + await async_update_entity(hass, mock_entity_id) + entity_state = hass.states.get(mock_entity_id) + assert entity_state is not None + assert entity_state.attributes[ha_const.ATTR_SUPPORTED_FEATURES] == 0 + # Test the properties cumulatively + expected_features = 0 + for feat_prop, flag in FEATURE_FLAGS: + setattr(dmr_device_mock, feat_prop, True) + expected_features |= flag + await async_update_entity(hass, mock_entity_id) + entity_state = hass.states.get(mock_entity_id) + assert entity_state is not None + assert ( + entity_state.attributes[ha_const.ATTR_SUPPORTED_FEATURES] + == expected_features + ) + + # Check interface methods interact directly with the device + await hass.services.async_call( + MP_DOMAIN, + ha_const.SERVICE_VOLUME_SET, + {ATTR_ENTITY_ID: mock_entity_id, mp_const.ATTR_MEDIA_VOLUME_LEVEL: 0.80}, + blocking=True, + ) + dmr_device_mock.async_set_volume_level.assert_awaited_once_with(0.80) + await hass.services.async_call( + MP_DOMAIN, + ha_const.SERVICE_VOLUME_MUTE, + {ATTR_ENTITY_ID: mock_entity_id, mp_const.ATTR_MEDIA_VOLUME_MUTED: True}, + blocking=True, + ) + dmr_device_mock.async_mute_volume.assert_awaited_once_with(True) + await hass.services.async_call( + MP_DOMAIN, + ha_const.SERVICE_MEDIA_PAUSE, + {ATTR_ENTITY_ID: mock_entity_id}, + blocking=True, + ) + dmr_device_mock.async_pause.assert_awaited_once_with() + await hass.services.async_call( + MP_DOMAIN, + ha_const.SERVICE_MEDIA_PLAY, + {ATTR_ENTITY_ID: mock_entity_id}, + blocking=True, + ) + dmr_device_mock.async_pause.assert_awaited_once_with() + await hass.services.async_call( + MP_DOMAIN, + ha_const.SERVICE_MEDIA_STOP, + {ATTR_ENTITY_ID: mock_entity_id}, + blocking=True, + ) + dmr_device_mock.async_stop.assert_awaited_once_with() + await hass.services.async_call( + MP_DOMAIN, + ha_const.SERVICE_MEDIA_NEXT_TRACK, + {ATTR_ENTITY_ID: mock_entity_id}, + blocking=True, + ) + dmr_device_mock.async_next.assert_awaited_once_with() + await hass.services.async_call( + MP_DOMAIN, + ha_const.SERVICE_MEDIA_PREVIOUS_TRACK, + {ATTR_ENTITY_ID: mock_entity_id}, + blocking=True, + ) + dmr_device_mock.async_previous.assert_awaited_once_with() + await hass.services.async_call( + MP_DOMAIN, + ha_const.SERVICE_MEDIA_SEEK, + {ATTR_ENTITY_ID: mock_entity_id, mp_const.ATTR_MEDIA_SEEK_POSITION: 33}, + blocking=True, + ) + dmr_device_mock.async_seek_rel_time.assert_awaited_once_with(timedelta(seconds=33)) + + # play_media performs a few calls to the device for setup and play + # Start from stopped, and device can stop too + dmr_device_mock.can_stop = True + dmr_device_mock.transport_state = TransportState.STOPPED + dmr_device_mock.async_stop.reset_mock() + dmr_device_mock.async_set_transport_uri.reset_mock() + dmr_device_mock.async_wait_for_can_play.reset_mock() + dmr_device_mock.async_play.reset_mock() + await hass.services.async_call( + MP_DOMAIN, + mp_const.SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: mock_entity_id, + mp_const.ATTR_MEDIA_CONTENT_TYPE: mp_const.MEDIA_TYPE_MUSIC, + mp_const.ATTR_MEDIA_CONTENT_ID: "http://192.88.99.20:8200/MediaItems/17621.mp3", + mp_const.ATTR_MEDIA_ENQUEUE: False, + }, + blocking=True, + ) + dmr_device_mock.async_stop.assert_awaited_once_with() + dmr_device_mock.async_set_transport_uri.assert_awaited_once_with( + "http://192.88.99.20:8200/MediaItems/17621.mp3", "Home Assistant" + ) + dmr_device_mock.async_wait_for_can_play.assert_awaited_once_with() + dmr_device_mock.async_play.assert_awaited_once_with() + + # play_media again, while the device is already playing and can't stop + dmr_device_mock.can_stop = False + dmr_device_mock.transport_state = TransportState.PLAYING + dmr_device_mock.async_stop.reset_mock() + dmr_device_mock.async_set_transport_uri.reset_mock() + dmr_device_mock.async_wait_for_can_play.reset_mock() + dmr_device_mock.async_play.reset_mock() + await hass.services.async_call( + MP_DOMAIN, + mp_const.SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: mock_entity_id, + mp_const.ATTR_MEDIA_CONTENT_TYPE: mp_const.MEDIA_TYPE_MUSIC, + mp_const.ATTR_MEDIA_CONTENT_ID: "http://192.88.99.20:8200/MediaItems/17621.mp3", + mp_const.ATTR_MEDIA_ENQUEUE: False, + }, + blocking=True, + ) + dmr_device_mock.async_stop.assert_not_awaited() + dmr_device_mock.async_set_transport_uri.assert_awaited_once_with( + "http://192.88.99.20:8200/MediaItems/17621.mp3", "Home Assistant" + ) + dmr_device_mock.async_wait_for_can_play.assert_awaited_once_with() + dmr_device_mock.async_play.assert_not_awaited() + + +async def test_unavailable_device( + hass: HomeAssistant, + domain_data_mock: Mock, + ssdp_scanner_mock: Mock, + config_entry_mock: MockConfigEntry, +) -> None: + """Test a DlnaDmrEntity with out a connected DmrDevice.""" + # Cause connection attempts to fail + domain_data_mock.upnp_factory.async_create_device.side_effect = UpnpConnectionError + + with patch( + "homeassistant.components.dlna_dmr.media_player.DmrDevice", autospec=True + ) as dmr_device_constructor_mock: + mock_entity_id = await setup_mock_component(hass, config_entry_mock) + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + + # Check device is not created + dmr_device_constructor_mock.assert_not_called() + + # Check attempt was made to create a device from the supplied URL + domain_data_mock.upnp_factory.async_create_device.assert_awaited_once_with( + MOCK_DEVICE_LOCATION + ) + # Check event notifiers are not acquired + domain_data_mock.async_get_event_notifier.assert_not_called() + # Check SSDP notifications are registered + ssdp_scanner_mock.async_register_callback.assert_any_call( + ANY, {"USN": MOCK_DEVICE_USN} + ) + ssdp_scanner_mock.async_register_callback.assert_any_call( + ANY, {"_udn": MOCK_DEVICE_UDN, "NTS": "ssdp:byebye"} + ) + # Quick check of the state to verify the entity has no connected DmrDevice + assert mock_state.state == ha_const.STATE_UNAVAILABLE + # Check the name matches that supplied + assert mock_state.name == MOCK_DEVICE_NAME + + # Check that an update does not attempt to contact the device because + # poll_availability is False + domain_data_mock.upnp_factory.async_create_device.reset_mock() + await async_update_entity(hass, mock_entity_id) + domain_data_mock.upnp_factory.async_create_device.assert_not_called() + + # Now set poll_availability = True and expect construction attempt + hass.config_entries.async_update_entry( + config_entry_mock, options={CONF_POLL_AVAILABILITY: True} + ) + await async_update_entity(hass, mock_entity_id) + domain_data_mock.upnp_factory.async_create_device.assert_awaited_once_with( + MOCK_DEVICE_LOCATION + ) + + # Check attributes are unavailable + attrs = mock_state.attributes + for attr in ATTR_TO_PROPERTY: + assert attr not in attrs + + assert attrs[ha_const.ATTR_FRIENDLY_NAME] == MOCK_DEVICE_NAME + assert attrs[ha_const.ATTR_SUPPORTED_FEATURES] == 0 + + # Check service calls do nothing + SERVICES: list[tuple[str, dict]] = [ + (ha_const.SERVICE_VOLUME_SET, {mp_const.ATTR_MEDIA_VOLUME_LEVEL: 0.80}), + (ha_const.SERVICE_VOLUME_MUTE, {mp_const.ATTR_MEDIA_VOLUME_MUTED: True}), + (ha_const.SERVICE_MEDIA_PAUSE, {}), + (ha_const.SERVICE_MEDIA_PLAY, {}), + (ha_const.SERVICE_MEDIA_STOP, {}), + (ha_const.SERVICE_MEDIA_NEXT_TRACK, {}), + (ha_const.SERVICE_MEDIA_PREVIOUS_TRACK, {}), + (ha_const.SERVICE_MEDIA_SEEK, {mp_const.ATTR_MEDIA_SEEK_POSITION: 33}), + ( + mp_const.SERVICE_PLAY_MEDIA, + { + mp_const.ATTR_MEDIA_CONTENT_TYPE: mp_const.MEDIA_TYPE_MUSIC, + mp_const.ATTR_MEDIA_CONTENT_ID: "http://192.88.99.20:8200/MediaItems/17621.mp3", + mp_const.ATTR_MEDIA_ENQUEUE: False, + }, + ), + ] + for service, data in SERVICES: + await hass.services.async_call( + MP_DOMAIN, + service, + {ATTR_ENTITY_ID: mock_entity_id, **data}, + blocking=True, + ) + + # Check hass device information has not been filled in yet + dev_reg = async_get_dr(hass) + device = dev_reg.async_get_device(identifiers={(DLNA_DOMAIN, MOCK_DEVICE_UDN)}) + assert device is None + + # Unload config entry to clean up + assert await hass.config_entries.async_remove(config_entry_mock.entry_id) == { + "require_restart": False + } + + # Confirm SSDP notifications unregistered + assert ssdp_scanner_mock.async_register_callback.return_value.call_count == 2 + + # Check event notifiers are not released + domain_data_mock.async_release_event_notifier.assert_not_called() + + # Confirm the entity is still unavailable + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_UNAVAILABLE + + +async def test_become_available( + hass: HomeAssistant, + domain_data_mock: Mock, + ssdp_scanner_mock: Mock, + config_entry_mock: MockConfigEntry, + dmr_device_mock: Mock, +) -> None: + """Test a device becoming available after the entity is constructed.""" + # Cause connection attempts to fail before adding entity + domain_data_mock.upnp_factory.async_create_device.side_effect = UpnpConnectionError + mock_entity_id = await setup_mock_component(hass, config_entry_mock) + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_UNAVAILABLE + + # Check hass device information has not been filled in yet + dev_reg = async_get_dr(hass) + device = dev_reg.async_get_device(identifiers={(DLNA_DOMAIN, MOCK_DEVICE_UDN)}) + assert device is None + + # Mock device is now available. + domain_data_mock.upnp_factory.async_create_device.side_effect = None + domain_data_mock.upnp_factory.async_create_device.reset_mock() + + # Send an SSDP notification from the now alive device + ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0] + await ssdp_callback( + { + ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, + ssdp.ATTR_SSDP_LOCATION: NEW_DEVICE_LOCATION, + }, + ssdp.SsdpChange.ALIVE, + ) + await hass.async_block_till_done() + + # Check device was created from the supplied URL + domain_data_mock.upnp_factory.async_create_device.assert_awaited_once_with( + NEW_DEVICE_LOCATION + ) + # Check event notifiers are acquired + domain_data_mock.async_get_event_notifier.assert_awaited_once_with( + EventListenAddr(LOCAL_IP, 0, None), hass + ) + # Check UPnP services are subscribed + dmr_device_mock.async_subscribe_services.assert_awaited_once_with( + auto_resubscribe=True + ) + assert dmr_device_mock.on_event is not None + # Quick check of the state to verify the entity has a connected DmrDevice + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_IDLE + # Check hass device information is now filled in + dev_reg = async_get_dr(hass) + device = dev_reg.async_get_device(identifiers={(DLNA_DOMAIN, MOCK_DEVICE_UDN)}) + assert device is not None + assert device.manufacturer == "device_manufacturer" + assert device.model == "device_model_name" + assert device.name == "device_name" + + # Unload config entry to clean up + assert await hass.config_entries.async_remove(config_entry_mock.entry_id) == { + "require_restart": False + } + + # Confirm SSDP notifications unregistered + assert ssdp_scanner_mock.async_register_callback.return_value.call_count == 2 + + # Confirm the entity has disconnected from the device + domain_data_mock.async_release_event_notifier.assert_awaited_once() + dmr_device_mock.async_unsubscribe_services.assert_awaited_once() + assert dmr_device_mock.on_event is None + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_UNAVAILABLE + + +async def test_alive_but_gone( + hass: HomeAssistant, + domain_data_mock: Mock, + ssdp_scanner_mock: Mock, + mock_disconnected_entity_id: str, +) -> None: + """Test a device sending an SSDP alive announcement, but not being connectable.""" + domain_data_mock.upnp_factory.async_create_device.side_effect = UpnpError + + # Send an SSDP notification from the still missing device + ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0] + await ssdp_callback( + { + ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, + ssdp.ATTR_SSDP_LOCATION: NEW_DEVICE_LOCATION, + }, + ssdp.SsdpChange.ALIVE, + ) + await hass.async_block_till_done() + + # Device should still be unavailable + mock_state = hass.states.get(mock_disconnected_entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_UNAVAILABLE + + +async def test_multiple_ssdp_alive( + hass: HomeAssistant, + domain_data_mock: Mock, + ssdp_scanner_mock: Mock, + mock_disconnected_entity_id: str, + dmr_device_mock: Mock, +) -> None: + """Test multiple SSDP alive notifications is ok, only connects to device once.""" + domain_data_mock.upnp_factory.async_create_device.reset_mock() + + # Contacting the device takes long enough that 2 simultaneous attempts could be made + async def create_device_delayed(_location): + """Delay before continuing with async_create_device. + + This gives a chance for parallel calls to `_device_connect` to occur. + """ + await asyncio.sleep(0.1) + return DEFAULT + + domain_data_mock.upnp_factory.async_create_device.side_effect = ( + create_device_delayed + ) + + # Send two SSDP notifications with the new device URL + ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0] + await ssdp_callback( + { + ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, + ssdp.ATTR_SSDP_LOCATION: NEW_DEVICE_LOCATION, + }, + ssdp.SsdpChange.ALIVE, + ) + await ssdp_callback( + { + ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, + ssdp.ATTR_SSDP_LOCATION: NEW_DEVICE_LOCATION, + }, + ssdp.SsdpChange.ALIVE, + ) + await hass.async_block_till_done() + + # Check device is contacted exactly once + domain_data_mock.upnp_factory.async_create_device.assert_awaited_once_with( + NEW_DEVICE_LOCATION + ) + + # Device should be available + mock_state = hass.states.get(mock_disconnected_entity_id) + assert mock_state is not None + assert mock_state.state == media_player.STATE_IDLE + + +async def test_ssdp_byebye( + hass: HomeAssistant, + ssdp_scanner_mock: Mock, + mock_entity_id: str, + dmr_device_mock: Mock, +) -> None: + """Test device is disconnected when byebye is received.""" + # First byebye will cause a disconnect + ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0] + await ssdp_callback( + { + ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, + "_udn": MOCK_DEVICE_UDN, + "NTS": "ssdp:byebye", + }, + ssdp.SsdpChange.BYEBYE, + ) + + dmr_device_mock.async_unsubscribe_services.assert_awaited_once() + + # Device should be gone + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + assert mock_state.state == media_player.STATE_IDLE + + # Second byebye will do nothing + await ssdp_callback( + { + ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, + "_udn": MOCK_DEVICE_UDN, + "NTS": "ssdp:byebye", + }, + ssdp.SsdpChange.BYEBYE, + ) + + dmr_device_mock.async_unsubscribe_services.assert_awaited_once() + + +async def test_ssdp_update_seen_bootid( + hass: HomeAssistant, + domain_data_mock: Mock, + ssdp_scanner_mock: Mock, + mock_disconnected_entity_id: str, + dmr_device_mock: Mock, +) -> None: + """Test device does not reconnect when it gets ssdp:update with next bootid.""" + # Start with a disconnected device + entity_id = mock_disconnected_entity_id + mock_state = hass.states.get(entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_UNAVAILABLE + + # "Reconnect" the device + domain_data_mock.upnp_factory.async_create_device.side_effect = None + + # Send SSDP alive with boot ID + ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0] + await ssdp_callback( + { + ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, + ssdp.ATTR_SSDP_LOCATION: MOCK_DEVICE_LOCATION, + ssdp.ATTR_SSDP_BOOTID: "1", + }, + ssdp.SsdpChange.ALIVE, + ) + await hass.async_block_till_done() + + # Send SSDP update with next boot ID + await ssdp_callback( + { + ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, + "_udn": MOCK_DEVICE_UDN, + "NTS": "ssdp:update", + ssdp.ATTR_SSDP_BOOTID: "1", + ssdp.ATTR_SSDP_NEXTBOOTID: "2", + }, + ssdp.SsdpChange.UPDATE, + ) + await hass.async_block_till_done() + + # Device was not reconnected, even with a new boot ID + mock_state = hass.states.get(entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_IDLE + + assert dmr_device_mock.async_unsubscribe_services.await_count == 0 + assert dmr_device_mock.async_subscribe_services.await_count == 1 + + # Send SSDP update with same next boot ID, again + await ssdp_callback( + { + ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, + "_udn": MOCK_DEVICE_UDN, + "NTS": "ssdp:update", + ssdp.ATTR_SSDP_BOOTID: "1", + ssdp.ATTR_SSDP_NEXTBOOTID: "2", + }, + ssdp.SsdpChange.UPDATE, + ) + await hass.async_block_till_done() + + # Nothing should change + mock_state = hass.states.get(entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_IDLE + + assert dmr_device_mock.async_unsubscribe_services.await_count == 0 + assert dmr_device_mock.async_subscribe_services.await_count == 1 + + # Send SSDP update with bad next boot ID + await ssdp_callback( + { + ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, + "_udn": MOCK_DEVICE_UDN, + "NTS": "ssdp:update", + ssdp.ATTR_SSDP_BOOTID: "2", + ssdp.ATTR_SSDP_NEXTBOOTID: "7c848375-a106-4bd1-ac3c-8e50427c8e4f", + }, + ssdp.SsdpChange.UPDATE, + ) + await hass.async_block_till_done() + + # Nothing should change + mock_state = hass.states.get(entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_IDLE + + assert dmr_device_mock.async_unsubscribe_services.await_count == 0 + assert dmr_device_mock.async_subscribe_services.await_count == 1 + + # Send a new SSDP alive with the new boot ID, device should not reconnect + await ssdp_callback( + { + ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, + ssdp.ATTR_SSDP_LOCATION: MOCK_DEVICE_LOCATION, + ssdp.ATTR_SSDP_BOOTID: "2", + }, + ssdp.SsdpChange.ALIVE, + ) + await hass.async_block_till_done() + + mock_state = hass.states.get(entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_IDLE + + assert dmr_device_mock.async_unsubscribe_services.await_count == 0 + assert dmr_device_mock.async_subscribe_services.await_count == 1 + + +async def test_ssdp_update_missed_bootid( + hass: HomeAssistant, + domain_data_mock: Mock, + ssdp_scanner_mock: Mock, + mock_disconnected_entity_id: str, + dmr_device_mock: Mock, +) -> None: + """Test device disconnects when it gets ssdp:update bootid it wasn't expecting.""" + # Start with a disconnected device + entity_id = mock_disconnected_entity_id + mock_state = hass.states.get(entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_UNAVAILABLE + + # "Reconnect" the device + domain_data_mock.upnp_factory.async_create_device.side_effect = None + + # Send SSDP alive with boot ID + ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0] + await ssdp_callback( + { + ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, + ssdp.ATTR_SSDP_LOCATION: MOCK_DEVICE_LOCATION, + ssdp.ATTR_SSDP_BOOTID: "1", + }, + ssdp.SsdpChange.ALIVE, + ) + await hass.async_block_till_done() + + # Send SSDP update with skipped boot ID (not previously seen) + await ssdp_callback( + { + ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, + "_udn": MOCK_DEVICE_UDN, + "NTS": "ssdp:update", + ssdp.ATTR_SSDP_BOOTID: "2", + ssdp.ATTR_SSDP_NEXTBOOTID: "3", + }, + ssdp.SsdpChange.UPDATE, + ) + await hass.async_block_till_done() + + # Device should not reconnect yet + mock_state = hass.states.get(entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_IDLE + + assert dmr_device_mock.async_unsubscribe_services.await_count == 0 + assert dmr_device_mock.async_subscribe_services.await_count == 1 + + # Send a new SSDP alive with the new boot ID, device should reconnect + await ssdp_callback( + { + ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, + ssdp.ATTR_SSDP_LOCATION: MOCK_DEVICE_LOCATION, + ssdp.ATTR_SSDP_BOOTID: "3", + }, + ssdp.SsdpChange.ALIVE, + ) + await hass.async_block_till_done() + + mock_state = hass.states.get(entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_IDLE + + assert dmr_device_mock.async_unsubscribe_services.await_count == 1 + assert dmr_device_mock.async_subscribe_services.await_count == 2 + + +async def test_ssdp_bootid( + hass: HomeAssistant, + domain_data_mock: Mock, + ssdp_scanner_mock: Mock, + mock_disconnected_entity_id: str, + dmr_device_mock: Mock, +) -> None: + """Test an alive with a new BOOTID.UPNP.ORG header causes a reconnect.""" + # Start with a disconnected device + entity_id = mock_disconnected_entity_id + mock_state = hass.states.get(entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_UNAVAILABLE + + # "Reconnect" the device + domain_data_mock.upnp_factory.async_create_device.side_effect = None + + # Send SSDP alive with boot ID + ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0] + await ssdp_callback( + { + ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, + ssdp.ATTR_SSDP_LOCATION: MOCK_DEVICE_LOCATION, + ssdp.ATTR_SSDP_BOOTID: "1", + }, + ssdp.SsdpChange.ALIVE, + ) + await hass.async_block_till_done() + + mock_state = hass.states.get(entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_IDLE + + assert dmr_device_mock.async_subscribe_services.call_count == 1 + assert dmr_device_mock.async_unsubscribe_services.call_count == 0 + + # Send SSDP alive with same boot ID, nothing should happen + await ssdp_callback( + { + ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, + ssdp.ATTR_SSDP_LOCATION: MOCK_DEVICE_LOCATION, + ssdp.ATTR_SSDP_BOOTID: "1", + }, + ssdp.SsdpChange.ALIVE, + ) + await hass.async_block_till_done() + + mock_state = hass.states.get(entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_IDLE + + assert dmr_device_mock.async_subscribe_services.call_count == 1 + assert dmr_device_mock.async_unsubscribe_services.call_count == 0 + + # Send a new SSDP alive with an incremented boot ID, device should be dis/reconnected + await ssdp_callback( + { + ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, + ssdp.ATTR_SSDP_LOCATION: MOCK_DEVICE_LOCATION, + ssdp.ATTR_SSDP_BOOTID: "2", + }, + ssdp.SsdpChange.ALIVE, + ) + await hass.async_block_till_done() + + mock_state = hass.states.get(entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_IDLE + + assert dmr_device_mock.async_subscribe_services.call_count == 2 + assert dmr_device_mock.async_unsubscribe_services.call_count == 1 + + +async def test_become_unavailable( + hass: HomeAssistant, + domain_data_mock: Mock, + mock_entity_id: str, + dmr_device_mock: Mock, +) -> None: + """Test a device becoming unavailable.""" + # Check async_update currently works + await async_update_entity(hass, mock_entity_id) + dmr_device_mock.async_update.assert_called_with(do_ping=False) + + # Now break the network connection and try to contact the device + dmr_device_mock.async_set_volume_level.side_effect = UpnpConnectionError + dmr_device_mock.async_update.reset_mock() + + # Interface service calls should flag that the device is unavailable, but + # not disconnect it immediately + await hass.services.async_call( + MP_DOMAIN, + ha_const.SERVICE_VOLUME_SET, + {ATTR_ENTITY_ID: mock_entity_id, mp_const.ATTR_MEDIA_VOLUME_LEVEL: 0.80}, + blocking=True, + ) + + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_IDLE + + # With a working connection, the state should be restored + await async_update_entity(hass, mock_entity_id) + dmr_device_mock.async_update.assert_any_call(do_ping=True) + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_IDLE + + # Break the service again, and the connection too. An update will cause the + # device to be disconnected + dmr_device_mock.async_update.reset_mock() + dmr_device_mock.async_update.side_effect = UpnpConnectionError + + await hass.services.async_call( + MP_DOMAIN, + ha_const.SERVICE_VOLUME_SET, + {ATTR_ENTITY_ID: mock_entity_id, mp_const.ATTR_MEDIA_VOLUME_LEVEL: 0.80}, + blocking=True, + ) + await async_update_entity(hass, mock_entity_id) + dmr_device_mock.async_update.assert_called_with(do_ping=True) + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_UNAVAILABLE + + +async def test_poll_availability( + hass: HomeAssistant, + domain_data_mock: Mock, + config_entry_mock: MockConfigEntry, + dmr_device_mock: Mock, +) -> None: + """Test device becomes available and noticed via poll_availability.""" + # Start with a disconnected device and poll_availability=True + domain_data_mock.upnp_factory.async_create_device.side_effect = UpnpConnectionError + config_entry_mock.options = MappingProxyType( + { + CONF_POLL_AVAILABILITY: True, + } + ) + mock_entity_id = await setup_mock_component(hass, config_entry_mock) + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_UNAVAILABLE + + # Check that an update will poll the device for availability + domain_data_mock.upnp_factory.async_create_device.reset_mock() + await async_update_entity(hass, mock_entity_id) + domain_data_mock.upnp_factory.async_create_device.assert_awaited_once_with( + MOCK_DEVICE_LOCATION + ) + + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_UNAVAILABLE + + # "Reconnect" the device + domain_data_mock.upnp_factory.async_create_device.side_effect = None + + # Check that an update will notice the device and connect to it + domain_data_mock.upnp_factory.async_create_device.reset_mock() + await async_update_entity(hass, mock_entity_id) + domain_data_mock.upnp_factory.async_create_device.assert_awaited_once_with( + MOCK_DEVICE_LOCATION + ) + + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_IDLE + + # Clean up + assert await hass.config_entries.async_remove(config_entry_mock.entry_id) == { + "require_restart": False + } + + +async def test_disappearing_device( + hass: HomeAssistant, + mock_disconnected_entity_id: str, +) -> None: + """Test attribute update or service call as device disappears. + + Normally HA will check if the entity is available before updating attributes + or calling a service, but it's possible for the device to go offline in + between the check and the method call. Here we test by accessing the entity + directly to skip the availability check. + """ + # Retrieve entity directly. + entity: media_player.DlnaDmrEntity = hass.data[MP_DOMAIN].get_entity( + mock_disconnected_entity_id + ) + + # Test attribute access + for attr in ATTR_TO_PROPERTY: + value = getattr(entity, attr) + assert value is None + + # media_image_url is normally hidden by entity_picture, but we want a direct check + assert entity.media_image_url is None + + # Test service calls + await entity.async_set_volume_level(0.1) + await entity.async_mute_volume(True) + await entity.async_media_pause() + await entity.async_media_play() + await entity.async_media_stop() + await entity.async_media_seek(22.0) + await entity.async_play_media("", "") + await entity.async_media_previous_track() + await entity.async_media_next_track() + + +async def test_resubscribe_failure( + hass: HomeAssistant, + mock_entity_id: str, + dmr_device_mock: Mock, +) -> None: + """Test failure to resubscribe to events notifications causes an update ping.""" + await async_update_entity(hass, mock_entity_id) + dmr_device_mock.async_update.assert_called_with(do_ping=False) + dmr_device_mock.async_update.reset_mock() + + on_event = dmr_device_mock.on_event + on_event(None, []) + await hass.async_block_till_done() + + await async_update_entity(hass, mock_entity_id) + dmr_device_mock.async_update.assert_called_with(do_ping=True) + + +async def test_config_update_listen_port( + hass: HomeAssistant, + domain_data_mock: Mock, + config_entry_mock: MockConfigEntry, + dmr_device_mock: Mock, + mock_entity_id: str, +) -> None: + """Test DlnaDmrEntity gets updated by ConfigEntry's CONF_LISTEN_PORT.""" + domain_data_mock.upnp_factory.async_create_device.reset_mock() + + hass.config_entries.async_update_entry( + config_entry_mock, + options={ + CONF_LISTEN_PORT: 1234, + }, + ) + await hass.async_block_till_done() + + # A new event listener with the changed port will be used + domain_data_mock.async_release_event_notifier.assert_awaited_once_with( + EventListenAddr(LOCAL_IP, 0, None) + ) + domain_data_mock.async_get_event_notifier.assert_awaited_with( + EventListenAddr(LOCAL_IP, 1234, None), hass + ) + + # Device will be reconnected + domain_data_mock.upnp_factory.async_create_device.assert_awaited_once_with( + MOCK_DEVICE_LOCATION + ) + assert dmr_device_mock.async_unsubscribe_services.await_count == 1 + assert dmr_device_mock.async_subscribe_services.await_count == 2 + + # Check that its still connected + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_IDLE + + +async def test_config_update_connect_failure( + hass: HomeAssistant, + domain_data_mock: Mock, + config_entry_mock: MockConfigEntry, + dmr_device_mock: Mock, + mock_entity_id: str, +) -> None: + """Test DlnaDmrEntity gracefully handles connect failure after config change.""" + domain_data_mock.upnp_factory.async_create_device.reset_mock() + domain_data_mock.upnp_factory.async_create_device.side_effect = UpnpError + + hass.config_entries.async_update_entry( + config_entry_mock, + options={ + CONF_LISTEN_PORT: 1234, + }, + ) + await hass.async_block_till_done() + + # Old event listener was released, new event listener was not created + domain_data_mock.async_release_event_notifier.assert_awaited_once_with( + EventListenAddr(LOCAL_IP, 0, None) + ) + domain_data_mock.async_get_event_notifier.assert_awaited_once() + + # There was an attempt to connect to the device + domain_data_mock.upnp_factory.async_create_device.assert_awaited_once_with( + MOCK_DEVICE_LOCATION + ) + + # Check that its no longer connected + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_UNAVAILABLE + + +async def test_config_update_callback_url( + hass: HomeAssistant, + domain_data_mock: Mock, + config_entry_mock: MockConfigEntry, + dmr_device_mock: Mock, + mock_entity_id: str, +) -> None: + """Test DlnaDmrEntity gets updated by ConfigEntry's CONF_CALLBACK_URL_OVERRIDE.""" + domain_data_mock.upnp_factory.async_create_device.reset_mock() + + hass.config_entries.async_update_entry( + config_entry_mock, + options={ + CONF_CALLBACK_URL_OVERRIDE: "http://www.example.net/notify", + }, + ) + await hass.async_block_till_done() + + # A new event listener with the changed callback URL will be used + domain_data_mock.async_release_event_notifier.assert_awaited_once_with( + EventListenAddr(LOCAL_IP, 0, None) + ) + domain_data_mock.async_get_event_notifier.assert_awaited_with( + EventListenAddr(LOCAL_IP, 0, "http://www.example.net/notify"), hass + ) + + # Device will be reconnected + domain_data_mock.upnp_factory.async_create_device.assert_awaited_once_with( + MOCK_DEVICE_LOCATION + ) + assert dmr_device_mock.async_unsubscribe_services.await_count == 1 + assert dmr_device_mock.async_subscribe_services.await_count == 2 + + # Check that its still connected + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_IDLE + + +async def test_config_update_poll_availability( + hass: HomeAssistant, + domain_data_mock: Mock, + config_entry_mock: MockConfigEntry, + dmr_device_mock: Mock, + mock_entity_id: str, +) -> None: + """Test DlnaDmrEntity gets updated by ConfigEntry's CONF_POLL_AVAILABILITY.""" + domain_data_mock.upnp_factory.async_create_device.reset_mock() + + # Updates of the device will not ping it yet + await async_update_entity(hass, mock_entity_id) + dmr_device_mock.async_update.assert_awaited_with(do_ping=False) + + hass.config_entries.async_update_entry( + config_entry_mock, + options={ + CONF_POLL_AVAILABILITY: True, + }, + ) + await hass.async_block_till_done() + + # Event listeners will not change + domain_data_mock.async_release_event_notifier.assert_not_awaited() + domain_data_mock.async_get_event_notifier.assert_awaited_once() + + # Device will not be reconnected + domain_data_mock.upnp_factory.async_create_device.assert_not_awaited() + assert dmr_device_mock.async_unsubscribe_services.await_count == 0 + assert dmr_device_mock.async_subscribe_services.await_count == 1 + + # Updates of the device will now ping it + await async_update_entity(hass, mock_entity_id) + dmr_device_mock.async_update.assert_awaited_with(do_ping=True) + + # Check that its still connected + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_IDLE diff --git a/tests/components/ssdp/test_init.py b/tests/components/ssdp/test_init.py index 86a9b3eea21..f3ddab39c39 100644 --- a/tests/components/ssdp/test_init.py +++ b/tests/components/ssdp/test_init.py @@ -69,7 +69,7 @@ async def test_ssdp_flow_dispatched_on_st(mock_get_ssdp, hass, caplog, mock_flow ssdp.ATTR_SSDP_SERVER: "mock-server", ssdp.ATTR_SSDP_EXT: "", ssdp.ATTR_UPNP_UDN: "uuid:mock-udn", - "_udn": ANY, + ssdp.ATTR_SSDP_UDN: ANY, "_timestamp": ANY, } assert "Failed to fetch ssdp data" not in caplog.text @@ -411,7 +411,7 @@ async def test_scan_with_registered_callback( ssdp.ATTR_SSDP_USN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL::mock-st", ssdp.ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", "x-rincon-bootseq": "55", - "_udn": ANY, + ssdp.ATTR_SSDP_UDN: ANY, "_timestamp": ANY, }, ssdp.SsdpChange.ALIVE, @@ -465,7 +465,7 @@ async def test_getting_existing_headers(mock_get_ssdp, hass, aioclient_mock): ssdp.ATTR_SSDP_USN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL::urn:mdx-netflix-com:service:target:3", ssdp.ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", - "_udn": ANY, + ssdp.ATTR_SSDP_UDN: ANY, "_timestamp": ANY, } ] @@ -482,7 +482,7 @@ async def test_getting_existing_headers(mock_get_ssdp, hass, aioclient_mock): ssdp.ATTR_SSDP_USN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL::urn:mdx-netflix-com:service:target:3", ssdp.ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", - "_udn": ANY, + ssdp.ATTR_SSDP_UDN: ANY, "_timestamp": ANY, } ] @@ -498,7 +498,7 @@ async def test_getting_existing_headers(mock_get_ssdp, hass, aioclient_mock): ssdp.ATTR_SSDP_USN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL::urn:mdx-netflix-com:service:target:3", ssdp.ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", - "_udn": ANY, + ssdp.ATTR_SSDP_UDN: ANY, "_timestamp": ANY, } diff --git a/tests/test_util/aiohttp.py b/tests/test_util/aiohttp.py index 58e4c6a2275..077ed292d10 100644 --- a/tests/test_util/aiohttp.py +++ b/tests/test_util/aiohttp.py @@ -9,6 +9,7 @@ from urllib.parse import parse_qs from aiohttp import ClientSession from aiohttp.client_exceptions import ClientError, ClientResponseError from aiohttp.streams import StreamReader +from multidict import CIMultiDict from yarl import URL from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE @@ -179,7 +180,7 @@ class AiohttpClientMockResponse: self.response = response self.exc = exc self.side_effect = side_effect - self._headers = headers or {} + self._headers = CIMultiDict(headers or {}) self._cookies = {} if cookies: From 0653693dffd3797e0ede138c69c8c96cb520a238 Mon Sep 17 00:00:00 2001 From: tube0013 Date: Mon, 27 Sep 2021 17:19:42 -0400 Subject: [PATCH 645/843] Add usb discovery for tubeszb ch340B serial devices (#56719) --- homeassistant/components/zha/manifest.json | 1 + homeassistant/generated/usb.py | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 7f9afc472a7..fe489d42f06 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -17,6 +17,7 @@ "usb": [ {"vid":"10C4","pid":"EA60","description":"*2652*","known_devices":["slae.sh cc2652rb stick"]}, {"vid":"10C4","pid":"EA60","description":"*tubeszb*","known_devices":["TubesZB Coordinator"]}, + {"vid":"1A86","pid":"7523","description":"*tubeszb*","known_devices":["TubesZB Coordinator"]}, {"vid":"1CF1","pid":"0030","description":"*conbee*","known_devices":["Conbee II"]}, {"vid":"10C4","pid":"8A2A","description":"*zigbee*","known_devices":["Nortek HUSBZB-1"]} ], diff --git a/homeassistant/generated/usb.py b/homeassistant/generated/usb.py index 4b1abcfd557..bebeb393329 100644 --- a/homeassistant/generated/usb.py +++ b/homeassistant/generated/usb.py @@ -23,6 +23,12 @@ USB = [ "pid": "EA60", "description": "*tubeszb*" }, + { + "domain": "zha", + "vid": "1A86", + "pid": "7523", + "description": "*tubeszb*" + }, { "domain": "zha", "vid": "1CF1", From 5976f898da1c706c377f0a885153fa16bf570f94 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 27 Sep 2021 23:30:13 +0200 Subject: [PATCH 646/843] Add WS API for removing statistics for a list of statistic_ids (#55078) * Add WS API for removing statistics for a list of statistic_ids * Refactor according to code review, enable foreign keys support for sqlite * Adjust tests * Move clear_statistics WS API to recorder * Adjust tests after rebase * Update docstring * Update homeassistant/components/recorder/websocket_api.py Co-authored-by: Paulus Schoutsen * Adjust tests after rebase Co-authored-by: Paulus Schoutsen --- homeassistant/components/recorder/__init__.py | 14 ++ .../components/recorder/statistics.py | 8 + homeassistant/components/recorder/util.py | 3 + .../components/recorder/websocket_api.py | 22 +++ tests/components/recorder/test_util.py | 6 +- .../components/recorder/test_websocket_api.py | 137 ++++++++++++++++++ 6 files changed, 188 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index bbf67b23c52..7351657f64c 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -323,6 +323,12 @@ def _async_register_services(hass, instance): ) +class ClearStatisticsTask(NamedTuple): + """Object to store statistics_ids which for which to remove statistics.""" + + statistic_ids: list[str] + + class PurgeTask(NamedTuple): """Object to store information about purge task.""" @@ -570,6 +576,11 @@ class Recorder(threading.Thread): start = statistics.get_start_time() self.queue.put(StatisticsTask(start)) + @callback + def async_clear_statistics(self, statistic_ids): + """Clear statistics for a list of statistic_ids.""" + self.queue.put(ClearStatisticsTask(statistic_ids)) + @callback def _async_setup_periodic_tasks(self): """Prepare periodic tasks.""" @@ -763,6 +774,9 @@ class Recorder(threading.Thread): if isinstance(event, StatisticsTask): self._run_statistics(event.start) return + if isinstance(event, ClearStatisticsTask): + statistics.clear_statistics(self, event.statistic_ids) + return if isinstance(event, WaitTask): self._queue_watch.set() return diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 74d27282ea8..1b864992010 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -458,6 +458,14 @@ def _configured_unit(unit: str, units: UnitSystem) -> str: return unit +def clear_statistics(instance: Recorder, statistic_ids: list[str]) -> None: + """Clear statistics for a list of statistic_ids.""" + with session_scope(session=instance.get_session()) as session: # type: ignore + session.query(StatisticsMeta).filter( + StatisticsMeta.statistic_id.in_(statistic_ids) + ).delete(synchronize_session=False) + + def list_statistic_ids( hass: HomeAssistant, statistic_type: Literal["mean"] | Literal["sum"] | None = None, diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index a3ca0514b4c..7e3948cf15b 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -284,6 +284,9 @@ def setup_connection_for_dialect(dialect_name, dbapi_connection, first_connectio # approximately 8MiB of memory execute_on_connection(dbapi_connection, "PRAGMA cache_size = -8192") + # enable support for foreign keys + execute_on_connection(dbapi_connection, "PRAGMA foreign_keys=ON") + if dialect_name == "mysql": execute_on_connection(dbapi_connection, "SET session wait_timeout=28800") diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index c5a332547cb..6d90f48eb76 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -4,6 +4,7 @@ import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback +from .const import DATA_INSTANCE from .statistics import validate_statistics @@ -11,6 +12,7 @@ from .statistics import validate_statistics def async_setup(hass: HomeAssistant) -> None: """Set up the recorder websocket API.""" websocket_api.async_register_command(hass, ws_validate_statistics) + websocket_api.async_register_command(hass, ws_clear_statistics) @websocket_api.websocket_command( @@ -28,3 +30,23 @@ async def ws_validate_statistics( hass, ) connection.send_result(msg["id"], statistic_ids) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "recorder/clear_statistics", + vol.Required("statistic_ids"): [str], + } +) +@callback +def ws_clear_statistics( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict +) -> None: + """Clear statistics for a list of statistic_ids. + + Note: The WS call posts a job to the recorder's queue and then returns, it doesn't + wait until the job is completed. + """ + hass.data[DATA_INSTANCE].async_clear_statistics(msg["statistic_ids"]) + connection.send_result(msg["id"]) diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py index cb54f0404b9..f193993ffe5 100644 --- a/tests/components/recorder/test_util.py +++ b/tests/components/recorder/test_util.py @@ -149,15 +149,17 @@ def test_setup_connection_for_dialect_sqlite(): util.setup_connection_for_dialect("sqlite", dbapi_connection, True) - assert len(execute_mock.call_args_list) == 2 + assert len(execute_mock.call_args_list) == 3 assert execute_mock.call_args_list[0][0][0] == "PRAGMA journal_mode=WAL" assert execute_mock.call_args_list[1][0][0] == "PRAGMA cache_size = -8192" + assert execute_mock.call_args_list[2][0][0] == "PRAGMA foreign_keys=ON" execute_mock.reset_mock() util.setup_connection_for_dialect("sqlite", dbapi_connection, False) - assert len(execute_mock.call_args_list) == 1 + assert len(execute_mock.call_args_list) == 2 assert execute_mock.call_args_list[0][0][0] == "PRAGMA cache_size = -8192" + assert execute_mock.call_args_list[1][0][0] == "PRAGMA foreign_keys=ON" def test_basic_sanity_check(hass_recorder): diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index ed07d949808..d9e546f6894 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -3,6 +3,7 @@ from datetime import timedelta import pytest +from pytest import approx from homeassistant.components.recorder.const import DATA_INSTANCE from homeassistant.components.recorder.models import StatisticsMeta @@ -11,6 +12,8 @@ from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM +from .common import trigger_db_commit + from tests.common import init_recorder_component BATTERY_SENSOR_ATTRIBUTES = { @@ -240,3 +243,137 @@ async def test_validate_statistics_unsupported_device_class( # Remove the state - empty response hass.states.async_remove("sensor.test") await assert_validation_result(client, {}) + + +async def test_clear_statistics(hass, hass_ws_client): + """Test removing statistics.""" + now = dt_util.utcnow() + + units = METRIC_SYSTEM + attributes = POWER_SENSOR_ATTRIBUTES + state = 10 + value = 10000 + + hass.config.units = units + await hass.async_add_executor_job(init_recorder_component, hass) + await async_setup_component(hass, "history", {}) + await async_setup_component(hass, "sensor", {}) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + hass.states.async_set("sensor.test1", state, attributes=attributes) + hass.states.async_set("sensor.test2", state * 2, attributes=attributes) + hass.states.async_set("sensor.test3", state * 3, attributes=attributes) + await hass.async_block_till_done() + + await hass.async_add_executor_job(trigger_db_commit, hass) + await hass.async_block_till_done() + + hass.data[DATA_INSTANCE].do_adhoc_statistics(start=now) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "history/statistics_during_period", + "start_time": now.isoformat(), + "period": "5minute", + } + ) + response = await client.receive_json() + assert response["success"] + expected_response = { + "sensor.test1": [ + { + "statistic_id": "sensor.test1", + "start": now.isoformat(), + "end": (now + timedelta(minutes=5)).isoformat(), + "mean": approx(value), + "min": approx(value), + "max": approx(value), + "last_reset": None, + "state": None, + "sum": None, + "sum_decrease": None, + "sum_increase": None, + } + ], + "sensor.test2": [ + { + "statistic_id": "sensor.test2", + "start": now.isoformat(), + "end": (now + timedelta(minutes=5)).isoformat(), + "mean": approx(value * 2), + "min": approx(value * 2), + "max": approx(value * 2), + "last_reset": None, + "state": None, + "sum": None, + "sum_decrease": None, + "sum_increase": None, + } + ], + "sensor.test3": [ + { + "statistic_id": "sensor.test3", + "start": now.isoformat(), + "end": (now + timedelta(minutes=5)).isoformat(), + "mean": approx(value * 3), + "min": approx(value * 3), + "max": approx(value * 3), + "last_reset": None, + "state": None, + "sum": None, + "sum_decrease": None, + "sum_increase": None, + } + ], + } + assert response["result"] == expected_response + + await client.send_json( + { + "id": 2, + "type": "recorder/clear_statistics", + "statistic_ids": ["sensor.test"], + } + ) + response = await client.receive_json() + assert response["success"] + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + + client = await hass_ws_client() + await client.send_json( + { + "id": 3, + "type": "history/statistics_during_period", + "start_time": now.isoformat(), + "period": "5minute", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == expected_response + + await client.send_json( + { + "id": 4, + "type": "recorder/clear_statistics", + "statistic_ids": ["sensor.test1", "sensor.test3"], + } + ) + response = await client.receive_json() + assert response["success"] + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + + client = await hass_ws_client() + await client.send_json( + { + "id": 5, + "type": "history/statistics_during_period", + "start_time": now.isoformat(), + "period": "5minute", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == {"sensor.test2": expected_response["sensor.test2"]} From ec9fc0052d5baae64750047df0927f0da37b36ef Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Mon, 27 Sep 2021 23:42:27 +0100 Subject: [PATCH 647/843] Define `unit_of_measurement` of all `utility_meter` sensors on HA start (#56112) * define unit_of_measurement on hass start * delay utility_meter state * check state * store siblings * don't check unit_of_measurement --- .../components/utility_meter/__init__.py | 2 + .../components/utility_meter/const.py | 1 + .../components/utility_meter/sensor.py | 52 ++++++++---- tests/components/utility_meter/test_sensor.py | 79 +++++++++++++------ 4 files changed, 97 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/utility_meter/__init__.py b/homeassistant/components/utility_meter/__init__.py index 32ed90a9111..d64b40ed60b 100644 --- a/homeassistant/components/utility_meter/__init__.py +++ b/homeassistant/components/utility_meter/__init__.py @@ -24,6 +24,7 @@ from .const import ( CONF_TARIFF, CONF_TARIFF_ENTITY, CONF_TARIFFS, + DATA_TARIFF_SENSORS, DATA_UTILITY, DOMAIN, METER_TYPES, @@ -98,6 +99,7 @@ async def async_setup(hass, config): _LOGGER.debug("Setup %s.%s", DOMAIN, meter) hass.data[DATA_UTILITY][meter] = conf + hass.data[DATA_UTILITY][meter][DATA_TARIFF_SENSORS] = [] if not conf[CONF_TARIFFS]: # only one entity is required diff --git a/homeassistant/components/utility_meter/const.py b/homeassistant/components/utility_meter/const.py index 3be6fa9a061..3e127e4a643 100644 --- a/homeassistant/components/utility_meter/const.py +++ b/homeassistant/components/utility_meter/const.py @@ -22,6 +22,7 @@ METER_TYPES = [ ] DATA_UTILITY = "utility_meter_data" +DATA_TARIFF_SENSORS = "utility_meter_sensors" CONF_METER = "meter" CONF_SOURCE_SENSOR = "source" diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index 36094e7d3e1..96bf12fdd4d 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -46,6 +46,7 @@ from .const import ( CONF_TARIFF, CONF_TARIFF_ENTITY, DAILY, + DATA_TARIFF_SENSORS, DATA_UTILITY, HOURLY, MONTHLY, @@ -96,19 +97,20 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= CONF_TARIFF_ENTITY ) conf_cron_pattern = hass.data[DATA_UTILITY][meter].get(CONF_CRON_PATTERN) - - meters.append( - UtilityMeterSensor( - conf_meter_source, - conf.get(CONF_NAME), - conf_meter_type, - conf_meter_offset, - conf_meter_net_consumption, - conf.get(CONF_TARIFF), - conf_meter_tariff_entity, - conf_cron_pattern, - ) + meter_sensor = UtilityMeterSensor( + meter, + conf_meter_source, + conf.get(CONF_NAME), + conf_meter_type, + conf_meter_offset, + conf_meter_net_consumption, + conf.get(CONF_TARIFF), + conf_meter_tariff_entity, + conf_cron_pattern, ) + meters.append(meter_sensor) + + hass.data[DATA_UTILITY][meter][DATA_TARIFF_SENSORS].append(meter_sensor) async_add_entities(meters) @@ -126,6 +128,7 @@ class UtilityMeterSensor(RestoreEntity, SensorEntity): def __init__( self, + parent_meter, source_entity, name, meter_type, @@ -136,8 +139,9 @@ class UtilityMeterSensor(RestoreEntity, SensorEntity): cron_pattern=None, ): """Initialize the Utility Meter sensor.""" + self._parent_meter = parent_meter self._sensor_source_id = source_entity - self._state = 0 + self._state = None self._last_period = 0 self._last_reset = dt_util.utcnow() self._collecting = None @@ -153,11 +157,26 @@ class UtilityMeterSensor(RestoreEntity, SensorEntity): self._tariff = tariff self._tariff_entity = tariff_entity + def start(self, unit): + """Initialize unit and state upon source initial update.""" + self._unit_of_measurement = unit + self._state = 0 + self.async_write_ha_state() + @callback def async_reading(self, event): """Handle the sensor state changes.""" old_state = event.data.get("old_state") new_state = event.data.get("new_state") + + if self._state is None and new_state.state: + # First state update initializes the utility_meter sensors + source_state = self.hass.states.get(self._sensor_source_id) + for sensor in self.hass.data[DATA_UTILITY][self._parent_meter][ + DATA_TARIFF_SENSORS + ]: + sensor.start(source_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)) + if ( old_state is None or new_state is None @@ -333,7 +352,12 @@ class UtilityMeterSensor(RestoreEntity, SensorEntity): self._change_status(tariff_entity_state.state) return - _LOGGER.debug("<%s> collecting from %s", self.name, self._sensor_source_id) + _LOGGER.debug( + "<%s> collecting %s from %s", + self.name, + self._unit_of_measurement, + self._sensor_source_id, + ) self._collecting = async_track_state_change_event( self.hass, [self._sensor_source_id], self.async_reading ) diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index ff30f0d66c2..91b03f7a1bb 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -31,6 +31,7 @@ from homeassistant.const import ( ENERGY_KILO_WATT_HOUR, EVENT_HOMEASSISTANT_START, STATE_UNAVAILABLE, + STATE_UNKNOWN, ) from homeassistant.core import State from homeassistant.setup import async_setup_component @@ -64,6 +65,8 @@ async def test_state(hass): await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + entity_id = config[DOMAIN]["energy_bill"]["source"] hass.states.async_set( entity_id, 2, {ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR} @@ -74,16 +77,19 @@ async def test_state(hass): assert state is not None assert state.state == "0" assert state.attributes.get("status") == COLLECTING + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR state = hass.states.get("sensor.energy_bill_midpeak") assert state is not None assert state.state == "0" assert state.attributes.get("status") == PAUSED + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR state = hass.states.get("sensor.energy_bill_offpeak") assert state is not None assert state.state == "0" assert state.attributes.get("status") == PAUSED + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR now = dt_util.utcnow() + timedelta(seconds=10) with patch("homeassistant.util.dt.utcnow", return_value=now): @@ -187,6 +193,49 @@ async def test_state(hass): assert state.state == "0.123" +async def test_init(hass): + """Test utility sensor state initializtion.""" + config = { + "utility_meter": { + "energy_bill": { + "source": "sensor.energy", + "tariffs": ["onpeak", "midpeak", "offpeak"], + } + } + } + + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_bill_onpeak") + assert state is not None + assert state.state == STATE_UNKNOWN + + state = hass.states.get("sensor.energy_bill_offpeak") + assert state is not None + assert state.state == STATE_UNKNOWN + + entity_id = config[DOMAIN]["energy_bill"]["source"] + hass.states.async_set( + entity_id, 2, {ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR} + ) + + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_bill_onpeak") + assert state is not None + assert state.state == "0" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR + + state = hass.states.get("sensor.energy_bill_offpeak") + assert state is not None + assert state.state == "0" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR + + async def test_device_class(hass): """Test utility device_class.""" config = { @@ -205,6 +254,8 @@ async def test_device_class(hass): await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + entity_id_energy = config[DOMAIN]["energy_meter"]["source"] hass.states.async_set( entity_id_energy, 2, {ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR} @@ -218,35 +269,13 @@ async def test_device_class(hass): state = hass.states.get("sensor.energy_meter") assert state is not None assert state.state == "0" - assert state.attributes.get(ATTR_DEVICE_CLASS) is None - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None - - state = hass.states.get("sensor.gas_meter") - assert state is not None - assert state.state == "0" - assert state.attributes.get(ATTR_DEVICE_CLASS) is None - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None - - hass.states.async_set( - entity_id_energy, 3, {ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR} - ) - hass.states.async_set( - entity_id_gas, 3, {ATTR_UNIT_OF_MEASUREMENT: "some_archaic_unit"} - ) - await hass.async_block_till_done() - - state = hass.states.get("sensor.energy_meter") - assert state is not None - assert state.state == "1" assert state.attributes.get(ATTR_DEVICE_CLASS) == "energy" assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR state = hass.states.get("sensor.gas_meter") assert state is not None - assert state.state == "1" + assert state.state == "0" assert state.attributes.get(ATTR_DEVICE_CLASS) is None assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "some_archaic_unit" @@ -272,6 +301,7 @@ async def test_restore_state(hass): attributes={ ATTR_STATUS: PAUSED, ATTR_LAST_RESET: last_reset, + ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, }, ), State( @@ -280,6 +310,7 @@ async def test_restore_state(hass): attributes={ ATTR_STATUS: COLLECTING, ATTR_LAST_RESET: last_reset, + ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, }, ), ], @@ -293,11 +324,13 @@ async def test_restore_state(hass): assert state.state == "3" assert state.attributes.get("status") == PAUSED assert state.attributes.get("last_reset") == last_reset + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR state = hass.states.get("sensor.energy_bill_offpeak") assert state.state == "6" assert state.attributes.get("status") == COLLECTING assert state.attributes.get("last_reset") == last_reset + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR # utility_meter is loaded, now set sensors according to utility_meter: hass.bus.async_fire(EVENT_HOMEASSISTANT_START) From 8ef123259e9b05a4a398b180a6199a57a68e1bda Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Sep 2021 00:43:29 +0200 Subject: [PATCH 648/843] Add WS API for updating unit_of_measurement in statistics metadata (#56184) * Add WS API for updating statistics metadata * Update homeassistant/components/recorder/websocket_api.py Co-authored-by: Bram Kragten * Update homeassistant/components/recorder/websocket_api.py Co-authored-by: Paulus Schoutsen * Fix typo Co-authored-by: Bram Kragten Co-authored-by: Paulus Schoutsen --- homeassistant/components/recorder/__init__.py | 17 ++++++ .../components/recorder/statistics.py | 10 ++++ .../components/recorder/websocket_api.py | 22 ++++++++ .../components/recorder/test_websocket_api.py | 52 +++++++++++++++++++ 4 files changed, 101 insertions(+) diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 7351657f64c..b473dead17b 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -329,6 +329,13 @@ class ClearStatisticsTask(NamedTuple): statistic_ids: list[str] +class UpdateStatisticsMetadataTask(NamedTuple): + """Object to store statistics_id and unit for update of statistics metadata.""" + + statistic_id: str + unit_of_measurement: str | None + + class PurgeTask(NamedTuple): """Object to store information about purge task.""" @@ -581,6 +588,11 @@ class Recorder(threading.Thread): """Clear statistics for a list of statistic_ids.""" self.queue.put(ClearStatisticsTask(statistic_ids)) + @callback + def async_update_statistics_metadata(self, statistic_id, unit_of_measurement): + """Update statistics metadata for a statistic_id.""" + self.queue.put(UpdateStatisticsMetadataTask(statistic_id, unit_of_measurement)) + @callback def _async_setup_periodic_tasks(self): """Prepare periodic tasks.""" @@ -777,6 +789,11 @@ class Recorder(threading.Thread): if isinstance(event, ClearStatisticsTask): statistics.clear_statistics(self, event.statistic_ids) return + if isinstance(event, UpdateStatisticsMetadataTask): + statistics.update_statistics_metadata( + self, event.statistic_id, event.unit_of_measurement + ) + return if isinstance(event, WaitTask): self._queue_watch.set() return diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 1b864992010..2b4775e2412 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -466,6 +466,16 @@ def clear_statistics(instance: Recorder, statistic_ids: list[str]) -> None: ).delete(synchronize_session=False) +def update_statistics_metadata( + instance: Recorder, statistic_id: str, unit_of_measurement: str | None +) -> None: + """Update statistics metadata for a statistic_id.""" + with session_scope(session=instance.get_session()) as session: # type: ignore + session.query(StatisticsMeta).filter( + StatisticsMeta.statistic_id == statistic_id + ).update({StatisticsMeta.unit_of_measurement: unit_of_measurement}) + + def list_statistic_ids( hass: HomeAssistant, statistic_type: Literal["mean"] | Literal["sum"] | None = None, diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index 6d90f48eb76..ba77692fe8e 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -1,4 +1,6 @@ """The Energy websocket API.""" +from __future__ import annotations + import voluptuous as vol from homeassistant.components import websocket_api @@ -13,6 +15,7 @@ def async_setup(hass: HomeAssistant) -> None: """Set up the recorder websocket API.""" websocket_api.async_register_command(hass, ws_validate_statistics) websocket_api.async_register_command(hass, ws_clear_statistics) + websocket_api.async_register_command(hass, ws_update_statistics_metadata) @websocket_api.websocket_command( @@ -50,3 +53,22 @@ def ws_clear_statistics( """ hass.data[DATA_INSTANCE].async_clear_statistics(msg["statistic_ids"]) connection.send_result(msg["id"]) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "recorder/update_statistics_metadata", + vol.Required("statistic_id"): str, + vol.Required("unit_of_measurement"): vol.Any(str, None), + } +) +@callback +def ws_update_statistics_metadata( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict +) -> None: + """Update statistics metadata for a statistic_id.""" + hass.data[DATA_INSTANCE].async_update_statistics_metadata( + msg["statistic_id"], msg["unit_of_measurement"] + ) + connection.send_result(msg["id"]) diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index d9e546f6894..9e856fc6b85 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -377,3 +377,55 @@ async def test_clear_statistics(hass, hass_ws_client): response = await client.receive_json() assert response["success"] assert response["result"] == {"sensor.test2": expected_response["sensor.test2"]} + + +@pytest.mark.parametrize("new_unit", ["dogs", None]) +async def test_update_statistics_metadata(hass, hass_ws_client, new_unit): + """Test removing statistics.""" + now = dt_util.utcnow() + + units = METRIC_SYSTEM + attributes = POWER_SENSOR_ATTRIBUTES + state = 10 + + hass.config.units = units + await hass.async_add_executor_job(init_recorder_component, hass) + await async_setup_component(hass, "history", {}) + await async_setup_component(hass, "sensor", {}) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + hass.states.async_set("sensor.test", state, attributes=attributes) + await hass.async_block_till_done() + + await hass.async_add_executor_job(trigger_db_commit, hass) + await hass.async_block_till_done() + + hass.data[DATA_INSTANCE].do_adhoc_statistics(period="hourly", start=now) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + + client = await hass_ws_client() + + await client.send_json({"id": 1, "type": "history/list_statistic_ids"}) + response = await client.receive_json() + assert response["success"] + assert response["result"] == [ + {"statistic_id": "sensor.test", "unit_of_measurement": "W"} + ] + + await client.send_json( + { + "id": 2, + "type": "recorder/update_statistics_metadata", + "statistic_id": "sensor.test", + "unit_of_measurement": new_unit, + } + ) + response = await client.receive_json() + assert response["success"] + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + + await client.send_json({"id": 3, "type": "history/list_statistic_ids"}) + response = await client.receive_json() + assert response["success"] + assert response["result"] == [ + {"statistic_id": "sensor.test", "unit_of_measurement": new_unit} + ] From aea754df5d7935e487fc770ab04c9c193b91507a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Sep 2021 22:45:53 -0500 Subject: [PATCH 649/843] Add dhcp support for TPLink KL60 and EP40 (#56726) --- homeassistant/components/tplink/manifest.json | 8 ++++++++ homeassistant/generated/dhcp.py | 10 ++++++++++ 2 files changed, 18 insertions(+) diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json index 22745e92ce7..03e63720dea 100644 --- a/homeassistant/components/tplink/manifest.json +++ b/homeassistant/components/tplink/manifest.json @@ -8,6 +8,10 @@ "quality_scale": "platinum", "iot_class": "local_polling", "dhcp": [ + { + "hostname": "ep*", + "macaddress": "E848B8*" + }, { "hostname": "hs*", "macaddress": "1C3BF3*" @@ -32,6 +36,10 @@ "hostname": "hs*", "macaddress": "C006C3*" }, + { + "hostname": "k[lp]*", + "macaddress": "003192*" + }, { "hostname": "k[lp]*", "macaddress": "1C3BF3*" diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 8db0a496f8c..710f4d84d2c 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -264,6 +264,11 @@ DHCP = [ "hostname": "eneco-*", "macaddress": "74C63B*" }, + { + "domain": "tplink", + "hostname": "ep*", + "macaddress": "E848B8*" + }, { "domain": "tplink", "hostname": "hs*", @@ -294,6 +299,11 @@ DHCP = [ "hostname": "hs*", "macaddress": "C006C3*" }, + { + "domain": "tplink", + "hostname": "k[lp]*", + "macaddress": "003192*" + }, { "domain": "tplink", "hostname": "k[lp]*", From f93539ef4c73f508299768cc793c95d0d55ea284 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Sep 2021 23:43:39 -0500 Subject: [PATCH 650/843] Add api to the network integration to get ipv4 broadcast addresses (#56722) --- homeassistant/components/network/__init__.py | 23 ++++++++- homeassistant/components/network/const.py | 1 + tests/components/network/test_init.py | 49 ++++++++++++++++++++ 3 files changed, 72 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/network/__init__.py b/homeassistant/components/network/__init__.py index a7dffad7084..024075ba2c1 100644 --- a/homeassistant/components/network/__init__.py +++ b/homeassistant/components/network/__init__.py @@ -1,7 +1,7 @@ """The Network Configuration integration.""" from __future__ import annotations -from ipaddress import IPv4Address, IPv6Address +from ipaddress import IPv4Address, IPv6Address, ip_interface import logging import voluptuous as vol @@ -17,6 +17,7 @@ from .const import ( ATTR_ADAPTERS, ATTR_CONFIGURED_ADAPTERS, DOMAIN, + IPV4_BROADCAST_ADDR, NETWORK_CONFIG_SCHEMA, ) from .models import Adapter @@ -75,6 +76,26 @@ def async_only_default_interface_enabled(adapters: list[Adapter]) -> bool: ) +@bind_hass +async def async_get_ipv4_broadcast_addresses(hass: HomeAssistant) -> set[IPv4Address]: + """Return a set of broadcast addresses.""" + broadcast_addresses: set[IPv4Address] = {IPv4Address(IPV4_BROADCAST_ADDR)} + adapters = await async_get_adapters(hass) + if async_only_default_interface_enabled(adapters): + return broadcast_addresses + for adapter in adapters: + if not adapter["enabled"]: + continue + for ip_info in adapter["ipv4"]: + interface = ip_interface( + f"{ip_info['address']}/{ip_info['network_prefix']}" + ) + broadcast_addresses.add( + IPv4Address(interface.network.broadcast_address.exploded) + ) + return broadcast_addresses + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up network for Home Assistant.""" diff --git a/homeassistant/components/network/const.py b/homeassistant/components/network/const.py index 8b695a52e13..7e7401251fc 100644 --- a/homeassistant/components/network/const.py +++ b/homeassistant/components/network/const.py @@ -17,6 +17,7 @@ DEFAULT_CONFIGURED_ADAPTERS: list[str] = [] MDNS_TARGET_IP: Final = "224.0.0.251" PUBLIC_TARGET_IP: Final = "8.8.8.8" +IPV4_BROADCAST_ADDR: Final = "255.255.255.255" NETWORK_CONFIG_SCHEMA = vol.Schema( { diff --git a/tests/components/network/test_init.py b/tests/components/network/test_init.py index 70cee5f847c..12d317e826a 100644 --- a/tests/components/network/test_init.py +++ b/tests/components/network/test_init.py @@ -1,4 +1,5 @@ """Test the Network Configuration.""" +from ipaddress import IPv4Address from unittest.mock import MagicMock, Mock, patch import ifaddr @@ -552,3 +553,51 @@ async def test_async_get_source_ip_cannot_determine_target(hass, hass_storage): await hass.async_block_till_done() assert await network.async_get_source_ip(hass, MDNS_TARGET_IP) == "192.168.1.5" + + +async def test_async_get_ipv4_broadcast_addresses_default(hass, hass_storage): + """Test getting ipv4 broadcast addresses when only the default address is enabled.""" + hass_storage[STORAGE_KEY] = { + "version": STORAGE_VERSION, + "key": STORAGE_KEY, + "data": {ATTR_CONFIGURED_ADAPTERS: ["eth1"]}, + } + + with patch( + "homeassistant.components.network.util.socket.socket", + return_value=_mock_socket(["192.168.1.5"]), + ), patch( + "homeassistant.components.network.util.ifaddr.get_adapters", + return_value=_generate_mock_adapters(), + ): + assert await async_setup_component(hass, network.DOMAIN, {network.DOMAIN: {}}) + await hass.async_block_till_done() + + assert await network.async_get_ipv4_broadcast_addresses(hass) == { + IPv4Address("255.255.255.255") + } + + +async def test_async_get_ipv4_broadcast_addresses_multiple(hass, hass_storage): + """Test getting ipv4 broadcast addresses when multiple adapters are enabled.""" + hass_storage[STORAGE_KEY] = { + "version": STORAGE_VERSION, + "key": STORAGE_KEY, + "data": {ATTR_CONFIGURED_ADAPTERS: ["eth1", "vtun0"]}, + } + + with patch( + "homeassistant.components.network.util.socket.socket", + return_value=_mock_socket([_LOOPBACK_IPADDR]), + ), patch( + "homeassistant.components.network.util.ifaddr.get_adapters", + return_value=_generate_mock_adapters(), + ): + assert await async_setup_component(hass, network.DOMAIN, {network.DOMAIN: {}}) + await hass.async_block_till_done() + + assert await network.async_get_ipv4_broadcast_addresses(hass) == { + IPv4Address("255.255.255.255"), + IPv4Address("192.168.1.255"), + IPv4Address("169.254.255.255"), + } From 412ecacca3de897830d0bb37955e5e577ed103b9 Mon Sep 17 00:00:00 2001 From: Myles Eftos Date: Tue, 28 Sep 2021 17:03:51 +1000 Subject: [PATCH 651/843] Amberelectric (#56448) * Add Amber Electric integration * Linting * Fixing some type hinting * Adding docstrings * Removing files that shouldn't have been changed * Splitting out test helpers * Testing the price sensor * Testing Controlled load and feed in channels * Refactoring mocks * switching state for native_value and unit_of_measurement for native_unit_of_measurement * Fixing docstrings * Fixing requiremennts_all.txt * isort fixes * Fixing pylint errors * Omitting __init__.py from test coverage * Add missing config_flow tests * Adding more sensor tests * Applying suggested changes to __init.py__ * Refactor coordinator to return the data object with all of the relevent data already setup * Another coordinator refactor - Better use the dictionary for when we build the sensors * Removing first function * Refactoring sensor files to use entity descriptions, remove factory * Rounding renewable percentage, return icons correctly * Cleaning up translation strings * Fixing relative path, removing TODO * Coordintator tests now accept new (more accurate) fixtures * Using a description placeholder * Putting missing translations strings back in * tighten up the no site error logic - self._site_id should never be None at the point of loading async_step_site * Removing DEVICE_CLASS, replacing the units with AUD/kWh * Settings _attr_unique_id * Removing icon function (it's already the default) * Apply suggestions from code review Co-authored-by: Paulus Schoutsen * Adding strings.json * Tighter wrapping for try/except * Generating translations * Removing update_method - not needed as it's being overriden * Apply suggestions from code review Co-authored-by: Paulus Schoutsen * Fixing tests * Add missing description placeholder * Fix warning * changing name from update to update_data to match async_update_data * renaming [async_]update_data => [async_]update_price_data to avoid confusion * Creating too man renewable sensors * Override update method * Coordinator tests use _async_update_data * Using $/kWh as the units * Using isinstance instead of __class__ test. Removing a zero len check * Asserting self._sites in second step * Linting * Remove useless tests Co-authored-by: jan iversen Co-authored-by: Paulus Schoutsen --- .coveragerc | 1 + CODEOWNERS | 1 + .../components/amberelectric/__init__.py | 32 ++ .../components/amberelectric/config_flow.py | 120 ++++++++ .../components/amberelectric/const.py | 11 + .../components/amberelectric/coordinator.py | 110 +++++++ .../components/amberelectric/manifest.json | 13 + .../components/amberelectric/sensor.py | 234 +++++++++++++++ .../components/amberelectric/strings.json | 22 ++ .../amberelectric/translations/en.json | 22 ++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/amberelectric/helpers.py | 121 ++++++++ .../amberelectric/test_config_flow.py | 148 +++++++++ .../amberelectric/test_coordinator.py | 202 +++++++++++++ tests/components/amberelectric/test_sensor.py | 282 ++++++++++++++++++ 17 files changed, 1326 insertions(+) create mode 100644 homeassistant/components/amberelectric/__init__.py create mode 100644 homeassistant/components/amberelectric/config_flow.py create mode 100644 homeassistant/components/amberelectric/const.py create mode 100644 homeassistant/components/amberelectric/coordinator.py create mode 100644 homeassistant/components/amberelectric/manifest.json create mode 100644 homeassistant/components/amberelectric/sensor.py create mode 100644 homeassistant/components/amberelectric/strings.json create mode 100644 homeassistant/components/amberelectric/translations/en.json create mode 100644 tests/components/amberelectric/helpers.py create mode 100644 tests/components/amberelectric/test_config_flow.py create mode 100644 tests/components/amberelectric/test_coordinator.py create mode 100644 tests/components/amberelectric/test_sensor.py diff --git a/.coveragerc b/.coveragerc index e21ea37fe06..4aa9565a5f2 100644 --- a/.coveragerc +++ b/.coveragerc @@ -51,6 +51,7 @@ omit = homeassistant/components/alarmdecoder/sensor.py homeassistant/components/alpha_vantage/sensor.py homeassistant/components/amazon_polly/* + homeassistant/components/amberelectric/__init__.py homeassistant/components/ambiclimate/climate.py homeassistant/components/ambient_station/* homeassistant/components/amcrest/* diff --git a/CODEOWNERS b/CODEOWNERS index cea06b6b361..01bb9abf77f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -37,6 +37,7 @@ homeassistant/components/alexa/* @home-assistant/cloud @ochlocracy homeassistant/components/almond/* @gcampax @balloob homeassistant/components/alpha_vantage/* @fabaff homeassistant/components/ambee/* @frenck +homeassistant/components/amberelectric/* @madpilot homeassistant/components/ambiclimate/* @danielhiversen homeassistant/components/ambient_station/* @bachya homeassistant/components/amcrest/* @flacjacket diff --git a/homeassistant/components/amberelectric/__init__.py b/homeassistant/components/amberelectric/__init__.py new file mode 100644 index 00000000000..0d39077f2f1 --- /dev/null +++ b/homeassistant/components/amberelectric/__init__.py @@ -0,0 +1,32 @@ +"""Support for Amber Electric.""" + +from amberelectric import Configuration +from amberelectric.api import amber_api + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import CONF_API_TOKEN, CONF_SITE_ID, DOMAIN, PLATFORMS +from .coordinator import AmberUpdateCoordinator + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Amber Electric from a config entry.""" + configuration = Configuration(access_token=entry.data[CONF_API_TOKEN]) + api_instance = amber_api.AmberApi.create(configuration) + site_id = entry.data[CONF_SITE_ID] + + coordinator = AmberUpdateCoordinator(hass, api_instance, site_id) + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/amberelectric/config_flow.py b/homeassistant/components/amberelectric/config_flow.py new file mode 100644 index 00000000000..efb5ddfb931 --- /dev/null +++ b/homeassistant/components/amberelectric/config_flow.py @@ -0,0 +1,120 @@ +"""Config flow for the Amber Electric integration.""" +from __future__ import annotations + +from typing import Any + +import amberelectric +from amberelectric.api import amber_api +from amberelectric.model.site import Site +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_API_TOKEN + +from .const import CONF_SITE_ID, CONF_SITE_NAME, CONF_SITE_NMI, DOMAIN + +API_URL = "https://app.amber.com.au/developers" + + +class AmberElectricConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self._errors: dict[str, str] = {} + self._sites: list[Site] | None = None + self._api_token: str | None = None + + def _fetch_sites(self, token: str) -> list[Site] | None: + configuration = amberelectric.Configuration(access_token=token) + api = amber_api.AmberApi.create(configuration) + + try: + sites = api.get_sites() + if len(sites) == 0: + self._errors[CONF_API_TOKEN] = "no_site" + return None + return sites + except amberelectric.ApiException as api_exception: + if api_exception.status == 403: + self._errors[CONF_API_TOKEN] = "invalid_api_token" + else: + self._errors[CONF_API_TOKEN] = "unknown_error" + return None + + async def async_step_user(self, user_input: dict[str, Any] | None = None): + """Step when user initializes a integration.""" + self._errors = {} + self._sites = None + self._api_token = None + + if user_input is not None: + token = user_input[CONF_API_TOKEN] + self._sites = await self.hass.async_add_executor_job( + self._fetch_sites, token + ) + + if self._sites is not None: + self._api_token = token + return await self.async_step_site() + + else: + user_input = {CONF_API_TOKEN: ""} + + return self.async_show_form( + step_id="user", + description_placeholders={"api_url": API_URL}, + data_schema=vol.Schema( + { + vol.Required( + CONF_API_TOKEN, default=user_input[CONF_API_TOKEN] + ): str, + } + ), + errors=self._errors, + ) + + async def async_step_site(self, user_input: dict[str, Any] = None): + """Step to select site.""" + self._errors = {} + + assert self._sites is not None + + api_token = self._api_token + if user_input is not None: + site_nmi = user_input[CONF_SITE_NMI] + sites = [site for site in self._sites if site.nmi == site_nmi] + site = sites[0] + site_id = site.id + name = user_input.get(CONF_SITE_NAME, site_id) + return self.async_create_entry( + title=name, + data={ + CONF_SITE_ID: site_id, + CONF_API_TOKEN: api_token, + CONF_SITE_NMI: site.nmi, + }, + ) + + user_input = { + CONF_API_TOKEN: api_token, + CONF_SITE_NMI: "", + CONF_SITE_NAME: "", + } + + return self.async_show_form( + step_id="site", + data_schema=vol.Schema( + { + vol.Required( + CONF_SITE_NMI, default=user_input[CONF_SITE_NMI] + ): vol.In([site.nmi for site in self._sites]), + vol.Optional( + CONF_SITE_NAME, default=user_input[CONF_SITE_NAME] + ): str, + } + ), + errors=self._errors, + ) diff --git a/homeassistant/components/amberelectric/const.py b/homeassistant/components/amberelectric/const.py new file mode 100644 index 00000000000..23c92334da3 --- /dev/null +++ b/homeassistant/components/amberelectric/const.py @@ -0,0 +1,11 @@ +"""Amber Electric Constants.""" +import logging + +DOMAIN = "amberelectric" +CONF_API_TOKEN = "api_token" +CONF_SITE_NAME = "site_name" +CONF_SITE_ID = "site_id" +CONF_SITE_NMI = "site_nmi" + +LOGGER = logging.getLogger(__package__) +PLATFORMS = ["sensor"] diff --git a/homeassistant/components/amberelectric/coordinator.py b/homeassistant/components/amberelectric/coordinator.py new file mode 100644 index 00000000000..6db1d529fb3 --- /dev/null +++ b/homeassistant/components/amberelectric/coordinator.py @@ -0,0 +1,110 @@ +"""Amber Electric Coordinator.""" +from __future__ import annotations + +from datetime import timedelta +from typing import Any + +from amberelectric import ApiException +from amberelectric.api import amber_api +from amberelectric.model.actual_interval import ActualInterval +from amberelectric.model.channel import ChannelType +from amberelectric.model.current_interval import CurrentInterval +from amberelectric.model.forecast_interval import ForecastInterval + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import LOGGER + + +def is_current(interval: ActualInterval | CurrentInterval | ForecastInterval) -> bool: + """Return true if the supplied interval is a CurrentInterval.""" + return isinstance(interval, CurrentInterval) + + +def is_forecast(interval: ActualInterval | CurrentInterval | ForecastInterval) -> bool: + """Return true if the supplied interval is a ForecastInterval.""" + return isinstance(interval, ForecastInterval) + + +def is_general(interval: ActualInterval | CurrentInterval | ForecastInterval) -> bool: + """Return true if the supplied interval is on the general channel.""" + return interval.channel_type == ChannelType.GENERAL + + +def is_controlled_load( + interval: ActualInterval | CurrentInterval | ForecastInterval, +) -> bool: + """Return true if the supplied interval is on the controlled load channel.""" + return interval.channel_type == ChannelType.CONTROLLED_LOAD + + +def is_feed_in(interval: ActualInterval | CurrentInterval | ForecastInterval) -> bool: + """Return true if the supplied interval is on the feed in channel.""" + return interval.channel_type == ChannelType.FEED_IN + + +class AmberUpdateCoordinator(DataUpdateCoordinator): + """AmberUpdateCoordinator - In charge of downloading the data for a site, which all the sensors read.""" + + def __init__( + self, hass: HomeAssistant, api: amber_api.AmberApi, site_id: str + ) -> None: + """Initialise the data service.""" + super().__init__( + hass, + LOGGER, + name="amberelectric", + update_interval=timedelta(minutes=1), + ) + self._api = api + self.site_id = site_id + + def update_price_data(self) -> dict[str, dict[str, Any]]: + """Update callback.""" + + result: dict[str, dict[str, Any]] = { + "current": {}, + "forecasts": {}, + "grid": {}, + } + try: + data = self._api.get_current_price(self.site_id, next=48) + except ApiException as api_exception: + raise UpdateFailed("Missing price data, skipping update") from api_exception + + current = [interval for interval in data if is_current(interval)] + forecasts = [interval for interval in data if is_forecast(interval)] + general = [interval for interval in current if is_general(interval)] + + if len(general) == 0: + raise UpdateFailed("No general channel configured") + + result["current"]["general"] = general[0] + result["forecasts"]["general"] = [ + interval for interval in forecasts if is_general(interval) + ] + result["grid"]["renewables"] = round(general[0].renewables) + + controlled_load = [ + interval for interval in current if is_controlled_load(interval) + ] + if controlled_load: + result["current"]["controlled_load"] = controlled_load[0] + result["forecasts"]["controlled_load"] = [ + interval for interval in forecasts if is_controlled_load(interval) + ] + + feed_in = [interval for interval in current if is_feed_in(interval)] + if feed_in: + result["current"]["feed_in"] = feed_in[0] + result["forecasts"]["feed_in"] = [ + interval for interval in forecasts if is_feed_in(interval) + ] + + LOGGER.debug("Fetched new Amber data: %s", data) + return result + + async def _async_update_data(self) -> dict[str, Any]: + """Async update wrapper.""" + return await self.hass.async_add_executor_job(self.update_price_data) diff --git a/homeassistant/components/amberelectric/manifest.json b/homeassistant/components/amberelectric/manifest.json new file mode 100644 index 00000000000..6dc79513e55 --- /dev/null +++ b/homeassistant/components/amberelectric/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "amberelectric", + "name": "Amber Electric", + "documentation": "https://www.home-assistant.io/integrations/amberelectric", + "config_flow": true, + "codeowners": [ + "@madpilot" + ], + "requirements": [ + "amberelectric==1.0.3" + ], + "iot_class": "cloud_polling" +} \ No newline at end of file diff --git a/homeassistant/components/amberelectric/sensor.py b/homeassistant/components/amberelectric/sensor.py new file mode 100644 index 00000000000..079d65541fe --- /dev/null +++ b/homeassistant/components/amberelectric/sensor.py @@ -0,0 +1,234 @@ +"""Amber Electric Sensor definitions.""" + +# There are three types of sensor: Current, Forecast and Grid +# Current and forecast will create general, controlled load and feed in as required +# At the moment renewables in the only grid sensor. + + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from amberelectric.model.channel import ChannelType +from amberelectric.model.current_interval import CurrentInterval +from amberelectric.model.forecast_interval import ForecastInterval + +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_ATTRIBUTION, CURRENCY_DOLLAR, ENERGY_KILO_WATT_HOUR +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import AmberUpdateCoordinator + +ATTRIBUTION = "Data provided by Amber Electric" + +ICONS = { + "general": "mdi:transmission-tower", + "controlled_load": "mdi:clock-outline", + "feed_in": "mdi:solar-power", +} + +UNIT = f"{CURRENCY_DOLLAR}/{ENERGY_KILO_WATT_HOUR}" + + +def friendly_channel_type(channel_type: str) -> str: + """Return a human readable version of the channel type.""" + if channel_type == "controlled_load": + return "Controlled Load" + if channel_type == "feed_in": + return "Feed In" + return "General" + + +class AmberSensor(CoordinatorEntity, SensorEntity): + """Amber Base Sensor.""" + + def __init__( + self, + coordinator: AmberUpdateCoordinator, + description: SensorEntityDescription, + channel_type: ChannelType, + ) -> None: + """Initialize the Sensor.""" + super().__init__(coordinator) + self.site_id = coordinator.site_id + self.entity_description = description + self.channel_type = channel_type + + @property + def unique_id(self) -> None: + """Return a unique id for each sensors.""" + self._attr_unique_id = ( + f"{self.site_id}-{self.entity_description.key}-{self.channel_type}" + ) + + +class AmberPriceSensor(AmberSensor): + """Amber Price Sensor.""" + + @property + def native_value(self) -> str | None: + """Return the current price in $/kWh.""" + interval = self.coordinator.data[self.entity_description.key][self.channel_type] + + if interval.channel_type == ChannelType.FEED_IN: + return round(interval.per_kwh, 0) / 100 * -1 + return round(interval.per_kwh, 0) / 100 + + @property + def device_state_attributes(self) -> Mapping[str, Any] | None: + """Return additional pieces of information about the price.""" + interval = self.coordinator.data[self.entity_description.key][self.channel_type] + + data: dict[str, Any] = {ATTR_ATTRIBUTION: ATTRIBUTION} + if interval is None: + return data + + data["duration"] = interval.duration + data["date"] = interval.date.isoformat() + data["per_kwh"] = round(interval.per_kwh) + if interval.channel_type == ChannelType.FEED_IN: + data["per_kwh"] = data["per_kwh"] * -1 + data["nem_date"] = interval.nem_time.isoformat() + data["spot_per_kwh"] = round(interval.spot_per_kwh) + data["start_time"] = interval.start_time.isoformat() + data["end_time"] = interval.end_time.isoformat() + data["renewables"] = round(interval.renewables) + data["estimate"] = interval.estimate + data["spike_status"] = interval.spike_status.value + data["channel_type"] = interval.channel_type.value + + if interval.range is not None: + data["range_min"] = interval.range.min + data["range_max"] = interval.range.max + + return data + + +class AmberForecastSensor(AmberSensor): + """Amber Forecast Sensor.""" + + @property + def native_value(self) -> str | None: + """Return the first forecast price in $/kWh.""" + intervals = self.coordinator.data[self.entity_description.key][ + self.channel_type + ] + interval = intervals[0] + + if interval.channel_type == ChannelType.FEED_IN: + return round(interval.per_kwh, 0) / 100 * -1 + return round(interval.per_kwh, 0) / 100 + + @property + def device_state_attributes(self) -> Mapping[str, Any] | None: + """Return additional pieces of information about the price.""" + intervals = self.coordinator.data[self.entity_description.key][ + self.channel_type + ] + + data = { + "forecasts": [], + "channel_type": intervals[0].channel_type.value, + ATTR_ATTRIBUTION: ATTRIBUTION, + } + + for interval in intervals: + datum = {} + datum["duration"] = interval.duration + datum["date"] = interval.date.isoformat() + datum["nem_date"] = interval.nem_time.isoformat() + datum["per_kwh"] = round(interval.per_kwh) + if interval.channel_type == ChannelType.FEED_IN: + datum["per_kwh"] = datum["per_kwh"] * -1 + datum["spot_per_kwh"] = round(interval.spot_per_kwh) + datum["start_time"] = interval.start_time.isoformat() + datum["end_time"] = interval.end_time.isoformat() + datum["renewables"] = round(interval.renewables) + datum["spike_status"] = interval.spike_status.value + + if interval.range is not None: + datum["range_min"] = interval.range.min + datum["range_max"] = interval.range.max + + data["forecasts"].append(datum) + + return data + + +class AmberGridSensor(CoordinatorEntity, SensorEntity): + """Sensor to show single grid specific values.""" + + def __init__( + self, + coordinator: AmberUpdateCoordinator, + description: SensorEntityDescription, + ) -> None: + """Initialize the Sensor.""" + super().__init__(coordinator) + self.site_id = coordinator.site_id + self.entity_description = description + self._attr_device_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} + self._attr_unique_id = f"{coordinator.site_id}-{description.key}" + + @property + def unique_id(self) -> None: + """Return a unique id for each sensors.""" + self._attr_unique_id = f"{self.site_id}-{self.entity_description.key}" + + @property + def native_value(self) -> str | None: + """Return the value of the sensor.""" + return self.coordinator.data["grid"][self.entity_description.key] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a config entry.""" + coordinator: AmberUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + current: dict[str, CurrentInterval] = coordinator.data["current"] + forecasts: dict[str, list[ForecastInterval]] = coordinator.data["forecasts"] + + entities: list = [] + for channel_type in current: + description = SensorEntityDescription( + key="current", + name=f"{entry.title} - {friendly_channel_type(channel_type)} Price", + native_unit_of_measurement=UNIT, + state_class=STATE_CLASS_MEASUREMENT, + icon=ICONS[channel_type], + ) + entities.append(AmberPriceSensor(coordinator, description, channel_type)) + + for channel_type in forecasts: + description = SensorEntityDescription( + key="forecasts", + name=f"{entry.title} - {friendly_channel_type(channel_type)} Forecast", + native_unit_of_measurement=UNIT, + state_class=STATE_CLASS_MEASUREMENT, + icon=ICONS[channel_type], + ) + entities.append(AmberForecastSensor(coordinator, description, channel_type)) + + renewables_description = SensorEntityDescription( + key="renewables", + name=f"{entry.title} - Renewables", + native_unit_of_measurement="%", + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:solar-power", + ) + entities.append(AmberGridSensor(coordinator, renewables_description)) + + async_add_entities(entities) diff --git a/homeassistant/components/amberelectric/strings.json b/homeassistant/components/amberelectric/strings.json new file mode 100644 index 00000000000..cdbff2022b3 --- /dev/null +++ b/homeassistant/components/amberelectric/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_token": "API Token", + "site_id": "Site ID" + }, + "title": "Amber Electric", + "description": "Go to {api_url} to generate an API key" + }, + "site": { + "data": { + "site_nmi": "Site NMI", + "site_name": "Site Name" + }, + "title": "Amber Electric", + "description": "Select the NMI of the site you would like to add" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/amberelectric/translations/en.json b/homeassistant/components/amberelectric/translations/en.json new file mode 100644 index 00000000000..60c7caae456 --- /dev/null +++ b/homeassistant/components/amberelectric/translations/en.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "site": { + "data": { + "site_name": "Site Name", + "site_nmi": "Site NMI" + }, + "description": "Select the NMI of the site you would like to add", + "title": "Amber Electric" + }, + "user": { + "data": { + "api_token": "API Token", + "site_id": "Site ID" + }, + "description": "Go to {api_url} to generate an API key", + "title": "Amber Electric" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index c69815d5e6c..da4079fe49f 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -22,6 +22,7 @@ FLOWS = [ "alarmdecoder", "almond", "ambee", + "amberelectric", "ambiclimate", "ambient_station", "apple_tv", diff --git a/requirements_all.txt b/requirements_all.txt index 518bbf0868b..81d13353538 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -281,6 +281,9 @@ alpha_vantage==2.3.1 # homeassistant.components.ambee ambee==0.3.0 +# homeassistant.components.amberelectric +amberelectric==1.0.3 + # homeassistant.components.ambiclimate ambiclimate==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4272bb87506..415eb9daadf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -199,6 +199,9 @@ airtouch4pyapi==1.0.5 # homeassistant.components.ambee ambee==0.3.0 +# homeassistant.components.amberelectric +amberelectric==1.0.3 + # homeassistant.components.ambiclimate ambiclimate==0.2.1 diff --git a/tests/components/amberelectric/helpers.py b/tests/components/amberelectric/helpers.py new file mode 100644 index 00000000000..fbb1ebfd7ad --- /dev/null +++ b/tests/components/amberelectric/helpers.py @@ -0,0 +1,121 @@ +"""Some common test functions for testing Amber components.""" + +from datetime import datetime, timedelta + +from amberelectric.model.actual_interval import ActualInterval +from amberelectric.model.channel import ChannelType +from amberelectric.model.current_interval import CurrentInterval +from amberelectric.model.forecast_interval import ForecastInterval +from amberelectric.model.interval import SpikeStatus +from dateutil import parser + + +def generate_actual_interval( + channel_type: ChannelType, end_time: datetime +) -> ActualInterval: + """Generate a mock actual interval.""" + start_time = end_time - timedelta(minutes=30) + return ActualInterval( + duration=30, + spot_per_kwh=1.0, + per_kwh=8.0, + date=start_time.date(), + nem_time=end_time, + start_time=start_time, + end_time=end_time, + renewables=50, + channel_type=channel_type.value, + spike_status=SpikeStatus.NO_SPIKE.value, + ) + + +def generate_current_interval( + channel_type: ChannelType, end_time: datetime +) -> CurrentInterval: + """Generate a mock current price.""" + start_time = end_time - timedelta(minutes=30) + return CurrentInterval( + duration=30, + spot_per_kwh=1.0, + per_kwh=8.0, + date=start_time.date(), + nem_time=end_time, + start_time=start_time, + end_time=end_time, + renewables=50.6, + channel_type=channel_type.value, + spike_status=SpikeStatus.NO_SPIKE.value, + estimate=True, + ) + + +def generate_forecast_interval( + channel_type: ChannelType, end_time: datetime +) -> ForecastInterval: + """Generate a mock forecast interval.""" + start_time = end_time - timedelta(minutes=30) + return ForecastInterval( + duration=30, + spot_per_kwh=1.1, + per_kwh=8.8, + date=start_time.date(), + nem_time=end_time, + start_time=start_time, + end_time=end_time, + renewables=50, + channel_type=channel_type.value, + spike_status=SpikeStatus.NO_SPIKE.value, + estimate=True, + ) + + +GENERAL_ONLY_SITE_ID = "01FG2K6V5TB6X9W0EWPPMZD6MJ" +GENERAL_AND_CONTROLLED_SITE_ID = "01FG2MC8RF7GBC4KJXP3YFZ162" +GENERAL_AND_FEED_IN_SITE_ID = "01FG2MCD8KTRZR9MNNW84VP50S" +GENERAL_AND_CONTROLLED_FEED_IN_SITE_ID = "01FG2MCD8KTRZR9MNNW847S50S" + +GENERAL_CHANNEL = [ + generate_current_interval( + ChannelType.GENERAL, parser.parse("2021-09-21T08:30:00+10:00") + ), + generate_forecast_interval( + ChannelType.GENERAL, parser.parse("2021-09-21T09:00:00+10:00") + ), + generate_forecast_interval( + ChannelType.GENERAL, parser.parse("2021-09-21T09:30:00+10:00") + ), + generate_forecast_interval( + ChannelType.GENERAL, parser.parse("2021-09-21T10:00:00+10:00") + ), +] + +CONTROLLED_LOAD_CHANNEL = [ + generate_current_interval( + ChannelType.CONTROLLED_LOAD, parser.parse("2021-09-21T08:30:00+10:00") + ), + generate_forecast_interval( + ChannelType.CONTROLLED_LOAD, parser.parse("2021-09-21T09:00:00+10:00") + ), + generate_forecast_interval( + ChannelType.CONTROLLED_LOAD, parser.parse("2021-09-21T09:30:00+10:00") + ), + generate_forecast_interval( + ChannelType.CONTROLLED_LOAD, parser.parse("2021-09-21T10:00:00+10:00") + ), +] + + +FEED_IN_CHANNEL = [ + generate_current_interval( + ChannelType.FEED_IN, parser.parse("2021-09-21T08:30:00+10:00") + ), + generate_forecast_interval( + ChannelType.FEED_IN, parser.parse("2021-09-21T09:00:00+10:00") + ), + generate_forecast_interval( + ChannelType.FEED_IN, parser.parse("2021-09-21T09:30:00+10:00") + ), + generate_forecast_interval( + ChannelType.FEED_IN, parser.parse("2021-09-21T10:00:00+10:00") + ), +] diff --git a/tests/components/amberelectric/test_config_flow.py b/tests/components/amberelectric/test_config_flow.py new file mode 100644 index 00000000000..71c40b4cf75 --- /dev/null +++ b/tests/components/amberelectric/test_config_flow.py @@ -0,0 +1,148 @@ +"""Tests for the Amber config flow.""" + +from typing import Generator +from unittest.mock import Mock, patch + +from amberelectric import ApiException +from amberelectric.model.site import Site +import pytest + +from homeassistant import data_entry_flow +from homeassistant.components.amberelectric.const import ( + CONF_SITE_ID, + CONF_SITE_NAME, + CONF_SITE_NMI, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_API_TOKEN +from homeassistant.core import HomeAssistant + +API_KEY = "psk_123456789" + + +@pytest.fixture(name="invalid_key_api") +def mock_invalid_key_api() -> Generator: + """Return an authentication error.""" + instance = Mock() + instance.get_sites.side_effect = ApiException(status=403) + + with patch("amberelectric.api.AmberApi.create", return_value=instance): + yield instance + + +@pytest.fixture(name="api_error") +def mock_api_error() -> Generator: + """Return an authentication error.""" + instance = Mock() + instance.get_sites.side_effect = ApiException(status=500) + + with patch("amberelectric.api.AmberApi.create", return_value=instance): + yield instance + + +@pytest.fixture(name="single_site_api") +def mock_single_site_api() -> Generator: + """Return a single site.""" + instance = Mock() + site = Site("01FG0AGP818PXK0DWHXJRRT2DH", "11111111111", []) + instance.get_sites.return_value = [site] + + with patch("amberelectric.api.AmberApi.create", return_value=instance): + yield instance + + +@pytest.fixture(name="no_site_api") +def mock_no_site_api() -> Generator: + """Return no site.""" + instance = Mock() + instance.get_sites.return_value = [] + + with patch("amberelectric.api.AmberApi.create", return_value=instance): + yield instance + + +async def test_single_site(hass: HomeAssistant, single_site_api: Mock) -> None: + """Test single site.""" + initial_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert initial_result.get("type") == data_entry_flow.RESULT_TYPE_FORM + assert initial_result.get("step_id") == "user" + + # Test filling in API key + enter_api_key_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_API_TOKEN: API_KEY}, + ) + assert enter_api_key_result.get("type") == data_entry_flow.RESULT_TYPE_FORM + assert enter_api_key_result.get("step_id") == "site" + + select_site_result = await hass.config_entries.flow.async_configure( + enter_api_key_result["flow_id"], + {CONF_SITE_NMI: "11111111111", CONF_SITE_NAME: "Home"}, + ) + + # Show available sites + assert select_site_result.get("type") == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert select_site_result.get("title") == "Home" + data = select_site_result.get("data") + assert data + assert data[CONF_API_TOKEN] == API_KEY + assert data[CONF_SITE_ID] == "01FG0AGP818PXK0DWHXJRRT2DH" + assert data[CONF_SITE_NMI] == "11111111111" + + +async def test_no_site(hass: HomeAssistant, no_site_api: Mock) -> None: + """Test no site.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_API_TOKEN: "psk_123456789"}, + ) + + assert result.get("type") == data_entry_flow.RESULT_TYPE_FORM + # Goes back to the user step + assert result.get("step_id") == "user" + assert result.get("errors") == {"api_token": "no_site"} + + +async def test_invalid_key(hass: HomeAssistant, invalid_key_api: Mock) -> None: + """Test invalid api key.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result.get("type") == data_entry_flow.RESULT_TYPE_FORM + assert result.get("step_id") == "user" + + # Test filling in API key + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_API_TOKEN: "psk_123456789"}, + ) + assert result.get("type") == data_entry_flow.RESULT_TYPE_FORM + # Goes back to the user step + assert result.get("step_id") == "user" + assert result.get("errors") == {"api_token": "invalid_api_token"} + + +async def test_unknown_error(hass: HomeAssistant, api_error: Mock) -> None: + """Test invalid api key.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result.get("type") == data_entry_flow.RESULT_TYPE_FORM + assert result.get("step_id") == "user" + + # Test filling in API key + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_API_TOKEN: "psk_123456789"}, + ) + assert result.get("type") == data_entry_flow.RESULT_TYPE_FORM + # Goes back to the user step + assert result.get("step_id") == "user" + assert result.get("errors") == {"api_token": "unknown_error"} diff --git a/tests/components/amberelectric/test_coordinator.py b/tests/components/amberelectric/test_coordinator.py new file mode 100644 index 00000000000..523172e2866 --- /dev/null +++ b/tests/components/amberelectric/test_coordinator.py @@ -0,0 +1,202 @@ +"""Tests for the Amber Electric Data Coordinator.""" +from typing import Generator +from unittest.mock import Mock, patch + +from amberelectric import ApiException +from amberelectric.model.channel import Channel, ChannelType +from amberelectric.model.site import Site +import pytest + +from homeassistant.components.amberelectric.coordinator import AmberUpdateCoordinator +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import UpdateFailed + +from tests.components.amberelectric.helpers import ( + CONTROLLED_LOAD_CHANNEL, + FEED_IN_CHANNEL, + GENERAL_AND_CONTROLLED_SITE_ID, + GENERAL_AND_FEED_IN_SITE_ID, + GENERAL_CHANNEL, + GENERAL_ONLY_SITE_ID, +) + + +@pytest.fixture(name="current_price_api") +def mock_api_current_price() -> Generator: + """Return an authentication error.""" + instance = Mock() + + general_site = Site( + GENERAL_ONLY_SITE_ID, + "11111111111", + [Channel(identifier="E1", type=ChannelType.GENERAL)], + ) + general_and_controlled_load = Site( + GENERAL_AND_CONTROLLED_SITE_ID, + "11111111112", + [ + Channel(identifier="E1", type=ChannelType.GENERAL), + Channel(identifier="E2", type=ChannelType.CONTROLLED_LOAD), + ], + ) + general_and_feed_in = Site( + GENERAL_AND_FEED_IN_SITE_ID, + "11111111113", + [ + Channel(identifier="E1", type=ChannelType.GENERAL), + Channel(identifier="E2", type=ChannelType.FEED_IN), + ], + ) + instance.get_sites.return_value = [ + general_site, + general_and_controlled_load, + general_and_feed_in, + ] + + with patch("amberelectric.api.AmberApi.create", return_value=instance): + yield instance + + +async def test_fetch_general_site(hass: HomeAssistant, current_price_api: Mock) -> None: + """Test fetching a site with only a general channel.""" + + current_price_api.get_current_price.return_value = GENERAL_CHANNEL + data_service = AmberUpdateCoordinator(hass, current_price_api, GENERAL_ONLY_SITE_ID) + result = await data_service._async_update_data() + + current_price_api.get_current_price.assert_called_with( + GENERAL_ONLY_SITE_ID, next=48 + ) + + assert result["current"].get("general") == GENERAL_CHANNEL[0] + assert result["forecasts"].get("general") == [ + GENERAL_CHANNEL[1], + GENERAL_CHANNEL[2], + GENERAL_CHANNEL[3], + ] + assert result["current"].get("controlled_load") is None + assert result["forecasts"].get("controlled_load") is None + assert result["current"].get("feed_in") is None + assert result["forecasts"].get("feed_in") is None + assert result["grid"]["renewables"] == round(GENERAL_CHANNEL[0].renewables) + + +async def test_fetch_no_general_site( + hass: HomeAssistant, current_price_api: Mock +) -> None: + """Test fetching a site with no general channel.""" + + current_price_api.get_current_price.return_value = CONTROLLED_LOAD_CHANNEL + data_service = AmberUpdateCoordinator(hass, current_price_api, GENERAL_ONLY_SITE_ID) + with pytest.raises(UpdateFailed): + await data_service._async_update_data() + + current_price_api.get_current_price.assert_called_with( + GENERAL_ONLY_SITE_ID, next=48 + ) + + +async def test_fetch_api_error(hass: HomeAssistant, current_price_api: Mock) -> None: + """Test that the old values are maintained if a second call fails.""" + + current_price_api.get_current_price.return_value = GENERAL_CHANNEL + data_service = AmberUpdateCoordinator(hass, current_price_api, GENERAL_ONLY_SITE_ID) + result = await data_service._async_update_data() + + current_price_api.get_current_price.assert_called_with( + GENERAL_ONLY_SITE_ID, next=48 + ) + + assert result["current"].get("general") == GENERAL_CHANNEL[0] + assert result["forecasts"].get("general") == [ + GENERAL_CHANNEL[1], + GENERAL_CHANNEL[2], + GENERAL_CHANNEL[3], + ] + assert result["current"].get("controlled_load") is None + assert result["forecasts"].get("controlled_load") is None + assert result["current"].get("feed_in") is None + assert result["forecasts"].get("feed_in") is None + assert result["grid"]["renewables"] == round(GENERAL_CHANNEL[0].renewables) + + current_price_api.get_current_price.side_effect = ApiException(status=403) + with pytest.raises(UpdateFailed): + await data_service._async_update_data() + + assert result["current"].get("general") == GENERAL_CHANNEL[0] + assert result["forecasts"].get("general") == [ + GENERAL_CHANNEL[1], + GENERAL_CHANNEL[2], + GENERAL_CHANNEL[3], + ] + assert result["current"].get("controlled_load") is None + assert result["forecasts"].get("controlled_load") is None + assert result["current"].get("feed_in") is None + assert result["forecasts"].get("feed_in") is None + assert result["grid"]["renewables"] == round(GENERAL_CHANNEL[0].renewables) + + +async def test_fetch_general_and_controlled_load_site( + hass: HomeAssistant, current_price_api: Mock +) -> None: + """Test fetching a site with a general and controlled load channel.""" + + current_price_api.get_current_price.return_value = ( + GENERAL_CHANNEL + CONTROLLED_LOAD_CHANNEL + ) + data_service = AmberUpdateCoordinator( + hass, current_price_api, GENERAL_AND_CONTROLLED_SITE_ID + ) + result = await data_service._async_update_data() + + current_price_api.get_current_price.assert_called_with( + GENERAL_AND_CONTROLLED_SITE_ID, next=48 + ) + + assert result["current"].get("general") == GENERAL_CHANNEL[0] + assert result["forecasts"].get("general") == [ + GENERAL_CHANNEL[1], + GENERAL_CHANNEL[2], + GENERAL_CHANNEL[3], + ] + assert result["current"].get("controlled_load") is CONTROLLED_LOAD_CHANNEL[0] + assert result["forecasts"].get("controlled_load") == [ + CONTROLLED_LOAD_CHANNEL[1], + CONTROLLED_LOAD_CHANNEL[2], + CONTROLLED_LOAD_CHANNEL[3], + ] + assert result["current"].get("feed_in") is None + assert result["forecasts"].get("feed_in") is None + assert result["grid"]["renewables"] == round(GENERAL_CHANNEL[0].renewables) + + +async def test_fetch_general_and_feed_in_site( + hass: HomeAssistant, current_price_api: Mock +) -> None: + """Test fetching a site with a general and feed_in channel.""" + + current_price_api.get_current_price.return_value = GENERAL_CHANNEL + FEED_IN_CHANNEL + data_service = AmberUpdateCoordinator( + hass, current_price_api, GENERAL_AND_FEED_IN_SITE_ID + ) + result = await data_service._async_update_data() + + current_price_api.get_current_price.assert_called_with( + GENERAL_AND_FEED_IN_SITE_ID, next=48 + ) + + assert result["current"].get("general") == GENERAL_CHANNEL[0] + assert result["forecasts"].get("general") == [ + GENERAL_CHANNEL[1], + GENERAL_CHANNEL[2], + GENERAL_CHANNEL[3], + ] + assert result["current"].get("controlled_load") is None + assert result["forecasts"].get("controlled_load") is None + assert result["current"].get("feed_in") is FEED_IN_CHANNEL[0] + assert result["forecasts"].get("feed_in") == [ + FEED_IN_CHANNEL[1], + FEED_IN_CHANNEL[2], + FEED_IN_CHANNEL[3], + ] + assert result["grid"]["renewables"] == round(GENERAL_CHANNEL[0].renewables) diff --git a/tests/components/amberelectric/test_sensor.py b/tests/components/amberelectric/test_sensor.py new file mode 100644 index 00000000000..20a50658abb --- /dev/null +++ b/tests/components/amberelectric/test_sensor.py @@ -0,0 +1,282 @@ +"""Test the Amber Electric Sensors.""" +from typing import AsyncGenerator, List +from unittest.mock import Mock, patch + +from amberelectric.model.current_interval import CurrentInterval +from amberelectric.model.range import Range +import pytest + +from homeassistant.components.amberelectric.const import ( + CONF_API_TOKEN, + CONF_SITE_ID, + CONF_SITE_NAME, + DOMAIN, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry +from tests.components.amberelectric.helpers import ( + CONTROLLED_LOAD_CHANNEL, + FEED_IN_CHANNEL, + GENERAL_AND_CONTROLLED_SITE_ID, + GENERAL_AND_FEED_IN_SITE_ID, + GENERAL_CHANNEL, + GENERAL_ONLY_SITE_ID, +) + +MOCK_API_TOKEN = "psk_0000000000000000" + + +@pytest.fixture +async def setup_general(hass) -> AsyncGenerator: + """Set up general channel.""" + MockConfigEntry( + domain="amberelectric", + data={ + CONF_SITE_NAME: "mock_title", + CONF_API_TOKEN: MOCK_API_TOKEN, + CONF_SITE_ID: GENERAL_ONLY_SITE_ID, + }, + ).add_to_hass(hass) + + instance = Mock() + with patch( + "amberelectric.api.AmberApi.create", + return_value=instance, + ) as mock_update: + instance.get_current_price = Mock(return_value=GENERAL_CHANNEL) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + yield mock_update.return_value + + +@pytest.fixture +async def setup_general_and_controlled_load(hass) -> AsyncGenerator: + """Set up general channel and controller load channel.""" + MockConfigEntry( + domain="amberelectric", + data={ + CONF_API_TOKEN: MOCK_API_TOKEN, + CONF_SITE_ID: GENERAL_AND_CONTROLLED_SITE_ID, + }, + ).add_to_hass(hass) + + instance = Mock() + with patch( + "amberelectric.api.AmberApi.create", + return_value=instance, + ) as mock_update: + instance.get_current_price = Mock( + return_value=GENERAL_CHANNEL + CONTROLLED_LOAD_CHANNEL + ) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + yield mock_update.return_value + + +@pytest.fixture +async def setup_general_and_feed_in(hass) -> AsyncGenerator: + """Set up general channel and feed in channel.""" + MockConfigEntry( + domain="amberelectric", + data={ + CONF_API_TOKEN: MOCK_API_TOKEN, + CONF_SITE_ID: GENERAL_AND_FEED_IN_SITE_ID, + }, + ).add_to_hass(hass) + + instance = Mock() + with patch( + "amberelectric.api.AmberApi.create", + return_value=instance, + ) as mock_update: + instance.get_current_price = Mock( + return_value=GENERAL_CHANNEL + FEED_IN_CHANNEL + ) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + yield mock_update.return_value + + +async def test_general_price_sensor(hass: HomeAssistant, setup_general: Mock) -> None: + """Test the General Price sensor.""" + assert len(hass.states.async_all()) == 3 + price = hass.states.get("sensor.mock_title_general_price") + assert price + assert price.state == "0.08" + attributes = price.attributes + assert attributes["duration"] == 30 + assert attributes["date"] == "2021-09-21" + assert attributes["per_kwh"] == 8 + assert attributes["nem_date"] == "2021-09-21T08:30:00+10:00" + assert attributes["spot_per_kwh"] == 1 + assert attributes["start_time"] == "2021-09-21T08:00:00+10:00" + assert attributes["end_time"] == "2021-09-21T08:30:00+10:00" + assert attributes["renewables"] == 51 + assert attributes["estimate"] is True + assert attributes["spike_status"] == "none" + assert attributes["channel_type"] == "general" + assert attributes["attribution"] == "Data provided by Amber Electric" + assert attributes.get("range_min") is None + assert attributes.get("range_max") is None + + with_range: List[CurrentInterval] = GENERAL_CHANNEL + with_range[0].range = Range(7.8, 12.4) + + setup_general.get_current_price.return_value = with_range + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + price = hass.states.get("sensor.mock_title_general_price") + assert price + attributes = price.attributes + assert attributes.get("range_min") == 7.8 + assert attributes.get("range_max") == 12.4 + + +async def test_general_and_controlled_load_price_sensor( + hass: HomeAssistant, setup_general_and_controlled_load: Mock +) -> None: + """Test the Controlled Price sensor.""" + assert len(hass.states.async_all()) == 5 + print(hass.states) + price = hass.states.get("sensor.mock_title_controlled_load_price") + assert price + assert price.state == "0.08" + attributes = price.attributes + assert attributes["duration"] == 30 + assert attributes["date"] == "2021-09-21" + assert attributes["per_kwh"] == 8 + assert attributes["nem_date"] == "2021-09-21T08:30:00+10:00" + assert attributes["spot_per_kwh"] == 1 + assert attributes["start_time"] == "2021-09-21T08:00:00+10:00" + assert attributes["end_time"] == "2021-09-21T08:30:00+10:00" + assert attributes["renewables"] == 51 + assert attributes["estimate"] is True + assert attributes["spike_status"] == "none" + assert attributes["channel_type"] == "controlledLoad" + assert attributes["attribution"] == "Data provided by Amber Electric" + + +async def test_general_and_feed_in_price_sensor( + hass: HomeAssistant, setup_general_and_feed_in: Mock +) -> None: + """Test the Feed In sensor.""" + assert len(hass.states.async_all()) == 5 + print(hass.states) + price = hass.states.get("sensor.mock_title_feed_in_price") + assert price + assert price.state == "-0.08" + attributes = price.attributes + assert attributes["duration"] == 30 + assert attributes["date"] == "2021-09-21" + assert attributes["per_kwh"] == -8 + assert attributes["nem_date"] == "2021-09-21T08:30:00+10:00" + assert attributes["spot_per_kwh"] == 1 + assert attributes["start_time"] == "2021-09-21T08:00:00+10:00" + assert attributes["end_time"] == "2021-09-21T08:30:00+10:00" + assert attributes["renewables"] == 51 + assert attributes["estimate"] is True + assert attributes["spike_status"] == "none" + assert attributes["channel_type"] == "feedIn" + assert attributes["attribution"] == "Data provided by Amber Electric" + + +async def test_general_forecast_sensor( + hass: HomeAssistant, setup_general: Mock +) -> None: + """Test the General Forecast sensor.""" + assert len(hass.states.async_all()) == 3 + price = hass.states.get("sensor.mock_title_general_forecast") + assert price + assert price.state == "0.09" + attributes = price.attributes + assert attributes["channel_type"] == "general" + assert attributes["attribution"] == "Data provided by Amber Electric" + + first_forecast = attributes["forecasts"][0] + assert first_forecast["duration"] == 30 + assert first_forecast["date"] == "2021-09-21" + assert first_forecast["per_kwh"] == 9 + assert first_forecast["nem_date"] == "2021-09-21T09:00:00+10:00" + assert first_forecast["spot_per_kwh"] == 1 + assert first_forecast["start_time"] == "2021-09-21T08:30:00+10:00" + assert first_forecast["end_time"] == "2021-09-21T09:00:00+10:00" + assert first_forecast["renewables"] == 50 + assert first_forecast["spike_status"] == "none" + + assert first_forecast.get("range_min") is None + assert first_forecast.get("range_max") is None + + with_range: List[CurrentInterval] = GENERAL_CHANNEL + with_range[1].range = Range(7.8, 12.4) + + setup_general.get_current_price.return_value = with_range + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + price = hass.states.get("sensor.mock_title_general_forecast") + assert price + attributes = price.attributes + first_forecast = attributes["forecasts"][0] + assert first_forecast.get("range_min") == 7.8 + assert first_forecast.get("range_max") == 12.4 + + +async def test_controlled_load_forecast_sensor( + hass: HomeAssistant, setup_general_and_controlled_load: Mock +) -> None: + """Test the Controlled Load Forecast sensor.""" + assert len(hass.states.async_all()) == 5 + price = hass.states.get("sensor.mock_title_controlled_load_forecast") + assert price + assert price.state == "0.09" + attributes = price.attributes + assert attributes["channel_type"] == "controlledLoad" + assert attributes["attribution"] == "Data provided by Amber Electric" + + first_forecast = attributes["forecasts"][0] + assert first_forecast["duration"] == 30 + assert first_forecast["date"] == "2021-09-21" + assert first_forecast["per_kwh"] == 9 + assert first_forecast["nem_date"] == "2021-09-21T09:00:00+10:00" + assert first_forecast["spot_per_kwh"] == 1 + assert first_forecast["start_time"] == "2021-09-21T08:30:00+10:00" + assert first_forecast["end_time"] == "2021-09-21T09:00:00+10:00" + assert first_forecast["renewables"] == 50 + assert first_forecast["spike_status"] == "none" + + +async def test_feed_in_forecast_sensor( + hass: HomeAssistant, setup_general_and_feed_in: Mock +) -> None: + """Test the Feed In Forecast sensor.""" + assert len(hass.states.async_all()) == 5 + price = hass.states.get("sensor.mock_title_feed_in_forecast") + assert price + assert price.state == "-0.09" + attributes = price.attributes + assert attributes["channel_type"] == "feedIn" + assert attributes["attribution"] == "Data provided by Amber Electric" + + first_forecast = attributes["forecasts"][0] + assert first_forecast["duration"] == 30 + assert first_forecast["date"] == "2021-09-21" + assert first_forecast["per_kwh"] == -9 + assert first_forecast["nem_date"] == "2021-09-21T09:00:00+10:00" + assert first_forecast["spot_per_kwh"] == 1 + assert first_forecast["start_time"] == "2021-09-21T08:30:00+10:00" + assert first_forecast["end_time"] == "2021-09-21T09:00:00+10:00" + assert first_forecast["renewables"] == 50 + assert first_forecast["spike_status"] == "none" + + +def test_renewable_sensor(hass: HomeAssistant, setup_general) -> None: + """Testing the creation of the Amber renewables sensor.""" + assert len(hass.states.async_all()) == 3 + sensor = hass.states.get("sensor.mock_title_renewables") + assert sensor + assert sensor.state == "51" From c48527858da90e2e9120d9d089d209fb4721d825 Mon Sep 17 00:00:00 2001 From: Oscar Calvo <2091582+ocalvo@users.noreply.github.com> Date: Tue, 28 Sep 2021 00:05:06 -0700 Subject: [PATCH 652/843] Activate fault handler (#56550) --- homeassistant/__main__.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index 8f12028b437..c2802e1b9c4 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -2,6 +2,7 @@ from __future__ import annotations import argparse +import faulthandler import os import platform import subprocess @@ -10,6 +11,8 @@ import threading from homeassistant.const import REQUIRED_PYTHON_VER, RESTART_EXIT_CODE, __version__ +FAULT_LOG_FILENAME = "home-assistant.log.fault" + def validate_python() -> None: """Validate that the right Python version is running.""" @@ -309,7 +312,15 @@ def main() -> int: open_ui=args.open_ui, ) - exit_code = runner.run(runtime_conf) + fault_file_name = os.path.join(config_dir, FAULT_LOG_FILENAME) + with open(fault_file_name, mode="a", encoding="utf8") as fault_file: + faulthandler.enable(fault_file) + exit_code = runner.run(runtime_conf) + faulthandler.disable() + + if os.path.getsize(fault_file_name) == 0: + os.remove(fault_file_name) + if exit_code == RESTART_EXIT_CODE and not args.runner: try_to_restart() From 552485bb05a4ca599fe2bfd25806fdb60436454e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Sep 2021 09:05:26 +0200 Subject: [PATCH 653/843] Tweak list_statistic_ids (#55845) Co-authored-by: Paulus Schoutsen --- .../components/recorder/statistics.py | 3 ++- homeassistant/components/sensor/recorder.py | 19 ++++++++----------- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 2b4775e2412..845e13d40b3 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -517,7 +517,8 @@ def list_statistic_ids( unit = _configured_unit(unit, units) platform_statistic_ids[statistic_id] = unit - statistic_ids = {**statistic_ids, **platform_statistic_ids} + for key, value in platform_statistic_ids.items(): + statistic_ids.setdefault(key, value) # Return a map of statistic_id to unit_of_measurement return [ diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 69db5be912f..806981d51eb 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -125,7 +125,7 @@ WARN_UNSUPPORTED_UNIT = "sensor_warn_unsupported_unit" WARN_UNSTABLE_UNIT = "sensor_warn_unstable_unit" -def _get_entities(hass: HomeAssistant) -> list[tuple[str, str, str | None]]: +def _get_entities(hass: HomeAssistant) -> list[tuple[str, str, str | None, str | None]]: """Get (entity_id, state_class, device_class) of all sensors for which to compile statistics.""" all_sensors = hass.states.all(DOMAIN) entity_ids = [] @@ -134,7 +134,8 @@ def _get_entities(hass: HomeAssistant) -> list[tuple[str, str, str | None]]: if (state_class := state.attributes.get(ATTR_STATE_CLASS)) not in STATE_CLASSES: continue device_class = state.attributes.get(ATTR_DEVICE_CLASS) - entity_ids.append((state.entity_id, state_class, device_class)) + unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + entity_ids.append((state.entity_id, state_class, device_class, unit)) return entity_ids @@ -298,11 +299,11 @@ def reset_detected( def _wanted_statistics( - entities: list[tuple[str, str, str | None]] + entities: list[tuple[str, str, str | None, str | None]] ) -> dict[str, set[str]]: """Prepare a dict with wanted statistics for entities.""" wanted_statistics = {} - for entity_id, state_class, device_class in entities: + for entity_id, state_class, device_class, _ in entities: if device_class in DEVICE_CLASS_STATISTICS[state_class]: wanted_statistics[entity_id] = DEVICE_CLASS_STATISTICS[state_class][ device_class @@ -367,6 +368,7 @@ def compile_statistics( # noqa: C901 entity_id, state_class, device_class, + _, ) in entities: if entity_id not in history_list: continue @@ -530,7 +532,7 @@ def list_statistic_ids(hass: HomeAssistant, statistic_type: str | None = None) - statistic_ids = {} - for entity_id, state_class, device_class in entities: + for entity_id, state_class, device_class, native_unit in entities: if device_class in DEVICE_CLASS_STATISTICS[state_class]: provided_statistics = DEVICE_CLASS_STATISTICS[state_class][device_class] else: @@ -549,12 +551,6 @@ def list_statistic_ids(hass: HomeAssistant, statistic_type: str | None = None) - ): continue - metadata = statistics.get_metadata(hass, entity_id) - if metadata: - native_unit: str | None = metadata["unit_of_measurement"] - else: - native_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - if device_class not in UNIT_CONVERSIONS: statistic_ids[entity_id] = native_unit continue @@ -580,6 +576,7 @@ def validate_statistics( entity_id, _state_class, device_class, + _unit, ) in entities: state = hass.states.get(entity_id) assert state is not None From e690d4b006cfb7bb6d1aa0e1c699fa388ec99ff3 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Tue, 28 Sep 2021 03:06:02 -0400 Subject: [PATCH 654/843] Add support for zwave_js device actions (#53038) Co-authored-by: Martin Hjelmare --- homeassistant/components/zwave_js/api.py | 2 +- homeassistant/components/zwave_js/const.py | 28 ++ .../components/zwave_js/device_action.py | 309 ++++++++++++ .../zwave_js/device_automation_helpers.py | 34 ++ .../components/zwave_js/device_condition.py | 35 +- .../components/zwave_js/device_trigger.py | 5 +- homeassistant/components/zwave_js/lock.py | 10 +- homeassistant/components/zwave_js/services.py | 43 +- .../components/zwave_js/strings.json | 17 +- .../components/zwave_js/translations/en.json | 9 + .../components/zwave_js/test_device_action.py | 457 ++++++++++++++++++ 11 files changed, 889 insertions(+), 60 deletions(-) create mode 100644 homeassistant/components/zwave_js/device_action.py create mode 100644 homeassistant/components/zwave_js/device_automation_helpers.py create mode 100644 tests/components/zwave_js/test_device_action.py diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index da8794e8048..adbdb10cf89 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -47,13 +47,13 @@ from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.dispatcher import async_dispatcher_connect from .const import ( + BITMASK_SCHEMA, CONF_DATA_COLLECTION_OPTED_IN, DATA_CLIENT, DOMAIN, EVENT_DEVICE_ADDED_TO_REGISTRY, ) from .helpers import async_enable_statistics, update_data_collection_preference -from .services import BITMASK_SCHEMA DATA_UNSUBSCRIBE = "unsubs" diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index e4486a681e1..21a7941f097 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -1,6 +1,10 @@ """Constants for the Z-Wave JS integration.""" import logging +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv + CONF_ADDON_DEVICE = "device" CONF_ADDON_EMULATE_HARDWARE = "emulate_hardware" CONF_ADDON_LOG_LEVEL = "log_level" @@ -56,6 +60,8 @@ ATTR_CURRENT_VALUE_RAW = "current_value_raw" ATTR_DESCRIPTION = "description" # service constants +SERVICE_SET_LOCK_USERCODE = "set_lock_usercode" +SERVICE_CLEAR_LOCK_USERCODE = "clear_lock_usercode" SERVICE_SET_VALUE = "set_value" SERVICE_RESET_METER = "reset_meter" SERVICE_MULTICAST_SET_VALUE = "multicast_set_value" @@ -98,3 +104,25 @@ ENTITY_DESC_KEY_TARGET_TEMPERATURE = "target_temperature" ENTITY_DESC_KEY_TIMESTAMP = "timestamp" ENTITY_DESC_KEY_MEASUREMENT = "measurement" ENTITY_DESC_KEY_TOTAL_INCREASING = "total_increasing" + +# Schema Constants + +# Validates that a bitmask is provided in hex form and converts it to decimal +# int equivalent since that's what the library uses +BITMASK_SCHEMA = vol.All( + cv.string, + vol.Lower, + vol.Match( + r"^(0x)?[0-9a-f]+$", + msg="Must provide an integer (e.g. 255) or a bitmask in hex form (e.g. 0xff)", + ), + lambda value: int(value, 16), +) + +VALUE_SCHEMA = vol.Any( + bool, + vol.Coerce(int), + vol.Coerce(float), + BITMASK_SCHEMA, + cv.string, +) diff --git a/homeassistant/components/zwave_js/device_action.py b/homeassistant/components/zwave_js/device_action.py new file mode 100644 index 00000000000..14d64f87eb7 --- /dev/null +++ b/homeassistant/components/zwave_js/device_action.py @@ -0,0 +1,309 @@ +"""Provides device actions for Z-Wave JS.""" +from __future__ import annotations + +from collections import defaultdict +import re +from typing import Any + +import voluptuous as vol +from zwave_js_server.const import CommandClass +from zwave_js_server.const.command_class.lock import ATTR_CODE_SLOT, ATTR_USERCODE +from zwave_js_server.const.command_class.meter import CC_SPECIFIC_METER_TYPE +from zwave_js_server.model.value import get_value_id +from zwave_js_server.util.command_class.meter import get_meter_type + +from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_ENTITY_ID, CONF_TYPE +from homeassistant.core import Context, HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType + +from .const import ( + ATTR_COMMAND_CLASS, + ATTR_CONFIG_PARAMETER, + ATTR_CONFIG_PARAMETER_BITMASK, + ATTR_ENDPOINT, + ATTR_METER_TYPE, + ATTR_PROPERTY, + ATTR_PROPERTY_KEY, + ATTR_REFRESH_ALL_VALUES, + ATTR_VALUE, + ATTR_WAIT_FOR_RESULT, + DOMAIN, + SERVICE_CLEAR_LOCK_USERCODE, + SERVICE_PING, + SERVICE_REFRESH_VALUE, + SERVICE_RESET_METER, + SERVICE_SET_CONFIG_PARAMETER, + SERVICE_SET_LOCK_USERCODE, + SERVICE_SET_VALUE, + VALUE_SCHEMA, +) +from .device_automation_helpers import ( + CONF_SUBTYPE, + VALUE_ID_REGEX, + get_config_parameter_value_schema, +) +from .helpers import async_get_node_from_device_id + +ACTION_TYPES = { + SERVICE_CLEAR_LOCK_USERCODE, + SERVICE_PING, + SERVICE_REFRESH_VALUE, + SERVICE_RESET_METER, + SERVICE_SET_CONFIG_PARAMETER, + SERVICE_SET_LOCK_USERCODE, + SERVICE_SET_VALUE, +} + +CLEAR_LOCK_USERCODE_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): SERVICE_CLEAR_LOCK_USERCODE, + vol.Required(CONF_ENTITY_ID): cv.entity_domain(LOCK_DOMAIN), + vol.Required(ATTR_CODE_SLOT): vol.Coerce(int), + } +) + +PING_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): SERVICE_PING, + } +) + +REFRESH_VALUE_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): SERVICE_REFRESH_VALUE, + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Optional(ATTR_REFRESH_ALL_VALUES, default=False): cv.boolean, + } +) + +RESET_METER_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): SERVICE_RESET_METER, + vol.Required(CONF_ENTITY_ID): cv.entity_domain(SENSOR_DOMAIN), + vol.Optional(ATTR_METER_TYPE): vol.Coerce(int), + vol.Optional(ATTR_VALUE): vol.Coerce(int), + } +) + +SET_CONFIG_PARAMETER_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): SERVICE_SET_CONFIG_PARAMETER, + vol.Required(ATTR_CONFIG_PARAMETER): vol.Any(int, str), + vol.Required(ATTR_CONFIG_PARAMETER_BITMASK): vol.Any(None, int, str), + vol.Required(ATTR_VALUE): vol.Coerce(int), + vol.Required(CONF_SUBTYPE): cv.string, + } +) + +SET_LOCK_USERCODE_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): SERVICE_SET_LOCK_USERCODE, + vol.Required(CONF_ENTITY_ID): cv.entity_domain(LOCK_DOMAIN), + vol.Required(ATTR_CODE_SLOT): vol.Coerce(int), + vol.Required(ATTR_USERCODE): cv.string, + } +) + +SET_VALUE_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): SERVICE_SET_VALUE, + vol.Required(ATTR_COMMAND_CLASS): vol.In([cc.value for cc in CommandClass]), + vol.Required(ATTR_PROPERTY): vol.Any(int, str), + vol.Optional(ATTR_PROPERTY_KEY): vol.Any(vol.Coerce(int), cv.string), + vol.Optional(ATTR_ENDPOINT): vol.Coerce(int), + vol.Required(ATTR_VALUE): VALUE_SCHEMA, + vol.Optional(ATTR_WAIT_FOR_RESULT, default=False): cv.boolean, + } +) + +ACTION_SCHEMA = vol.Any( + CLEAR_LOCK_USERCODE_SCHEMA, + PING_SCHEMA, + REFRESH_VALUE_SCHEMA, + RESET_METER_SCHEMA, + SET_CONFIG_PARAMETER_SCHEMA, + SET_LOCK_USERCODE_SCHEMA, + SET_VALUE_SCHEMA, +) + + +async def async_get_actions(hass: HomeAssistant, device_id: str) -> list[dict]: + """List device actions for Z-Wave JS devices.""" + registry = entity_registry.async_get(hass) + actions = [] + + node = async_get_node_from_device_id(hass, device_id) + + base_action = { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + } + + actions.extend( + [ + {**base_action, CONF_TYPE: SERVICE_SET_VALUE}, + {**base_action, CONF_TYPE: SERVICE_PING}, + ] + ) + actions.extend( + [ + { + **base_action, + CONF_TYPE: SERVICE_SET_CONFIG_PARAMETER, + ATTR_CONFIG_PARAMETER: config_value.property_, + ATTR_CONFIG_PARAMETER_BITMASK: config_value.property_key, + CONF_SUBTYPE: f"{config_value.value_id} ({config_value.property_name})", + } + for config_value in node.get_configuration_values().values() + ] + ) + + meter_endpoints: dict[int, dict[str, Any]] = defaultdict(dict) + + for entry in entity_registry.async_entries_for_device(registry, device_id): + entity_action = {**base_action, CONF_ENTITY_ID: entry.entity_id} + actions.append({**entity_action, CONF_TYPE: SERVICE_REFRESH_VALUE}) + if entry.domain == LOCK_DOMAIN: + actions.extend( + [ + {**entity_action, CONF_TYPE: SERVICE_SET_LOCK_USERCODE}, + {**entity_action, CONF_TYPE: SERVICE_CLEAR_LOCK_USERCODE}, + ] + ) + + if entry.domain == SENSOR_DOMAIN: + value_id = entry.unique_id.split(".")[1] + # If this unique ID doesn't have a value ID, we know it is the node status + # sensor which doesn't have any relevant actions + if re.match(VALUE_ID_REGEX, value_id): + value = node.values[value_id] + else: + continue + # If the value has the meterType CC specific value, we can add a reset_meter + # action for it + if CC_SPECIFIC_METER_TYPE in value.metadata.cc_specific: + meter_endpoints[value.endpoint].setdefault( + CONF_ENTITY_ID, entry.entity_id + ) + meter_endpoints[value.endpoint].setdefault(ATTR_METER_TYPE, set()).add( + get_meter_type(value) + ) + + if not meter_endpoints: + return actions + + for endpoint, endpoint_data in meter_endpoints.items(): + base_action[CONF_ENTITY_ID] = endpoint_data[CONF_ENTITY_ID] + actions.append( + { + **base_action, + CONF_TYPE: SERVICE_RESET_METER, + CONF_SUBTYPE: f"Endpoint {endpoint} (All)", + } + ) + for meter_type in endpoint_data[ATTR_METER_TYPE]: + actions.append( + { + **base_action, + CONF_TYPE: SERVICE_RESET_METER, + ATTR_METER_TYPE: meter_type, + CONF_SUBTYPE: f"Endpoint {endpoint} ({meter_type.name})", + } + ) + + return actions + + +async def async_call_action_from_config( + hass: HomeAssistant, config: dict, variables: dict, context: Context | None +) -> None: + """Execute a device action.""" + action_type = service = config.pop(CONF_TYPE) + if action_type not in ACTION_TYPES: + raise HomeAssistantError(f"Unhandled action type {action_type}") + + service_data = {k: v for k, v in config.items() if v not in (None, "")} + await hass.services.async_call( + DOMAIN, service, service_data, blocking=True, context=context + ) + + +async def async_get_action_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: + """List action capabilities.""" + action_type = config[CONF_TYPE] + node = async_get_node_from_device_id(hass, config[CONF_DEVICE_ID]) + + # Add additional fields to the automation action UI + if action_type == SERVICE_CLEAR_LOCK_USERCODE: + return { + "extra_fields": vol.Schema( + { + vol.Required(ATTR_CODE_SLOT): cv.string, + } + ) + } + + if action_type == SERVICE_SET_LOCK_USERCODE: + return { + "extra_fields": vol.Schema( + { + vol.Required(ATTR_CODE_SLOT): cv.string, + vol.Required(ATTR_USERCODE): cv.string, + } + ) + } + + if action_type == SERVICE_RESET_METER: + return { + "extra_fields": vol.Schema( + { + vol.Optional(ATTR_VALUE): cv.string, + } + ) + } + + if action_type == SERVICE_REFRESH_VALUE: + return { + "extra_fields": vol.Schema( + { + vol.Optional(ATTR_REFRESH_ALL_VALUES): cv.boolean, + } + ) + } + + if action_type == SERVICE_SET_VALUE: + return { + "extra_fields": vol.Schema( + { + vol.Required(ATTR_COMMAND_CLASS): vol.In( + {cc.value: cc.name for cc in CommandClass} + ), + vol.Required(ATTR_PROPERTY): cv.string, + vol.Optional(ATTR_PROPERTY_KEY): cv.string, + vol.Optional(ATTR_ENDPOINT): cv.string, + vol.Required(ATTR_VALUE): cv.string, + vol.Optional(ATTR_WAIT_FOR_RESULT): cv.boolean, + } + ) + } + + if action_type == SERVICE_SET_CONFIG_PARAMETER: + value_id = get_value_id( + node, + CommandClass.CONFIGURATION, + config[ATTR_CONFIG_PARAMETER], + property_key=config[ATTR_CONFIG_PARAMETER_BITMASK], + ) + value_schema = get_config_parameter_value_schema(node, value_id) + if value_schema is None: + return {} + return {"extra_fields": vol.Schema({vol.Required(ATTR_VALUE): value_schema})} + + return {} diff --git a/homeassistant/components/zwave_js/device_automation_helpers.py b/homeassistant/components/zwave_js/device_automation_helpers.py new file mode 100644 index 00000000000..cfdb65a4b02 --- /dev/null +++ b/homeassistant/components/zwave_js/device_automation_helpers.py @@ -0,0 +1,34 @@ +"""Provides helpers for Z-Wave JS device automations.""" +from __future__ import annotations + +from typing import cast + +import voluptuous as vol +from zwave_js_server.const import ConfigurationValueType +from zwave_js_server.model.node import Node +from zwave_js_server.model.value import ConfigurationValue + +NODE_STATUSES = ["asleep", "awake", "dead", "alive"] + +CONF_SUBTYPE = "subtype" +CONF_VALUE_ID = "value_id" + +VALUE_ID_REGEX = r"([0-9]+-[0-9]+-[0-9]+-).+" + + +def get_config_parameter_value_schema(node: Node, value_id: str) -> vol.Schema | None: + """Get the extra fields schema for a config parameter value.""" + config_value = cast(ConfigurationValue, node.values[value_id]) + min_ = config_value.metadata.min + max_ = config_value.metadata.max + + if config_value.configuration_value_type in ( + ConfigurationValueType.RANGE, + ConfigurationValueType.MANUAL_ENTRY, + ): + return vol.All(vol.Coerce(int), vol.Range(min=min_, max=max_)) + + if config_value.configuration_value_type == ConfigurationValueType.ENUMERATED: + return vol.In({int(k): v for k, v in config_value.metadata.states.items()}) + + return None diff --git a/homeassistant/components/zwave_js/device_condition.py b/homeassistant/components/zwave_js/device_condition.py index f17654f184a..6694d88a135 100644 --- a/homeassistant/components/zwave_js/device_condition.py +++ b/homeassistant/components/zwave_js/device_condition.py @@ -14,7 +14,6 @@ from homeassistant.const import CONF_CONDITION, CONF_DEVICE_ID, CONF_DOMAIN, CON from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import condition, config_validation as cv -from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA from homeassistant.helpers.typing import ConfigType, TemplateVarsType from . import DOMAIN @@ -24,34 +23,37 @@ from .const import ( ATTR_PROPERTY, ATTR_PROPERTY_KEY, ATTR_VALUE, + VALUE_SCHEMA, +) +from .device_automation_helpers import ( + CONF_SUBTYPE, + CONF_VALUE_ID, + NODE_STATUSES, + get_config_parameter_value_schema, ) from .helpers import ( async_get_node_from_device_id, async_is_device_config_entry_not_loaded, check_type_schema_map, - get_value_state_schema, get_zwave_value_from_config, remove_keys_with_empty_values, ) -CONF_SUBTYPE = "subtype" -CONF_VALUE_ID = "value_id" CONF_STATUS = "status" NODE_STATUS_TYPE = "node_status" -NODE_STATUS_TYPES = ["asleep", "awake", "dead", "alive"] CONFIG_PARAMETER_TYPE = "config_parameter" VALUE_TYPE = "value" CONDITION_TYPES = {NODE_STATUS_TYPE, CONFIG_PARAMETER_TYPE, VALUE_TYPE} -NODE_STATUS_CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend( +NODE_STATUS_CONDITION_SCHEMA = cv.DEVICE_CONDITION_BASE_SCHEMA.extend( { vol.Required(CONF_TYPE): NODE_STATUS_TYPE, - vol.Required(CONF_STATUS): vol.In(NODE_STATUS_TYPES), + vol.Required(CONF_STATUS): vol.In(NODE_STATUSES), } ) -CONFIG_PARAMETER_CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend( +CONFIG_PARAMETER_CONDITION_SCHEMA = cv.DEVICE_CONDITION_BASE_SCHEMA.extend( { vol.Required(CONF_TYPE): CONFIG_PARAMETER_TYPE, vol.Required(CONF_VALUE_ID): cv.string, @@ -60,20 +62,14 @@ CONFIG_PARAMETER_CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend( } ) -VALUE_CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend( +VALUE_CONDITION_SCHEMA = cv.DEVICE_CONDITION_BASE_SCHEMA.extend( { vol.Required(CONF_TYPE): VALUE_TYPE, vol.Required(ATTR_COMMAND_CLASS): vol.In([cc.value for cc in CommandClass]), vol.Required(ATTR_PROPERTY): vol.Any(vol.Coerce(int), cv.string), vol.Optional(ATTR_PROPERTY_KEY): vol.Any(vol.Coerce(int), cv.string), vol.Optional(ATTR_ENDPOINT): vol.Coerce(int), - vol.Required(ATTR_VALUE): vol.Any( - bool, - vol.Coerce(int), - vol.Coerce(float), - cv.boolean, - cv.string, - ), + vol.Required(ATTR_VALUE): VALUE_SCHEMA, } ) @@ -204,10 +200,9 @@ async def async_get_condition_capabilities( # Add additional fields to the automation trigger UI if config[CONF_TYPE] == CONFIG_PARAMETER_TYPE: value_id = config[CONF_VALUE_ID] - value_schema = get_value_state_schema(node.values[value_id]) - if not value_schema: + value_schema = get_config_parameter_value_schema(node, value_id) + if value_schema is None: return {} - return {"extra_fields": vol.Schema({vol.Required(ATTR_VALUE): value_schema})} if config[CONF_TYPE] == VALUE_TYPE: @@ -234,7 +229,7 @@ async def async_get_condition_capabilities( if config[CONF_TYPE] == NODE_STATUS_TYPE: return { "extra_fields": vol.Schema( - {vol.Required(CONF_STATUS): vol.In(NODE_STATUS_TYPES)} + {vol.Required(CONF_STATUS): vol.In(NODE_STATUSES)} ) } diff --git a/homeassistant/components/zwave_js/device_trigger.py b/homeassistant/components/zwave_js/device_trigger.py index d3deba0979a..11236697198 100644 --- a/homeassistant/components/zwave_js/device_trigger.py +++ b/homeassistant/components/zwave_js/device_trigger.py @@ -49,6 +49,7 @@ from .const import ( ZWAVE_JS_NOTIFICATION_EVENT, ZWAVE_JS_VALUE_NOTIFICATION_EVENT, ) +from .device_automation_helpers import CONF_SUBTYPE, NODE_STATUSES from .helpers import ( async_get_node_from_device_id, async_get_node_status_sensor_entity_id, @@ -65,8 +66,6 @@ from .triggers.value_updated import ( PLATFORM_TYPE as VALUE_UPDATED_PLATFORM_TYPE, ) -CONF_SUBTYPE = "subtype" - # Trigger types ENTRY_CONTROL_NOTIFICATION = "event.notification.entry_control" NOTIFICATION_NOTIFICATION = "event.notification.notification" @@ -153,8 +152,6 @@ BASE_STATE_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( } ) -NODE_STATUSES = ["asleep", "awake", "dead", "alive"] - NODE_STATUS_SCHEMA = BASE_STATE_SCHEMA.extend( { vol.Required(CONF_TYPE): NODE_STATUS, diff --git a/homeassistant/components/zwave_js/lock.py b/homeassistant/components/zwave_js/lock.py index 0f2a0862d7f..d70b6ef2009 100644 --- a/homeassistant/components/zwave_js/lock.py +++ b/homeassistant/components/zwave_js/lock.py @@ -25,7 +25,12 @@ from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DATA_CLIENT, DOMAIN +from .const import ( + DATA_CLIENT, + DOMAIN, + SERVICE_CLEAR_LOCK_USERCODE, + SERVICE_SET_LOCK_USERCODE, +) from .discovery import ZwaveDiscoveryInfo from .entity import ZWaveBaseEntity @@ -42,9 +47,6 @@ STATE_TO_ZWAVE_MAP: dict[int, dict[str, int | bool]] = { }, } -SERVICE_SET_LOCK_USERCODE = "set_lock_usercode" -SERVICE_CLEAR_LOCK_USERCODE = "clear_lock_usercode" - async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/zwave_js/services.py b/homeassistant/components/zwave_js/services.py index 9b165aada18..08841465321 100644 --- a/homeassistant/components/zwave_js/services.py +++ b/homeassistant/components/zwave_js/services.py @@ -59,27 +59,6 @@ def broadcast_command(val: dict[str, Any]) -> dict[str, Any]: ) -# Validates that a bitmask is provided in hex form and converts it to decimal -# int equivalent since that's what the library uses -BITMASK_SCHEMA = vol.All( - cv.string, - vol.Lower, - vol.Match( - r"^(0x)?[0-9a-f]+$", - msg="Must provide an integer (e.g. 255) or a bitmask in hex form (e.g. 0xff)", - ), - lambda value: int(value, 16), -) - -VALUE_SCHEMA = vol.Any( - bool, - vol.Coerce(int), - vol.Coerce(float), - BITMASK_SCHEMA, - cv.string, -) - - class ZWaveServices: """Class that holds our services (Zwave Commands) that should be published to hass.""" @@ -198,10 +177,10 @@ class ZWaveServices: vol.Coerce(int), cv.string ), vol.Optional(const.ATTR_CONFIG_PARAMETER_BITMASK): vol.Any( - vol.Coerce(int), BITMASK_SCHEMA + vol.Coerce(int), const.BITMASK_SCHEMA ), vol.Required(const.ATTR_CONFIG_VALUE): vol.Any( - vol.Coerce(int), BITMASK_SCHEMA, cv.string + vol.Coerce(int), const.BITMASK_SCHEMA, cv.string ), }, cv.has_at_least_one_key( @@ -232,8 +211,10 @@ class ZWaveServices: vol.Coerce(int), { vol.Any( - vol.Coerce(int), BITMASK_SCHEMA, cv.string - ): vol.Any(vol.Coerce(int), BITMASK_SCHEMA, cv.string) + vol.Coerce(int), const.BITMASK_SCHEMA, cv.string + ): vol.Any( + vol.Coerce(int), const.BITMASK_SCHEMA, cv.string + ) }, ), }, @@ -284,9 +265,11 @@ class ZWaveServices: vol.Coerce(int), str ), vol.Optional(const.ATTR_ENDPOINT): vol.Coerce(int), - vol.Required(const.ATTR_VALUE): VALUE_SCHEMA, + vol.Required(const.ATTR_VALUE): const.VALUE_SCHEMA, vol.Optional(const.ATTR_WAIT_FOR_RESULT): cv.boolean, - vol.Optional(const.ATTR_OPTIONS): {cv.string: VALUE_SCHEMA}, + vol.Optional(const.ATTR_OPTIONS): { + cv.string: const.VALUE_SCHEMA + }, }, cv.has_at_least_one_key( ATTR_DEVICE_ID, ATTR_ENTITY_ID, ATTR_AREA_ID @@ -319,8 +302,10 @@ class ZWaveServices: vol.Coerce(int), str ), vol.Optional(const.ATTR_ENDPOINT): vol.Coerce(int), - vol.Required(const.ATTR_VALUE): VALUE_SCHEMA, - vol.Optional(const.ATTR_OPTIONS): {cv.string: VALUE_SCHEMA}, + vol.Required(const.ATTR_VALUE): const.VALUE_SCHEMA, + vol.Optional(const.ATTR_OPTIONS): { + cv.string: const.VALUE_SCHEMA + }, }, vol.Any( cv.has_at_least_one_key( diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index d0bdec1a80c..75c1ea76e9d 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -67,7 +67,9 @@ "on_supervisor": { "title": "Select connection method", "description": "Do you want to use the Z-Wave JS Supervisor add-on?", - "data": { "use_addon": "Use the Z-Wave JS Supervisor add-on" } + "data": { + "use_addon": "Use the Z-Wave JS Supervisor add-on" + } }, "install_addon": { "title": "The Z-Wave JS add-on installation has started" @@ -81,7 +83,9 @@ "emulate_hardware": "Emulate Hardware" } }, - "start_addon": { "title": "The Z-Wave JS add-on is starting." } + "start_addon": { + "title": "The Z-Wave JS add-on is starting." + } }, "error": { "invalid_ws_url": "Invalid websocket URL", @@ -118,6 +122,15 @@ "node_status": "Node status", "config_parameter": "Config parameter {subtype} value", "value": "Current value of a Z-Wave Value" + }, + "action_type": { + "clear_lock_usercode": "Clear usercode on {entity_name}", + "set_lock_usercode": "Set a usercode on {entity_name}", + "set_config_parameter": "Set value of config parameter {subtype}", + "set_value": "Set value of a Z-Wave Value", + "refresh_value": "Refresh the value(s) for {entity_name}", + "ping": "Ping device", + "reset_meter": "Reset meters on {subtype}" } } } diff --git a/homeassistant/components/zwave_js/translations/en.json b/homeassistant/components/zwave_js/translations/en.json index 8ba33702d1d..abe37c4da04 100644 --- a/homeassistant/components/zwave_js/translations/en.json +++ b/homeassistant/components/zwave_js/translations/en.json @@ -58,6 +58,15 @@ } }, "device_automation": { + "action_type": { + "clear_lock_usercode": "Clear usercode on {entity_name}", + "ping": "Ping device", + "refresh_value": "Refresh the value(s) for {entity_name}", + "reset_meter": "Reset meters on {subtype}", + "set_config_parameter": "Set value of config parameter {subtype}", + "set_lock_usercode": "Set a usercode on {entity_name}", + "set_value": "Set value of a Z-Wave Value" + }, "condition_type": { "config_parameter": "Config parameter {subtype} value", "node_status": "Node status", diff --git a/tests/components/zwave_js/test_device_action.py b/tests/components/zwave_js/test_device_action.py new file mode 100644 index 00000000000..65bc8e4bddb --- /dev/null +++ b/tests/components/zwave_js/test_device_action.py @@ -0,0 +1,457 @@ +"""The tests for Z-Wave JS device actions.""" +import pytest +import voluptuous_serialize +from zwave_js_server.client import Client +from zwave_js_server.const import CommandClass +from zwave_js_server.model.node import Node + +from homeassistant.components import automation +from homeassistant.components.zwave_js import DOMAIN, device_action +from homeassistant.components.zwave_js.helpers import get_device_id +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv, device_registry +from homeassistant.setup import async_setup_component + +from tests.common import async_get_device_automations, async_mock_service + + +async def test_get_actions( + hass: HomeAssistant, + client: Client, + lock_schlage_be469: Node, + integration: ConfigEntry, +) -> None: + """Test we get the expected actions from a zwave_js node.""" + node = lock_schlage_be469 + dev_reg = device_registry.async_get(hass) + device = dev_reg.async_get_device({get_device_id(client, node)}) + assert device + expected_actions = [ + { + "domain": DOMAIN, + "type": "clear_lock_usercode", + "device_id": device.id, + "entity_id": "lock.touchscreen_deadbolt", + }, + { + "domain": DOMAIN, + "type": "set_lock_usercode", + "device_id": device.id, + "entity_id": "lock.touchscreen_deadbolt", + }, + { + "domain": DOMAIN, + "type": "refresh_value", + "device_id": device.id, + "entity_id": "lock.touchscreen_deadbolt", + }, + { + "domain": DOMAIN, + "type": "set_value", + "device_id": device.id, + }, + { + "domain": DOMAIN, + "type": "ping", + "device_id": device.id, + }, + { + "domain": DOMAIN, + "type": "set_config_parameter", + "device_id": device.id, + "parameter": 3, + "bitmask": None, + "subtype": f"{node.node_id}-112-0-3 (Beeper)", + }, + ] + actions = await async_get_device_automations(hass, "action", device.id) + for action in expected_actions: + assert action in actions + + +async def test_get_actions_meter( + hass: HomeAssistant, + client: Client, + aeon_smart_switch_6: Node, + integration: ConfigEntry, +) -> None: + """Test we get the expected meter actions from a zwave_js node.""" + node = aeon_smart_switch_6 + dev_reg = device_registry.async_get(hass) + device = dev_reg.async_get_device({get_device_id(client, node)}) + assert device + actions = await async_get_device_automations(hass, "action", device.id) + filtered_actions = [action for action in actions if action["type"] == "reset_meter"] + assert len(filtered_actions) > 0 + + +async def test_action(hass: HomeAssistant) -> None: + """Test for turn_on and turn_off actions.""" + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "event", + "event_type": "test_event_clear_lock_usercode", + }, + "action": { + "domain": DOMAIN, + "type": "clear_lock_usercode", + "device_id": "fake", + "entity_id": "lock.touchscreen_deadbolt", + "code_slot": 1, + }, + }, + { + "trigger": { + "platform": "event", + "event_type": "test_event_set_lock_usercode", + }, + "action": { + "domain": DOMAIN, + "type": "set_lock_usercode", + "device_id": "fake", + "entity_id": "lock.touchscreen_deadbolt", + "code_slot": 1, + "usercode": "1234", + }, + }, + { + "trigger": { + "platform": "event", + "event_type": "test_event_refresh_value", + }, + "action": { + "domain": DOMAIN, + "type": "refresh_value", + "device_id": "fake", + "entity_id": "lock.touchscreen_deadbolt", + }, + }, + { + "trigger": { + "platform": "event", + "event_type": "test_event_ping", + }, + "action": { + "domain": DOMAIN, + "type": "ping", + "device_id": "fake", + }, + }, + { + "trigger": { + "platform": "event", + "event_type": "test_event_set_value", + }, + "action": { + "domain": DOMAIN, + "type": "set_value", + "device_id": "fake", + "command_class": 112, + "property": "test", + "value": 1, + }, + }, + { + "trigger": { + "platform": "event", + "event_type": "test_event_set_config_parameter", + }, + "action": { + "domain": DOMAIN, + "type": "set_config_parameter", + "device_id": "fake", + "parameter": 3, + "bitmask": None, + "subtype": "2-112-0-3 (Beeper)", + "value": 255, + }, + }, + ] + }, + ) + + clear_lock_usercode = async_mock_service(hass, "zwave_js", "clear_lock_usercode") + hass.bus.async_fire("test_event_clear_lock_usercode") + await hass.async_block_till_done() + assert len(clear_lock_usercode) == 1 + + set_lock_usercode = async_mock_service(hass, "zwave_js", "set_lock_usercode") + hass.bus.async_fire("test_event_set_lock_usercode") + await hass.async_block_till_done() + assert len(set_lock_usercode) == 1 + + refresh_value = async_mock_service(hass, "zwave_js", "refresh_value") + hass.bus.async_fire("test_event_refresh_value") + await hass.async_block_till_done() + assert len(refresh_value) == 1 + + ping = async_mock_service(hass, "zwave_js", "ping") + hass.bus.async_fire("test_event_ping") + await hass.async_block_till_done() + assert len(ping) == 1 + + set_value = async_mock_service(hass, "zwave_js", "set_value") + hass.bus.async_fire("test_event_set_value") + await hass.async_block_till_done() + assert len(set_value) == 1 + + set_config_parameter = async_mock_service(hass, "zwave_js", "set_config_parameter") + hass.bus.async_fire("test_event_set_config_parameter") + await hass.async_block_till_done() + assert len(set_config_parameter) == 1 + + +async def test_get_action_capabilities( + hass: HomeAssistant, + client: Client, + climate_radio_thermostat_ct100_plus: Node, + integration: ConfigEntry, +): + """Test we get the expected action capabilities.""" + node = climate_radio_thermostat_ct100_plus + dev_reg = device_registry.async_get(hass) + device = device_registry.async_entries_for_config_entry( + dev_reg, integration.entry_id + )[0] + + # Test refresh_value + capabilities = await device_action.async_get_action_capabilities( + hass, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "refresh_value", + }, + ) + assert capabilities and "extra_fields" in capabilities + + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [{"type": "boolean", "name": "refresh_all_values", "optional": True}] + + # Test ping + capabilities = await device_action.async_get_action_capabilities( + hass, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "ping", + }, + ) + assert not capabilities + + # Test set_value + capabilities = await device_action.async_get_action_capabilities( + hass, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "set_value", + }, + ) + assert capabilities and "extra_fields" in capabilities + + cc_options = [(cc.value, cc.name) for cc in CommandClass] + + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [ + { + "name": "command_class", + "required": True, + "options": cc_options, + "type": "select", + }, + {"name": "property", "required": True, "type": "string"}, + {"name": "property_key", "optional": True, "type": "string"}, + {"name": "endpoint", "optional": True, "type": "string"}, + {"name": "value", "required": True, "type": "string"}, + {"type": "boolean", "name": "wait_for_result", "optional": True}, + ] + + # Test enumerated type param + capabilities = await device_action.async_get_action_capabilities( + hass, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "set_config_parameter", + "parameter": 1, + "bitmask": None, + "subtype": f"{node.node_id}-112-0-1 (Temperature Reporting Threshold)", + }, + ) + assert capabilities and "extra_fields" in capabilities + + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [ + { + "name": "value", + "required": True, + "options": [ + (0, "Disabled"), + (1, "0.5° F"), + (2, "1.0° F"), + (3, "1.5° F"), + (4, "2.0° F"), + ], + "type": "select", + } + ] + + # Test range type param + capabilities = await device_action.async_get_action_capabilities( + hass, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "set_config_parameter", + "parameter": 10, + "bitmask": None, + "subtype": f"{node.node_id}-112-0-10 (Temperature Reporting Filter)", + }, + ) + assert capabilities and "extra_fields" in capabilities + + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [ + { + "name": "value", + "required": True, + "type": "integer", + "valueMin": 0, + "valueMax": 124, + } + ] + + # Test undefined type param + capabilities = await device_action.async_get_action_capabilities( + hass, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "set_config_parameter", + "parameter": 2, + "bitmask": None, + "subtype": f"{node.node_id}-112-0-2 (HVAC Settings)", + }, + ) + assert not capabilities + + +async def test_get_action_capabilities_lock_triggers( + hass: HomeAssistant, + client: Client, + lock_schlage_be469: Node, + integration: ConfigEntry, +): + """Test we get the expected action capabilities for lock triggers.""" + dev_reg = device_registry.async_get(hass) + device = device_registry.async_entries_for_config_entry( + dev_reg, integration.entry_id + )[0] + + # Test clear_lock_usercode + capabilities = await device_action.async_get_action_capabilities( + hass, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "entity_id": "lock.touchscreen_deadbolt", + "type": "clear_lock_usercode", + }, + ) + assert capabilities and "extra_fields" in capabilities + + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [{"type": "string", "name": "code_slot", "required": True}] + + # Test set_lock_usercode + capabilities = await device_action.async_get_action_capabilities( + hass, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "entity_id": "lock.touchscreen_deadbolt", + "type": "set_lock_usercode", + }, + ) + assert capabilities and "extra_fields" in capabilities + + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [ + {"type": "string", "name": "code_slot", "required": True}, + {"type": "string", "name": "usercode", "required": True}, + ] + + +async def test_get_action_capabilities_meter_triggers( + hass: HomeAssistant, + client: Client, + aeon_smart_switch_6: Node, + integration: ConfigEntry, +) -> None: + """Test we get the expected action capabilities for meter triggers.""" + node = aeon_smart_switch_6 + dev_reg = device_registry.async_get(hass) + device = dev_reg.async_get_device({get_device_id(client, node)}) + assert device + capabilities = await device_action.async_get_action_capabilities( + hass, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "entity_id": "sensor.meter", + "type": "reset_meter", + }, + ) + assert capabilities and "extra_fields" in capabilities + + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [{"type": "string", "name": "value", "optional": True}] + + +async def test_failure_scenarios( + hass: HomeAssistant, + client: Client, + hank_binary_switch: Node, + integration: ConfigEntry, +): + """Test failure scenarios.""" + dev_reg = device_registry.async_get(hass) + device = device_registry.async_entries_for_config_entry( + dev_reg, integration.entry_id + )[0] + + with pytest.raises(HomeAssistantError): + await device_action.async_call_action_from_config( + hass, {"type": "failed.test", "device_id": device.id}, {}, None + ) + + assert ( + await device_action.async_get_action_capabilities( + hass, {"type": "failed.test", "device_id": device.id} + ) + == {} + ) From 8dc4824aac611c5a8b716f456d3f5db630cbd672 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Sep 2021 10:19:57 +0300 Subject: [PATCH 655/843] Bump dessant/lock-threads from 2.1.2 to 3 (#56727) Bumps [dessant/lock-threads](https://github.com/dessant/lock-threads) from 2.1.2 to 3. - [Release notes](https://github.com/dessant/lock-threads/releases) - [Changelog](https://github.com/dessant/lock-threads/blob/master/CHANGELOG.md) - [Commits](https://github.com/dessant/lock-threads/compare/v2.1.2...v3) --- updated-dependencies: - dependency-name: dessant/lock-threads dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/lock.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index 96fc69e3b68..cface3ddca5 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -9,7 +9,7 @@ jobs: lock: runs-on: ubuntu-latest steps: - - uses: dessant/lock-threads@v2.1.2 + - uses: dessant/lock-threads@v3 with: github-token: ${{ github.token }} issue-lock-inactive-days: "30" From 9d89e1ae00f0dfad821b860b0fa46fdcc67ce44b Mon Sep 17 00:00:00 2001 From: Regev Brody Date: Tue, 28 Sep 2021 10:25:34 +0300 Subject: [PATCH 656/843] Bump WazeRouteCalculator to 0.13 (#56718) --- homeassistant/components/waze_travel_time/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/waze_travel_time/manifest.json b/homeassistant/components/waze_travel_time/manifest.json index 24927ac9ae3..832ec8a12e3 100644 --- a/homeassistant/components/waze_travel_time/manifest.json +++ b/homeassistant/components/waze_travel_time/manifest.json @@ -2,7 +2,7 @@ "domain": "waze_travel_time", "name": "Waze Travel Time", "documentation": "https://www.home-assistant.io/integrations/waze_travel_time", - "requirements": ["WazeRouteCalculator==0.12"], + "requirements": ["WazeRouteCalculator==0.13"], "codeowners": [], "config_flow": true, "iot_class": "cloud_polling" diff --git a/requirements_all.txt b/requirements_all.txt index 81d13353538..2af2fcabfe0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -85,7 +85,7 @@ TwitterAPI==2.7.5 WSDiscovery==2.0.0 # homeassistant.components.waze_travel_time -WazeRouteCalculator==0.12 +WazeRouteCalculator==0.13 # homeassistant.components.abode abodepy==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 415eb9daadf..db31304efce 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -42,7 +42,7 @@ RtmAPI==0.7.2 WSDiscovery==2.0.0 # homeassistant.components.waze_travel_time -WazeRouteCalculator==0.12 +WazeRouteCalculator==0.13 # homeassistant.components.abode abodepy==1.2.0 From 922d4c42a3e05c611e7d1cdd04278746138bdc00 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Tue, 28 Sep 2021 08:30:21 +0100 Subject: [PATCH 657/843] Inherit Filter sensor state_class from source sensor (#56407) --- homeassistant/components/filter/sensor.py | 5 +++++ tests/components/filter/test_sensor.py | 22 ++++++++++++++++++---- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/filter/sensor.py b/homeassistant/components/filter/sensor.py index f9705887549..665ef6b6ecd 100644 --- a/homeassistant/components/filter/sensor.py +++ b/homeassistant/components/filter/sensor.py @@ -15,6 +15,7 @@ from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAI from homeassistant.components.input_number import DOMAIN as INPUT_NUMBER_DOMAIN from homeassistant.components.recorder import history from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, DEVICE_CLASSES as SENSOR_DEVICE_CLASSES, DOMAIN as SENSOR_DOMAIN, PLATFORM_SCHEMA, @@ -191,6 +192,7 @@ class SensorFilter(SensorEntity): self._filters = filters self._icon = None self._device_class = None + self._attr_state_class = None @callback def _update_filter_sensor_state_event(self, event): @@ -248,6 +250,9 @@ class SensorFilter(SensorEntity): ): self._device_class = new_state.attributes.get(ATTR_DEVICE_CLASS) + if self._attr_state_class is None: + self._attr_state_class = new_state.attributes.get(ATTR_STATE_CLASS) + if self._unit_of_measurement is None: self._unit_of_measurement = new_state.attributes.get( ATTR_UNIT_OF_MEASUREMENT diff --git a/tests/components/filter/test_sensor.py b/tests/components/filter/test_sensor.py index 60fae0fc5be..89e8758c661 100644 --- a/tests/components/filter/test_sensor.py +++ b/tests/components/filter/test_sensor.py @@ -15,8 +15,17 @@ from homeassistant.components.filter.sensor import ( TimeSMAFilter, TimeThrottleFilter, ) -from homeassistant.components.sensor import DEVICE_CLASS_TEMPERATURE -from homeassistant.const import SERVICE_RELOAD, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + DEVICE_CLASS_TEMPERATURE, + STATE_CLASS_TOTAL_INCREASING, +) +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + SERVICE_RELOAD, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) import homeassistant.core as ha from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -264,12 +273,17 @@ async def test_setup(hass): hass.states.async_set( "sensor.test_monitored", 1, - {"icon": "mdi:test", "device_class": DEVICE_CLASS_TEMPERATURE}, + { + "icon": "mdi:test", + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, + }, ) await hass.async_block_till_done() state = hass.states.get("sensor.test") assert state.attributes["icon"] == "mdi:test" - assert state.attributes["device_class"] == DEVICE_CLASS_TEMPERATURE + assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_TEMPERATURE + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING assert state.state == "1.0" From 0d6aa89fd48553609aff2f1678a27ae9c84dd9a2 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Tue, 28 Sep 2021 02:49:32 -0500 Subject: [PATCH 658/843] Refactor Sonos alarms and favorites updating (#55529) --- homeassistant/components/sonos/alarms.py | 95 ++++++++++++------- homeassistant/components/sonos/favorites.py | 83 +++++++++++++--- .../components/sonos/household_coordinator.py | 55 ++++++----- homeassistant/components/sonos/manifest.json | 2 +- homeassistant/components/sonos/speaker.py | 4 +- homeassistant/components/sonos/switch.py | 6 ++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/sonos/conftest.py | 11 ++- tests/components/sonos/test_switch.py | 6 +- 10 files changed, 188 insertions(+), 78 deletions(-) diff --git a/homeassistant/components/sonos/alarms.py b/homeassistant/components/sonos/alarms.py index 215e4fede32..73149c6a286 100644 --- a/homeassistant/components/sonos/alarms.py +++ b/homeassistant/components/sonos/alarms.py @@ -6,9 +6,10 @@ import logging from typing import Any from soco import SoCo -from soco.alarms import Alarm, get_alarms +from soco.alarms import Alarm, Alarms from soco.exceptions import SoCoException +from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import DATA_SONOS, SONOS_ALARMS_UPDATED, SONOS_CREATE_ALARM @@ -23,48 +24,76 @@ class SonosAlarms(SonosHouseholdCoordinator): def __init__(self, *args: Any) -> None: """Initialize the data.""" super().__init__(*args) - self._alarms: dict[str, Alarm] = {} + self.alarms: Alarms = Alarms() + self.created_alarm_ids: set[str] = set() def __iter__(self) -> Iterator: """Return an iterator for the known alarms.""" - alarms = list(self._alarms.values()) - return iter(alarms) + return iter(self.alarms) def get(self, alarm_id: str) -> Alarm | None: """Get an Alarm instance.""" - return self._alarms.get(alarm_id) + return self.alarms.get(alarm_id) - async def async_update_entities(self, soco: SoCo) -> bool: + async def async_update_entities( + self, soco: SoCo, update_id: int | None = None + ) -> None: """Create and update alarms entities, return success.""" + updated = await self.hass.async_add_executor_job( + self.update_cache, soco, update_id + ) + if not updated: + return + + for alarm_id, alarm in self.alarms.alarms.items(): + if alarm_id in self.created_alarm_ids: + continue + speaker = self.hass.data[DATA_SONOS].discovered.get(alarm.zone.uid) + if speaker: + async_dispatcher_send( + self.hass, SONOS_CREATE_ALARM, speaker, [alarm_id] + ) + async_dispatcher_send(self.hass, f"{SONOS_ALARMS_UPDATED}-{self.household_id}") + + @callback + def async_handle_event(self, event_id: str, soco: SoCo) -> None: + """Create a task to update from an event callback.""" + _, event_id = event_id.split(":") + event_id = int(event_id) + self.hass.async_create_task(self.async_process_event(event_id, soco)) + + async def async_process_event(self, event_id: str, soco: SoCo) -> None: + """Process the event payload in an async lock and update entities.""" + async with self.cache_update_lock: + if event_id <= self.last_processed_event_id: + # Skip updates if this event_id has already been seen + return + await self.async_update_entities(soco, event_id) + + def update_cache(self, soco: SoCo, update_id: int | None = None) -> bool: + """Update cache of known alarms and return if cache has changed.""" try: - new_alarms = await self.hass.async_add_executor_job(self.update_cache, soco) + self.alarms.update(soco) except (OSError, SoCoException) as err: - _LOGGER.error("Could not refresh alarms using %s: %s", soco, err) + _LOGGER.error("Could not update alarms using %s: %s", soco, err) return False - for alarm in new_alarms: - speaker = self.hass.data[DATA_SONOS].discovered[alarm.zone.uid] - async_dispatcher_send( - self.hass, SONOS_CREATE_ALARM, speaker, [alarm.alarm_id] - ) - async_dispatcher_send(self.hass, f"{SONOS_ALARMS_UPDATED}-{self.household_id}") + if update_id and self.alarms.last_id < update_id: + # Skip updates if latest query result is outdated or lagging + return False + + if ( + self.last_processed_event_id + and self.alarms.last_id <= self.last_processed_event_id + ): + # Skip updates already processed + return False + + _LOGGER.debug( + "Updating processed event %s from %s (was %s)", + self.alarms.last_id, + soco, + self.last_processed_event_id, + ) + self.last_processed_event_id = self.alarms.last_id return True - - def update_cache(self, soco: SoCo) -> set[Alarm]: - """Populate cache of known alarms. - - Prune deleted alarms and return new alarms. - """ - soco_alarms = get_alarms(soco) - new_alarms = set() - - for alarm in soco_alarms: - if alarm.alarm_id not in self._alarms: - new_alarms.add(alarm) - self._alarms[alarm.alarm_id] = alarm - - for alarm_id, alarm in list(self._alarms.items()): - if alarm not in soco_alarms: - self._alarms.pop(alarm_id) - - return new_alarms diff --git a/homeassistant/components/sonos/favorites.py b/homeassistant/components/sonos/favorites.py index 9695265b24d..adf31b0f507 100644 --- a/homeassistant/components/sonos/favorites.py +++ b/homeassistant/components/sonos/favorites.py @@ -3,12 +3,14 @@ from __future__ import annotations from collections.abc import Iterator import logging +import re from typing import Any from soco import SoCo from soco.data_structures import DidlFavorite from soco.exceptions import SoCoException +from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import SONOS_FAVORITES_UPDATED @@ -24,30 +26,87 @@ class SonosFavorites(SonosHouseholdCoordinator): """Initialize the data.""" super().__init__(*args) self._favorites: list[DidlFavorite] = [] + self.last_polled_ids: dict[str, int] = {} def __iter__(self) -> Iterator: """Return an iterator for the known favorites.""" favorites = self._favorites.copy() return iter(favorites) - async def async_update_entities(self, soco: SoCo) -> bool: + async def async_update_entities( + self, soco: SoCo, update_id: int | None = None + ) -> None: """Update the cache and update entities.""" - try: - await self.hass.async_add_executor_job(self.update_cache, soco) - except (OSError, SoCoException) as err: - _LOGGER.warning("Error requesting favorites from %s: %s", soco, err) - return False + updated = await self.hass.async_add_executor_job( + self.update_cache, soco, update_id + ) + if not updated: + return async_dispatcher_send( self.hass, f"{SONOS_FAVORITES_UPDATED}-{self.household_id}" ) - return True - def update_cache(self, soco: SoCo) -> None: - """Request new Sonos favorites from a speaker.""" + @callback + def async_handle_event(self, event_id: str, container_ids: str, soco: SoCo) -> None: + """Create a task to update from an event callback.""" + if not (match := re.search(r"FV:2,(\d+)", container_ids)): + return + + container_id = int(match.groups()[0]) + event_id = int(event_id.split(",")[-1]) + + self.hass.async_create_task( + self.async_process_event(event_id, container_id, soco) + ) + + async def async_process_event( + self, event_id: int, container_id: int, soco: SoCo + ) -> None: + """Process the event payload in an async lock and update entities.""" + async with self.cache_update_lock: + last_poll_id = self.last_polled_ids.get(soco.uid) + if ( + self.last_processed_event_id + and event_id <= self.last_processed_event_id + ): + # Skip updates if this event_id has already been seen + if not last_poll_id: + self.last_polled_ids[soco.uid] = container_id + return + + if last_poll_id and container_id <= last_poll_id: + return + + _LOGGER.debug( + "New favorites event %s from %s (was %s)", + event_id, + soco, + self.last_processed_event_id, + ) + self.last_processed_event_id = event_id + await self.async_update_entities(soco, container_id) + + def update_cache(self, soco: SoCo, update_id: int | None = None) -> bool: + """Update cache of known favorites and return if cache has changed.""" new_favorites = soco.music_library.get_sonos_favorites() - self._favorites = [] + # Polled update_id values do not match event_id values + # Each speaker can return a different polled update_id + last_poll_id = self.last_polled_ids.get(soco.uid) + if last_poll_id and new_favorites.update_id <= last_poll_id: + # Skip updates already processed + return False + self.last_polled_ids[soco.uid] = new_favorites.update_id + + _LOGGER.debug( + "Processing favorites update_id %s for %s (was: %s)", + new_favorites.update_id, + soco, + last_poll_id, + ) + + self._favorites = [] for fav in new_favorites: try: # exclude non-playable favorites with no linked resources @@ -58,7 +117,9 @@ class SonosFavorites(SonosHouseholdCoordinator): _LOGGER.error("Unhandled favorite '%s': %s", fav.title, ex) _LOGGER.debug( - "Cached %s favorites for household %s", + "Cached %s favorites for household %s using %s", len(self._favorites), self.household_id, + soco, ) + return True diff --git a/homeassistant/components/sonos/household_coordinator.py b/homeassistant/components/sonos/household_coordinator.py index da964e93984..f233b338279 100644 --- a/homeassistant/components/sonos/household_coordinator.py +++ b/homeassistant/components/sonos/household_coordinator.py @@ -1,14 +1,14 @@ """Class representing a Sonos household storage helper.""" from __future__ import annotations -from collections import deque +import asyncio from collections.abc import Callable, Coroutine import logging -from typing import Any from soco import SoCo +from soco.exceptions import SoCoException -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.debounce import Debouncer from .const import DATA_SONOS @@ -23,19 +23,18 @@ class SonosHouseholdCoordinator: """Initialize the data.""" self.hass = hass self.household_id = household_id - self._processed_events = deque(maxlen=5) self.async_poll: Callable[[], Coroutine[None, None, None]] | None = None + self.last_processed_event_id: int | None = None + self.cache_update_lock: asyncio.Lock | None = None def setup(self, soco: SoCo) -> None: """Set up the SonosAlarm instance.""" self.update_cache(soco) - self.hass.add_job(self._async_create_polling_debouncer) + self.hass.add_job(self._async_setup) - async def _async_create_polling_debouncer(self) -> None: - """Create a polling debouncer in async context. - - Used to ensure redundant poll requests from all speakers are coalesced. - """ + async def _async_setup(self) -> None: + """Finish setup in async context.""" + self.cache_update_lock = asyncio.Lock() self.async_poll = Debouncer( self.hass, _LOGGER, @@ -44,31 +43,37 @@ class SonosHouseholdCoordinator: function=self._async_poll, ).async_call + @property + def class_type(self) -> str: + """Return the class type of this instance.""" + return type(self).__name__ + async def _async_poll(self) -> None: """Poll any known speaker.""" discovered = self.hass.data[DATA_SONOS].discovered for uid, speaker in discovered.items(): - _LOGGER.debug("Updating %s using %s", type(self).__name__, speaker.soco) - success = await self.async_update_entities(speaker.soco) - - if success: + _LOGGER.debug("Polling %s using %s", self.class_type, speaker.soco) + try: + await self.async_update_entities(speaker.soco) + except (OSError, SoCoException) as err: + _LOGGER.error( + "Could not refresh %s using %s: %s", + self.class_type, + speaker.soco, + err, + ) + else: # Prefer this SoCo instance next update discovered.move_to_end(uid, last=False) break - @callback - def async_handle_event(self, event_id: str, soco: SoCo) -> None: - """Create a task to update from an event callback.""" - if event_id in self._processed_events: - return - self._processed_events.append(event_id) - self.hass.async_create_task(self.async_update_entities(soco)) - - async def async_update_entities(self, soco: SoCo) -> bool: + async def async_update_entities( + self, soco: SoCo, update_id: int | None = None + ) -> None: """Update the cache and update entities.""" raise NotImplementedError() - def update_cache(self, soco: SoCo) -> Any: - """Update the cache of the household-level feature.""" + def update_cache(self, soco: SoCo, update_id: int | None = None) -> bool: + """Update the cache of the household-level feature and return if cache has changed.""" raise NotImplementedError() diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index d9c2a2cc6c9..249a6d4cc00 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -3,7 +3,7 @@ "name": "Sonos", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sonos", - "requirements": ["soco==0.23.3"], + "requirements": ["soco==0.24.0"], "dependencies": ["ssdp"], "after_dependencies": ["plex", "zeroconf"], "zeroconf": ["_sonos._tcp.local."], diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index b47d1444384..ea49175b665 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -451,7 +451,9 @@ class SonosSpeaker: """Add the soco instance associated with the event to the callback.""" if not (event_id := event.variables.get("favorites_update_id")): return - self.favorites.async_handle_event(event_id, self.soco) + if not (container_ids := event.variables.get("container_update_i_ds")): + return + self.favorites.async_handle_event(event_id, container_ids, self.soco) @callback def async_dispatch_media_update(self, event: SonosEvent) -> None: diff --git a/homeassistant/components/sonos/switch.py b/homeassistant/components/sonos/switch.py index 482780453af..cee60cbbafa 100644 --- a/homeassistant/components/sonos/switch.py +++ b/homeassistant/components/sonos/switch.py @@ -37,8 +37,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def _async_create_entity(speaker: SonosSpeaker, alarm_ids: list[str]) -> None: entities = [] + created_alarms = ( + hass.data[DATA_SONOS].alarms[speaker.household_id].created_alarm_ids + ) for alarm_id in alarm_ids: + if alarm_id in created_alarms: + continue _LOGGER.debug("Creating alarm %s on %s", alarm_id, speaker.zone_name) + created_alarms.add(alarm_id) entities.append(SonosAlarmEntity(alarm_id, speaker)) async_add_entities(entities) diff --git a/requirements_all.txt b/requirements_all.txt index 2af2fcabfe0..b5942b7f63a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2187,7 +2187,7 @@ smhi-pkg==1.0.15 snapcast==2.1.3 # homeassistant.components.sonos -soco==0.23.3 +soco==0.24.0 # homeassistant.components.solaredge_local solaredge-local==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index db31304efce..d5b2f9ddfc8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1239,7 +1239,7 @@ smarthab==0.21 smhi-pkg==1.0.15 # homeassistant.components.sonos -soco==0.23.3 +soco==0.24.0 # homeassistant.components.solaredge solaredge==0.0.2 diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index d970c8923ef..f650c6e8fef 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -39,6 +39,7 @@ class SonosMockEvent: base, count = self.variables[var_name].split(":") newcount = int(count) + 1 self.variables[var_name] = ":".join([base, str(newcount)]) + return self.variables[var_name] @pytest.fixture(name="config_entry") @@ -114,8 +115,8 @@ def config_fixture(): @pytest.fixture(name="music_library") def music_library_fixture(): """Create music_library fixture.""" - music_library = Mock() - music_library.get_sonos_favorites.return_value = [] + music_library = MagicMock() + music_library.get_sonos_favorites.return_value.update_id = 1 return music_library @@ -125,12 +126,13 @@ def alarm_clock_fixture(): alarm_clock = SonosMockService("AlarmClock") alarm_clock.ListAlarms = Mock() alarm_clock.ListAlarms.return_value = { + "CurrentAlarmListVersion": "RINCON_test:14", "CurrentAlarmList": "" '' - " " + "", } return alarm_clock @@ -141,6 +143,7 @@ def alarm_clock_fixture_extended(): alarm_clock = SonosMockService("AlarmClock") alarm_clock.ListAlarms = Mock() alarm_clock.ListAlarms.return_value = { + "CurrentAlarmListVersion": "RINCON_test:15", "CurrentAlarmList": "" '' - " " + "", } return alarm_clock diff --git a/tests/components/sonos/test_switch.py b/tests/components/sonos/test_switch.py index f684a8f351e..d71d403fd8a 100644 --- a/tests/components/sonos/test_switch.py +++ b/tests/components/sonos/test_switch.py @@ -69,13 +69,17 @@ async def test_alarm_create_delete( alarm_clock.ListAlarms.return_value = two_alarms + alarm_event.variables["alarm_list_version"] = two_alarms["CurrentAlarmListVersion"] + sub_callback(event=alarm_event) await hass.async_block_till_done() assert "switch.sonos_alarm_14" in entity_registry.entities assert "switch.sonos_alarm_15" in entity_registry.entities - alarm_event.increment_variable("alarm_list_version") + one_alarm["CurrentAlarmListVersion"] = alarm_event.increment_variable( + "alarm_list_version" + ) alarm_clock.ListAlarms.return_value = one_alarm From 2581a3a735321df07b1649e280dee50dcacc3e22 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 28 Sep 2021 09:56:06 +0200 Subject: [PATCH 659/843] Add binary sensor platform to Tractive integration (#56635) * Add binary sensor platform * Update .coveragerc file * Create battery charging sensor only if tracker supports it * Improve async_setup_entry * Add TRAXL1 model --- .coveragerc | 1 + homeassistant/components/tractive/__init__.py | 8 +- .../components/tractive/binary_sensor.py | 96 +++++++++++++++++++ 3 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/tractive/binary_sensor.py diff --git a/.coveragerc b/.coveragerc index 4aa9565a5f2..4fc01dd17db 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1100,6 +1100,7 @@ omit = homeassistant/components/traccar/device_tracker.py homeassistant/components/traccar/const.py homeassistant/components/tractive/__init__.py + homeassistant/components/tractive/binary_sensor.py homeassistant/components/tractive/device_tracker.py homeassistant/components/tractive/entity.py homeassistant/components/tractive/sensor.py diff --git a/homeassistant/components/tractive/__init__.py b/homeassistant/components/tractive/__init__.py index c380471769c..5dcbd4574b3 100644 --- a/homeassistant/components/tractive/__init__.py +++ b/homeassistant/components/tractive/__init__.py @@ -9,6 +9,7 @@ import aiotractive from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + ATTR_BATTERY_CHARGING, ATTR_BATTERY_LEVEL, CONF_EMAIL, CONF_PASSWORD, @@ -32,7 +33,7 @@ from .const import ( TRACKER_POSITION_UPDATED, ) -PLATFORMS = ["device_tracker", "sensor"] +PLATFORMS = ["binary_sensor", "device_tracker", "sensor"] _LOGGER = logging.getLogger(__name__) @@ -187,7 +188,10 @@ class TractiveClient: continue def _send_hardware_update(self, event): - payload = {ATTR_BATTERY_LEVEL: event["hardware"]["battery_level"]} + payload = { + ATTR_BATTERY_LEVEL: event["hardware"]["battery_level"], + ATTR_BATTERY_CHARGING: event["charging_state"] == "CHARGING", + } self._dispatch_tracker_event( TRACKER_HARDWARE_STATUS_UPDATED, event["tracker_id"], payload ) diff --git a/homeassistant/components/tractive/binary_sensor.py b/homeassistant/components/tractive/binary_sensor.py new file mode 100644 index 00000000000..fd3a00c377d --- /dev/null +++ b/homeassistant/components/tractive/binary_sensor.py @@ -0,0 +1,96 @@ +"""Support for Tractive binary sensors.""" +from __future__ import annotations + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_BATTERY_CHARGING, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.const import ATTR_BATTERY_CHARGING +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .const import ( + CLIENT, + DOMAIN, + SERVER_UNAVAILABLE, + TRACKABLES, + TRACKER_HARDWARE_STATUS_UPDATED, +) +from .entity import TractiveEntity + +TRACKERS_WITH_BUILTIN_BATTERY = ("TRNJA4", "TRAXL1") + + +class TractiveBinarySensor(TractiveEntity, BinarySensorEntity): + """Tractive sensor.""" + + def __init__(self, user_id, trackable, tracker_details, unique_id, description): + """Initialize sensor entity.""" + super().__init__(user_id, trackable, tracker_details) + + self._attr_name = f"{trackable['details']['name']} {description.name}" + self._attr_unique_id = unique_id + self.entity_description = description + + @callback + def handle_server_unavailable(self): + """Handle server unavailable.""" + self._attr_available = False + self.async_write_ha_state() + + @callback + def handle_hardware_status_update(self, event): + """Handle hardware status update.""" + self._attr_is_on = event[self.entity_description.key] + self._attr_available = True + self.async_write_ha_state() + + async def async_added_to_hass(self): + """Handle entity which will be added.""" + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{TRACKER_HARDWARE_STATUS_UPDATED}-{self._tracker_id}", + self.handle_hardware_status_update, + ) + ) + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{SERVER_UNAVAILABLE}-{self._user_id}", + self.handle_server_unavailable, + ) + ) + + +SENSOR_TYPE = BinarySensorEntityDescription( + key=ATTR_BATTERY_CHARGING, + name="Battery Charging", + device_class=DEVICE_CLASS_BATTERY_CHARGING, +) + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up Tractive device trackers.""" + client = hass.data[DOMAIN][entry.entry_id][CLIENT] + trackables = hass.data[DOMAIN][entry.entry_id][TRACKABLES] + + entities = [] + + for item in trackables: + if item.tracker_details["model_number"] not in TRACKERS_WITH_BUILTIN_BATTERY: + continue + entities.append( + TractiveBinarySensor( + client.user_id, + item.trackable, + item.tracker_details, + f"{item.trackable['_id']}_{SENSOR_TYPE.key}", + SENSOR_TYPE, + ) + ) + + async_add_entities(entities) From 495e5cb1c01761882ac1acac9b0c83bc5b262f99 Mon Sep 17 00:00:00 2001 From: gjong Date: Tue, 28 Sep 2021 09:59:40 +0200 Subject: [PATCH 660/843] Update YouLess library for support for PVOutput firmware (#55784) --- homeassistant/components/youless/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/youless/manifest.json b/homeassistant/components/youless/manifest.json index 1ea7bc67ba9..04a66d507ef 100644 --- a/homeassistant/components/youless/manifest.json +++ b/homeassistant/components/youless/manifest.json @@ -3,7 +3,7 @@ "name": "YouLess", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/youless", - "requirements": ["youless-api==0.12"], + "requirements": ["youless-api==0.13"], "codeowners": ["@gjong"], "iot_class": "local_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index b5942b7f63a..e907682bcb2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2465,7 +2465,7 @@ yeelight==0.7.5 yeelightsunflower==0.0.10 # homeassistant.components.youless -youless-api==0.12 +youless-api==0.13 # homeassistant.components.media_extractor youtube_dl==2021.04.26 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d5b2f9ddfc8..53aa50dbe7f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1403,7 +1403,7 @@ yalexs==1.1.13 yeelight==0.7.5 # homeassistant.components.youless -youless-api==0.12 +youless-api==0.13 # homeassistant.components.zeroconf zeroconf==0.36.7 From b64b926e1322a52eebe7ed65bfd4c61bf5f65fcc Mon Sep 17 00:00:00 2001 From: Adrian Huber Date: Tue, 28 Sep 2021 10:04:08 +0200 Subject: [PATCH 661/843] Add raid monitoring to glances (#56623) --- homeassistant/components/glances/const.py | 12 ++++++++++++ homeassistant/components/glances/sensor.py | 7 +++++++ 2 files changed, 19 insertions(+) diff --git a/homeassistant/components/glances/const.py b/homeassistant/components/glances/const.py index 491dd297a05..50f915ef4de 100644 --- a/homeassistant/components/glances/const.py +++ b/homeassistant/components/glances/const.py @@ -194,4 +194,16 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( native_unit_of_measurement=DATA_MEBIBYTES, icon="mdi:docker", ), + GlancesSensorEntityDescription( + key="used", + type="raid", + name_suffix="Raid used", + icon="mdi:harddisk", + ), + GlancesSensorEntityDescription( + key="available", + type="raid", + name_suffix="Raid available", + icon="mdi:harddisk", + ), ) diff --git a/homeassistant/components/glances/sensor.py b/homeassistant/components/glances/sensor.py index 76e2a1c617a..92173f9d143 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -38,6 +38,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): description, ) ) + elif description.type == "raid": + for raid_device in client.api.data[description.type]: + dev.append(GlancesSensor(client, name, raid_device, description)) elif client.api.data[description.type]: dev.append( GlancesSensor( @@ -214,3 +217,7 @@ class GlancesSensor(SensorEntity): self._state = round(mem_use / 1024 ** 2, 1) except KeyError: self._state = STATE_UNAVAILABLE + elif self.entity_description.type == "raid": + for raid_device, raid in value["raid"].items(): + if raid_device == self._sensor_name_prefix: + self._state = raid[self.entity_description.key] From b17930115228d18232b33294a6111e0ca0c7dbc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Tue, 28 Sep 2021 10:15:45 +0200 Subject: [PATCH 662/843] Adjust lock configuration (#56731) --- .github/workflows/lock.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index cface3ddca5..6be819f9b82 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -12,9 +12,9 @@ jobs: - uses: dessant/lock-threads@v3 with: github-token: ${{ github.token }} - issue-lock-inactive-days: "30" - issue-exclude-created-before: "2020-10-01T00:00:00Z" + issue-inactive-days: "30" + exclude-issue-created-before: "2020-10-01T00:00:00Z" issue-lock-reason: "" - pr-lock-inactive-days: "1" - pr-exclude-created-before: "2020-11-01T00:00:00Z" + pr-inactive-days: "1" + exclude-pr-created-before: "2020-11-01T00:00:00Z" pr-lock-reason: "" From 0044fa9fb9794dad5290a431b5f339640748e477 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 28 Sep 2021 10:21:14 +0200 Subject: [PATCH 663/843] Add support for pedestal MIOT fans to Xiaomi Miio integration (#56555) * Add initial support for Xiaomi Fan ZA5 * Add sensor, number and switch platform * Addionizer switch * Improve ionizer icon * Fix parent of XiaomiFanMiot class * Add another MIOT models * Fix consts * Add powersupply attached binary sensor * Simplify async_create_miio_device_and_coordinator * Simplify XiaomiGenericFan * Fix XiaomiFanZA5 parent * Remove pass * Remove unused _available variable * 1C doesn't support direction * Suggested change * Use elif * Clean up oscillation angle * Fix typo --- .../components/xiaomi_miio/__init__.py | 25 +++- .../components/xiaomi_miio/binary_sensor.py | 13 +- homeassistant/components/xiaomi_miio/const.py | 59 +++++++- homeassistant/components/xiaomi_miio/fan.py | 134 ++++++++++++++++-- .../components/xiaomi_miio/number.py | 49 +++++-- .../components/xiaomi_miio/sensor.py | 4 + .../components/xiaomi_miio/switch.py | 40 ++++++ 7 files changed, 291 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/__init__.py b/homeassistant/components/xiaomi_miio/__init__.py index a1ea7565dab..157199e977a 100644 --- a/homeassistant/components/xiaomi_miio/__init__.py +++ b/homeassistant/components/xiaomi_miio/__init__.py @@ -13,7 +13,12 @@ from miio import ( AirPurifierMiot, DeviceException, Fan, + Fan1C, FanP5, + FanP9, + FanP10, + FanP11, + FanZA5, ) from miio.gateway.gateway import GatewayException @@ -33,7 +38,12 @@ from .const import ( KEY_COORDINATOR, KEY_DEVICE, MODEL_AIRPURIFIER_3C, + MODEL_FAN_1C, MODEL_FAN_P5, + MODEL_FAN_P9, + MODEL_FAN_P10, + MODEL_FAN_P11, + MODEL_FAN_ZA5, MODELS_AIR_MONITOR, MODELS_FAN, MODELS_FAN_MIIO, @@ -52,7 +62,7 @@ _LOGGER = logging.getLogger(__name__) GATEWAY_PLATFORMS = ["alarm_control_panel", "light", "sensor", "switch"] SWITCH_PLATFORMS = ["switch"] -FAN_PLATFORMS = ["fan", "number", "select", "sensor", "switch"] +FAN_PLATFORMS = ["binary_sensor", "fan", "number", "select", "sensor", "switch"] HUMIDIFIER_PLATFORMS = [ "binary_sensor", "humidifier", @@ -65,6 +75,15 @@ LIGHT_PLATFORMS = ["light"] VACUUM_PLATFORMS = ["vacuum"] AIR_MONITOR_PLATFORMS = ["air_quality", "sensor"] +MODEL_TO_CLASS_MAP = { + MODEL_FAN_1C: Fan1C, + MODEL_FAN_P10: FanP10, + MODEL_FAN_P11: FanP11, + MODEL_FAN_P5: FanP5, + MODEL_FAN_P9: FanP9, + MODEL_FAN_ZA5: FanZA5, +} + async def async_setup_entry( hass: core.HomeAssistant, entry: config_entries.ConfigEntry @@ -150,8 +169,8 @@ async def async_create_miio_device_and_coordinator( elif model.startswith("zhimi.airfresh."): device = AirFresh(host, token) # Pedestal fans - elif model == MODEL_FAN_P5: - device = FanP5(host, token) + elif model in MODEL_TO_CLASS_MAP: + device = MODEL_TO_CLASS_MAP[model](host, token) elif model in MODELS_FAN_MIIO: device = Fan(host, token, model=model) else: diff --git a/homeassistant/components/xiaomi_miio/binary_sensor.py b/homeassistant/components/xiaomi_miio/binary_sensor.py index 6254c00916e..a91f06d1194 100644 --- a/homeassistant/components/xiaomi_miio/binary_sensor.py +++ b/homeassistant/components/xiaomi_miio/binary_sensor.py @@ -7,6 +7,7 @@ from typing import Callable from homeassistant.components.binary_sensor import ( DEVICE_CLASS_CONNECTIVITY, + DEVICE_CLASS_PLUG, BinarySensorEntity, BinarySensorEntityDescription, ) @@ -18,6 +19,7 @@ from .const import ( DOMAIN, KEY_COORDINATOR, KEY_DEVICE, + MODEL_FAN_ZA5, MODELS_HUMIDIFIER_MIIO, MODELS_HUMIDIFIER_MIOT, MODELS_HUMIDIFIER_MJJSQ, @@ -25,6 +27,7 @@ from .const import ( from .device import XiaomiCoordinatedMiioEntity ATTR_NO_WATER = "no_water" +ATTR_POWERSUPPLY_ATTACHED = "powersupply_attached" ATTR_WATER_TANK_DETACHED = "water_tank_detached" @@ -48,8 +51,14 @@ BINARY_SENSOR_TYPES = ( device_class=DEVICE_CLASS_CONNECTIVITY, value=lambda value: not value, ), + XiaomiMiioBinarySensorDescription( + key=ATTR_POWERSUPPLY_ATTACHED, + name="Power Supply", + device_class=DEVICE_CLASS_PLUG, + ), ) +FAN_ZA5_BINARY_SENSORS = (ATTR_POWERSUPPLY_ATTACHED,) HUMIDIFIER_MIIO_BINARY_SENSORS = (ATTR_WATER_TANK_DETACHED,) HUMIDIFIER_MIOT_BINARY_SENSORS = (ATTR_WATER_TANK_DETACHED,) HUMIDIFIER_MJJSQ_BINARY_SENSORS = (ATTR_NO_WATER, ATTR_WATER_TANK_DETACHED) @@ -62,7 +71,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): if config_entry.data[CONF_FLOW_TYPE] == CONF_DEVICE: model = config_entry.data[CONF_MODEL] sensors = [] - if model in MODELS_HUMIDIFIER_MIIO: + if model in MODEL_FAN_ZA5: + sensors = FAN_ZA5_BINARY_SENSORS + elif model in MODELS_HUMIDIFIER_MIIO: sensors = HUMIDIFIER_MIIO_BINARY_SENSORS elif model in MODELS_HUMIDIFIER_MIOT: sensors = HUMIDIFIER_MIOT_BINARY_SENSORS diff --git a/homeassistant/components/xiaomi_miio/const.py b/homeassistant/components/xiaomi_miio/const.py index 29c740cf800..cda65bdf0aa 100644 --- a/homeassistant/components/xiaomi_miio/const.py +++ b/homeassistant/components/xiaomi_miio/const.py @@ -59,13 +59,18 @@ MODEL_AIRHUMIDIFIER_MJJSQ = "deerma.humidifier.mjjsq" MODEL_AIRFRESH_VA2 = "zhimi.airfresh.va2" +MODEL_FAN_1C = "dmaker.fan.1c" +MODEL_FAN_P10 = "dmaker.fan.p10" +MODEL_FAN_P11 = "dmaker.fan.p11" MODEL_FAN_P5 = "dmaker.fan.p5" +MODEL_FAN_P9 = "dmaker.fan.p9" MODEL_FAN_SA1 = "zhimi.fan.sa1" MODEL_FAN_V2 = "zhimi.fan.v2" MODEL_FAN_V3 = "zhimi.fan.v3" MODEL_FAN_ZA1 = "zhimi.fan.za1" MODEL_FAN_ZA3 = "zhimi.fan.za3" MODEL_FAN_ZA4 = "zhimi.fan.za4" +MODEL_FAN_ZA5 = "zhimi.fan.za5" MODELS_FAN_MIIO = [ MODEL_FAN_P5, @@ -77,6 +82,14 @@ MODELS_FAN_MIIO = [ MODEL_FAN_ZA4, ] +MODELS_FAN_MIOT = [ + MODEL_FAN_1C, + MODEL_FAN_P10, + MODEL_FAN_P11, + MODEL_FAN_P9, + MODEL_FAN_ZA5, +] + MODELS_PURIFIER_MIOT = [ MODEL_AIRPURIFIER_3, MODEL_AIRPURIFIER_3C, @@ -151,7 +164,9 @@ MODELS_SWITCH = [ "chuangmi.plug.hmi205", "chuangmi.plug.hmi206", ] -MODELS_FAN = MODELS_PURIFIER_MIIO + MODELS_PURIFIER_MIOT + MODELS_FAN_MIIO +MODELS_FAN = ( + MODELS_PURIFIER_MIIO + MODELS_PURIFIER_MIOT + MODELS_FAN_MIIO + MODELS_FAN_MIOT +) MODELS_HUMIDIFIER = ( MODELS_HUMIDIFIER_MIOT + MODELS_HUMIDIFIER_MIIO + MODELS_HUMIDIFIER_MJJSQ ) @@ -236,10 +251,10 @@ FEATURE_SET_FAN_LEVEL = 4096 FEATURE_SET_MOTOR_SPEED = 8192 FEATURE_SET_CLEAN = 16384 FEATURE_SET_OSCILLATION_ANGLE = 32768 -FEATURE_SET_OSCILLATION_ANGLE_MAX_140 = 65536 -FEATURE_SET_DELAY_OFF_COUNTDOWN = 131072 -FEATURE_SET_LED_BRIGHTNESS_LEVEL = 262144 -FEATURE_SET_FAVORITE_RPM = 524288 +FEATURE_SET_DELAY_OFF_COUNTDOWN = 65536 +FEATURE_SET_LED_BRIGHTNESS_LEVEL = 131072 +FEATURE_SET_FAVORITE_RPM = 262144 +FEATURE_SET_IONIZER = 524288 FEATURE_FLAGS_AIRPURIFIER_MIIO = ( FEATURE_SET_BUZZER @@ -324,7 +339,7 @@ FEATURE_FLAGS_AIRFRESH = ( FEATURE_FLAGS_FAN_P5 = ( FEATURE_SET_BUZZER | FEATURE_SET_CHILD_LOCK - | FEATURE_SET_OSCILLATION_ANGLE_MAX_140 + | FEATURE_SET_OSCILLATION_ANGLE | FEATURE_SET_LED | FEATURE_SET_DELAY_OFF_COUNTDOWN ) @@ -336,3 +351,35 @@ FEATURE_FLAGS_FAN = ( | FEATURE_SET_LED_BRIGHTNESS | FEATURE_SET_DELAY_OFF_COUNTDOWN ) + +FEATURE_FLAGS_FAN_ZA5 = ( + FEATURE_SET_BUZZER + | FEATURE_SET_CHILD_LOCK + | FEATURE_SET_OSCILLATION_ANGLE + | FEATURE_SET_LED_BRIGHTNESS + | FEATURE_SET_DELAY_OFF_COUNTDOWN + | FEATURE_SET_IONIZER +) + +FEATURE_FLAGS_FAN_1C = ( + FEATURE_SET_BUZZER + | FEATURE_SET_CHILD_LOCK + | FEATURE_SET_LED + | FEATURE_SET_DELAY_OFF_COUNTDOWN +) + +FEATURE_FLAGS_FAN_P9 = ( + FEATURE_SET_BUZZER + | FEATURE_SET_CHILD_LOCK + | FEATURE_SET_OSCILLATION_ANGLE + | FEATURE_SET_LED + | FEATURE_SET_DELAY_OFF_COUNTDOWN +) + +FEATURE_FLAGS_FAN_P10_P11 = ( + FEATURE_SET_BUZZER + | FEATURE_SET_CHILD_LOCK + | FEATURE_SET_OSCILLATION_ANGLE + | FEATURE_SET_LED + | FEATURE_SET_DELAY_OFF_COUNTDOWN +) diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index b5aa0cce780..1b275ea2d6e 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -11,6 +11,10 @@ from miio.fan import ( MoveDirection as FanMoveDirection, OperationMode as FanOperationMode, ) +from miio.fan_miot import ( + OperationMode as FanMiotOperationMode, + OperationModeFanZA5 as FanZA5OperationMode, +) import voluptuous as vol from homeassistant.components.fan import ( @@ -41,7 +45,11 @@ from .const import ( FEATURE_FLAGS_AIRPURIFIER_PRO_V7, FEATURE_FLAGS_AIRPURIFIER_V3, FEATURE_FLAGS_FAN, + FEATURE_FLAGS_FAN_1C, FEATURE_FLAGS_FAN_P5, + FEATURE_FLAGS_FAN_P9, + FEATURE_FLAGS_FAN_P10_P11, + FEATURE_FLAGS_FAN_ZA5, FEATURE_RESET_FILTER, FEATURE_SET_EXTRA_FEATURES, KEY_COORDINATOR, @@ -52,8 +60,14 @@ from .const import ( MODEL_AIRPURIFIER_PRO, MODEL_AIRPURIFIER_PRO_V7, MODEL_AIRPURIFIER_V3, + MODEL_FAN_1C, MODEL_FAN_P5, + MODEL_FAN_P9, + MODEL_FAN_P10, + MODEL_FAN_P11, + MODEL_FAN_ZA5, MODELS_FAN_MIIO, + MODELS_FAN_MIOT, MODELS_PURIFIER_MIOT, SERVICE_RESET_FILTER, SERVICE_SET_EXTRA_FEATURES, @@ -197,6 +211,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities): entity = XiaomiFanP5(name, device, config_entry, unique_id, coordinator) elif model in MODELS_FAN_MIIO: entity = XiaomiFan(name, device, config_entry, unique_id, coordinator) + elif model == MODEL_FAN_ZA5: + entity = XiaomiFanZA5(name, device, config_entry, unique_id, coordinator) + elif model in MODELS_FAN_MIOT: + entity = XiaomiFanMiot(name, device, config_entry, unique_id, coordinator) else: return @@ -264,6 +282,11 @@ class XiaomiGenericDevice(XiaomiCoordinatedMiioEntity, FanEntity): """Flag supported features.""" return self._supported_features + @property + @abstractmethod + def operation_mode_class(self): + """Hold operation mode class.""" + @property def preset_modes(self) -> list: """Get the list of available preset modes.""" @@ -326,11 +349,6 @@ class XiaomiGenericAirPurifier(XiaomiGenericDevice): self._speed_count = 100 - @property - @abstractmethod - def operation_mode_class(self): - """Hold operation mode class.""" - @property def speed_count(self): """Return the number of speeds of the fan supported.""" @@ -695,16 +713,21 @@ class XiaomiGenericFan(XiaomiGenericDevice): if self._model == MODEL_FAN_P5: self._device_features = FEATURE_FLAGS_FAN_P5 - self._preset_modes = [mode.name for mode in FanOperationMode] + elif self._model == MODEL_FAN_ZA5: + self._device_features = FEATURE_FLAGS_FAN_ZA5 + elif self._model == MODEL_FAN_1C: + self._device_features = FEATURE_FLAGS_FAN_1C + elif self._model == MODEL_FAN_P9: + self._device_features = FEATURE_FLAGS_FAN_P9 + elif self._model in (MODEL_FAN_P10, MODEL_FAN_P11): + self._device_features = FEATURE_FLAGS_FAN_P10_P11 else: self._device_features = FEATURE_FLAGS_FAN - self._preset_modes = [ATTR_MODE_NATURE, ATTR_MODE_NORMAL] self._supported_features = ( - SUPPORT_SET_SPEED - | SUPPORT_OSCILLATE - | SUPPORT_PRESET_MODE - | SUPPORT_DIRECTION + SUPPORT_SET_SPEED | SUPPORT_OSCILLATE | SUPPORT_PRESET_MODE ) + if self._model != MODEL_FAN_1C: + self._supported_features |= SUPPORT_DIRECTION self._preset_mode = None self._oscillating = None self._percentage = None @@ -714,6 +737,11 @@ class XiaomiGenericFan(XiaomiGenericDevice): """Get the active preset mode.""" return self._preset_mode + @property + def preset_modes(self) -> list: + """Get the list of available preset modes.""" + return [mode.name for mode in self.operation_mode_class] + @property def percentage(self): """Return the current speed as a percentage.""" @@ -764,11 +792,20 @@ class XiaomiFan(XiaomiGenericFan): else: self._percentage = self.coordinator.data.direct_speed + @property + def operation_mode_class(self): + """Hold operation mode class.""" + @property def preset_mode(self): """Get the active preset mode.""" return ATTR_MODE_NATURE if self._nature_mode else ATTR_MODE_NORMAL + @property + def preset_modes(self) -> list: + """Get the list of available preset modes.""" + return [ATTR_MODE_NATURE, ATTR_MODE_NORMAL] + @callback def _handle_coordinator_update(self): """Fetch state from the device.""" @@ -843,6 +880,11 @@ class XiaomiFanP5(XiaomiGenericFan): self._oscillating = self.coordinator.data.oscillate self._percentage = self.coordinator.data.speed + @property + def operation_mode_class(self): + """Hold operation mode class.""" + return FanOperationMode + @callback def _handle_coordinator_update(self): """Fetch state from the device.""" @@ -861,7 +903,7 @@ class XiaomiFanP5(XiaomiGenericFan): await self._try_command( "Setting operation mode of the miio device failed.", self._device.set_mode, - FanOperationMode[preset_mode], + self.operation_mode_class[preset_mode], ) self._preset_mode = preset_mode self.async_write_ha_state() @@ -884,3 +926,71 @@ class XiaomiFanP5(XiaomiGenericFan): await self.async_turn_on() else: self.async_write_ha_state() + + +class XiaomiFanMiot(XiaomiGenericFan): + """Representation of a Xiaomi Fan Miot.""" + + @property + def operation_mode_class(self): + """Hold operation mode class.""" + return FanMiotOperationMode + + @property + def preset_mode(self): + """Get the active preset mode.""" + return self._preset_mode + + @callback + def _handle_coordinator_update(self): + """Fetch state from the device.""" + self._state = self.coordinator.data.is_on + self._preset_mode = self.coordinator.data.mode.name + self._oscillating = self.coordinator.data.oscillate + if self.coordinator.data.is_on: + self._percentage = self.coordinator.data.fan_speed + else: + self._percentage = 0 + + self.async_write_ha_state() + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode of the fan.""" + if preset_mode not in self.preset_modes: + _LOGGER.warning("'%s'is not a valid preset mode", preset_mode) + return + await self._try_command( + "Setting operation mode of the miio device failed.", + self._device.set_mode, + self.operation_mode_class[preset_mode], + ) + self._preset_mode = preset_mode + self.async_write_ha_state() + + async def async_set_percentage(self, percentage: int) -> None: + """Set the percentage of the fan.""" + if percentage == 0: + self._percentage = 0 + await self.async_turn_off() + return + + await self._try_command( + "Setting fan speed percentage of the miio device failed.", + self._device.set_speed, + percentage, + ) + self._percentage = percentage + + if not self.is_on: + await self.async_turn_on() + else: + self.async_write_ha_state() + + +class XiaomiFanZA5(XiaomiFanMiot): + """Representation of a Xiaomi Fan ZA5.""" + + @property + def operation_mode_class(self): + """Hold operation mode class.""" + return FanZA5OperationMode diff --git a/homeassistant/components/xiaomi_miio/number.py b/homeassistant/components/xiaomi_miio/number.py index 2547c33bbfa..a915ce57847 100644 --- a/homeassistant/components/xiaomi_miio/number.py +++ b/homeassistant/components/xiaomi_miio/number.py @@ -25,7 +25,11 @@ from .const import ( FEATURE_FLAGS_AIRPURIFIER_V1, FEATURE_FLAGS_AIRPURIFIER_V3, FEATURE_FLAGS_FAN, + FEATURE_FLAGS_FAN_1C, FEATURE_FLAGS_FAN_P5, + FEATURE_FLAGS_FAN_P9, + FEATURE_FLAGS_FAN_P10_P11, + FEATURE_FLAGS_FAN_ZA5, FEATURE_SET_DELAY_OFF_COUNTDOWN, FEATURE_SET_FAN_LEVEL, FEATURE_SET_FAVORITE_LEVEL, @@ -33,7 +37,6 @@ from .const import ( FEATURE_SET_LED_BRIGHTNESS_LEVEL, FEATURE_SET_MOTOR_SPEED, FEATURE_SET_OSCILLATION_ANGLE, - FEATURE_SET_OSCILLATION_ANGLE_MAX_140, FEATURE_SET_VOLUME, KEY_COORDINATOR, KEY_DEVICE, @@ -47,13 +50,18 @@ from .const import ( MODEL_AIRPURIFIER_PRO_V7, MODEL_AIRPURIFIER_V1, MODEL_AIRPURIFIER_V3, + MODEL_FAN_1C, MODEL_FAN_P5, + MODEL_FAN_P9, + MODEL_FAN_P10, + MODEL_FAN_P11, MODEL_FAN_SA1, MODEL_FAN_V2, MODEL_FAN_V3, MODEL_FAN_ZA1, MODEL_FAN_ZA3, MODEL_FAN_ZA4, + MODEL_FAN_ZA5, MODELS_PURIFIER_MIIO, MODELS_PURIFIER_MIOT, ) @@ -80,6 +88,15 @@ class XiaomiMiioNumberDescription(NumberEntityDescription): method: str | None = None +@dataclass +class OscillationAngleValues: + """A class that describes oscillation angle values.""" + + max_value: float | None = None + min_value: float | None = None + step: float | None = None + + NUMBER_TYPES = { FEATURE_SET_MOTOR_SPEED: XiaomiMiioNumberDescription( key=ATTR_MOTOR_SPEED, @@ -129,16 +146,6 @@ NUMBER_TYPES = { step=1, method="async_set_oscillation_angle", ), - FEATURE_SET_OSCILLATION_ANGLE_MAX_140: XiaomiMiioNumberDescription( - key=ATTR_OSCILLATION_ANGLE, - name="Oscillation Angle", - icon="mdi:angle-acute", - unit_of_measurement=DEGREE, - min_value=30, - max_value=140, - step=30, - method="async_set_oscillation_angle", - ), FEATURE_SET_DELAY_OFF_COUNTDOWN: XiaomiMiioNumberDescription( key=ATTR_DELAY_OFF_COUNTDOWN, name="Delay Off Countdown", @@ -181,13 +188,26 @@ MODEL_TO_FEATURES_MAP = { MODEL_AIRPURIFIER_PRO_V7: FEATURE_FLAGS_AIRPURIFIER_PRO_V7, MODEL_AIRPURIFIER_V1: FEATURE_FLAGS_AIRPURIFIER_V1, MODEL_AIRPURIFIER_V3: FEATURE_FLAGS_AIRPURIFIER_V3, + MODEL_FAN_1C: FEATURE_FLAGS_FAN_1C, + MODEL_FAN_P10: FEATURE_FLAGS_FAN_P10_P11, + MODEL_FAN_P11: FEATURE_FLAGS_FAN_P10_P11, MODEL_FAN_P5: FEATURE_FLAGS_FAN_P5, + MODEL_FAN_P9: FEATURE_FLAGS_FAN_P9, MODEL_FAN_SA1: FEATURE_FLAGS_FAN, MODEL_FAN_V2: FEATURE_FLAGS_FAN, MODEL_FAN_V3: FEATURE_FLAGS_FAN, MODEL_FAN_ZA1: FEATURE_FLAGS_FAN, MODEL_FAN_ZA3: FEATURE_FLAGS_FAN, MODEL_FAN_ZA4: FEATURE_FLAGS_FAN, + MODEL_FAN_ZA5: FEATURE_FLAGS_FAN_ZA5, +} + +OSCILLATION_ANGLE_VALUES = { + MODEL_FAN_P5: OscillationAngleValues(max_value=140, min_value=30, step=30), + MODEL_FAN_ZA5: OscillationAngleValues(max_value=120, min_value=30, step=30), + MODEL_FAN_P9: OscillationAngleValues(max_value=150, min_value=30, step=30), + MODEL_FAN_P10: OscillationAngleValues(max_value=140, min_value=30, step=30), + MODEL_FAN_P11: OscillationAngleValues(max_value=140, min_value=30, step=30), } @@ -210,6 +230,13 @@ async def async_setup_entry(hass, config_entry, async_add_entities): for feature, description in NUMBER_TYPES.items(): if feature & features: + if ( + description.key == ATTR_OSCILLATION_ANGLE + and model in OSCILLATION_ANGLE_VALUES + ): + description.max_value = OSCILLATION_ANGLE_VALUES[model].max_value + description.min_value = OSCILLATION_ANGLE_VALUES[model].min_value + description.step = OSCILLATION_ANGLE_VALUES[model].step entities.append( XiaomiNumberEntity( f"{config_entry.title} {description.name}", diff --git a/homeassistant/components/xiaomi_miio/sensor.py b/homeassistant/components/xiaomi_miio/sensor.py index 7e2a5d230cd..d199c051eae 100644 --- a/homeassistant/components/xiaomi_miio/sensor.py +++ b/homeassistant/components/xiaomi_miio/sensor.py @@ -68,6 +68,7 @@ from .const import ( MODEL_FAN_ZA1, MODEL_FAN_ZA3, MODEL_FAN_ZA4, + MODEL_FAN_ZA5, MODELS_AIR_QUALITY_MONITOR, MODELS_HUMIDIFIER_MIIO, MODELS_HUMIDIFIER_MIOT, @@ -331,6 +332,8 @@ FAN_V2_V3_SENSORS = ( ATTR_TEMPERATURE, ) +FAN_ZA5_SENSORS = (ATTR_HUMIDITY, ATTR_TEMPERATURE) + MODEL_TO_SENSORS_MAP = { MODEL_AIRFRESH_VA2: AIRFRESH_SENSORS, MODEL_AIRHUMIDIFIER_CA1: HUMIDIFIER_CA1_CB1_SENSORS, @@ -342,6 +345,7 @@ MODEL_TO_SENSORS_MAP = { MODEL_AIRPURIFIER_V3: PURIFIER_V3_SENSORS, MODEL_FAN_V2: FAN_V2_V3_SENSORS, MODEL_FAN_V3: FAN_V2_V3_SENSORS, + MODEL_FAN_ZA5: FAN_ZA5_SENSORS, } diff --git a/homeassistant/components/xiaomi_miio/switch.py b/homeassistant/components/xiaomi_miio/switch.py index 30854d65101..fd9d4053313 100644 --- a/homeassistant/components/xiaomi_miio/switch.py +++ b/homeassistant/components/xiaomi_miio/switch.py @@ -46,12 +46,17 @@ from .const import ( FEATURE_FLAGS_AIRPURIFIER_V1, FEATURE_FLAGS_AIRPURIFIER_V3, FEATURE_FLAGS_FAN, + FEATURE_FLAGS_FAN_1C, FEATURE_FLAGS_FAN_P5, + FEATURE_FLAGS_FAN_P9, + FEATURE_FLAGS_FAN_P10_P11, + FEATURE_FLAGS_FAN_ZA5, FEATURE_SET_AUTO_DETECT, FEATURE_SET_BUZZER, FEATURE_SET_CHILD_LOCK, FEATURE_SET_CLEAN, FEATURE_SET_DRY, + FEATURE_SET_IONIZER, FEATURE_SET_LEARN_MODE, FEATURE_SET_LED, KEY_COORDINATOR, @@ -67,10 +72,15 @@ from .const import ( MODEL_AIRPURIFIER_PRO_V7, MODEL_AIRPURIFIER_V1, MODEL_AIRPURIFIER_V3, + MODEL_FAN_1C, MODEL_FAN_P5, + MODEL_FAN_P9, + MODEL_FAN_P10, + MODEL_FAN_P11, MODEL_FAN_ZA1, MODEL_FAN_ZA3, MODEL_FAN_ZA4, + MODEL_FAN_ZA5, MODELS_FAN, MODELS_HUMIDIFIER, MODELS_HUMIDIFIER_MJJSQ, @@ -108,6 +118,7 @@ ATTR_CLEAN = "clean_mode" ATTR_DRY = "dry" ATTR_LEARN_MODE = "learn_mode" ATTR_LED = "led" +ATTR_IONIZER = "ionizer" ATTR_LOAD_POWER = "load_power" ATTR_MODEL = "model" ATTR_POWER = "power" @@ -165,10 +176,15 @@ MODEL_TO_FEATURES_MAP = { MODEL_AIRPURIFIER_PRO_V7: FEATURE_FLAGS_AIRPURIFIER_PRO_V7, MODEL_AIRPURIFIER_V1: FEATURE_FLAGS_AIRPURIFIER_V1, MODEL_AIRPURIFIER_V3: FEATURE_FLAGS_AIRPURIFIER_V3, + MODEL_FAN_1C: FEATURE_FLAGS_FAN_1C, + MODEL_FAN_P10: FEATURE_FLAGS_FAN_P10_P11, + MODEL_FAN_P11: FEATURE_FLAGS_FAN_P10_P11, MODEL_FAN_P5: FEATURE_FLAGS_FAN_P5, + MODEL_FAN_P9: FEATURE_FLAGS_FAN_P9, MODEL_FAN_ZA1: FEATURE_FLAGS_FAN, MODEL_FAN_ZA3: FEATURE_FLAGS_FAN, MODEL_FAN_ZA4: FEATURE_FLAGS_FAN, + MODEL_FAN_ZA5: FEATURE_FLAGS_FAN_ZA5, } @@ -239,6 +255,14 @@ SWITCH_TYPES = ( method_on="async_set_auto_detect_on", method_off="async_set_auto_detect_off", ), + XiaomiMiioSwitchDescription( + key=ATTR_IONIZER, + feature=FEATURE_SET_IONIZER, + name="Ionizer", + icon="mdi:shimmer", + method_on="async_set_ionizer_on", + method_off="async_set_ionizer_off", + ), ) @@ -578,6 +602,22 @@ class XiaomiGenericCoordinatedSwitch(XiaomiCoordinatedMiioEntity, SwitchEntity): False, ) + async def async_set_ionizer_on(self) -> bool: + """Turn ionizer on.""" + return await self._try_command( + "Turning ionizer of the miio device on failed.", + self._device.set_ionizer, + True, + ) + + async def async_set_ionizer_off(self) -> bool: + """Turn ionizer off.""" + return await self._try_command( + "Turning ionizer of the miio device off failed.", + self._device.set_ionizer, + False, + ) + class XiaomiGatewaySwitch(XiaomiGatewayDevice, SwitchEntity): """Representation of a XiaomiGatewaySwitch.""" From c95f7a5ba696b3e64a3534b294c39d44dc09bd0b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Sep 2021 09:58:25 -0500 Subject: [PATCH 664/843] Add network support to tplink for discovery across subnets (#56721) --- homeassistant/components/tplink/__init__.py | 18 ++++++++++++++---- homeassistant/components/tplink/config_flow.py | 7 ++----- homeassistant/components/tplink/manifest.json | 1 + tests/components/tplink/__init__.py | 2 +- 4 files changed, 18 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/tplink/__init__.py b/homeassistant/components/tplink/__init__.py index 9b21e532776..e365f0f2453 100644 --- a/homeassistant/components/tplink/__init__.py +++ b/homeassistant/components/tplink/__init__.py @@ -1,6 +1,7 @@ """Component to embed TP-Link smart home devices.""" from __future__ import annotations +import asyncio from typing import Any from kasa import SmartDevice, SmartDeviceException @@ -8,6 +9,7 @@ from kasa.discover import Discover import voluptuous as vol from homeassistant import config_entries +from homeassistant.components import network from homeassistant.config_entries import ConfigEntry, ConfigEntryNotReady from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME from homeassistant.core import HomeAssistant, callback @@ -79,6 +81,17 @@ def async_trigger_discovery( ) +async def async_discover_devices(hass: HomeAssistant) -> dict[str, SmartDevice]: + """Discover TPLink devices on configured network interfaces.""" + broadcast_addresses = await network.async_get_ipv4_broadcast_addresses(hass) + tasks = [Discover.discover(target=str(address)) for address in broadcast_addresses] + discovered_devices: dict[str, SmartDevice] = {} + for device_list in await asyncio.gather(*tasks): + for device in device_list.values(): + discovered_devices[dr.format_mac(device.mac)] = device + return discovered_devices + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the TP-Link component.""" conf = config.get(DOMAIN) @@ -91,10 +104,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: elif entry.unique_id: config_entries_by_mac[entry.unique_id] = entry - discovered_devices = { - dr.format_mac(device.mac): device - for device in (await Discover.discover()).values() - } + discovered_devices = await async_discover_devices(hass) hosts_by_mac = {mac: device.host for mac, device in discovered_devices.items()} if legacy_entry: diff --git a/homeassistant/components/tplink/config_flow.py b/homeassistant/components/tplink/config_flow.py index e8f1fb0a702..d9bed10ee42 100644 --- a/homeassistant/components/tplink/config_flow.py +++ b/homeassistant/components/tplink/config_flow.py @@ -16,7 +16,7 @@ from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import device_registry as dr from homeassistant.helpers.typing import DiscoveryInfoType -from . import async_entry_is_legacy +from . import async_discover_devices, async_entry_is_legacy from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -119,10 +119,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): for entry in self._async_current_entries() if not async_entry_is_legacy(entry) } - self._discovered_devices = { - dr.format_mac(device.mac): device - for device in (await Discover.discover()).values() - } + self._discovered_devices = await async_discover_devices(self.hass) devices_name = { formatted_mac: f"{device.alias} {device.model} ({device.host}) {formatted_mac}" for formatted_mac, device in self._discovered_devices.items() diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json index 03e63720dea..cfc9fce5213 100644 --- a/homeassistant/components/tplink/manifest.json +++ b/homeassistant/components/tplink/manifest.json @@ -5,6 +5,7 @@ "documentation": "https://www.home-assistant.io/integrations/tplink", "requirements": ["python-kasa==0.4.0"], "codeowners": ["@rytilahti", "@thegardenmonkey"], + "dependencies": ["network"], "quality_scale": "platinum", "iot_class": "local_polling", "dhcp": [ diff --git a/tests/components/tplink/__init__.py b/tests/components/tplink/__init__.py index f49f93258a3..1507685245b 100644 --- a/tests/components/tplink/__init__.py +++ b/tests/components/tplink/__init__.py @@ -87,7 +87,7 @@ def _mocked_strip() -> SmartStrip: def _patch_discovery(device=None, no_device=False): - async def _discovery(*_): + async def _discovery(*args, **kwargs): if no_device: return {} return {IP_ADDRESS: _mocked_bulb()} From bc59387437cea91d675584bb9057338aa5992817 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Sep 2021 11:36:45 -0500 Subject: [PATCH 665/843] Explictly close the TPLink SmartDevice protocol on unload (#56743) * Explictly close the TPLink SmartDevice protocol on unload - There is a destructor that will eventually do this when the object gets gc. Its better to explictly do it at unload. * fix coro mock --- homeassistant/components/tplink/__init__.py | 2 ++ tests/components/tplink/__init__.py | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/homeassistant/components/tplink/__init__.py b/homeassistant/components/tplink/__init__.py index e365f0f2453..5c40f61e2df 100644 --- a/homeassistant/components/tplink/__init__.py +++ b/homeassistant/components/tplink/__init__.py @@ -151,8 +151,10 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass_data: dict[str, Any] = hass.data[DOMAIN] if entry.entry_id not in hass_data: return True + device: SmartDevice = hass.data[DOMAIN][entry.entry_id].device if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass_data.pop(entry.entry_id) + await device.protocol.close() return unload_ok diff --git a/tests/components/tplink/__init__.py b/tests/components/tplink/__init__.py index 1507685245b..870e05e970b 100644 --- a/tests/components/tplink/__init__.py +++ b/tests/components/tplink/__init__.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from kasa import SmartBulb, SmartPlug, SmartStrip from kasa.exceptions import SmartDeviceException +from kasa.protocol import TPLinkSmartHomeProtocol MODULE = "homeassistant.components.tplink" MODULE_CONFIG_FLOW = "homeassistant.components.tplink.config_flow" @@ -14,6 +15,12 @@ MAC_ADDRESS = "aa:bb:cc:dd:ee:ff" DEFAULT_ENTRY_TITLE = f"{ALIAS} {MODEL}" +def _mock_protocol() -> TPLinkSmartHomeProtocol: + protocol = MagicMock(auto_spec=TPLinkSmartHomeProtocol) + protocol.close = AsyncMock() + return protocol + + def _mocked_bulb() -> SmartBulb: bulb = MagicMock(auto_spec=SmartBulb) bulb.update = AsyncMock() @@ -36,6 +43,7 @@ def _mocked_bulb() -> SmartBulb: bulb.set_brightness = AsyncMock() bulb.set_hsv = AsyncMock() bulb.set_color_temp = AsyncMock() + bulb.protocol = _mock_protocol() return bulb @@ -55,6 +63,7 @@ def _mocked_plug() -> SmartPlug: plug.hw_info = {"sw_ver": "1.0.0"} plug.turn_off = AsyncMock() plug.turn_on = AsyncMock() + plug.protocol = _mock_protocol() return plug @@ -74,14 +83,17 @@ def _mocked_strip() -> SmartStrip: strip.hw_info = {"sw_ver": "1.0.0"} strip.turn_off = AsyncMock() strip.turn_on = AsyncMock() + strip.protocol = _mock_protocol() plug0 = _mocked_plug() plug0.alias = "Plug0" plug0.device_id = "bb:bb:cc:dd:ee:ff_PLUG0DEVICEID" plug0.mac = "bb:bb:cc:dd:ee:ff" + plug0.protocol = _mock_protocol() plug1 = _mocked_plug() plug1.device_id = "cc:bb:cc:dd:ee:ff_PLUG1DEVICEID" plug1.mac = "cc:bb:cc:dd:ee:ff" plug1.alias = "Plug1" + plug1.protocol = _mock_protocol() strip.children = [plug0, plug1] return strip From db30c27455c8ffde3bdf70e38faa131ea0333bcc Mon Sep 17 00:00:00 2001 From: Milan Meulemans Date: Tue, 28 Sep 2021 22:39:54 +0200 Subject: [PATCH 666/843] Clean up Nanoleaf (#56732) --- homeassistant/components/nanoleaf/__init__.py | 8 +- homeassistant/components/nanoleaf/const.py | 4 - homeassistant/components/nanoleaf/light.py | 149 ++++++------------ 3 files changed, 51 insertions(+), 110 deletions(-) diff --git a/homeassistant/components/nanoleaf/__init__.py b/homeassistant/components/nanoleaf/__init__.py index 313af5b0ae3..c706f52035f 100644 --- a/homeassistant/components/nanoleaf/__init__.py +++ b/homeassistant/components/nanoleaf/__init__.py @@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DEVICE, DOMAIN, NAME, SERIAL_NO +from .const import DOMAIN async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -22,11 +22,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except InvalidToken as err: raise ConfigEntryAuthFailed from err - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { - DEVICE: nanoleaf, - NAME: nanoleaf.name, - SERIAL_NO: nanoleaf.serial_no, - } + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = nanoleaf hass.async_create_task( hass.config_entries.async_forward_entry_setup(entry, "light") diff --git a/homeassistant/components/nanoleaf/const.py b/homeassistant/components/nanoleaf/const.py index 6d393fa3428..505af8ce69d 100644 --- a/homeassistant/components/nanoleaf/const.py +++ b/homeassistant/components/nanoleaf/const.py @@ -1,7 +1,3 @@ """Constants for Nanoleaf integration.""" DOMAIN = "nanoleaf" - -DEVICE = "device" -SERIAL_NO = "serial_no" -NAME = "name" diff --git a/homeassistant/components/nanoleaf/light.py b/homeassistant/components/nanoleaf/light.py index 0a80a3f7d60..f5537d3dc1c 100644 --- a/homeassistant/components/nanoleaf/light.py +++ b/homeassistant/components/nanoleaf/light.py @@ -1,10 +1,8 @@ """Support for Nanoleaf Lights.""" from __future__ import annotations -import logging - from aiohttp import ServerDisconnectedError -from aionanoleaf import Unavailable +from aionanoleaf import Nanoleaf, Unavailable import voluptuous as vol from homeassistant.components.light import ( @@ -32,22 +30,11 @@ from homeassistant.util.color import ( color_temperature_mired_to_kelvin as mired_to_kelvin, ) -from .const import DEVICE, DOMAIN, NAME, SERIAL_NO - -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN +RESERVED_EFFECTS = ("*Solid*", "*Static*", "*Dynamic*") DEFAULT_NAME = "Nanoleaf" -ICON = "mdi:triangle-outline" - -SUPPORT_NANOLEAF = ( - SUPPORT_BRIGHTNESS - | SUPPORT_COLOR_TEMP - | SUPPORT_EFFECT - | SUPPORT_COLOR - | SUPPORT_TRANSITION -) - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, @@ -77,94 +64,74 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Nanoleaf light.""" - data = hass.data[DOMAIN][entry.entry_id] - async_add_entities([NanoleafLight(data[DEVICE], data[NAME], data[SERIAL_NO])], True) + nanoleaf: Nanoleaf = hass.data[DOMAIN][entry.entry_id] + async_add_entities([NanoleafLight(nanoleaf)]) class NanoleafLight(LightEntity): """Representation of a Nanoleaf Light.""" - def __init__(self, light, name, unique_id): + def __init__(self, nanoleaf: Nanoleaf) -> None: """Initialize an Nanoleaf light.""" - self._unique_id = unique_id - self._available = True - self._brightness = None - self._color_temp = None - self._effect = None - self._effects_list = None - self._light = light - self._name = name - self._hs_color = None - self._state = None - - @property - def available(self): - """Return availability.""" - return self._available + self._nanoleaf = nanoleaf + self._attr_unique_id = self._nanoleaf.serial_no + self._attr_name = self._nanoleaf.name + self._attr_min_mireds = 154 + self._attr_max_mireds = 833 @property def brightness(self): """Return the brightness of the light.""" - if self._brightness is not None: - return int(self._brightness * 2.55) - return None + return int(self._nanoleaf.brightness * 2.55) @property def color_temp(self): """Return the current color temperature.""" - if self._color_temp is not None: - return color_util.color_temperature_kelvin_to_mired(self._color_temp) - return None + return color_util.color_temperature_kelvin_to_mired( + self._nanoleaf.color_temperature + ) @property def effect(self): """Return the current effect.""" - return self._effect + # The API returns the *Solid* effect if the Nanoleaf is in HS or CT mode. + # The effects *Static* and *Dynamic* are not supported by Home Assistant. + # These reserved effects are implicitly set and are not in the effect_list. + # https://forum.nanoleaf.me/docs/openapi#_byoot0bams8f + return ( + None if self._nanoleaf.effect in RESERVED_EFFECTS else self._nanoleaf.effect + ) @property def effect_list(self): """Return the list of supported effects.""" - return self._effects_list - - @property - def min_mireds(self): - """Return the coldest color_temp that this light supports.""" - return 154 - - @property - def max_mireds(self): - """Return the warmest color_temp that this light supports.""" - return 833 - - @property - def unique_id(self): - """Return a unique ID.""" - return self._unique_id - - @property - def name(self): - """Return the display name of this light.""" - return self._name + return self._nanoleaf.effects_list @property def icon(self): """Return the icon to use in the frontend, if any.""" - return ICON + return "mdi:triangle-outline" @property def is_on(self): """Return true if light is on.""" - return self._light.is_on + return self._nanoleaf.is_on @property def hs_color(self): """Return the color in HS.""" - return self._hs_color + return self._nanoleaf.hue, self._nanoleaf.saturation @property def supported_features(self): """Flag supported features.""" - return SUPPORT_NANOLEAF + return ( + SUPPORT_BRIGHTNESS + | SUPPORT_COLOR_TEMP + | SUPPORT_EFFECT + | SUPPORT_COLOR + | SUPPORT_TRANSITION + ) async def async_turn_on(self, **kwargs): """Instruct the light to turn on.""" @@ -176,61 +143,43 @@ class NanoleafLight(LightEntity): if hs_color: hue, saturation = hs_color - await self._light.set_hue(int(hue)) - await self._light.set_saturation(int(saturation)) + await self._nanoleaf.set_hue(int(hue)) + await self._nanoleaf.set_saturation(int(saturation)) if color_temp_mired: - await self._light.set_color_temperature(mired_to_kelvin(color_temp_mired)) + await self._nanoleaf.set_color_temperature( + mired_to_kelvin(color_temp_mired) + ) if transition: if brightness: # tune to the required brightness in n seconds - await self._light.set_brightness( + await self._nanoleaf.set_brightness( int(brightness / 2.55), transition=int(kwargs[ATTR_TRANSITION]) ) else: # If brightness is not specified, assume full brightness - await self._light.set_brightness( - 100, transition=int(kwargs[ATTR_TRANSITION]) - ) + await self._nanoleaf.set_brightness(100, transition=int(transition)) else: # If no transition is occurring, turn on the light - await self._light.turn_on() + await self._nanoleaf.turn_on() if brightness: - await self._light.set_brightness(int(brightness / 2.55)) + await self._nanoleaf.set_brightness(int(brightness / 2.55)) if effect: - if effect not in self._effects_list: + if effect not in self.effect_list: raise ValueError( f"Attempting to apply effect not in the effect list: '{effect}'" ) - await self._light.set_effect(effect) + await self._nanoleaf.set_effect(effect) async def async_turn_off(self, **kwargs): """Instruct the light to turn off.""" transition = kwargs.get(ATTR_TRANSITION) - if transition: - await self._light.set_brightness(0, transition=int(transition)) - else: - await self._light.turn_off() + await self._nanoleaf.turn_off(transition) async def async_update(self) -> None: """Fetch new state data for this light.""" try: - await self._light.get_info() + await self._nanoleaf.get_info() except ServerDisconnectedError: # Retry the request once if the device disconnected - await self._light.get_info() + await self._nanoleaf.get_info() except Unavailable: - self._available = False + self._attr_available = False return - self._available = True - self._brightness = self._light.brightness - self._effects_list = self._light.effects_list - # Nanoleaf api returns non-existent effect named "*Solid*" when light set to solid color. - # This causes various issues with scening (see https://github.com/home-assistant/core/issues/36359). - # Until fixed at the library level, we should ensure the effect exists before saving to light properties - self._effect = ( - self._light.effect if self._light.effect in self._effects_list else None - ) - if self._effect is None: - self._color_temp = self._light.color_temperature - self._hs_color = self._light.hue, self._light.saturation - else: - self._color_temp = None - self._hs_color = None - self._state = self._light.is_on + self._attr_available = True From e76ddb4b27b2fd93a66df9371efc9af251b6628f Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Tue, 28 Sep 2021 18:37:45 -0400 Subject: [PATCH 667/843] Add proper S2 support for adding zwave_js nodes (#56516) Co-authored-by: Martin Hjelmare --- homeassistant/components/zwave_js/api.py | 136 ++++++++-- tests/components/zwave_js/test_api.py | 248 ++++++++++++------ .../nortek_thermostat_added_event.json | 5 +- 3 files changed, 294 insertions(+), 95 deletions(-) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index adbdb10cf89..f1a4150ad1a 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -10,7 +10,12 @@ from aiohttp import hdrs, web, web_exceptions, web_request import voluptuous as vol from zwave_js_server import dump from zwave_js_server.client import Client -from zwave_js_server.const import CommandClass, InclusionStrategy, LogLevel +from zwave_js_server.const import ( + CommandClass, + InclusionStrategy, + LogLevel, + SecurityClass, +) from zwave_js_server.exceptions import ( BaseZwaveJSServerError, FailedCommand, @@ -19,7 +24,7 @@ from zwave_js_server.exceptions import ( SetValueFailed, ) from zwave_js_server.firmware import begin_firmware_update -from zwave_js_server.model.controller import ControllerStatistics +from zwave_js_server.model.controller import ControllerStatistics, InclusionGrant from zwave_js_server.model.firmware import ( FirmwareUpdateFinished, FirmwareUpdateProgress, @@ -67,7 +72,8 @@ TYPE = "type" PROPERTY = "property" PROPERTY_KEY = "property_key" VALUE = "value" -SECURE = "secure" +INCLUSION_STRATEGY = "inclusion_strategy" +PIN = "pin" # constants for log config commands CONFIG = "config" @@ -85,6 +91,10 @@ STATUS = "status" ENABLED = "enabled" OPTED_IN = "opted_in" +# constants for granting security classes +SECURITY_CLASSES = "security_classes" +CLIENT_SIDE_AUTH = "client_side_auth" + def async_get_entry(orig_func: Callable) -> Callable: """Decorate async function to get entry.""" @@ -171,6 +181,8 @@ def async_register_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_node_metadata) websocket_api.async_register_command(hass, websocket_ping_node) websocket_api.async_register_command(hass, websocket_add_node) + websocket_api.async_register_command(hass, websocket_grant_security_classes) + websocket_api.async_register_command(hass, websocket_validate_dsk_and_enter_pin) websocket_api.async_register_command(hass, websocket_stop_inclusion) websocket_api.async_register_command(hass, websocket_stop_exclusion) websocket_api.async_register_command(hass, websocket_remove_node) @@ -371,7 +383,9 @@ async def websocket_ping_node( { vol.Required(TYPE): "zwave_js/add_node", vol.Required(ENTRY_ID): str, - vol.Optional(SECURE, default=False): bool, + vol.Optional(INCLUSION_STRATEGY, default=InclusionStrategy.DEFAULT): vol.In( + [strategy.value for strategy in InclusionStrategy] + ), } ) @websocket_api.async_response @@ -386,11 +400,7 @@ async def websocket_add_node( ) -> None: """Add a node to the Z-Wave network.""" controller = client.driver.controller - - if msg[SECURE]: - inclusion_strategy = InclusionStrategy.SECURITY_S0 - else: - inclusion_strategy = InclusionStrategy.INSECURE + inclusion_strategy = InclusionStrategy(msg[INCLUSION_STRATEGY]) @callback def async_cleanup() -> None: @@ -404,6 +414,26 @@ async def websocket_add_node( websocket_api.event_message(msg[ID], {"event": event["event"]}) ) + @callback + def forward_dsk(event: dict) -> None: + connection.send_message( + websocket_api.event_message( + msg[ID], {"event": event["event"], "dsk": event["dsk"]} + ) + ) + + @callback + def forward_requested_grant(event: dict) -> None: + connection.send_message( + websocket_api.event_message( + msg[ID], + { + "event": event["event"], + "requested_grant": event["requested_grant"].to_dict(), + }, + ) + ) + @callback def forward_stage(event: dict) -> None: connection.send_message( @@ -426,6 +456,7 @@ async def websocket_add_node( "node_id": node.node_id, "status": node.status, "ready": node.ready, + "low_security": event["result"].get("lowSecurity", False), } connection.send_message( websocket_api.event_message( @@ -452,6 +483,8 @@ async def websocket_add_node( controller.on("inclusion started", forward_event), controller.on("inclusion failed", forward_event), controller.on("inclusion stopped", forward_event), + controller.on("validate dsk and enter pin", forward_dsk), + controller.on("grant security classes", forward_requested_grant), controller.on("node added", node_added), async_dispatcher_connect( hass, EVENT_DEVICE_ADDED_TO_REGISTRY, device_registered @@ -465,6 +498,59 @@ async def websocket_add_node( ) +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zwave_js/grant_security_classes", + vol.Required(ENTRY_ID): str, + vol.Required(SECURITY_CLASSES): [ + vol.In([sec_cls.value for sec_cls in SecurityClass]) + ], + vol.Optional(CLIENT_SIDE_AUTH, default=False): bool, + } +) +@websocket_api.async_response +@async_handle_failed_command +@async_get_entry +async def websocket_grant_security_classes( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict, + entry: ConfigEntry, + client: Client, +) -> None: + """Add a node to the Z-Wave network.""" + inclusion_grant = InclusionGrant( + [SecurityClass(sec_cls) for sec_cls in msg[SECURITY_CLASSES]], + msg[CLIENT_SIDE_AUTH], + ) + await client.driver.controller.async_grant_security_classes(inclusion_grant) + connection.send_result(msg[ID]) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zwave_js/validate_dsk_and_enter_pin", + vol.Required(ENTRY_ID): str, + vol.Required(PIN): str, + } +) +@websocket_api.async_response +@async_handle_failed_command +@async_get_entry +async def websocket_validate_dsk_and_enter_pin( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict, + entry: ConfigEntry, + client: Client, +) -> None: + """Add a node to the Z-Wave network.""" + await client.driver.controller.async_validate_dsk_and_enter_pin(msg[PIN]) + connection.send_result(msg[ID]) + + @websocket_api.require_admin @websocket_api.websocket_command( { @@ -583,7 +669,9 @@ async def websocket_remove_node( vol.Required(TYPE): "zwave_js/replace_failed_node", vol.Required(ENTRY_ID): str, vol.Required(NODE_ID): int, - vol.Optional(SECURE, default=False): bool, + vol.Optional(INCLUSION_STRATEGY, default=InclusionStrategy.DEFAULT): vol.In( + [strategy.value for strategy in InclusionStrategy] + ), } ) @websocket_api.async_response @@ -599,11 +687,7 @@ async def websocket_replace_failed_node( """Replace a failed node with a new node.""" controller = client.driver.controller node_id = msg[NODE_ID] - - if msg[SECURE]: - inclusion_strategy = InclusionStrategy.SECURITY_S0 - else: - inclusion_strategy = InclusionStrategy.INSECURE + inclusion_strategy = InclusionStrategy(msg[INCLUSION_STRATEGY]) @callback def async_cleanup() -> None: @@ -617,6 +701,26 @@ async def websocket_replace_failed_node( websocket_api.event_message(msg[ID], {"event": event["event"]}) ) + @callback + def forward_dsk(event: dict) -> None: + connection.send_message( + websocket_api.event_message( + msg[ID], {"event": event["event"], "dsk": event["dsk"]} + ) + ) + + @callback + def forward_requested_grant(event: dict) -> None: + connection.send_message( + websocket_api.event_message( + msg[ID], + { + "event": event["event"], + "requested_grant": event["requested_grant"].to_dict(), + }, + ) + ) + @callback def forward_stage(event: dict) -> None: connection.send_message( @@ -678,6 +782,8 @@ async def websocket_replace_failed_node( controller.on("inclusion started", forward_event), controller.on("inclusion failed", forward_event), controller.on("inclusion stopped", forward_event), + controller.on("validate dsk and enter pin", forward_dsk), + controller.on("grant security classes", forward_requested_grant), controller.on("node removed", node_removed), controller.on("node added", node_added), async_dispatcher_connect( diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 29c0ce4bba4..1551b55a429 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -3,7 +3,12 @@ import json from unittest.mock import patch import pytest -from zwave_js_server.const import CommandClass, InclusionStrategy, LogLevel +from zwave_js_server.const import ( + CommandClass, + InclusionStrategy, + LogLevel, + SecurityClass, +) from zwave_js_server.event import Event from zwave_js_server.exceptions import ( FailedCommand, @@ -16,6 +21,7 @@ from zwave_js_server.model.value import _get_value_id_from_dict, get_value_id from homeassistant.components.websocket_api.const import ERR_NOT_FOUND from homeassistant.components.zwave_js.api import ( + CLIENT_SIDE_AUTH, COMMAND_CLASS_ID, CONFIG, ENABLED, @@ -24,13 +30,15 @@ from homeassistant.components.zwave_js.api import ( FILENAME, FORCE_CONSOLE, ID, + INCLUSION_STRATEGY, LEVEL, LOG_TO_FILE, NODE_ID, OPTED_IN, + PIN, PROPERTY, PROPERTY_KEY, - SECURE, + SECURITY_CLASSES, TYPE, VALUE, ) @@ -354,31 +362,6 @@ async def test_ping_node( assert msg["error"]["code"] == ERR_NOT_LOADED -async def test_add_node_secure( - hass, nortek_thermostat_added_event, integration, client, hass_ws_client -): - """Test the add_node websocket command with secure flag.""" - entry = integration - ws_client = await hass_ws_client(hass) - - client.async_send_command.return_value = {"success": True} - - await ws_client.send_json( - {ID: 1, TYPE: "zwave_js/add_node", ENTRY_ID: entry.entry_id, SECURE: True} - ) - - msg = await ws_client.receive_json() - assert msg["success"] - - assert len(client.async_send_command.call_args_list) == 1 - assert client.async_send_command.call_args[0][0] == { - "command": "controller.begin_inclusion", - "options": {"strategy": InclusionStrategy.SECURITY_S0}, - } - - client.async_send_command.reset_mock() - - async def test_add_node( hass, nortek_thermostat_added_event, integration, client, hass_ws_client ): @@ -389,7 +372,12 @@ async def test_add_node( client.async_send_command.return_value = {"success": True} await ws_client.send_json( - {ID: 3, TYPE: "zwave_js/add_node", ENTRY_ID: entry.entry_id} + { + ID: 3, + TYPE: "zwave_js/add_node", + ENTRY_ID: entry.entry_id, + INCLUSION_STRATEGY: InclusionStrategy.DEFAULT.value, + } ) msg = await ws_client.receive_json() @@ -398,7 +386,7 @@ async def test_add_node( assert len(client.async_send_command.call_args_list) == 1 assert client.async_send_command.call_args[0][0] == { "command": "controller.begin_inclusion", - "options": {"strategy": InclusionStrategy.INSECURE}, + "options": {"strategy": InclusionStrategy.DEFAULT}, } event = Event( @@ -414,6 +402,37 @@ async def test_add_node( msg = await ws_client.receive_json() assert msg["event"]["event"] == "inclusion started" + event = Event( + type="grant security classes", + data={ + "source": "controller", + "event": "grant security classes", + "requested": {"securityClasses": [0, 1, 2, 7], "clientSideAuth": False}, + }, + ) + client.driver.receive_event(event) + + msg = await ws_client.receive_json() + assert msg["event"]["event"] == "grant security classes" + assert msg["event"]["requested_grant"] == { + "securityClasses": [0, 1, 2, 7], + "clientSideAuth": False, + } + + event = Event( + type="validate dsk and enter pin", + data={ + "source": "controller", + "event": "validate dsk and enter pin", + "dsk": "test", + }, + ) + client.driver.receive_event(event) + + msg = await ws_client.receive_json() + assert msg["event"]["event"] == "validate dsk and enter pin" + assert msg["event"]["dsk"] == "test" + client.driver.receive_event(nortek_thermostat_added_event) msg = await ws_client.receive_json() assert msg["event"]["event"] == "node added" @@ -421,6 +440,7 @@ async def test_add_node( "node_id": 67, "status": 0, "ready": False, + "low_security": False, } assert msg["event"]["node"] == node_details @@ -503,6 +523,94 @@ async def test_add_node( assert msg["error"]["code"] == ERR_NOT_LOADED +async def test_grant_security_classes(hass, integration, client, hass_ws_client): + """Test the grant_security_classes websocket command.""" + entry = integration + ws_client = await hass_ws_client(hass) + + client.async_send_command.return_value = {} + + await ws_client.send_json( + { + ID: 1, + TYPE: "zwave_js/grant_security_classes", + ENTRY_ID: entry.entry_id, + SECURITY_CLASSES: [SecurityClass.S2_UNAUTHENTICATED], + CLIENT_SIDE_AUTH: False, + } + ) + + msg = await ws_client.receive_json() + assert msg["success"] + + assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_args[0][0] == { + "command": "controller.grant_security_classes", + "inclusionGrant": {"securityClasses": [0], "clientSideAuth": False}, + } + + # Test sending command with not loaded entry fails + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + await ws_client.send_json( + { + ID: 4, + TYPE: "zwave_js/grant_security_classes", + ENTRY_ID: entry.entry_id, + SECURITY_CLASSES: [SecurityClass.S2_UNAUTHENTICATED], + CLIENT_SIDE_AUTH: False, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_LOADED + + +async def test_validate_dsk_and_enter_pin(hass, integration, client, hass_ws_client): + """Test the validate_dsk_and_enter_pin websocket command.""" + entry = integration + ws_client = await hass_ws_client(hass) + + client.async_send_command.return_value = {} + + await ws_client.send_json( + { + ID: 1, + TYPE: "zwave_js/validate_dsk_and_enter_pin", + ENTRY_ID: entry.entry_id, + PIN: "test", + } + ) + + msg = await ws_client.receive_json() + assert msg["success"] + + assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_args[0][0] == { + "command": "controller.validate_dsk_and_enter_pin", + "pin": "test", + } + + # Test sending command with not loaded entry fails + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + await ws_client.send_json( + { + ID: 4, + TYPE: "zwave_js/validate_dsk_and_enter_pin", + ENTRY_ID: entry.entry_id, + PIN: "test", + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_LOADED + + async def test_cancel_inclusion_exclusion(hass, integration, client, hass_ws_client): """Test cancelling the inclusion and exclusion process.""" entry = integration @@ -607,7 +715,6 @@ async def test_remove_node( data={ "source": "controller", "event": "exclusion started", - "secure": False, }, ) client.driver.receive_event(event) @@ -666,52 +773,6 @@ async def test_remove_node( assert msg["error"]["code"] == ERR_NOT_LOADED -async def test_replace_failed_node_secure( - hass, - nortek_thermostat, - integration, - client, - hass_ws_client, -): - """Test the replace_failed_node websocket command with secure flag.""" - entry = integration - ws_client = await hass_ws_client(hass) - - dev_reg = dr.async_get(hass) - - # Create device registry entry for mock node - dev_reg.async_get_or_create( - config_entry_id=entry.entry_id, - identifiers={(DOMAIN, "3245146787-67")}, - name="Node 67", - ) - - client.async_send_command.return_value = {"success": True} - - await ws_client.send_json( - { - ID: 1, - TYPE: "zwave_js/replace_failed_node", - ENTRY_ID: entry.entry_id, - NODE_ID: 67, - SECURE: True, - } - ) - - msg = await ws_client.receive_json() - assert msg["success"] - assert msg["result"] - - assert len(client.async_send_command.call_args_list) == 1 - assert client.async_send_command.call_args[0][0] == { - "command": "controller.replace_failed_node", - "nodeId": nortek_thermostat.node_id, - "options": {"strategy": InclusionStrategy.SECURITY_S0}, - } - - client.async_send_command.reset_mock() - - async def test_replace_failed_node( hass, nortek_thermostat, @@ -744,6 +805,7 @@ async def test_replace_failed_node( TYPE: "zwave_js/replace_failed_node", ENTRY_ID: entry.entry_id, NODE_ID: 67, + INCLUSION_STRATEGY: InclusionStrategy.DEFAULT.value, } ) @@ -755,7 +817,7 @@ async def test_replace_failed_node( assert client.async_send_command.call_args[0][0] == { "command": "controller.replace_failed_node", "nodeId": nortek_thermostat.node_id, - "options": {"strategy": InclusionStrategy.INSECURE}, + "options": {"strategy": InclusionStrategy.DEFAULT}, } client.async_send_command.reset_mock() @@ -773,12 +835,42 @@ async def test_replace_failed_node( msg = await ws_client.receive_json() assert msg["event"]["event"] == "inclusion started" + event = Event( + type="grant security classes", + data={ + "source": "controller", + "event": "grant security classes", + "requested": {"securityClasses": [0, 1, 2, 7], "clientSideAuth": False}, + }, + ) + client.driver.receive_event(event) + + msg = await ws_client.receive_json() + assert msg["event"]["event"] == "grant security classes" + assert msg["event"]["requested_grant"] == { + "securityClasses": [0, 1, 2, 7], + "clientSideAuth": False, + } + + event = Event( + type="validate dsk and enter pin", + data={ + "source": "controller", + "event": "validate dsk and enter pin", + "dsk": "test", + }, + ) + client.driver.receive_event(event) + + msg = await ws_client.receive_json() + assert msg["event"]["event"] == "validate dsk and enter pin" + assert msg["event"]["dsk"] == "test" + event = Event( type="inclusion stopped", data={ "source": "controller", "event": "inclusion stopped", - "secure": False, }, ) client.driver.receive_event(event) diff --git a/tests/fixtures/zwave_js/nortek_thermostat_added_event.json b/tests/fixtures/zwave_js/nortek_thermostat_added_event.json index 0f90d2ae147..98ae03afbf2 100644 --- a/tests/fixtures/zwave_js/nortek_thermostat_added_event.json +++ b/tests/fixtures/zwave_js/nortek_thermostat_added_event.json @@ -251,5 +251,6 @@ } } ] - } -} \ No newline at end of file + }, + "result": {} +} From 160571888ced8c0f3aa753dd9ea6d5f6e5009cfa Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 29 Sep 2021 01:56:58 +0200 Subject: [PATCH 668/843] Use NamedTuple for intesishome swing settings (#56752) --- .../components/intesishome/climate.py | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/intesishome/climate.py b/homeassistant/components/intesishome/climate.py index d58efddeb3c..b93babf534e 100644 --- a/homeassistant/components/intesishome/climate.py +++ b/homeassistant/components/intesishome/climate.py @@ -1,6 +1,7 @@ """Support for IntesisHome and airconwithme Smart AC Controllers.""" import logging from random import randrange +from typing import NamedTuple from pyintesishome import IHAuthenticationError, IHConnectionError, IntesisHome import voluptuous as vol @@ -53,6 +54,14 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( } ) + +class SwingSettings(NamedTuple): + """Settings for swing mode.""" + + vvane: str + hvane: str + + MAP_IH_TO_HVAC_MODE = { "auto": HVAC_MODE_HEAT_COOL, "cool": HVAC_MODE_COOL, @@ -73,10 +82,10 @@ MAP_PRESET_MODE_TO_IH = {v: k for k, v in MAP_IH_TO_PRESET_MODE.items()} IH_SWING_STOP = "auto/stop" IH_SWING_SWING = "swing" MAP_SWING_TO_IH = { - SWING_OFF: {"vvane": IH_SWING_STOP, "hvane": IH_SWING_STOP}, - SWING_BOTH: {"vvane": IH_SWING_SWING, "hvane": IH_SWING_SWING}, - SWING_HORIZONTAL: {"vvane": IH_SWING_STOP, "hvane": IH_SWING_SWING}, - SWING_VERTICAL: {"vvane": IH_SWING_SWING, "hvane": IH_SWING_STOP}, + SWING_OFF: SwingSettings(vvane=IH_SWING_STOP, hvane=IH_SWING_STOP), + SWING_BOTH: SwingSettings(vvane=IH_SWING_SWING, hvane=IH_SWING_SWING), + SWING_HORIZONTAL: SwingSettings(vvane=IH_SWING_STOP, hvane=IH_SWING_SWING), + SWING_VERTICAL: SwingSettings(vvane=IH_SWING_SWING, hvane=IH_SWING_STOP), } @@ -305,13 +314,12 @@ class IntesisAC(ClimateEntity): async def async_set_swing_mode(self, swing_mode): """Set the vertical vane.""" - swing_settings = MAP_SWING_TO_IH.get(swing_mode) - if swing_settings: + if swing_settings := MAP_SWING_TO_IH.get(swing_mode): await self._controller.set_vertical_vane( - self._device_id, swing_settings.get("vvane") + self._device_id, swing_settings.vvane ) await self._controller.set_horizontal_vane( - self._device_id, swing_settings.get("hvane") + self._device_id, swing_settings.hvane ) async def async_update(self): From a91fbec198765df932f390177b6aa86047297c6e Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 29 Sep 2021 01:58:36 +0200 Subject: [PATCH 669/843] Use NamedTuple for esphome service metadata (#56754) --- homeassistant/components/esphome/__init__.py | 107 ++++++++++--------- 1 file changed, 58 insertions(+), 49 deletions(-) diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index e4a5de7541b..ed23aa7ec75 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -7,7 +7,7 @@ from dataclasses import dataclass, field import functools import logging import math -from typing import Any, Callable, Generic, TypeVar, cast, overload +from typing import Any, Callable, Generic, NamedTuple, TypeVar, cast, overload from aioesphomeapi import ( APIClient, @@ -569,51 +569,60 @@ async def _async_setup_device_registry( return device_entry.id +class ServiceMetadata(NamedTuple): + """Metadata for services.""" + + validator: Any + example: str + selector: dict[str, Any] + description: str | None = None + + ARG_TYPE_METADATA = { - UserServiceArgType.BOOL: { - "validator": cv.boolean, - "example": "False", - "selector": {"boolean": None}, - }, - UserServiceArgType.INT: { - "validator": vol.Coerce(int), - "example": "42", - "selector": {"number": {CONF_MODE: "box"}}, - }, - UserServiceArgType.FLOAT: { - "validator": vol.Coerce(float), - "example": "12.3", - "selector": {"number": {CONF_MODE: "box", "step": 1e-3}}, - }, - UserServiceArgType.STRING: { - "validator": cv.string, - "example": "Example text", - "selector": {"text": None}, - }, - UserServiceArgType.BOOL_ARRAY: { - "validator": [cv.boolean], - "description": "A list of boolean values.", - "example": "[True, False]", - "selector": {"object": {}}, - }, - UserServiceArgType.INT_ARRAY: { - "validator": [vol.Coerce(int)], - "description": "A list of integer values.", - "example": "[42, 34]", - "selector": {"object": {}}, - }, - UserServiceArgType.FLOAT_ARRAY: { - "validator": [vol.Coerce(float)], - "description": "A list of floating point numbers.", - "example": "[ 12.3, 34.5 ]", - "selector": {"object": {}}, - }, - UserServiceArgType.STRING_ARRAY: { - "validator": [cv.string], - "description": "A list of strings.", - "example": "['Example text', 'Another example']", - "selector": {"object": {}}, - }, + UserServiceArgType.BOOL: ServiceMetadata( + validator=cv.boolean, + example="False", + selector={"boolean": None}, + ), + UserServiceArgType.INT: ServiceMetadata( + validator=vol.Coerce(int), + example="42", + selector={"number": {CONF_MODE: "box"}}, + ), + UserServiceArgType.FLOAT: ServiceMetadata( + validator=vol.Coerce(float), + example="12.3", + selector={"number": {CONF_MODE: "box", "step": 1e-3}}, + ), + UserServiceArgType.STRING: ServiceMetadata( + validator=cv.string, + example="Example text", + selector={"text": None}, + ), + UserServiceArgType.BOOL_ARRAY: ServiceMetadata( + validator=[cv.boolean], + description="A list of boolean values.", + example="[True, False]", + selector={"object": {}}, + ), + UserServiceArgType.INT_ARRAY: ServiceMetadata( + validator=[vol.Coerce(int)], + description="A list of integer values.", + example="[42, 34]", + selector={"object": {}}, + ), + UserServiceArgType.FLOAT_ARRAY: ServiceMetadata( + validator=[vol.Coerce(float)], + description="A list of floating point numbers.", + example="[ 12.3, 34.5 ]", + selector={"object": {}}, + ), + UserServiceArgType.STRING_ARRAY: ServiceMetadata( + validator=[cv.string], + description="A list of strings.", + example="['Example text', 'Another example']", + selector={"object": {}}, + ), } @@ -636,13 +645,13 @@ async def _register_service( ) return metadata = ARG_TYPE_METADATA[arg.type] - schema[vol.Required(arg.name)] = metadata["validator"] + schema[vol.Required(arg.name)] = metadata.validator fields[arg.name] = { "name": arg.name, "required": True, - "description": metadata.get("description"), - "example": metadata["example"], - "selector": metadata["selector"], + "description": metadata.description, + "example": metadata.example, + "selector": metadata.selector, } async def execute_service(call: ServiceCall) -> None: From 15aafc8db6d02f898338e56a962430da756c7f1e Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 29 Sep 2021 01:59:40 +0200 Subject: [PATCH 670/843] Use NamedTuple for discovery service details (#56751) --- .../components/discovery/__init__.py | 43 +++++++++++-------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/discovery/__init__.py b/homeassistant/components/discovery/__init__.py index 3a925fb0579..bade569bb46 100644 --- a/homeassistant/components/discovery/__init__.py +++ b/homeassistant/components/discovery/__init__.py @@ -4,6 +4,7 @@ from __future__ import annotations from datetime import timedelta import json import logging +from typing import NamedTuple from netdisco.discovery import NetworkDiscovery import voluptuous as vol @@ -46,16 +47,24 @@ CONFIG_ENTRY_HANDLERS = { "logitech_mediaserver": "squeezebox", } + +class ServiceDetails(NamedTuple): + """Store service details.""" + + component: str + platform: str | None + + # These have no config flows SERVICE_HANDLERS = { - SERVICE_ENIGMA2: ("media_player", "enigma2"), - SERVICE_SABNZBD: ("sabnzbd", None), - "yamaha": ("media_player", "yamaha"), - "frontier_silicon": ("media_player", "frontier_silicon"), - "openhome": ("media_player", "openhome"), - "bose_soundtouch": ("media_player", "soundtouch"), - "bluesound": ("media_player", "bluesound"), - "lg_smart_device": ("media_player", "lg_soundbar"), + SERVICE_ENIGMA2: ServiceDetails("media_player", "enigma2"), + SERVICE_SABNZBD: ServiceDetails("sabnzbd", None), + "yamaha": ServiceDetails("media_player", "yamaha"), + "frontier_silicon": ServiceDetails("media_player", "frontier_silicon"), + "openhome": ServiceDetails("media_player", "openhome"), + "bose_soundtouch": ServiceDetails("media_player", "soundtouch"), + "bluesound": ServiceDetails("media_player", "bluesound"), + "lg_smart_device": ServiceDetails("media_player", "lg_soundbar"), } OPTIONAL_SERVICE_HANDLERS: dict[str, tuple[str, str | None]] = {} @@ -172,24 +181,24 @@ async def async_setup(hass, config): ) return - comp_plat = SERVICE_HANDLERS.get(service) + service_details = SERVICE_HANDLERS.get(service) - if not comp_plat and service in enabled_platforms: - comp_plat = OPTIONAL_SERVICE_HANDLERS[service] + if not service_details and service in enabled_platforms: + service_details = OPTIONAL_SERVICE_HANDLERS[service] # We do not know how to handle this service. - if not comp_plat: + if not service_details: logger.debug("Unknown service discovered: %s %s", service, info) return logger.info("Found new service: %s %s", service, info) - component, platform = comp_plat - - if platform is None: - await async_discover(hass, service, info, component, config) + if service_details.platform is None: + await async_discover(hass, service, info, service_details.component, config) else: - await async_load_platform(hass, component, platform, info, config) + await async_load_platform( + hass, service_details.component, service_details.platform, info, config + ) async def scan_devices(now): """Scan for devices.""" From 718f8d8bf796e9bb7cbdc29a7f2d19d79e9f5927 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 29 Sep 2021 02:00:19 +0200 Subject: [PATCH 671/843] Use NamedTuple for xbox media type details (#56753) --- homeassistant/components/xbox/browse_media.py | 36 ++++++++++++------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/xbox/browse_media.py b/homeassistant/components/xbox/browse_media.py index d1438a46f23..b6e5a89efb3 100644 --- a/homeassistant/components/xbox/browse_media.py +++ b/homeassistant/components/xbox/browse_media.py @@ -1,6 +1,8 @@ """Support for media browsing.""" from __future__ import annotations +from typing import NamedTuple + from xbox.webapi.api.client import XboxLiveClient from xbox.webapi.api.provider.catalog.const import HOME_APP_IDS, SYSTEM_PFN_ID_MAP from xbox.webapi.api.provider.catalog.models import ( @@ -23,15 +25,23 @@ from homeassistant.components.media_player.const import ( MEDIA_TYPE_GAME, ) + +class MediaTypeDetails(NamedTuple): + """Details for media type.""" + + type: str + cls: str + + TYPE_MAP = { - "App": { - "type": MEDIA_TYPE_APP, - "class": MEDIA_CLASS_APP, - }, - "Game": { - "type": MEDIA_TYPE_GAME, - "class": MEDIA_CLASS_GAME, - }, + "App": MediaTypeDetails( + type=MEDIA_TYPE_APP, + cls=MEDIA_CLASS_APP, + ), + "Game": MediaTypeDetails( + type=MEDIA_TYPE_GAME, + cls=MEDIA_CLASS_GAME, + ), } @@ -109,11 +119,11 @@ async def build_item_response( BrowseMedia( media_class=MEDIA_CLASS_DIRECTORY, media_content_id=c_type, - media_content_type=TYPE_MAP[c_type]["type"], + media_content_type=TYPE_MAP[c_type].type, title=f"{c_type}s", can_play=False, can_expand=True, - children_media_class=TYPE_MAP[c_type]["class"], + children_media_class=TYPE_MAP[c_type].cls, ) ) @@ -145,7 +155,7 @@ async def build_item_response( for app in apps.result if app.content_type == media_content_id and app.one_store_product_id ], - children_media_class=TYPE_MAP[media_content_id]["class"], + children_media_class=TYPE_MAP[media_content_id].cls, ) @@ -159,9 +169,9 @@ def item_payload(item: InstalledPackage, images: dict[str, list[Image]]): thumbnail = f"https:{thumbnail}" return BrowseMedia( - media_class=TYPE_MAP[item.content_type]["class"], + media_class=TYPE_MAP[item.content_type].cls, media_content_id=item.one_store_product_id, - media_content_type=TYPE_MAP[item.content_type]["type"], + media_content_type=TYPE_MAP[item.content_type].type, title=item.name, can_play=True, can_expand=False, From f7d95588f8804d9e27577d43953e7125a699bb16 Mon Sep 17 00:00:00 2001 From: Michael Chisholm Date: Wed, 29 Sep 2021 10:37:23 +1000 Subject: [PATCH 672/843] Provide most media metadata in DlnaDmrEntity (#56728) Co-authored-by: Steven Looman --- homeassistant/components/dlna_dmr/const.py | 44 +++++ .../components/dlna_dmr/manifest.json | 2 +- .../components/dlna_dmr/media_player.py | 159 ++++++++++++++---- homeassistant/components/ssdp/manifest.json | 2 +- homeassistant/components/upnp/manifest.json | 2 +- .../components/yeelight/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../components/dlna_dmr/test_media_player.py | 59 +++++-- 10 files changed, 227 insertions(+), 49 deletions(-) diff --git a/homeassistant/components/dlna_dmr/const.py b/homeassistant/components/dlna_dmr/const.py index 7b081469ca8..f3217fdafff 100644 --- a/homeassistant/components/dlna_dmr/const.py +++ b/homeassistant/components/dlna_dmr/const.py @@ -1,8 +1,12 @@ """Constants for the DLNA DMR component.""" +from __future__ import annotations +from collections.abc import Mapping import logging from typing import Final +from homeassistant.components.media_player import const as _mp_const + LOGGER = logging.getLogger(__package__) DOMAIN: Final = "dlna_dmr" @@ -14,3 +18,43 @@ CONF_POLL_AVAILABILITY: Final = "poll_availability" DEFAULT_NAME: Final = "DLNA Digital Media Renderer" CONNECT_TIMEOUT: Final = 10 + +# Map UPnP class to media_player media_content_type +MEDIA_TYPE_MAP: Mapping[str, str] = { + "object": _mp_const.MEDIA_TYPE_URL, + "object.item": _mp_const.MEDIA_TYPE_URL, + "object.item.imageItem": _mp_const.MEDIA_TYPE_IMAGE, + "object.item.imageItem.photo": _mp_const.MEDIA_TYPE_IMAGE, + "object.item.audioItem": _mp_const.MEDIA_TYPE_MUSIC, + "object.item.audioItem.musicTrack": _mp_const.MEDIA_TYPE_MUSIC, + "object.item.audioItem.audioBroadcast": _mp_const.MEDIA_TYPE_MUSIC, + "object.item.audioItem.audioBook": _mp_const.MEDIA_TYPE_PODCAST, + "object.item.videoItem": _mp_const.MEDIA_TYPE_VIDEO, + "object.item.videoItem.movie": _mp_const.MEDIA_TYPE_MOVIE, + "object.item.videoItem.videoBroadcast": _mp_const.MEDIA_TYPE_TVSHOW, + "object.item.videoItem.musicVideoClip": _mp_const.MEDIA_TYPE_VIDEO, + "object.item.playlistItem": _mp_const.MEDIA_TYPE_PLAYLIST, + "object.item.textItem": _mp_const.MEDIA_TYPE_URL, + "object.item.bookmarkItem": _mp_const.MEDIA_TYPE_URL, + "object.item.epgItem": _mp_const.MEDIA_TYPE_EPISODE, + "object.item.epgItem.audioProgram": _mp_const.MEDIA_TYPE_EPISODE, + "object.item.epgItem.videoProgram": _mp_const.MEDIA_TYPE_EPISODE, + "object.container": _mp_const.MEDIA_TYPE_PLAYLIST, + "object.container.person": _mp_const.MEDIA_TYPE_ARTIST, + "object.container.person.musicArtist": _mp_const.MEDIA_TYPE_ARTIST, + "object.container.playlistContainer": _mp_const.MEDIA_TYPE_PLAYLIST, + "object.container.album": _mp_const.MEDIA_TYPE_ALBUM, + "object.container.album.musicAlbum": _mp_const.MEDIA_TYPE_ALBUM, + "object.container.album.photoAlbum": _mp_const.MEDIA_TYPE_ALBUM, + "object.container.genre": _mp_const.MEDIA_TYPE_GENRE, + "object.container.genre.musicGenre": _mp_const.MEDIA_TYPE_GENRE, + "object.container.genre.movieGenre": _mp_const.MEDIA_TYPE_GENRE, + "object.container.channelGroup": _mp_const.MEDIA_TYPE_CHANNELS, + "object.container.channelGroup.audioChannelGroup": _mp_const.MEDIA_TYPE_CHANNELS, + "object.container.channelGroup.videoChannelGroup": _mp_const.MEDIA_TYPE_CHANNELS, + "object.container.epgContainer": _mp_const.MEDIA_TYPE_TVSHOW, + "object.container.storageSystem": _mp_const.MEDIA_TYPE_PLAYLIST, + "object.container.storageVolume": _mp_const.MEDIA_TYPE_PLAYLIST, + "object.container.storageFolder": _mp_const.MEDIA_TYPE_PLAYLIST, + "object.container.bookmarkFolder": _mp_const.MEDIA_TYPE_PLAYLIST, +} diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index 2e802ee876f..002228e28b3 100644 --- a/homeassistant/components/dlna_dmr/manifest.json +++ b/homeassistant/components/dlna_dmr/manifest.json @@ -3,7 +3,7 @@ "name": "DLNA Digital Media Renderer", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/dlna_dmr", - "requirements": ["async-upnp-client==0.22.3"], + "requirements": ["async-upnp-client==0.22.4"], "dependencies": ["network", "ssdp"], "ssdp": [ { diff --git a/homeassistant/components/dlna_dmr/media_player.py b/homeassistant/components/dlna_dmr/media_player.py index d7db104ee42..8542464e41e 100644 --- a/homeassistant/components/dlna_dmr/media_player.py +++ b/homeassistant/components/dlna_dmr/media_player.py @@ -37,7 +37,6 @@ from homeassistant.const import ( STATE_ON, STATE_PAUSED, STATE_PLAYING, - STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry, entity_registry @@ -51,6 +50,7 @@ from .const import ( DEFAULT_NAME, DOMAIN, LOGGER as _LOGGER, + MEDIA_TYPE_MAP, ) from .data import EventListenAddr, get_domain_data @@ -389,11 +389,6 @@ class DlnaDmrEntity(MediaPlayerEntity): domain_data = get_domain_data(self.hass) await domain_data.async_release_event_notifier(self._event_addr) - @property - def available(self) -> bool: - """Device is available when we have a connection to it.""" - return self._device is not None and self._device.profile_device.available - async def async_update(self) -> None: """Retrieve the latest data.""" if not self._device: @@ -426,6 +421,44 @@ class DlnaDmrEntity(MediaPlayerEntity): self.check_available = True self.schedule_update_ha_state() + @property + def available(self) -> bool: + """Device is available when we have a connection to it.""" + return self._device is not None and self._device.profile_device.available + + @property + def unique_id(self) -> str: + """Report the UDN (Unique Device Name) as this entity's unique ID.""" + return self.udn + + @property + def usn(self) -> str: + """Get the USN based on the UDN (Unique Device Name) and device type.""" + return f"{self.udn}::{self.device_type}" + + @property + def state(self) -> str | None: + """State of the player.""" + if not self._device or not self.available: + return STATE_OFF + if self._device.transport_state is None: + return STATE_ON + if self._device.transport_state in ( + TransportState.PLAYING, + TransportState.TRANSITIONING, + ): + return STATE_PLAYING + if self._device.transport_state in ( + TransportState.PAUSED_PLAYBACK, + TransportState.PAUSED_RECORDING, + ): + return STATE_PAUSED + if self._device.transport_state == TransportState.VENDOR_DEFINED: + # Unable to map this state to anything reasonable, so it's "Unknown" + return None + + return STATE_IDLE + @property def supported_features(self) -> int: """Flag media player features that are supported at this moment. @@ -552,7 +585,8 @@ class DlnaDmrEntity(MediaPlayerEntity): """Title of current playing media.""" if not self._device: return None - return self._device.media_title + # Use the best available title + return self._device.media_program_title or self._device.media_title @property def media_image_url(self) -> str | None: @@ -562,26 +596,18 @@ class DlnaDmrEntity(MediaPlayerEntity): return self._device.media_image_url @property - def state(self) -> str: - """State of the player.""" - if not self._device or not self.available: - return STATE_OFF - if self._device.transport_state is None: - return STATE_ON - if self._device.transport_state in ( - TransportState.PLAYING, - TransportState.TRANSITIONING, - ): - return STATE_PLAYING - if self._device.transport_state in ( - TransportState.PAUSED_PLAYBACK, - TransportState.PAUSED_RECORDING, - ): - return STATE_PAUSED - if self._device.transport_state == TransportState.VENDOR_DEFINED: - return STATE_UNKNOWN + def media_content_id(self) -> str | None: + """Content ID of current playing media.""" + if not self._device: + return None + return self._device.current_track_uri - return STATE_IDLE + @property + def media_content_type(self) -> str | None: + """Content type of current playing media.""" + if not self._device or not self._device.media_class: + return None + return MEDIA_TYPE_MAP.get(self._device.media_class) @property def media_duration(self) -> int | None: @@ -608,11 +634,80 @@ class DlnaDmrEntity(MediaPlayerEntity): return self._device.media_position_updated_at @property - def unique_id(self) -> str: - """Report the UDN (Unique Device Name) as this entity's unique ID.""" - return self.udn + def media_artist(self) -> str | None: + """Artist of current playing media, music track only.""" + if not self._device: + return None + return self._device.media_artist @property - def usn(self) -> str: - """Get the USN based on the UDN (Unique Device Name) and device type.""" - return f"{self.udn}::{self.device_type}" + def media_album_name(self) -> str | None: + """Album name of current playing media, music track only.""" + if not self._device: + return None + return self._device.media_album_name + + @property + def media_album_artist(self) -> str | None: + """Album artist of current playing media, music track only.""" + if not self._device: + return None + return self._device.media_album_artist + + @property + def media_track(self) -> int | None: + """Track number of current playing media, music track only.""" + if not self._device: + return None + return self._device.media_track_number + + @property + def media_series_title(self) -> str | None: + """Title of series of current playing media, TV show only.""" + if not self._device: + return None + return self._device.media_series_title + + @property + def media_season(self) -> str | None: + """Season number, starting at 1, of current playing media, TV show only.""" + if not self._device: + return None + # Some DMRs, like Kodi, leave this as 0 and encode the season & episode + # in the episode_number metadata, as {season:d}{episode:02d} + if ( + not self._device.media_season_number + or self._device.media_season_number == "0" + ) and self._device.media_episode_number: + try: + episode = int(self._device.media_episode_number, 10) + if episode > 100: + return str(episode // 100) + except ValueError: + pass + return self._device.media_season_number + + @property + def media_episode(self) -> str | None: + """Episode number of current playing media, TV show only.""" + if not self._device: + return None + # Complement to media_season math above + if ( + not self._device.media_season_number + or self._device.media_season_number == "0" + ) and self._device.media_episode_number: + try: + episode = int(self._device.media_episode_number, 10) + if episode > 100: + return str(episode % 100) + except ValueError: + pass + return self._device.media_episode_number + + @property + def media_channel(self) -> str | None: + """Channel name currently playing.""" + if not self._device: + return None + return self._device.media_channel_name diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index 6590e6fa756..3a6531fcacb 100644 --- a/homeassistant/components/ssdp/manifest.json +++ b/homeassistant/components/ssdp/manifest.json @@ -2,7 +2,7 @@ "domain": "ssdp", "name": "Simple Service Discovery Protocol (SSDP)", "documentation": "https://www.home-assistant.io/integrations/ssdp", - "requirements": ["async-upnp-client==0.22.3"], + "requirements": ["async-upnp-client==0.22.4"], "dependencies": ["network"], "after_dependencies": ["zeroconf"], "codeowners": [], diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index fb5912657c0..6ab3896cfdb 100644 --- a/homeassistant/components/upnp/manifest.json +++ b/homeassistant/components/upnp/manifest.json @@ -3,7 +3,7 @@ "name": "UPnP/IGD", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/upnp", - "requirements": ["async-upnp-client==0.22.3"], + "requirements": ["async-upnp-client==0.22.4"], "dependencies": ["network", "ssdp"], "codeowners": ["@StevenLooman","@ehendrix23"], "ssdp": [ diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index 3ecece7414f..163a718c0bb 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -2,7 +2,7 @@ "domain": "yeelight", "name": "Yeelight", "documentation": "https://www.home-assistant.io/integrations/yeelight", - "requirements": ["yeelight==0.7.5", "async-upnp-client==0.22.3"], + "requirements": ["yeelight==0.7.5", "async-upnp-client==0.22.4"], "codeowners": ["@rytilahti", "@zewelor", "@shenxn", "@starkillerOG"], "config_flow": true, "dependencies": ["network"], diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 8b0802b26a8..13867fb7afe 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -4,7 +4,7 @@ aiodiscover==1.4.2 aiohttp==3.7.4.post0 aiohttp_cors==0.7.0 astral==2.2 -async-upnp-client==0.22.3 +async-upnp-client==0.22.4 async_timeout==3.0.1 attrs==21.2.0 awesomeversion==21.8.1 diff --git a/requirements_all.txt b/requirements_all.txt index e907682bcb2..1a6b64cce2b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -330,7 +330,7 @@ asterisk_mbox==0.5.0 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.22.3 +async-upnp-client==0.22.4 # homeassistant.components.supla asyncpysupla==0.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 53aa50dbe7f..aca2471d108 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -224,7 +224,7 @@ arcam-fmj==0.7.0 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.22.3 +async-upnp-client==0.22.4 # homeassistant.components.aurora auroranoaa==0.0.2 diff --git a/tests/components/dlna_dmr/test_media_player.py b/tests/components/dlna_dmr/test_media_player.py index 99bdc14b553..4c27de1be67 100644 --- a/tests/components/dlna_dmr/test_media_player.py +++ b/tests/components/dlna_dmr/test_media_player.py @@ -2,9 +2,10 @@ from __future__ import annotations import asyncio -from collections.abc import AsyncIterable +from collections.abc import AsyncIterable, Mapping from datetime import timedelta from types import MappingProxyType +from typing import Any from unittest.mock import ANY, DEFAULT, Mock, patch from async_upnp_client.exceptions import UpnpConnectionError, UpnpError @@ -73,6 +74,8 @@ async def mock_entity_id( """ entity_id = await setup_mock_component(hass, config_entry_mock) + assert dmr_device_mock.async_subscribe_services.await_count == 1 + yield entity_id # Unload config entry to clean up @@ -97,6 +100,8 @@ async def mock_disconnected_entity_id( entity_id = await setup_mock_component(hass, config_entry_mock) + assert dmr_device_mock.async_subscribe_services.await_count == 0 + yield entity_id # Unload config entry to clean up @@ -239,7 +244,6 @@ async def test_setup_entry_with_options( async def test_event_subscribe_failure( hass: HomeAssistant, - domain_data_mock: Mock, config_entry_mock: MockConfigEntry, dmr_device_mock: Mock, ) -> None: @@ -310,11 +314,15 @@ async def test_available_device( await async_update_entity(hass, mock_entity_id) # Check attributes come directly from the device - entity_state = hass.states.get(mock_entity_id) - assert entity_state is not None - attrs = entity_state.attributes - assert attrs is not None + async def get_attrs() -> Mapping[str, Any]: + await async_update_entity(hass, mock_entity_id) + entity_state = hass.states.get(mock_entity_id) + assert entity_state is not None + attrs = entity_state.attributes + assert attrs is not None + return attrs + attrs = await get_attrs() assert attrs[mp_const.ATTR_MEDIA_VOLUME_LEVEL] is dmr_device_mock.volume_level assert attrs[mp_const.ATTR_MEDIA_VOLUME_MUTED] is dmr_device_mock.is_volume_muted assert attrs[mp_const.ATTR_MEDIA_DURATION] is dmr_device_mock.media_duration @@ -323,9 +331,43 @@ async def test_available_device( attrs[mp_const.ATTR_MEDIA_POSITION_UPDATED_AT] is dmr_device_mock.media_position_updated_at ) - assert attrs[mp_const.ATTR_MEDIA_TITLE] is dmr_device_mock.media_title + assert attrs[mp_const.ATTR_MEDIA_CONTENT_ID] is dmr_device_mock.current_track_uri + assert attrs[mp_const.ATTR_MEDIA_ARTIST] is dmr_device_mock.media_artist + assert attrs[mp_const.ATTR_MEDIA_ALBUM_NAME] is dmr_device_mock.media_album_name + assert attrs[mp_const.ATTR_MEDIA_ALBUM_ARTIST] is dmr_device_mock.media_album_artist + assert attrs[mp_const.ATTR_MEDIA_TRACK] is dmr_device_mock.media_track_number + assert attrs[mp_const.ATTR_MEDIA_SERIES_TITLE] is dmr_device_mock.media_series_title + assert attrs[mp_const.ATTR_MEDIA_SEASON] is dmr_device_mock.media_season_number + assert attrs[mp_const.ATTR_MEDIA_EPISODE] is dmr_device_mock.media_episode_number + assert attrs[mp_const.ATTR_MEDIA_CHANNEL] is dmr_device_mock.media_channel_name # Entity picture is cached, won't correspond to remote image assert isinstance(attrs[ha_const.ATTR_ENTITY_PICTURE], str) + # media_title depends on what is available + assert attrs[mp_const.ATTR_MEDIA_TITLE] is dmr_device_mock.media_program_title + dmr_device_mock.media_program_title = None + attrs = await get_attrs() + assert attrs[mp_const.ATTR_MEDIA_TITLE] is dmr_device_mock.media_title + # media_content_type is mapped from UPnP class to MediaPlayer type + dmr_device_mock.media_class = "object.item.audioItem.musicTrack" + attrs = await get_attrs() + assert attrs[mp_const.ATTR_MEDIA_CONTENT_TYPE] == mp_const.MEDIA_TYPE_MUSIC + dmr_device_mock.media_class = "object.item.videoItem.movie" + attrs = await get_attrs() + assert attrs[mp_const.ATTR_MEDIA_CONTENT_TYPE] == mp_const.MEDIA_TYPE_MOVIE + dmr_device_mock.media_class = "object.item.videoItem.videoBroadcast" + attrs = await get_attrs() + assert attrs[mp_const.ATTR_MEDIA_CONTENT_TYPE] == mp_const.MEDIA_TYPE_TVSHOW + # media_season & media_episode have a special case + dmr_device_mock.media_season_number = "0" + dmr_device_mock.media_episode_number = "123" + attrs = await get_attrs() + assert attrs[mp_const.ATTR_MEDIA_SEASON] == "1" + assert attrs[mp_const.ATTR_MEDIA_EPISODE] == "23" + dmr_device_mock.media_season_number = "0" + dmr_device_mock.media_episode_number = "S1E23" # Unexpected and not parsed + attrs = await get_attrs() + assert attrs[mp_const.ATTR_MEDIA_SEASON] == "0" + assert attrs[mp_const.ATTR_MEDIA_EPISODE] == "S1E23" # Check supported feature flags, one at a time. # tuple(async_upnp_client feature check property, HA feature flag) @@ -688,7 +730,6 @@ async def test_multiple_ssdp_alive( domain_data_mock: Mock, ssdp_scanner_mock: Mock, mock_disconnected_entity_id: str, - dmr_device_mock: Mock, ) -> None: """Test multiple SSDP alive notifications is ok, only connects to device once.""" domain_data_mock.upnp_factory.async_create_device.reset_mock() @@ -1028,7 +1069,6 @@ async def test_ssdp_bootid( async def test_become_unavailable( hass: HomeAssistant, - domain_data_mock: Mock, mock_entity_id: str, dmr_device_mock: Mock, ) -> None: @@ -1226,7 +1266,6 @@ async def test_config_update_connect_failure( hass: HomeAssistant, domain_data_mock: Mock, config_entry_mock: MockConfigEntry, - dmr_device_mock: Mock, mock_entity_id: str, ) -> None: """Test DlnaDmrEntity gracefully handles connect failure after config change.""" From cf36d0966d924671de0d63bce628bb0d6c3481f4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Sep 2021 19:57:22 -0500 Subject: [PATCH 673/843] Add coverage to verify tplink unique ids (#56746) --- tests/components/tplink/test_light.py | 18 ++++++++++ tests/components/tplink/test_sensor.py | 33 ++++++++++++++++++ tests/components/tplink/test_switch.py | 48 ++++++++++++++++++++++---- 3 files changed, 93 insertions(+), 6 deletions(-) diff --git a/tests/components/tplink/test_light.py b/tests/components/tplink/test_light.py index 6881faac9a2..19116005c37 100644 --- a/tests/components/tplink/test_light.py +++ b/tests/components/tplink/test_light.py @@ -18,6 +18,7 @@ from homeassistant.components.light import ( from homeassistant.components.tplink.const import DOMAIN from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from . import MAC_ADDRESS, _mocked_bulb, _patch_discovery, _patch_single_discovery @@ -25,6 +26,23 @@ from . import MAC_ADDRESS, _mocked_bulb, _patch_discovery, _patch_single_discove from tests.common import MockConfigEntry +async def test_light_unique_id(hass: HomeAssistant) -> None: + """Test a light unique id.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + bulb = _mocked_bulb() + bulb.color_temp = None + with _patch_discovery(device=bulb), _patch_single_discovery(device=bulb): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "light.my_bulb" + entity_registry = er.async_get(hass) + assert entity_registry.async_get(entity_id).unique_id == "AABBCCDDEEFF" + + async def test_color_light(hass: HomeAssistant) -> None: """Test a light.""" already_migrated_config_entry = MockConfigEntry( diff --git a/tests/components/tplink/test_sensor.py b/tests/components/tplink/test_sensor.py index 565c5b51ef5..839588d2756 100644 --- a/tests/components/tplink/test_sensor.py +++ b/tests/components/tplink/test_sensor.py @@ -5,6 +5,7 @@ from unittest.mock import Mock from homeassistant.components import tplink from homeassistant.components.tplink.const import DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from . import ( @@ -120,3 +121,35 @@ async def test_color_light_no_emeter(hass: HomeAssistant) -> None: ] for sensor_entity_id in not_expected: assert hass.states.get(sensor_entity_id) is None + + +async def test_sensor_unique_id(hass: HomeAssistant) -> None: + """Test a sensor unique ids.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + plug = _mocked_plug() + plug.color_temp = None + plug.has_emeter = True + plug.emeter_realtime = Mock( + power=100, + total=30, + voltage=121, + current=5, + ) + plug.emeter_today = None + with _patch_discovery(device=plug), _patch_single_discovery(device=plug): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + + expected = { + "sensor.my_plug_current_consumption": "aa:bb:cc:dd:ee:ff_current_power_w", + "sensor.my_plug_total_consumption": "aa:bb:cc:dd:ee:ff_total_energy_kwh", + "sensor.my_plug_today_s_consumption": "aa:bb:cc:dd:ee:ff_today_energy_kwh", + "sensor.my_plug_voltage": "aa:bb:cc:dd:ee:ff_voltage", + "sensor.my_plug_current": "aa:bb:cc:dd:ee:ff_current_a", + } + entity_registry = er.async_get(hass) + for sensor_entity_id, value in expected.items(): + assert entity_registry.async_get(sensor_entity_id).unique_id == value diff --git a/tests/components/tplink/test_switch.py b/tests/components/tplink/test_switch.py index f62051b2328..9e7f9189aab 100644 --- a/tests/components/tplink/test_switch.py +++ b/tests/components/tplink/test_switch.py @@ -10,6 +10,7 @@ from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.components.tplink.const import DOMAIN from homeassistant.const import ATTR_ENTITY_ID, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -52,6 +53,22 @@ async def test_plug(hass: HomeAssistant) -> None: plug.turn_on.reset_mock() +async def test_plug_unique_id(hass: HomeAssistant) -> None: + """Test a plug unique id.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + plug = _mocked_plug() + with _patch_discovery(device=plug), _patch_single_discovery(device=plug): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "switch.my_plug" + entity_registry = er.async_get(hass) + assert entity_registry.async_get(entity_id).unique_id == "aa:bb:cc:dd:ee:ff" + + async def test_plug_update_fails(hass: HomeAssistant) -> None: """Test a smart plug update failure.""" already_migrated_config_entry = MockConfigEntry( @@ -80,8 +97,8 @@ async def test_strip(hass: HomeAssistant) -> None: domain=DOMAIN, data={}, unique_id=MAC_ADDRESS ) already_migrated_config_entry.add_to_hass(hass) - plug = _mocked_strip() - with _patch_discovery(device=plug), _patch_single_discovery(device=plug): + strip = _mocked_strip() + with _patch_discovery(device=strip), _patch_single_discovery(device=strip): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() @@ -97,11 +114,30 @@ async def test_strip(hass: HomeAssistant) -> None: await hass.services.async_call( SWITCH_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True ) - plug.children[plug_id].turn_off.assert_called_once() - plug.children[plug_id].turn_off.reset_mock() + strip.children[plug_id].turn_off.assert_called_once() + strip.children[plug_id].turn_off.reset_mock() await hass.services.async_call( SWITCH_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True ) - plug.children[plug_id].turn_on.assert_called_once() - plug.children[plug_id].turn_on.reset_mock() + strip.children[plug_id].turn_on.assert_called_once() + strip.children[plug_id].turn_on.reset_mock() + + +async def test_strip_unique_ids(hass: HomeAssistant) -> None: + """Test a strip unique id.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + strip = _mocked_strip() + with _patch_discovery(device=strip), _patch_single_discovery(device=strip): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + + for plug_id in range(2): + entity_id = f"switch.plug{plug_id}" + entity_registry = er.async_get(hass) + assert ( + entity_registry.async_get(entity_id).unique_id == f"PLUG{plug_id}DEVICEID" + ) From 8e91e6e97ed083f828e02f3cb75df200e46cba54 Mon Sep 17 00:00:00 2001 From: Myles Eftos Date: Wed, 29 Sep 2021 11:01:35 +1000 Subject: [PATCH 674/843] Adding price spike binary sensor to the Amber electric integration (#56736) --- .../components/amberelectric/binary_sensor.py | 88 +++++++++++ .../components/amberelectric/const.py | 4 +- .../components/amberelectric/coordinator.py | 1 + .../components/amberelectric/sensor.py | 25 ++-- .../amberelectric/test_binary_sensor.py | 140 ++++++++++++++++++ .../amberelectric/test_coordinator.py | 42 ++++++ tests/components/amberelectric/test_sensor.py | 14 +- 7 files changed, 291 insertions(+), 23 deletions(-) create mode 100644 homeassistant/components/amberelectric/binary_sensor.py create mode 100644 tests/components/amberelectric/test_binary_sensor.py diff --git a/homeassistant/components/amberelectric/binary_sensor.py b/homeassistant/components/amberelectric/binary_sensor.py new file mode 100644 index 00000000000..aff19c6f695 --- /dev/null +++ b/homeassistant/components/amberelectric/binary_sensor.py @@ -0,0 +1,88 @@ +"""Amber Electric Binary Sensor definitions.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_ATTRIBUTION +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ATTRIBUTION, DOMAIN +from .coordinator import AmberUpdateCoordinator + +PRICE_SPIKE_ICONS = { + "none": "mdi:power-plug", + "potential": "mdi:power-plug-outline", + "spike": "mdi:power-plug-off", +} + + +class AmberPriceGridSensor(CoordinatorEntity, BinarySensorEntity): + """Sensor to show single grid binary values.""" + + def __init__( + self, + coordinator: AmberUpdateCoordinator, + description: BinarySensorEntityDescription, + ) -> None: + """Initialize the Sensor.""" + super().__init__(coordinator) + self.site_id = coordinator.site_id + self.entity_description = description + self._attr_unique_id = f"{coordinator.site_id}-{description.key}" + self._attr_device_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} + + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + return self.coordinator.data["grid"][self.entity_description.key] + + +class AmberPriceSpikeBinarySensor(AmberPriceGridSensor): + """Sensor to show single grid binary values.""" + + @property + def icon(self): + """Return the sensor icon.""" + status = self.coordinator.data["grid"]["price_spike"] + return PRICE_SPIKE_ICONS[status] + + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + return self.coordinator.data["grid"]["price_spike"] == "spike" + + @property + def device_state_attributes(self) -> Mapping[str, Any] | None: + """Return additional pieces of information about the price spike.""" + + spike_status = self.coordinator.data["grid"]["price_spike"] + return { + "spike_status": spike_status, + ATTR_ATTRIBUTION: ATTRIBUTION, + } + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a config entry.""" + coordinator: AmberUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + entities: list = [] + price_spike_description = BinarySensorEntityDescription( + key="price_spike", + name=f"{entry.title} - Price Spike", + ) + entities.append(AmberPriceSpikeBinarySensor(coordinator, price_spike_description)) + async_add_entities(entities) diff --git a/homeassistant/components/amberelectric/const.py b/homeassistant/components/amberelectric/const.py index 23c92334da3..fe2e5f9bb88 100644 --- a/homeassistant/components/amberelectric/const.py +++ b/homeassistant/components/amberelectric/const.py @@ -7,5 +7,7 @@ CONF_SITE_NAME = "site_name" CONF_SITE_ID = "site_id" CONF_SITE_NMI = "site_nmi" +ATTRIBUTION = "Data provided by Amber Electric" + LOGGER = logging.getLogger(__package__) -PLATFORMS = ["sensor"] +PLATFORMS = ["sensor", "binary_sensor"] diff --git a/homeassistant/components/amberelectric/coordinator.py b/homeassistant/components/amberelectric/coordinator.py index 6db1d529fb3..904da59f65c 100644 --- a/homeassistant/components/amberelectric/coordinator.py +++ b/homeassistant/components/amberelectric/coordinator.py @@ -85,6 +85,7 @@ class AmberUpdateCoordinator(DataUpdateCoordinator): interval for interval in forecasts if is_general(interval) ] result["grid"]["renewables"] = round(general[0].renewables) + result["grid"]["price_spike"] = general[0].spike_status.value controlled_load = [ interval for interval in current if is_controlled_load(interval) diff --git a/homeassistant/components/amberelectric/sensor.py b/homeassistant/components/amberelectric/sensor.py index 079d65541fe..0a47615046e 100644 --- a/homeassistant/components/amberelectric/sensor.py +++ b/homeassistant/components/amberelectric/sensor.py @@ -25,11 +25,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN +from .const import ATTRIBUTION, DOMAIN from .coordinator import AmberUpdateCoordinator -ATTRIBUTION = "Data provided by Amber Electric" - ICONS = { "general": "mdi:transmission-tower", "controlled_load": "mdi:clock-outline", @@ -63,9 +61,6 @@ class AmberSensor(CoordinatorEntity, SensorEntity): self.entity_description = description self.channel_type = channel_type - @property - def unique_id(self) -> None: - """Return a unique id for each sensors.""" self._attr_unique_id = ( f"{self.site_id}-{self.entity_description.key}-{self.channel_type}" ) @@ -119,9 +114,11 @@ class AmberForecastSensor(AmberSensor): @property def native_value(self) -> str | None: """Return the first forecast price in $/kWh.""" - intervals = self.coordinator.data[self.entity_description.key][ + intervals = self.coordinator.data[self.entity_description.key].get( self.channel_type - ] + ) + if not intervals: + return None interval = intervals[0] if interval.channel_type == ChannelType.FEED_IN: @@ -131,9 +128,12 @@ class AmberForecastSensor(AmberSensor): @property def device_state_attributes(self) -> Mapping[str, Any] | None: """Return additional pieces of information about the price.""" - intervals = self.coordinator.data[self.entity_description.key][ + intervals = self.coordinator.data[self.entity_description.key].get( self.channel_type - ] + ) + + if not intervals: + return None data = { "forecasts": [], @@ -179,11 +179,6 @@ class AmberGridSensor(CoordinatorEntity, SensorEntity): self._attr_device_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} self._attr_unique_id = f"{coordinator.site_id}-{description.key}" - @property - def unique_id(self) -> None: - """Return a unique id for each sensors.""" - self._attr_unique_id = f"{self.site_id}-{self.entity_description.key}" - @property def native_value(self) -> str | None: """Return the value of the sensor.""" diff --git a/tests/components/amberelectric/test_binary_sensor.py b/tests/components/amberelectric/test_binary_sensor.py new file mode 100644 index 00000000000..9aa4782b9a4 --- /dev/null +++ b/tests/components/amberelectric/test_binary_sensor.py @@ -0,0 +1,140 @@ +"""Test the Amber Electric Sensors.""" +from __future__ import annotations + +from typing import AsyncGenerator +from unittest.mock import Mock, patch + +from amberelectric.model.channel import ChannelType +from amberelectric.model.current_interval import CurrentInterval +from amberelectric.model.interval import SpikeStatus +from dateutil import parser +import pytest + +from homeassistant.components.amberelectric.const import ( + CONF_API_TOKEN, + CONF_SITE_ID, + CONF_SITE_NAME, + DOMAIN, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry +from tests.components.amberelectric.helpers import ( + GENERAL_CHANNEL, + GENERAL_ONLY_SITE_ID, + generate_current_interval, +) + +MOCK_API_TOKEN = "psk_0000000000000000" + + +@pytest.fixture +async def setup_no_spike(hass) -> AsyncGenerator: + """Set up general channel.""" + MockConfigEntry( + domain="amberelectric", + data={ + CONF_SITE_NAME: "mock_title", + CONF_API_TOKEN: MOCK_API_TOKEN, + CONF_SITE_ID: GENERAL_ONLY_SITE_ID, + }, + ).add_to_hass(hass) + + instance = Mock() + with patch( + "amberelectric.api.AmberApi.create", + return_value=instance, + ) as mock_update: + instance.get_current_price = Mock(return_value=GENERAL_CHANNEL) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + yield mock_update.return_value + + +@pytest.fixture +async def setup_potential_spike(hass) -> AsyncGenerator: + """Set up general channel.""" + MockConfigEntry( + domain="amberelectric", + data={ + CONF_SITE_NAME: "mock_title", + CONF_API_TOKEN: MOCK_API_TOKEN, + CONF_SITE_ID: GENERAL_ONLY_SITE_ID, + }, + ).add_to_hass(hass) + + instance = Mock() + with patch( + "amberelectric.api.AmberApi.create", + return_value=instance, + ) as mock_update: + general_channel: list[CurrentInterval] = [ + generate_current_interval( + ChannelType.GENERAL, parser.parse("2021-09-21T08:30:00+10:00") + ), + ] + general_channel[0].spike_status = SpikeStatus.POTENTIAL + instance.get_current_price = Mock(return_value=general_channel) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + yield mock_update.return_value + + +@pytest.fixture +async def setup_spike(hass) -> AsyncGenerator: + """Set up general channel.""" + MockConfigEntry( + domain="amberelectric", + data={ + CONF_SITE_NAME: "mock_title", + CONF_API_TOKEN: MOCK_API_TOKEN, + CONF_SITE_ID: GENERAL_ONLY_SITE_ID, + }, + ).add_to_hass(hass) + + instance = Mock() + with patch( + "amberelectric.api.AmberApi.create", + return_value=instance, + ) as mock_update: + general_channel: list[CurrentInterval] = [ + generate_current_interval( + ChannelType.GENERAL, parser.parse("2021-09-21T08:30:00+10:00") + ), + ] + general_channel[0].spike_status = SpikeStatus.SPIKE + instance.get_current_price = Mock(return_value=general_channel) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + yield mock_update.return_value + + +def test_no_spike_sensor(hass: HomeAssistant, setup_no_spike) -> None: + """Testing the creation of the Amber renewables sensor.""" + assert len(hass.states.async_all()) == 4 + sensor = hass.states.get("binary_sensor.mock_title_price_spike") + assert sensor + assert sensor.state == "off" + assert sensor.attributes["icon"] == "mdi:power-plug" + assert sensor.attributes["spike_status"] == "none" + + +def test_potential_spike_sensor(hass: HomeAssistant, setup_potential_spike) -> None: + """Testing the creation of the Amber renewables sensor.""" + assert len(hass.states.async_all()) == 4 + sensor = hass.states.get("binary_sensor.mock_title_price_spike") + assert sensor + assert sensor.state == "off" + assert sensor.attributes["icon"] == "mdi:power-plug-outline" + assert sensor.attributes["spike_status"] == "potential" + + +def test_spike_sensor(hass: HomeAssistant, setup_spike) -> None: + """Testing the creation of the Amber renewables sensor.""" + assert len(hass.states.async_all()) == 4 + sensor = hass.states.get("binary_sensor.mock_title_price_spike") + assert sensor + assert sensor.state == "on" + assert sensor.attributes["icon"] == "mdi:power-plug-off" + assert sensor.attributes["spike_status"] == "spike" diff --git a/tests/components/amberelectric/test_coordinator.py b/tests/components/amberelectric/test_coordinator.py index 523172e2866..5085f9c50f8 100644 --- a/tests/components/amberelectric/test_coordinator.py +++ b/tests/components/amberelectric/test_coordinator.py @@ -1,10 +1,15 @@ """Tests for the Amber Electric Data Coordinator.""" +from __future__ import annotations + from typing import Generator from unittest.mock import Mock, patch from amberelectric import ApiException from amberelectric.model.channel import Channel, ChannelType +from amberelectric.model.current_interval import CurrentInterval +from amberelectric.model.interval import SpikeStatus from amberelectric.model.site import Site +from dateutil import parser import pytest from homeassistant.components.amberelectric.coordinator import AmberUpdateCoordinator @@ -18,6 +23,7 @@ from tests.components.amberelectric.helpers import ( GENERAL_AND_FEED_IN_SITE_ID, GENERAL_CHANNEL, GENERAL_ONLY_SITE_ID, + generate_current_interval, ) @@ -79,6 +85,7 @@ async def test_fetch_general_site(hass: HomeAssistant, current_price_api: Mock) assert result["current"].get("feed_in") is None assert result["forecasts"].get("feed_in") is None assert result["grid"]["renewables"] == round(GENERAL_CHANNEL[0].renewables) + assert result["grid"]["price_spike"] == "none" async def test_fetch_no_general_site( @@ -134,6 +141,7 @@ async def test_fetch_api_error(hass: HomeAssistant, current_price_api: Mock) -> assert result["current"].get("feed_in") is None assert result["forecasts"].get("feed_in") is None assert result["grid"]["renewables"] == round(GENERAL_CHANNEL[0].renewables) + assert result["grid"]["price_spike"] == "none" async def test_fetch_general_and_controlled_load_site( @@ -168,6 +176,7 @@ async def test_fetch_general_and_controlled_load_site( assert result["current"].get("feed_in") is None assert result["forecasts"].get("feed_in") is None assert result["grid"]["renewables"] == round(GENERAL_CHANNEL[0].renewables) + assert result["grid"]["price_spike"] == "none" async def test_fetch_general_and_feed_in_site( @@ -200,3 +209,36 @@ async def test_fetch_general_and_feed_in_site( FEED_IN_CHANNEL[3], ] assert result["grid"]["renewables"] == round(GENERAL_CHANNEL[0].renewables) + assert result["grid"]["price_spike"] == "none" + + +async def test_fetch_potential_spike( + hass: HomeAssistant, current_price_api: Mock +) -> None: + """Test fetching a site with only a general channel.""" + + general_channel: list[CurrentInterval] = [ + generate_current_interval( + ChannelType.GENERAL, parser.parse("2021-09-21T08:30:00+10:00") + ), + ] + general_channel[0].spike_status = SpikeStatus.POTENTIAL + current_price_api.get_current_price.return_value = general_channel + data_service = AmberUpdateCoordinator(hass, current_price_api, GENERAL_ONLY_SITE_ID) + result = await data_service._async_update_data() + assert result["grid"]["price_spike"] == "potential" + + +async def test_fetch_spike(hass: HomeAssistant, current_price_api: Mock) -> None: + """Test fetching a site with only a general channel.""" + + general_channel: list[CurrentInterval] = [ + generate_current_interval( + ChannelType.GENERAL, parser.parse("2021-09-21T08:30:00+10:00") + ), + ] + general_channel[0].spike_status = SpikeStatus.SPIKE + current_price_api.get_current_price.return_value = general_channel + data_service = AmberUpdateCoordinator(hass, current_price_api, GENERAL_ONLY_SITE_ID) + result = await data_service._async_update_data() + assert result["grid"]["price_spike"] == "spike" diff --git a/tests/components/amberelectric/test_sensor.py b/tests/components/amberelectric/test_sensor.py index 20a50658abb..865121bd1ee 100644 --- a/tests/components/amberelectric/test_sensor.py +++ b/tests/components/amberelectric/test_sensor.py @@ -101,7 +101,7 @@ async def setup_general_and_feed_in(hass) -> AsyncGenerator: async def test_general_price_sensor(hass: HomeAssistant, setup_general: Mock) -> None: """Test the General Price sensor.""" - assert len(hass.states.async_all()) == 3 + assert len(hass.states.async_all()) == 4 price = hass.states.get("sensor.mock_title_general_price") assert price assert price.state == "0.08" @@ -140,7 +140,7 @@ async def test_general_and_controlled_load_price_sensor( hass: HomeAssistant, setup_general_and_controlled_load: Mock ) -> None: """Test the Controlled Price sensor.""" - assert len(hass.states.async_all()) == 5 + assert len(hass.states.async_all()) == 6 print(hass.states) price = hass.states.get("sensor.mock_title_controlled_load_price") assert price @@ -164,7 +164,7 @@ async def test_general_and_feed_in_price_sensor( hass: HomeAssistant, setup_general_and_feed_in: Mock ) -> None: """Test the Feed In sensor.""" - assert len(hass.states.async_all()) == 5 + assert len(hass.states.async_all()) == 6 print(hass.states) price = hass.states.get("sensor.mock_title_feed_in_price") assert price @@ -188,7 +188,7 @@ async def test_general_forecast_sensor( hass: HomeAssistant, setup_general: Mock ) -> None: """Test the General Forecast sensor.""" - assert len(hass.states.async_all()) == 3 + assert len(hass.states.async_all()) == 4 price = hass.states.get("sensor.mock_title_general_forecast") assert price assert price.state == "0.09" @@ -230,7 +230,7 @@ async def test_controlled_load_forecast_sensor( hass: HomeAssistant, setup_general_and_controlled_load: Mock ) -> None: """Test the Controlled Load Forecast sensor.""" - assert len(hass.states.async_all()) == 5 + assert len(hass.states.async_all()) == 6 price = hass.states.get("sensor.mock_title_controlled_load_forecast") assert price assert price.state == "0.09" @@ -254,7 +254,7 @@ async def test_feed_in_forecast_sensor( hass: HomeAssistant, setup_general_and_feed_in: Mock ) -> None: """Test the Feed In Forecast sensor.""" - assert len(hass.states.async_all()) == 5 + assert len(hass.states.async_all()) == 6 price = hass.states.get("sensor.mock_title_feed_in_forecast") assert price assert price.state == "-0.09" @@ -276,7 +276,7 @@ async def test_feed_in_forecast_sensor( def test_renewable_sensor(hass: HomeAssistant, setup_general) -> None: """Testing the creation of the Amber renewables sensor.""" - assert len(hass.states.async_all()) == 3 + assert len(hass.states.async_all()) == 4 sensor = hass.states.get("sensor.mock_title_renewables") assert sensor assert sensor.state == "51" From 4513a462480db418686848a659538f6ff33c88e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Hickmann?= Date: Tue, 28 Sep 2021 22:53:43 -0300 Subject: [PATCH 675/843] Add zeroconf support for yeelight (#56758) --- .../components/yeelight/config_flow.py | 11 ++++ .../components/yeelight/manifest.json | 9 ++- homeassistant/generated/zeroconf.py | 4 ++ tests/components/yeelight/__init__.py | 11 ++++ tests/components/yeelight/test_config_flow.py | 65 +++++++++++++++++++ 5 files changed, 97 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/yeelight/config_flow.py b/homeassistant/components/yeelight/config_flow.py index 73bbcdcfe5f..d59e03c965d 100644 --- a/homeassistant/components/yeelight/config_flow.py +++ b/homeassistant/components/yeelight/config_flow.py @@ -60,6 +60,17 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._discovered_ip = discovery_info[IP_ADDRESS] return await self._async_handle_discovery() + async def async_step_zeroconf(self, discovery_info): + """Handle discovery from zeroconf.""" + self._discovered_ip = discovery_info["host"] + await self.async_set_unique_id( + "{0:#0{1}x}".format(int(discovery_info["name"][-26:-18]), 18) + ) + self._abort_if_unique_id_configured( + updates={CONF_HOST: self._discovered_ip}, reload_on_update=False + ) + return await self._async_handle_discovery() + async def async_step_ssdp(self, discovery_info): """Handle discovery from ssdp.""" self._discovered_ip = urlparse(discovery_info["location"]).hostname diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index 163a718c0bb..ca6fe09fe53 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -8,9 +8,12 @@ "dependencies": ["network"], "quality_scale": "platinum", "iot_class": "local_push", - "dhcp": [{ - "hostname": "yeelink-*" - }], + "dhcp": [ + { + "hostname": "yeelink-*" + } + ], + "zeroconf": [{ "type": "_miio._udp.local.", "name": "yeelink-*" }], "homekit": { "models": ["YL*"] } diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index cf94ff03a1c..da7c08df675 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -168,6 +168,10 @@ ZEROCONF = { }, { "domain": "xiaomi_miio" + }, + { + "domain": "yeelight", + "name": "yeelink-*" } ], "_nanoleafapi._tcp.local.": [ diff --git a/tests/components/yeelight/__init__.py b/tests/components/yeelight/__init__.py index 84035f61fdf..4d673dfaa94 100644 --- a/tests/components/yeelight/__init__.py +++ b/tests/components/yeelight/__init__.py @@ -37,6 +37,17 @@ CAPABILITIES = { "name": "", } +ID_DECIMAL = f"{int(ID, 16):08d}" + +ZEROCONF_DATA = { + "host": IP_ADDRESS, + "port": 54321, + "hostname": f"yeelink-light-strip1_miio{ID_DECIMAL}.local.", + "type": "_miio._udp.local.", + "name": f"yeelink-light-strip1_miio{ID_DECIMAL}._miio._udp.local.", + "properties": {"epoch": "1", "mac": "000000000000"}, +} + NAME = "name" SHORT_ID = hex(int("0x000000000015243f", 16)) UNIQUE_NAME = f"yeelight_{MODEL}_{SHORT_ID}" diff --git a/tests/components/yeelight/test_config_flow.py b/tests/components/yeelight/test_config_flow.py index 6bc3ba68275..8d4b7f48543 100644 --- a/tests/components/yeelight/test_config_flow.py +++ b/tests/components/yeelight/test_config_flow.py @@ -33,6 +33,7 @@ from . import ( MODULE_CONFIG_FLOW, NAME, UNIQUE_FRIENDLY_NAME, + ZEROCONF_DATA, _mocked_bulb, _patch_discovery, _patch_discovery_interval, @@ -576,3 +577,67 @@ async def test_discovered_ssdp(hass): assert result["type"] == RESULT_TYPE_ABORT assert result["reason"] == "already_configured" + + +async def test_discovered_zeroconf(hass): + """Test we can setup when discovered from zeroconf.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + mocked_bulb = _mocked_bulb() + with _patch_discovery(), _patch_discovery_interval(), patch( + f"{MODULE_CONFIG_FLOW}.AsyncBulb", return_value=mocked_bulb + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZEROCONF_DATA, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + with _patch_discovery(), _patch_discovery_interval(), patch( + f"{MODULE}.async_setup", return_value=True + ) as mock_async_setup, patch( + f"{MODULE}.async_setup_entry", return_value=True + ) as mock_async_setup_entry: + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["data"] == { + CONF_HOST: IP_ADDRESS, + CONF_ID: "0x000000000015243f", + CONF_MODEL: MODEL, + } + assert mock_async_setup.called + assert mock_async_setup_entry.called + + mocked_bulb = _mocked_bulb() + with _patch_discovery(), _patch_discovery_interval(), patch( + f"{MODULE_CONFIG_FLOW}.AsyncBulb", return_value=mocked_bulb + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZEROCONF_DATA, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + mocked_bulb = _mocked_bulb() + with _patch_discovery(), _patch_discovery_interval(), patch( + f"{MODULE_CONFIG_FLOW}.AsyncBulb", return_value=mocked_bulb + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=CAPABILITIES, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" From 34ef47db558f06f39cddd01455faa0848356e67e Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 29 Sep 2021 05:59:03 +0200 Subject: [PATCH 676/843] Fritz honor sys option pref_disable_new_entities (#56740) --- homeassistant/components/fritz/common.py | 33 ++++++++++++++++++- .../components/fritz/device_tracker.py | 24 ++++++++------ homeassistant/components/fritz/switch.py | 32 +++++++++++------- 3 files changed, 66 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/fritz/common.py b/homeassistant/components/fritz/common.py index 2a9a5e5cd2e..a8c77f2deb2 100644 --- a/homeassistant/components/fritz/common.py +++ b/homeassistant/components/fritz/common.py @@ -1,6 +1,7 @@ """Support for AVM FRITZ!Box classes.""" from __future__ import annotations +from collections.abc import ValuesView from dataclasses import dataclass, field from datetime import datetime, timedelta import logging @@ -42,6 +43,36 @@ from .const import ( _LOGGER = logging.getLogger(__name__) +def _is_tracked(mac: str, current_devices: ValuesView) -> bool: + """Check if device is already tracked.""" + for tracked in current_devices: + if mac in tracked: + return True + return False + + +def device_filter_out_from_trackers( + mac: str, + device: FritzDevice, + pref_disable_new_entities: bool, + current_devices: ValuesView, +) -> bool: + """Check if device should be filtered out from trackers.""" + reason: str | None = None + if device.ip_address == "": + reason = "Missing IP" + elif _is_tracked(mac, current_devices): + reason = "Already tracked" + elif pref_disable_new_entities: + reason = "Disabled System Options" + + if reason: + _LOGGER.debug( + "Skip adding device %s [%s], reason: %s", device.hostname, mac, reason + ) + return bool(reason) + + class ClassSetupMissing(Exception): """Raised when a Class func is called before setup.""" @@ -170,7 +201,7 @@ class FritzBoxTools: return self._unique_id @property - def devices(self) -> dict[str, Any]: + def devices(self) -> dict[str, FritzDevice]: """Return devices.""" return self._devices diff --git a/homeassistant/components/fritz/device_tracker.py b/homeassistant/components/fritz/device_tracker.py index e18ec8005cc..f3134f32a27 100644 --- a/homeassistant/components/fritz/device_tracker.py +++ b/homeassistant/components/fritz/device_tracker.py @@ -20,7 +20,13 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType -from .common import FritzBoxTools, FritzData, FritzDevice, FritzDeviceBase +from .common import ( + FritzBoxTools, + FritzData, + FritzDevice, + FritzDeviceBase, + device_filter_out_from_trackers, +) from .const import DATA_FRITZ, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -74,7 +80,9 @@ async def async_setup_entry( @callback def update_router() -> None: """Update the values of the router.""" - _async_add_entities(router, async_add_entities, data_fritz) + _async_add_entities( + router, async_add_entities, data_fritz, entry.pref_disable_new_entities + ) entry.async_on_unload( async_dispatcher_connect(hass, router.signal_device_new, update_router) @@ -88,22 +96,18 @@ def _async_add_entities( router: FritzBoxTools, async_add_entities: AddEntitiesCallback, data_fritz: FritzData, + pref_disable_new_entities: bool, ) -> None: """Add new tracker entities from the router.""" - def _is_tracked(mac: str) -> bool: - for tracked in data_fritz.tracked.values(): - if mac in tracked: - return True - - return False - new_tracked = [] if router.unique_id not in data_fritz.tracked: data_fritz.tracked[router.unique_id] = set() for mac, device in router.devices.items(): - if device.ip_address == "" or _is_tracked(mac): + if device_filter_out_from_trackers( + mac, device, pref_disable_new_entities, data_fritz.tracked.values() + ): continue new_tracked.append(FritzBoxTracker(router, device)) diff --git a/homeassistant/components/fritz/switch.py b/homeassistant/components/fritz/switch.py index 430817d4506..bde9cbcb4ce 100644 --- a/homeassistant/components/fritz/switch.py +++ b/homeassistant/components/fritz/switch.py @@ -31,6 +31,7 @@ from .common import ( FritzDevice, FritzDeviceBase, SwitchInfo, + device_filter_out_from_trackers, ) from .const import ( DATA_FRITZ, @@ -267,17 +268,12 @@ def wifi_entities_list( def profile_entities_list( - router: FritzBoxTools, data_fritz: FritzData + router: FritzBoxTools, + data_fritz: FritzData, + pref_disable_new_entities: bool, ) -> list[FritzBoxProfileSwitch]: """Add new tracker entities from the router.""" - def _is_tracked(mac: str) -> bool: - for tracked in data_fritz.profile_switches.values(): - if mac in tracked: - return True - - return False - new_profiles: list[FritzBoxProfileSwitch] = [] if "X_AVM-DE_HostFilter1" not in router.connection.services: @@ -287,7 +283,9 @@ def profile_entities_list( data_fritz.profile_switches[router.unique_id] = set() for mac, device in router.devices.items(): - if device.ip_address == "" or _is_tracked(mac): + if device_filter_out_from_trackers( + mac, device, pref_disable_new_entities, data_fritz.profile_switches.values() + ): continue new_profiles.append(FritzBoxProfileSwitch(router, device)) @@ -301,13 +299,14 @@ def all_entities_list( device_friendly_name: str, data_fritz: FritzData, local_ip: str, + pref_disable_new_entities: bool, ) -> list[Entity]: """Get a list of all entities.""" return [ *deflection_entities_list(fritzbox_tools, device_friendly_name), *port_entities_list(fritzbox_tools, device_friendly_name, local_ip), *wifi_entities_list(fritzbox_tools, device_friendly_name), - *profile_entities_list(fritzbox_tools, data_fritz), + *profile_entities_list(fritzbox_tools, data_fritz, pref_disable_new_entities), ] @@ -326,7 +325,12 @@ async def async_setup_entry( ) entities_list = await hass.async_add_executor_job( - all_entities_list, fritzbox_tools, entry.title, data_fritz, local_ip + all_entities_list, + fritzbox_tools, + entry.title, + data_fritz, + local_ip, + entry.pref_disable_new_entities, ) async_add_entities(entities_list) @@ -334,7 +338,11 @@ async def async_setup_entry( @callback def update_router() -> None: """Update the values of the router.""" - async_add_entities(profile_entities_list(fritzbox_tools, data_fritz)) + async_add_entities( + profile_entities_list( + fritzbox_tools, data_fritz, entry.pref_disable_new_entities + ) + ) entry.async_on_unload( async_dispatcher_connect(hass, fritzbox_tools.signal_device_new, update_router) From 115bb39c10e63b840bc09c7e6afb92a2868df133 Mon Sep 17 00:00:00 2001 From: Regev Brody Date: Wed, 29 Sep 2021 09:37:16 +0300 Subject: [PATCH 677/843] Fix cover group to handle unknown state properly (#56739) * fix cover group unknown state * fix cover grup state * fix cover group issue --- homeassistant/components/group/cover.py | 8 +++++-- tests/components/group/test_cover.py | 31 ++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/group/cover.py b/homeassistant/components/group/cover.py index 3870ad3cca5..45d88a07f88 100644 --- a/homeassistant/components/group/cover.py +++ b/homeassistant/components/group/cover.py @@ -37,6 +37,7 @@ from homeassistant.const import ( CONF_ENTITIES, CONF_NAME, CONF_UNIQUE_ID, + STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_OPENING, @@ -85,7 +86,7 @@ async def async_setup_platform( class CoverGroup(GroupEntity, CoverEntity): """Representation of a CoverGroup.""" - _attr_is_closed: bool | None = False + _attr_is_closed: bool | None = None _attr_is_opening: bool | None = False _attr_is_closing: bool | None = False _attr_current_cover_position: int | None = 100 @@ -258,7 +259,7 @@ class CoverGroup(GroupEntity, CoverEntity): """Update state and attributes.""" self._attr_assumed_state = False - self._attr_is_closed = True + self._attr_is_closed = None self._attr_is_closing = False self._attr_is_opening = False for entity_id in self._entities: @@ -268,6 +269,9 @@ class CoverGroup(GroupEntity, CoverEntity): if state.state == STATE_OPEN: self._attr_is_closed = False continue + if state.state == STATE_CLOSED: + self._attr_is_closed = True + continue if state.state == STATE_CLOSING: self._attr_is_closing = True continue diff --git a/tests/components/group/test_cover.py b/tests/components/group/test_cover.py index 758bc5e0dac..9d16be9150b 100644 --- a/tests/components/group/test_cover.py +++ b/tests/components/group/test_cover.py @@ -32,6 +32,7 @@ from homeassistant.const import ( STATE_CLOSING, STATE_OPEN, STATE_OPENING, + STATE_UNKNOWN, ) from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component @@ -99,7 +100,7 @@ async def setup_comp(hass, config_count): async def test_attributes(hass, setup_comp): """Test handling of state attributes.""" state = hass.states.get(COVER_GROUP) - assert state.state == STATE_CLOSED + assert state.state == STATE_UNKNOWN assert state.attributes[ATTR_FRIENDLY_NAME] == DEFAULT_NAME assert state.attributes[ATTR_ENTITY_ID] == [ DEMO_COVER, @@ -112,6 +113,34 @@ async def test_attributes(hass, setup_comp): assert ATTR_CURRENT_POSITION not in state.attributes assert ATTR_CURRENT_TILT_POSITION not in state.attributes + # Set entity as closed + hass.states.async_set(DEMO_COVER, STATE_CLOSED, {}) + await hass.async_block_till_done() + + state = hass.states.get(COVER_GROUP) + assert state.state == STATE_CLOSED + + # Set entity as opening + hass.states.async_set(DEMO_COVER, STATE_OPENING, {}) + await hass.async_block_till_done() + + state = hass.states.get(COVER_GROUP) + assert state.state == STATE_OPENING + + # Set entity as closing + hass.states.async_set(DEMO_COVER, STATE_CLOSING, {}) + await hass.async_block_till_done() + + state = hass.states.get(COVER_GROUP) + assert state.state == STATE_CLOSING + + # Set entity as unknown again + hass.states.async_set(DEMO_COVER, STATE_UNKNOWN, {}) + await hass.async_block_till_done() + + state = hass.states.get(COVER_GROUP) + assert state.state == STATE_UNKNOWN + # Add Entity that supports open / close / stop hass.states.async_set(DEMO_COVER, STATE_OPEN, {ATTR_SUPPORTED_FEATURES: 11}) await hass.async_block_till_done() From 6a9b484f2dfc025e37266f2167c3b6c1989c47af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Wed, 29 Sep 2021 09:46:05 +0200 Subject: [PATCH 678/843] Remove timeout for backup services (#56763) --- homeassistant/components/hassio/__init__.py | 21 +++++++++++---------- tests/components/hassio/test_init.py | 2 +- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 06dfb69b5f3..eacf5be5f9f 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -137,7 +137,7 @@ class APIEndpointSettings(NamedTuple): command: str schema: vol.Schema - timeout: int = 60 + timeout: int | None = 60 pass_data: bool = False @@ -154,37 +154,37 @@ MAP_SERVICE_API = { SERVICE_BACKUP_FULL: APIEndpointSettings( "/backups/new/full", SCHEMA_BACKUP_FULL, - 300, + None, True, ), SERVICE_BACKUP_PARTIAL: APIEndpointSettings( "/backups/new/partial", SCHEMA_BACKUP_PARTIAL, - 300, + None, True, ), SERVICE_RESTORE_FULL: APIEndpointSettings( "/backups/{slug}/restore/full", SCHEMA_RESTORE_FULL, - 300, + None, True, ), SERVICE_RESTORE_PARTIAL: APIEndpointSettings( "/backups/{slug}/restore/partial", SCHEMA_RESTORE_PARTIAL, - 300, + None, True, ), SERVICE_SNAPSHOT_FULL: APIEndpointSettings( "/backups/new/full", SCHEMA_BACKUP_FULL, - 300, + None, True, ), SERVICE_SNAPSHOT_PARTIAL: APIEndpointSettings( "/backups/new/partial", SCHEMA_BACKUP_PARTIAL, - 300, + None, True, ), } @@ -418,7 +418,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: hass.data[DOMAIN] = hassio = HassIO(hass.loop, websession, host) if not await hassio.is_connected(): - _LOGGER.warning("Not connected with Hass.io / system too busy!") + _LOGGER.warning("Not connected with the supervisor / system too busy!") store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) data = await store.async_load() @@ -520,8 +520,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: payload=payload, timeout=api_endpoint.timeout, ) - except HassioAPIError as err: - _LOGGER.error("Error on Supervisor API: %s", err) + except HassioAPIError: + # The exceptions are logged properly in hassio.send_command + pass for service, settings in MAP_SERVICE_API.items(): hass.services.async_register( diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 5af9908de3a..6e62545ec68 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -287,7 +287,7 @@ async def test_warn_when_cannot_connect(hass, caplog): assert result assert hass.components.hassio.is_hassio() - assert "Not connected with Hass.io / system too busy!" in caplog.text + assert "Not connected with the supervisor / system too busy!" in caplog.text async def test_service_register(hassio_env, hass): From deb0cc4116870218d033810d16dbe93c714de2e2 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Wed, 29 Sep 2021 11:25:06 +0200 Subject: [PATCH 679/843] Upgrade holidays to 0.11.3 (#56762) --- homeassistant/components/workday/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index f43003738df..c22df102c48 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -2,7 +2,7 @@ "domain": "workday", "name": "Workday", "documentation": "https://www.home-assistant.io/integrations/workday", - "requirements": ["holidays==0.11.2"], + "requirements": ["holidays==0.11.3"], "codeowners": ["@fabaff"], "quality_scale": "internal", "iot_class": "local_polling" diff --git a/requirements_all.txt b/requirements_all.txt index 1a6b64cce2b..3389acb1584 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -807,7 +807,7 @@ hlk-sw16==0.0.9 hole==0.5.1 # homeassistant.components.workday -holidays==0.11.2 +holidays==0.11.3 # homeassistant.components.frontend home-assistant-frontend==20210922.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index aca2471d108..3f35b80347a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -482,7 +482,7 @@ hlk-sw16==0.0.9 hole==0.5.1 # homeassistant.components.workday -holidays==0.11.2 +holidays==0.11.3 # homeassistant.components.frontend home-assistant-frontend==20210922.0 From be34a2ddea05b7275a033297060e8c0525146c50 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Wed, 29 Sep 2021 11:25:29 +0200 Subject: [PATCH 680/843] Upgrade beautifulsoup4 to 4.10.0 (#56764) --- homeassistant/components/scrape/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/scrape/manifest.json b/homeassistant/components/scrape/manifest.json index c57dd14e37d..09e6b4a4c0b 100644 --- a/homeassistant/components/scrape/manifest.json +++ b/homeassistant/components/scrape/manifest.json @@ -2,7 +2,7 @@ "domain": "scrape", "name": "Scrape", "documentation": "https://www.home-assistant.io/integrations/scrape", - "requirements": ["beautifulsoup4==4.9.3"], + "requirements": ["beautifulsoup4==4.10.0"], "after_dependencies": ["rest"], "codeowners": ["@fabaff"], "iot_class": "cloud_polling" diff --git a/requirements_all.txt b/requirements_all.txt index 3389acb1584..0c0debd867f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -375,7 +375,7 @@ batinfo==0.4.2 # beacontools[scan]==1.2.3 # homeassistant.components.scrape -beautifulsoup4==4.9.3 +beautifulsoup4==4.10.0 # homeassistant.components.beewi_smartclim # beewi_smartclim==0.0.10 From 52e9f76f94e4e04b149bd9ac67ec58347b53dc2b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 29 Sep 2021 11:25:50 +0200 Subject: [PATCH 681/843] Tweak DB migration to schema version 21 (#56767) --- homeassistant/components/recorder/migration.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 0e66585d86e..8eef957bc88 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -522,9 +522,11 @@ def _apply_update(instance, session, new_version, old_version): # noqa: C901 ) with contextlib.suppress(SQLAlchemyError): connection.execute( + # Using LOCK=EXCLUSIVE to prevent the database from corrupting + # https://github.com/home-assistant/core/issues/56104 text( f"ALTER TABLE {table} CONVERT TO " - "CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + "CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci LOCK=EXCLUSIVE" ) ) elif new_version == 22: From d3df6f26f91f7af9dcbe36e4885ef0aed01f57b0 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 29 Sep 2021 13:22:42 +0200 Subject: [PATCH 682/843] Add missing voltage sensor in Shelly integration (#56773) * Disable voltage sensor by default * Add voltage sensor for Shelly 2/2.5 * Enable emeter voltage by default --- homeassistant/components/shelly/sensor.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 8a1ac340d31..09e91946cf3 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -83,6 +83,14 @@ SENSORS: Final = { device_class=sensor.DEVICE_CLASS_POWER, state_class=sensor.STATE_CLASS_MEASUREMENT, ), + ("device", "voltage"): BlockAttributeDescription( + name="Voltage", + unit=ELECTRIC_POTENTIAL_VOLT, + value=lambda value: round(value, 1), + device_class=sensor.DEVICE_CLASS_VOLTAGE, + state_class=sensor.STATE_CLASS_MEASUREMENT, + default_enabled=False, + ), ("emeter", "voltage"): BlockAttributeDescription( name="Voltage", unit=ELECTRIC_POTENTIAL_VOLT, @@ -247,6 +255,7 @@ RPC_SENSORS: Final = { value=lambda status, _: round(float(status["voltage"]), 1), device_class=sensor.DEVICE_CLASS_VOLTAGE, state_class=sensor.STATE_CLASS_MEASUREMENT, + default_enabled=False, ), "energy": RpcAttributeDescription( key="switch", From 41e5f05d99a2f5cba7679d01b1aff12a4dacaadb Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 29 Sep 2021 13:24:34 +0200 Subject: [PATCH 683/843] Fix energy validation when not tracking costs (#56768) --- homeassistant/components/energy/validate.py | 39 ++++++---- tests/components/energy/test_validate.py | 82 +++++++++++++++++++++ 2 files changed, 106 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/energy/validate.py b/homeassistant/components/energy/validate.py index 29c5ae88b48..d03883d046b 100644 --- a/homeassistant/components/energy/validate.py +++ b/homeassistant/components/energy/validate.py @@ -272,12 +272,15 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: if flow.get("stat_cost") is not None: _async_validate_cost_stat(hass, flow["stat_cost"], source_result) + elif flow.get("entity_energy_price") is not None: + _async_validate_price_entity( + hass, flow["entity_energy_price"], source_result + ) - else: - if flow.get("entity_energy_price") is not None: - _async_validate_price_entity( - hass, flow["entity_energy_price"], source_result - ) + if ( + flow.get("entity_energy_price") is not None + or flow.get("number_energy_price") is not None + ): _async_validate_auto_generated_cost_entity( hass, hass.data[DOMAIN]["cost_sensors"][flow["stat_energy_from"]], @@ -298,12 +301,15 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: _async_validate_cost_stat( hass, flow["stat_compensation"], source_result ) + elif flow.get("entity_energy_price") is not None: + _async_validate_price_entity( + hass, flow["entity_energy_price"], source_result + ) - else: - if flow.get("entity_energy_price") is not None: - _async_validate_price_entity( - hass, flow["entity_energy_price"], source_result - ) + if ( + flow.get("entity_energy_price") is not None + or flow.get("number_energy_price") is not None + ): _async_validate_auto_generated_cost_entity( hass, hass.data[DOMAIN]["cost_sensors"][flow["stat_energy_to"]], @@ -322,12 +328,15 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: if source.get("stat_cost") is not None: _async_validate_cost_stat(hass, source["stat_cost"], source_result) + elif source.get("entity_energy_price") is not None: + _async_validate_price_entity( + hass, source["entity_energy_price"], source_result + ) - else: - if source.get("entity_energy_price") is not None: - _async_validate_price_entity( - hass, source["entity_energy_price"], source_result - ) + if ( + source.get("entity_energy_price") is not None + or source.get("number_energy_price") is not None + ): _async_validate_auto_generated_cost_entity( hass, hass.data[DOMAIN]["cost_sensors"][source["stat_energy_from"]], diff --git a/tests/components/energy/test_validate.py b/tests/components/energy/test_validate.py index d566eb51b28..1dd38047209 100644 --- a/tests/components/energy/test_validate.py +++ b/tests/components/energy/test_validate.py @@ -625,3 +625,85 @@ async def test_validation_gas(hass, mock_energy_manager, mock_is_entity_recorded ], "device_consumption": [], } + + +async def test_validation_gas_no_costs_tracking( + hass, mock_energy_manager, mock_is_entity_recorded +): + """Test validating gas with sensors without cost tracking.""" + await mock_energy_manager.async_update( + { + "energy_sources": [ + { + "type": "gas", + "stat_energy_from": "sensor.gas_consumption_1", + "stat_cost": None, + "entity_energy_from": None, + "entity_energy_price": None, + "number_energy_price": None, + }, + ] + } + ) + hass.states.async_set( + "sensor.gas_consumption_1", + "10.10", + { + "device_class": "gas", + "unit_of_measurement": "m³", + "state_class": "total_increasing", + }, + ) + + assert (await validate.async_validate(hass)).as_dict() == { + "energy_sources": [[]], + "device_consumption": [], + } + + +async def test_validation_grid_no_costs_tracking( + hass, mock_energy_manager, mock_is_entity_recorded +): + """Test validating grid with sensors for energy without cost tracking.""" + await mock_energy_manager.async_update( + { + "energy_sources": [ + { + "type": "grid", + "flow_from": [ + { + "stat_energy_from": "sensor.grid_energy", + "stat_cost": None, + "entity_energy_from": "sensor.grid_energy", + "entity_energy_price": None, + "number_energy_price": None, + }, + ], + "flow_to": [ + { + "stat_energy_to": "sensor.grid_energy", + "stat_cost": None, + "entity_energy_to": "sensor.grid_energy", + "entity_energy_price": None, + "number_energy_price": None, + }, + ], + "cost_adjustment_day": 0.0, + } + ] + } + ) + hass.states.async_set( + "sensor.grid_energy", + "10.10", + { + "device_class": "energy", + "unit_of_measurement": "kWh", + "state_class": "total_increasing", + }, + ) + + assert (await validate.async_validate(hass)).as_dict() == { + "energy_sources": [[]], + "device_consumption": [], + } From 565a9fea6be97c2facb838701992db9df825d3a9 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 29 Sep 2021 14:06:51 +0200 Subject: [PATCH 684/843] Import Callable from collections.abc (2) (#56776) --- homeassistant/components/amcrest/__init__.py | 3 ++- homeassistant/components/amcrest/binary_sensor.py | 3 ++- homeassistant/components/amcrest/camera.py | 3 ++- homeassistant/components/amcrest/sensor.py | 3 ++- homeassistant/components/azure_event_hub/__init__.py | 3 ++- homeassistant/components/camera/__init__.py | 4 ++-- homeassistant/components/climacell/const.py | 2 +- homeassistant/components/deconz/logbook.py | 2 +- homeassistant/components/dsmr_reader/definitions.py | 3 ++- homeassistant/components/firmata/pin.py | 4 +++- homeassistant/components/fritzbox/binary_sensor.py | 3 ++- homeassistant/components/fritzbox/model.py | 3 ++- homeassistant/components/fritzbox/sensor.py | 3 ++- homeassistant/components/frontend/storage.py | 3 ++- homeassistant/components/homematicip_cloud/hap.py | 3 ++- homeassistant/components/http/data_validator.py | 4 ++-- homeassistant/components/huawei_lte/__init__.py | 3 ++- homeassistant/components/huawei_lte/sensor.py | 3 ++- homeassistant/components/influxdb/__init__.py | 3 ++- homeassistant/components/keenetic_ndms2/router.py | 2 +- homeassistant/components/mobile_app/helpers.py | 2 +- homeassistant/components/mobile_app/push_notification.py | 4 +++- homeassistant/components/motioneye/__init__.py | 3 ++- homeassistant/components/mysensors/__init__.py | 2 +- homeassistant/components/mysensors/device_tracker.py | 3 ++- homeassistant/components/mysensors/gateway.py | 4 ++-- homeassistant/components/mysensors/helpers.py | 2 +- homeassistant/components/netgear/router.py | 6 ++++-- homeassistant/components/nmap_tracker/device_tracker.py | 3 ++- homeassistant/components/ovo_energy/sensor.py | 3 ++- homeassistant/components/picnic/const.py | 3 ++- homeassistant/components/recorder/__init__.py | 3 ++- homeassistant/components/recorder/purge.py | 3 ++- homeassistant/components/recorder/statistics.py | 4 ++-- homeassistant/components/recorder/util.py | 4 ++-- homeassistant/components/renault/renault_coordinator.py | 4 ++-- homeassistant/components/renault/renault_vehicle.py | 4 ++-- homeassistant/components/renault/select.py | 3 ++- homeassistant/components/renault/sensor.py | 3 ++- homeassistant/components/rfxtrx/sensor.py | 2 +- homeassistant/components/sonos/helpers.py | 3 ++- homeassistant/components/sonos/speaker.py | 4 ++-- homeassistant/components/synology_dsm/__init__.py | 3 ++- homeassistant/components/tile/device_tracker.py | 4 ++-- homeassistant/components/timer/__init__.py | 2 +- homeassistant/components/zha/core/decorators.py | 3 ++- homeassistant/components/zha/core/discovery.py | 2 +- homeassistant/components/zha/core/helpers.py | 4 ++-- homeassistant/components/zha/core/registries.py | 3 ++- homeassistant/components/zha/core/typing.py | 4 ++-- homeassistant/components/zwave_js/__init__.py | 2 +- homeassistant/components/zwave_js/api.py | 3 ++- homeassistant/components/zwave_js/helpers.py | 3 ++- 53 files changed, 100 insertions(+), 65 deletions(-) diff --git a/homeassistant/components/amcrest/__init__.py b/homeassistant/components/amcrest/__init__.py index 26247816ac9..bb8956f8b15 100644 --- a/homeassistant/components/amcrest/__init__.py +++ b/homeassistant/components/amcrest/__init__.py @@ -1,12 +1,13 @@ """Support for Amcrest IP cameras.""" from __future__ import annotations +from collections.abc import Callable from contextlib import suppress from dataclasses import dataclass from datetime import datetime, timedelta import logging import threading -from typing import Any, Callable +from typing import Any import aiohttp from amcrest import AmcrestError, ApiWrapper, LoginError diff --git a/homeassistant/components/amcrest/binary_sensor.py b/homeassistant/components/amcrest/binary_sensor.py index 48bc6727585..ea8f15d838c 100644 --- a/homeassistant/components/amcrest/binary_sensor.py +++ b/homeassistant/components/amcrest/binary_sensor.py @@ -1,11 +1,12 @@ """Support for Amcrest IP camera binary sensors.""" from __future__ import annotations +from collections.abc import Callable from contextlib import suppress from dataclasses import dataclass from datetime import timedelta import logging -from typing import TYPE_CHECKING, Callable +from typing import TYPE_CHECKING from amcrest import AmcrestError import voluptuous as vol diff --git a/homeassistant/components/amcrest/camera.py b/homeassistant/components/amcrest/camera.py index f118bd0da77..3c91607f96d 100644 --- a/homeassistant/components/amcrest/camera.py +++ b/homeassistant/components/amcrest/camera.py @@ -2,10 +2,11 @@ from __future__ import annotations import asyncio +from collections.abc import Callable from datetime import timedelta from functools import partial import logging -from typing import TYPE_CHECKING, Any, Callable +from typing import TYPE_CHECKING, Any from aiohttp import web from amcrest import AmcrestError diff --git a/homeassistant/components/amcrest/sensor.py b/homeassistant/components/amcrest/sensor.py index 87bb1d5c758..752aabc2c92 100644 --- a/homeassistant/components/amcrest/sensor.py +++ b/homeassistant/components/amcrest/sensor.py @@ -1,9 +1,10 @@ """Support for Amcrest IP camera sensors.""" from __future__ import annotations +from collections.abc import Callable from datetime import timedelta import logging -from typing import TYPE_CHECKING, Callable +from typing import TYPE_CHECKING from amcrest import AmcrestError diff --git a/homeassistant/components/azure_event_hub/__init__.py b/homeassistant/components/azure_event_hub/__init__.py index 9bae21ec43b..039542f9ed6 100644 --- a/homeassistant/components/azure_event_hub/__init__.py +++ b/homeassistant/components/azure_event_hub/__init__.py @@ -2,10 +2,11 @@ from __future__ import annotations import asyncio +from collections.abc import Callable import json import logging import time -from typing import Any, Callable +from typing import Any from azure.eventhub import EventData, EventDataBatch from azure.eventhub.aio import EventHubProducerClient, EventHubSharedKeyCredential diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 040a49dcc4a..bfa68fe67e6 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio import base64 import collections -from collections.abc import Awaitable, Mapping +from collections.abc import Awaitable, Callable, Mapping from contextlib import suppress from dataclasses import dataclass from datetime import datetime, timedelta @@ -14,7 +14,7 @@ import inspect import logging import os from random import SystemRandom -from typing import Callable, Final, cast, final +from typing import Final, cast, final from aiohttp import web import async_timeout diff --git a/homeassistant/components/climacell/const.py b/homeassistant/components/climacell/const.py index 162fbb01545..3b5bc360d7c 100644 --- a/homeassistant/components/climacell/const.py +++ b/homeassistant/components/climacell/const.py @@ -1,9 +1,9 @@ """Constants for the ClimaCell integration.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass from enum import IntEnum -from typing import Callable from pyclimacell.const import ( DAILY, diff --git a/homeassistant/components/deconz/logbook.py b/homeassistant/components/deconz/logbook.py index 16497d00ccb..0d7ad67dda6 100644 --- a/homeassistant/components/deconz/logbook.py +++ b/homeassistant/components/deconz/logbook.py @@ -1,7 +1,7 @@ """Describe deCONZ logbook events.""" from __future__ import annotations -from typing import Callable +from collections.abc import Callable from homeassistant.const import ATTR_DEVICE_ID, CONF_EVENT from homeassistant.core import HomeAssistant, callback diff --git a/homeassistant/components/dsmr_reader/definitions.py b/homeassistant/components/dsmr_reader/definitions.py index 1e9834e7e5e..1c719bc890b 100644 --- a/homeassistant/components/dsmr_reader/definitions.py +++ b/homeassistant/components/dsmr_reader/definitions.py @@ -1,8 +1,9 @@ """Definitions for DSMR Reader sensors added to MQTT.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass -from typing import Callable, Final +from typing import Final from homeassistant.components.sensor import ( STATE_CLASS_MEASUREMENT, diff --git a/homeassistant/components/firmata/pin.py b/homeassistant/components/firmata/pin.py index af07871efc7..6dadb07fd63 100644 --- a/homeassistant/components/firmata/pin.py +++ b/homeassistant/components/firmata/pin.py @@ -1,6 +1,8 @@ """Code to handle pins on a Firmata board.""" +from __future__ import annotations + +from collections.abc import Callable import logging -from typing import Callable from .board import FirmataBoard, FirmataPinType from .const import PIN_MODE_INPUT, PIN_MODE_PULLUP, PIN_TYPE_ANALOG diff --git a/homeassistant/components/fritzbox/binary_sensor.py b/homeassistant/components/fritzbox/binary_sensor.py index 25831da957c..1317710c570 100644 --- a/homeassistant/components/fritzbox/binary_sensor.py +++ b/homeassistant/components/fritzbox/binary_sensor.py @@ -1,8 +1,9 @@ """Support for Fritzbox binary sensors.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass -from typing import Callable, Final +from typing import Final from pyfritzhome.fritzhomedevice import FritzhomeDevice diff --git a/homeassistant/components/fritzbox/model.py b/homeassistant/components/fritzbox/model.py index baa8f656c02..fa6da56caeb 100644 --- a/homeassistant/components/fritzbox/model.py +++ b/homeassistant/components/fritzbox/model.py @@ -1,8 +1,9 @@ """Models for the AVM FRITZ!SmartHome integration.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass -from typing import Callable, TypedDict +from typing import TypedDict from pyfritzhome import FritzhomeDevice diff --git a/homeassistant/components/fritzbox/sensor.py b/homeassistant/components/fritzbox/sensor.py index 6f1cf49129d..7ff66f193c9 100644 --- a/homeassistant/components/fritzbox/sensor.py +++ b/homeassistant/components/fritzbox/sensor.py @@ -1,8 +1,9 @@ """Support for AVM FRITZ!SmartHome temperature sensor only devices.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass -from typing import Callable, Final +from typing import Final from pyfritzhome.fritzhomedevice import FritzhomeDevice diff --git a/homeassistant/components/frontend/storage.py b/homeassistant/components/frontend/storage.py index 294b707c965..0b04655bd86 100644 --- a/homeassistant/components/frontend/storage.py +++ b/homeassistant/components/frontend/storage.py @@ -1,8 +1,9 @@ """API for persistent storage for the frontend.""" from __future__ import annotations +from collections.abc import Callable from functools import wraps -from typing import Any, Callable +from typing import Any import voluptuous as vol diff --git a/homeassistant/components/homematicip_cloud/hap.py b/homeassistant/components/homematicip_cloud/hap.py index 3f8f6ae6086..a3537bff31b 100644 --- a/homeassistant/components/homematicip_cloud/hap.py +++ b/homeassistant/components/homematicip_cloud/hap.py @@ -2,8 +2,9 @@ from __future__ import annotations import asyncio +from collections.abc import Callable import logging -from typing import Any, Callable +from typing import Any from homematicip.aio.auth import AsyncAuth from homematicip.aio.home import AsyncHome diff --git a/homeassistant/components/http/data_validator.py b/homeassistant/components/http/data_validator.py index f64a3c4830e..cc661d43fd8 100644 --- a/homeassistant/components/http/data_validator.py +++ b/homeassistant/components/http/data_validator.py @@ -1,11 +1,11 @@ """Decorator for view methods to help with data validation.""" from __future__ import annotations -from collections.abc import Awaitable +from collections.abc import Awaitable, Callable from functools import wraps from http import HTTPStatus import logging -from typing import Any, Callable +from typing import Any from aiohttp import web import voluptuous as vol diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index ec9281659f5..92122f1b2be 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -2,11 +2,12 @@ from __future__ import annotations from collections import defaultdict +from collections.abc import Callable from contextlib import suppress from datetime import timedelta import logging import time -from typing import Any, Callable, cast +from typing import Any, cast import attr from huawei_lte_api.AuthorizedConnection import AuthorizedConnection diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py index 746e44687ca..f62450088ae 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -2,9 +2,10 @@ from __future__ import annotations from bisect import bisect +from collections.abc import Callable import logging import re -from typing import Callable, NamedTuple +from typing import NamedTuple import attr diff --git a/homeassistant/components/influxdb/__init__.py b/homeassistant/components/influxdb/__init__.py index bb5cf0173c1..407036e327c 100644 --- a/homeassistant/components/influxdb/__init__.py +++ b/homeassistant/components/influxdb/__init__.py @@ -1,6 +1,7 @@ """Support for sending data to an Influx database.""" from __future__ import annotations +from collections.abc import Callable from contextlib import suppress from dataclasses import dataclass import logging @@ -8,7 +9,7 @@ import math import queue import threading import time -from typing import Any, Callable +from typing import Any from influxdb import InfluxDBClient, exceptions from influxdb_client import InfluxDBClient as InfluxDBClientV2 diff --git a/homeassistant/components/keenetic_ndms2/router.py b/homeassistant/components/keenetic_ndms2/router.py index d79f2591525..8da8034a162 100644 --- a/homeassistant/components/keenetic_ndms2/router.py +++ b/homeassistant/components/keenetic_ndms2/router.py @@ -1,9 +1,9 @@ """The Keenetic Client class.""" from __future__ import annotations +from collections.abc import Callable from datetime import timedelta import logging -from typing import Callable from ndms2_client import Client, ConnectionException, Device, TelnetConnection from ndms2_client.client import RouterInfo diff --git a/homeassistant/components/mobile_app/helpers.py b/homeassistant/components/mobile_app/helpers.py index 9902e1d93d7..2325a75e630 100644 --- a/homeassistant/components/mobile_app/helpers.py +++ b/homeassistant/components/mobile_app/helpers.py @@ -1,9 +1,9 @@ """Helpers for mobile_app.""" from __future__ import annotations +from collections.abc import Callable import json import logging -from typing import Callable from aiohttp.web import Response, json_response from nacl.encoding import Base64Encoder diff --git a/homeassistant/components/mobile_app/push_notification.py b/homeassistant/components/mobile_app/push_notification.py index 1cc5bac5d1c..f3852895d32 100644 --- a/homeassistant/components/mobile_app/push_notification.py +++ b/homeassistant/components/mobile_app/push_notification.py @@ -1,6 +1,8 @@ """Push notification handling.""" +from __future__ import annotations + import asyncio -from typing import Callable +from collections.abc import Callable from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.event import async_call_later diff --git a/homeassistant/components/motioneye/__init__.py b/homeassistant/components/motioneye/__init__.py index 3eebcd4ee53..07385f24216 100644 --- a/homeassistant/components/motioneye/__init__.py +++ b/homeassistant/components/motioneye/__init__.py @@ -2,10 +2,11 @@ from __future__ import annotations import asyncio +from collections.abc import Callable import json import logging from types import MappingProxyType -from typing import Any, Callable +from typing import Any from urllib.parse import urlencode, urljoin from aiohttp.web import Request, Response diff --git a/homeassistant/components/mysensors/__init__.py b/homeassistant/components/mysensors/__init__.py index 3d0f219c2a8..b6ad78f5dc8 100644 --- a/homeassistant/components/mysensors/__init__.py +++ b/homeassistant/components/mysensors/__init__.py @@ -2,9 +2,9 @@ from __future__ import annotations import asyncio +from collections.abc import Callable from functools import partial import logging -from typing import Callable from mysensors import BaseAsyncGateway import voluptuous as vol diff --git a/homeassistant/components/mysensors/device_tracker.py b/homeassistant/components/mysensors/device_tracker.py index 544fb8d6b09..1dd29dbf864 100644 --- a/homeassistant/components/mysensors/device_tracker.py +++ b/homeassistant/components/mysensors/device_tracker.py @@ -1,7 +1,8 @@ """Support for tracking MySensors devices.""" from __future__ import annotations -from typing import Any, Callable +from collections.abc import Callable +from typing import Any from homeassistant.components import mysensors from homeassistant.components.device_tracker import DOMAIN diff --git a/homeassistant/components/mysensors/gateway.py b/homeassistant/components/mysensors/gateway.py index f9410f66e8f..1f9b96e6825 100644 --- a/homeassistant/components/mysensors/gateway.py +++ b/homeassistant/components/mysensors/gateway.py @@ -3,11 +3,11 @@ from __future__ import annotations import asyncio from collections import defaultdict -from collections.abc import Coroutine +from collections.abc import Callable, Coroutine import logging import socket import sys -from typing import Any, Callable +from typing import Any import async_timeout from mysensors import BaseAsyncGateway, Message, Sensor, mysensors diff --git a/homeassistant/components/mysensors/helpers.py b/homeassistant/components/mysensors/helpers.py index 7c50526cd6e..eba382fb52d 100644 --- a/homeassistant/components/mysensors/helpers.py +++ b/homeassistant/components/mysensors/helpers.py @@ -2,9 +2,9 @@ from __future__ import annotations from collections import defaultdict +from collections.abc import Callable from enum import IntEnum import logging -from typing import Callable from mysensors import BaseAsyncGateway, Message from mysensors.sensor import ChildSensor diff --git a/homeassistant/components/netgear/router.py b/homeassistant/components/netgear/router.py index a500bffb966..83b1aaa9f32 100644 --- a/homeassistant/components/netgear/router.py +++ b/homeassistant/components/netgear/router.py @@ -1,8 +1,10 @@ """Represent the Netgear router and its devices.""" +from __future__ import annotations + from abc import abstractmethod +from collections.abc import Callable from datetime import timedelta import logging -from typing import Callable from pynetgear import Netgear @@ -62,7 +64,7 @@ def async_setup_netgear_entry( hass: HomeAssistantType, entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - entity_class_generator: Callable[["NetgearRouter", dict], list], + entity_class_generator: Callable[[NetgearRouter, dict], list], ) -> None: """Set up device tracker for Netgear component.""" router = hass.data[DOMAIN][entry.unique_id] diff --git a/homeassistant/components/nmap_tracker/device_tracker.py b/homeassistant/components/nmap_tracker/device_tracker.py index e475afd24c8..b63280951a6 100644 --- a/homeassistant/components/nmap_tracker/device_tracker.py +++ b/homeassistant/components/nmap_tracker/device_tracker.py @@ -1,8 +1,9 @@ """Support for scanning a network with nmap.""" from __future__ import annotations +from collections.abc import Callable import logging -from typing import Any, Callable +from typing import Any import voluptuous as vol diff --git a/homeassistant/components/ovo_energy/sensor.py b/homeassistant/components/ovo_energy/sensor.py index 16fd15bfbde..0afca4f84f2 100644 --- a/homeassistant/components/ovo_energy/sensor.py +++ b/homeassistant/components/ovo_energy/sensor.py @@ -1,9 +1,10 @@ """Support for OVO Energy sensors.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass from datetime import timedelta -from typing import Callable, Final +from typing import Final from ovoenergy import OVODailyUsage from ovoenergy.ovoenergy import OVOEnergy diff --git a/homeassistant/components/picnic/const.py b/homeassistant/components/picnic/const.py index e37f85cb28b..5aa21fd671b 100644 --- a/homeassistant/components/picnic/const.py +++ b/homeassistant/components/picnic/const.py @@ -1,8 +1,9 @@ """Constants for the Picnic integration.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass -from typing import Any, Callable, Literal +from typing import Any, Literal from homeassistant.components.sensor import SensorEntityDescription from homeassistant.const import CURRENCY_EURO, DEVICE_CLASS_TIMESTAMP diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index b473dead17b..1b090c331a7 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from collections.abc import Callable import concurrent.futures from datetime import datetime, timedelta import logging @@ -9,7 +10,7 @@ import queue import sqlite3 import threading import time -from typing import Any, Callable, NamedTuple +from typing import Any, NamedTuple from sqlalchemy import create_engine, event as sqlalchemy_event, exc, func, select from sqlalchemy.exc import SQLAlchemyError diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index 49803117119..bc91f7ce67e 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -1,9 +1,10 @@ """Purge old data helper.""" from __future__ import annotations +from collections.abc import Callable from datetime import datetime import logging -from typing import TYPE_CHECKING, Callable +from typing import TYPE_CHECKING from sqlalchemy.orm.session import Session from sqlalchemy.sql.expression import distinct diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 845e13d40b3..ec76cc5545b 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -2,12 +2,12 @@ from __future__ import annotations from collections import defaultdict -from collections.abc import Iterable +from collections.abc import Callable, Iterable import dataclasses from datetime import datetime, timedelta from itertools import groupby import logging -from typing import TYPE_CHECKING, Any, Callable, Literal +from typing import TYPE_CHECKING, Any, Literal from sqlalchemy import bindparam, func from sqlalchemy.exc import SQLAlchemyError diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index 7e3948cf15b..101915c7117 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -1,14 +1,14 @@ """SQLAlchemy util functions.""" from __future__ import annotations -from collections.abc import Generator +from collections.abc import Callable, Generator from contextlib import contextmanager from datetime import timedelta import functools import logging import os import time -from typing import TYPE_CHECKING, Callable +from typing import TYPE_CHECKING from sqlalchemy import text from sqlalchemy.exc import OperationalError, SQLAlchemyError diff --git a/homeassistant/components/renault/renault_coordinator.py b/homeassistant/components/renault/renault_coordinator.py index 64e414a9ab7..4487d9db9ab 100644 --- a/homeassistant/components/renault/renault_coordinator.py +++ b/homeassistant/components/renault/renault_coordinator.py @@ -1,10 +1,10 @@ """Proxy to handle account communication with Renault servers.""" from __future__ import annotations -from collections.abc import Awaitable +from collections.abc import Awaitable, Callable from datetime import timedelta import logging -from typing import Callable, TypeVar +from typing import TypeVar from renault_api.kamereon.exceptions import ( AccessDeniedException, diff --git a/homeassistant/components/renault/renault_vehicle.py b/homeassistant/components/renault/renault_vehicle.py index 90bc4a2def4..12f5f4e8671 100644 --- a/homeassistant/components/renault/renault_vehicle.py +++ b/homeassistant/components/renault/renault_vehicle.py @@ -2,11 +2,11 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable +from collections.abc import Awaitable, Callable from dataclasses import dataclass from datetime import timedelta import logging -from typing import Callable, cast +from typing import cast from renault_api.kamereon import models from renault_api.renault_vehicle import RenaultVehicle diff --git a/homeassistant/components/renault/select.py b/homeassistant/components/renault/select.py index fa9c491030d..a8f4a15dc21 100644 --- a/homeassistant/components/renault/select.py +++ b/homeassistant/components/renault/select.py @@ -1,8 +1,9 @@ """Support for Renault sensors.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass -from typing import Callable, cast +from typing import cast from renault_api.kamereon.models import KamereonVehicleBatteryStatusData diff --git a/homeassistant/components/renault/sensor.py b/homeassistant/components/renault/sensor.py index 4cb0d723234..bcdb01a05f3 100644 --- a/homeassistant/components/renault/sensor.py +++ b/homeassistant/components/renault/sensor.py @@ -1,8 +1,9 @@ """Support for Renault sensors.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass -from typing import Callable, cast +from typing import cast from renault_api.kamereon.enums import ChargeState, PlugState from renault_api.kamereon.models import ( diff --git a/homeassistant/components/rfxtrx/sensor.py b/homeassistant/components/rfxtrx/sensor.py index fd3be53bfda..c72d2e288e1 100644 --- a/homeassistant/components/rfxtrx/sensor.py +++ b/homeassistant/components/rfxtrx/sensor.py @@ -1,9 +1,9 @@ """Support for RFXtrx sensors.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass import logging -from typing import Callable from RFXtrx import ControlEvent, SensorEvent diff --git a/homeassistant/components/sonos/helpers.py b/homeassistant/components/sonos/helpers.py index 2e5b3a8c032..01a75eb7747 100644 --- a/homeassistant/components/sonos/helpers.py +++ b/homeassistant/components/sonos/helpers.py @@ -1,9 +1,10 @@ """Helper methods for common tasks.""" from __future__ import annotations +from collections.abc import Callable import functools as ft import logging -from typing import Any, Callable +from typing import Any from soco.exceptions import SoCoException, SoCoUPnPException diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index ea49175b665..851711c2e12 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -2,12 +2,12 @@ from __future__ import annotations import asyncio -from collections.abc import Coroutine +from collections.abc import Callable, Coroutine import contextlib import datetime from functools import partial import logging -from typing import Any, Callable +from typing import Any import urllib.parse import async_timeout diff --git a/homeassistant/components/synology_dsm/__init__.py b/homeassistant/components/synology_dsm/__init__.py index 87eb345b03d..a4b03ecce3d 100644 --- a/homeassistant/components/synology_dsm/__init__.py +++ b/homeassistant/components/synology_dsm/__init__.py @@ -1,9 +1,10 @@ """The Synology DSM component.""" from __future__ import annotations +from collections.abc import Callable from datetime import timedelta import logging -from typing import Any, Callable +from typing import Any import async_timeout from synology_dsm import SynologyDSM diff --git a/homeassistant/components/tile/device_tracker.py b/homeassistant/components/tile/device_tracker.py index 27446389f50..36cd16de23a 100644 --- a/homeassistant/components/tile/device_tracker.py +++ b/homeassistant/components/tile/device_tracker.py @@ -1,9 +1,9 @@ """Support for Tile device trackers.""" from __future__ import annotations -from collections.abc import Awaitable +from collections.abc import Awaitable, Callable import logging -from typing import Any, Callable +from typing import Any from pytile.tile import Tile diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py index c4544a4b13f..e4aa6be1ff1 100644 --- a/homeassistant/components/timer/__init__.py +++ b/homeassistant/components/timer/__init__.py @@ -1,9 +1,9 @@ """Support for Timers.""" from __future__ import annotations +from collections.abc import Callable from datetime import datetime, timedelta import logging -from typing import Callable import voluptuous as vol diff --git a/homeassistant/components/zha/core/decorators.py b/homeassistant/components/zha/core/decorators.py index c3eec07e980..a27e4cc0bfc 100644 --- a/homeassistant/components/zha/core/decorators.py +++ b/homeassistant/components/zha/core/decorators.py @@ -1,7 +1,8 @@ """Decorators for ZHA core registries.""" from __future__ import annotations -from typing import Callable, TypeVar +from collections.abc import Callable +from typing import TypeVar CALLABLE_T = TypeVar("CALLABLE_T", bound=Callable) # pylint: disable=invalid-name diff --git a/homeassistant/components/zha/core/discovery.py b/homeassistant/components/zha/core/discovery.py index 6545f14668f..49d640c3165 100644 --- a/homeassistant/components/zha/core/discovery.py +++ b/homeassistant/components/zha/core/discovery.py @@ -2,8 +2,8 @@ from __future__ import annotations from collections import Counter +from collections.abc import Callable import logging -from typing import Callable from homeassistant import const as ha_const from homeassistant.core import HomeAssistant, callback diff --git a/homeassistant/components/zha/core/helpers.py b/homeassistant/components/zha/core/helpers.py index 34359c19420..47ee682b46e 100644 --- a/homeassistant/components/zha/core/helpers.py +++ b/homeassistant/components/zha/core/helpers.py @@ -8,14 +8,14 @@ from __future__ import annotations import asyncio import binascii -from collections.abc import Iterator +from collections.abc import Callable, Iterator from dataclasses import dataclass import functools import itertools import logging from random import uniform import re -from typing import Any, Callable +from typing import Any import voluptuous as vol import zigpy.exceptions diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py index 53425e329c0..f7f35e0755d 100644 --- a/homeassistant/components/zha/core/registries.py +++ b/homeassistant/components/zha/core/registries.py @@ -2,7 +2,8 @@ from __future__ import annotations import collections -from typing import Callable, Dict +from collections.abc import Callable +from typing import Dict import attr from zigpy import zcl diff --git a/homeassistant/components/zha/core/typing.py b/homeassistant/components/zha/core/typing.py index 15e8be0db1e..62a797d9fd5 100644 --- a/homeassistant/components/zha/core/typing.py +++ b/homeassistant/components/zha/core/typing.py @@ -1,6 +1,6 @@ """Typing helpers for ZHA component.""" - -from typing import TYPE_CHECKING, Callable, TypeVar +from collections.abc import Callable +from typing import TYPE_CHECKING, TypeVar import zigpy.device import zigpy.endpoint diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index 7a8f284787f..77fcf44b4d8 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio from collections import defaultdict -from typing import Callable +from collections.abc import Callable from async_timeout import timeout from zwave_js_server.client import Client as ZwaveClient diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index f1a4150ad1a..03bccd814db 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -1,10 +1,11 @@ """Websocket API for Z-Wave JS.""" from __future__ import annotations +from collections.abc import Callable import dataclasses from functools import partial, wraps import json -from typing import Any, Callable +from typing import Any from aiohttp import hdrs, web, web_exceptions, web_request import voluptuous as vol diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index 4744c7f9fc1..4894c40b8ae 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -1,7 +1,8 @@ """Helper functions for Z-Wave JS integration.""" from __future__ import annotations -from typing import Any, Callable, cast +from collections.abc import Callable +from typing import Any, cast import voluptuous as vol from zwave_js_server.client import Client as ZwaveClient From 19685ecff0137b1b77919cfdacf0ad0f8482348c Mon Sep 17 00:00:00 2001 From: jan iversen Date: Wed, 29 Sep 2021 15:15:55 +0200 Subject: [PATCH 685/843] Set strict typing for modbus. (#56779) --- .strict-typing | 1 + mypy.ini | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/.strict-typing b/.strict-typing index 091ee3b8b2c..b5be241ac00 100644 --- a/.strict-typing +++ b/.strict-typing @@ -65,6 +65,7 @@ homeassistant.components.local_ip.* homeassistant.components.lock.* homeassistant.components.mailbox.* homeassistant.components.media_player.* +homeassistant.components.modbus.* homeassistant.components.mysensors.* homeassistant.components.nam.* homeassistant.components.neato.* diff --git a/mypy.ini b/mypy.ini index 1d84658657d..a9c4ed4e3d5 100644 --- a/mypy.ini +++ b/mypy.ini @@ -726,6 +726,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.modbus.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.mysensors.*] check_untyped_defs = true disallow_incomplete_defs = true From 364767ff22f6ea53630998d0615c44b9f2f0ae4a Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 29 Sep 2021 16:15:36 +0200 Subject: [PATCH 686/843] Import Callable from collections.abc (4) (#56778) --- homeassistant/components/daikin/sensor.py | 2 +- homeassistant/components/denonavr/receiver.py | 2 +- homeassistant/components/device_tracker/legacy.py | 4 ++-- homeassistant/components/dynalite/bridge.py | 3 ++- homeassistant/components/dynalite/dynalitebase.py | 3 ++- homeassistant/components/forecast_solar/models.py | 3 ++- homeassistant/components/group/media_player.py | 3 ++- homeassistant/components/group/util.py | 4 ++-- homeassistant/components/iotawatt/sensor.py | 2 +- homeassistant/components/iqvia/__init__.py | 4 ++-- homeassistant/components/knx/cover.py | 3 ++- homeassistant/components/knx/expose.py | 2 +- homeassistant/components/lyric/sensor.py | 5 ++++- homeassistant/components/mqtt/debug_info.py | 5 ++++- homeassistant/components/mqtt/device_trigger.py | 3 ++- homeassistant/components/mqtt/mixins.py | 2 +- homeassistant/components/mqtt/subscription.py | 3 ++- homeassistant/components/nest/camera_sdm.py | 3 ++- homeassistant/components/onvif/event.py | 2 +- homeassistant/components/sensor/recorder.py | 2 +- homeassistant/components/system_bridge/binary_sensor.py | 2 +- homeassistant/components/system_bridge/coordinator.py | 2 +- homeassistant/components/system_bridge/sensor.py | 3 ++- homeassistant/components/tasmota/binary_sensor.py | 3 ++- homeassistant/components/tasmota/device_trigger.py | 3 ++- homeassistant/components/tod/binary_sensor.py | 4 +++- homeassistant/components/webhook/__init__.py | 3 +-- homeassistant/components/wled/coordinator.py | 2 +- 28 files changed, 50 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/daikin/sensor.py b/homeassistant/components/daikin/sensor.py index 1b590b261b7..62d3e8e1f7e 100644 --- a/homeassistant/components/daikin/sensor.py +++ b/homeassistant/components/daikin/sensor.py @@ -1,8 +1,8 @@ """Support for Daikin AC sensors.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass -from typing import Callable from pydaikin.daikin_base import Appliance diff --git a/homeassistant/components/denonavr/receiver.py b/homeassistant/components/denonavr/receiver.py index a35b6c80fcd..5c15468e6d4 100644 --- a/homeassistant/components/denonavr/receiver.py +++ b/homeassistant/components/denonavr/receiver.py @@ -1,8 +1,8 @@ """Code to handle a DenonAVR receiver.""" from __future__ import annotations +from collections.abc import Callable import logging -from typing import Callable from denonavr import DenonAVR diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py index 991a4bb7bb1..31d060200f0 100644 --- a/homeassistant/components/device_tracker/legacy.py +++ b/homeassistant/components/device_tracker/legacy.py @@ -2,11 +2,11 @@ from __future__ import annotations import asyncio -from collections.abc import Coroutine, Sequence +from collections.abc import Callable, Coroutine, Sequence from datetime import timedelta import hashlib from types import ModuleType -from typing import Any, Callable, Final, final +from typing import Any, Final, final import attr import voluptuous as vol diff --git a/homeassistant/components/dynalite/bridge.py b/homeassistant/components/dynalite/bridge.py index 9c911e6983d..82666f20a40 100644 --- a/homeassistant/components/dynalite/bridge.py +++ b/homeassistant/components/dynalite/bridge.py @@ -1,8 +1,9 @@ """Code to handle a Dynalite bridge.""" from __future__ import annotations +from collections.abc import Callable from types import MappingProxyType -from typing import Any, Callable +from typing import Any from dynalite_devices_lib.dynalite_devices import ( CONF_AREA as dyn_CONF_AREA, diff --git a/homeassistant/components/dynalite/dynalitebase.py b/homeassistant/components/dynalite/dynalitebase.py index 56def12afbe..72803f86f02 100644 --- a/homeassistant/components/dynalite/dynalitebase.py +++ b/homeassistant/components/dynalite/dynalitebase.py @@ -1,7 +1,8 @@ """Support for the Dynalite devices as entities.""" from __future__ import annotations -from typing import Any, Callable +from collections.abc import Callable +from typing import Any from homeassistant.components.dynalite.bridge import DynaliteBridge from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/forecast_solar/models.py b/homeassistant/components/forecast_solar/models.py index 6bcc97d49f2..af9b6125713 100644 --- a/homeassistant/components/forecast_solar/models.py +++ b/homeassistant/components/forecast_solar/models.py @@ -1,8 +1,9 @@ """Models for the Forecast.Solar integration.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass -from typing import Any, Callable +from typing import Any from forecast_solar.models import Estimate diff --git a/homeassistant/components/group/media_player.py b/homeassistant/components/group/media_player.py index b7db9fa9631..844e6e3799f 100644 --- a/homeassistant/components/group/media_player.py +++ b/homeassistant/components/group/media_player.py @@ -1,7 +1,8 @@ """This platform allows several media players to be grouped into one media player.""" from __future__ import annotations -from typing import Any, Callable +from collections.abc import Callable +from typing import Any import voluptuous as vol diff --git a/homeassistant/components/group/util.py b/homeassistant/components/group/util.py index 7e284691049..0944ceb6745 100644 --- a/homeassistant/components/group/util.py +++ b/homeassistant/components/group/util.py @@ -1,9 +1,9 @@ """Utility functions to combine state attributes from multiple entities.""" from __future__ import annotations -from collections.abc import Iterator +from collections.abc import Callable, Iterator from itertools import groupby -from typing import Any, Callable +from typing import Any from homeassistant.core import State diff --git a/homeassistant/components/iotawatt/sensor.py b/homeassistant/components/iotawatt/sensor.py index c52f8cb9189..ba0ec30caa0 100644 --- a/homeassistant/components/iotawatt/sensor.py +++ b/homeassistant/components/iotawatt/sensor.py @@ -1,9 +1,9 @@ """Support for IoTaWatt Energy monitor.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass import logging -from typing import Callable from iotawattpy.sensor import Sensor diff --git a/homeassistant/components/iqvia/__init__.py b/homeassistant/components/iqvia/__init__.py index 5a7fa682bc1..0a782669846 100644 --- a/homeassistant/components/iqvia/__init__.py +++ b/homeassistant/components/iqvia/__init__.py @@ -2,10 +2,10 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable +from collections.abc import Awaitable, Callable from datetime import timedelta from functools import partial -from typing import Any, Callable, Dict, cast +from typing import Any, Dict, cast from pyiqvia import Client from pyiqvia.errors import IQVIAError diff --git a/homeassistant/components/knx/cover.py b/homeassistant/components/knx/cover.py index 5d32726474c..85cd6c9a60f 100644 --- a/homeassistant/components/knx/cover.py +++ b/homeassistant/components/knx/cover.py @@ -1,8 +1,9 @@ """Support for KNX/IP covers.""" from __future__ import annotations +from collections.abc import Callable from datetime import datetime -from typing import Any, Callable +from typing import Any from xknx import XKNX from xknx.devices import Cover as XknxCover, Device as XknxDevice diff --git a/homeassistant/components/knx/expose.py b/homeassistant/components/knx/expose.py index 408ab25e7cc..b4b15c977fd 100644 --- a/homeassistant/components/knx/expose.py +++ b/homeassistant/components/knx/expose.py @@ -1,7 +1,7 @@ """Exposures to KNX bus.""" from __future__ import annotations -from typing import Callable +from collections.abc import Callable from xknx import XKNX from xknx.devices import DateTime, ExposeSensor diff --git a/homeassistant/components/lyric/sensor.py b/homeassistant/components/lyric/sensor.py index b5b0ffdeb3d..6f550813ad8 100644 --- a/homeassistant/components/lyric/sensor.py +++ b/homeassistant/components/lyric/sensor.py @@ -1,7 +1,10 @@ """Support for Honeywell Lyric sensor platform.""" +from __future__ import annotations + +from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta -from typing import Callable, cast +from typing import cast from aiolyric.objects.device import LyricDevice from aiolyric.objects.location import LyricLocation diff --git a/homeassistant/components/mqtt/debug_info.py b/homeassistant/components/mqtt/debug_info.py index 57cb88e65e3..f9bb3f1c91f 100644 --- a/homeassistant/components/mqtt/debug_info.py +++ b/homeassistant/components/mqtt/debug_info.py @@ -1,7 +1,10 @@ """Helper to handle a set of topics to subscribe to.""" +from __future__ import annotations + from collections import deque +from collections.abc import Callable from functools import wraps -from typing import Any, Callable +from typing import Any from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/mqtt/device_trigger.py b/homeassistant/components/mqtt/device_trigger.py index 6348156ef50..fe1bd608305 100644 --- a/homeassistant/components/mqtt/device_trigger.py +++ b/homeassistant/components/mqtt/device_trigger.py @@ -1,8 +1,9 @@ """Provides device automations for MQTT.""" from __future__ import annotations +from collections.abc import Callable import logging -from typing import Any, Callable +from typing import Any import attr import voluptuous as vol diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index 11bf70ceceb..e21f3f5c280 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -2,9 +2,9 @@ from __future__ import annotations from abc import abstractmethod +from collections.abc import Callable import json import logging -from typing import Callable import voluptuous as vol diff --git a/homeassistant/components/mqtt/subscription.py b/homeassistant/components/mqtt/subscription.py index 03259a37380..6d132b28a98 100644 --- a/homeassistant/components/mqtt/subscription.py +++ b/homeassistant/components/mqtt/subscription.py @@ -1,7 +1,8 @@ """Helper to handle a set of topics to subscribe to.""" from __future__ import annotations -from typing import Any, Callable +from collections.abc import Callable +from typing import Any import attr diff --git a/homeassistant/components/nest/camera_sdm.py b/homeassistant/components/nest/camera_sdm.py index 07a75129b44..0a917e8cbdc 100644 --- a/homeassistant/components/nest/camera_sdm.py +++ b/homeassistant/components/nest/camera_sdm.py @@ -1,9 +1,10 @@ """Support for Google Nest SDM Cameras.""" from __future__ import annotations +from collections.abc import Callable import datetime import logging -from typing import Any, Callable +from typing import Any from google_nest_sdm.camera_traits import ( CameraEventImageTrait, diff --git a/homeassistant/components/onvif/event.py b/homeassistant/components/onvif/event.py index a45cc02c84b..f76efb2bc8e 100644 --- a/homeassistant/components/onvif/event.py +++ b/homeassistant/components/onvif/event.py @@ -2,9 +2,9 @@ from __future__ import annotations import asyncio +from collections.abc import Callable from contextlib import suppress import datetime as dt -from typing import Callable from httpx import RemoteProtocolError, TransportError from onvif import ONVIFCamera, ONVIFService diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 806981d51eb..b8499bc8040 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -2,11 +2,11 @@ from __future__ import annotations from collections import defaultdict +from collections.abc import Callable import datetime import itertools import logging import math -from typing import Callable from homeassistant.components.recorder import history, statistics from homeassistant.components.recorder.models import ( diff --git a/homeassistant/components/system_bridge/binary_sensor.py b/homeassistant/components/system_bridge/binary_sensor.py index 0587ec3629c..a622a3a925a 100644 --- a/homeassistant/components/system_bridge/binary_sensor.py +++ b/homeassistant/components/system_bridge/binary_sensor.py @@ -1,8 +1,8 @@ """Support for System Bridge binary sensors.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass -from typing import Callable from systembridge import Bridge diff --git a/homeassistant/components/system_bridge/coordinator.py b/homeassistant/components/system_bridge/coordinator.py index 177a09e5d25..fb0b63c715a 100644 --- a/homeassistant/components/system_bridge/coordinator.py +++ b/homeassistant/components/system_bridge/coordinator.py @@ -2,9 +2,9 @@ from __future__ import annotations import asyncio +from collections.abc import Callable from datetime import timedelta import logging -from typing import Callable from systembridge import Bridge from systembridge.exceptions import ( diff --git a/homeassistant/components/system_bridge/sensor.py b/homeassistant/components/system_bridge/sensor.py index 720bfc78a72..3e8d2aa7ff0 100644 --- a/homeassistant/components/system_bridge/sensor.py +++ b/homeassistant/components/system_bridge/sensor.py @@ -1,9 +1,10 @@ """Support for System Bridge sensors.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta -from typing import Callable, Final, cast +from typing import Final, cast from systembridge import Bridge diff --git a/homeassistant/components/tasmota/binary_sensor.py b/homeassistant/components/tasmota/binary_sensor.py index 1ccee0bf7d3..a0f4dfff5ac 100644 --- a/homeassistant/components/tasmota/binary_sensor.py +++ b/homeassistant/components/tasmota/binary_sensor.py @@ -1,8 +1,9 @@ """Support for Tasmota binary sensors.""" from __future__ import annotations +from collections.abc import Callable from datetime import datetime -from typing import Any, Callable +from typing import Any from hatasmota import switch as tasmota_switch from hatasmota.entity import TasmotaEntity as HATasmotaEntity diff --git a/homeassistant/components/tasmota/device_trigger.py b/homeassistant/components/tasmota/device_trigger.py index 27bcc4228ea..61efbb76e23 100644 --- a/homeassistant/components/tasmota/device_trigger.py +++ b/homeassistant/components/tasmota/device_trigger.py @@ -1,8 +1,9 @@ """Provides device automations for Tasmota.""" from __future__ import annotations +from collections.abc import Callable import logging -from typing import Any, Callable +from typing import Any import attr from hatasmota.models import DiscoveryHashType diff --git a/homeassistant/components/tod/binary_sensor.py b/homeassistant/components/tod/binary_sensor.py index 8264468e2e7..3cf74a4c1f4 100644 --- a/homeassistant/components/tod/binary_sensor.py +++ b/homeassistant/components/tod/binary_sensor.py @@ -1,7 +1,9 @@ """Support for representing current time of the day as binary sensors.""" +from __future__ import annotations + +from collections.abc import Callable from datetime import datetime, timedelta import logging -from typing import Callable import voluptuous as vol diff --git a/homeassistant/components/webhook/__init__.py b/homeassistant/components/webhook/__init__.py index cc0d8db1407..656f12950ea 100644 --- a/homeassistant/components/webhook/__init__.py +++ b/homeassistant/components/webhook/__init__.py @@ -1,10 +1,9 @@ """Webhooks for Home Assistant.""" from __future__ import annotations -from collections.abc import Awaitable +from collections.abc import Awaitable, Callable import logging import secrets -from typing import Callable from aiohttp.web import Request, Response import voluptuous as vol diff --git a/homeassistant/components/wled/coordinator.py b/homeassistant/components/wled/coordinator.py index b730ac1543a..06c1f8b5dc3 100644 --- a/homeassistant/components/wled/coordinator.py +++ b/homeassistant/components/wled/coordinator.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from typing import Callable +from collections.abc import Callable from wled import WLED, Device as WLEDDevice, WLEDConnectionClosed, WLEDError From d51487f82ae84577782e75e3ca49ccff65037666 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 29 Sep 2021 16:19:06 +0200 Subject: [PATCH 687/843] Import Callable from collections.abc (3) (#56777) --- homeassistant/components/airly/sensor.py | 3 ++- homeassistant/components/asuswrt/router.py | 3 ++- homeassistant/components/august/binary_sensor.py | 3 ++- homeassistant/components/august/sensor.py | 3 ++- homeassistant/components/bluetooth_tracker/device_tracker.py | 4 ++-- homeassistant/components/crownstone/config_flow.py | 3 ++- homeassistant/components/devolo_home_control/subscriber.py | 3 +-- homeassistant/components/energy/data.py | 4 ++-- homeassistant/components/esphome/entry_data.py | 3 ++- homeassistant/components/fjaraskupan/__init__.py | 2 +- homeassistant/components/fjaraskupan/binary_sensor.py | 2 +- homeassistant/components/fritz/common.py | 4 ++-- homeassistant/components/fritz/sensor.py | 3 ++- homeassistant/components/gios/model.py | 2 +- homeassistant/components/gogogate2/common.py | 4 ++-- homeassistant/components/gtfs/sensor.py | 3 ++- homeassistant/components/guardian/util.py | 4 ++-- homeassistant/components/hyperion/__init__.py | 3 ++- homeassistant/components/hyperion/light.py | 4 ++-- homeassistant/components/kostal_plenticore/sensor.py | 3 ++- homeassistant/components/kraken/const.py | 3 ++- homeassistant/components/lcn/__init__.py | 2 +- homeassistant/components/litejet/trigger.py | 2 +- homeassistant/components/melcloud/sensor.py | 3 ++- homeassistant/components/modbus/base_platform.py | 3 ++- homeassistant/components/modbus/modbus.py | 3 ++- homeassistant/components/nws/__init__.py | 3 +-- homeassistant/components/philips_js/__init__.py | 3 ++- homeassistant/components/shelly/entity.py | 3 ++- homeassistant/components/shelly/logbook.py | 2 +- homeassistant/components/sht31/sensor.py | 2 +- homeassistant/components/simplisafe/__init__.py | 4 ++-- homeassistant/components/starline/account.py | 3 ++- homeassistant/components/starline/entity.py | 2 +- homeassistant/components/stream/worker.py | 4 ++-- homeassistant/components/switcher_kis/utils.py | 3 ++- homeassistant/components/system_health/__init__.py | 3 +-- homeassistant/components/template/__init__.py | 2 +- homeassistant/components/template/template_entity.py | 3 ++- homeassistant/components/tradfri/base_class.py | 3 ++- homeassistant/components/tradfri/cover.py | 3 ++- homeassistant/components/tradfri/light.py | 3 ++- homeassistant/components/tradfri/sensor.py | 3 ++- homeassistant/components/tradfri/switch.py | 3 ++- homeassistant/components/vicare/__init__.py | 3 ++- homeassistant/components/websocket_api/connection.py | 4 ++-- homeassistant/components/websocket_api/decorators.py | 3 ++- homeassistant/components/withings/common.py | 3 ++- homeassistant/components/xiaomi_miio/binary_sensor.py | 2 +- 49 files changed, 85 insertions(+), 61 deletions(-) diff --git a/homeassistant/components/airly/sensor.py b/homeassistant/components/airly/sensor.py index 7fbfe2077a7..fc587f15140 100644 --- a/homeassistant/components/airly/sensor.py +++ b/homeassistant/components/airly/sensor.py @@ -1,8 +1,9 @@ """Support for the Airly sensor service.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass -from typing import Any, Callable, cast +from typing import Any, cast from homeassistant.components.sensor import ( STATE_CLASS_MEASUREMENT, diff --git a/homeassistant/components/asuswrt/router.py b/homeassistant/components/asuswrt/router.py index 5cdcfb834b8..7e89ea07dbd 100644 --- a/homeassistant/components/asuswrt/router.py +++ b/homeassistant/components/asuswrt/router.py @@ -1,9 +1,10 @@ """Represent the AsusWrt router.""" from __future__ import annotations +from collections.abc import Callable from datetime import datetime, timedelta import logging -from typing import Any, Callable +from typing import Any from aioasuswrt.asuswrt import AsusWrt diff --git a/homeassistant/components/august/binary_sensor.py b/homeassistant/components/august/binary_sensor.py index 6a2c9a2ff6d..804a9810a94 100644 --- a/homeassistant/components/august/binary_sensor.py +++ b/homeassistant/components/august/binary_sensor.py @@ -1,10 +1,11 @@ """Support for August binary sensors.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta import logging -from typing import Callable, cast +from typing import cast from yalexs.activity import ( ACTION_DOORBELL_CALL_MISSED, diff --git a/homeassistant/components/august/sensor.py b/homeassistant/components/august/sensor.py index e78ae520034..263d20be1b6 100644 --- a/homeassistant/components/august/sensor.py +++ b/homeassistant/components/august/sensor.py @@ -1,9 +1,10 @@ """Support for August sensors.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass import logging -from typing import Callable, Generic, TypeVar +from typing import Generic, TypeVar from yalexs.activity import ActivityType from yalexs.keypad import KeypadDetail diff --git a/homeassistant/components/bluetooth_tracker/device_tracker.py b/homeassistant/components/bluetooth_tracker/device_tracker.py index ca1da5987a4..8883f600019 100644 --- a/homeassistant/components/bluetooth_tracker/device_tracker.py +++ b/homeassistant/components/bluetooth_tracker/device_tracker.py @@ -2,10 +2,10 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable +from collections.abc import Awaitable, Callable from datetime import datetime, timedelta import logging -from typing import Any, Callable, Final +from typing import Any, Final import bluetooth # pylint: disable=import-error from bt_proximity import BluetoothRSSI diff --git a/homeassistant/components/crownstone/config_flow.py b/homeassistant/components/crownstone/config_flow.py index 86826f5f6f8..7c0ea4fd27d 100644 --- a/homeassistant/components/crownstone/config_flow.py +++ b/homeassistant/components/crownstone/config_flow.py @@ -1,7 +1,8 @@ """Flow handler for Crownstone.""" from __future__ import annotations -from typing import Any, Callable +from collections.abc import Callable +from typing import Any from crownstone_cloud import CrownstoneCloud from crownstone_cloud.exceptions import ( diff --git a/homeassistant/components/devolo_home_control/subscriber.py b/homeassistant/components/devolo_home_control/subscriber.py index 9899aa3a587..13ffabeaba2 100644 --- a/homeassistant/components/devolo_home_control/subscriber.py +++ b/homeassistant/components/devolo_home_control/subscriber.py @@ -1,7 +1,6 @@ """Subscriber for devolo home control API publisher.""" - +from collections.abc import Callable import logging -from typing import Callable _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/energy/data.py b/homeassistant/components/energy/data.py index 1cea20564b4..f8c14ed8b73 100644 --- a/homeassistant/components/energy/data.py +++ b/homeassistant/components/energy/data.py @@ -3,8 +3,8 @@ from __future__ import annotations import asyncio from collections import Counter -from collections.abc import Awaitable -from typing import Callable, Literal, Optional, TypedDict, Union, cast +from collections.abc import Awaitable, Callable +from typing import Literal, Optional, TypedDict, Union, cast import voluptuous as vol diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 2b926b9b270..51fc18ee37e 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -2,8 +2,9 @@ from __future__ import annotations import asyncio +from collections.abc import Callable from dataclasses import dataclass, field -from typing import Any, Callable, cast +from typing import Any, cast from aioesphomeapi import ( COMPONENT_TYPE_TO_INFO, diff --git a/homeassistant/components/fjaraskupan/__init__.py b/homeassistant/components/fjaraskupan/__init__.py index 9d635e3bf7f..ac22e788a6e 100644 --- a/homeassistant/components/fjaraskupan/__init__.py +++ b/homeassistant/components/fjaraskupan/__init__.py @@ -1,10 +1,10 @@ """The Fjäråskupan integration.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass from datetime import timedelta import logging -from typing import Callable from bleak import BleakScanner from bleak.backends.device import BLEDevice diff --git a/homeassistant/components/fjaraskupan/binary_sensor.py b/homeassistant/components/fjaraskupan/binary_sensor.py index 2484a0d9bc2..9af93eaf9c0 100644 --- a/homeassistant/components/fjaraskupan/binary_sensor.py +++ b/homeassistant/components/fjaraskupan/binary_sensor.py @@ -1,8 +1,8 @@ """Support for sensors.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass -from typing import Callable from fjaraskupan import Device, State diff --git a/homeassistant/components/fritz/common.py b/homeassistant/components/fritz/common.py index a8c77f2deb2..6b0f0873c85 100644 --- a/homeassistant/components/fritz/common.py +++ b/homeassistant/components/fritz/common.py @@ -1,12 +1,12 @@ """Support for AVM FRITZ!Box classes.""" from __future__ import annotations -from collections.abc import ValuesView +from collections.abc import Callable, ValuesView from dataclasses import dataclass, field from datetime import datetime, timedelta import logging from types import MappingProxyType -from typing import Any, Callable, TypedDict +from typing import Any, TypedDict from fritzconnection import FritzConnection from fritzconnection.core.exceptions import ( diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py index 53efc7a83f3..15aed604ffc 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -1,9 +1,10 @@ """AVM FRITZ!Box binary sensors.""" from __future__ import annotations +from collections.abc import Callable import datetime import logging -from typing import Callable, TypedDict +from typing import TypedDict from fritzconnection.core.exceptions import ( FritzActionError, diff --git a/homeassistant/components/gios/model.py b/homeassistant/components/gios/model.py index b6ae9a9f78f..0f5d992590b 100644 --- a/homeassistant/components/gios/model.py +++ b/homeassistant/components/gios/model.py @@ -1,8 +1,8 @@ """Type definitions for GIOS integration.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass -from typing import Callable from homeassistant.components.sensor import SensorEntityDescription diff --git a/homeassistant/components/gogogate2/common.py b/homeassistant/components/gogogate2/common.py index c1f81f8fd32..5d190034028 100644 --- a/homeassistant/components/gogogate2/common.py +++ b/homeassistant/components/gogogate2/common.py @@ -1,10 +1,10 @@ """Common code for GogoGate2 component.""" from __future__ import annotations -from collections.abc import Awaitable, Mapping +from collections.abc import Awaitable, Callable, Mapping from datetime import timedelta import logging -from typing import Any, Callable, NamedTuple +from typing import Any, NamedTuple from ismartgate import AbstractGateApi, GogoGate2Api, ISmartGateApi from ismartgate.common import AbstractDoor, get_door_by_id diff --git a/homeassistant/components/gtfs/sensor.py b/homeassistant/components/gtfs/sensor.py index f97bc9796ec..9450c717148 100644 --- a/homeassistant/components/gtfs/sensor.py +++ b/homeassistant/components/gtfs/sensor.py @@ -1,11 +1,12 @@ """Support for GTFS (Google/General Transport Format Schema).""" from __future__ import annotations +from collections.abc import Callable import datetime import logging import os import threading -from typing import Any, Callable +from typing import Any import pygtfs from sqlalchemy.sql import text diff --git a/homeassistant/components/guardian/util.py b/homeassistant/components/guardian/util.py index c4d0e0be4d7..d83334e7a40 100644 --- a/homeassistant/components/guardian/util.py +++ b/homeassistant/components/guardian/util.py @@ -2,9 +2,9 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable +from collections.abc import Awaitable, Callable from datetime import timedelta -from typing import Any, Callable, Dict, cast +from typing import Any, Dict, cast from aioguardian import Client from aioguardian.errors import GuardianError diff --git a/homeassistant/components/hyperion/__init__.py b/homeassistant/components/hyperion/__init__.py index 36185c68758..b43b25ca5ac 100644 --- a/homeassistant/components/hyperion/__init__.py +++ b/homeassistant/components/hyperion/__init__.py @@ -2,9 +2,10 @@ from __future__ import annotations import asyncio +from collections.abc import Callable from contextlib import suppress import logging -from typing import Any, Callable, cast +from typing import Any, cast from awesomeversion import AwesomeVersion from hyperion import client, const as hyperion_const diff --git a/homeassistant/components/hyperion/light.py b/homeassistant/components/hyperion/light.py index e9d23b4077e..d27e96e85de 100644 --- a/homeassistant/components/hyperion/light.py +++ b/homeassistant/components/hyperion/light.py @@ -1,11 +1,11 @@ """Support for Hyperion-NG remotes.""" from __future__ import annotations -from collections.abc import Mapping, Sequence +from collections.abc import Callable, Mapping, Sequence import functools import logging from types import MappingProxyType -from typing import Any, Callable +from typing import Any from hyperion import client, const diff --git a/homeassistant/components/kostal_plenticore/sensor.py b/homeassistant/components/kostal_plenticore/sensor.py index 19ac4db0f90..15971cec68d 100644 --- a/homeassistant/components/kostal_plenticore/sensor.py +++ b/homeassistant/components/kostal_plenticore/sensor.py @@ -1,9 +1,10 @@ """Platform for Kostal Plenticore sensors.""" from __future__ import annotations +from collections.abc import Callable from datetime import timedelta import logging -from typing import Any, Callable +from typing import Any from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorEntity from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/kraken/const.py b/homeassistant/components/kraken/const.py index 669d64a49c8..7382510efd0 100644 --- a/homeassistant/components/kraken/const.py +++ b/homeassistant/components/kraken/const.py @@ -1,8 +1,9 @@ """Constants for the kraken integration.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass -from typing import Callable, Dict, TypedDict +from typing import Dict, TypedDict from homeassistant.components.sensor import SensorEntityDescription from homeassistant.helpers.update_coordinator import DataUpdateCoordinator diff --git a/homeassistant/components/lcn/__init__.py b/homeassistant/components/lcn/__init__.py index 9db564812a8..48a63a50fa9 100644 --- a/homeassistant/components/lcn/__init__.py +++ b/homeassistant/components/lcn/__init__.py @@ -1,8 +1,8 @@ """Support for LCN devices.""" from __future__ import annotations +from collections.abc import Callable import logging -from typing import Callable import pypck diff --git a/homeassistant/components/litejet/trigger.py b/homeassistant/components/litejet/trigger.py index 5ff841a55c3..21b7927ebe2 100644 --- a/homeassistant/components/litejet/trigger.py +++ b/homeassistant/components/litejet/trigger.py @@ -1,5 +1,5 @@ """Trigger an automation when a LiteJet switch is released.""" -from typing import Callable +from collections.abc import Callable import voluptuous as vol diff --git a/homeassistant/components/melcloud/sensor.py b/homeassistant/components/melcloud/sensor.py index 608c3547724..19be1ea172d 100644 --- a/homeassistant/components/melcloud/sensor.py +++ b/homeassistant/components/melcloud/sensor.py @@ -1,8 +1,9 @@ """Support for MelCloud device sensors.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass -from typing import Any, Callable +from typing import Any from pymelcloud import DEVICE_TYPE_ATA, DEVICE_TYPE_ATW from pymelcloud.atw_device import Zone diff --git a/homeassistant/components/modbus/base_platform.py b/homeassistant/components/modbus/base_platform.py index 0c91fe8e3a6..95f8d33b366 100644 --- a/homeassistant/components/modbus/base_platform.py +++ b/homeassistant/components/modbus/base_platform.py @@ -2,10 +2,11 @@ from __future__ import annotations from abc import abstractmethod +from collections.abc import Callable from datetime import datetime, timedelta import logging import struct -from typing import Any, Callable, cast +from typing import Any, cast from homeassistant.const import ( CONF_ADDRESS, diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index f30f7893022..e81afc968ca 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -3,8 +3,9 @@ from __future__ import annotations import asyncio from collections import namedtuple +from collections.abc import Callable import logging -from typing import Any, Callable +from typing import Any from pymodbus.client.sync import ( BaseModbusClient, diff --git a/homeassistant/components/nws/__init__.py b/homeassistant/components/nws/__init__.py index 0e00c848970..318ba687d30 100644 --- a/homeassistant/components/nws/__init__.py +++ b/homeassistant/components/nws/__init__.py @@ -1,10 +1,9 @@ """The National Weather Service integration.""" from __future__ import annotations -from collections.abc import Awaitable +from collections.abc import Awaitable, Callable import datetime import logging -from typing import Callable from pynws import SimpleNWS diff --git a/homeassistant/components/philips_js/__init__.py b/homeassistant/components/philips_js/__init__.py index 1006df699f4..79698ea4136 100644 --- a/homeassistant/components/philips_js/__init__.py +++ b/homeassistant/components/philips_js/__init__.py @@ -2,9 +2,10 @@ from __future__ import annotations import asyncio +from collections.abc import Callable from datetime import timedelta import logging -from typing import Any, Callable +from typing import Any from haphilipsjs import ConnectionFailure, PhilipsTV diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 13fd3aade3b..f12633bd0e3 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -2,9 +2,10 @@ from __future__ import annotations import asyncio +from collections.abc import Callable from dataclasses import dataclass import logging -from typing import Any, Callable, Final, cast +from typing import Any, Final, cast from aioshelly.block_device import Block import async_timeout diff --git a/homeassistant/components/shelly/logbook.py b/homeassistant/components/shelly/logbook.py index a1c8d5eceee..d4278e3e98e 100644 --- a/homeassistant/components/shelly/logbook.py +++ b/homeassistant/components/shelly/logbook.py @@ -1,7 +1,7 @@ """Describe Shelly logbook events.""" from __future__ import annotations -from typing import Callable +from collections.abc import Callable from homeassistant.const import ATTR_DEVICE_ID from homeassistant.core import HomeAssistant, callback diff --git a/homeassistant/components/sht31/sensor.py b/homeassistant/components/sht31/sensor.py index 1415c4856b6..2d7c81072f6 100644 --- a/homeassistant/components/sht31/sensor.py +++ b/homeassistant/components/sht31/sensor.py @@ -1,11 +1,11 @@ """Support for Sensirion SHT31 temperature and humidity sensor.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass from datetime import timedelta import logging import math -from typing import Callable from Adafruit_SHT31 import SHT31 import voluptuous as vol diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py index 924cf398f64..4ba26f0adc7 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -2,8 +2,8 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable -from typing import Callable, cast +from collections.abc import Awaitable, Callable +from typing import cast from uuid import UUID from simplipy import get_api diff --git a/homeassistant/components/starline/account.py b/homeassistant/components/starline/account.py index 8af9940370e..9033375ce90 100644 --- a/homeassistant/components/starline/account.py +++ b/homeassistant/components/starline/account.py @@ -1,8 +1,9 @@ """StarLine Account.""" from __future__ import annotations +from collections.abc import Callable from datetime import datetime, timedelta -from typing import Any, Callable +from typing import Any from starline import StarlineApi, StarlineDevice diff --git a/homeassistant/components/starline/entity.py b/homeassistant/components/starline/entity.py index b48816e1a7c..727960e5f46 100644 --- a/homeassistant/components/starline/entity.py +++ b/homeassistant/components/starline/entity.py @@ -1,7 +1,7 @@ """StarLine base entity.""" from __future__ import annotations -from typing import Callable +from collections.abc import Callable from homeassistant.helpers.entity import Entity diff --git a/homeassistant/components/stream/worker.py b/homeassistant/components/stream/worker.py index 314e4f33e80..a576ff6d02b 100644 --- a/homeassistant/components/stream/worker.py +++ b/homeassistant/components/stream/worker.py @@ -2,12 +2,12 @@ from __future__ import annotations from collections import defaultdict, deque -from collections.abc import Generator, Iterator, Mapping +from collections.abc import Callable, Generator, Iterator, Mapping import datetime from io import BytesIO import logging from threading import Event -from typing import Any, Callable, cast +from typing import Any, cast import av diff --git a/homeassistant/components/switcher_kis/utils.py b/homeassistant/components/switcher_kis/utils.py index b2cc45cf67c..5a35be8aa95 100644 --- a/homeassistant/components/switcher_kis/utils.py +++ b/homeassistant/components/switcher_kis/utils.py @@ -2,8 +2,9 @@ from __future__ import annotations import asyncio +from collections.abc import Callable import logging -from typing import Any, Callable +from typing import Any from aioswitcher.bridge import SwitcherBase, SwitcherBridge diff --git a/homeassistant/components/system_health/__init__.py b/homeassistant/components/system_health/__init__.py index 651961c72ac..2683f6a2f3a 100644 --- a/homeassistant/components/system_health/__init__.py +++ b/homeassistant/components/system_health/__init__.py @@ -2,11 +2,10 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable +from collections.abc import Awaitable, Callable import dataclasses from datetime import datetime import logging -from typing import Callable import aiohttp import async_timeout diff --git a/homeassistant/components/template/__init__.py b/homeassistant/components/template/__init__.py index 3e34b927971..9a0fa5a7320 100644 --- a/homeassistant/components/template/__init__.py +++ b/homeassistant/components/template/__init__.py @@ -2,8 +2,8 @@ from __future__ import annotations import asyncio +from collections.abc import Callable import logging -from typing import Callable from homeassistant import config as conf_util from homeassistant.const import ( diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index 6bf889ebf02..42517b00d4a 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -1,8 +1,9 @@ """TemplateEntity utility class.""" from __future__ import annotations +from collections.abc import Callable import logging -from typing import Any, Callable +from typing import Any import voluptuous as vol diff --git a/homeassistant/components/tradfri/base_class.py b/homeassistant/components/tradfri/base_class.py index 1e86be6c1a5..b0679a2a8ce 100644 --- a/homeassistant/components/tradfri/base_class.py +++ b/homeassistant/components/tradfri/base_class.py @@ -1,9 +1,10 @@ """Base class for IKEA TRADFRI.""" from __future__ import annotations +from collections.abc import Callable from functools import wraps import logging -from typing import Any, Callable +from typing import Any from pytradfri.command import Command from pytradfri.device import Device diff --git a/homeassistant/components/tradfri/cover.py b/homeassistant/components/tradfri/cover.py index 5a6140ed5fc..7bcbf5af5e1 100644 --- a/homeassistant/components/tradfri/cover.py +++ b/homeassistant/components/tradfri/cover.py @@ -1,7 +1,8 @@ """Support for IKEA Tradfri covers.""" from __future__ import annotations -from typing import Any, Callable, cast +from collections.abc import Callable +from typing import Any, cast from pytradfri.command import Command diff --git a/homeassistant/components/tradfri/light.py b/homeassistant/components/tradfri/light.py index e4d7fb1fc4f..c41bc55bcc8 100644 --- a/homeassistant/components/tradfri/light.py +++ b/homeassistant/components/tradfri/light.py @@ -1,7 +1,8 @@ """Support for IKEA Tradfri lights.""" from __future__ import annotations -from typing import Any, Callable, cast +from collections.abc import Callable +from typing import Any, cast from pytradfri.command import Command diff --git a/homeassistant/components/tradfri/sensor.py b/homeassistant/components/tradfri/sensor.py index 23b7ecc2fab..f761aba5ddd 100644 --- a/homeassistant/components/tradfri/sensor.py +++ b/homeassistant/components/tradfri/sensor.py @@ -1,7 +1,8 @@ """Support for IKEA Tradfri sensors.""" from __future__ import annotations -from typing import Any, Callable, cast +from collections.abc import Callable +from typing import Any, cast from pytradfri.command import Command diff --git a/homeassistant/components/tradfri/switch.py b/homeassistant/components/tradfri/switch.py index 7366bf7a898..b7051989265 100644 --- a/homeassistant/components/tradfri/switch.py +++ b/homeassistant/components/tradfri/switch.py @@ -1,7 +1,8 @@ """Support for IKEA Tradfri switches.""" from __future__ import annotations -from typing import Any, Callable, cast +from collections.abc import Callable +from typing import Any, cast from pytradfri.command import Command diff --git a/homeassistant/components/vicare/__init__.py b/homeassistant/components/vicare/__init__.py index b811b9bbfb5..5d5c5548be1 100644 --- a/homeassistant/components/vicare/__init__.py +++ b/homeassistant/components/vicare/__init__.py @@ -1,10 +1,11 @@ """The ViCare integration.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass import enum import logging -from typing import Callable, Generic, TypeVar +from typing import Generic, TypeVar from PyViCare.PyViCareDevice import Device from PyViCare.PyViCareFuelCell import FuelCell diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py index 0d3bd5fdf4d..aec56fdfbf2 100644 --- a/homeassistant/components/websocket_api/connection.py +++ b/homeassistant/components/websocket_api/connection.py @@ -2,8 +2,8 @@ from __future__ import annotations import asyncio -from collections.abc import Hashable -from typing import TYPE_CHECKING, Any, Callable +from collections.abc import Callable, Hashable +from typing import TYPE_CHECKING, Any import voluptuous as vol diff --git a/homeassistant/components/websocket_api/decorators.py b/homeassistant/components/websocket_api/decorators.py index af762cf2d46..eff82a8c71d 100644 --- a/homeassistant/components/websocket_api/decorators.py +++ b/homeassistant/components/websocket_api/decorators.py @@ -2,8 +2,9 @@ from __future__ import annotations import asyncio +from collections.abc import Callable from functools import wraps -from typing import Any, Callable +from typing import Any import voluptuous as vol diff --git a/homeassistant/components/withings/common.py b/homeassistant/components/withings/common.py index 9d8d68c1927..9e4beff8c38 100644 --- a/homeassistant/components/withings/common.py +++ b/homeassistant/components/withings/common.py @@ -2,13 +2,14 @@ from __future__ import annotations import asyncio +from collections.abc import Callable from dataclasses import dataclass import datetime from datetime import timedelta from enum import Enum, IntEnum import logging import re -from typing import Any, Callable, Dict +from typing import Any, Dict from aiohttp.web import Response import requests diff --git a/homeassistant/components/xiaomi_miio/binary_sensor.py b/homeassistant/components/xiaomi_miio/binary_sensor.py index a91f06d1194..61c3a4fde61 100644 --- a/homeassistant/components/xiaomi_miio/binary_sensor.py +++ b/homeassistant/components/xiaomi_miio/binary_sensor.py @@ -1,9 +1,9 @@ """Support for Xiaomi Miio binary sensors.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass from enum import Enum -from typing import Callable from homeassistant.components.binary_sensor import ( DEVICE_CLASS_CONNECTIVITY, From 77ee72cbb9fed55779b0ee58443c3f41e5b35f5a Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 29 Sep 2021 16:32:11 +0200 Subject: [PATCH 688/843] Import Callable from collections.abc (1) (#56775) --- homeassistant/auth/permissions/__init__.py | 3 ++- homeassistant/auth/permissions/entities.py | 2 +- homeassistant/config.py | 4 ++-- homeassistant/helpers/aiohttp_client.py | 4 ++-- homeassistant/helpers/config_entry_oauth2_flow.py | 4 ++-- homeassistant/helpers/config_validation.py | 4 ++-- homeassistant/helpers/debounce.py | 4 ++-- homeassistant/helpers/deprecation.py | 3 ++- homeassistant/helpers/discovery.py | 3 ++- homeassistant/helpers/dispatcher.py | 5 ++++- homeassistant/helpers/entity_component.py | 4 ++-- homeassistant/helpers/entity_platform.py | 4 ++-- homeassistant/helpers/entity_registry.py | 4 ++-- homeassistant/helpers/entityfilter.py | 2 +- homeassistant/helpers/frame.py | 3 ++- homeassistant/helpers/httpx_client.py | 3 ++- homeassistant/helpers/integration_platform.py | 4 ++-- homeassistant/helpers/intent.py | 4 ++-- homeassistant/helpers/ratelimit.py | 4 ++-- homeassistant/helpers/script.py | 4 ++-- homeassistant/helpers/selector.py | 3 ++- homeassistant/helpers/service.py | 4 ++-- homeassistant/helpers/start.py | 5 +++-- homeassistant/helpers/storage.py | 3 ++- homeassistant/helpers/template.py | 4 ++-- homeassistant/helpers/trace.py | 4 ++-- homeassistant/helpers/trigger.py | 3 ++- homeassistant/helpers/update_coordinator.py | 4 ++-- homeassistant/scripts/benchmark/__init__.py | 3 ++- homeassistant/scripts/check_config.py | 4 ++-- homeassistant/setup.py | 3 +-- homeassistant/util/__init__.py | 4 ++-- homeassistant/util/async_.py | 4 ++-- homeassistant/util/decorator.py | 6 ++++-- homeassistant/util/distance.py | 2 +- homeassistant/util/json.py | 3 ++- 36 files changed, 73 insertions(+), 58 deletions(-) diff --git a/homeassistant/auth/permissions/__init__.py b/homeassistant/auth/permissions/__init__.py index 898a8334234..694ea2b7379 100644 --- a/homeassistant/auth/permissions/__init__.py +++ b/homeassistant/auth/permissions/__init__.py @@ -1,8 +1,9 @@ """Permissions for Home Assistant.""" from __future__ import annotations +from collections.abc import Callable import logging -from typing import Any, Callable +from typing import Any import voluptuous as vol diff --git a/homeassistant/auth/permissions/entities.py b/homeassistant/auth/permissions/entities.py index f19590a6349..3f2a0c14f19 100644 --- a/homeassistant/auth/permissions/entities.py +++ b/homeassistant/auth/permissions/entities.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections import OrderedDict -from typing import Callable +from collections.abc import Callable import voluptuous as vol diff --git a/homeassistant/config.py b/homeassistant/config.py index a51fe711ea5..540336eeca3 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -2,14 +2,14 @@ from __future__ import annotations from collections import OrderedDict -from collections.abc import Sequence +from collections.abc import Callable, Sequence import logging import os from pathlib import Path import re import shutil from types import ModuleType -from typing import Any, Callable +from typing import Any from urllib.parse import urlparse from awesomeversion import AwesomeVersion diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index 696f2d40cb8..c7f77bf086d 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -2,12 +2,12 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable +from collections.abc import Awaitable, Callable from contextlib import suppress from ssl import SSLContext import sys from types import MappingProxyType -from typing import Any, Callable, cast +from typing import Any, cast import aiohttp from aiohttp import web diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index a3a00d46df3..014c1f4f272 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -9,11 +9,11 @@ from __future__ import annotations from abc import ABC, ABCMeta, abstractmethod import asyncio -from collections.abc import Awaitable +from collections.abc import Awaitable, Callable import logging import secrets import time -from typing import Any, Callable, Dict, cast +from typing import Any, Dict, cast from aiohttp import client, web import async_timeout diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 3a2fb6c70e4..4bfcb98e9d4 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -1,7 +1,7 @@ """Helpers for config validation using voluptuous.""" from __future__ import annotations -from collections.abc import Hashable +from collections.abc import Callable, Hashable from datetime import ( date as date_sys, datetime as datetime_sys, @@ -15,7 +15,7 @@ from numbers import Number import os import re from socket import _GLOBAL_DEFAULT_TIMEOUT # type: ignore # private, not in typeshed -from typing import Any, Callable, Dict, TypeVar, cast +from typing import Any, Dict, TypeVar, cast from urllib.parse import urlparse from uuid import UUID diff --git a/homeassistant/helpers/debounce.py b/homeassistant/helpers/debounce.py index 75e0215d2cb..e3f13e3ad16 100644 --- a/homeassistant/helpers/debounce.py +++ b/homeassistant/helpers/debounce.py @@ -2,9 +2,9 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable +from collections.abc import Awaitable, Callable from logging import Logger -from typing import Any, Callable +from typing import Any from homeassistant.core import HassJob, HomeAssistant, callback diff --git a/homeassistant/helpers/deprecation.py b/homeassistant/helpers/deprecation.py index e20748913ba..4bf57c1a4e1 100644 --- a/homeassistant/helpers/deprecation.py +++ b/homeassistant/helpers/deprecation.py @@ -1,10 +1,11 @@ """Deprecation helpers for Home Assistant.""" from __future__ import annotations +from collections.abc import Callable import functools import inspect import logging -from typing import Any, Callable +from typing import Any from ..helpers.frame import MissingIntegrationFrame, get_integration_frame diff --git a/homeassistant/helpers/discovery.py b/homeassistant/helpers/discovery.py index 53dbca867d7..3f7523e9299 100644 --- a/homeassistant/helpers/discovery.py +++ b/homeassistant/helpers/discovery.py @@ -7,7 +7,8 @@ There are two different types of discoveries that can be fired/listened for. """ from __future__ import annotations -from typing import Any, Callable, TypedDict +from collections.abc import Callable +from typing import Any, TypedDict from homeassistant import core, setup from homeassistant.core import CALLBACK_TYPE diff --git a/homeassistant/helpers/dispatcher.py b/homeassistant/helpers/dispatcher.py index 2b365412e27..d1f7b2b97f9 100644 --- a/homeassistant/helpers/dispatcher.py +++ b/homeassistant/helpers/dispatcher.py @@ -1,6 +1,9 @@ """Helpers for Home Assistant dispatcher & internal component/platform.""" +from __future__ import annotations + +from collections.abc import Callable import logging -from typing import Any, Callable +from typing import Any from homeassistant.core import HassJob, HomeAssistant, callback from homeassistant.loader import bind_hass diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index 7f329d02133..d65f485166b 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -2,12 +2,12 @@ from __future__ import annotations import asyncio -from collections.abc import Iterable +from collections.abc import Callable, Iterable from datetime import timedelta from itertools import chain import logging from types import ModuleType -from typing import Any, Callable +from typing import Any import voluptuous as vol diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 778b7f3747d..c212645325c 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -2,13 +2,13 @@ from __future__ import annotations import asyncio -from collections.abc import Coroutine, Iterable +from collections.abc import Callable, Coroutine, Iterable from contextvars import ContextVar from datetime import datetime, timedelta import logging from logging import Logger from types import ModuleType -from typing import TYPE_CHECKING, Any, Callable, Protocol +from typing import TYPE_CHECKING, Any, Protocol import voluptuous as vol diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 5d1c495904b..88233b30df7 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -10,9 +10,9 @@ timer. from __future__ import annotations from collections import OrderedDict -from collections.abc import Iterable, Mapping +from collections.abc import Callable, Iterable, Mapping import logging -from typing import TYPE_CHECKING, Any, Callable, cast +from typing import TYPE_CHECKING, Any, cast import attr diff --git a/homeassistant/helpers/entityfilter.py b/homeassistant/helpers/entityfilter.py index e026955f286..727231dde00 100644 --- a/homeassistant/helpers/entityfilter.py +++ b/homeassistant/helpers/entityfilter.py @@ -1,9 +1,9 @@ """Helper class to implement include/exclude of entities and domains.""" from __future__ import annotations +from collections.abc import Callable import fnmatch import re -from typing import Callable import voluptuous as vol diff --git a/homeassistant/helpers/frame.py b/homeassistant/helpers/frame.py index f10e8f4c25c..7d29d78dc54 100644 --- a/homeassistant/helpers/frame.py +++ b/homeassistant/helpers/frame.py @@ -2,10 +2,11 @@ from __future__ import annotations import asyncio +from collections.abc import Callable import functools import logging from traceback import FrameSummary, extract_stack -from typing import Any, Callable, TypeVar, cast +from typing import Any, TypeVar, cast from homeassistant.exceptions import HomeAssistantError diff --git a/homeassistant/helpers/httpx_client.py b/homeassistant/helpers/httpx_client.py index cc4f5be47d8..ec5b0a5e7ca 100644 --- a/homeassistant/helpers/httpx_client.py +++ b/homeassistant/helpers/httpx_client.py @@ -1,8 +1,9 @@ """Helper for httpx.""" from __future__ import annotations +from collections.abc import Callable import sys -from typing import Any, Callable +from typing import Any import httpx diff --git a/homeassistant/helpers/integration_platform.py b/homeassistant/helpers/integration_platform.py index 57a81083c50..0e619fe551b 100644 --- a/homeassistant/helpers/integration_platform.py +++ b/homeassistant/helpers/integration_platform.py @@ -2,9 +2,9 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable +from collections.abc import Awaitable, Callable import logging -from typing import Any, Callable +from typing import Any from homeassistant.core import Event, HomeAssistant from homeassistant.loader import async_get_integration, bind_hass diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index cfc89240b78..c25cc25e99b 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -1,10 +1,10 @@ """Module to coordinate user intentions.""" from __future__ import annotations -from collections.abc import Iterable +from collections.abc import Callable, Iterable import logging import re -from typing import Any, Callable, Dict +from typing import Any, Dict import voluptuous as vol diff --git a/homeassistant/helpers/ratelimit.py b/homeassistant/helpers/ratelimit.py index 389b3f4d2d5..350f50423e1 100644 --- a/homeassistant/helpers/ratelimit.py +++ b/homeassistant/helpers/ratelimit.py @@ -2,10 +2,10 @@ from __future__ import annotations import asyncio -from collections.abc import Hashable +from collections.abc import Callable, Hashable from datetime import datetime, timedelta import logging -from typing import Any, Callable +from typing import Any from homeassistant.core import HomeAssistant, callback import homeassistant.util.dt as dt_util diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index e5e8ef4fd52..c43b918c59d 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -2,14 +2,14 @@ from __future__ import annotations import asyncio -from collections.abc import Sequence +from collections.abc import Callable, Sequence from contextlib import asynccontextmanager, suppress from datetime import datetime, timedelta from functools import partial import itertools import logging from types import MappingProxyType -from typing import Any, Callable, Dict, TypedDict, Union, cast +from typing import Any, Dict, TypedDict, Union, cast import async_timeout import voluptuous as vol diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index 5bde59c06dc..6258d6db1c3 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -1,7 +1,8 @@ """Selectors for Home Assistant.""" from __future__ import annotations -from typing import Any, Callable, cast +from collections.abc import Callable +from typing import Any, cast import voluptuous as vol diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index ed07c6bda63..002d6447441 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -2,11 +2,11 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Iterable +from collections.abc import Awaitable, Callable, Iterable import dataclasses from functools import partial, wraps import logging -from typing import TYPE_CHECKING, Any, Callable, TypedDict +from typing import TYPE_CHECKING, Any, TypedDict import voluptuous as vol diff --git a/homeassistant/helpers/start.py b/homeassistant/helpers/start.py index e7e827ec5c3..805ac193834 100644 --- a/homeassistant/helpers/start.py +++ b/homeassistant/helpers/start.py @@ -1,6 +1,7 @@ """Helpers to help during startup.""" -from collections.abc import Awaitable -from typing import Callable +from __future__ import annotations + +from collections.abc import Awaitable, Callable from homeassistant.const import EVENT_HOMEASSISTANT_START from homeassistant.core import Event, HomeAssistant, callback diff --git a/homeassistant/helpers/storage.py b/homeassistant/helpers/storage.py index 0d5e24b3b40..116c9186149 100644 --- a/homeassistant/helpers/storage.py +++ b/homeassistant/helpers/storage.py @@ -2,11 +2,12 @@ from __future__ import annotations import asyncio +from collections.abc import Callable from contextlib import suppress from json import JSONEncoder import logging import os -from typing import Any, Callable +from typing import Any from homeassistant.const import EVENT_HOMEASSISTANT_FINAL_WRITE from homeassistant.core import CALLBACK_TYPE, CoreState, Event, HomeAssistant, callback diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 94323c14f56..831400feaf9 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -5,7 +5,7 @@ from ast import literal_eval import asyncio import base64 import collections.abc -from collections.abc import Generator, Iterable +from collections.abc import Callable, Generator, Iterable from contextlib import suppress from contextvars import ContextVar from datetime import datetime, timedelta @@ -17,7 +17,7 @@ from operator import attrgetter import random import re import sys -from typing import Any, Callable, cast +from typing import Any, cast from urllib.parse import urlencode as urllib_urlencode import weakref diff --git a/homeassistant/helpers/trace.py b/homeassistant/helpers/trace.py index 58b0dc19d43..0c364124c50 100644 --- a/homeassistant/helpers/trace.py +++ b/homeassistant/helpers/trace.py @@ -2,11 +2,11 @@ from __future__ import annotations from collections import deque -from collections.abc import Generator +from collections.abc import Callable, Generator from contextlib import contextmanager from contextvars import ContextVar from functools import wraps -from typing import Any, Callable, cast +from typing import Any, cast from homeassistant.helpers.typing import TemplateVarsType import homeassistant.util.dt as dt_util diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index 9d431cdb7b8..c7ef6d31be4 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -2,8 +2,9 @@ from __future__ import annotations import asyncio +from collections.abc import Callable import logging -from typing import Any, Callable +from typing import Any import voluptuous as vol diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index 2203ab240ef..a48fca8a01f 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -2,11 +2,11 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable +from collections.abc import Awaitable, Callable from datetime import datetime, timedelta import logging from time import monotonic -from typing import Callable, Generic, TypeVar +from typing import Generic, TypeVar import urllib.error import aiohttp diff --git a/homeassistant/scripts/benchmark/__init__.py b/homeassistant/scripts/benchmark/__init__.py index 2acefbce128..1005b48e1ca 100644 --- a/homeassistant/scripts/benchmark/__init__.py +++ b/homeassistant/scripts/benchmark/__init__.py @@ -4,12 +4,13 @@ from __future__ import annotations import argparse import asyncio import collections +from collections.abc import Callable from contextlib import suppress from datetime import datetime import json import logging from timeit import default_timer as timer -from typing import Callable, TypeVar +from typing import TypeVar from homeassistant import core from homeassistant.components.websocket_api.const import JSON_DUMP diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index ac3208aad19..8e683bb5a1b 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -4,11 +4,11 @@ from __future__ import annotations import argparse import asyncio from collections import OrderedDict -from collections.abc import Mapping, Sequence +from collections.abc import Callable, Mapping, Sequence from glob import glob import logging import os -from typing import Any, Callable +from typing import Any from unittest.mock import patch from homeassistant import core diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 1c372394c43..a917eb65b69 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -2,12 +2,11 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Generator, Iterable +from collections.abc import Awaitable, Callable, Generator, Iterable import contextlib import logging.handlers from timeit import default_timer as timer from types import ModuleType -from typing import Callable from homeassistant import config as conf_util, core, loader, requirements from homeassistant.config import async_notify_setup_error diff --git a/homeassistant/util/__init__.py b/homeassistant/util/__init__.py index 60f3e409f06..4f7f1af2e7d 100644 --- a/homeassistant/util/__init__.py +++ b/homeassistant/util/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from collections.abc import Coroutine, Iterable, KeysView +from collections.abc import Callable, Coroutine, Iterable, KeysView from datetime import datetime, timedelta import enum from functools import lru_cache, wraps @@ -12,7 +12,7 @@ import socket import string import threading from types import MappingProxyType -from typing import Any, Callable, TypeVar +from typing import Any, TypeVar import slugify as unicode_slug diff --git a/homeassistant/util/async_.py b/homeassistant/util/async_.py index 86308b48f7a..bf7250b68e6 100644 --- a/homeassistant/util/async_.py +++ b/homeassistant/util/async_.py @@ -3,13 +3,13 @@ from __future__ import annotations from asyncio import Semaphore, coroutines, ensure_future, gather, get_running_loop from asyncio.events import AbstractEventLoop -from collections.abc import Awaitable, Coroutine +from collections.abc import Awaitable, Callable, Coroutine import concurrent.futures import functools import logging import threading from traceback import extract_stack -from typing import Any, Callable, TypeVar +from typing import Any, TypeVar _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/util/decorator.py b/homeassistant/util/decorator.py index d2943d39979..602cdba5598 100644 --- a/homeassistant/util/decorator.py +++ b/homeassistant/util/decorator.py @@ -1,6 +1,8 @@ """Decorator utility functions.""" -from collections.abc import Hashable -from typing import Callable, TypeVar +from __future__ import annotations + +from collections.abc import Callable, Hashable +from typing import TypeVar CALLABLE_T = TypeVar("CALLABLE_T", bound=Callable) # pylint: disable=invalid-name diff --git a/homeassistant/util/distance.py b/homeassistant/util/distance.py index 6b21e9b4c47..38b9253ffbf 100644 --- a/homeassistant/util/distance.py +++ b/homeassistant/util/distance.py @@ -1,8 +1,8 @@ """Distance util functions.""" from __future__ import annotations +from collections.abc import Callable from numbers import Number -from typing import Callable from homeassistant.const import ( LENGTH, diff --git a/homeassistant/util/json.py b/homeassistant/util/json.py index fac008d9f0f..e82bd968754 100644 --- a/homeassistant/util/json.py +++ b/homeassistant/util/json.py @@ -2,11 +2,12 @@ from __future__ import annotations from collections import deque +from collections.abc import Callable import json import logging import os import tempfile -from typing import Any, Callable +from typing import Any from homeassistant.core import Event, State from homeassistant.exceptions import HomeAssistantError From daebc34f4dc78df7b4157da891b60f18407b12ac Mon Sep 17 00:00:00 2001 From: avee87 <6134677+avee87@users.noreply.github.com> Date: Wed, 29 Sep 2021 15:59:46 +0100 Subject: [PATCH 689/843] Add code_format to template alarm (#56700) * Added code_format to template alarm * review comment * review comment * use constant * back to enum * none -> no_code --- .../template/alarm_control_panel.py | 23 +++- .../template/test_alarm_control_panel.py | 112 +++++++++++++++++- 2 files changed, 131 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py index 2706b2d433d..2cb830e54c2 100644 --- a/homeassistant/components/template/alarm_control_panel.py +++ b/homeassistant/components/template/alarm_control_panel.py @@ -1,4 +1,5 @@ """Support for Template alarm control panels.""" +from enum import Enum import logging import voluptuous as vol @@ -6,6 +7,7 @@ import voluptuous as vol from homeassistant.components.alarm_control_panel import ( ENTITY_ID_FORMAT, FORMAT_NUMBER, + FORMAT_TEXT, PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, AlarmControlPanelEntity, ) @@ -54,6 +56,16 @@ CONF_ARM_NIGHT_ACTION = "arm_night" CONF_DISARM_ACTION = "disarm" CONF_ALARM_CONTROL_PANELS = "panels" CONF_CODE_ARM_REQUIRED = "code_arm_required" +CONF_CODE_FORMAT = "code_format" + + +class CodeFormat(Enum): + """Class to represent different code formats.""" + + no_code = None + number = FORMAT_NUMBER + text = FORMAT_TEXT + ALARM_CONTROL_PANEL_SCHEMA = vol.Schema( { @@ -63,6 +75,9 @@ ALARM_CONTROL_PANEL_SCHEMA = vol.Schema( vol.Optional(CONF_ARM_HOME_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_ARM_NIGHT_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_CODE_ARM_REQUIRED, default=True): cv.boolean, + vol.Optional(CONF_CODE_FORMAT, default=CodeFormat.number.name): cv.enum( + CodeFormat + ), vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_UNIQUE_ID): cv.string, } @@ -89,6 +104,7 @@ async def _async_create_entities(hass, config): arm_home_action = device_config.get(CONF_ARM_HOME_ACTION) arm_night_action = device_config.get(CONF_ARM_NIGHT_ACTION) code_arm_required = device_config[CONF_CODE_ARM_REQUIRED] + code_format = device_config[CONF_CODE_FORMAT] unique_id = device_config.get(CONF_UNIQUE_ID) alarm_control_panels.append( @@ -102,6 +118,7 @@ async def _async_create_entities(hass, config): arm_home_action, arm_night_action, code_arm_required, + code_format, unique_id, ) ) @@ -128,6 +145,7 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity): arm_home_action, arm_night_action, code_arm_required, + code_format, unique_id, ): """Initialize the panel.""" @@ -139,6 +157,7 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity): self._template = state_template self._disarm_script = None self._code_arm_required = code_arm_required + self._code_format = code_format domain = __name__.split(".")[-2] if disarm_action is not None: self._disarm_script = Script(hass, disarm_action, name, domain) @@ -187,8 +206,8 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity): @property def code_format(self): - """Return one or more digits/characters.""" - return FORMAT_NUMBER + """Regex for code format or None if no code is required.""" + return self._code_format.value @property def code_arm_required(self): diff --git a/tests/components/template/test_alarm_control_panel.py b/tests/components/template/test_alarm_control_panel.py index fabf626afd3..e7a898efc49 100644 --- a/tests/components/template/test_alarm_control_panel.py +++ b/tests/components/template/test_alarm_control_panel.py @@ -204,7 +204,7 @@ async def test_no_action_scripts(hass, start_ha): "platform": "template", "panels": { "bad name here": { - "value_template": "{{ disarmed }}", + "value_template": "disarmed", "arm_away": { "service": "alarm_control_panel.alarm_arm_away", "entity_id": "alarm_control_panel.test", @@ -246,6 +246,40 @@ async def test_no_action_scripts(hass, start_ha): }, "required key not provided @ data['panels']", ), + ( + { + "alarm_control_panel": { + "platform": "template", + "panels": { + "test_template_panel": { + "value_template": "disarmed", + "arm_away": { + "service": "alarm_control_panel.alarm_arm_away", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "arm_home": { + "service": "alarm_control_panel.alarm_arm_home", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "arm_night": { + "service": "alarm_control_panel.alarm_arm_night", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "disarm": { + "service": "alarm_control_panel.alarm_disarm", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "code_format": "bad_format", + } + }, + } + }, + "value must be one of ['no_code', 'number', 'text']", + ), ], ) async def test_template_syntax_error(hass, msg, start_ha, caplog_setup_text): @@ -264,7 +298,7 @@ async def test_template_syntax_error(hass, msg, start_ha, caplog_setup_text): "panels": { "test_template_panel": { "name": "Template Alarm Panel", - "value_template": "{{ disarmed }}", + "value_template": "disarmed", "arm_away": { "service": "alarm_control_panel.alarm_arm_away", "entity_id": "alarm_control_panel.test", @@ -451,3 +485,77 @@ async def test_arm_home_action(hass, func, start_ha, calls): async def test_unique_id(hass, start_ha): """Test unique_id option only creates one alarm control panel per id.""" assert len(hass.states.async_all()) == 1 + + +@pytest.mark.parametrize("count,domain", [(1, "alarm_control_panel")]) +@pytest.mark.parametrize( + "config,code_format,code_arm_required", + [ + ( + { + "alarm_control_panel": { + "platform": "template", + "panels": { + "test_template_panel": { + "value_template": "disarmed", + } + }, + } + }, + "number", + True, + ), + ( + { + "alarm_control_panel": { + "platform": "template", + "panels": { + "test_template_panel": { + "value_template": "disarmed", + "code_format": "text", + } + }, + } + }, + "text", + True, + ), + ( + { + "alarm_control_panel": { + "platform": "template", + "panels": { + "test_template_panel": { + "value_template": "disarmed", + "code_format": "no_code", + "code_arm_required": False, + } + }, + } + }, + None, + False, + ), + ( + { + "alarm_control_panel": { + "platform": "template", + "panels": { + "test_template_panel": { + "value_template": "disarmed", + "code_format": "text", + "code_arm_required": False, + } + }, + } + }, + "text", + False, + ), + ], +) +async def test_code_config(hass, code_format, code_arm_required, start_ha): + """Test configuration options related to alarm code.""" + state = hass.states.get(TEMPLATE_NAME) + assert state.attributes.get("code_format") == code_format + assert state.attributes.get("code_arm_required") == code_arm_required From 00651a40554de2003fb0824bb524c38078d012a2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 29 Sep 2021 17:08:27 +0200 Subject: [PATCH 690/843] Optimize _get_states_with_session (#56734) * Optimize _get_states_with_session * Move custom filters to derived table * Remove useless derived table * Filter old states after grouping * Split query * Add comments * Simplify state update period criteria * Only apply custom filters if we didn't get an include list of entities Co-authored-by: J. Nick Koston --- homeassistant/components/recorder/history.py | 95 ++++++++++++-------- tests/components/recorder/test_history.py | 13 ++- 2 files changed, 70 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/recorder/history.py b/homeassistant/components/recorder/history.py index 6c89fef2be3..36a4f6d0696 100644 --- a/homeassistant/components/recorder/history.py +++ b/homeassistant/components/recorder/history.py @@ -80,6 +80,11 @@ def _get_significant_states( """ Return states changes during UTC period start_time - end_time. + entity_ids is an optional iterable of entities to include in the results. + + filters is an optional SQLAlchemy filter which will be applied to the database + queries unless entity_ids is given, in which case its ignored. + Significant states are all states where there is a state change, as well as all states from certain domains (for instance thermostat so that we get current temperature in our graphs). @@ -240,47 +245,63 @@ def _get_states_with_session( if run is None: return [] - # We have more than one entity to look at (most commonly we want - # all entities,) so we need to do a search on all states since the - # last recorder run started. + # We have more than one entity to look at so we need to do a query on states + # since the last recorder run started. query = session.query(*QUERY_STATES) - most_recent_states_by_date = session.query( - States.entity_id.label("max_entity_id"), - func.max(States.last_updated).label("max_last_updated"), - ).filter( - (States.last_updated >= run.start) & (States.last_updated < utc_point_in_time) - ) - if entity_ids: - most_recent_states_by_date.filter(States.entity_id.in_(entity_ids)) - - most_recent_states_by_date = most_recent_states_by_date.group_by(States.entity_id) - - most_recent_states_by_date = most_recent_states_by_date.subquery() - - most_recent_state_ids = session.query( - func.max(States.state_id).label("max_state_id") - ).join( - most_recent_states_by_date, - and_( - States.entity_id == most_recent_states_by_date.c.max_entity_id, - States.last_updated == most_recent_states_by_date.c.max_last_updated, - ), - ) - - most_recent_state_ids = most_recent_state_ids.group_by(States.entity_id) - - most_recent_state_ids = most_recent_state_ids.subquery() - - query = query.join( - most_recent_state_ids, - States.state_id == most_recent_state_ids.c.max_state_id, - ) - - if entity_ids is not None: - query = query.filter(States.entity_id.in_(entity_ids)) + # We got an include-list of entities, accelerate the query by filtering already + # in the inner query. + most_recent_state_ids = ( + session.query( + func.max(States.state_id).label("max_state_id"), + ) + .filter( + (States.last_updated >= run.start) + & (States.last_updated < utc_point_in_time) + ) + .filter(States.entity_id.in_(entity_ids)) + ) + most_recent_state_ids = most_recent_state_ids.group_by(States.entity_id) + most_recent_state_ids = most_recent_state_ids.subquery() + query = query.join( + most_recent_state_ids, + States.state_id == most_recent_state_ids.c.max_state_id, + ) else: + # We did not get an include-list of entities, query all states in the inner + # query, then filter out unwanted domains as well as applying the custom filter. + # This filtering can't be done in the inner query because the domain column is + # not indexed and we can't control what's in the custom filter. + most_recent_states_by_date = ( + session.query( + States.entity_id.label("max_entity_id"), + func.max(States.last_updated).label("max_last_updated"), + ) + .filter( + (States.last_updated >= run.start) + & (States.last_updated < utc_point_in_time) + ) + .group_by(States.entity_id) + .subquery() + ) + most_recent_state_ids = ( + session.query(func.max(States.state_id).label("max_state_id")) + .join( + most_recent_states_by_date, + and_( + States.entity_id == most_recent_states_by_date.c.max_entity_id, + States.last_updated + == most_recent_states_by_date.c.max_last_updated, + ), + ) + .group_by(States.entity_id) + .subquery() + ) + query = query.join( + most_recent_state_ids, + States.state_id == most_recent_state_ids.c.max_state_id, + ) query = query.filter(~States.domain.in_(IGNORE_DOMAINS)) if filters: query = filters.apply(query) diff --git a/tests/components/recorder/test_history.py b/tests/components/recorder/test_history.py index b2940f2bb39..67a666c934f 100644 --- a/tests/components/recorder/test_history.py +++ b/tests/components/recorder/test_history.py @@ -48,13 +48,24 @@ def test_get_states(hass_recorder): wait_recording_done(hass) - # Get states returns everything before POINT + # Get states returns everything before POINT for all entities for state1, state2 in zip( states, sorted(history.get_states(hass, future), key=lambda state: state.entity_id), ): assert state1 == state2 + # Get states returns everything before POINT for tested entities + entities = [f"test.point_in_time_{i % 5}" for i in range(5)] + for state1, state2 in zip( + states, + sorted( + history.get_states(hass, future, entities), + key=lambda state: state.entity_id, + ), + ): + assert state1 == state2 + # Test get_state here because we have a DB setup assert states[0] == history.get_state(hass, future, states[0].entity_id) From d13c3e3917c7f9c4c84335b5b53bef91618a1457 Mon Sep 17 00:00:00 2001 From: Andre Richter Date: Wed, 29 Sep 2021 17:14:41 +0200 Subject: [PATCH 691/843] Migrate Vallox to new fan entity model (#56663) * Migrate Vallox to new fan entity model * Review comments 1 * Minor corrections * Review comments 2 --- homeassistant/components/vallox/__init__.py | 35 +++-- homeassistant/components/vallox/const.py | 18 +++ homeassistant/components/vallox/fan.py | 124 ++++++++++++------ homeassistant/components/vallox/sensor.py | 11 +- homeassistant/components/vallox/services.yaml | 2 +- 5 files changed, 126 insertions(+), 64 deletions(-) diff --git a/homeassistant/components/vallox/__init__.py b/homeassistant/components/vallox/__init__.py index 96c83c82c36..bdd7242a76a 100644 --- a/homeassistant/components/vallox/__init__.py +++ b/homeassistant/components/vallox/__init__.py @@ -30,6 +30,7 @@ from .const import ( METRIC_KEY_PROFILE_FAN_SPEED_HOME, SIGNAL_VALLOX_STATE_UPDATE, STATE_PROXY_SCAN_INTERVAL, + STR_TO_VALLOX_PROFILE_SETTABLE, ) _LOGGER = logging.getLogger(__name__) @@ -46,25 +47,15 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -PROFILE_TO_STR_SETTABLE = { - VALLOX_PROFILE.HOME: "Home", - VALLOX_PROFILE.AWAY: "Away", - VALLOX_PROFILE.BOOST: "Boost", - VALLOX_PROFILE.FIREPLACE: "Fireplace", -} - -STR_TO_PROFILE = {v: k for (k, v) in PROFILE_TO_STR_SETTABLE.items()} - -PROFILE_TO_STR_REPORTABLE = { - **{VALLOX_PROFILE.NONE: "None", VALLOX_PROFILE.EXTRA: "Extra"}, - **PROFILE_TO_STR_SETTABLE, -} - ATTR_PROFILE = "profile" ATTR_PROFILE_FAN_SPEED = "fan_speed" SERVICE_SCHEMA_SET_PROFILE = vol.Schema( - {vol.Required(ATTR_PROFILE): vol.All(cv.string, vol.In(STR_TO_PROFILE))} + { + vol.Required(ATTR_PROFILE): vol.All( + cv.string, vol.In(STR_TO_VALLOX_PROFILE_SETTABLE) + ) + } ) SERVICE_SCHEMA_SET_PROFILE_FAN_SPEED = vol.Schema( @@ -163,14 +154,14 @@ class ValloxStateProxy: return value - def get_profile(self) -> str: + def get_profile(self) -> VALLOX_PROFILE: """Return cached profile value.""" _LOGGER.debug("Returning profile") if not self._valid: raise OSError("Device state out of sync.") - return PROFILE_TO_STR_REPORTABLE[self._profile] + return self._profile async def async_update(self, time: datetime | None = None) -> None: """Fetch state update.""" @@ -201,8 +192,13 @@ class ValloxServiceHandler: """Set the ventilation profile.""" _LOGGER.debug("Setting ventilation profile to: %s", profile) + _LOGGER.warning( + "Attention: The service 'vallox.set_profile' is superseded by the 'fan.set_preset_mode' service." + "It will be removed in the future, please migrate to 'fan.set_preset_mode' to prevent breakage" + ) + try: - await self._client.set_profile(STR_TO_PROFILE[profile]) + await self._client.set_profile(STR_TO_VALLOX_PROFILE_SETTABLE[profile]) return True except (OSError, ValloxApiException) as err: @@ -271,6 +267,7 @@ class ValloxServiceHandler: result = await getattr(self, method["method"])(**params) - # Force state_proxy to refresh device state, so that updates are propagated to platforms. + # This state change affects other entities like sensors. Force an immediate update that can + # be observed by all parties involved. if result: await self._state_proxy.async_update() diff --git a/homeassistant/components/vallox/const.py b/homeassistant/components/vallox/const.py index 038e46043da..6a9c4ddc5f4 100644 --- a/homeassistant/components/vallox/const.py +++ b/homeassistant/components/vallox/const.py @@ -2,6 +2,8 @@ from datetime import timedelta +from vallox_websocket_api import PROFILE as VALLOX_PROFILE + DOMAIN = "vallox" DEFAULT_NAME = "Vallox" @@ -20,3 +22,19 @@ MODE_OFF = 5 DEFAULT_FAN_SPEED_HOME = 50 DEFAULT_FAN_SPEED_AWAY = 25 DEFAULT_FAN_SPEED_BOOST = 65 + +VALLOX_PROFILE_TO_STR_SETTABLE = { + VALLOX_PROFILE.HOME: "Home", + VALLOX_PROFILE.AWAY: "Away", + VALLOX_PROFILE.BOOST: "Boost", + VALLOX_PROFILE.FIREPLACE: "Fireplace", +} + +VALLOX_PROFILE_TO_STR_REPORTABLE = { + VALLOX_PROFILE.EXTRA: "Extra", + **VALLOX_PROFILE_TO_STR_SETTABLE, +} + +STR_TO_VALLOX_PROFILE_SETTABLE = { + value: key for (key, value) in VALLOX_PROFILE_TO_STR_SETTABLE.items() +} diff --git a/homeassistant/components/vallox/fan.py b/homeassistant/components/vallox/fan.py index b8d320a7e7e..8ee1b8b471f 100644 --- a/homeassistant/components/vallox/fan.py +++ b/homeassistant/components/vallox/fan.py @@ -6,8 +6,13 @@ import logging from typing import Any from vallox_websocket_api import Vallox +from vallox_websocket_api.exceptions import ValloxApiException -from homeassistant.components.fan import FanEntity +from homeassistant.components.fan import ( + SUPPORT_PRESET_MODE, + FanEntity, + NotValidPresetModeError, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -23,11 +28,12 @@ from .const import ( MODE_OFF, MODE_ON, SIGNAL_VALLOX_STATE_UPDATE, + STR_TO_VALLOX_PROFILE_SETTABLE, + VALLOX_PROFILE_TO_STR_SETTABLE, ) _LOGGER = logging.getLogger(__name__) -# Device attributes ATTR_PROFILE_FAN_SPEED_HOME = { "description": "fan_speed_home", "metric_key": METRIC_KEY_PROFILE_FAN_SPEED_HOME, @@ -65,39 +71,44 @@ async def async_setup_platform( class ValloxFan(FanEntity): """Representation of the fan.""" + _attr_should_poll = False + def __init__( self, name: str, client: Vallox, state_proxy: ValloxStateProxy ) -> None: """Initialize the fan.""" - self._name = name self._client = client self._state_proxy = state_proxy - self._available = False self._is_on = False + self._preset_mode: str | None = None self._fan_speed_home: int | None = None self._fan_speed_away: int | None = None self._fan_speed_boost: int | None = None - @property - def should_poll(self) -> bool: - """Do not poll the device.""" - return False + self._attr_name = name + self._attr_available = False @property - def name(self) -> str: - """Return the name of the device.""" - return self._name + def supported_features(self) -> int: + """Flag supported features.""" + return SUPPORT_PRESET_MODE @property - def available(self) -> bool: - """Return if state is known.""" - return self._available + def preset_modes(self) -> list[str]: + """Return a list of available preset modes.""" + # Use the Vallox profile names for the preset names. + return list(STR_TO_VALLOX_PROFILE_SETTABLE.keys()) @property def is_on(self) -> bool: """Return if device is on.""" return self._is_on + @property + def preset_mode(self) -> str | None: + """Return the current preset mode.""" + return self._preset_mode + @property def extra_state_attributes(self) -> Mapping[str, int | None]: """Return device specific state attributes.""" @@ -126,6 +137,8 @@ class ValloxFan(FanEntity): # Fetch if the whole device is in regular operation state. self._is_on = self._state_proxy.fetch_metric(METRIC_KEY_MODE) == MODE_ON + vallox_profile = self._state_proxy.get_profile() + # Fetch the profile fan speeds. fan_speed_home = self._state_proxy.fetch_metric( ATTR_PROFILE_FAN_SPEED_HOME["metric_key"] @@ -138,10 +151,12 @@ class ValloxFan(FanEntity): ) except (OSError, KeyError, TypeError) as err: - self._available = False + self._attr_available = False _LOGGER.error("Error updating fan: %s", err) return + self._preset_mode = VALLOX_PROFILE_TO_STR_SETTABLE.get(vallox_profile) + self._fan_speed_home = ( int(fan_speed_home) if isinstance(fan_speed_home, (int, float)) else None ) @@ -152,15 +167,42 @@ class ValloxFan(FanEntity): int(fan_speed_boost) if isinstance(fan_speed_boost, (int, float)) else None ) - self._available = True + self._attr_available = True + + async def _async_set_preset_mode_internal(self, preset_mode: str) -> bool: + """ + Set new preset mode. + + Returns true if the mode has been changed, false otherwise. + """ + try: + self._valid_preset_mode_or_raise(preset_mode) # type: ignore[no-untyped-call] + + except NotValidPresetModeError as err: + _LOGGER.error(err) + return False + + if preset_mode == self.preset_mode: + return False + + try: + await self._client.set_profile(STR_TO_VALLOX_PROFILE_SETTABLE[preset_mode]) + + except (OSError, ValloxApiException) as err: + _LOGGER.error("Error setting preset: %s", err) + return False + + return True + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + update_needed = await self._async_set_preset_mode_internal(preset_mode) + + if update_needed: + # This state change affects other entities like sensors. Force an immediate update that + # can be observed by all parties involved. + await self._state_proxy.async_update() - # - # The fan entity model has changed to use percentages and preset_modes - # instead of speeds. - # - # Please review - # https://developers.home-assistant.io/docs/core/entity/fan/ - # async def async_turn_on( self, speed: str | None = None, @@ -171,39 +213,37 @@ class ValloxFan(FanEntity): """Turn the device on.""" _LOGGER.debug("Turn on: %s", speed) - # Only the case speed == None equals the GUI toggle switch being activated. - if speed is not None: - return + update_needed = False - if self._is_on: - _LOGGER.error("Already on") - return + if preset_mode: + update_needed = await self._async_set_preset_mode_internal(preset_mode) - try: - await self._client.set_values({METRIC_KEY_MODE: MODE_ON}) + if not self.is_on: + try: + await self._client.set_values({METRIC_KEY_MODE: MODE_ON}) - except OSError as err: - self._available = False - _LOGGER.error("Error turning on: %s", err) - return + except OSError as err: + _LOGGER.error("Error turning on: %s", err) - # This state change affects other entities like sensors. Force an immediate update that can - # be observed by all parties involved. - await self._state_proxy.async_update() + else: + update_needed = True + + if update_needed: + # This state change affects other entities like sensors. Force an immediate update that + # can be observed by all parties involved. + await self._state_proxy.async_update() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" - if not self._is_on: - _LOGGER.error("Already off") + if not self.is_on: return try: await self._client.set_values({METRIC_KEY_MODE: MODE_OFF}) except OSError as err: - self._available = False _LOGGER.error("Error turning off: %s", err) return # Same as for turn_on method. - await self._state_proxy.async_update(None) + await self._state_proxy.async_update() diff --git a/homeassistant/components/vallox/sensor.py b/homeassistant/components/vallox/sensor.py index 74920853eb6..ff22c317bc1 100644 --- a/homeassistant/components/vallox/sensor.py +++ b/homeassistant/components/vallox/sensor.py @@ -25,7 +25,13 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import ValloxStateProxy -from .const import DOMAIN, METRIC_KEY_MODE, MODE_ON, SIGNAL_VALLOX_STATE_UPDATE +from .const import ( + DOMAIN, + METRIC_KEY_MODE, + MODE_ON, + SIGNAL_VALLOX_STATE_UPDATE, + VALLOX_PROFILE_TO_STR_REPORTABLE, +) _LOGGER = logging.getLogger(__name__) @@ -89,13 +95,14 @@ class ValloxProfileSensor(ValloxSensor): async def async_update(self) -> None: """Fetch state from the ventilation unit.""" try: - self._attr_native_value = self._state_proxy.get_profile() + vallox_profile = self._state_proxy.get_profile() except OSError as err: self._attr_available = False _LOGGER.error("Error updating sensor: %s", err) return + self._attr_native_value = VALLOX_PROFILE_TO_STR_REPORTABLE.get(vallox_profile) self._attr_available = True diff --git a/homeassistant/components/vallox/services.yaml b/homeassistant/components/vallox/services.yaml index 98d7abac249..5cfa1dae4b5 100644 --- a/homeassistant/components/vallox/services.yaml +++ b/homeassistant/components/vallox/services.yaml @@ -15,7 +15,7 @@ set_profile: - 'Home' set_profile_fan_speed_home: - name: Set profile fan speed hom + name: Set profile fan speed home description: Set the fan speed of the Home profile. fields: fan_speed: From 40ecf22bac25d384ca36600e77454deb412b91a0 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 29 Sep 2021 17:20:17 +0200 Subject: [PATCH 692/843] Remove automatic splitting of net meters from statistics (#56772) --- .../components/recorder/migration.py | 10 -- homeassistant/components/recorder/models.py | 2 - .../components/recorder/statistics.py | 11 +- homeassistant/components/sensor/recorder.py | 11 -- tests/components/history/test_init.py | 2 - tests/components/recorder/test_statistics.py | 10 -- .../components/recorder/test_websocket_api.py | 6 - tests/components/sensor/test_recorder.py | 114 ------------------ 8 files changed, 2 insertions(+), 164 deletions(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 8eef957bc88..0c9b7d767d8 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -502,15 +502,6 @@ def _apply_update(instance, session, new_version, old_version): # noqa: C901 ], ) elif new_version == 21: - if engine.dialect.name in ["mysql", "oracle", "postgresql"]: - data_type = "DOUBLE PRECISION" - else: - data_type = "FLOAT" - _add_columns( - connection, - "statistics", - [f"sum_increase {data_type}"], - ) # Try to change the character set of the statistic_meta table if engine.dialect.name == "mysql": for table in ("events", "states", "statistics_meta"): @@ -591,7 +582,6 @@ def _apply_update(instance, session, new_version, old_version): # noqa: C901 last_reset=last_statistic.last_reset, state=last_statistic.state, sum=last_statistic.sum, - sum_increase=last_statistic.sum_increase, ) ) else: diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py index 7b561ab3f5b..0d9e31bc25d 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -249,7 +249,6 @@ class StatisticData(StatisticDataBase, total=False): last_reset: datetime | None state: float sum: float - sum_increase: float class StatisticsBase: @@ -274,7 +273,6 @@ class StatisticsBase: last_reset = Column(DATETIME_TYPE) state = Column(DOUBLE_TYPE) sum = Column(DOUBLE_TYPE) - sum_increase = Column(DOUBLE_TYPE) @classmethod def from_stats(cls, metadata_id: str, stats: StatisticData): diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index ec76cc5545b..4cc20f55910 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -54,7 +54,6 @@ QUERY_STATISTICS = [ Statistics.last_reset, Statistics.state, Statistics.sum, - Statistics.sum_increase, ] QUERY_STATISTICS_SHORT_TERM = [ @@ -66,7 +65,6 @@ QUERY_STATISTICS_SHORT_TERM = [ StatisticsShortTerm.last_reset, StatisticsShortTerm.state, StatisticsShortTerm.sum, - StatisticsShortTerm.sum_increase, ] QUERY_STATISTICS_SUMMARY_MEAN = [ @@ -82,7 +80,6 @@ QUERY_STATISTICS_SUMMARY_SUM = [ StatisticsShortTerm.last_reset, StatisticsShortTerm.state, StatisticsShortTerm.sum, - StatisticsShortTerm.sum_increase, func.row_number() .over( partition_by=StatisticsShortTerm.metadata_id, @@ -308,14 +305,13 @@ def compile_hourly_statistics( if stats: for stat in stats: - metadata_id, start, last_reset, state, _sum, sum_increase, _ = stat + metadata_id, start, last_reset, state, _sum, _ = stat if metadata_id in summary: summary[metadata_id].update( { "last_reset": process_timestamp(last_reset), "state": state, "sum": _sum, - "sum_increase": sum_increase, } ) else: @@ -324,7 +320,6 @@ def compile_hourly_statistics( "last_reset": process_timestamp(last_reset), "state": state, "sum": _sum, - "sum_increase": sum_increase, } # Insert compiled hourly statistics in the database @@ -693,9 +688,7 @@ def _sorted_statistics_to_dict( db_state.last_reset ), "state": convert(db_state.state, units), - "sum": (_sum := convert(db_state.sum, units)), - "sum_increase": (inc := convert(db_state.sum_increase, units)), - "sum_decrease": None if _sum is None or inc is None else inc - _sum, + "sum": convert(db_state.sum, units), } ) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index b8499bc8040..07a55795677 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -419,15 +419,12 @@ def compile_statistics( # noqa: C901 last_reset = old_last_reset = None new_state = old_state = None _sum = 0.0 - sum_increase = 0.0 - sum_increase_tmp = 0.0 last_stats = statistics.get_last_statistics(hass, 1, entity_id, False) if entity_id in last_stats: # We have compiled history for this sensor before, use that as a starting point last_reset = old_last_reset = last_stats[entity_id][0]["last_reset"] new_state = old_state = last_stats[entity_id][0]["state"] _sum = last_stats[entity_id][0]["sum"] or 0.0 - sum_increase = last_stats[entity_id][0]["sum_increase"] or 0.0 for fstate, state in fstates: @@ -486,10 +483,6 @@ def compile_statistics( # noqa: C901 # The sensor has been reset, update the sum if old_state is not None: _sum += new_state - old_state - sum_increase += sum_increase_tmp - sum_increase_tmp = 0.0 - if fstate > 0: - sum_increase_tmp += fstate # ..and update the starting point new_state = fstate old_last_reset = last_reset @@ -499,8 +492,6 @@ def compile_statistics( # noqa: C901 else: old_state = new_state else: - if new_state is not None and fstate > new_state: - sum_increase_tmp += fstate - new_state new_state = fstate # Deprecated, will be removed in Home Assistant 2021.11 @@ -514,11 +505,9 @@ def compile_statistics( # noqa: C901 # Update the sum with the last state _sum += new_state - old_state - sum_increase += sum_increase_tmp if last_reset is not None: stat["last_reset"] = dt_util.parse_datetime(last_reset) stat["sum"] = _sum - stat["sum_increase"] = sum_increase stat["state"] = new_state result.append({"meta": meta, "stat": (stat,)}) diff --git a/tests/components/history/test_init.py b/tests/components/history/test_init.py index 661d703725d..35075d79241 100644 --- a/tests/components/history/test_init.py +++ b/tests/components/history/test_init.py @@ -916,8 +916,6 @@ async def test_statistics_during_period( "last_reset": None, "state": None, "sum": None, - "sum_decrease": None, - "sum_increase": None, } ] } diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index 8116eaa4b06..d3496407949 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -52,8 +52,6 @@ def test_compile_hourly_statistics(hass_recorder): "last_reset": None, "state": None, "sum": None, - "sum_decrease": None, - "sum_increase": None, } expected_2 = { "statistic_id": "sensor.test1", @@ -65,8 +63,6 @@ def test_compile_hourly_statistics(hass_recorder): "last_reset": None, "state": None, "sum": None, - "sum_decrease": None, - "sum_increase": None, } expected_stats1 = [ {**expected_1, "statistic_id": "sensor.test1"}, @@ -182,8 +178,6 @@ def test_compile_periodic_statistics_exception( "last_reset": None, "state": None, "sum": None, - "sum_decrease": None, - "sum_increase": None, } expected_2 = { "statistic_id": "sensor.test1", @@ -195,8 +189,6 @@ def test_compile_periodic_statistics_exception( "last_reset": None, "state": None, "sum": None, - "sum_decrease": None, - "sum_increase": None, } expected_stats1 = [ {**expected_1, "statistic_id": "sensor.test1"}, @@ -255,8 +247,6 @@ def test_rename_entity(hass_recorder): "last_reset": None, "state": None, "sum": None, - "sum_decrease": None, - "sum_increase": None, } expected_stats1 = [ {**expected_1, "statistic_id": "sensor.test1"}, diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index 9e856fc6b85..4f54a43ca6e 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -293,8 +293,6 @@ async def test_clear_statistics(hass, hass_ws_client): "last_reset": None, "state": None, "sum": None, - "sum_decrease": None, - "sum_increase": None, } ], "sensor.test2": [ @@ -308,8 +306,6 @@ async def test_clear_statistics(hass, hass_ws_client): "last_reset": None, "state": None, "sum": None, - "sum_decrease": None, - "sum_increase": None, } ], "sensor.test3": [ @@ -323,8 +319,6 @@ async def test_clear_statistics(hass, hass_ws_client): "last_reset": None, "state": None, "sum": None, - "sum_decrease": None, - "sum_increase": None, } ], } diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 0860fbef525..de4fc3f4fb6 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -113,8 +113,6 @@ def test_compile_hourly_statistics( "last_reset": None, "state": None, "sum": None, - "sum_decrease": None, - "sum_increase": None, } ] } @@ -178,8 +176,6 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes "last_reset": None, "state": None, "sum": None, - "sum_decrease": None, - "sum_increase": None, } ], "sensor.test6": [ @@ -193,8 +189,6 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes "last_reset": None, "state": None, "sum": None, - "sum_decrease": None, - "sum_increase": None, } ], "sensor.test7": [ @@ -208,8 +202,6 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes "last_reset": None, "state": None, "sum": None, - "sum_decrease": None, - "sum_increase": None, } ], } @@ -285,8 +277,6 @@ def test_compile_hourly_sum_statistics_amount( "last_reset": process_timestamp_to_utc_isoformat(period0), "state": approx(factor * seq[2]), "sum": approx(factor * 10.0), - "sum_decrease": approx(factor * 0.0), - "sum_increase": approx(factor * 10.0), }, { "statistic_id": "sensor.test1", @@ -298,8 +288,6 @@ def test_compile_hourly_sum_statistics_amount( "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(factor * seq[5]), "sum": approx(factor * 40.0), - "sum_decrease": approx(factor * 10.0), - "sum_increase": approx(factor * 50.0), }, { "statistic_id": "sensor.test1", @@ -311,8 +299,6 @@ def test_compile_hourly_sum_statistics_amount( "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(factor * seq[8]), "sum": approx(factor * 70.0), - "sum_decrease": approx(factor * 10.0), - "sum_increase": approx(factor * 80.0), }, ] } @@ -407,8 +393,6 @@ def test_compile_hourly_sum_statistics_amount_reset_every_state_change( "last_reset": process_timestamp_to_utc_isoformat(dt_util.as_local(one)), "state": approx(factor * seq[7]), "sum": approx(factor * (sum(seq) - seq[0])), - "sum_decrease": approx(factor * 0.0), - "sum_increase": approx(factor * (sum(seq) - seq[0])), }, { "statistic_id": "sensor.test1", @@ -422,8 +406,6 @@ def test_compile_hourly_sum_statistics_amount_reset_every_state_change( "last_reset": process_timestamp_to_utc_isoformat(dt_util.as_local(two)), "state": approx(factor * seq[7]), "sum": approx(factor * (2 * sum(seq) - seq[0])), - "sum_decrease": approx(factor * 0.0), - "sum_increase": approx(factor * (2 * sum(seq) - seq[0])), }, ] } @@ -495,8 +477,6 @@ def test_compile_hourly_sum_statistics_amount_invalid_last_reset( "last_reset": process_timestamp_to_utc_isoformat(dt_util.as_local(one)), "state": approx(factor * seq[7]), "sum": approx(factor * (sum(seq) - seq[0] - seq[3])), - "sum_decrease": approx(factor * 0.0), - "sum_increase": approx(factor * (sum(seq) - seq[0] - seq[3])), }, ] } @@ -565,10 +545,6 @@ def test_compile_hourly_sum_statistics_nan_inf_state( "last_reset": process_timestamp_to_utc_isoformat(one), "state": approx(factor * seq[7]), "sum": approx(factor * (seq[2] + seq[3] + seq[4] + seq[6] + seq[7])), - "sum_decrease": approx(factor * 0.0), - "sum_increase": approx( - factor * (seq[2] + seq[3] + seq[4] + seq[6] + seq[7]) - ), }, ] } @@ -635,8 +611,6 @@ def test_compile_hourly_sum_statistics_total_no_reset( "last_reset": None, "state": approx(factor * seq[2]), "sum": approx(factor * 10.0), - "sum_decrease": approx(factor * 0.0), - "sum_increase": approx(factor * 10.0), }, { "statistic_id": "sensor.test1", @@ -648,8 +622,6 @@ def test_compile_hourly_sum_statistics_total_no_reset( "last_reset": None, "state": approx(factor * seq[5]), "sum": approx(factor * 30.0), - "sum_decrease": approx(factor * 10.0), - "sum_increase": approx(factor * 40.0), }, { "statistic_id": "sensor.test1", @@ -661,8 +633,6 @@ def test_compile_hourly_sum_statistics_total_no_reset( "last_reset": None, "state": approx(factor * seq[8]), "sum": approx(factor * 60.0), - "sum_decrease": approx(factor * 10.0), - "sum_increase": approx(factor * 70.0), }, ] } @@ -727,8 +697,6 @@ def test_compile_hourly_sum_statistics_total_increasing( "last_reset": None, "state": approx(factor * seq[2]), "sum": approx(factor * 10.0), - "sum_decrease": approx(factor * 0.0), - "sum_increase": approx(factor * 10.0), }, { "statistic_id": "sensor.test1", @@ -740,8 +708,6 @@ def test_compile_hourly_sum_statistics_total_increasing( "last_reset": None, "state": approx(factor * seq[5]), "sum": approx(factor * 50.0), - "sum_decrease": approx(factor * 0.0), - "sum_increase": approx(factor * 50.0), }, { "statistic_id": "sensor.test1", @@ -753,8 +719,6 @@ def test_compile_hourly_sum_statistics_total_increasing( "last_reset": None, "state": approx(factor * seq[8]), "sum": approx(factor * 80.0), - "sum_decrease": approx(factor * 0.0), - "sum_increase": approx(factor * 80.0), }, ] } @@ -829,8 +793,6 @@ def test_compile_hourly_sum_statistics_total_increasing_small_dip( "min": None, "state": approx(factor * seq[2]), "sum": approx(factor * 10.0), - "sum_decrease": approx(factor * 0.0), - "sum_increase": approx(factor * 10.0), }, { "last_reset": None, @@ -842,8 +804,6 @@ def test_compile_hourly_sum_statistics_total_increasing_small_dip( "min": None, "state": approx(factor * seq[5]), "sum": approx(factor * 30.0), - "sum_decrease": approx(factor * 1.0), - "sum_increase": approx(factor * 31.0), }, { "last_reset": None, @@ -855,8 +815,6 @@ def test_compile_hourly_sum_statistics_total_increasing_small_dip( "min": None, "state": approx(factor * seq[8]), "sum": approx(factor * 60.0), - "sum_decrease": approx(factor * 2.0), - "sum_increase": approx(factor * 62.0), }, ] } @@ -928,8 +886,6 @@ def test_compile_hourly_energy_statistics_unsupported(hass_recorder, caplog): "last_reset": process_timestamp_to_utc_isoformat(period0), "state": approx(20.0), "sum": approx(10.0), - "sum_decrease": approx(0.0), - "sum_increase": approx(10.0), }, { "statistic_id": "sensor.test1", @@ -941,8 +897,6 @@ def test_compile_hourly_energy_statistics_unsupported(hass_recorder, caplog): "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(40.0), "sum": approx(40.0), - "sum_decrease": approx(10.0), - "sum_increase": approx(50.0), }, { "statistic_id": "sensor.test1", @@ -954,8 +908,6 @@ def test_compile_hourly_energy_statistics_unsupported(hass_recorder, caplog): "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(70.0), "sum": approx(70.0), - "sum_decrease": approx(10.0), - "sum_increase": approx(80.0), }, ] } @@ -1023,8 +975,6 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "last_reset": process_timestamp_to_utc_isoformat(period0), "state": approx(20.0), "sum": approx(10.0), - "sum_decrease": approx(0.0), - "sum_increase": approx(10.0), }, { "statistic_id": "sensor.test1", @@ -1036,8 +986,6 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(40.0), "sum": approx(40.0), - "sum_decrease": approx(10.0), - "sum_increase": approx(50.0), }, { "statistic_id": "sensor.test1", @@ -1049,8 +997,6 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(70.0), "sum": approx(70.0), - "sum_decrease": approx(10.0), - "sum_increase": approx(80.0), }, ], "sensor.test2": [ @@ -1064,8 +1010,6 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "last_reset": process_timestamp_to_utc_isoformat(period0), "state": approx(130.0), "sum": approx(20.0), - "sum_decrease": approx(0.0), - "sum_increase": approx(20.0), }, { "statistic_id": "sensor.test2", @@ -1077,8 +1021,6 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(45.0), "sum": approx(-65.0), - "sum_decrease": approx(130.0), - "sum_increase": approx(65.0), }, { "statistic_id": "sensor.test2", @@ -1090,8 +1032,6 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(75.0), "sum": approx(-35.0), - "sum_decrease": approx(130.0), - "sum_increase": approx(95.0), }, ], "sensor.test3": [ @@ -1105,8 +1045,6 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "last_reset": process_timestamp_to_utc_isoformat(period0), "state": approx(5.0 / 1000), "sum": approx(5.0 / 1000), - "sum_decrease": approx(0.0 / 1000), - "sum_increase": approx(5.0 / 1000), }, { "statistic_id": "sensor.test3", @@ -1118,8 +1056,6 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(50.0 / 1000), "sum": approx(60.0 / 1000), - "sum_decrease": approx(0.0 / 1000), - "sum_increase": approx(60.0 / 1000), }, { "statistic_id": "sensor.test3", @@ -1131,8 +1067,6 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(90.0 / 1000), "sum": approx(100.0 / 1000), - "sum_decrease": approx(0.0 / 1000), - "sum_increase": approx(100.0 / 1000), }, ], } @@ -1187,8 +1121,6 @@ def test_compile_hourly_statistics_unchanged( "last_reset": None, "state": None, "sum": None, - "sum_decrease": None, - "sum_increase": None, } ] } @@ -1222,8 +1154,6 @@ def test_compile_hourly_statistics_partially_unavailable(hass_recorder, caplog): "last_reset": None, "state": None, "sum": None, - "sum_decrease": None, - "sum_increase": None, } ] } @@ -1282,8 +1212,6 @@ def test_compile_hourly_statistics_unavailable( "last_reset": None, "state": None, "sum": None, - "sum_decrease": None, - "sum_increase": None, } ] } @@ -1435,8 +1363,6 @@ def test_compile_hourly_statistics_changing_units_1( "last_reset": None, "state": None, "sum": None, - "sum_decrease": None, - "sum_increase": None, } ] } @@ -1464,8 +1390,6 @@ def test_compile_hourly_statistics_changing_units_1( "last_reset": None, "state": None, "sum": None, - "sum_decrease": None, - "sum_increase": None, } ] } @@ -1572,8 +1496,6 @@ def test_compile_hourly_statistics_changing_units_3( "last_reset": None, "state": None, "sum": None, - "sum_decrease": None, - "sum_increase": None, } ] } @@ -1599,8 +1521,6 @@ def test_compile_hourly_statistics_changing_units_3( "last_reset": None, "state": None, "sum": None, - "sum_decrease": None, - "sum_increase": None, } ] } @@ -1680,8 +1600,6 @@ def test_compile_hourly_statistics_changing_statistics( "last_reset": None, "state": None, "sum": None, - "sum_decrease": None, - "sum_increase": None, }, { "statistic_id": "sensor.test1", @@ -1693,8 +1611,6 @@ def test_compile_hourly_statistics_changing_statistics( "last_reset": None, "state": approx(30.0), "sum": approx(30.0), - "sum_decrease": approx(10.0), - "sum_increase": approx(40.0), }, ] } @@ -1763,8 +1679,6 @@ def test_compile_statistics_hourly_summary(hass_recorder, caplog): expected_averages = {"sensor.test1": [], "sensor.test2": [], "sensor.test3": []} expected_states = {"sensor.test4": []} expected_sums = {"sensor.test4": []} - expected_decreases = {"sensor.test4": []} - expected_increases = {"sensor.test4": []} last_states = { "sensor.test1": None, "sensor.test2": None, @@ -1818,10 +1732,6 @@ def test_compile_statistics_hourly_summary(hass_recorder, caplog): expected_sums["sensor.test4"].append( _sum(seq, last_state, expected_sums["sensor.test4"]) ) - expected_decreases["sensor.test4"].append(0) - expected_increases["sensor.test4"].append( - _sum(seq, last_state, expected_increases["sensor.test4"]) - ) last_states["sensor.test4"] = seq[-1] start += timedelta(minutes=5) @@ -1879,16 +1789,6 @@ def test_compile_statistics_hourly_summary(hass_recorder, caplog): expected_sum = ( expected_sums[entity_id][i] if entity_id in expected_sums else None ) - expected_decrease = ( - expected_decreases[entity_id][i] - if entity_id in expected_decreases - else None - ) - expected_increase = ( - expected_increases[entity_id][i] - if entity_id in expected_increases - else None - ) expected_stats[entity_id].append( { "statistic_id": entity_id, @@ -1900,8 +1800,6 @@ def test_compile_statistics_hourly_summary(hass_recorder, caplog): "last_reset": None, "state": expected_state, "sum": expected_sum, - "sum_decrease": expected_decrease, - "sum_increase": expected_increase, } ) start += timedelta(minutes=5) @@ -1949,16 +1847,6 @@ def test_compile_statistics_hourly_summary(hass_recorder, caplog): if entity_id in expected_sums else None ) - expected_decrease = ( - expected_decreases[entity_id][(i + 1) * 12 - 1] - if entity_id in expected_decreases - else None - ) - expected_increase = ( - expected_increases[entity_id][(i + 1) * 12 - 1] - if entity_id in expected_increases - else None - ) expected_stats[entity_id].append( { "statistic_id": entity_id, @@ -1970,8 +1858,6 @@ def test_compile_statistics_hourly_summary(hass_recorder, caplog): "last_reset": None, "state": expected_state, "sum": expected_sum, - "sum_decrease": expected_decrease, - "sum_increase": expected_increase, } ) start += timedelta(hours=1) From d5c3d234ecbf47315bbaf037bd718a1a1abcf67b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Wed, 29 Sep 2021 17:43:51 +0200 Subject: [PATCH 693/843] Open garage, add config flow (#55290) --- .coveragerc | 1 + .../components/opengarage/__init__.py | 39 +++- .../components/opengarage/config_flow.py | 107 ++++++++++ homeassistant/components/opengarage/const.py | 11 + homeassistant/components/opengarage/cover.py | 66 +++--- .../components/opengarage/manifest.json | 13 +- .../components/opengarage/strings.json | 22 ++ homeassistant/generated/config_flows.py | 1 + requirements_test_all.txt | 3 + tests/components/opengarage/__init__.py | 1 + .../components/opengarage/test_config_flow.py | 202 ++++++++++++++++++ 11 files changed, 428 insertions(+), 38 deletions(-) create mode 100644 homeassistant/components/opengarage/config_flow.py create mode 100644 homeassistant/components/opengarage/const.py create mode 100644 homeassistant/components/opengarage/strings.json create mode 100644 tests/components/opengarage/__init__.py create mode 100644 tests/components/opengarage/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 4fc01dd17db..899c16e3920 100644 --- a/.coveragerc +++ b/.coveragerc @@ -764,6 +764,7 @@ omit = homeassistant/components/opencv/* homeassistant/components/openevse/sensor.py homeassistant/components/openexchangerates/sensor.py + homeassistant/components/opengarage/__init__.py homeassistant/components/opengarage/cover.py homeassistant/components/openhome/__init__.py homeassistant/components/openhome/media_player.py diff --git a/homeassistant/components/opengarage/__init__.py b/homeassistant/components/opengarage/__init__.py index 2f4d2e09cfb..5ea3af79ae4 100644 --- a/homeassistant/components/opengarage/__init__.py +++ b/homeassistant/components/opengarage/__init__.py @@ -1 +1,38 @@ -"""The opengarage component.""" +"""The OpenGarage integration.""" +from __future__ import annotations + +import opengarage + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_DEVICE_KEY, DOMAIN + +PLATFORMS = ["cover"] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up OpenGarage from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + + hass.data[DOMAIN][entry.entry_id] = opengarage.OpenGarage( + f"{entry.data[CONF_HOST]}:{entry.data[CONF_PORT]}", + entry.data[CONF_DEVICE_KEY], + entry.data[CONF_VERIFY_SSL], + async_get_clientsession(hass), + ) + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/opengarage/config_flow.py b/homeassistant/components/opengarage/config_flow.py new file mode 100644 index 00000000000..9121391b4e0 --- /dev/null +++ b/homeassistant/components/opengarage/config_flow.py @@ -0,0 +1,107 @@ +"""Config flow for OpenGarage integration.""" +from __future__ import annotations + +import logging +from typing import Any + +import aiohttp +import opengarage +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SSL, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import format_mac + +from .const import CONF_DEVICE_KEY, DEFAULT_PORT, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_DEVICE_KEY): str, + vol.Required(CONF_HOST, default="http://"): str, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): int, + vol.Optional(CONF_VERIFY_SSL, default=False): bool, + } +) + + +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: + """Validate the user input allows us to connect. + + Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + """ + open_garage = opengarage.OpenGarage( + f"{data[CONF_HOST]}:{data[CONF_PORT]}", + data[CONF_DEVICE_KEY], + data[CONF_VERIFY_SSL], + async_get_clientsession(hass), + ) + + try: + status = await open_garage.update_state() + except aiohttp.ClientError as exp: + raise CannotConnect from exp + + if status is None: + raise InvalidAuth + + return {"title": status.get("name"), "unique_id": format_mac(status["mac"])} + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for OpenGarage.""" + + VERSION = 1 + + async def async_step_import(self, import_info): + """Set the config entry up from yaml.""" + import_info[CONF_HOST] = ( + f"{'https' if import_info[CONF_SSL] else 'http'}://" + f"{import_info.get(CONF_HOST)}" + ) + + del import_info[CONF_SSL] + return await self.async_step_user(import_info) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA + ) + + errors = {} + + try: + info = await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(info["unique_id"]) + self._abort_if_unique_id_configured() + + return self.async_create_entry(title=info["title"], data=user_input) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/opengarage/const.py b/homeassistant/components/opengarage/const.py new file mode 100644 index 00000000000..7cf9287e182 --- /dev/null +++ b/homeassistant/components/opengarage/const.py @@ -0,0 +1,11 @@ +"""Constants for the OpenGarage integration.""" + +ATTR_DISTANCE_SENSOR = "distance_sensor" +ATTR_DOOR_STATE = "door_state" +ATTR_SIGNAL_STRENGTH = "wifi_signal" + +CONF_DEVICE_KEY = "device_key" + +DEFAULT_NAME = "OpenGarage" +DEFAULT_PORT = 80 +DOMAIN = "opengarage" diff --git a/homeassistant/components/opengarage/cover.py b/homeassistant/components/opengarage/cover.py index 95743146a5f..5323ae7b0d3 100644 --- a/homeassistant/components/opengarage/cover.py +++ b/homeassistant/components/opengarage/cover.py @@ -1,9 +1,9 @@ """Platform for the opengarage.io cover component.""" import logging -import opengarage import voluptuous as vol +from homeassistant import config_entries from homeassistant.components.cover import ( DEVICE_CLASS_GARAGE, PLATFORM_SCHEMA, @@ -23,21 +23,19 @@ from homeassistant.const import ( STATE_OPEN, STATE_OPENING, ) -from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.device_registry import format_mac + +from .const import ( + ATTR_DISTANCE_SENSOR, + ATTR_DOOR_STATE, + ATTR_SIGNAL_STRENGTH, + CONF_DEVICE_KEY, + DEFAULT_PORT, + DOMAIN, +) _LOGGER = logging.getLogger(__name__) -ATTR_DISTANCE_SENSOR = "distance_sensor" -ATTR_DOOR_STATE = "door_state" -ATTR_SIGNAL_STRENGTH = "wifi_signal" - -CONF_DEVICE_KEY = "device_key" - -DEFAULT_NAME = "OpenGarage" -DEFAULT_PORT = 80 - STATES_MAP = {0: STATE_CLOSED, 1: STATE_OPEN} COVER_SCHEMA = vol.Schema( @@ -58,29 +56,22 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the OpenGarage covers.""" - covers = [] devices = config.get(CONF_COVERS) - for device_config in devices.values(): - opengarage_url = ( - f"{'https' if device_config[CONF_SSL] else 'http'}://" - f"{device_config.get(CONF_HOST)}:{device_config.get(CONF_PORT)}" - ) - - open_garage = opengarage.OpenGarage( - opengarage_url, - device_config[CONF_DEVICE_KEY], - device_config[CONF_VERIFY_SSL], - async_get_clientsession(hass), - ) - status = await open_garage.update_state() - covers.append( - OpenGarageCover( - device_config.get(CONF_NAME), open_garage, format_mac(status["mac"]) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=device_config, ) ) - async_add_entities(covers, True) + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up the OpenGarage covers.""" + async_add_entities( + [OpenGarageCover(hass.data[DOMAIN][entry.entry_id], entry.unique_id)], True + ) class OpenGarageCover(CoverEntity): @@ -89,14 +80,13 @@ class OpenGarageCover(CoverEntity): _attr_device_class = DEVICE_CLASS_GARAGE _attr_supported_features = SUPPORT_OPEN | SUPPORT_CLOSE - def __init__(self, name, open_garage, device_id): + def __init__(self, open_garage, device_id): """Initialize the cover.""" - self._attr_name = name self._open_garage = open_garage self._state = None self._state_before_move = None self._extra_state_attributes = {} - self._attr_unique_id = device_id + self._attr_unique_id = self._device_id = device_id @property def extra_state_attributes(self): @@ -183,3 +173,13 @@ class OpenGarageCover(CoverEntity): self._state = self._state_before_move self._state_before_move = None + + @property + def device_info(self): + """Return the device_info of the device.""" + device_info = { + "identifiers": {(DOMAIN, self._device_id)}, + "name": self.name, + "manufacturer": "Open Garage", + } + return device_info diff --git a/homeassistant/components/opengarage/manifest.json b/homeassistant/components/opengarage/manifest.json index b6c617408b5..bf32b060f11 100644 --- a/homeassistant/components/opengarage/manifest.json +++ b/homeassistant/components/opengarage/manifest.json @@ -2,7 +2,12 @@ "domain": "opengarage", "name": "OpenGarage", "documentation": "https://www.home-assistant.io/integrations/opengarage", - "codeowners": ["@danielhiversen"], - "requirements": ["open-garage==0.1.5"], - "iot_class": "local_polling" -} + "codeowners": [ + "@danielhiversen" + ], + "requirements": [ + "open-garage==0.1.5" + ], + "iot_class": "local_polling", + "config_flow": true +} \ No newline at end of file diff --git a/homeassistant/components/opengarage/strings.json b/homeassistant/components/opengarage/strings.json new file mode 100644 index 00000000000..20e90386b45 --- /dev/null +++ b/homeassistant/components/opengarage/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "user": { + "data": { + "device_key": "Device key", + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index da4079fe49f..0bd6edaf146 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -201,6 +201,7 @@ FLOWS = [ "ondilo_ico", "onewire", "onvif", + "opengarage", "opentherm_gw", "openuv", "openweathermap", diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3f35b80347a..8464ee19383 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -660,6 +660,9 @@ ondilo==0.2.0 # homeassistant.components.onvif onvif-zeep-async==1.2.0 +# homeassistant.components.opengarage +open-garage==0.1.5 + # homeassistant.components.openerz openerz-api==0.1.0 diff --git a/tests/components/opengarage/__init__.py b/tests/components/opengarage/__init__.py new file mode 100644 index 00000000000..04c2572fde2 --- /dev/null +++ b/tests/components/opengarage/__init__.py @@ -0,0 +1 @@ +"""Tests for the OpenGarage integration.""" diff --git a/tests/components/opengarage/test_config_flow.py b/tests/components/opengarage/test_config_flow.py new file mode 100644 index 00000000000..ca89d07cedc --- /dev/null +++ b/tests/components/opengarage/test_config_flow.py @@ -0,0 +1,202 @@ +"""Test the OpenGarage config flow.""" +from unittest.mock import patch + +import aiohttp + +from homeassistant import config_entries, setup +from homeassistant.components.opengarage.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + +from tests.common import MockConfigEntry + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + with patch( + "opengarage.OpenGarage.update_state", + return_value={"name": "Name of the device", "mac": "unique"}, + ), patch( + "homeassistant.components.opengarage.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"host": "http://1.1.1.1", "device_key": "AfsasdnfkjDD"}, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "Name of the device" + assert result2["data"] == { + "host": "http://1.1.1.1", + "device_key": "AfsasdnfkjDD", + "port": 80, + "verify_ssl": False, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_auth(hass: HomeAssistant) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "opengarage.OpenGarage.update_state", + return_value=None, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"host": "http://1.1.1.1", "device_key": "AfsasdnfkjDD"}, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_cannot_connect(hass: HomeAssistant) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "opengarage.OpenGarage.update_state", + side_effect=aiohttp.ClientError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"host": "http://1.1.1.1", "device_key": "AfsasdnfkjDD"}, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_unknown_error(hass: HomeAssistant) -> None: + """Test we handle unknown error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "opengarage.OpenGarage.update_state", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"host": "http://1.1.1.1", "device_key": "AfsasdnfkjDD"}, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "unknown"} + + +async def test_flow_entry_already_exists(hass: HomeAssistant) -> None: + """Test user input for config_entry that already exists.""" + first_entry = MockConfigEntry( + domain="opengarage", + data={ + "host": "http://1.1.1.1", + "device_key": "AfsasdnfkjDD", + }, + unique_id="unique", + ) + first_entry.add_to_hass(hass) + + with patch( + "opengarage.OpenGarage.update_state", + return_value={"name": "Name of the device", "mac": "unique"}, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={ + "host": "http://1.1.1.1", + "device_key": "AfsasdnfkjDD", + "port": 80, + "verify_ssl": False, + }, + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_step_import(hass: HomeAssistant) -> None: + """Test when import configuring from yaml.""" + with patch( + "opengarage.OpenGarage.update_state", + return_value={"name": "Name of the device", "mac": "unique"}, + ), patch( + "homeassistant.components.opengarage.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + "host": "1.1.1.1", + "device_key": "AfsasdnfkjDD", + "port": 1234, + "verify_ssl": False, + "ssl": False, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "Name of the device" + assert result["data"] == { + "host": "http://1.1.1.1", + "device_key": "AfsasdnfkjDD", + "port": 1234, + "verify_ssl": False, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_step_import_ssl(hass: HomeAssistant) -> None: + """Test when import configuring from yaml.""" + with patch( + "opengarage.OpenGarage.update_state", + return_value={"name": "Name of the device", "mac": "unique"}, + ), patch( + "homeassistant.components.opengarage.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + "host": "1.1.1.1", + "device_key": "AfsasdnfkjDD", + "port": 1234, + "verify_ssl": False, + "ssl": True, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "Name of the device" + assert result["data"] == { + "host": "https://1.1.1.1", + "device_key": "AfsasdnfkjDD", + "port": 1234, + "verify_ssl": False, + } + assert len(mock_setup_entry.mock_calls) == 1 From 50fffe48f803994cbbc8fb2edf4b9b2dd8265f28 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Wed, 29 Sep 2021 17:55:27 +0200 Subject: [PATCH 694/843] Add zwave to zwave_js migration (#56159) Co-authored-by: Paulus Schoutsen --- homeassistant/components/ozw/config_flow.py | 9 - homeassistant/components/ozw/migration.py | 171 ------- homeassistant/components/ozw/websocket_api.py | 59 --- homeassistant/components/zwave/__init__.py | 91 +--- homeassistant/components/zwave/manifest.json | 1 - homeassistant/components/zwave/migration.py | 167 +++++++ homeassistant/components/zwave/util.py | 5 + .../components/zwave/websocket_api.py | 15 +- homeassistant/components/zwave_js/api.py | 117 +++++ .../components/zwave_js/config_flow.py | 10 + homeassistant/components/zwave_js/entity.py | 6 + homeassistant/components/zwave_js/migrate.py | 334 +++++++++++++- script/hassfest/dependencies.py | 4 +- tests/components/ozw/test_config_flow.py | 46 -- tests/components/ozw/test_migration.py | 285 ------------ tests/components/zwave/test_init.py | 35 +- tests/components/zwave/test_websocket_api.py | 8 +- tests/components/zwave_js/test_api.py | 47 ++ tests/components/zwave_js/test_config_flow.py | 68 +++ tests/components/zwave_js/test_migrate.py | 428 ++++++++++++++++++ 20 files changed, 1219 insertions(+), 687 deletions(-) delete mode 100644 homeassistant/components/ozw/migration.py create mode 100644 homeassistant/components/zwave/migration.py delete mode 100644 tests/components/ozw/test_migration.py diff --git a/homeassistant/components/ozw/config_flow.py b/homeassistant/components/ozw/config_flow.py index cc07d738488..c1dbbe2e093 100644 --- a/homeassistant/components/ozw/config_flow.py +++ b/homeassistant/components/ozw/config_flow.py @@ -35,15 +35,6 @@ class DomainConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.integration_created_addon = False self.install_task = None - async def async_step_import(self, data): - """Handle imported data. - - This step will be used when importing data during zwave to ozw migration. - """ - self.network_key = data.get(CONF_NETWORK_KEY) - self.usb_path = data.get(CONF_USB_PATH) - return await self.async_step_user() - async def async_step_user(self, user_input=None): """Handle the initial step.""" if self._async_current_entries(): diff --git a/homeassistant/components/ozw/migration.py b/homeassistant/components/ozw/migration.py deleted file mode 100644 index 86df69bc955..00000000000 --- a/homeassistant/components/ozw/migration.py +++ /dev/null @@ -1,171 +0,0 @@ -"""Provide tools for migrating from the zwave integration.""" -from homeassistant.helpers.device_registry import ( - async_get_registry as async_get_device_registry, -) -from homeassistant.helpers.entity_registry import ( - async_entries_for_config_entry, - async_get_registry as async_get_entity_registry, -) - -from .const import DOMAIN, MIGRATED, NODES_VALUES -from .entity import create_device_id, create_value_id - -# The following dicts map labels between OpenZWave 1.4 and 1.6. -METER_CC_LABELS = { - "Energy": "Electric - kWh", - "Power": "Electric - W", - "Count": "Electric - Pulses", - "Voltage": "Electric - V", - "Current": "Electric - A", - "Power Factor": "Electric - PF", -} - -NOTIFICATION_CC_LABELS = { - "General": "Start", - "Smoke": "Smoke Alarm", - "Carbon Monoxide": "Carbon Monoxide", - "Carbon Dioxide": "Carbon Dioxide", - "Heat": "Heat", - "Flood": "Water", - "Access Control": "Access Control", - "Burglar": "Home Security", - "Power Management": "Power Management", - "System": "System", - "Emergency": "Emergency", - "Clock": "Clock", - "Appliance": "Appliance", - "HomeHealth": "Home Health", -} - -CC_ID_LABELS = { - 50: METER_CC_LABELS, - 113: NOTIFICATION_CC_LABELS, -} - - -async def async_get_migration_data(hass): - """Return dict with ozw side migration info.""" - data = {} - nodes_values = hass.data[DOMAIN][NODES_VALUES] - ozw_config_entries = hass.config_entries.async_entries(DOMAIN) - config_entry = ozw_config_entries[0] # ozw only has a single config entry - ent_reg = await async_get_entity_registry(hass) - entity_entries = async_entries_for_config_entry(ent_reg, config_entry.entry_id) - unique_entries = {entry.unique_id: entry for entry in entity_entries} - dev_reg = await async_get_device_registry(hass) - - for node_id, node_values in nodes_values.items(): - for entity_values in node_values: - unique_id = create_value_id(entity_values.primary) - if unique_id not in unique_entries: - continue - node = entity_values.primary.node - device_identifier = ( - DOMAIN, - create_device_id(node, entity_values.primary.instance), - ) - device_entry = dev_reg.async_get_device({device_identifier}, set()) - data[unique_id] = { - "node_id": node_id, - "node_instance": entity_values.primary.instance, - "device_id": device_entry.id, - "command_class": entity_values.primary.command_class.value, - "command_class_label": entity_values.primary.label, - "value_index": entity_values.primary.index, - "unique_id": unique_id, - "entity_entry": unique_entries[unique_id], - } - - return data - - -def map_node_values(zwave_data, ozw_data): - """Map zwave node values onto ozw node values.""" - migration_map = {"device_entries": {}, "entity_entries": {}} - - for zwave_entry in zwave_data.values(): - node_id = zwave_entry["node_id"] - node_instance = zwave_entry["node_instance"] - cc_id = zwave_entry["command_class"] - zwave_cc_label = zwave_entry["command_class_label"] - - if cc_id in CC_ID_LABELS: - labels = CC_ID_LABELS[cc_id] - ozw_cc_label = labels.get(zwave_cc_label, zwave_cc_label) - - ozw_entry = next( - ( - entry - for entry in ozw_data.values() - if entry["node_id"] == node_id - and entry["node_instance"] == node_instance - and entry["command_class"] == cc_id - and entry["command_class_label"] == ozw_cc_label - ), - None, - ) - else: - value_index = zwave_entry["value_index"] - - ozw_entry = next( - ( - entry - for entry in ozw_data.values() - if entry["node_id"] == node_id - and entry["node_instance"] == node_instance - and entry["command_class"] == cc_id - and entry["value_index"] == value_index - ), - None, - ) - - if ozw_entry is None: - continue - - # Save the zwave_entry under the ozw entity_id to create the map. - # Check that the mapped entities have the same domain. - if zwave_entry["entity_entry"].domain == ozw_entry["entity_entry"].domain: - migration_map["entity_entries"][ - ozw_entry["entity_entry"].entity_id - ] = zwave_entry - migration_map["device_entries"][ozw_entry["device_id"]] = zwave_entry[ - "device_id" - ] - - return migration_map - - -async def async_migrate(hass, migration_map): - """Perform zwave to ozw migration.""" - dev_reg = await async_get_device_registry(hass) - for ozw_device_id, zwave_device_id in migration_map["device_entries"].items(): - zwave_device_entry = dev_reg.async_get(zwave_device_id) - dev_reg.async_update_device( - ozw_device_id, - area_id=zwave_device_entry.area_id, - name_by_user=zwave_device_entry.name_by_user, - ) - - ent_reg = await async_get_entity_registry(hass) - for zwave_entry in migration_map["entity_entries"].values(): - zwave_entity_id = zwave_entry["entity_entry"].entity_id - ent_reg.async_remove(zwave_entity_id) - - for ozw_entity_id, zwave_entry in migration_map["entity_entries"].items(): - entity_entry = zwave_entry["entity_entry"] - ent_reg.async_update_entity( - ozw_entity_id, - new_entity_id=entity_entry.entity_id, - name=entity_entry.name, - icon=entity_entry.icon, - ) - - zwave_config_entry = hass.config_entries.async_entries("zwave")[0] - await hass.config_entries.async_remove(zwave_config_entry.entry_id) - - ozw_config_entry = hass.config_entries.async_entries("ozw")[0] - updates = { - **ozw_config_entry.data, - MIGRATED: True, - } - hass.config_entries.async_update_entry(ozw_config_entry, data=updates) diff --git a/homeassistant/components/ozw/websocket_api.py b/homeassistant/components/ozw/websocket_api.py index 4b96c577bf2..bb55a686db8 100644 --- a/homeassistant/components/ozw/websocket_api.py +++ b/homeassistant/components/ozw/websocket_api.py @@ -25,7 +25,6 @@ from homeassistant.helpers import config_validation as cv from .const import ATTR_CONFIG_PARAMETER, ATTR_CONFIG_VALUE, DOMAIN, MANAGER from .lock import ATTR_USERCODE -from .migration import async_get_migration_data, async_migrate, map_node_values _LOGGER = logging.getLogger(__name__) @@ -58,7 +57,6 @@ ATTR_NEIGHBORS = "neighbors" @callback def async_register_api(hass): """Register all of our api endpoints.""" - websocket_api.async_register_command(hass, websocket_migrate_zwave) websocket_api.async_register_command(hass, websocket_get_instances) websocket_api.async_register_command(hass, websocket_get_nodes) websocket_api.async_register_command(hass, websocket_network_status) @@ -168,63 +166,6 @@ def _get_config_params(node, *args): return config_params -@websocket_api.require_admin -@websocket_api.async_response -@websocket_api.websocket_command( - { - vol.Required(TYPE): "ozw/migrate_zwave", - vol.Optional(DRY_RUN, default=True): bool, - } -) -async def websocket_migrate_zwave(hass, connection, msg): - """Migrate the zwave integration device and entity data to ozw integration.""" - if "zwave" not in hass.config.components: - _LOGGER.error("Can not migrate, zwave integration is not loaded") - connection.send_message( - websocket_api.error_message( - msg["id"], "zwave_not_loaded", "Integration zwave is not loaded" - ) - ) - return - - zwave = hass.components.zwave - zwave_data = await zwave.async_get_ozw_migration_data(hass) - _LOGGER.debug("Migration zwave data: %s", zwave_data) - - ozw_data = await async_get_migration_data(hass) - _LOGGER.debug("Migration ozw data: %s", ozw_data) - - can_migrate = map_node_values(zwave_data, ozw_data) - - zwave_entity_ids = [ - entry["entity_entry"].entity_id for entry in zwave_data.values() - ] - ozw_entity_ids = [entry["entity_entry"].entity_id for entry in ozw_data.values()] - migration_device_map = { - zwave_device_id: ozw_device_id - for ozw_device_id, zwave_device_id in can_migrate["device_entries"].items() - } - migration_entity_map = { - zwave_entry["entity_entry"].entity_id: ozw_entity_id - for ozw_entity_id, zwave_entry in can_migrate["entity_entries"].items() - } - _LOGGER.debug("Migration entity map: %s", migration_entity_map) - - if not msg[DRY_RUN]: - await async_migrate(hass, can_migrate) - - connection.send_result( - msg[ID], - { - "migration_device_map": migration_device_map, - "zwave_entity_ids": zwave_entity_ids, - "ozw_entity_ids": ozw_entity_ids, - "migration_entity_map": migration_entity_map, - "migrated": not msg[DRY_RUN], - }, - ) - - @websocket_api.websocket_command({vol.Required(TYPE): "ozw/get_instances"}) def websocket_get_instances(hass, connection, msg): """Get a list of OZW instances.""" diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index 8a5705ae7bb..e14352a92a3 100644 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -29,7 +29,6 @@ from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.entity_component import DEFAULT_SCAN_INTERVAL from homeassistant.helpers.entity_platform import EntityPlatform from homeassistant.helpers.entity_registry import ( - async_entries_for_config_entry, async_get_registry as async_get_entity_registry, ) from homeassistant.helpers.entity_values import EntityValues @@ -56,11 +55,18 @@ from .const import ( DOMAIN, ) from .discovery_schemas import DISCOVERY_SCHEMAS +from .migration import ( # noqa: F401 pylint: disable=unused-import + async_add_migration_entity_value, + async_get_migration_data, + async_is_ozw_migrated, + async_is_zwave_js_migrated, +) from .node_entity import ZWaveBaseEntity, ZWaveNodeEntity from .util import ( check_has_unique_id, check_node_schema, check_value_schema, + compute_value_unique_id, is_node_parsed, node_device_id_and_name, node_name, @@ -253,64 +259,6 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_get_ozw_migration_data(hass): - """Return dict with info for migration to ozw integration.""" - data_to_migrate = {} - - zwave_config_entries = hass.config_entries.async_entries(DOMAIN) - if not zwave_config_entries: - _LOGGER.error("Config entry not set up") - return data_to_migrate - - if hass.data.get(DATA_ZWAVE_CONFIG_YAML_PRESENT): - _LOGGER.warning( - "Remove %s from configuration.yaml " - "to avoid setting up this integration on restart " - "after completing migration to ozw", - DOMAIN, - ) - - config_entry = zwave_config_entries[0] # zwave only has a single config entry - ent_reg = await async_get_entity_registry(hass) - entity_entries = async_entries_for_config_entry(ent_reg, config_entry.entry_id) - unique_entries = {entry.unique_id: entry for entry in entity_entries} - dev_reg = await async_get_device_registry(hass) - - for entity_values in hass.data[DATA_ENTITY_VALUES]: - node = entity_values.primary.node - unique_id = compute_value_unique_id(node, entity_values.primary) - if unique_id not in unique_entries: - continue - device_identifier, _ = node_device_id_and_name( - node, entity_values.primary.instance - ) - device_entry = dev_reg.async_get_device({device_identifier}, set()) - data_to_migrate[unique_id] = { - "node_id": node.node_id, - "node_instance": entity_values.primary.instance, - "device_id": device_entry.id, - "command_class": entity_values.primary.command_class, - "command_class_label": entity_values.primary.label, - "value_index": entity_values.primary.index, - "unique_id": unique_id, - "entity_entry": unique_entries[unique_id], - } - - return data_to_migrate - - -@callback -def async_is_ozw_migrated(hass): - """Return True if migration to ozw is done.""" - ozw_config_entries = hass.config_entries.async_entries("ozw") - if not ozw_config_entries: - return False - - ozw_config_entry = ozw_config_entries[0] # only one ozw entry is allowed - migrated = bool(ozw_config_entry.data.get("migrated")) - return migrated - - def _obj_to_dict(obj): """Convert an object into a hash for debug.""" return { @@ -404,9 +352,22 @@ async def async_setup_entry(hass, config_entry): # noqa: C901 # pylint: enable=import-error from pydispatch import dispatcher - if async_is_ozw_migrated(hass): + if async_is_ozw_migrated(hass) or async_is_zwave_js_migrated(hass): + + if hass.data.get(DATA_ZWAVE_CONFIG_YAML_PRESENT): + config_yaml_message = ( + ", and remove %s from configuration.yaml " + "to avoid setting up this integration on restart ", + DOMAIN, + ) + else: + config_yaml_message = "" + _LOGGER.error( - "Migration to ozw has been done. Please remove the zwave integration" + "Migration away from legacy Z-Wave has been done. " + "Please remove the %s integration%s", + DOMAIN, + config_yaml_message, ) return False @@ -1307,6 +1268,9 @@ class ZWaveDeviceEntity(ZWaveBaseEntity): self.refresh_from_network, ) + # Add legacy Z-Wave migration data. + await async_add_migration_entity_value(self.hass, self.entity_id, self.values) + def _update_attributes(self): """Update the node attributes. May only be used inside callback.""" self.node_id = self.node.node_id @@ -1386,8 +1350,3 @@ class ZWaveDeviceEntity(ZWaveBaseEntity): ) or self.node.is_ready: return compute_value_unique_id(self.node, self.values.primary) return None - - -def compute_value_unique_id(node, value): - """Compute unique_id a value would get if it were to get one.""" - return f"{node.node_id}-{value.object_id}" diff --git a/homeassistant/components/zwave/manifest.json b/homeassistant/components/zwave/manifest.json index f65dbb557db..bf3a9abe77e 100644 --- a/homeassistant/components/zwave/manifest.json +++ b/homeassistant/components/zwave/manifest.json @@ -4,7 +4,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zwave", "requirements": ["homeassistant-pyozw==0.1.10", "pydispatcher==2.0.5"], - "after_dependencies": ["ozw"], "codeowners": ["@home-assistant/z-wave"], "iot_class": "local_push" } diff --git a/homeassistant/components/zwave/migration.py b/homeassistant/components/zwave/migration.py new file mode 100644 index 00000000000..0b151d18e4b --- /dev/null +++ b/homeassistant/components/zwave/migration.py @@ -0,0 +1,167 @@ +"""Handle migration from legacy Z-Wave to OpenZWave and Z-Wave JS.""" +from __future__ import annotations + +from typing import TYPE_CHECKING, TypedDict, cast + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import async_get as async_get_device_registry +from homeassistant.helpers.entity_registry import async_get as async_get_entity_registry +from homeassistant.helpers.singleton import singleton +from homeassistant.helpers.storage import Store + +from .const import DOMAIN +from .util import node_device_id_and_name + +if TYPE_CHECKING: + from . import ZWaveDeviceEntityValues + +LEGACY_ZWAVE_MIGRATION = f"{DOMAIN}_legacy_zwave_migration" +STORAGE_WRITE_DELAY = 30 +STORAGE_KEY = f"{DOMAIN}.legacy_zwave_migration" +STORAGE_VERSION = 1 + + +class ZWaveMigrationData(TypedDict): + """Represent the Z-Wave migration data dict.""" + + node_id: int + node_instance: int + command_class: int + command_class_label: str + value_index: int + device_id: str + domain: str + entity_id: str + unique_id: str + unit_of_measurement: str | None + + +@callback +def async_is_ozw_migrated(hass): + """Return True if migration to ozw is done.""" + ozw_config_entries = hass.config_entries.async_entries("ozw") + if not ozw_config_entries: + return False + + ozw_config_entry = ozw_config_entries[0] # only one ozw entry is allowed + migrated = bool(ozw_config_entry.data.get("migrated")) + return migrated + + +@callback +def async_is_zwave_js_migrated(hass): + """Return True if migration to Z-Wave JS is done.""" + zwave_js_config_entries = hass.config_entries.async_entries("zwave_js") + if not zwave_js_config_entries: + return False + + migrated = any( + config_entry.data.get("migrated") for config_entry in zwave_js_config_entries + ) + return migrated + + +async def async_add_migration_entity_value( + hass: HomeAssistant, + entity_id: str, + entity_values: ZWaveDeviceEntityValues, +) -> None: + """Add Z-Wave entity value for legacy Z-Wave migration.""" + migration_handler: LegacyZWaveMigration = await get_legacy_zwave_migration(hass) + migration_handler.add_entity_value(entity_id, entity_values) + + +async def async_get_migration_data( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dict[str, ZWaveMigrationData]: + """Return Z-Wave migration data.""" + migration_handler: LegacyZWaveMigration = await get_legacy_zwave_migration(hass) + return await migration_handler.get_data(config_entry) + + +@singleton(LEGACY_ZWAVE_MIGRATION) +async def get_legacy_zwave_migration(hass: HomeAssistant) -> LegacyZWaveMigration: + """Return legacy Z-Wave migration handler.""" + migration_handler = LegacyZWaveMigration(hass) + await migration_handler.load_data() + return migration_handler + + +class LegacyZWaveMigration: + """Handle the migration from zwave to ozw and zwave_js.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Set up migration instance.""" + self._hass = hass + self._store = Store(hass, STORAGE_VERSION, STORAGE_KEY) + self._data: dict[str, dict[str, ZWaveMigrationData]] = {} + + async def load_data(self) -> None: + """Load Z-Wave migration data.""" + stored = cast(dict, await self._store.async_load()) + if stored: + self._data = stored + + @callback + def save_data( + self, config_entry_id: str, entity_id: str, data: ZWaveMigrationData + ) -> None: + """Save Z-Wave migration data.""" + if config_entry_id not in self._data: + self._data[config_entry_id] = {} + self._data[config_entry_id][entity_id] = data + self._store.async_delay_save(self._data_to_save, STORAGE_WRITE_DELAY) + + @callback + def _data_to_save(self) -> dict[str, dict[str, ZWaveMigrationData]]: + """Return data to save.""" + return self._data + + @callback + def add_entity_value( + self, + entity_id: str, + entity_values: ZWaveDeviceEntityValues, + ) -> None: + """Add info for one entity and Z-Wave value.""" + ent_reg = async_get_entity_registry(self._hass) + dev_reg = async_get_device_registry(self._hass) + + node = entity_values.primary.node + entity_entry = ent_reg.async_get(entity_id) + assert entity_entry + device_identifier, _ = node_device_id_and_name( + node, entity_values.primary.instance + ) + device_entry = dev_reg.async_get_device({device_identifier}, set()) + assert device_entry + + # Normalize unit of measurement. + if unit := entity_entry.unit_of_measurement: + unit = unit.lower() + if unit == "": + unit = None + + data: ZWaveMigrationData = { + "node_id": node.node_id, + "node_instance": entity_values.primary.instance, + "command_class": entity_values.primary.command_class, + "command_class_label": entity_values.primary.label, + "value_index": entity_values.primary.index, + "device_id": device_entry.id, + "domain": entity_entry.domain, + "entity_id": entity_id, + "unique_id": entity_entry.unique_id, + "unit_of_measurement": unit, + } + + self.save_data(entity_entry.config_entry_id, entity_id, data) + + async def get_data( + self, config_entry: ConfigEntry + ) -> dict[str, ZWaveMigrationData]: + """Return Z-Wave migration data.""" + await self.load_data() + data = self._data.get(config_entry.entry_id) + return data or {} diff --git a/homeassistant/components/zwave/util.py b/homeassistant/components/zwave/util.py index da8fa37f44f..19be3f7a659 100644 --- a/homeassistant/components/zwave/util.py +++ b/homeassistant/components/zwave/util.py @@ -88,6 +88,11 @@ def check_value_schema(value, schema): return True +def compute_value_unique_id(node, value): + """Compute unique_id a value would get if it were to get one.""" + return f"{node.node_id}-{value.object_id}" + + def node_name(node): """Return the name of the node.""" if is_node_parsed(node): diff --git a/homeassistant/components/zwave/websocket_api.py b/homeassistant/components/zwave/websocket_api.py index bf84a27166e..b86e46bee98 100644 --- a/homeassistant/components/zwave/websocket_api.py +++ b/homeassistant/components/zwave/websocket_api.py @@ -2,7 +2,6 @@ import voluptuous as vol from homeassistant.components import websocket_api -from homeassistant.components.ozw.const import DOMAIN as OZW_DOMAIN from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.core import callback @@ -59,12 +58,14 @@ def websocket_get_migration_config(hass, connection, msg): @websocket_api.require_admin +@websocket_api.websocket_command( + {vol.Required(TYPE): "zwave/start_zwave_js_config_flow"} +) @websocket_api.async_response -@websocket_api.websocket_command({vol.Required(TYPE): "zwave/start_ozw_config_flow"}) -async def websocket_start_ozw_config_flow(hass, connection, msg): - """Start the ozw integration config flow (for migration wizard). +async def websocket_start_zwave_js_config_flow(hass, connection, msg): + """Start the Z-Wave JS integration config flow (for migration wizard). - Return data with the flow id of the started ozw config flow. + Return data with the flow id of the started Z-Wave JS config flow. """ config = hass.data[DATA_ZWAVE_CONFIG] data = { @@ -72,7 +73,7 @@ async def websocket_start_ozw_config_flow(hass, connection, msg): "network_key": config[CONF_NETWORK_KEY], } result = await hass.config_entries.flow.async_init( - OZW_DOMAIN, context={"source": SOURCE_IMPORT}, data=data + "zwave_js", context={"source": SOURCE_IMPORT}, data=data ) connection.send_result( msg[ID], @@ -86,4 +87,4 @@ def async_load_websocket_api(hass): websocket_api.async_register_command(hass, websocket_network_status) websocket_api.async_register_command(hass, websocket_get_config) websocket_api.async_register_command(hass, websocket_get_migration_config) - websocket_api.async_register_command(hass, websocket_start_ozw_config_flow) + websocket_api.async_register_command(hass, websocket_start_zwave_js_config_flow) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 03bccd814db..8057b900baa 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -58,8 +58,15 @@ from .const import ( DATA_CLIENT, DOMAIN, EVENT_DEVICE_ADDED_TO_REGISTRY, + LOGGER, ) from .helpers import async_enable_statistics, update_data_collection_preference +from .migrate import ( + ZWaveMigrationData, + async_get_migration_data, + async_map_legacy_zwave_values, + async_migrate_legacy_zwave, +) DATA_UNSUBSCRIBE = "unsubs" @@ -96,6 +103,9 @@ OPTED_IN = "opted_in" SECURITY_CLASSES = "security_classes" CLIENT_SIDE_AUTH = "client_side_auth" +# constants for migration +DRY_RUN = "dry_run" + def async_get_entry(orig_func: Callable) -> Callable: """Decorate async function to get entry.""" @@ -218,6 +228,8 @@ def async_register_api(hass: HomeAssistant) -> None: hass, websocket_subscribe_controller_statistics ) websocket_api.async_register_command(hass, websocket_subscribe_node_statistics) + websocket_api.async_register_command(hass, websocket_node_ready) + websocket_api.async_register_command(hass, websocket_migrate_zwave) hass.http.register_view(DumpView()) hass.http.register_view(FirmwareUploadView()) @@ -272,6 +284,42 @@ async def websocket_network_status( ) +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zwave_js/node_ready", + vol.Required(ENTRY_ID): str, + vol.Required(NODE_ID): int, + } +) +@websocket_api.async_response +@async_get_node +async def websocket_node_ready( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict, + node: Node, +) -> None: + """Subscribe to the node ready event of a Z-Wave JS node.""" + + @callback + def forward_event(event: dict) -> None: + """Forward the event.""" + connection.send_message( + websocket_api.event_message(msg[ID], {"event": event["event"]}) + ) + + @callback + def async_cleanup() -> None: + """Remove signal listeners.""" + for unsub in unsubs: + unsub() + + connection.subscriptions[msg["id"]] = async_cleanup + msg[DATA_UNSUBSCRIBE] = unsubs = [node.on("ready", forward_event)] + + connection.send_result(msg[ID]) + + @websocket_api.websocket_command( { vol.Required(TYPE): "zwave_js/node_status", @@ -1743,3 +1791,72 @@ async def websocket_subscribe_node_statistics( connection.subscriptions[msg["id"]] = async_cleanup connection.send_result(msg[ID], _get_node_statistics_dict(node.statistics)) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zwave_js/migrate_zwave", + vol.Required(ENTRY_ID): str, + vol.Optional(DRY_RUN, default=True): bool, + } +) +@websocket_api.async_response +@async_get_entry +async def websocket_migrate_zwave( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict, + entry: ConfigEntry, + client: Client, +) -> None: + """Migrate Z-Wave device and entity data to Z-Wave JS integration.""" + if "zwave" not in hass.config.components: + connection.send_message( + websocket_api.error_message( + msg["id"], "zwave_not_loaded", "Integration zwave is not loaded" + ) + ) + return + + zwave = hass.components.zwave + zwave_config_entries = hass.config_entries.async_entries("zwave") + zwave_config_entry = zwave_config_entries[0] # zwave only has a single config entry + zwave_data: dict[str, ZWaveMigrationData] = await zwave.async_get_migration_data( + hass, zwave_config_entry + ) + LOGGER.debug("Migration zwave data: %s", zwave_data) + + zwave_js_config_entry = entry + zwave_js_data = await async_get_migration_data(hass, zwave_js_config_entry) + LOGGER.debug("Migration zwave_js data: %s", zwave_js_data) + + migration_map = async_map_legacy_zwave_values(zwave_data, zwave_js_data) + + zwave_entity_ids = [entry["entity_id"] for entry in zwave_data.values()] + zwave_js_entity_ids = [entry["entity_id"] for entry in zwave_js_data.values()] + migration_device_map = { + zwave_device_id: zwave_js_device_id + for zwave_js_device_id, zwave_device_id in migration_map.device_entries.items() + } + migration_entity_map = { + zwave_entry["entity_id"]: zwave_js_entity_id + for zwave_js_entity_id, zwave_entry in migration_map.entity_entries.items() + } + LOGGER.debug("Migration entity map: %s", migration_entity_map) + + if not msg[DRY_RUN]: + await async_migrate_legacy_zwave( + hass, zwave_config_entry, zwave_js_config_entry, migration_map + ) + + connection.send_result( + msg[ID], + { + "migration_device_map": migration_device_map, + "zwave_entity_ids": zwave_entity_ids, + "zwave_js_entity_ids": zwave_js_entity_ids, + "migration_entity_map": migration_entity_map, + "migrated": not msg[DRY_RUN], + }, + ) diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index a4f7343f0e0..c95078caf04 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -302,6 +302,16 @@ class ConfigFlow(BaseZwaveJSFlow, config_entries.ConfigFlow, domain=DOMAIN): """Return the options flow.""" return OptionsFlowHandler(config_entry) + async def async_step_import(self, data: dict[str, Any]) -> FlowResult: + """Handle imported data. + + This step will be used when importing data + during Z-Wave to Z-Wave JS migration. + """ + self.network_key = data.get(CONF_NETWORK_KEY) + self.usb_path = data.get(CONF_USB_PATH) + return await self.async_step_user() + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: diff --git a/homeassistant/components/zwave_js/entity.py b/homeassistant/components/zwave_js/entity.py index 793eaa435d5..f9bba52c95b 100644 --- a/homeassistant/components/zwave_js/entity.py +++ b/homeassistant/components/zwave_js/entity.py @@ -15,6 +15,7 @@ from homeassistant.helpers.entity import Entity from .const import DOMAIN from .discovery import ZwaveDiscoveryInfo from .helpers import get_device_id, get_unique_id +from .migrate import async_add_migration_entity_value LOGGER = logging.getLogger(__name__) @@ -109,6 +110,11 @@ class ZWaveBaseEntity(Entity): ) ) + # Add legacy Z-Wave migration data. + await async_add_migration_entity_value( + self.hass, self.config_entry, self.entity_id, self.info + ) + def generate_name( self, include_value_name: bool = False, diff --git a/homeassistant/components/zwave_js/migrate.py b/homeassistant/components/zwave_js/migrate.py index 397f7efba24..6598f26d45c 100644 --- a/homeassistant/components/zwave_js/migrate.py +++ b/homeassistant/components/zwave_js/migrate.py @@ -1,27 +1,355 @@ """Functions used to migrate unique IDs for Z-Wave JS entities.""" from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field import logging +from typing import TypedDict, cast from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.model.value import Value as ZwaveValue +from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.device_registry import ( + DeviceEntry, + async_get as async_get_device_registry, +) from homeassistant.helpers.entity_registry import ( EntityRegistry, RegistryEntry, async_entries_for_device, + async_get as async_get_entity_registry, ) +from homeassistant.helpers.singleton import singleton +from homeassistant.helpers.storage import Store from .const import DOMAIN from .discovery import ZwaveDiscoveryInfo -from .helpers import get_unique_id +from .helpers import get_device_id, get_unique_id _LOGGER = logging.getLogger(__name__) +LEGACY_ZWAVE_MIGRATION = f"{DOMAIN}_legacy_zwave_migration" +MIGRATED = "migrated" +STORAGE_WRITE_DELAY = 30 +STORAGE_KEY = f"{DOMAIN}.legacy_zwave_migration" +STORAGE_VERSION = 1 + +NOTIFICATION_CC_LABEL_TO_PROPERTY_NAME = { + "Smoke": "Smoke Alarm", + "Carbon Monoxide": "CO Alarm", + "Carbon Dioxide": "CO2 Alarm", + "Heat": "Heat Alarm", + "Flood": "Water Alarm", + "Access Control": "Access Control", + "Burglar": "Home Security", + "Power Management": "Power Management", + "System": "System", + "Emergency": "Siren", + "Clock": "Clock", + "Appliance": "Appliance", + "HomeHealth": "Home Health", +} + +SENSOR_MULTILEVEL_CC_LABEL_TO_PROPERTY_NAME = { + "Temperature": "Air temperature", + "General": "General purpose", + "Luminance": "Illuminance", + "Power": "Power", + "Relative Humidity": "Humidity", + "Velocity": "Velocity", + "Direction": "Direction", + "Atmospheric Pressure": "Atmospheric pressure", + "Barometric Pressure": "Barometric pressure", + "Solar Radiation": "Solar radiation", + "Dew Point": "Dew point", + "Rain Rate": "Rain rate", + "Tide Level": "Tide level", + "Weight": "Weight", + "Voltage": "Voltage", + "Current": "Current", + "CO2 Level": "Carbon dioxide (CO₂) level", + "Air Flow": "Air flow", + "Tank Capacity": "Tank capacity", + "Distance": "Distance", + "Angle Position": "Angle position", + "Rotation": "Rotation", + "Water Temperature": "Water temperature", + "Soil Temperature": "Soil temperature", + "Seismic Intensity": "Seismic Intensity", + "Seismic Magnitude": "Seismic magnitude", + "Ultraviolet": "Ultraviolet", + "Electrical Resistivity": "Electrical resistivity", + "Electrical Conductivity": "Electrical conductivity", + "Loudness": "Loudness", + "Moisture": "Moisture", +} + +CC_ID_LABEL_TO_PROPERTY = { + 49: SENSOR_MULTILEVEL_CC_LABEL_TO_PROPERTY_NAME, + 113: NOTIFICATION_CC_LABEL_TO_PROPERTY_NAME, +} + + +class ZWaveMigrationData(TypedDict): + """Represent the Z-Wave migration data dict.""" + + node_id: int + node_instance: int + command_class: int + command_class_label: str + value_index: int + device_id: str + domain: str + entity_id: str + unique_id: str + unit_of_measurement: str | None + + +class ZWaveJSMigrationData(TypedDict): + """Represent the Z-Wave JS migration data dict.""" + + node_id: int + endpoint_index: int + command_class: int + value_property_name: str + value_property_key_name: str | None + value_id: str + device_id: str + domain: str + entity_id: str + unique_id: str + unit_of_measurement: str | None + + +@dataclass +class LegacyZWaveMappedData: + """Represent the mapped data between Z-Wave and Z-Wave JS.""" + + entity_entries: dict[str, ZWaveMigrationData] = field(default_factory=dict) + device_entries: dict[str, str] = field(default_factory=dict) + + +async def async_add_migration_entity_value( + hass: HomeAssistant, + config_entry: ConfigEntry, + entity_id: str, + discovery_info: ZwaveDiscoveryInfo, +) -> None: + """Add Z-Wave JS entity value for legacy Z-Wave migration.""" + migration_handler: LegacyZWaveMigration = await get_legacy_zwave_migration(hass) + migration_handler.add_entity_value(config_entry, entity_id, discovery_info) + + +async def async_get_migration_data( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dict[str, ZWaveJSMigrationData]: + """Return Z-Wave JS migration data.""" + migration_handler: LegacyZWaveMigration = await get_legacy_zwave_migration(hass) + return await migration_handler.get_data(config_entry) + + +@singleton(LEGACY_ZWAVE_MIGRATION) +async def get_legacy_zwave_migration(hass: HomeAssistant) -> LegacyZWaveMigration: + """Return legacy Z-Wave migration handler.""" + migration_handler = LegacyZWaveMigration(hass) + await migration_handler.load_data() + return migration_handler + + +class LegacyZWaveMigration: + """Handle the migration from zwave to zwave_js.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Set up migration instance.""" + self._hass = hass + self._store = Store(hass, STORAGE_VERSION, STORAGE_KEY) + self._data: dict[str, dict[str, ZWaveJSMigrationData]] = {} + + async def load_data(self) -> None: + """Load Z-Wave JS migration data.""" + stored = cast(dict, await self._store.async_load()) + if stored: + self._data = stored + + @callback + def save_data( + self, config_entry_id: str, entity_id: str, data: ZWaveJSMigrationData + ) -> None: + """Save Z-Wave JS migration data.""" + if config_entry_id not in self._data: + self._data[config_entry_id] = {} + self._data[config_entry_id][entity_id] = data + self._store.async_delay_save(self._data_to_save, STORAGE_WRITE_DELAY) + + @callback + def _data_to_save(self) -> dict[str, dict[str, ZWaveJSMigrationData]]: + """Return data to save.""" + return self._data + + @callback + def add_entity_value( + self, + config_entry: ConfigEntry, + entity_id: str, + discovery_info: ZwaveDiscoveryInfo, + ) -> None: + """Add info for one entity and Z-Wave JS value.""" + ent_reg = async_get_entity_registry(self._hass) + dev_reg = async_get_device_registry(self._hass) + + node = discovery_info.node + primary_value = discovery_info.primary_value + entity_entry = ent_reg.async_get(entity_id) + assert entity_entry + device_identifier = get_device_id(node.client, node) + device_entry = dev_reg.async_get_device({device_identifier}, set()) + assert device_entry + + # Normalize unit of measurement. + if unit := entity_entry.unit_of_measurement: + unit = unit.lower() + if unit == "": + unit = None + + data: ZWaveJSMigrationData = { + "node_id": node.node_id, + "endpoint_index": node.index, + "command_class": primary_value.command_class, + "value_property_name": primary_value.property_name, + "value_property_key_name": primary_value.property_key_name, + "value_id": primary_value.value_id, + "device_id": device_entry.id, + "domain": entity_entry.domain, + "entity_id": entity_id, + "unique_id": entity_entry.unique_id, + "unit_of_measurement": unit, + } + + self.save_data(config_entry.entry_id, entity_id, data) + + async def get_data( + self, config_entry: ConfigEntry + ) -> dict[str, ZWaveJSMigrationData]: + """Return Z-Wave JS migration data for a config entry.""" + await self.load_data() + data = self._data.get(config_entry.entry_id) + return data or {} + + +@callback +def async_map_legacy_zwave_values( + zwave_data: dict[str, ZWaveMigrationData], + zwave_js_data: dict[str, ZWaveJSMigrationData], +) -> LegacyZWaveMappedData: + """Map Z-Wave node values onto Z-Wave JS node values.""" + migration_map = LegacyZWaveMappedData() + zwave_proc_data: dict[ + tuple[int, int, int, str, str | None, str | None], + ZWaveMigrationData | None, + ] = {} + zwave_js_proc_data: dict[ + tuple[int, int, int, str, str | None, str | None], + ZWaveJSMigrationData | None, + ] = {} + + for zwave_item in zwave_data.values(): + zwave_js_property_name = CC_ID_LABEL_TO_PROPERTY.get( + zwave_item["command_class"], {} + ).get(zwave_item["command_class_label"]) + item_id = ( + zwave_item["node_id"], + zwave_item["command_class"], + zwave_item["node_instance"] - 1, + zwave_item["domain"], + zwave_item["unit_of_measurement"], + zwave_js_property_name, + ) + + # Filter out duplicates that are not resolvable. + if item_id in zwave_proc_data: + zwave_proc_data[item_id] = None + continue + + zwave_proc_data[item_id] = zwave_item + + for zwave_js_item in zwave_js_data.values(): + # Only identify with property name if there is a command class label map. + if zwave_js_item["command_class"] in CC_ID_LABEL_TO_PROPERTY: + zwave_js_property_name = zwave_js_item["value_property_name"] + else: + zwave_js_property_name = None + item_id = ( + zwave_js_item["node_id"], + zwave_js_item["command_class"], + zwave_js_item["endpoint_index"], + zwave_js_item["domain"], + zwave_js_item["unit_of_measurement"], + zwave_js_property_name, + ) + + # Filter out duplicates that are not resolvable. + if item_id in zwave_js_proc_data: + zwave_js_proc_data[item_id] = None + continue + + zwave_js_proc_data[item_id] = zwave_js_item + + for item_id, zwave_entry in zwave_proc_data.items(): + zwave_js_entry = zwave_js_proc_data.pop(item_id, None) + + if zwave_entry is None or zwave_js_entry is None: + continue + + migration_map.entity_entries[zwave_js_entry["entity_id"]] = zwave_entry + migration_map.device_entries[zwave_js_entry["device_id"]] = zwave_entry[ + "device_id" + ] + + return migration_map + + +async def async_migrate_legacy_zwave( + hass: HomeAssistant, + zwave_config_entry: ConfigEntry, + zwave_js_config_entry: ConfigEntry, + migration_map: LegacyZWaveMappedData, +) -> None: + """Perform Z-Wave to Z-Wave JS migration.""" + dev_reg = async_get_device_registry(hass) + for zwave_js_device_id, zwave_device_id in migration_map.device_entries.items(): + zwave_device_entry = dev_reg.async_get(zwave_device_id) + if not zwave_device_entry: + continue + dev_reg.async_update_device( + zwave_js_device_id, + area_id=zwave_device_entry.area_id, + name_by_user=zwave_device_entry.name_by_user, + ) + + ent_reg = async_get_entity_registry(hass) + for zwave_js_entity_id, zwave_entry in migration_map.entity_entries.items(): + zwave_entity_id = zwave_entry["entity_id"] + entity_entry = ent_reg.async_get(zwave_entity_id) + if not entity_entry: + continue + ent_reg.async_remove(zwave_entity_id) + ent_reg.async_update_entity( + zwave_js_entity_id, + new_entity_id=entity_entry.entity_id, + name=entity_entry.name, + icon=entity_entry.icon, + ) + + await hass.config_entries.async_remove(zwave_config_entry.entry_id) + + updates = { + **zwave_js_config_entry.data, + MIGRATED: True, + } + hass.config_entries.async_update_entry(zwave_js_config_entry, data=updates) + @dataclass class ValueID: diff --git a/script/hassfest/dependencies.py b/script/hassfest/dependencies.py index 1df09d6f0d5..b650d3232ac 100644 --- a/script/hassfest/dependencies.py +++ b/script/hassfest/dependencies.py @@ -134,14 +134,14 @@ IGNORE_VIOLATIONS = { # Demo ("demo", "manual"), ("demo", "openalpr_local"), - # Migration wizard from zwave to ozw. - "ozw", # Migration of settings from zeroconf to network ("network", "zeroconf"), # This should become a helper method that integrations can submit data to ("websocket_api", "lovelace"), ("websocket_api", "shopping_list"), "logbook", + # Migration wizard from zwave to zwave_js. + "zwave_js", } diff --git a/tests/components/ozw/test_config_flow.py b/tests/components/ozw/test_config_flow.py index 6fdc86f710e..004c492bb2d 100644 --- a/tests/components/ozw/test_config_flow.py +++ b/tests/components/ozw/test_config_flow.py @@ -512,49 +512,3 @@ async def test_discovery_addon_not_installed( assert result["type"] == "form" assert result["step_id"] == "start_addon" - - -async def test_import_addon_installed( - hass, supervisor, addon_installed, addon_options, set_addon_options, start_addon -): - """Test add-on already installed but not running on Supervisor.""" - hass.config.components.add("mqtt") - await setup.async_setup_component(hass, "persistent_notification", {}) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={"usb_path": "/test/imported", "network_key": "imported123"}, - ) - - assert result["type"] == "form" - assert result["step_id"] == "on_supervisor" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"use_addon": True} - ) - - assert result["type"] == "form" - assert result["step_id"] == "start_addon" - - # the default input should be the imported data - default_input = result["data_schema"]({}) - - with patch( - "homeassistant.components.ozw.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_configure( - result["flow_id"], default_input - ) - await hass.async_block_till_done() - - assert result["type"] == "create_entry" - assert result["title"] == TITLE - assert result["data"] == { - "usb_path": "/test/imported", - "network_key": "imported123", - "use_addon": True, - "integration_created_addon": False, - } - assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/ozw/test_migration.py b/tests/components/ozw/test_migration.py deleted file mode 100644 index 076974bc48f..00000000000 --- a/tests/components/ozw/test_migration.py +++ /dev/null @@ -1,285 +0,0 @@ -"""Test zwave to ozw migration.""" -from unittest.mock import patch - -import pytest - -from homeassistant.components.ozw.websocket_api import ID, TYPE -from homeassistant.helpers import device_registry as dr, entity_registry as er - -from .common import setup_ozw - -from tests.common import MockConfigEntry, mock_device_registry, mock_registry - -ZWAVE_SOURCE_NODE_DEVICE_ID = "zwave_source_node_device_id" -ZWAVE_SOURCE_NODE_DEVICE_NAME = "Z-Wave Source Node Device" -ZWAVE_SOURCE_NODE_DEVICE_AREA = "Z-Wave Source Node Area" -ZWAVE_SOURCE_ENTITY = "sensor.zwave_source_node" -ZWAVE_SOURCE_NODE_UNIQUE_ID = "10-4321" -ZWAVE_BATTERY_DEVICE_ID = "zwave_battery_device_id" -ZWAVE_BATTERY_DEVICE_NAME = "Z-Wave Battery Device" -ZWAVE_BATTERY_DEVICE_AREA = "Z-Wave Battery Area" -ZWAVE_BATTERY_ENTITY = "sensor.zwave_battery_level" -ZWAVE_BATTERY_UNIQUE_ID = "36-1234" -ZWAVE_BATTERY_NAME = "Z-Wave Battery Level" -ZWAVE_BATTERY_ICON = "mdi:zwave-test-battery" -ZWAVE_POWER_DEVICE_ID = "zwave_power_device_id" -ZWAVE_POWER_DEVICE_NAME = "Z-Wave Power Device" -ZWAVE_POWER_DEVICE_AREA = "Z-Wave Power Area" -ZWAVE_POWER_ENTITY = "binary_sensor.zwave_power" -ZWAVE_POWER_UNIQUE_ID = "32-5678" -ZWAVE_POWER_NAME = "Z-Wave Power" -ZWAVE_POWER_ICON = "mdi:zwave-test-power" - - -@pytest.fixture(name="zwave_migration_data") -def zwave_migration_data_fixture(hass): - """Return mock zwave migration data.""" - zwave_source_node_device = dr.DeviceEntry( - id=ZWAVE_SOURCE_NODE_DEVICE_ID, - name_by_user=ZWAVE_SOURCE_NODE_DEVICE_NAME, - area_id=ZWAVE_SOURCE_NODE_DEVICE_AREA, - ) - zwave_source_node_entry = er.RegistryEntry( - entity_id=ZWAVE_SOURCE_ENTITY, - unique_id=ZWAVE_SOURCE_NODE_UNIQUE_ID, - platform="zwave", - name="Z-Wave Source Node", - ) - zwave_battery_device = dr.DeviceEntry( - id=ZWAVE_BATTERY_DEVICE_ID, - name_by_user=ZWAVE_BATTERY_DEVICE_NAME, - area_id=ZWAVE_BATTERY_DEVICE_AREA, - ) - zwave_battery_entry = er.RegistryEntry( - entity_id=ZWAVE_BATTERY_ENTITY, - unique_id=ZWAVE_BATTERY_UNIQUE_ID, - platform="zwave", - name=ZWAVE_BATTERY_NAME, - icon=ZWAVE_BATTERY_ICON, - ) - zwave_power_device = dr.DeviceEntry( - id=ZWAVE_POWER_DEVICE_ID, - name_by_user=ZWAVE_POWER_DEVICE_NAME, - area_id=ZWAVE_POWER_DEVICE_AREA, - ) - zwave_power_entry = er.RegistryEntry( - entity_id=ZWAVE_POWER_ENTITY, - unique_id=ZWAVE_POWER_UNIQUE_ID, - platform="zwave", - name=ZWAVE_POWER_NAME, - icon=ZWAVE_POWER_ICON, - ) - zwave_migration_data = { - ZWAVE_SOURCE_NODE_UNIQUE_ID: { - "node_id": 10, - "node_instance": 1, - "device_id": zwave_source_node_device.id, - "command_class": 113, - "command_class_label": "SourceNodeId", - "value_index": 2, - "unique_id": ZWAVE_SOURCE_NODE_UNIQUE_ID, - "entity_entry": zwave_source_node_entry, - }, - ZWAVE_BATTERY_UNIQUE_ID: { - "node_id": 36, - "node_instance": 1, - "device_id": zwave_battery_device.id, - "command_class": 128, - "command_class_label": "Battery Level", - "value_index": 0, - "unique_id": ZWAVE_BATTERY_UNIQUE_ID, - "entity_entry": zwave_battery_entry, - }, - ZWAVE_POWER_UNIQUE_ID: { - "node_id": 32, - "node_instance": 1, - "device_id": zwave_power_device.id, - "command_class": 50, - "command_class_label": "Power", - "value_index": 8, - "unique_id": ZWAVE_POWER_UNIQUE_ID, - "entity_entry": zwave_power_entry, - }, - } - - mock_device_registry( - hass, - { - zwave_source_node_device.id: zwave_source_node_device, - zwave_battery_device.id: zwave_battery_device, - zwave_power_device.id: zwave_power_device, - }, - ) - mock_registry( - hass, - { - ZWAVE_SOURCE_ENTITY: zwave_source_node_entry, - ZWAVE_BATTERY_ENTITY: zwave_battery_entry, - ZWAVE_POWER_ENTITY: zwave_power_entry, - }, - ) - - return zwave_migration_data - - -@pytest.fixture(name="zwave_integration") -def zwave_integration_fixture(hass, zwave_migration_data): - """Mock the zwave integration.""" - hass.config.components.add("zwave") - zwave_config_entry = MockConfigEntry(domain="zwave", data={"usb_path": "/dev/test"}) - zwave_config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.zwave.async_get_ozw_migration_data", - return_value=zwave_migration_data, - ): - yield zwave_config_entry - - -async def test_migrate_zwave(hass, migration_data, hass_ws_client, zwave_integration): - """Test the zwave to ozw migration websocket api.""" - await setup_ozw(hass, fixture=migration_data) - client = await hass_ws_client(hass) - - assert hass.config_entries.async_entries("zwave") - - await client.send_json({ID: 5, TYPE: "ozw/migrate_zwave", "dry_run": False}) - msg = await client.receive_json() - result = msg["result"] - - migration_entity_map = { - ZWAVE_BATTERY_ENTITY: "sensor.water_sensor_6_battery_level", - } - - assert result["zwave_entity_ids"] == [ - ZWAVE_SOURCE_ENTITY, - ZWAVE_BATTERY_ENTITY, - ZWAVE_POWER_ENTITY, - ] - assert result["ozw_entity_ids"] == [ - "sensor.smart_plug_electric_w", - "sensor.water_sensor_6_battery_level", - ] - assert result["migration_entity_map"] == migration_entity_map - assert result["migrated"] is True - - dev_reg = dr.async_get(hass) - ent_reg = er.async_get(hass) - - # check the device registry migration - - # check that the migrated entries have correct attributes - battery_entry = dev_reg.async_get_device( - identifiers={("ozw", "1.36.1")}, connections=set() - ) - assert battery_entry.name_by_user == ZWAVE_BATTERY_DEVICE_NAME - assert battery_entry.area_id == ZWAVE_BATTERY_DEVICE_AREA - power_entry = dev_reg.async_get_device( - identifiers={("ozw", "1.32.1")}, connections=set() - ) - assert power_entry.name_by_user == ZWAVE_POWER_DEVICE_NAME - assert power_entry.area_id == ZWAVE_POWER_DEVICE_AREA - - migration_device_map = { - ZWAVE_BATTERY_DEVICE_ID: battery_entry.id, - ZWAVE_POWER_DEVICE_ID: power_entry.id, - } - - assert result["migration_device_map"] == migration_device_map - - # check the entity registry migration - - # this should have been migrated and no longer present under that id - assert not ent_reg.async_is_registered("sensor.water_sensor_6_battery_level") - - # these should not have been migrated and is still in the registry - assert ent_reg.async_is_registered(ZWAVE_SOURCE_ENTITY) - source_entry = ent_reg.async_get(ZWAVE_SOURCE_ENTITY) - assert source_entry.unique_id == ZWAVE_SOURCE_NODE_UNIQUE_ID - assert ent_reg.async_is_registered(ZWAVE_POWER_ENTITY) - source_entry = ent_reg.async_get(ZWAVE_POWER_ENTITY) - assert source_entry.unique_id == ZWAVE_POWER_UNIQUE_ID - assert ent_reg.async_is_registered("sensor.smart_plug_electric_w") - - # this is the new entity_id of the ozw entity - assert ent_reg.async_is_registered(ZWAVE_BATTERY_ENTITY) - - # check that the migrated entries have correct attributes - battery_entry = ent_reg.async_get(ZWAVE_BATTERY_ENTITY) - assert battery_entry.unique_id == "1-36-610271249" - assert battery_entry.name == ZWAVE_BATTERY_NAME - assert battery_entry.icon == ZWAVE_BATTERY_ICON - - # check that the zwave config entry has been removed - assert not hass.config_entries.async_entries("zwave") - - # Check that the zwave integration fails entry setup after migration - zwave_config_entry = MockConfigEntry(domain="zwave") - zwave_config_entry.add_to_hass(hass) - assert not await hass.config_entries.async_setup(zwave_config_entry.entry_id) - - -async def test_migrate_zwave_dry_run( - hass, migration_data, hass_ws_client, zwave_integration -): - """Test the zwave to ozw migration websocket api dry run.""" - await setup_ozw(hass, fixture=migration_data) - client = await hass_ws_client(hass) - - await client.send_json({ID: 5, TYPE: "ozw/migrate_zwave"}) - msg = await client.receive_json() - result = msg["result"] - - migration_entity_map = { - ZWAVE_BATTERY_ENTITY: "sensor.water_sensor_6_battery_level", - } - - assert result["zwave_entity_ids"] == [ - ZWAVE_SOURCE_ENTITY, - ZWAVE_BATTERY_ENTITY, - ZWAVE_POWER_ENTITY, - ] - assert result["ozw_entity_ids"] == [ - "sensor.smart_plug_electric_w", - "sensor.water_sensor_6_battery_level", - ] - assert result["migration_entity_map"] == migration_entity_map - assert result["migrated"] is False - - ent_reg = er.async_get(hass) - - # no real migration should have been done - assert ent_reg.async_is_registered("sensor.water_sensor_6_battery_level") - assert ent_reg.async_is_registered("sensor.smart_plug_electric_w") - - assert ent_reg.async_is_registered(ZWAVE_SOURCE_ENTITY) - source_entry = ent_reg.async_get(ZWAVE_SOURCE_ENTITY) - assert source_entry.unique_id == ZWAVE_SOURCE_NODE_UNIQUE_ID - - assert ent_reg.async_is_registered(ZWAVE_BATTERY_ENTITY) - battery_entry = ent_reg.async_get(ZWAVE_BATTERY_ENTITY) - assert battery_entry.unique_id == ZWAVE_BATTERY_UNIQUE_ID - - assert ent_reg.async_is_registered(ZWAVE_POWER_ENTITY) - power_entry = ent_reg.async_get(ZWAVE_POWER_ENTITY) - assert power_entry.unique_id == ZWAVE_POWER_UNIQUE_ID - - # check that the zwave config entry has not been removed - assert hass.config_entries.async_entries("zwave") - - # Check that the zwave integration can be setup after dry run - zwave_config_entry = zwave_integration - with patch("openzwave.option.ZWaveOption"), patch("openzwave.network.ZWaveNetwork"): - assert await hass.config_entries.async_setup(zwave_config_entry.entry_id) - - -async def test_migrate_zwave_not_setup(hass, migration_data, hass_ws_client): - """Test the zwave to ozw migration websocket without zwave setup.""" - await setup_ozw(hass, fixture=migration_data) - client = await hass_ws_client(hass) - - await client.send_json({ID: 5, TYPE: "ozw/migrate_zwave"}) - msg = await client.receive_json() - - assert not msg["success"] - assert msg["error"]["code"] == "zwave_not_loaded" - assert msg["error"]["message"] == "Integration zwave is not loaded" diff --git a/tests/components/zwave/test_init.py b/tests/components/zwave/test_init.py index ecf5759b835..b0114d087ad 100644 --- a/tests/components/zwave/test_init.py +++ b/tests/components/zwave/test_init.py @@ -15,8 +15,7 @@ from homeassistant.components.zwave import ( DATA_NETWORK, const, ) -from homeassistant.components.zwave.binary_sensor import get_device -from homeassistant.const import ATTR_ENTITY_ID, ATTR_NAME +from homeassistant.const import ATTR_NAME from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util import dt as dt_util @@ -1854,38 +1853,6 @@ async def test_remove_association(hass, mock_openzwave, zwave_setup_ready): assert group.remove_association.mock_calls[0][1][1] == 5 -async def test_refresh_entity(hass, mock_openzwave, zwave_setup_ready): - """Test zwave refresh_entity service.""" - node = MockNode() - value = MockValue( - data=False, node=node, command_class=const.COMMAND_CLASS_SENSOR_BINARY - ) - power_value = MockValue(data=50, node=node, command_class=const.COMMAND_CLASS_METER) - values = MockEntityValues(primary=value, power=power_value) - device = get_device(node=node, values=values, node_config={}) - device.hass = hass - device.entity_id = "binary_sensor.mock_entity_id" - await device.async_added_to_hass() - await hass.async_block_till_done() - - await hass.services.async_call( - "zwave", "refresh_entity", {ATTR_ENTITY_ID: "binary_sensor.mock_entity_id"} - ) - await hass.async_block_till_done() - - assert node.refresh_value.called - assert len(node.refresh_value.mock_calls) == 2 - assert ( - sorted( - [ - node.refresh_value.mock_calls[0][1][0], - node.refresh_value.mock_calls[1][1][0], - ] - ) - == sorted([value.value_id, power_value.value_id]) - ) - - async def test_refresh_node(hass, mock_openzwave, zwave_setup_ready): """Test zwave refresh_node service.""" zwave_network = hass.data[DATA_NETWORK] diff --git a/tests/components/zwave/test_websocket_api.py b/tests/components/zwave/test_websocket_api.py index 2e37ed47fce..2ad94d29b0e 100644 --- a/tests/components/zwave/test_websocket_api.py +++ b/tests/components/zwave/test_websocket_api.py @@ -44,8 +44,8 @@ async def test_zwave_ws_api(hass, mock_openzwave, hass_ws_client): assert result[CONF_POLLING_INTERVAL] == 6000 -async def test_zwave_ozw_migration_api(hass, mock_openzwave, hass_ws_client): - """Test Z-Wave to OpenZWave websocket migration API.""" +async def test_zwave_zwave_js_migration_api(hass, mock_openzwave, hass_ws_client): + """Test Z-Wave to Z-Wave JS websocket migration API.""" await async_setup_component( hass, @@ -76,14 +76,14 @@ async def test_zwave_ozw_migration_api(hass, mock_openzwave, hass_ws_client): ) as async_init: async_init.return_value = {"flow_id": "mock_flow_id"} - await client.send_json({ID: 7, TYPE: "zwave/start_ozw_config_flow"}) + await client.send_json({ID: 7, TYPE: "zwave/start_zwave_js_config_flow"}) msg = await client.receive_json() result = msg["result"] assert result["flow_id"] == "mock_flow_id" assert async_init.call_args == call( - "ozw", + "zwave_js", context={"source": config_entries.SOURCE_IMPORT}, data={"usb_path": "/dev/zwave", "network_key": NETWORK_KEY}, ) diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 1551b55a429..44b9acf2db5 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -1,4 +1,5 @@ """Test the Z-Wave JS Websocket API.""" +from copy import deepcopy import json from unittest.mock import patch @@ -17,6 +18,7 @@ from zwave_js_server.exceptions import ( NotFoundError, SetValueFailed, ) +from zwave_js_server.model.node import Node from zwave_js_server.model.value import _get_value_id_from_dict, get_value_id from homeassistant.components.websocket_api.const import ERR_NOT_FOUND @@ -78,6 +80,51 @@ async def test_network_status(hass, integration, hass_ws_client): assert msg["error"]["code"] == ERR_NOT_LOADED +async def test_node_ready( + hass, + multisensor_6_state, + client, + integration, + hass_ws_client, +): + """Test the node ready websocket command.""" + entry = integration + ws_client = await hass_ws_client(hass) + node_data = deepcopy(multisensor_6_state) # Copy to allow modification in tests. + node = Node(client, node_data) + node.data["ready"] = False + client.driver.controller.nodes[node.node_id] = node + + await ws_client.send_json( + { + ID: 3, + TYPE: "zwave_js/node_ready", + ENTRY_ID: entry.entry_id, + "node_id": node.node_id, + } + ) + + msg = await ws_client.receive_json() + assert msg["success"] + + node.data["ready"] = True + event = Event( + "ready", + { + "source": "node", + "event": "ready", + "nodeId": node.node_id, + "nodeState": node.data, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + + msg = await ws_client.receive_json() + + assert msg["event"]["event"] == "ready" + + async def test_node_status(hass, multisensor_6, integration, hass_ws_client): """Test the node status websocket command.""" entry = integration diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index 757dc6d5364..c2f1b12ca15 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -2053,3 +2053,71 @@ async def test_options_addon_not_installed( assert entry.data["integration_created_addon"] is True assert client.connect.call_count == 2 assert client.disconnect.call_count == 1 + + +@pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) +async def test_import_addon_installed( + hass, + supervisor, + addon_installed, + addon_options, + set_addon_options, + start_addon, + get_addon_discovery_info, +): + """Test import step while add-on already installed on Supervisor.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={"usb_path": "/test/imported", "network_key": "imported123"}, + ) + + assert result["type"] == "form" + assert result["step_id"] == "on_supervisor" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"use_addon": True} + ) + + assert result["type"] == "form" + assert result["step_id"] == "configure_addon" + + # the default input should be the imported data + default_input = result["data_schema"]({}) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], default_input + ) + + assert set_addon_options.call_args == call( + hass, + "core_zwave_js", + {"options": {"device": "/test/imported", "network_key": "imported123"}}, + ) + + assert result["type"] == "progress" + assert result["step_id"] == "start_addon" + + with patch( + "homeassistant.components.zwave_js.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.zwave_js.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert start_addon.call_args == call(hass, "core_zwave_js") + + assert result["type"] == "create_entry" + assert result["title"] == TITLE + assert result["data"] == { + "url": "ws://host1:3001", + "usb_path": "/test/imported", + "network_key": "imported123", + "use_addon": True, + "integration_created_addon": False, + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/zwave_js/test_migrate.py b/tests/components/zwave_js/test_migrate.py index 37c53700d95..ff3712b607e 100644 --- a/tests/components/zwave_js/test_migrate.py +++ b/tests/components/zwave_js/test_migrate.py @@ -1,15 +1,443 @@ """Test the Z-Wave JS migration module.""" import copy +from unittest.mock import patch import pytest from zwave_js_server.model.node import Node +from homeassistant.components.zwave_js.api import ENTRY_ID, ID, TYPE from homeassistant.components.zwave_js.const import DOMAIN from homeassistant.components.zwave_js.helpers import get_device_id from homeassistant.helpers import device_registry as dr, entity_registry as er from .common import AIR_TEMPERATURE_SENSOR, NOTIFICATION_MOTION_BINARY_SENSOR +from tests.common import MockConfigEntry, mock_device_registry, mock_registry + +# Switch device +ZWAVE_SWITCH_DEVICE_ID = "zwave_switch_device_id" +ZWAVE_SWITCH_DEVICE_NAME = "Z-Wave Switch Device" +ZWAVE_SWITCH_DEVICE_AREA = "Z-Wave Switch Area" +ZWAVE_SWITCH_ENTITY = "switch.zwave_switch_node" +ZWAVE_SWITCH_UNIQUE_ID = "102-6789" +ZWAVE_SWITCH_NAME = "Z-Wave Switch" +ZWAVE_SWITCH_ICON = "mdi:zwave-test-switch" +ZWAVE_POWER_ENTITY = "sensor.zwave_power" +ZWAVE_POWER_UNIQUE_ID = "102-5678" +ZWAVE_POWER_NAME = "Z-Wave Power" +ZWAVE_POWER_ICON = "mdi:zwave-test-power" + +# Multisensor device +ZWAVE_MULTISENSOR_DEVICE_ID = "zwave_multisensor_device_id" +ZWAVE_MULTISENSOR_DEVICE_NAME = "Z-Wave Multisensor Device" +ZWAVE_MULTISENSOR_DEVICE_AREA = "Z-Wave Multisensor Area" +ZWAVE_SOURCE_NODE_ENTITY = "sensor.zwave_source_node" +ZWAVE_SOURCE_NODE_UNIQUE_ID = "52-4321" +ZWAVE_BATTERY_ENTITY = "sensor.zwave_battery_level" +ZWAVE_BATTERY_UNIQUE_ID = "52-1234" +ZWAVE_BATTERY_NAME = "Z-Wave Battery Level" +ZWAVE_BATTERY_ICON = "mdi:zwave-test-battery" +ZWAVE_TAMPERING_ENTITY = "sensor.zwave_tampering" +ZWAVE_TAMPERING_UNIQUE_ID = "52-3456" +ZWAVE_TAMPERING_NAME = "Z-Wave Tampering" +ZWAVE_TAMPERING_ICON = "mdi:zwave-test-tampering" + + +@pytest.fixture(name="zwave_migration_data") +def zwave_migration_data_fixture(hass): + """Return mock zwave migration data.""" + zwave_switch_device = dr.DeviceEntry( + id=ZWAVE_SWITCH_DEVICE_ID, + name_by_user=ZWAVE_SWITCH_DEVICE_NAME, + area_id=ZWAVE_SWITCH_DEVICE_AREA, + ) + zwave_switch_entry = er.RegistryEntry( + entity_id=ZWAVE_SWITCH_ENTITY, + unique_id=ZWAVE_SWITCH_UNIQUE_ID, + platform="zwave", + name=ZWAVE_SWITCH_NAME, + icon=ZWAVE_SWITCH_ICON, + ) + zwave_multisensor_device = dr.DeviceEntry( + id=ZWAVE_MULTISENSOR_DEVICE_ID, + name_by_user=ZWAVE_MULTISENSOR_DEVICE_NAME, + area_id=ZWAVE_MULTISENSOR_DEVICE_AREA, + ) + zwave_source_node_entry = er.RegistryEntry( + entity_id=ZWAVE_SOURCE_NODE_ENTITY, + unique_id=ZWAVE_SOURCE_NODE_UNIQUE_ID, + platform="zwave", + name="Z-Wave Source Node", + ) + zwave_battery_entry = er.RegistryEntry( + entity_id=ZWAVE_BATTERY_ENTITY, + unique_id=ZWAVE_BATTERY_UNIQUE_ID, + platform="zwave", + name=ZWAVE_BATTERY_NAME, + icon=ZWAVE_BATTERY_ICON, + unit_of_measurement="%", + ) + zwave_power_entry = er.RegistryEntry( + entity_id=ZWAVE_POWER_ENTITY, + unique_id=ZWAVE_POWER_UNIQUE_ID, + platform="zwave", + name=ZWAVE_POWER_NAME, + icon=ZWAVE_POWER_ICON, + unit_of_measurement="W", + ) + zwave_tampering_entry = er.RegistryEntry( + entity_id=ZWAVE_TAMPERING_ENTITY, + unique_id=ZWAVE_TAMPERING_UNIQUE_ID, + platform="zwave", + name=ZWAVE_TAMPERING_NAME, + icon=ZWAVE_TAMPERING_ICON, + unit_of_measurement="", # Test empty string unit normalization. + ) + + zwave_migration_data = { + ZWAVE_SWITCH_ENTITY: { + "node_id": 102, + "node_instance": 1, + "command_class": 37, + "command_class_label": "", + "value_index": 1, + "device_id": zwave_switch_device.id, + "domain": zwave_switch_entry.domain, + "entity_id": zwave_switch_entry.entity_id, + "unique_id": ZWAVE_SWITCH_UNIQUE_ID, + "unit_of_measurement": zwave_switch_entry.unit_of_measurement, + }, + ZWAVE_POWER_ENTITY: { + "node_id": 102, + "node_instance": 1, + "command_class": 50, + "command_class_label": "Power", + "value_index": 8, + "device_id": zwave_switch_device.id, + "domain": zwave_power_entry.domain, + "entity_id": zwave_power_entry.entity_id, + "unique_id": ZWAVE_POWER_UNIQUE_ID, + "unit_of_measurement": zwave_power_entry.unit_of_measurement, + }, + ZWAVE_SOURCE_NODE_ENTITY: { + "node_id": 52, + "node_instance": 1, + "command_class": 113, + "command_class_label": "SourceNodeId", + "value_index": 1, + "device_id": zwave_multisensor_device.id, + "domain": zwave_source_node_entry.domain, + "entity_id": zwave_source_node_entry.entity_id, + "unique_id": ZWAVE_SOURCE_NODE_UNIQUE_ID, + "unit_of_measurement": zwave_source_node_entry.unit_of_measurement, + }, + ZWAVE_BATTERY_ENTITY: { + "node_id": 52, + "node_instance": 1, + "command_class": 128, + "command_class_label": "Battery Level", + "value_index": 0, + "device_id": zwave_multisensor_device.id, + "domain": zwave_battery_entry.domain, + "entity_id": zwave_battery_entry.entity_id, + "unique_id": ZWAVE_BATTERY_UNIQUE_ID, + "unit_of_measurement": zwave_battery_entry.unit_of_measurement, + }, + ZWAVE_TAMPERING_ENTITY: { + "node_id": 52, + "node_instance": 1, + "command_class": 113, + "command_class_label": "Burglar", + "value_index": 10, + "device_id": zwave_multisensor_device.id, + "domain": zwave_tampering_entry.domain, + "entity_id": zwave_tampering_entry.entity_id, + "unique_id": ZWAVE_TAMPERING_UNIQUE_ID, + "unit_of_measurement": zwave_tampering_entry.unit_of_measurement, + }, + } + + mock_device_registry( + hass, + { + zwave_switch_device.id: zwave_switch_device, + zwave_multisensor_device.id: zwave_multisensor_device, + }, + ) + mock_registry( + hass, + { + ZWAVE_SWITCH_ENTITY: zwave_switch_entry, + ZWAVE_SOURCE_NODE_ENTITY: zwave_source_node_entry, + ZWAVE_BATTERY_ENTITY: zwave_battery_entry, + ZWAVE_POWER_ENTITY: zwave_power_entry, + ZWAVE_TAMPERING_ENTITY: zwave_tampering_entry, + }, + ) + + return zwave_migration_data + + +@pytest.fixture(name="zwave_integration") +def zwave_integration_fixture(hass, zwave_migration_data): + """Mock the zwave integration.""" + hass.config.components.add("zwave") + zwave_config_entry = MockConfigEntry(domain="zwave", data={"usb_path": "/dev/test"}) + zwave_config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.zwave.async_get_migration_data", + return_value=zwave_migration_data, + ): + yield zwave_config_entry + + +async def test_migrate_zwave( + hass, + zwave_integration, + aeon_smart_switch_6, + multisensor_6, + integration, + hass_ws_client, +): + """Test the Z-Wave to Z-Wave JS migration websocket api.""" + entry = integration + client = await hass_ws_client(hass) + + assert hass.config_entries.async_entries("zwave") + + await client.send_json( + { + ID: 5, + TYPE: "zwave_js/migrate_zwave", + ENTRY_ID: entry.entry_id, + "dry_run": False, + } + ) + msg = await client.receive_json() + result = msg["result"] + + migration_entity_map = { + ZWAVE_SWITCH_ENTITY: "switch.smart_switch_6", + ZWAVE_BATTERY_ENTITY: "sensor.multisensor_6_battery_level", + } + + assert result["zwave_entity_ids"] == [ + ZWAVE_SWITCH_ENTITY, + ZWAVE_POWER_ENTITY, + ZWAVE_SOURCE_NODE_ENTITY, + ZWAVE_BATTERY_ENTITY, + ZWAVE_TAMPERING_ENTITY, + ] + expected_zwave_js_entities = [ + "switch.smart_switch_6", + "sensor.multisensor_6_air_temperature", + "sensor.multisensor_6_illuminance", + "sensor.multisensor_6_humidity", + "sensor.multisensor_6_ultraviolet", + "binary_sensor.multisensor_6_home_security_tampering_product_cover_removed", + "binary_sensor.multisensor_6_home_security_motion_detection", + "sensor.multisensor_6_battery_level", + "binary_sensor.multisensor_6_low_battery_level", + "light.smart_switch_6", + "sensor.smart_switch_6_electric_consumed_kwh", + "sensor.smart_switch_6_electric_consumed_w", + "sensor.smart_switch_6_electric_consumed_v", + "sensor.smart_switch_6_electric_consumed_a", + ] + # Assert that both lists have the same items without checking order + assert not set(result["zwave_js_entity_ids"]) ^ set(expected_zwave_js_entities) + assert result["migration_entity_map"] == migration_entity_map + assert result["migrated"] is True + + dev_reg = dr.async_get(hass) + ent_reg = er.async_get(hass) + + # check the device registry migration + + # check that the migrated entries have correct attributes + multisensor_device_entry = dev_reg.async_get_device( + identifiers={("zwave_js", "3245146787-52")}, connections=set() + ) + assert multisensor_device_entry + assert multisensor_device_entry.name_by_user == ZWAVE_MULTISENSOR_DEVICE_NAME + assert multisensor_device_entry.area_id == ZWAVE_MULTISENSOR_DEVICE_AREA + switch_device_entry = dev_reg.async_get_device( + identifiers={("zwave_js", "3245146787-102")}, connections=set() + ) + assert switch_device_entry + assert switch_device_entry.name_by_user == ZWAVE_SWITCH_DEVICE_NAME + assert switch_device_entry.area_id == ZWAVE_SWITCH_DEVICE_AREA + + migration_device_map = { + ZWAVE_SWITCH_DEVICE_ID: switch_device_entry.id, + ZWAVE_MULTISENSOR_DEVICE_ID: multisensor_device_entry.id, + } + + assert result["migration_device_map"] == migration_device_map + + # check the entity registry migration + + # this should have been migrated and no longer present under that id + assert not ent_reg.async_is_registered("sensor.multisensor_6_battery_level") + + # these should not have been migrated and is still in the registry + assert ent_reg.async_is_registered(ZWAVE_SOURCE_NODE_ENTITY) + source_entry = ent_reg.async_get(ZWAVE_SOURCE_NODE_ENTITY) + assert source_entry.unique_id == ZWAVE_SOURCE_NODE_UNIQUE_ID + assert ent_reg.async_is_registered(ZWAVE_POWER_ENTITY) + source_entry = ent_reg.async_get(ZWAVE_POWER_ENTITY) + assert source_entry.unique_id == ZWAVE_POWER_UNIQUE_ID + assert ent_reg.async_is_registered(ZWAVE_TAMPERING_ENTITY) + tampering_entry = ent_reg.async_get(ZWAVE_TAMPERING_ENTITY) + assert tampering_entry.unique_id == ZWAVE_TAMPERING_UNIQUE_ID + assert ent_reg.async_is_registered("sensor.smart_switch_6_electric_consumed_w") + + # this is the new entity_ids of the zwave_js entities + assert ent_reg.async_is_registered(ZWAVE_SWITCH_ENTITY) + assert ent_reg.async_is_registered(ZWAVE_BATTERY_ENTITY) + + # check that the migrated entries have correct attributes + switch_entry = ent_reg.async_get(ZWAVE_SWITCH_ENTITY) + assert switch_entry + assert switch_entry.unique_id == "3245146787.102-37-0-currentValue" + assert switch_entry.name == ZWAVE_SWITCH_NAME + assert switch_entry.icon == ZWAVE_SWITCH_ICON + battery_entry = ent_reg.async_get(ZWAVE_BATTERY_ENTITY) + assert battery_entry + assert battery_entry.unique_id == "3245146787.52-128-0-level" + assert battery_entry.name == ZWAVE_BATTERY_NAME + assert battery_entry.icon == ZWAVE_BATTERY_ICON + + # check that the zwave config entry has been removed + assert not hass.config_entries.async_entries("zwave") + + # Check that the zwave integration fails entry setup after migration + zwave_config_entry = MockConfigEntry(domain="zwave") + zwave_config_entry.add_to_hass(hass) + assert not await hass.config_entries.async_setup(zwave_config_entry.entry_id) + + +async def test_migrate_zwave_dry_run( + hass, + zwave_integration, + aeon_smart_switch_6, + multisensor_6, + integration, + hass_ws_client, +): + """Test the zwave to zwave_js migration websocket api dry run.""" + entry = integration + client = await hass_ws_client(hass) + + await client.send_json( + {ID: 5, TYPE: "zwave_js/migrate_zwave", ENTRY_ID: entry.entry_id} + ) + msg = await client.receive_json() + result = msg["result"] + + migration_entity_map = { + ZWAVE_SWITCH_ENTITY: "switch.smart_switch_6", + ZWAVE_BATTERY_ENTITY: "sensor.multisensor_6_battery_level", + } + + assert result["zwave_entity_ids"] == [ + ZWAVE_SWITCH_ENTITY, + ZWAVE_POWER_ENTITY, + ZWAVE_SOURCE_NODE_ENTITY, + ZWAVE_BATTERY_ENTITY, + ZWAVE_TAMPERING_ENTITY, + ] + expected_zwave_js_entities = [ + "switch.smart_switch_6", + "sensor.multisensor_6_air_temperature", + "sensor.multisensor_6_illuminance", + "sensor.multisensor_6_humidity", + "sensor.multisensor_6_ultraviolet", + "binary_sensor.multisensor_6_home_security_tampering_product_cover_removed", + "binary_sensor.multisensor_6_home_security_motion_detection", + "sensor.multisensor_6_battery_level", + "binary_sensor.multisensor_6_low_battery_level", + "light.smart_switch_6", + "sensor.smart_switch_6_electric_consumed_kwh", + "sensor.smart_switch_6_electric_consumed_w", + "sensor.smart_switch_6_electric_consumed_v", + "sensor.smart_switch_6_electric_consumed_a", + ] + # Assert that both lists have the same items without checking order + assert not set(result["zwave_js_entity_ids"]) ^ set(expected_zwave_js_entities) + assert result["migration_entity_map"] == migration_entity_map + + dev_reg = dr.async_get(hass) + + multisensor_device_entry = dev_reg.async_get_device( + identifiers={("zwave_js", "3245146787-52")}, connections=set() + ) + assert multisensor_device_entry + assert multisensor_device_entry.name_by_user is None + assert multisensor_device_entry.area_id is None + switch_device_entry = dev_reg.async_get_device( + identifiers={("zwave_js", "3245146787-102")}, connections=set() + ) + assert switch_device_entry + assert switch_device_entry.name_by_user is None + assert switch_device_entry.area_id is None + + migration_device_map = { + ZWAVE_SWITCH_DEVICE_ID: switch_device_entry.id, + ZWAVE_MULTISENSOR_DEVICE_ID: multisensor_device_entry.id, + } + + assert result["migration_device_map"] == migration_device_map + + assert result["migrated"] is False + + ent_reg = er.async_get(hass) + + # no real migration should have been done + assert ent_reg.async_is_registered("switch.smart_switch_6") + assert ent_reg.async_is_registered("sensor.multisensor_6_battery_level") + assert ent_reg.async_is_registered("sensor.smart_switch_6_electric_consumed_w") + + assert ent_reg.async_is_registered(ZWAVE_SOURCE_NODE_ENTITY) + source_entry = ent_reg.async_get(ZWAVE_SOURCE_NODE_ENTITY) + assert source_entry + assert source_entry.unique_id == ZWAVE_SOURCE_NODE_UNIQUE_ID + + assert ent_reg.async_is_registered(ZWAVE_BATTERY_ENTITY) + battery_entry = ent_reg.async_get(ZWAVE_BATTERY_ENTITY) + assert battery_entry + assert battery_entry.unique_id == ZWAVE_BATTERY_UNIQUE_ID + + assert ent_reg.async_is_registered(ZWAVE_POWER_ENTITY) + power_entry = ent_reg.async_get(ZWAVE_POWER_ENTITY) + assert power_entry + assert power_entry.unique_id == ZWAVE_POWER_UNIQUE_ID + + # check that the zwave config entry has not been removed + assert hass.config_entries.async_entries("zwave") + + # Check that the zwave integration can be setup after dry run + zwave_config_entry = zwave_integration + with patch("openzwave.option.ZWaveOption"), patch("openzwave.network.ZWaveNetwork"): + assert await hass.config_entries.async_setup(zwave_config_entry.entry_id) + + +async def test_migrate_zwave_not_setup( + hass, aeon_smart_switch_6, multisensor_6, integration, hass_ws_client +): + """Test the zwave to zwave_js migration websocket without zwave setup.""" + entry = integration + client = await hass_ws_client(hass) + + await client.send_json( + {ID: 5, TYPE: "zwave_js/migrate_zwave", ENTRY_ID: entry.entry_id} + ) + msg = await client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "zwave_not_loaded" + assert msg["error"]["message"] == "Integration zwave is not loaded" + async def test_unique_id_migration_dupes( hass, multisensor_6_state, client, integration From 60eb4264510d798f29d5a5256186cdb6a4e7b3e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Wed, 29 Sep 2021 18:17:12 +0200 Subject: [PATCH 695/843] Add Surepetcare locks (#56396) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Surepetcare, add lock Signed-off-by: Daniel Hjelseth Høyer * Fix tests Signed-off-by: Daniel Hjelseth Høyer * Surepetcare, lock name Signed-off-by: Daniel Hjelseth Høyer * surepetcare_id Signed-off-by: Daniel Hjelseth Høyer * typing Signed-off-by: Daniel Hjelseth Høyer * Fix review comment Signed-off-by: Daniel Hjelseth Høyer * Fix review comment Signed-off-by: Daniel Hjelseth Høyer * Fix review comment Signed-off-by: Daniel Hjelseth Høyer * add more tests Signed-off-by: Daniel Hjelseth Høyer * Fix review comment Signed-off-by: Daniel Hjelseth Høyer --- .../components/surepetcare/__init__.py | 8 +- homeassistant/components/surepetcare/lock.py | 98 +++++++++++++++++++ tests/components/surepetcare/__init__.py | 2 + tests/components/surepetcare/test_lock.py | 75 ++++++++++++++ 4 files changed, 179 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/surepetcare/lock.py create mode 100644 tests/components/surepetcare/test_lock.py diff --git a/homeassistant/components/surepetcare/__init__.py b/homeassistant/components/surepetcare/__init__.py index ece42d0f410..368a548249d 100644 --- a/homeassistant/components/surepetcare/__init__.py +++ b/homeassistant/components/surepetcare/__init__.py @@ -40,7 +40,7 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -PLATFORMS = ["binary_sensor", "sensor"] +PLATFORMS = ["binary_sensor", "lock", "sensor"] SCAN_INTERVAL = timedelta(minutes=3) CONFIG_SCHEMA = vol.Schema( @@ -118,7 +118,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: vol.Required(ATTR_LOCK_STATE): vol.All( cv.string, vol.Lower, - vol.In(coordinator.lock_states.keys()), + vol.In(coordinator.lock_states_callbacks.keys()), ), } ) @@ -171,7 +171,7 @@ class SurePetcareDataCoordinator(DataUpdateCoordinator): api_timeout=SURE_API_TIMEOUT, session=async_get_clientsession(hass), ) - self.lock_states = { + self.lock_states_callbacks = { LockState.UNLOCKED.name.lower(): self.surepy.sac.unlock, LockState.LOCKED_IN.name.lower(): self.surepy.sac.lock_in, LockState.LOCKED_OUT.name.lower(): self.surepy.sac.lock_out, @@ -195,7 +195,7 @@ class SurePetcareDataCoordinator(DataUpdateCoordinator): """Call when setting the lock state.""" flap_id = call.data[ATTR_FLAP_ID] state = call.data[ATTR_LOCK_STATE] - await self.lock_states[state](flap_id) + await self.lock_states_callbacks[state](flap_id) await self.async_request_refresh() def get_pets(self) -> dict[str, int]: diff --git a/homeassistant/components/surepetcare/lock.py b/homeassistant/components/surepetcare/lock.py new file mode 100644 index 00000000000..e5b31150152 --- /dev/null +++ b/homeassistant/components/surepetcare/lock.py @@ -0,0 +1,98 @@ +"""Support for Sure PetCare Flaps locks.""" +from __future__ import annotations + +import logging +from typing import Any + +from surepy.entities import SurepyEntity +from surepy.enums import EntityType, LockState + +from homeassistant.components.lock import STATE_LOCKED, STATE_UNLOCKED, LockEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import SurePetcareDataCoordinator +from .const import DOMAIN +from .entity import SurePetcareEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Sure PetCare locks on a config entry.""" + + entities: list[SurePetcareLock] = [] + + coordinator: SurePetcareDataCoordinator = hass.data[DOMAIN][entry.entry_id] + + for surepy_entity in coordinator.data.values(): + if surepy_entity.type not in [ + EntityType.CAT_FLAP, + EntityType.PET_FLAP, + ]: + continue + + for lock_state in ( + LockState.LOCKED_IN, + LockState.LOCKED_OUT, + LockState.LOCKED_ALL, + ): + entities.append(SurePetcareLock(surepy_entity.id, coordinator, lock_state)) + + async_add_entities(entities) + + +class SurePetcareLock(SurePetcareEntity, LockEntity): + """A lock implementation for Sure Petcare Entities.""" + + coordinator: SurePetcareDataCoordinator + + def __init__( + self, + surepetcare_id: int, + coordinator: SurePetcareDataCoordinator, + lock_state: LockState, + ) -> None: + """Initialize a Sure Petcare lock.""" + self._lock_state = lock_state.name.lower() + self._available = False + + super().__init__(surepetcare_id, coordinator) + + self._attr_name = f"{self._device_name} {self._lock_state.replace('_', ' ')}" + self._attr_unique_id = f"{self._device_id}-{self._lock_state}" + + @property + def available(self) -> bool: + """Return true if entity is available.""" + return self._available and super().available + + @callback + def _update_attr(self, surepy_entity: SurepyEntity) -> None: + """Update the state.""" + status = surepy_entity.raw_data()["status"] + + self._attr_is_locked = ( + LockState(status["locking"]["mode"]).name.lower() == self._lock_state + ) + + self._available = bool(status.get("online")) + + async def async_lock(self, **kwargs: Any) -> None: + """Lock the lock.""" + if self.state == STATE_LOCKED: + return + await self.coordinator.lock_states_callbacks[self._lock_state](self._id) + self._attr_is_locked = True + self.async_write_ha_state() + + async def async_unlock(self, **kwargs: Any) -> None: + """Unlock the lock.""" + if self.state == STATE_UNLOCKED: + return + await self.coordinator.surepy.sac.unlock(self._id) + self._attr_is_locked = False + self.async_write_ha_state() diff --git a/tests/components/surepetcare/__init__.py b/tests/components/surepetcare/__init__.py index 7dda9e23d90..854ac923ead 100644 --- a/tests/components/surepetcare/__init__.py +++ b/tests/components/surepetcare/__init__.py @@ -38,6 +38,7 @@ MOCK_CAT_FLAP = { "locking": {"mode": 0}, "learn_mode": 0, "signal": {"device_rssi": 65, "hub_rssi": 64}, + "online": True, }, } @@ -52,6 +53,7 @@ MOCK_PET_FLAP = { "locking": {"mode": 0}, "learn_mode": 0, "signal": {"device_rssi": 70, "hub_rssi": 65}, + "online": True, }, } diff --git a/tests/components/surepetcare/test_lock.py b/tests/components/surepetcare/test_lock.py new file mode 100644 index 00000000000..3a29ced9ace --- /dev/null +++ b/tests/components/surepetcare/test_lock.py @@ -0,0 +1,75 @@ +"""The tests for the Sure Petcare lock platform.""" + +from homeassistant.components.surepetcare.const import DOMAIN +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component + +from . import HOUSEHOLD_ID, MOCK_CAT_FLAP, MOCK_CONFIG, MOCK_PET_FLAP + +EXPECTED_ENTITY_IDS = { + "lock.cat_flap_locked_in": f"{HOUSEHOLD_ID}-{MOCK_CAT_FLAP['id']}-locked_in", + "lock.cat_flap_locked_out": f"{HOUSEHOLD_ID}-{MOCK_CAT_FLAP['id']}-locked_out", + "lock.cat_flap_locked_all": f"{HOUSEHOLD_ID}-{MOCK_CAT_FLAP['id']}-locked_all", + "lock.pet_flap_locked_in": f"{HOUSEHOLD_ID}-{MOCK_PET_FLAP['id']}-locked_in", + "lock.pet_flap_locked_out": f"{HOUSEHOLD_ID}-{MOCK_PET_FLAP['id']}-locked_out", + "lock.pet_flap_locked_all": f"{HOUSEHOLD_ID}-{MOCK_PET_FLAP['id']}-locked_all", +} + + +async def test_locks(hass, surepetcare) -> None: + """Test the generation of unique ids.""" + assert await async_setup_component(hass, DOMAIN, MOCK_CONFIG) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + state_entity_ids = hass.states.async_entity_ids() + + for entity_id, unique_id in EXPECTED_ENTITY_IDS.items(): + surepetcare.reset_mock() + + assert entity_id in state_entity_ids + state = hass.states.get(entity_id) + assert state + assert state.state == "unlocked" + entity = entity_registry.async_get(entity_id) + assert entity.unique_id == unique_id + + await hass.services.async_call( + "lock", "unlock", {"entity_id": entity_id}, blocking=True + ) + state = hass.states.get(entity_id) + assert state.state == "unlocked" + # already unlocked + assert surepetcare.unlock.call_count == 0 + + await hass.services.async_call( + "lock", "lock", {"entity_id": entity_id}, blocking=True + ) + state = hass.states.get(entity_id) + assert state.state == "locked" + if "locked_in" in entity_id: + assert surepetcare.lock_in.call_count == 1 + elif "locked_out" in entity_id: + assert surepetcare.lock_out.call_count == 1 + elif "locked_all" in entity_id: + assert surepetcare.lock.call_count == 1 + + # lock again should not trigger another request + await hass.services.async_call( + "lock", "lock", {"entity_id": entity_id}, blocking=True + ) + state = hass.states.get(entity_id) + assert state.state == "locked" + if "locked_in" in entity_id: + assert surepetcare.lock_in.call_count == 1 + elif "locked_out" in entity_id: + assert surepetcare.lock_out.call_count == 1 + elif "locked_all" in entity_id: + assert surepetcare.lock.call_count == 1 + + await hass.services.async_call( + "lock", "unlock", {"entity_id": entity_id}, blocking=True + ) + state = hass.states.get(entity_id) + assert state.state == "unlocked" + assert surepetcare.unlock.call_count == 1 From dbba2c4afefcce8321e03afcfbbc795fa3ad33ba Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Wed, 29 Sep 2021 12:35:20 -0400 Subject: [PATCH 696/843] Add "Summation Delivered" Sensor for SmartEnergy metering ZHA channel (#56666) --- .../components/zha/core/channels/base.py | 4 + .../zha/core/channels/smartenergy.py | 144 ++++++--- .../components/zha/core/discovery.py | 29 +- .../components/zha/core/registries.py | 30 +- homeassistant/components/zha/entity.py | 40 +++ homeassistant/components/zha/sensor.py | 101 +++++- tests/components/zha/common.py | 17 +- tests/components/zha/test_discover.py | 102 +++--- tests/components/zha/test_registries.py | 71 +++++ tests/components/zha/test_sensor.py | 290 +++++++++++++++++- tests/components/zha/zha_devices_list.py | 60 +++- 11 files changed, 785 insertions(+), 103 deletions(-) diff --git a/homeassistant/components/zha/core/channels/base.py b/homeassistant/components/zha/core/channels/base.py index 17f2693a090..d297b5187c0 100644 --- a/homeassistant/components/zha/core/channels/base.py +++ b/homeassistant/components/zha/core/channels/base.py @@ -148,6 +148,10 @@ class ZigbeeChannel(LogMixin): """Return the status of the channel.""" return self._status + def __hash__(self) -> int: + """Make this a hashable.""" + return hash(self._unique_id) + @callback def async_send_signal(self, signal: str, *args: Any) -> None: """Send a signal through hass dispatcher.""" diff --git a/homeassistant/components/zha/core/channels/smartenergy.py b/homeassistant/components/zha/core/channels/smartenergy.py index 373d7312a4b..f3f0e76a5fb 100644 --- a/homeassistant/components/zha/core/channels/smartenergy.py +++ b/homeassistant/components/zha/core/channels/smartenergy.py @@ -1,19 +1,13 @@ """Smart energy channels module for Zigbee Home Automation.""" from __future__ import annotations +import enum +from functools import partialmethod + from zigpy.zcl.clusters import smartenergy -from homeassistant.const import ( - POWER_WATT, - TIME_HOURS, - TIME_SECONDS, - VOLUME_FLOW_RATE_CUBIC_FEET_PER_MINUTE, - VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR, -) -from homeassistant.core import callback - from .. import registries, typing as zha_typing -from ..const import REPORT_CONFIG_DEFAULT +from ..const import REPORT_CONFIG_ASAP, REPORT_CONFIG_DEFAULT, REPORT_CONFIG_OP from .base import ZigbeeChannel @@ -61,59 +55,101 @@ class Messaging(ZigbeeChannel): class Metering(ZigbeeChannel): """Metering channel.""" - REPORT_CONFIG = ({"attr": "instantaneous_demand", "config": REPORT_CONFIG_DEFAULT},) + REPORT_CONFIG = ( + {"attr": "instantaneous_demand", "config": REPORT_CONFIG_OP}, + {"attr": "current_summ_delivered", "config": REPORT_CONFIG_DEFAULT}, + {"attr": "status", "config": REPORT_CONFIG_ASAP}, + ) ZCL_INIT_ATTRS = { - "divisor": True, - "multiplier": True, - "unit_of_measure": True, "demand_formatting": True, + "divisor": True, + "metering_device_type": True, + "multiplier": True, + "summa_formatting": True, + "unit_of_measure": True, } - unit_of_measure_map = { - 0x00: POWER_WATT, - 0x01: VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR, - 0x02: VOLUME_FLOW_RATE_CUBIC_FEET_PER_MINUTE, - 0x03: f"ccf/{TIME_HOURS}", - 0x04: f"US gal/{TIME_HOURS}", - 0x05: f"IMP gal/{TIME_HOURS}", - 0x06: f"BTU/{TIME_HOURS}", - 0x07: f"l/{TIME_HOURS}", - 0x08: "kPa", - 0x09: "kPa", - 0x0A: f"mcf/{TIME_HOURS}", - 0x0B: "unitless", - 0x0C: f"MJ/{TIME_SECONDS}", + metering_device_type = { + 0: "Electric Metering", + 1: "Gas Metering", + 2: "Water Metering", + 3: "Thermal Metering", + 4: "Pressure Metering", + 5: "Heat Metering", + 6: "Cooling Metering", + 128: "Mirrored Gas Metering", + 129: "Mirrored Water Metering", + 130: "Mirrored Thermal Metering", + 131: "Mirrored Pressure Metering", + 132: "Mirrored Heat Metering", + 133: "Mirrored Cooling Metering", } + class DeviceStatusElectric(enum.IntFlag): + """Metering Device Status.""" + + NO_ALARMS = 0 + CHECK_METER = 1 + LOW_BATTERY = 2 + TAMPER_DETECT = 4 + POWER_FAILURE = 8 + POWER_QUALITY = 16 + LEAK_DETECT = 32 # Really? + SERVICE_DISCONNECT = 64 + RESERVED = 128 + + class DeviceStatusDefault(enum.IntFlag): + """Metering Device Status.""" + + NO_ALARMS = 0 + + class FormatSelector(enum.IntEnum): + """Format specified selector.""" + + DEMAND = 0 + SUMMATION = 1 + def __init__( self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType ) -> None: """Initialize Metering.""" super().__init__(cluster, ch_pool) self._format_spec = None + self._summa_format = None @property def divisor(self) -> int: """Return divisor for the value.""" return self.cluster.get("divisor") or 1 + @property + def device_type(self) -> int | None: + """Return metering device type.""" + dev_type = self.cluster.get("metering_device_type") + if dev_type is None: + return None + return self.metering_device_type.get(dev_type, dev_type) + @property def multiplier(self) -> int: """Return multiplier for the value.""" return self.cluster.get("multiplier") or 1 - @callback - def attribute_updated(self, attrid: int, value: int) -> None: - """Handle attribute update from Metering cluster.""" - if None in (self.multiplier, self.divisor, self._format_spec): - return - super().attribute_updated(attrid, value) + @property + def status(self) -> int | None: + """Return metering device status.""" + status = self.cluster.get("status") + if status is None: + return None + if self.cluster.get("metering_device_type") == 0: + # Electric metering device type + return self.DeviceStatusElectric(status) + return self.DeviceStatusDefault(status) @property def unit_of_measurement(self) -> str: """Return unit of measurement.""" - uom = self.cluster.get("unit_of_measure", 0x7F) - return self.unit_of_measure_map.get(uom & 0x7F, "unknown") + return self.cluster.get("unit_of_measure") async def async_initialize_channel_specific(self, from_cache: bool) -> None: """Fetch config from device and updates format specifier.""" @@ -121,29 +157,49 @@ class Metering(ZigbeeChannel): fmting = self.cluster.get( "demand_formatting", 0xF9 ) # 1 digit to the right, 15 digits to the left + self._format_spec = self.get_formatting(fmting) - r_digits = int(fmting & 0x07) # digits to the right of decimal point - l_digits = (fmting >> 3) & 0x0F # digits to the left of decimal point + fmting = self.cluster.get( + "summa_formatting", 0xF9 + ) # 1 digit to the right, 15 digits to the left + self._summa_format = self.get_formatting(fmting) + + @staticmethod + def get_formatting(formatting: int) -> str: + """Return a formatting string, given the formatting value. + + Bits 0 to 2: Number of Digits to the right of the Decimal Point. + Bits 3 to 6: Number of Digits to the left of the Decimal Point. + Bit 7: If set, suppress leading zeros. + """ + r_digits = int(formatting & 0x07) # digits to the right of decimal point + l_digits = (formatting >> 3) & 0x0F # digits to the left of decimal point if l_digits == 0: l_digits = 15 width = r_digits + l_digits + (1 if r_digits > 0 else 0) - if fmting & 0x80: - self._format_spec = "{:" + str(width) + "." + str(r_digits) + "f}" - else: - self._format_spec = "{:0" + str(width) + "." + str(r_digits) + "f}" + if formatting & 0x80: + # suppress leading 0 + return f"{{:{width}.{r_digits}f}}" - def formatter_function(self, value: int) -> int | float: + return f"{{:0{width}.{r_digits}f}}" + + def _formatter_function(self, selector: FormatSelector, value: int) -> int | float: """Return formatted value for display.""" value = value * self.multiplier / self.divisor - if self.unit_of_measurement == POWER_WATT: + if self.unit_of_measurement == 0: # Zigbee spec power unit is kW, but we show the value in W value_watt = value * 1000 if value_watt < 100: return round(value_watt, 1) return round(value_watt) + if selector == self.FormatSelector.SUMMATION: + return self._summa_format.format(value).lstrip() return self._format_spec.format(value).lstrip() + demand_formatter = partialmethod(_formatter_function, FormatSelector.DEMAND) + summa_formatter = partialmethod(_formatter_function, FormatSelector.SUMMATION) + @registries.ZIGBEE_CHANNEL_REGISTRY.register(smartenergy.Prepayment.cluster_id) class Prepayment(ZigbeeChannel): diff --git a/homeassistant/components/zha/core/discovery.py b/homeassistant/components/zha/core/discovery.py index 49d640c3165..4d70c7aea96 100644 --- a/homeassistant/components/zha/core/discovery.py +++ b/homeassistant/components/zha/core/discovery.py @@ -46,7 +46,8 @@ async def async_add_entities( """Add entities helper.""" if not entities: return - to_add = [ent_cls(*args) for ent_cls, args in entities] + to_add = [ent_cls.create_entity(*args) for ent_cls, args in entities] + to_add = [entity for entity in to_add if entity is not None] _async_add_entities(to_add, update_before_add=update_before_add) entities.clear() @@ -63,6 +64,7 @@ class ProbeEndpoint: """Process an endpoint on a zigpy device.""" self.discover_by_device_type(channel_pool) self.discover_by_cluster_id(channel_pool) + self.discover_multi_entities(channel_pool) @callback def discover_by_device_type(self, channel_pool: zha_typing.ChannelPoolType) -> None: @@ -159,6 +161,31 @@ class ProbeEndpoint: channel = channel_class(cluster, ep_channels) self.probe_single_cluster(component, channel, ep_channels) + @staticmethod + @callback + def discover_multi_entities(channel_pool: zha_typing.ChannelPoolType) -> None: + """Process an endpoint on and discover multiple entities.""" + + remaining_channels = channel_pool.unclaimed_channels() + for channel in remaining_channels: + unique_id = f"{channel_pool.unique_id}-{channel.cluster.cluster_id}" + + matches, claimed = zha_regs.ZHA_ENTITIES.get_multi_entity( + channel_pool.manufacturer, + channel_pool.model, + channel, + remaining_channels, + ) + if not claimed: + continue + + channel_pool.claim_channels(claimed) + for component, ent_classes_list in matches.items(): + for entity_class in ent_classes_list: + channel_pool.async_new_entity( + component, entity_class, unique_id, claimed + ) + def initialize(self, hass: HomeAssistant) -> None: """Update device overrides config.""" zha_config = hass.data[zha_const.DATA_ZHA].get(zha_const.DATA_ZHA_CONFIG, {}) diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py index f7f35e0755d..203867db17d 100644 --- a/homeassistant/components/zha/core/registries.py +++ b/homeassistant/components/zha/core/registries.py @@ -84,7 +84,6 @@ SINGLE_INPUT_CLUSTER_DEVICE_CLASS = { zcl.clusters.measurement.RelativeHumidity.cluster_id: SENSOR, zcl.clusters.measurement.TemperatureMeasurement.cluster_id: SENSOR, zcl.clusters.security.IasZone.cluster_id: BINARY_SENSOR, - zcl.clusters.smartenergy.Metering.cluster_id: SENSOR, } SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS = { @@ -247,7 +246,9 @@ class ZHAEntityRegistry: def __init__(self): """Initialize Registry instance.""" self._strict_registry: RegistryDictType = collections.defaultdict(dict) - self._loose_registry: RegistryDictType = collections.defaultdict(dict) + self._multi_entity_registry: RegistryDictType = collections.defaultdict( + lambda: collections.defaultdict(list) + ) self._group_registry: GroupRegistryDictType = {} def get_entity( @@ -267,6 +268,27 @@ class ZHAEntityRegistry: return default, [] + def get_multi_entity( + self, + manufacturer: str, + model: str, + primary_channel: ChannelType, + aux_channels: list[ChannelType], + components: set | None = None, + ) -> tuple[dict[str, list[CALLABLE_T]], list[ChannelType]]: + """Match ZHA Channels to potentially multiple ZHA Entity classes.""" + result: dict[str, list[CALLABLE_T]] = collections.defaultdict(list) + claimed: set[ChannelType] = set() + for component in components or self._multi_entity_registry: + matches = self._multi_entity_registry[component] + for match in sorted(matches, key=lambda x: x.weight, reverse=True): + if match.strict_matched(manufacturer, model, [primary_channel]): + claimed |= set(match.claim_channels(aux_channels)) + ent_classes = self._multi_entity_registry[component][match] + result[component].extend(ent_classes) + + return result, list(claimed) + def get_group_entity(self, component: str) -> CALLABLE_T: """Match a ZHA group to a ZHA Entity class.""" return self._group_registry.get(component) @@ -296,7 +318,7 @@ class ZHAEntityRegistry: return decorator - def loose_match( + def multipass_match( self, component: str, channel_names: Callable | set[str] | str = None, @@ -316,7 +338,7 @@ class ZHAEntityRegistry: All non empty fields of a match rule must match. """ - self._loose_registry[component][rule] = zha_entity + self._multi_entity_registry[component][rule].append(zha_entity) return zha_entity return decorator diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index 50dd7e16a28..6fd68056025 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -41,12 +41,16 @@ UPDATE_GROUP_FROM_CHILD_DELAY = 0.5 class BaseZhaEntity(LogMixin, entity.Entity): """A base class for ZHA entities.""" + _unique_id_suffix: str | None = None + def __init__(self, unique_id: str, zha_device: ZhaDeviceType, **kwargs) -> None: """Init ZHA entity.""" self._name: str = "" self._force_update: bool = False self._should_poll: bool = False self._unique_id: str = unique_id + if self._unique_id_suffix: + self._unique_id += f"-{self._unique_id_suffix}" self._state: Any = None self._extra_state_attributes: dict[str, Any] = {} self._zha_device: ZhaDeviceType = zha_device @@ -142,6 +146,16 @@ class BaseZhaEntity(LogMixin, entity.Entity): class ZhaEntity(BaseZhaEntity, RestoreEntity): """A base class for non group ZHA entities.""" + def __init_subclass__(cls, id_suffix: str | None = None, **kwargs) -> None: + """Initialize subclass. + + :param id_suffix: suffix to add to the unique_id of the entity. Used for multi + entities using the same channel/cluster id for the entity. + """ + super().__init_subclass__(**kwargs) + if id_suffix: + cls._unique_id_suffix = id_suffix + def __init__( self, unique_id: str, @@ -155,10 +169,26 @@ class ZhaEntity(BaseZhaEntity, RestoreEntity): ch_names = [ch.cluster.ep_attribute for ch in channels] ch_names = ", ".join(sorted(ch_names)) self._name: str = f"{zha_device.name} {ieeetail} {ch_names}" + if self._unique_id_suffix: + self._name += f" {self._unique_id_suffix}" self.cluster_channels: dict[str, ChannelType] = {} for channel in channels: self.cluster_channels[channel.name] = channel + @classmethod + def create_entity( + cls, + unique_id: str, + zha_device: ZhaDeviceType, + channels: list[ChannelType], + **kwargs, + ) -> ZhaEntity | None: + """Entity Factory. + + Return entity if it is a supported configuration, otherwise return None + """ + return cls(unique_id, zha_device, channels, **kwargs) + @property def available(self) -> bool: """Return entity availability.""" @@ -238,6 +268,16 @@ class ZhaGroupEntity(BaseZhaEntity): """Return entity availability.""" return self._available + @classmethod + def create_entity( + cls, entity_ids: list[str], unique_id: str, group_id: int, zha_device, **kwargs + ) -> ZhaGroupEntity | None: + """Group Entity Factory. + + Return entity if it is a supported configuration, otherwise return None + """ + return cls(entity_ids, unique_id, group_id, zha_device, **kwargs) + async def _handle_group_membership_changed(self): """Handle group membership changed.""" # Make sure we don't call remove twice as members are removed diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index cc401cb1e05..342fbd58d89 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -16,17 +16,28 @@ from homeassistant.components.sensor import ( DEVICE_CLASS_TEMPERATURE, DOMAIN, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntity, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, + DEVICE_CLASS_ENERGY, + ENERGY_KILO_WATT_HOUR, LIGHT_LUX, PERCENTAGE, POWER_WATT, PRESSURE_HPA, TEMP_CELSIUS, + TIME_HOURS, + TIME_SECONDS, + VOLUME_CUBIC_FEET, + VOLUME_CUBIC_METERS, + VOLUME_FLOW_RATE_CUBIC_FEET_PER_MINUTE, + VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR, + VOLUME_GALLONS, + VOLUME_LITERS, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -72,6 +83,7 @@ BATTERY_SIZES = { CHANNEL_ST_HUMIDITY_CLUSTER = f"channel_0x{SMARTTHINGS_HUMIDITY_CLUSTER:04x}" STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, DOMAIN) +MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, DOMAIN) async def async_setup_entry( @@ -262,21 +274,100 @@ class Illuminance(Sensor): return round(pow(10, ((value - 1) / 10000)), 1) -@STRICT_MATCH(channel_names=CHANNEL_SMARTENERGY_METERING) +@MULTI_MATCH(channel_names=CHANNEL_SMARTENERGY_METERING) class SmartEnergyMetering(Sensor): """Metering sensor.""" - SENSOR_ATTR = "instantaneous_demand" - _device_class = DEVICE_CLASS_POWER + SENSOR_ATTR: int | str = "instantaneous_demand" + _device_class: str | None = DEVICE_CLASS_POWER + _state_class: str | None = STATE_CLASS_MEASUREMENT + + unit_of_measure_map = { + 0x00: POWER_WATT, + 0x01: VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR, + 0x02: VOLUME_FLOW_RATE_CUBIC_FEET_PER_MINUTE, + 0x03: f"100 {VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR}", + 0x04: f"US {VOLUME_GALLONS}/{TIME_HOURS}", + 0x05: f"IMP {VOLUME_GALLONS}/{TIME_HOURS}", + 0x06: f"BTU/{TIME_HOURS}", + 0x07: f"l/{TIME_HOURS}", + 0x08: "kPa", # gauge + 0x09: "kPa", # absolute + 0x0A: f"1000 {VOLUME_GALLONS}/{TIME_HOURS}", + 0x0B: "unitless", + 0x0C: f"MJ/{TIME_SECONDS}", + } + + @classmethod + def create_entity( + cls, + unique_id: str, + zha_device: ZhaDeviceType, + channels: list[ChannelType], + **kwargs, + ) -> ZhaEntity | None: + """Entity Factory. + + Return entity if it is a supported configuration, otherwise return None + """ + se_channel = channels[0] + if cls.SENSOR_ATTR in se_channel.cluster.unsupported_attributes: + return None + + return cls(unique_id, zha_device, channels, **kwargs) def formatter(self, value: int) -> int | float: """Pass through channel formatter.""" - return self._channel.formatter_function(value) + return self._channel.demand_formatter(value) @property def native_unit_of_measurement(self) -> str: """Return Unit of measurement.""" - return self._channel.unit_of_measurement + return self.unit_of_measure_map.get(self._channel.unit_of_measurement) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return device state attrs for battery sensors.""" + attrs = {} + if self._channel.device_type is not None: + attrs["device_type"] = self._channel.device_type + status = self._channel.status + if status is not None: + attrs["status"] = str(status)[len(status.__class__.__name__) + 1 :] + return attrs + + +@MULTI_MATCH(channel_names=CHANNEL_SMARTENERGY_METERING) +class SmartEnergySummation(SmartEnergyMetering, id_suffix="summation_delivered"): + """Smart Energy Metering summation sensor.""" + + SENSOR_ATTR: int | str = "current_summ_delivered" + _device_class: str | None = DEVICE_CLASS_ENERGY + _state_class: str = STATE_CLASS_TOTAL_INCREASING + + unit_of_measure_map = { + 0x00: ENERGY_KILO_WATT_HOUR, + 0x01: VOLUME_CUBIC_METERS, + 0x02: VOLUME_CUBIC_FEET, + 0x03: f"100 {VOLUME_CUBIC_FEET}", + 0x04: f"US {VOLUME_GALLONS}", + 0x05: f"IMP {VOLUME_GALLONS}", + 0x06: "BTU", + 0x07: VOLUME_LITERS, + 0x08: "kPa", # gauge + 0x09: "kPa", # absolute + 0x0A: f"1000 {VOLUME_CUBIC_FEET}", + 0x0B: "unitless", + 0x0C: "MJ", + } + + def formatter(self, value: int) -> int | float: + """Numeric pass-through formatter.""" + if self._channel.unit_of_measurement != 0: + return self._channel.summa_formatter(value) + + cooked = float(self._channel.multiplier * value) / self._channel.divisor + return round(cooked, 3) @STRICT_MATCH(channel_names=CHANNEL_PRESSURE) diff --git a/tests/components/zha/common.py b/tests/components/zha/common.py index e87962c8d8b..b302869d9e4 100644 --- a/tests/components/zha/common.py +++ b/tests/components/zha/common.py @@ -109,6 +109,18 @@ async def send_attributes_report(hass, cluster: zigpy.zcl.Cluster, attributes: d async def find_entity_id(domain, zha_device, hass): """Find the entity id under the testing. + This is used to get the entity id in order to get the state from the state + machine so that we can test state changes. + """ + entities = await find_entity_ids(domain, zha_device, hass) + if not entities: + return None + return entities[0] + + +async def find_entity_ids(domain, zha_device, hass): + """Find the entity ids under the testing. + This is used to get the entity id in order to get the state from the state machine so that we can test state changes. """ @@ -118,10 +130,11 @@ async def find_entity_id(domain, zha_device, hass): enitiy_ids = hass.states.async_entity_ids(domain) await hass.async_block_till_done() + res = [] for entity_id in enitiy_ids: if entity_id.startswith(head): - return entity_id - return None + res.append(entity_id) + return res def async_find_group_entity_id(hass, domain, group): diff --git a/tests/components/zha/test_discover.py b/tests/components/zha/test_discover.py index 86cdcaa1c60..d765ede0e5f 100644 --- a/tests/components/zha/test_discover.py +++ b/tests/components/zha/test_discover.py @@ -41,6 +41,7 @@ from .zha_devices_list import ( ) NO_TAIL_ID = re.compile("_\\d$") +UNIQUE_ID_HD = re.compile(r"^(([\da-fA-F]{2}:){7}[\da-fA-F]{2}-\d{1,3})", re.X) @pytest.fixture @@ -102,12 +103,6 @@ async def test_devices( finally: zha_channels.ChannelPool.async_new_entity = orig_new_entity - entity_ids = hass_disable_services.states.async_entity_ids() - await hass_disable_services.async_block_till_done() - zha_entity_ids = { - ent for ent in entity_ids if ent.split(".")[0] in zha_const.PLATFORMS - } - if cluster_identify: called = int(zha_device_joined_restored.name == "zha_device_joined") assert cluster_identify.request.call_count == called @@ -128,26 +123,49 @@ async def test_devices( event_channels = { ch.id for pool in zha_dev.channels.pools for ch in pool.client_channels.values() } - - entity_map = device[DEV_SIG_ENT_MAP] - assert zha_entity_ids == { - e[DEV_SIG_ENT_MAP_ID] - for e in entity_map.values() - if not e.get("default_match", False) - } assert event_channels == set(device[DEV_SIG_EVT_CHANNELS]) + # build a dict of entity_class -> (component, unique_id, channels) tuple + ha_ent_info = {} for call in _dispatch.call_args_list: _, component, entity_cls, unique_id, channels = call[0] - key = (component, unique_id) - entity_id = entity_registry.async_get_entity_id(component, "zha", unique_id) + unique_id_head = UNIQUE_ID_HD.match(unique_id).group(0) # ieee + endpoint_id + ha_ent_info[(unique_id_head, entity_cls.__name__)] = ( + component, + unique_id, + channels, + ) - assert key in entity_map - assert entity_id is not None - no_tail_id = NO_TAIL_ID.sub("", entity_map[key][DEV_SIG_ENT_MAP_ID]) - assert entity_id.startswith(no_tail_id) - assert {ch.name for ch in channels} == set(entity_map[key][DEV_SIG_CHANNELS]) - assert entity_cls.__name__ == entity_map[key][DEV_SIG_ENT_MAP_CLASS] + for comp_id, ent_info in device[DEV_SIG_ENT_MAP].items(): + component, unique_id = comp_id + no_tail_id = NO_TAIL_ID.sub("", ent_info[DEV_SIG_ENT_MAP_ID]) + ha_entity_id = entity_registry.async_get_entity_id(component, "zha", unique_id) + assert ha_entity_id is not None + assert ha_entity_id.startswith(no_tail_id) + + test_ent_class = ent_info[DEV_SIG_ENT_MAP_CLASS] + test_unique_id_head = UNIQUE_ID_HD.match(unique_id).group(0) + assert (test_unique_id_head, test_ent_class) in ha_ent_info + + ha_comp, ha_unique_id, ha_channels = ha_ent_info[ + (test_unique_id_head, test_ent_class) + ] + assert component is ha_comp + # unique_id used for discover is the same for "multi entities" + assert unique_id.startswith(ha_unique_id) + assert {ch.name for ch in ha_channels} == set(ent_info[DEV_SIG_CHANNELS]) + + assert _dispatch.call_count == len(device[DEV_SIG_ENT_MAP]) + + entity_ids = hass_disable_services.states.async_entity_ids() + await hass_disable_services.async_block_till_done() + + zha_entity_ids = { + ent for ent in entity_ids if ent.split(".")[0] in zha_const.PLATFORMS + } + assert zha_entity_ids == { + e[DEV_SIG_ENT_MAP_ID] for e in device[DEV_SIG_ENT_MAP].values() + } def _get_first_identify_cluster(zigpy_device): @@ -279,21 +297,35 @@ async def test_discover_endpoint(device_info, channels_mock, hass): assert device_info[DEV_SIG_EVT_CHANNELS] == sorted( ch.id for pool in channels.pools for ch in pool.client_channels.values() ) - assert new_ent.call_count == len( - [ - device_info - for device_info in device_info[DEV_SIG_ENT_MAP].values() - if not device_info.get("default_match", False) - ] - ) + assert new_ent.call_count == len(list(device_info[DEV_SIG_ENT_MAP].values())) - for call_args in new_ent.call_args_list: - comp, ent_cls, unique_id, channels = call_args[0] - map_id = (comp, unique_id) - assert map_id in device_info[DEV_SIG_ENT_MAP] - entity_info = device_info[DEV_SIG_ENT_MAP][map_id] - assert {ch.name for ch in channels} == set(entity_info[DEV_SIG_CHANNELS]) - assert ent_cls.__name__ == entity_info[DEV_SIG_ENT_MAP_CLASS] + # build a dict of entity_class -> (component, unique_id, channels) tuple + ha_ent_info = {} + for call in new_ent.call_args_list: + component, entity_cls, unique_id, channels = call[0] + unique_id_head = UNIQUE_ID_HD.match(unique_id).group(0) # ieee + endpoint_id + ha_ent_info[(unique_id_head, entity_cls.__name__)] = ( + component, + unique_id, + channels, + ) + + for comp_id, ent_info in device_info[DEV_SIG_ENT_MAP].items(): + component, unique_id = comp_id + + test_ent_class = ent_info[DEV_SIG_ENT_MAP_CLASS] + test_unique_id_head = UNIQUE_ID_HD.match(unique_id).group(0) + assert (test_unique_id_head, test_ent_class) in ha_ent_info + + ha_comp, ha_unique_id, ha_channels = ha_ent_info[ + (test_unique_id_head, test_ent_class) + ] + assert component is ha_comp + # unique_id used for discover is the same for "multi entities" + assert unique_id.startswith(ha_unique_id) + assert {ch.name for ch in ha_channels} == set(ent_info[DEV_SIG_CHANNELS]) + + assert new_ent.call_count == len(device_info[DEV_SIG_ENT_MAP]) def _ch_mock(cluster): diff --git a/tests/components/zha/test_registries.py b/tests/components/zha/test_registries.py index b0f1a44a3d6..d202c7256dd 100644 --- a/tests/components/zha/test_registries.py +++ b/tests/components/zha/test_registries.py @@ -314,3 +314,74 @@ def test_weighted_match(channel, entity_registry, manufacturer, model, match_nam assert match.__name__ == match_name assert claimed == [ch_on_off] + + +def test_multi_sensor_match(channel, entity_registry): + """Test multi-entity match.""" + + s = mock.sentinel + + @entity_registry.multipass_match( + s.binary_sensor, + channel_names="smartenergy_metering", + ) + class SmartEnergySensor2: + pass + + ch_se = channel("smartenergy_metering", 0x0702) + ch_illuminati = channel("illuminance", 0x0401) + + match, claimed = entity_registry.get_multi_entity( + "manufacturer", + "model", + primary_channel=ch_illuminati, + aux_channels=[ch_se, ch_illuminati], + ) + + assert s.binary_sensor not in match + assert s.component not in match + assert set(claimed) == set() + + match, claimed = entity_registry.get_multi_entity( + "manufacturer", + "model", + primary_channel=ch_se, + aux_channels=[ch_se, ch_illuminati], + ) + + assert s.binary_sensor in match + assert s.component not in match + assert set(claimed) == {ch_se} + assert {cls.__name__ for cls in match[s.binary_sensor]} == { + SmartEnergySensor2.__name__ + } + + @entity_registry.multipass_match( + s.component, channel_names="smartenergy_metering", aux_channels="illuminance" + ) + class SmartEnergySensor1: + pass + + @entity_registry.multipass_match( + s.binary_sensor, + channel_names="smartenergy_metering", + aux_channels="illuminance", + ) + class SmartEnergySensor3: + pass + + match, claimed = entity_registry.get_multi_entity( + "manufacturer", + "model", + primary_channel=ch_se, + aux_channels={ch_se, ch_illuminati}, + ) + + assert s.binary_sensor in match + assert s.component in match + assert set(claimed) == {ch_se, ch_illuminati} + assert {cls.__name__ for cls in match[s.binary_sensor]} == { + SmartEnergySensor2.__name__, + SmartEnergySensor3.__name__, + } + assert {cls.__name__ for cls in match[s.component]} == {SmartEnergySensor1.__name__} diff --git a/tests/components/zha/test_sensor.py b/tests/components/zha/test_sensor.py index ddccb5117d5..21731da72e6 100644 --- a/tests/components/zha/test_sensor.py +++ b/tests/components/zha/test_sensor.py @@ -11,10 +11,13 @@ import zigpy.zcl.clusters.smartenergy as smartenergy from homeassistant.components.sensor import DOMAIN import homeassistant.config as config_util from homeassistant.const import ( + ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT, CONF_UNIT_SYSTEM, CONF_UNIT_SYSTEM_IMPERIAL, CONF_UNIT_SYSTEM_METRIC, + DEVICE_CLASS_ENERGY, + ENERGY_KILO_WATT_HOUR, LIGHT_LUX, PERCENTAGE, POWER_WATT, @@ -23,6 +26,8 @@ from homeassistant.const import ( STATE_UNKNOWN, TEMP_CELSIUS, TEMP_FAHRENHEIT, + VOLUME_CUBIC_FEET, + VOLUME_CUBIC_METERS, ) from homeassistant.helpers import restore_state from homeassistant.util import dt as dt_util @@ -31,11 +36,14 @@ from .common import ( async_enable_traffic, async_test_rejoin, find_entity_id, + find_entity_ids, send_attribute_report, send_attributes_report, ) from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE +ENTITY_ID_PREFIX = "sensor.fakemanufacturer_fakemodel_e769900a_{}" + async def async_test_humidity(hass, cluster, entity_id): """Test humidity sensor.""" @@ -65,9 +73,38 @@ async def async_test_illuminance(hass, cluster, entity_id): async def async_test_metering(hass, cluster, entity_id): - """Test metering sensor.""" + """Test Smart Energy metering sensor.""" await send_attributes_report(hass, cluster, {1025: 1, 1024: 12345, 1026: 100}) - assert_state(hass, entity_id, "12345.0", "unknown") + assert_state(hass, entity_id, "12345.0", None) + assert hass.states.get(entity_id).attributes["status"] == "NO_ALARMS" + assert hass.states.get(entity_id).attributes["device_type"] == "Electric Metering" + + await send_attributes_report(hass, cluster, {1024: 12346, "status": 64 + 8}) + assert_state(hass, entity_id, "12346.0", None) + assert ( + hass.states.get(entity_id).attributes["status"] + == "SERVICE_DISCONNECT|POWER_FAILURE" + ) + + await send_attributes_report( + hass, cluster, {"status": 32, "metering_device_type": 1} + ) + # currently only statuses for electric meters are supported + assert hass.states.get(entity_id).attributes["status"] == "" + + +async def async_test_smart_energy_summation(hass, cluster, entity_id): + """Test SmartEnergy Summation delivered sensro.""" + + await send_attributes_report( + hass, cluster, {1025: 1, "current_summ_delivered": 12321, 1026: 100} + ) + assert_state(hass, entity_id, "12.32", VOLUME_CUBIC_METERS) + assert hass.states.get(entity_id).attributes["status"] == "NO_ALARMS" + assert hass.states.get(entity_id).attributes["device_type"] == "Electric Metering" + assert ( + hass.states.get(entity_id).attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_ENERGY + ) async def async_test_electrical_measurement(hass, cluster, entity_id): @@ -106,40 +143,81 @@ async def async_test_powerconfiguration(hass, cluster, entity_id): @pytest.mark.parametrize( - "cluster_id, test_func, report_count, read_plug", + "cluster_id, entity_suffix, test_func, report_count, read_plug, unsupported_attrs", ( - (measurement.RelativeHumidity.cluster_id, async_test_humidity, 1, None), + ( + measurement.RelativeHumidity.cluster_id, + "humidity", + async_test_humidity, + 1, + None, + None, + ), ( measurement.TemperatureMeasurement.cluster_id, + "temperature", async_test_temperature, 1, None, + None, + ), + ( + measurement.PressureMeasurement.cluster_id, + "pressure", + async_test_pressure, + 1, + None, + None, ), - (measurement.PressureMeasurement.cluster_id, async_test_pressure, 1, None), ( measurement.IlluminanceMeasurement.cluster_id, + "illuminance", async_test_illuminance, 1, None, + None, ), ( smartenergy.Metering.cluster_id, + "smartenergy_metering", async_test_metering, 1, { "demand_formatting": 0xF9, "divisor": 1, + "metering_device_type": 0x00, "multiplier": 1, + "status": 0x00, }, + {"current_summ_delivered"}, + ), + ( + smartenergy.Metering.cluster_id, + "smartenergy_metering_summation_delivered", + async_test_smart_energy_summation, + 1, + { + "demand_formatting": 0xF9, + "divisor": 1000, + "metering_device_type": 0x00, + "multiplier": 1, + "status": 0x00, + "summa_formatting": 0b1_0111_010, + "unit_of_measure": 0x01, + }, + {"instaneneous_demand"}, ), ( homeautomation.ElectricalMeasurement.cluster_id, + "electrical_measurement", async_test_electrical_measurement, 1, None, + None, ), ( general.PowerConfiguration.cluster_id, + "power", async_test_powerconfiguration, 2, { @@ -147,6 +225,7 @@ async def async_test_powerconfiguration(hass, cluster, entity_id): "battery_voltage": 29, "battery_quantity": 3, }, + None, ), ), ) @@ -155,9 +234,11 @@ async def test_sensor( zigpy_device_mock, zha_device_joined_restored, cluster_id, + entity_suffix, test_func, report_count, read_plug, + unsupported_attrs, ): """Test zha sensor platform.""" @@ -171,12 +252,15 @@ async def test_sensor( } ) cluster = zigpy_device.endpoints[1].in_clusters[cluster_id] + if unsupported_attrs: + for attr in unsupported_attrs: + cluster.add_unsupported_attribute(attr) if cluster_id == smartenergy.Metering.cluster_id: # this one is mains powered zigpy_device.node_desc.mac_capability_flags |= 0b_0000_0100 cluster.PLUGGED_ATTR_READS = read_plug zha_device = await zha_device_joined_restored(zigpy_device) - entity_id = await find_entity_id(DOMAIN, zha_device, hass) + entity_id = ENTITY_ID_PREFIX.format(entity_suffix) await async_enable_traffic(hass, [zha_device], enabled=False) await hass.async_block_till_done() @@ -372,3 +456,197 @@ async def test_electrical_measurement_init( assert channel.divisor == 10 assert channel.multiplier == 20 assert hass.states.get(entity_id).state == "60.0" + + +@pytest.mark.parametrize( + "cluster_id, unsupported_attributes, entity_ids, missing_entity_ids", + ( + ( + smartenergy.Metering.cluster_id, + { + "instantaneous_demand", + }, + { + "smartenergy_metering_summation_delivered", + }, + { + "smartenergy_metering", + }, + ), + ( + smartenergy.Metering.cluster_id, + {"instantaneous_demand", "current_summ_delivered"}, + {}, + { + "smartenergy_metering_summation_delivered", + "smartenergy_metering", + }, + ), + ( + smartenergy.Metering.cluster_id, + {}, + { + "smartenergy_metering_summation_delivered", + "smartenergy_metering", + }, + {}, + ), + ), +) +async def test_unsupported_attributes_sensor( + hass, + zigpy_device_mock, + zha_device_joined_restored, + cluster_id, + unsupported_attributes, + entity_ids, + missing_entity_ids, +): + """Test zha sensor platform.""" + + entity_ids = {ENTITY_ID_PREFIX.format(e) for e in entity_ids} + missing_entity_ids = {ENTITY_ID_PREFIX.format(e) for e in missing_entity_ids} + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [cluster_id, general.Basic.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, + } + } + ) + cluster = zigpy_device.endpoints[1].in_clusters[cluster_id] + if cluster_id == smartenergy.Metering.cluster_id: + # this one is mains powered + zigpy_device.node_desc.mac_capability_flags |= 0b_0000_0100 + for attr in unsupported_attributes: + cluster.add_unsupported_attribute(attr) + zha_device = await zha_device_joined_restored(zigpy_device) + + await async_enable_traffic(hass, [zha_device], enabled=False) + await hass.async_block_till_done() + present_entity_ids = set(await find_entity_ids(DOMAIN, zha_device, hass)) + assert present_entity_ids == entity_ids + assert missing_entity_ids not in present_entity_ids + + +@pytest.mark.parametrize( + "raw_uom, raw_value, expected_state, expected_uom", + ( + ( + 1, + 12320, + "1.23", + VOLUME_CUBIC_METERS, + ), + ( + 1, + 1232000, + "123.20", + VOLUME_CUBIC_METERS, + ), + ( + 3, + 2340, + "0.23", + f"100 {VOLUME_CUBIC_FEET}", + ), + ( + 3, + 2360, + "0.24", + f"100 {VOLUME_CUBIC_FEET}", + ), + ( + 8, + 23660, + "2.37", + "kPa", + ), + ( + 0, + 9366, + "0.937", + ENERGY_KILO_WATT_HOUR, + ), + ( + 0, + 999, + "0.1", + ENERGY_KILO_WATT_HOUR, + ), + ( + 0, + 10091, + "1.009", + ENERGY_KILO_WATT_HOUR, + ), + ( + 0, + 10099, + "1.01", + ENERGY_KILO_WATT_HOUR, + ), + ( + 0, + 100999, + "10.1", + ENERGY_KILO_WATT_HOUR, + ), + ( + 0, + 100023, + "10.002", + ENERGY_KILO_WATT_HOUR, + ), + ( + 0, + 102456, + "10.246", + ENERGY_KILO_WATT_HOUR, + ), + ), +) +async def test_se_summation_uom( + hass, + zigpy_device_mock, + zha_device_joined, + raw_uom, + raw_value, + expected_state, + expected_uom, +): + """Test zha smart energy summation.""" + + entity_id = ENTITY_ID_PREFIX.format("smartenergy_metering_summation_delivered") + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [ + smartenergy.Metering.cluster_id, + general.Basic.cluster_id, + ], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.SIMPLE_SENSOR, + } + } + ) + zigpy_device.node_desc.mac_capability_flags |= 0b_0000_0100 + + cluster = zigpy_device.endpoints[1].in_clusters[smartenergy.Metering.cluster_id] + for attr in ("instanteneous_demand",): + cluster.add_unsupported_attribute(attr) + cluster.PLUGGED_ATTR_READS = { + "current_summ_delivered": raw_value, + "demand_formatting": 0xF9, + "divisor": 10000, + "metering_device_type": 0x00, + "multiplier": 1, + "status": 0x00, + "summa_formatting": 0b1_0111_010, + "unit_of_measure": raw_uom, + } + await zha_device_joined(zigpy_device) + + assert_state(hass, entity_id, expected_state, expected_uom) diff --git a/tests/components/zha/zha_devices_list.py b/tests/components/zha/zha_devices_list.py index 30780bcaa86..531e9649ec3 100644 --- a/tests/components/zha/zha_devices_list.py +++ b/tests/components/zha/zha_devices_list.py @@ -118,6 +118,7 @@ DEVICES = [ DEV_SIG_ENTITIES: [ "sensor.centralite_3210_l_77665544_electrical_measurement", "sensor.centralite_3210_l_77665544_smartenergy_metering", + "sensor.centralite_3210_l_77665544_smartenergy_metering_summation_delivered", "switch.centralite_3210_l_77665544_on_off", ], DEV_SIG_ENT_MAP: { @@ -131,6 +132,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_77665544_smartenergy_metering", }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { + DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_77665544_smartenergy_metering_summation_delivered", + }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", @@ -391,6 +397,7 @@ DEVICES = [ }, DEV_SIG_ENTITIES: [ "sensor.climaxtechnology_psmp5_00_00_02_02tc_77665544_smartenergy_metering", + "sensor.climaxtechnology_psmp5_00_00_02_02tc_77665544_smartenergy_metering_summation_delivered", "switch.climaxtechnology_psmp5_00_00_02_02tc_77665544_on_off", ], DEV_SIG_ENT_MAP: { @@ -404,6 +411,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", DEV_SIG_ENT_MAP_ID: "sensor.climaxtechnology_psmp5_00_00_02_02tc_77665544_smartenergy_metering", }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { + DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", + DEV_SIG_ENT_MAP_ID: "sensor.climaxtechnology_psmp5_00_00_02_02tc_77665544_smartenergy_metering_summation_delivered", + }, }, DEV_SIG_EVT_CHANNELS: ["4:0x0019"], SIG_MANUFACTURER: "ClimaxTechnology", @@ -943,6 +955,7 @@ DEVICES = [ DEV_SIG_ENTITIES: [ "light.jasco_products_45852_77665544_level_on_off", "sensor.jasco_products_45852_77665544_smartenergy_metering", + "sensor.jasco_products_45852_77665544_smartenergy_metering_summation_delivered", ], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { @@ -955,6 +968,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45852_77665544_smartenergy_metering", }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { + DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", + DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45852_77665544_smartenergy_metering_summation_delivered", + }, }, DEV_SIG_EVT_CHANNELS: ["1:0x0019", "2:0x0006", "2:0x0008"], SIG_MANUFACTURER: "Jasco Products", @@ -982,6 +1000,7 @@ DEVICES = [ DEV_SIG_ENTITIES: [ "light.jasco_products_45856_77665544_on_off", "sensor.jasco_products_45856_77665544_smartenergy_metering", + "sensor.jasco_products_45856_77665544_smartenergy_metering_summation_delivered", ], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { @@ -994,6 +1013,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45856_77665544_smartenergy_metering", }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { + DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", + DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45856_77665544_smartenergy_metering_summation_delivered", + }, }, DEV_SIG_EVT_CHANNELS: ["1:0x0019", "2:0x0006"], SIG_MANUFACTURER: "Jasco Products", @@ -1021,6 +1045,7 @@ DEVICES = [ DEV_SIG_ENTITIES: [ "light.jasco_products_45857_77665544_level_on_off", "sensor.jasco_products_45857_77665544_smartenergy_metering", + "sensor.jasco_products_45857_77665544_smartenergy_metering_summation_delivered", ], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { @@ -1033,6 +1058,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45857_77665544_smartenergy_metering", }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { + DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", + DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45857_77665544_smartenergy_metering_summation_delivered", + }, }, DEV_SIG_EVT_CHANNELS: ["1:0x0019", "2:0x0006", "2:0x0008"], SIG_MANUFACTURER: "Jasco Products", @@ -2782,12 +2812,6 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "IASZone", DEV_SIG_ENT_MAP_ID: "binary_sensor.samjin_multi_77665544_ias_zone", }, - ("binary_sensor", "00:11:22:33:44:55:66:77-1-64514"): { - DEV_SIG_CHANNELS: ["manufacturer_specific"], - DEV_SIG_ENT_MAP_CLASS: "BinaryInput", - DEV_SIG_ENT_MAP_ID: "binary_sensor.samjin_multi_77665544_manufacturer_specific", - "default_match": True, - }, }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], SIG_MANUFACTURER: "Samjin", @@ -2925,6 +2949,7 @@ DEVICES = [ "light.sercomm_corp_sz_esw01_77665544_on_off", "sensor.sercomm_corp_sz_esw01_77665544_electrical_measurement", "sensor.sercomm_corp_sz_esw01_77665544_smartenergy_metering", + "sensor.sercomm_corp_sz_esw01_77665544_smartenergy_metering_summation_delivered", ], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { @@ -2937,6 +2962,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_77665544_smartenergy_metering", }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { + DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_77665544_smartenergy_metering_summation_delivered", + }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", @@ -3423,6 +3453,7 @@ DEVICES = [ DEV_SIG_ENTITIES: [ "light.sengled_e11_g13_77665544_level_on_off", "sensor.sengled_e11_g13_77665544_smartenergy_metering", + "sensor.sengled_e11_g13_77665544_smartenergy_metering_summation_delivered", ], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { @@ -3435,6 +3466,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", DEV_SIG_ENT_MAP_ID: "sensor.sengled_e11_g13_77665544_smartenergy_metering", }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { + DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", + DEV_SIG_ENT_MAP_ID: "sensor.sengled_e11_g13_77665544_smartenergy_metering_summation_delivered", + }, }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], SIG_MANUFACTURER: "sengled", @@ -3455,6 +3491,7 @@ DEVICES = [ DEV_SIG_ENTITIES: [ "light.sengled_e12_n14_77665544_level_on_off", "sensor.sengled_e12_n14_77665544_smartenergy_metering", + "sensor.sengled_e12_n14_77665544_smartenergy_metering_sumaiton_delivered", ], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { @@ -3467,6 +3504,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", DEV_SIG_ENT_MAP_ID: "sensor.sengled_e12_n14_77665544_smartenergy_metering", }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { + DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", + DEV_SIG_ENT_MAP_ID: "sensor.sengled_e12_n14_77665544_smartenergy_metering_summation_delivered", + }, }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], SIG_MANUFACTURER: "sengled", @@ -3487,6 +3529,7 @@ DEVICES = [ DEV_SIG_ENTITIES: [ "light.sengled_z01_a19nae26_77665544_level_light_color_on_off", "sensor.sengled_z01_a19nae26_77665544_smartenergy_metering", + "sensor.sengled_z01_a19nae26_77665544_smartenergy_metering_summation_delivered", ], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { @@ -3499,6 +3542,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", DEV_SIG_ENT_MAP_ID: "sensor.sengled_z01_a19nae26_77665544_smartenergy_metering", }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { + DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", + DEV_SIG_ENT_MAP_ID: "sensor.sengled_z01_a19nae26_77665544_smartenergy_metering_summation_delivered", + }, }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], SIG_MANUFACTURER: "sengled", From f7ef973c680c2b19942e11b105cec10658465843 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Sep 2021 12:18:13 -0500 Subject: [PATCH 697/843] Bump aiodiscover to 1.4.4 to fix mac matching with leading 0s (#56791) --- homeassistant/components/dhcp/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/dhcp/manifest.json b/homeassistant/components/dhcp/manifest.json index 3cf03c09d3f..8ec6bf855c8 100644 --- a/homeassistant/components/dhcp/manifest.json +++ b/homeassistant/components/dhcp/manifest.json @@ -2,7 +2,7 @@ "domain": "dhcp", "name": "DHCP Discovery", "documentation": "https://www.home-assistant.io/integrations/dhcp", - "requirements": ["scapy==2.4.5", "aiodiscover==1.4.2"], + "requirements": ["scapy==2.4.5", "aiodiscover==1.4.4"], "codeowners": ["@bdraco"], "quality_scale": "internal", "iot_class": "local_push" diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 13867fb7afe..ba6fa1e587c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,6 +1,6 @@ PyJWT==2.1.0 PyNaCl==1.4.0 -aiodiscover==1.4.2 +aiodiscover==1.4.4 aiohttp==3.7.4.post0 aiohttp_cors==0.7.0 astral==2.2 diff --git a/requirements_all.txt b/requirements_all.txt index 0c0debd867f..b9ca097c02a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -148,7 +148,7 @@ aioazuredevops==1.3.5 aiobotocore==1.2.2 # homeassistant.components.dhcp -aiodiscover==1.4.2 +aiodiscover==1.4.4 # homeassistant.components.dnsip # homeassistant.components.minecraft_server diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8464ee19383..1e78ff6d382 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -93,7 +93,7 @@ aioazuredevops==1.3.5 aiobotocore==1.2.2 # homeassistant.components.dhcp -aiodiscover==1.4.2 +aiodiscover==1.4.4 # homeassistant.components.dnsip # homeassistant.components.minecraft_server From ae00c221e005cd2fb835418504b0de01db55fa67 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Wed, 29 Sep 2021 12:06:48 -0600 Subject: [PATCH 698/843] Add long-term statistics for Guardian sensors (#55413) * Add long-term statistics for Guardian sensors * Code review --- homeassistant/components/guardian/sensor.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/guardian/sensor.py b/homeassistant/components/guardian/sensor.py index fb7952669cc..16b05e20767 100644 --- a/homeassistant/components/guardian/sensor.py +++ b/homeassistant/components/guardian/sensor.py @@ -1,7 +1,11 @@ """Sensors for the Elexa Guardian integration.""" from __future__ import annotations -from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( DEVICE_CLASS_BATTERY, @@ -41,6 +45,7 @@ SENSOR_DESCRIPTION_TEMPERATURE = SensorEntityDescription( name="Temperature", device_class=DEVICE_CLASS_TEMPERATURE, native_unit_of_measurement=TEMP_FAHRENHEIT, + state_class=STATE_CLASS_MEASUREMENT, ) SENSOR_DESCRIPTION_UPTIME = SensorEntityDescription( key=SENSOR_KIND_UPTIME, From ef13e473cfcc738933dd312019946b752168c17f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 29 Sep 2021 20:16:02 +0200 Subject: [PATCH 699/843] Warn if template functions fail and no default is specified (#56453) Co-authored-by: Martin Hjelmare Co-authored-by: Paulus Schoutsen --- homeassistant/helpers/template.py | 226 ++++++++++++++++++++++-------- tests/helpers/test_template.py | 118 +++++++++++++++- 2 files changed, 280 insertions(+), 64 deletions(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 831400feaf9..019b3aaf5fb 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -6,7 +6,7 @@ import asyncio import base64 import collections.abc from collections.abc import Callable, Generator, Iterable -from contextlib import suppress +from contextlib import contextmanager, suppress from contextvars import ContextVar from datetime import datetime, timedelta from functools import partial, wraps @@ -88,7 +88,9 @@ _COLLECTABLE_STATE_ATTRIBUTES = { ALL_STATES_RATE_LIMIT = timedelta(minutes=1) DOMAIN_STATES_RATE_LIMIT = timedelta(seconds=1) -template_cv: ContextVar[str | None] = ContextVar("template_cv", default=None) +template_cv: ContextVar[tuple[str, str] | None] = ContextVar( + "template_cv", default=None +) @bind_hass @@ -336,13 +338,14 @@ class Template: def ensure_valid(self) -> None: """Return if template is valid.""" - if self.is_static or self._compiled_code is not None: - return + with set_template(self.template, "compiling"): + if self.is_static or self._compiled_code is not None: + return - try: - self._compiled_code = self._env.compile(self.template) # type: ignore[no-untyped-call] - except jinja2.TemplateError as err: - raise TemplateError(err) from err + try: + self._compiled_code = self._env.compile(self.template) # type: ignore[no-untyped-call] + except jinja2.TemplateError as err: + raise TemplateError(err) from err def render( self, @@ -1201,8 +1204,26 @@ def utcnow(hass: HomeAssistant) -> datetime: return dt_util.utcnow() -def forgiving_round(value, precision=0, method="common"): - """Round accepted strings.""" +def warn_no_default(function, value, default): + """Log warning if no default is specified.""" + template, action = template_cv.get() or ("", "rendering or compiling") + _LOGGER.warning( + ( + "Template warning: '%s' got invalid input '%s' when %s template '%s' " + "but no default was specified. Currently '%s' will return '%s', however this template will fail " + "to render in Home Assistant core 2021.12" + ), + function, + value, + action, + template, + function, + default, + ) + + +def forgiving_round(value, precision=0, method="common", default=_SENTINEL): + """Filter to round a value.""" try: # support rounding methods like jinja multiplier = float(10 ** precision) @@ -1218,94 +1239,137 @@ def forgiving_round(value, precision=0, method="common"): return int(value) if precision == 0 else value except (ValueError, TypeError): # If value can't be converted to float - return value + if default is _SENTINEL: + warn_no_default("round", value, value) + return value + return default -def multiply(value, amount): +def multiply(value, amount, default=_SENTINEL): """Filter to convert value to float and multiply it.""" try: return float(value) * amount except (ValueError, TypeError): # If value can't be converted to float - return value + if default is _SENTINEL: + warn_no_default("multiply", value, value) + return value + return default -def logarithm(value, base=math.e): - """Filter to get logarithm of the value with a specific base.""" +def logarithm(value, base=math.e, default=_SENTINEL): + """Filter and function to get logarithm of the value with a specific base.""" try: return math.log(float(value), float(base)) except (ValueError, TypeError): - return value + if default is _SENTINEL: + warn_no_default("log", value, value) + return value + return default -def sine(value): - """Filter to get sine of the value.""" +def sine(value, default=_SENTINEL): + """Filter and function to get sine of the value.""" try: return math.sin(float(value)) except (ValueError, TypeError): - return value + if default is _SENTINEL: + warn_no_default("sin", value, value) + return value + return default -def cosine(value): - """Filter to get cosine of the value.""" +def cosine(value, default=_SENTINEL): + """Filter and function to get cosine of the value.""" try: return math.cos(float(value)) except (ValueError, TypeError): - return value + if default is _SENTINEL: + warn_no_default("cos", value, value) + return value + return default -def tangent(value): - """Filter to get tangent of the value.""" +def tangent(value, default=_SENTINEL): + """Filter and function to get tangent of the value.""" try: return math.tan(float(value)) except (ValueError, TypeError): - return value + if default is _SENTINEL: + warn_no_default("tan", value, value) + return value + return default -def arc_sine(value): - """Filter to get arc sine of the value.""" +def arc_sine(value, default=_SENTINEL): + """Filter and function to get arc sine of the value.""" try: return math.asin(float(value)) except (ValueError, TypeError): - return value + if default is _SENTINEL: + warn_no_default("asin", value, value) + return value + return default -def arc_cosine(value): - """Filter to get arc cosine of the value.""" +def arc_cosine(value, default=_SENTINEL): + """Filter and function to get arc cosine of the value.""" try: return math.acos(float(value)) except (ValueError, TypeError): - return value + if default is _SENTINEL: + warn_no_default("acos", value, value) + return value + return default -def arc_tangent(value): - """Filter to get arc tangent of the value.""" +def arc_tangent(value, default=_SENTINEL): + """Filter and function to get arc tangent of the value.""" try: return math.atan(float(value)) except (ValueError, TypeError): - return value + if default is _SENTINEL: + warn_no_default("atan", value, value) + return value + return default -def arc_tangent2(*args): - """Filter to calculate four quadrant arc tangent of y / x.""" +def arc_tangent2(*args, default=_SENTINEL): + """Filter and function to calculate four quadrant arc tangent of y / x. + + The parameters to atan2 may be passed either in an iterable or as separate arguments + The default value may be passed either as a positional or in a keyword argument + """ try: - if len(args) == 1 and isinstance(args[0], (list, tuple)): + if 1 <= len(args) <= 2 and isinstance(args[0], (list, tuple)): + if len(args) == 2 and default is _SENTINEL: + # Default value passed as a positional argument + default = args[1] args = args[0] + elif len(args) == 3 and default is _SENTINEL: + # Default value passed as a positional argument + default = args[2] return math.atan2(float(args[0]), float(args[1])) except (ValueError, TypeError): - return args + if default is _SENTINEL: + warn_no_default("atan2", args, args) + return args + return default -def square_root(value): - """Filter to get square root of the value.""" +def square_root(value, default=_SENTINEL): + """Filter and function to get square root of the value.""" try: return math.sqrt(float(value)) except (ValueError, TypeError): - return value + if default is _SENTINEL: + warn_no_default("sqrt", value, value) + return value + return default -def timestamp_custom(value, date_format=DATE_STR_FORMAT, local=True): +def timestamp_custom(value, date_format=DATE_STR_FORMAT, local=True, default=_SENTINEL): """Filter to convert given timestamp to format.""" try: date = dt_util.utc_from_timestamp(value) @@ -1316,10 +1380,13 @@ def timestamp_custom(value, date_format=DATE_STR_FORMAT, local=True): return date.strftime(date_format) except (ValueError, TypeError): # If timestamp can't be converted - return value + if default is _SENTINEL: + warn_no_default("timestamp_custom", value, value) + return value + return default -def timestamp_local(value): +def timestamp_local(value, default=_SENTINEL): """Filter to convert given timestamp to local date/time.""" try: return dt_util.as_local(dt_util.utc_from_timestamp(value)).strftime( @@ -1327,32 +1394,44 @@ def timestamp_local(value): ) except (ValueError, TypeError): # If timestamp can't be converted - return value + if default is _SENTINEL: + warn_no_default("timestamp_local", value, value) + return value + return default -def timestamp_utc(value): +def timestamp_utc(value, default=_SENTINEL): """Filter to convert given timestamp to UTC date/time.""" try: return dt_util.utc_from_timestamp(value).strftime(DATE_STR_FORMAT) except (ValueError, TypeError): # If timestamp can't be converted - return value + if default is _SENTINEL: + warn_no_default("timestamp_utc", value, value) + return value + return default -def forgiving_as_timestamp(value): - """Try to convert value to timestamp.""" +def forgiving_as_timestamp(value, default=_SENTINEL): + """Filter and function which tries to convert value to timestamp.""" try: return dt_util.as_timestamp(value) except (ValueError, TypeError): - return None + if default is _SENTINEL: + warn_no_default("as_timestamp", value, None) + return None + return default -def strptime(string, fmt): +def strptime(string, fmt, default=_SENTINEL): """Parse a time string to datetime.""" try: return datetime.strptime(string, fmt) except (ValueError, AttributeError, TypeError): - return string + if default is _SENTINEL: + warn_no_default("strptime", string, string) + return string + return default def fail_when_undefined(value): @@ -1362,12 +1441,26 @@ def fail_when_undefined(value): return value -def forgiving_float(value): +def forgiving_float(value, default=_SENTINEL): """Try to convert value to a float.""" try: return float(value) except (ValueError, TypeError): - return value + if default is _SENTINEL: + warn_no_default("float", value, value) + return value + return default + + +def forgiving_float_filter(value, default=_SENTINEL): + """Try to convert value to a float.""" + try: + return float(value) + except (ValueError, TypeError): + if default is _SENTINEL: + warn_no_default("float", value, 0) + return 0 + return default def is_number(value): @@ -1493,22 +1586,33 @@ def urlencode(value): return urllib_urlencode(value).encode("utf-8") +@contextmanager +def set_template(template_str: str, action: str) -> Generator: + """Store template being parsed or rendered in a Contextvar to aid error handling.""" + template_cv.set((template_str, action)) + try: + yield + finally: + template_cv.set(None) + + def _render_with_context( template_str: str, template: jinja2.Template, **kwargs: Any ) -> str: """Store template being rendered in a ContextVar to aid error handling.""" - template_cv.set(template_str) - return template.render(**kwargs) + with set_template(template_str, "rendering"): + return template.render(**kwargs) class LoggingUndefined(jinja2.Undefined): """Log on undefined variables.""" def _log_message(self): - template = template_cv.get() or "" + template, action = template_cv.get() or ("", "rendering or compiling") _LOGGER.warning( - "Template variable warning: %s when rendering '%s'", + "Template variable warning: %s when %s '%s'", self._undefined_message, + action, template, ) @@ -1516,10 +1620,11 @@ class LoggingUndefined(jinja2.Undefined): try: return super()._fail_with_undefined_error(*args, **kwargs) except self._undefined_exception as ex: - template = template_cv.get() or "" + template, action = template_cv.get() or ("", "rendering or compiling") _LOGGER.error( - "Template variable error: %s when rendering '%s'", + "Template variable error: %s when %s '%s'", self._undefined_message, + action, template, ) raise ex @@ -1587,6 +1692,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.filters["bitwise_or"] = bitwise_or self.filters["ord"] = ord self.filters["is_number"] = is_number + self.filters["float"] = forgiving_float_filter self.globals["log"] = logarithm self.globals["sin"] = sine self.globals["cos"] = cosine diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 0ac59c68d2d..5522956c81c 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -38,6 +38,12 @@ def _set_up_units(hass): ) +def render(hass, template_str, variables=None): + """Create render info from template.""" + tmp = template.Template(template_str, hass) + return tmp.async_render(variables) + + def render_to_info(hass, template_str, variables=None): """Create render info from template.""" tmp = template.Template(template_str, hass) @@ -196,8 +202,8 @@ def test_iterating_domain_states(hass): ) -def test_float(hass): - """Test float.""" +def test_float_function(hass): + """Test float function.""" hass.states.async_set("sensor.temperature", "12") assert ( @@ -219,6 +225,20 @@ def test_float(hass): == "forgiving" ) + assert render(hass, "{{ float('bad', 1) }}") == 1 + assert render(hass, "{{ float('bad', default=1) }}") == 1 + + +def test_float_filter(hass): + """Test float filter.""" + hass.states.async_set("sensor.temperature", "12") + + assert render(hass, "{{ states.sensor.temperature.state | float }}") == 12.0 + assert render(hass, "{{ states.sensor.temperature.state | float > 11 }}") is True + assert render(hass, "{{ 'bad' | float }}") == 0 + assert render(hass, "{{ 'bad' | float(1) }}") == 1 + assert render(hass, "{{ 'bad' | float(default=1) }}") == 1 + @pytest.mark.parametrize( "value, expected", @@ -295,8 +315,8 @@ def test_rounding_value(hass): ) -def test_rounding_value_get_original_value_on_error(hass): - """Test rounding value get original value on error.""" +def test_rounding_value_on_error(hass): + """Test rounding value handling of error.""" assert template.Template("{{ None | round }}", hass).async_render() is None assert ( @@ -304,6 +324,9 @@ def test_rounding_value_get_original_value_on_error(hass): == "no_number" ) + # Test handling of default return value + assert render(hass, "{{ 'no_number' | round(default=1) }}") == 1 + def test_multiply(hass): """Test multiply.""" @@ -317,6 +340,10 @@ def test_multiply(hass): == out ) + # Test handling of default return value + assert render(hass, "{{ 'no_number' | multiply(10, 1) }}") == 1 + assert render(hass, "{{ 'no_number' | multiply(10, default=1) }}") == 1 + def test_logarithm(hass): """Test logarithm.""" @@ -343,6 +370,12 @@ def test_logarithm(hass): == expected ) + # Test handling of default return value + assert render(hass, "{{ 'no_number' | log(10, 1) }}") == 1 + assert render(hass, "{{ 'no_number' | log(10, default=1) }}") == 1 + assert render(hass, "{{ log('no_number', 10, 1) }}") == 1 + assert render(hass, "{{ log('no_number', 10, default=1) }}") == 1 + def test_sine(hass): """Test sine.""" @@ -360,6 +393,13 @@ def test_sine(hass): template.Template("{{ %s | sin | round(3) }}" % value, hass).async_render() == expected ) + assert render(hass, f"{{{{ sin({value}) | round(3) }}}}") == expected + + # Test handling of default return value + assert render(hass, "{{ 'no_number' | sin(1) }}") == 1 + assert render(hass, "{{ 'no_number' | sin(default=1) }}") == 1 + assert render(hass, "{{ sin('no_number', 1) }}") == 1 + assert render(hass, "{{ sin('no_number', default=1) }}") == 1 def test_cos(hass): @@ -378,6 +418,13 @@ def test_cos(hass): template.Template("{{ %s | cos | round(3) }}" % value, hass).async_render() == expected ) + assert render(hass, f"{{{{ cos({value}) | round(3) }}}}") == expected + + # Test handling of default return value + assert render(hass, "{{ 'no_number' | sin(1) }}") == 1 + assert render(hass, "{{ 'no_number' | sin(default=1) }}") == 1 + assert render(hass, "{{ sin('no_number', 1) }}") == 1 + assert render(hass, "{{ sin('no_number', default=1) }}") == 1 def test_tan(hass): @@ -396,6 +443,13 @@ def test_tan(hass): template.Template("{{ %s | tan | round(3) }}" % value, hass).async_render() == expected ) + assert render(hass, f"{{{{ tan({value}) | round(3) }}}}") == expected + + # Test handling of default return value + assert render(hass, "{{ 'no_number' | tan(1) }}") == 1 + assert render(hass, "{{ 'no_number' | tan(default=1) }}") == 1 + assert render(hass, "{{ tan('no_number', 1) }}") == 1 + assert render(hass, "{{ tan('no_number', default=1) }}") == 1 def test_sqrt(hass): @@ -414,6 +468,13 @@ def test_sqrt(hass): template.Template("{{ %s | sqrt | round(3) }}" % value, hass).async_render() == expected ) + assert render(hass, f"{{{{ sqrt({value}) | round(3) }}}}") == expected + + # Test handling of default return value + assert render(hass, "{{ 'no_number' | sqrt(1) }}") == 1 + assert render(hass, "{{ 'no_number' | sqrt(default=1) }}") == 1 + assert render(hass, "{{ sqrt('no_number', 1) }}") == 1 + assert render(hass, "{{ sqrt('no_number', default=1) }}") == 1 def test_arc_sine(hass): @@ -434,6 +495,13 @@ def test_arc_sine(hass): template.Template("{{ %s | asin | round(3) }}" % value, hass).async_render() == expected ) + assert render(hass, f"{{{{ asin({value}) | round(3) }}}}") == expected + + # Test handling of default return value + assert render(hass, "{{ 'no_number' | asin(1) }}") == 1 + assert render(hass, "{{ 'no_number' | asin(default=1) }}") == 1 + assert render(hass, "{{ asin('no_number', 1) }}") == 1 + assert render(hass, "{{ asin('no_number', default=1) }}") == 1 def test_arc_cos(hass): @@ -454,6 +522,13 @@ def test_arc_cos(hass): template.Template("{{ %s | acos | round(3) }}" % value, hass).async_render() == expected ) + assert render(hass, f"{{{{ acos({value}) | round(3) }}}}") == expected + + # Test handling of default return value + assert render(hass, "{{ 'no_number' | acos(1) }}") == 1 + assert render(hass, "{{ 'no_number' | acos(default=1) }}") == 1 + assert render(hass, "{{ acos('no_number', 1) }}") == 1 + assert render(hass, "{{ acos('no_number', default=1) }}") == 1 def test_arc_tan(hass): @@ -476,6 +551,13 @@ def test_arc_tan(hass): template.Template("{{ %s | atan | round(3) }}" % value, hass).async_render() == expected ) + assert render(hass, f"{{{{ atan({value}) | round(3) }}}}") == expected + + # Test handling of default return value + assert render(hass, "{{ 'no_number' | atan(1) }}") == 1 + assert render(hass, "{{ 'no_number' | atan(default=1) }}") == 1 + assert render(hass, "{{ atan('no_number', 1) }}") == 1 + assert render(hass, "{{ atan('no_number', default=1) }}") == 1 def test_arc_tan2(hass): @@ -510,6 +592,12 @@ def test_arc_tan2(hass): == expected ) + # Test handling of default return value + assert render(hass, "{{ ('duck', 'goose') | atan2(1) }}") == 1 + assert render(hass, "{{ ('duck', 'goose') | atan2(default=1) }}") == 1 + assert render(hass, "{{ atan2('duck', 'goose', 1) }}") == 1 + assert render(hass, "{{ atan2('duck', 'goose', default=1) }}") == 1 + def test_strptime(hass): """Test the parse timestamp method.""" @@ -532,6 +620,10 @@ def test_strptime(hass): assert template.Template(temp, hass).async_render() == expected + # Test handling of default return value + assert render(hass, "{{ strptime('invalid', '%Y', 1) }}") == 1 + assert render(hass, "{{ strptime('invalid', '%Y', default=1) }}") == 1 + def test_timestamp_custom(hass): """Test the timestamps to custom filter.""" @@ -554,6 +646,10 @@ def test_timestamp_custom(hass): assert template.Template(f"{{{{ {inp} | {fil} }}}}", hass).async_render() == out + # Test handling of default return value + assert render(hass, "{{ None | timestamp_custom('invalid', True, 1) }}") == 1 + assert render(hass, "{{ None | timestamp_custom(default=1) }}") == 1 + def test_timestamp_local(hass): """Test the timestamps to local filter.""" @@ -565,6 +661,10 @@ def test_timestamp_local(hass): == out ) + # Test handling of default return value + assert render(hass, "{{ None | timestamp_local(1) }}") == 1 + assert render(hass, "{{ None | timestamp_local(default=1) }}") == 1 + @pytest.mark.parametrize( "input", @@ -702,6 +802,10 @@ def test_timestamp_utc(hass): == out ) + # Test handling of default return value + assert render(hass, "{{ None | timestamp_utc(1) }}") == 1 + assert render(hass, "{{ None | timestamp_utc(default=1) }}") == 1 + def test_as_timestamp(hass): """Test the as_timestamp function.""" @@ -720,6 +824,12 @@ def test_as_timestamp(hass): ) assert template.Template(tpl, hass).async_render() == 1706951424.0 + # Test handling of default return value + assert render(hass, "{{ 'invalid' | as_timestamp(1) }}") == 1 + assert render(hass, "{{ 'invalid' | as_timestamp(default=1) }}") == 1 + assert render(hass, "{{ as_timestamp('invalid', 1) }}") == 1 + assert render(hass, "{{ as_timestamp('invalid', default=1) }}") == 1 + @patch.object(random, "choice") def test_random_every_time(test_choice, hass): From 8f4ba564d48d7db8e233929ff2f475fa8dc93790 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Wed, 29 Sep 2021 13:17:55 -0500 Subject: [PATCH 700/843] Plex media browser improvements (#56312) --- homeassistant/components/plex/helpers.py | 24 ++ .../components/plex/media_browser.py | 33 ++- tests/components/plex/test_browse_media.py | 215 ++++++++++++++++-- 3 files changed, 244 insertions(+), 28 deletions(-) create mode 100644 homeassistant/components/plex/helpers.py diff --git a/homeassistant/components/plex/helpers.py b/homeassistant/components/plex/helpers.py new file mode 100644 index 00000000000..be873614ba6 --- /dev/null +++ b/homeassistant/components/plex/helpers.py @@ -0,0 +1,24 @@ +"""Helper methods for common Plex integration operations.""" + + +def pretty_title(media, short_name=False): + """Return a formatted title for the given media item.""" + year = None + if media.type == "album": + title = f"{media.parentTitle} - {media.title}" + elif media.type == "episode": + title = f"{media.seasonEpisode.upper()} - {media.title}" + if not short_name: + title = f"{media.grandparentTitle} - {title}" + elif media.type == "track": + title = f"{media.index}. {media.title}" + else: + title = media.title + + if media.type in ["album", "movie", "season"]: + year = media.year + + if year: + title += f" ({year!s})" + + return title diff --git a/homeassistant/components/plex/media_browser.py b/homeassistant/components/plex/media_browser.py index ac3c6e8f8f8..6be7462da39 100644 --- a/homeassistant/components/plex/media_browser.py +++ b/homeassistant/components/plex/media_browser.py @@ -1,4 +1,5 @@ """Support to interface with the Plex API.""" +from itertools import islice import logging from homeassistant.components.media_player import BrowseMedia @@ -17,6 +18,7 @@ from homeassistant.components.media_player.const import ( from homeassistant.components.media_player.errors import BrowseError from .const import DOMAIN +from .helpers import pretty_title class UnknownMediaType(BrowseError): @@ -32,9 +34,10 @@ PLAYLISTS_BROWSE_PAYLOAD = { "can_play": False, "can_expand": True, } -SPECIAL_METHODS = { - "On Deck": "onDeck", - "Recently Added": "recentlyAdded", + +LIBRARY_PREFERRED_LIBTYPE = { + "show": "episode", + "artist": "album", } ITEM_TYPE_MEDIA_CLASS = { @@ -57,7 +60,7 @@ def browse_media( # noqa: C901 ): """Implement the websocket media browsing helper.""" - def item_payload(item): + def item_payload(item, short_name=False): """Create response payload for a single media item.""" try: media_class = ITEM_TYPE_MEDIA_CLASS[item.type] @@ -65,7 +68,7 @@ def browse_media( # noqa: C901 _LOGGER.debug("Unknown type received: %s", item.type) raise UnknownMediaType from err payload = { - "title": item.title, + "title": pretty_title(item, short_name), "media_class": media_class, "media_content_id": str(item.ratingKey), "media_content_type": item.type, @@ -129,7 +132,7 @@ def browse_media( # noqa: C901 media_info.children = [] for item in media: try: - media_info.children.append(item_payload(item)) + media_info.children.append(item_payload(item, short_name=True)) except UnknownMediaType: continue return media_info @@ -180,8 +183,22 @@ def browse_media( # noqa: C901 "children_media_class": children_media_class, } - method = SPECIAL_METHODS[special_folder] - items = getattr(library_or_section, method)() + if special_folder == "On Deck": + items = library_or_section.onDeck() + elif special_folder == "Recently Added": + if library_or_section.TYPE: + libtype = LIBRARY_PREFERRED_LIBTYPE.get( + library_or_section.TYPE, library_or_section.TYPE + ) + items = library_or_section.recentlyAdded(libtype=libtype) + else: + recent_iter = ( + x + for x in library_or_section.search(sort="addedAt:desc", limit=100) + if x.type in ["album", "episode", "movie"] + ) + items = list(islice(recent_iter, 30)) + for item in items: try: payload["children"].append(item_payload(item)) diff --git a/tests/components/plex/test_browse_media.py b/tests/components/plex/test_browse_media.py index 4892262fc32..be4869839d2 100644 --- a/tests/components/plex/test_browse_media.py +++ b/tests/components/plex/test_browse_media.py @@ -1,15 +1,74 @@ """Tests for Plex media browser.""" +from unittest.mock import patch + from homeassistant.components.media_player.const import ( ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, ) from homeassistant.components.plex.const import CONF_SERVER_IDENTIFIER -from homeassistant.components.plex.media_browser import SPECIAL_METHODS from homeassistant.components.websocket_api.const import ERR_UNKNOWN_ERROR, TYPE_RESULT from .const import DEFAULT_DATA +class MockPlexShow: + """Mock a plexapi Season instance.""" + + ratingKey = 30 + title = "TV Show" + type = "show" + + def __iter__(self): + """Iterate over episodes.""" + yield MockPlexSeason() + + +class MockPlexSeason: + """Mock a plexapi Season instance.""" + + ratingKey = 20 + title = "Season 1" + type = "season" + year = 2021 + + def __iter__(self): + """Iterate over episodes.""" + yield MockPlexEpisode() + + +class MockPlexEpisode: + """Mock a plexapi Episode instance.""" + + ratingKey = 10 + title = "Episode 1" + grandparentTitle = "TV Show" + seasonEpisode = "s01e01" + type = "episode" + + +class MockPlexAlbum: + """Mock a plexapi Album instance.""" + + ratingKey = 200 + parentTitle = "Artist" + title = "Album" + type = "album" + year = 2001 + + def __iter__(self): + """Iterate over tracks.""" + yield MockPlexTrack() + + +class MockPlexTrack: + """Mock a plexapi Track instance.""" + + index = 1 + ratingKey = 100 + title = "Track 1" + type = "track" + + async def test_browse_media( hass, hass_ws_client, @@ -58,15 +117,13 @@ async def test_browse_media( result = msg["result"] assert result[ATTR_MEDIA_CONTENT_TYPE] == "server" assert result[ATTR_MEDIA_CONTENT_ID] == DEFAULT_DATA[CONF_SERVER_IDENTIFIER] - # Library Sections + Special Sections + Playlists - assert ( - len(result["children"]) - == len(mock_plex_server.library.sections()) + len(SPECIAL_METHODS) + 1 - ) + # Library Sections + On Deck + Recently Added + Playlists + assert len(result["children"]) == len(mock_plex_server.library.sections()) + 3 + music = next(iter(x for x in result["children"] if x["title"] == "Music")) tvshows = next(iter(x for x in result["children"] if x["title"] == "TV Shows")) playlists = next(iter(x for x in result["children"] if x["title"] == "Playlists")) - special_keys = list(SPECIAL_METHODS.keys()) + special_keys = ["On Deck", "Recently Added"] # Browse into a special folder (server) msg_id += 1 @@ -144,23 +201,34 @@ async def test_browse_media( result = msg["result"] assert result[ATTR_MEDIA_CONTENT_TYPE] == "library" result_id = int(result[ATTR_MEDIA_CONTENT_ID]) - assert len(result["children"]) == len( - mock_plex_server.library.sectionByID(result_id).all() - ) + len(SPECIAL_METHODS) + # All items in section + On Deck + Recently Added + assert ( + len(result["children"]) + == len(mock_plex_server.library.sectionByID(result_id).all()) + 2 + ) # Browse into a Plex TV show msg_id += 1 - await websocket_client.send_json( - { - "id": msg_id, - "type": "media_player/browse_media", - "entity_id": media_players[0], - ATTR_MEDIA_CONTENT_TYPE: result["children"][-1][ATTR_MEDIA_CONTENT_TYPE], - ATTR_MEDIA_CONTENT_ID: str(result["children"][-1][ATTR_MEDIA_CONTENT_ID]), - } - ) + mock_show = MockPlexShow() + mock_season = next(iter(mock_show)) + with patch.object( + mock_plex_server, "fetch_item", return_value=mock_show + ) as mock_fetch: + await websocket_client.send_json( + { + "id": msg_id, + "type": "media_player/browse_media", + "entity_id": media_players[0], + ATTR_MEDIA_CONTENT_TYPE: result["children"][-1][ + ATTR_MEDIA_CONTENT_TYPE + ], + ATTR_MEDIA_CONTENT_ID: str( + result["children"][-1][ATTR_MEDIA_CONTENT_ID] + ), + } + ) + msg = await websocket_client.receive_json() - msg = await websocket_client.receive_json() assert msg["id"] == msg_id assert msg["type"] == TYPE_RESULT assert msg["success"] @@ -168,6 +236,90 @@ async def test_browse_media( assert result[ATTR_MEDIA_CONTENT_TYPE] == "show" result_id = int(result[ATTR_MEDIA_CONTENT_ID]) assert result["title"] == mock_plex_server.fetch_item(result_id).title + assert result["children"][0]["title"] == f"{mock_season.title} ({mock_season.year})" + + # Browse into a Plex TV show season + msg_id += 1 + mock_episode = next(iter(mock_season)) + with patch.object( + mock_plex_server, "fetch_item", return_value=mock_season + ) as mock_fetch: + await websocket_client.send_json( + { + "id": msg_id, + "type": "media_player/browse_media", + "entity_id": media_players[0], + ATTR_MEDIA_CONTENT_TYPE: result["children"][0][ATTR_MEDIA_CONTENT_TYPE], + ATTR_MEDIA_CONTENT_ID: str( + result["children"][0][ATTR_MEDIA_CONTENT_ID] + ), + } + ) + + msg = await websocket_client.receive_json() + + assert mock_fetch.called + assert msg["id"] == msg_id + assert msg["type"] == TYPE_RESULT + assert msg["success"] + result = msg["result"] + assert result[ATTR_MEDIA_CONTENT_TYPE] == "season" + result_id = int(result[ATTR_MEDIA_CONTENT_ID]) + assert result["title"] == f"{mock_season.title} ({mock_season.year})" + assert ( + result["children"][0]["title"] + == f"{mock_episode.seasonEpisode.upper()} - {mock_episode.title}" + ) + + # Browse into a Plex music library + msg_id += 1 + await websocket_client.send_json( + { + "id": msg_id, + "type": "media_player/browse_media", + "entity_id": media_players[0], + ATTR_MEDIA_CONTENT_TYPE: music[ATTR_MEDIA_CONTENT_TYPE], + ATTR_MEDIA_CONTENT_ID: str(music[ATTR_MEDIA_CONTENT_ID]), + } + ) + msg = await websocket_client.receive_json() + + assert msg["success"] + result = msg["result"] + result_id = int(result[ATTR_MEDIA_CONTENT_ID]) + assert result[ATTR_MEDIA_CONTENT_TYPE] == "library" + assert result["title"] == "Music" + + # Browse into a Plex album + msg_id += 1 + mock_album = MockPlexAlbum() + with patch.object( + mock_plex_server, "fetch_item", return_value=mock_album + ) as mock_fetch: + await websocket_client.send_json( + { + "id": msg_id, + "type": "media_player/browse_media", + "entity_id": media_players[0], + ATTR_MEDIA_CONTENT_TYPE: result["children"][-1][ + ATTR_MEDIA_CONTENT_TYPE + ], + ATTR_MEDIA_CONTENT_ID: str( + result["children"][-1][ATTR_MEDIA_CONTENT_ID] + ), + } + ) + msg = await websocket_client.receive_json() + + assert mock_fetch.called + assert msg["success"] + result = msg["result"] + result_id = int(result[ATTR_MEDIA_CONTENT_ID]) + assert result[ATTR_MEDIA_CONTENT_TYPE] == "album" + assert ( + result["title"] + == f"{mock_album.parentTitle} - {mock_album.title} ({mock_album.year})" + ) # Browse into a non-existent TV season unknown_key = 99999999999999 @@ -211,3 +363,26 @@ async def test_browse_media( result = msg["result"] assert result[ATTR_MEDIA_CONTENT_TYPE] == "playlists" result_id = result[ATTR_MEDIA_CONTENT_ID] + + # Browse recently added items + msg_id += 1 + mock_items = [MockPlexAlbum(), MockPlexEpisode(), MockPlexSeason(), MockPlexTrack()] + with patch("plexapi.library.Library.search", return_value=mock_items) as mock_fetch: + await websocket_client.send_json( + { + "id": msg_id, + "type": "media_player/browse_media", + "entity_id": media_players[0], + ATTR_MEDIA_CONTENT_TYPE: "server", + ATTR_MEDIA_CONTENT_ID: f"{DEFAULT_DATA[CONF_SERVER_IDENTIFIER]}:{special_keys[1]}", + } + ) + msg = await websocket_client.receive_json() + + assert msg["success"] + result = msg["result"] + assert result[ATTR_MEDIA_CONTENT_TYPE] == "server" + result_id = result[ATTR_MEDIA_CONTENT_ID] + for child in result["children"]: + assert child["media_content_type"] in ["album", "episode"] + assert child["media_content_type"] not in ["season", "track"] From a966714032e6fcae8b7f08acbf2f8cce5b5fb52e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 29 Sep 2021 20:42:39 +0200 Subject: [PATCH 701/843] Minor cleanup of recorder statistics code (#55339) --- homeassistant/components/recorder/models.py | 2 +- .../components/recorder/statistics.py | 39 ++++++------------- 2 files changed, 12 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py index 0d9e31bc25d..a43c7781c8d 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -275,7 +275,7 @@ class StatisticsBase: sum = Column(DOUBLE_TYPE) @classmethod - def from_stats(cls, metadata_id: str, stats: StatisticData): + def from_stats(cls, metadata_id: int, stats: StatisticData): """Create object from a statistics.""" return cls( # type: ignore metadata_id=metadata_id, diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 4cc20f55910..5a35625e837 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -182,26 +182,11 @@ def get_start_time() -> datetime: return last_period -def _get_metadata_ids( - hass: HomeAssistant, session: scoped_session, statistic_ids: list[str] -) -> list[str]: - """Resolve metadata_id for a list of statistic_ids.""" - baked_query = hass.data[STATISTICS_META_BAKERY]( - lambda session: session.query(*QUERY_STATISTIC_META_ID) - ) - baked_query += lambda q: q.filter( - StatisticsMeta.statistic_id.in_(bindparam("statistic_ids")) - ) - result = execute(baked_query(session).params(statistic_ids=statistic_ids)) - - return [id for id, _ in result] if result else [] - - def _update_or_add_metadata( hass: HomeAssistant, session: scoped_session, new_metadata: StatisticMetaData, -) -> str: +) -> int: """Get metadata_id for a statistic_id. If the statistic_id is previously unknown, add it. If it's already known, update @@ -215,16 +200,15 @@ def _update_or_add_metadata( unit = new_metadata["unit_of_measurement"] has_mean = new_metadata["has_mean"] has_sum = new_metadata["has_sum"] - session.add( - StatisticsMeta.from_meta(DOMAIN, statistic_id, unit, has_mean, has_sum) - ) - metadata_ids = _get_metadata_ids(hass, session, [statistic_id]) + meta = StatisticsMeta.from_meta(DOMAIN, statistic_id, unit, has_mean, has_sum) + session.add(meta) + session.flush() # Flush to get the metadata id assigned _LOGGER.debug( "Added new statistics metadata for %s, new_metadata: %s", statistic_id, new_metadata, ) - return metadata_ids[0] + return meta.id # type: ignore[no-any-return] metadata_id, old_metadata = next(iter(old_metadata_dict.items())) if ( @@ -382,7 +366,7 @@ def _get_metadata( session: scoped_session, statistic_ids: list[str] | None, statistic_type: Literal["mean"] | Literal["sum"] | None, -) -> dict[str, StatisticMetaData]: +) -> dict[int, StatisticMetaData]: """Fetch meta data, returns a dict of StatisticMetaData indexed by statistic_id. If statistic_ids is given, fetch metadata only for the listed statistics_ids. @@ -419,7 +403,7 @@ def _get_metadata( metadata_ids = [metadata[0] for metadata in result] # Prepare the result dict - metadata: dict[str, StatisticMetaData] = {} + metadata: dict[int, StatisticMetaData] = {} for _id in metadata_ids: meta = _meta(result, _id) if meta: @@ -432,12 +416,11 @@ def get_metadata( statistic_id: str, ) -> StatisticMetaData | None: """Return metadata for a statistic_id.""" - statistic_ids = [statistic_id] with session_scope(hass=hass) as session: - metadata_ids = _get_metadata_ids(hass, session, [statistic_id]) - if not metadata_ids: + metadata = _get_metadata(hass, session, [statistic_id], None) + if not metadata: return None - return _get_metadata(hass, session, statistic_ids, None).get(metadata_ids[0]) + return next(iter(metadata.values())) def _configured_unit(unit: str, units: UnitSystem) -> str: @@ -646,7 +629,7 @@ def _sorted_statistics_to_dict( hass: HomeAssistant, stats: list, statistic_ids: list[str] | None, - metadata: dict[str, StatisticMetaData], + metadata: dict[int, StatisticMetaData], convert_units: bool, duration: timedelta, ) -> dict[str, list[dict]]: From 23cbd9075aa234b3c8f875c1a75a1a2a8cc186ef Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Sep 2021 13:49:33 -0500 Subject: [PATCH 702/843] Wait for yeelight internal state to change before update after on/off (#56795) --- homeassistant/components/yeelight/light.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py index 30861fa0001..3f5bb29bab7 100644 --- a/homeassistant/components/yeelight/light.py +++ b/homeassistant/components/yeelight/light.py @@ -1,6 +1,7 @@ """Light platform support for yeelight.""" from __future__ import annotations +import asyncio import logging import math @@ -209,6 +210,9 @@ SERVICE_SCHEMA_SET_AUTO_DELAY_OFF_SCENE = { } +STATE_CHANGE_TIME = 0.25 # seconds + + @callback def _transitions_config_parser(transitions): """Parse transitions config into initialized objects.""" @@ -759,6 +763,7 @@ class YeelightGenericLight(YeelightEntity, LightEntity): await self.async_set_default() # Some devices (mainly nightlights) will not send back the on state so we need to force a refresh + await asyncio.sleep(STATE_CHANGE_TIME) if not self.is_on: await self.device.async_update(True) @@ -778,6 +783,7 @@ class YeelightGenericLight(YeelightEntity, LightEntity): await self._async_turn_off(duration) # Some devices will not send back the off state so we need to force a refresh + await asyncio.sleep(STATE_CHANGE_TIME) if self.is_on: await self.device.async_update(True) From 0463007050c3e6ab9d55b8232e24c679d4b70ff7 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 29 Sep 2021 21:06:11 +0200 Subject: [PATCH 703/843] Add switch platform to Tractive integration (#55517) --- .coveragerc | 1 + homeassistant/components/tractive/__init__.py | 20 +- homeassistant/components/tractive/const.py | 3 + homeassistant/components/tractive/switch.py | 173 ++++++++++++++++++ 4 files changed, 191 insertions(+), 6 deletions(-) create mode 100644 homeassistant/components/tractive/switch.py diff --git a/.coveragerc b/.coveragerc index 899c16e3920..e22611edf51 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1105,6 +1105,7 @@ omit = homeassistant/components/tractive/device_tracker.py homeassistant/components/tractive/entity.py homeassistant/components/tractive/sensor.py + homeassistant/components/tractive/switch.py homeassistant/components/tradfri/* homeassistant/components/trafikverket_train/sensor.py homeassistant/components/trafikverket_weatherstation/sensor.py diff --git a/homeassistant/components/tractive/__init__.py b/homeassistant/components/tractive/__init__.py index 5dcbd4574b3..be612ef5cc7 100644 --- a/homeassistant/components/tractive/__init__.py +++ b/homeassistant/components/tractive/__init__.py @@ -21,7 +21,10 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ( + ATTR_BUZZER, ATTR_DAILY_GOAL, + ATTR_LED, + ATTR_LIVE_TRACKING, ATTR_MINUTES_ACTIVE, CLIENT, DOMAIN, @@ -33,7 +36,7 @@ from .const import ( TRACKER_POSITION_UPDATED, ) -PLATFORMS = ["binary_sensor", "device_tracker", "sensor"] +PLATFORMS = ["binary_sensor", "device_tracker", "sensor", "switch"] _LOGGER = logging.getLogger(__name__) @@ -43,10 +46,11 @@ _LOGGER = logging.getLogger(__name__) class Trackables: """A class that describes trackables.""" - trackable: dict | None = None - tracker_details: dict | None = None - hw_info: dict | None = None - pos_report: dict | None = None + tracker: aiotractive.tracker.Tracker + trackable: dict + tracker_details: dict + hw_info: dict + pos_report: dict async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -112,7 +116,7 @@ async def _generate_trackables(client, trackable): tracker.details(), tracker.hw_info(), tracker.pos_report() ) - return Trackables(trackable, tracker_details, hw_info, pos_report) + return Trackables(tracker, trackable, tracker_details, hw_info, pos_report) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -188,9 +192,13 @@ class TractiveClient: continue def _send_hardware_update(self, event): + # Sometimes hardware event doesn't contain complete data. payload = { ATTR_BATTERY_LEVEL: event["hardware"]["battery_level"], ATTR_BATTERY_CHARGING: event["charging_state"] == "CHARGING", + ATTR_LIVE_TRACKING: event.get("live_tracking", {}).get("active"), + ATTR_BUZZER: event.get("buzzer_control", {}).get("active"), + ATTR_LED: event.get("led_control", {}).get("active"), } self._dispatch_tracker_event( TRACKER_HARDWARE_STATUS_UPDATED, event["tracker_id"], payload diff --git a/homeassistant/components/tractive/const.py b/homeassistant/components/tractive/const.py index 7f1b5ddb4f2..6a61024cd51 100644 --- a/homeassistant/components/tractive/const.py +++ b/homeassistant/components/tractive/const.py @@ -7,6 +7,9 @@ DOMAIN = "tractive" RECONNECT_INTERVAL = timedelta(seconds=10) ATTR_DAILY_GOAL = "daily_goal" +ATTR_BUZZER = "buzzer" +ATTR_LED = "led" +ATTR_LIVE_TRACKING = "live_tracking" ATTR_MINUTES_ACTIVE = "minutes_active" CLIENT = "client" diff --git a/homeassistant/components/tractive/switch.py b/homeassistant/components/tractive/switch.py new file mode 100644 index 00000000000..d58e38a7cc9 --- /dev/null +++ b/homeassistant/components/tractive/switch.py @@ -0,0 +1,173 @@ +"""Support for Tractive switches.""" +from __future__ import annotations + +from dataclasses import dataclass +import logging +from typing import Any, Literal + +from aiotractive.exceptions import TractiveError + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import Trackables +from .const import ( + ATTR_BUZZER, + ATTR_LED, + ATTR_LIVE_TRACKING, + CLIENT, + DOMAIN, + SERVER_UNAVAILABLE, + TRACKABLES, + TRACKER_HARDWARE_STATUS_UPDATED, +) +from .entity import TractiveEntity + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class TractiveRequiredKeysMixin: + """Mixin for required keys.""" + + method: Literal["async_set_buzzer", "async_set_led", "async_set_live_tracking"] + + +@dataclass +class TractiveSwitchEntityDescription( + SwitchEntityDescription, TractiveRequiredKeysMixin +): + """Class describing Tractive switch entities.""" + + +SWITCH_TYPES: tuple[TractiveSwitchEntityDescription, ...] = ( + TractiveSwitchEntityDescription( + key=ATTR_BUZZER, + name="Tracker Buzzer", + icon="mdi:volume-high", + method="async_set_buzzer", + ), + TractiveSwitchEntityDescription( + key=ATTR_LED, + name="Tracker LED", + icon="mdi:led-on", + method="async_set_led", + ), + TractiveSwitchEntityDescription( + key=ATTR_LIVE_TRACKING, + name="Live Tracking", + icon="mdi:map-marker-path", + method="async_set_live_tracking", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Tractive switches.""" + client = hass.data[DOMAIN][entry.entry_id][CLIENT] + trackables = hass.data[DOMAIN][entry.entry_id][TRACKABLES] + + entities = [ + TractiveSwitch(client.user_id, item, description) + for description in SWITCH_TYPES + for item in trackables + ] + + async_add_entities(entities) + + +class TractiveSwitch(TractiveEntity, SwitchEntity): + """Tractive switch.""" + + entity_description: TractiveSwitchEntityDescription + + def __init__( + self, + user_id: str, + item: Trackables, + description: TractiveSwitchEntityDescription, + ) -> None: + """Initialize switch entity.""" + super().__init__(user_id, item.trackable, item.tracker_details) + + self._attr_name = f"{item.trackable['details']['name']} {description.name}" + self._attr_unique_id = f"{item.trackable['_id']}_{description.key}" + self._attr_available = False + self._tracker = item.tracker + self._method = getattr(self, description.method) + self.entity_description = description + + @callback + def handle_server_unavailable(self) -> None: + """Handle server unavailable.""" + self._attr_available = False + self.async_write_ha_state() + + @callback + def handle_hardware_status_update(self, event: dict[str, Any]) -> None: + """Handle hardware status update.""" + if (state := event[self.entity_description.key]) is None: + return + self._attr_is_on = state + self._attr_available = True + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{TRACKER_HARDWARE_STATUS_UPDATED}-{self._tracker_id}", + self.handle_hardware_status_update, + ) + ) + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{SERVER_UNAVAILABLE}-{self._user_id}", + self.handle_server_unavailable, + ) + ) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on a switch.""" + try: + result = await self._method(True) + except TractiveError as error: + _LOGGER.error(error) + return + # Write state back to avoid switch flips with a slow response + if result["pending"]: + self._attr_is_on = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off a switch.""" + try: + result = await self._method(False) + except TractiveError as error: + _LOGGER.error(error) + return + # Write state back to avoid switch flips with a slow response + if result["pending"]: + self._attr_is_on = False + self.async_write_ha_state() + + async def async_set_buzzer(self, active: bool) -> dict[str, Any]: + """Set the buzzer on/off.""" + return await self._tracker.set_buzzer_active(active) + + async def async_set_led(self, active: bool) -> dict[str, Any]: + """Set the LED on/off.""" + return await self._tracker.set_led_active(active) + + async def async_set_live_tracking(self, active: bool) -> dict[str, Any]: + """Set the live tracking on/off.""" + return await self._tracker.set_live_tracking_active(active) From f224ab6d67c4b1f704d313f9f30c7e124bd5b05f Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Wed, 29 Sep 2021 21:19:21 +0200 Subject: [PATCH 704/843] Use isinstance to verify class in deCONZ integration (#56794) * Don't enable any variants of the daylight sensor entities by default * Use isinstance rather than doing ZHATYPE compare * Accidentally removed an import --- .../components/deconz/alarm_control_panel.py | 2 +- .../components/deconz/binary_sensor.py | 33 +++++++++++++++---- homeassistant/components/deconz/climate.py | 9 +++-- homeassistant/components/deconz/const.py | 17 ---------- homeassistant/components/deconz/cover.py | 19 +++++------ .../components/deconz/deconz_device.py | 1 + .../components/deconz/deconz_event.py | 17 +++++----- homeassistant/components/deconz/fan.py | 9 +++-- homeassistant/components/deconz/light.py | 20 +++-------- homeassistant/components/deconz/lock.py | 10 ++++-- homeassistant/components/deconz/sensor.py | 27 +++++++++------ homeassistant/components/deconz/switch.py | 13 +++++--- tests/components/deconz/test_sensor.py | 4 +-- 13 files changed, 95 insertions(+), 86 deletions(-) diff --git a/homeassistant/components/deconz/alarm_control_panel.py b/homeassistant/components/deconz/alarm_control_panel.py index 00d21f8141e..73e85f13713 100644 --- a/homeassistant/components/deconz/alarm_control_panel.py +++ b/homeassistant/components/deconz/alarm_control_panel.py @@ -76,7 +76,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities) -> None: for sensor in sensors: if ( - sensor.type in AncillaryControl.ZHATYPE + isinstance(sensor, AncillaryControl) and sensor.unique_id not in gateway.entities[DOMAIN] and get_alarm_system_for_unique_id(gateway, sensor.unique_id) ): diff --git a/homeassistant/components/deconz/binary_sensor.py b/homeassistant/components/deconz/binary_sensor.py index 7dd569388e1..96de780c137 100644 --- a/homeassistant/components/deconz/binary_sensor.py +++ b/homeassistant/components/deconz/binary_sensor.py @@ -1,5 +1,15 @@ """Support for deCONZ binary sensors.""" -from pydeconz.sensor import CarbonMonoxide, Fire, OpenClose, Presence, Vibration, Water +from pydeconz.sensor import ( + Alarm, + CarbonMonoxide, + Fire, + GenericFlag, + GenericStatus, + OpenClose, + Presence, + Vibration, + Water, +) from homeassistant.components.binary_sensor import ( DEVICE_CLASS_GAS, @@ -21,6 +31,18 @@ from .const import ATTR_DARK, ATTR_ON, NEW_SENSOR from .deconz_device import DeconzDevice from .gateway import get_gateway_from_config_entry +DECONZ_BINARY_SENSORS = ( + Alarm, + CarbonMonoxide, + Fire, + GenericFlag, + GenericStatus, + OpenClose, + Presence, + Vibration, + Water, +) + ATTR_ORIENTATION = "orientation" ATTR_TILTANGLE = "tiltangle" ATTR_VIBRATIONSTRENGTH = "vibrationstrength" @@ -65,13 +87,12 @@ async def async_setup_entry(hass, config_entry, async_add_entities): for sensor in sensors: + if not gateway.option_allow_clip_sensor and sensor.type.startswith("CLIP"): + continue + if ( - sensor.BINARY + isinstance(sensor, DECONZ_BINARY_SENSORS) and sensor.unique_id not in gateway.entities[DOMAIN] - and ( - gateway.option_allow_clip_sensor - or not sensor.type.startswith("CLIP") - ) ): entities.append(DeconzBinarySensor(sensor, gateway)) diff --git a/homeassistant/components/deconz/climate.py b/homeassistant/components/deconz/climate.py index 307636480c9..86c19be3cd2 100644 --- a/homeassistant/components/deconz/climate.py +++ b/homeassistant/components/deconz/climate.py @@ -84,13 +84,12 @@ async def async_setup_entry(hass, config_entry, async_add_entities): for sensor in sensors: + if not gateway.option_allow_clip_sensor and sensor.type.startswith("CLIP"): + continue + if ( - sensor.type in Thermostat.ZHATYPE + isinstance(sensor, Thermostat) and sensor.unique_id not in gateway.entities[DOMAIN] - and ( - gateway.option_allow_clip_sensor - or not sensor.type.startswith("CLIP") - ) ): entities.append(DeconzThermostat(sensor, gateway)) diff --git a/homeassistant/components/deconz/const.py b/homeassistant/components/deconz/const.py index e961a62c7a0..67753aa0355 100644 --- a/homeassistant/components/deconz/const.py +++ b/homeassistant/components/deconz/const.py @@ -57,23 +57,6 @@ ATTR_OFFSET = "offset" ATTR_ON = "on" ATTR_VALVE = "valve" -# Covers -LEVEL_CONTROLLABLE_OUTPUT = "Level controllable output" -DAMPERS = [LEVEL_CONTROLLABLE_OUTPUT] -WINDOW_COVERING_CONTROLLER = "Window covering controller" -WINDOW_COVERING_DEVICE = "Window covering device" -WINDOW_COVERS = [WINDOW_COVERING_CONTROLLER, WINDOW_COVERING_DEVICE] -COVER_TYPES = DAMPERS + WINDOW_COVERS - -# Fans -FANS = ["Fan"] - -# Locks -LOCK_TYPES = ["Door Lock", "ZHADoorLock"] - -# Sirens -SIRENS = ["Warning device"] - # Switches POWER_PLUGS = ["On/Off light", "On/Off plug-in unit", "Smart plug"] diff --git a/homeassistant/components/deconz/cover.py b/homeassistant/components/deconz/cover.py index bc16b7881af..abf1fe4eea4 100644 --- a/homeassistant/components/deconz/cover.py +++ b/homeassistant/components/deconz/cover.py @@ -1,4 +1,7 @@ """Support for deCONZ covers.""" + +from pydeconz.light import Cover + from homeassistant.components.cover import ( ATTR_POSITION, ATTR_TILT_POSITION, @@ -18,20 +21,14 @@ from homeassistant.components.cover import ( from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from .const import ( - COVER_TYPES, - LEVEL_CONTROLLABLE_OUTPUT, - NEW_LIGHT, - WINDOW_COVERING_CONTROLLER, - WINDOW_COVERING_DEVICE, -) +from .const import NEW_LIGHT from .deconz_device import DeconzDevice from .gateway import get_gateway_from_config_entry DEVICE_CLASS = { - LEVEL_CONTROLLABLE_OUTPUT: DEVICE_CLASS_DAMPER, - WINDOW_COVERING_CONTROLLER: DEVICE_CLASS_SHADE, - WINDOW_COVERING_DEVICE: DEVICE_CLASS_SHADE, + "Level controllable output": DEVICE_CLASS_DAMPER, + "Window covering controller": DEVICE_CLASS_SHADE, + "Window covering device": DEVICE_CLASS_SHADE, } @@ -47,7 +44,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): for light in lights: if ( - light.type in COVER_TYPES + isinstance(light, Cover) and light.unique_id not in gateway.entities[DOMAIN] ): entities.append(DeconzCover(light, gateway)) diff --git a/homeassistant/components/deconz/deconz_device.py b/homeassistant/components/deconz/deconz_device.py index 06b86ee214a..fe9eaa8ff60 100644 --- a/homeassistant/components/deconz/deconz_device.py +++ b/homeassistant/components/deconz/deconz_device.py @@ -1,4 +1,5 @@ """Base class for deCONZ devices.""" + from homeassistant.core import callback from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE from homeassistant.helpers.dispatcher import async_dispatcher_connect diff --git a/homeassistant/components/deconz/deconz_event.py b/homeassistant/components/deconz/deconz_event.py index ce67d2a4b29..2d9799c4d02 100644 --- a/homeassistant/components/deconz/deconz_event.py +++ b/homeassistant/components/deconz/deconz_event.py @@ -40,23 +40,24 @@ async def async_setup_events(gateway) -> None: @callback def async_add_sensor(sensors=gateway.api.sensors.values()): """Create DeconzEvent.""" + new_events = [] + known_events = {event.unique_id for event in gateway.events} + for sensor in sensors: if not gateway.option_allow_clip_sensor and sensor.type.startswith("CLIP"): continue - if ( - sensor.type not in Switch.ZHATYPE + AncillaryControl.ZHATYPE - or sensor.unique_id in {event.unique_id for event in gateway.events} - ): + if sensor.unique_id in known_events: continue - if sensor.type in Switch.ZHATYPE: - new_event = DeconzEvent(sensor, gateway) + if isinstance(sensor, Switch): + new_events.append(DeconzEvent(sensor, gateway)) - elif sensor.type in AncillaryControl.ZHATYPE: - new_event = DeconzAlarmEvent(sensor, gateway) + elif isinstance(sensor, AncillaryControl): + new_events.append(DeconzAlarmEvent(sensor, gateway)) + for new_event in new_events: gateway.hass.async_create_task(new_event.async_update_device_registry()) gateway.events.append(new_event) diff --git a/homeassistant/components/deconz/fan.py b/homeassistant/components/deconz/fan.py index 8a94b78b85f..38fc087cdfd 100644 --- a/homeassistant/components/deconz/fan.py +++ b/homeassistant/components/deconz/fan.py @@ -1,6 +1,8 @@ """Support for deCONZ fans.""" from __future__ import annotations +from pydeconz.light import Fan + from homeassistant.components.fan import ( DOMAIN, SPEED_HIGH, @@ -17,7 +19,7 @@ from homeassistant.util.percentage import ( percentage_to_ordered_list_item, ) -from .const import FANS, NEW_LIGHT +from .const import NEW_LIGHT from .deconz_device import DeconzDevice from .gateway import get_gateway_from_config_entry @@ -39,7 +41,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities) -> None: for light in lights: - if light.type in FANS and light.unique_id not in gateway.entities[DOMAIN]: + if ( + isinstance(light, Fan) + and light.unique_id not in gateway.entities[DOMAIN] + ): entities.append(DeconzFan(light, gateway)) if entities: diff --git a/homeassistant/components/deconz/light.py b/homeassistant/components/deconz/light.py index 3c48fbc5177..2202bdbe58f 100644 --- a/homeassistant/components/deconz/light.py +++ b/homeassistant/components/deconz/light.py @@ -2,6 +2,8 @@ from __future__ import annotations +from pydeconz.light import Light + from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, @@ -28,25 +30,12 @@ from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.util.color import color_hs_to_xy -from .const import ( - COVER_TYPES, - DOMAIN as DECONZ_DOMAIN, - LOCK_TYPES, - NEW_GROUP, - NEW_LIGHT, - POWER_PLUGS, - SIRENS, -) +from .const import DOMAIN as DECONZ_DOMAIN, NEW_GROUP, NEW_LIGHT, POWER_PLUGS from .deconz_device import DeconzDevice from .gateway import get_gateway_from_config_entry -CONTROLLER = ["Configuration tool"] DECONZ_GROUP = "is_deconz_group" -OTHER_LIGHT_RESOURCE_TYPES = ( - CONTROLLER + COVER_TYPES + LOCK_TYPES + POWER_PLUGS + SIRENS -) - async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the deCONZ lights and groups from a config entry.""" @@ -60,7 +49,8 @@ async def async_setup_entry(hass, config_entry, async_add_entities): for light in lights: if ( - light.type not in OTHER_LIGHT_RESOURCE_TYPES + isinstance(light, Light) + and light.type not in POWER_PLUGS and light.unique_id not in gateway.entities[DOMAIN] ): entities.append(DeconzLight(light, gateway)) diff --git a/homeassistant/components/deconz/lock.py b/homeassistant/components/deconz/lock.py index 19770734087..bb23ec4be7a 100644 --- a/homeassistant/components/deconz/lock.py +++ b/homeassistant/components/deconz/lock.py @@ -1,9 +1,13 @@ """Support for deCONZ locks.""" + +from pydeconz.light import Lock +from pydeconz.sensor import DoorLock + from homeassistant.components.lock import DOMAIN, LockEntity from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from .const import LOCK_TYPES, NEW_LIGHT, NEW_SENSOR +from .const import NEW_LIGHT, NEW_SENSOR from .deconz_device import DeconzDevice from .gateway import get_gateway_from_config_entry @@ -21,7 +25,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): for light in lights: if ( - light.type in LOCK_TYPES + isinstance(light, Lock) and light.unique_id not in gateway.entities[DOMAIN] ): entities.append(DeconzLock(light, gateway)) @@ -43,7 +47,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): for sensor in sensors: if ( - sensor.type in LOCK_TYPES + isinstance(sensor, DoorLock) and sensor.unique_id not in gateway.entities[DOMAIN] ): entities.append(DeconzLock(sensor, gateway)) diff --git a/homeassistant/components/deconz/sensor.py b/homeassistant/components/deconz/sensor.py index 5c826e61516..e0e979ccf0b 100644 --- a/homeassistant/components/deconz/sensor.py +++ b/homeassistant/components/deconz/sensor.py @@ -1,10 +1,9 @@ """Support for deCONZ sensors.""" from pydeconz.sensor import ( - AncillaryControl, + AirQuality, Battery, Consumption, Daylight, - DoorLock, Humidity, LightLevel, Power, @@ -12,6 +11,7 @@ from pydeconz.sensor import ( Switch, Temperature, Thermostat, + Time, ) from homeassistant.components.sensor import ( @@ -48,6 +48,18 @@ from .const import ATTR_DARK, ATTR_ON, NEW_SENSOR from .deconz_device import DeconzDevice from .gateway import get_gateway_from_config_entry +DECONZ_SENSORS = ( + AirQuality, + Consumption, + Daylight, + Humidity, + LightLevel, + Power, + Pressure, + Temperature, + Time, +) + ATTR_CURRENT = "current" ATTR_POWER = "power" ATTR_DAYLIGHT = "daylight" @@ -136,13 +148,8 @@ async def async_setup_entry(hass, config_entry, async_add_entities): battery_handler.create_tracker(sensor) if ( - not sensor.BINARY - and sensor.type - not in AncillaryControl.ZHATYPE - + Battery.ZHATYPE - + DoorLock.ZHATYPE - + Switch.ZHATYPE - + Thermostat.ZHATYPE + isinstance(sensor, DECONZ_SENSORS) + and not isinstance(sensor, Thermostat) and sensor.unique_id not in gateway.entities[DOMAIN] ): entities.append(DeconzSensor(sensor, gateway)) @@ -301,7 +308,7 @@ class DeconzBattery(DeconzDevice, SensorEntity): """Return the state attributes of the battery.""" attr = {} - if self._device.type in Switch.ZHATYPE: + if isinstance(self._device, Switch): for event in self.gateway.events: if self._device == event.device: attr[ATTR_EVENT_ID] = event.event_id diff --git a/homeassistant/components/deconz/switch.py b/homeassistant/components/deconz/switch.py index 8f31af3a6cf..f7559e37838 100644 --- a/homeassistant/components/deconz/switch.py +++ b/homeassistant/components/deconz/switch.py @@ -1,9 +1,12 @@ """Support for deCONZ switches.""" + +from pydeconz.light import Siren + from homeassistant.components.switch import DOMAIN, SwitchEntity from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from .const import DOMAIN as DECONZ_DOMAIN, NEW_LIGHT, POWER_PLUGS, SIRENS +from .const import DOMAIN as DECONZ_DOMAIN, NEW_LIGHT, POWER_PLUGS from .deconz_device import DeconzDevice from .gateway import get_gateway_from_config_entry @@ -20,10 +23,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities): # Siren platform replacing sirens in switch platform added in 2021.10 for light in gateway.api.lights.values(): - if light.type not in SIRENS: - continue - if entity_id := entity_registry.async_get_entity_id( - DOMAIN, DECONZ_DOMAIN, light.unique_id + if isinstance(light, Siren) and ( + entity_id := entity_registry.async_get_entity_id( + DOMAIN, DECONZ_DOMAIN, light.unique_id + ) ): entity_registry.async_remove(entity_id) diff --git a/tests/components/deconz/test_sensor.py b/tests/components/deconz/test_sensor.py index 7f8bce24d80..33f9c8c6a2c 100644 --- a/tests/components/deconz/test_sensor.py +++ b/tests/components/deconz/test_sensor.py @@ -15,7 +15,6 @@ from homeassistant.const import ( DEVICE_CLASS_POWER, DEVICE_CLASS_TEMPERATURE, STATE_UNAVAILABLE, - STATE_UNKNOWN, ) from homeassistant.helpers import entity_registry as er from homeassistant.util import dt @@ -553,5 +552,4 @@ async def test_unsupported_sensor(hass, aioclient_mock): with patch.dict(DECONZ_WEB_REQUEST, data): await setup_deconz_integration(hass, aioclient_mock) - assert len(hass.states.async_all()) == 1 - assert hass.states.get("sensor.name").state == STATE_UNKNOWN + assert len(hass.states.async_all()) == 0 From 18340b2fd9262f8c1d52ce23fbc73f2d6e122c4f Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Wed, 29 Sep 2021 16:33:35 -0400 Subject: [PATCH 705/843] Bump zwave-js-server-python to 0.31.1 (#56517) --- homeassistant/components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index c7b2b35837b..1a9091005e2 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -3,7 +3,7 @@ "name": "Z-Wave JS", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zwave_js", - "requirements": ["zwave-js-server-python==0.30.0"], + "requirements": ["zwave-js-server-python==0.31.1"], "codeowners": ["@home-assistant/z-wave"], "dependencies": ["usb", "http", "websocket_api"], "iot_class": "local_push", diff --git a/requirements_all.txt b/requirements_all.txt index b9ca097c02a..250d29c88e5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2504,4 +2504,4 @@ zigpy==0.38.0 zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.30.0 +zwave-js-server-python==0.31.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1e78ff6d382..7e8efd26c01 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1430,4 +1430,4 @@ zigpy-znp==0.5.4 zigpy==0.38.0 # homeassistant.components.zwave_js -zwave-js-server-python==0.30.0 +zwave-js-server-python==0.31.1 From f8903e11e0f95f9e123c02ce98891a869986e7b9 Mon Sep 17 00:00:00 2001 From: RDFurman Date: Wed, 29 Sep 2021 15:10:22 -0600 Subject: [PATCH 706/843] Fix honeywell connection error (#56757) * Catch ConnectionError and retry * Add unload and reload functionality * Update listener on retry * Call reload directly and await Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../components/honeywell/__init__.py | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/honeywell/__init__.py b/homeassistant/components/honeywell/__init__.py index 03dc9ea9c8c..c61e4fc18eb 100644 --- a/homeassistant/components/honeywell/__init__.py +++ b/homeassistant/components/honeywell/__init__.py @@ -43,15 +43,30 @@ async def async_setup_entry(hass, config): _LOGGER.debug("No devices found") return False - data = HoneywellData(hass, client, username, password, devices) + data = HoneywellData(hass, config, client, username, password, devices) await data.async_update() hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][config.entry_id] = data hass.config_entries.async_setup_platforms(config, PLATFORMS) + config.async_on_unload(config.add_update_listener(update_listener)) + return True +async def update_listener(hass, config) -> None: + """Update listener.""" + await hass.config_entries.async_reload(config.entry_id) + + +async def async_unload_entry(hass, config): + """Unload the config config and platforms.""" + unload_ok = await hass.config_entries.async_unload_platforms(config, PLATFORMS) + if unload_ok: + hass.data.pop(DOMAIN) + return unload_ok + + def get_somecomfort_client(username, password): """Initialize the somecomfort client.""" try: @@ -70,9 +85,10 @@ def get_somecomfort_client(username, password): class HoneywellData: """Get the latest data and update.""" - def __init__(self, hass, client, username, password, devices): + def __init__(self, hass, config, client, username, password, devices): """Initialize the data object.""" self._hass = hass + self._config = config self._client = client self._username = username self._password = password @@ -102,6 +118,7 @@ class HoneywellData: return False self.devices = devices + await self._hass.config_entries.async_reload(self._config.entry_id) return True async def _refresh_devices(self): @@ -120,8 +137,9 @@ class HoneywellData: break except ( somecomfort.client.APIRateLimited, - OSError, + somecomfort.client.ConnectionError, somecomfort.client.ConnectionTimeout, + OSError, ) as exp: retries -= 1 if retries == 0: From 7dfcccd43ede50d04402980db6835e4f7c6e890d Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Wed, 29 Sep 2021 23:57:07 +0200 Subject: [PATCH 707/843] Bump holidays to 0.11.3.1 (#56804) --- homeassistant/components/workday/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index c22df102c48..f75e25dd97e 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -2,7 +2,7 @@ "domain": "workday", "name": "Workday", "documentation": "https://www.home-assistant.io/integrations/workday", - "requirements": ["holidays==0.11.3"], + "requirements": ["holidays==0.11.3.1"], "codeowners": ["@fabaff"], "quality_scale": "internal", "iot_class": "local_polling" diff --git a/requirements_all.txt b/requirements_all.txt index 250d29c88e5..56a183c57de 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -807,7 +807,7 @@ hlk-sw16==0.0.9 hole==0.5.1 # homeassistant.components.workday -holidays==0.11.3 +holidays==0.11.3.1 # homeassistant.components.frontend home-assistant-frontend==20210922.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7e8efd26c01..c49eeffa9ab 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -482,7 +482,7 @@ hlk-sw16==0.0.9 hole==0.5.1 # homeassistant.components.workday -holidays==0.11.3 +holidays==0.11.3.1 # homeassistant.components.frontend home-assistant-frontend==20210922.0 From 8c3fc95fb81174fcf167d9e931605042b43e484a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 29 Sep 2021 23:57:16 +0200 Subject: [PATCH 708/843] Fallback to state machine in statistics (#56785) --- homeassistant/components/sensor/recorder.py | 91 +++++++++++---------- tests/components/sensor/test_recorder.py | 58 +++++++++++++ 2 files changed, 104 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 07a55795677..e09d1fad4c3 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -2,13 +2,13 @@ from __future__ import annotations from collections import defaultdict -from collections.abc import Callable +from collections.abc import Callable, Iterable import datetime import itertools import logging import math -from homeassistant.components.recorder import history, statistics +from homeassistant.components.recorder import history, is_entity_recorded, statistics from homeassistant.components.recorder.models import ( StatisticData, StatisticMetaData, @@ -125,19 +125,19 @@ WARN_UNSUPPORTED_UNIT = "sensor_warn_unsupported_unit" WARN_UNSTABLE_UNIT = "sensor_warn_unstable_unit" -def _get_entities(hass: HomeAssistant) -> list[tuple[str, str, str | None, str | None]]: - """Get (entity_id, state_class, device_class) of all sensors for which to compile statistics.""" +def _get_sensor_states(hass: HomeAssistant) -> list[State]: + """Get the current state of all sensors for which to compile statistics.""" all_sensors = hass.states.all(DOMAIN) - entity_ids = [] + statistics_sensors = [] for state in all_sensors: - if (state_class := state.attributes.get(ATTR_STATE_CLASS)) not in STATE_CLASSES: + if not is_entity_recorded(hass, state.entity_id): continue - device_class = state.attributes.get(ATTR_DEVICE_CLASS) - unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - entity_ids.append((state.entity_id, state_class, device_class, unit)) + if (state.attributes.get(ATTR_STATE_CLASS)) not in STATE_CLASSES: + continue + statistics_sensors.append(state) - return entity_ids + return statistics_sensors def _time_weighted_average( @@ -193,7 +193,7 @@ def _parse_float(state: str) -> float: def _normalize_states( hass: HomeAssistant, - entity_history: list[State], + entity_history: Iterable[State], device_class: str | None, entity_id: str, ) -> tuple[str | None, list[tuple[float, State]]]: @@ -298,18 +298,18 @@ def reset_detected( return state < 0.9 * previous_state -def _wanted_statistics( - entities: list[tuple[str, str, str | None, str | None]] -) -> dict[str, set[str]]: +def _wanted_statistics(sensor_states: list[State]) -> dict[str, set[str]]: """Prepare a dict with wanted statistics for entities.""" wanted_statistics = {} - for entity_id, state_class, device_class, _ in entities: + for state in sensor_states: + state_class = state.attributes[ATTR_STATE_CLASS] + device_class = state.attributes.get(ATTR_DEVICE_CLASS) if device_class in DEVICE_CLASS_STATISTICS[state_class]: - wanted_statistics[entity_id] = DEVICE_CLASS_STATISTICS[state_class][ + wanted_statistics[state.entity_id] = DEVICE_CLASS_STATISTICS[state_class][ device_class ] else: - wanted_statistics[entity_id] = DEFAULT_STATISTICS[state_class] + wanted_statistics[state.entity_id] = DEFAULT_STATISTICS[state_class] return wanted_statistics @@ -337,12 +337,13 @@ def compile_statistics( # noqa: C901 """ result: list[StatisticResult] = [] - entities = _get_entities(hass) - - wanted_statistics = _wanted_statistics(entities) + sensor_states = _get_sensor_states(hass) + wanted_statistics = _wanted_statistics(sensor_states) # Get history between start and end - entities_full_history = [i[0] for i in entities if "sum" in wanted_statistics[i[0]]] + entities_full_history = [ + i.entity_id for i in sensor_states if "sum" in wanted_statistics[i.entity_id] + ] history_list = {} if entities_full_history: history_list = history.get_significant_states( # type: ignore @@ -353,7 +354,9 @@ def compile_statistics( # noqa: C901 significant_changes_only=False, ) entities_significant_history = [ - i[0] for i in entities if "sum" not in wanted_statistics[i[0]] + i.entity_id + for i in sensor_states + if "sum" not in wanted_statistics[i.entity_id] ] if entities_significant_history: _history_list = history.get_significant_states( # type: ignore @@ -363,16 +366,19 @@ def compile_statistics( # noqa: C901 entity_ids=entities_significant_history, ) history_list = {**history_list, **_history_list} + # If there are no recent state changes, the sensor's state may already be pruned + # from the recorder. Get the state from the state machine instead. + for _state in sensor_states: + if _state.entity_id not in history_list: + history_list[_state.entity_id] = (_state,) - for ( # pylint: disable=too-many-nested-blocks - entity_id, - state_class, - device_class, - _, - ) in entities: + for _state in sensor_states: # pylint: disable=too-many-nested-blocks + entity_id = _state.entity_id if entity_id not in history_list: continue + state_class = _state.attributes[ATTR_STATE_CLASS] + device_class = _state.attributes.get(ATTR_DEVICE_CLASS) entity_history = history_list[entity_id] unit, fstates = _normalize_states(hass, entity_history, device_class, entity_id) @@ -517,11 +523,15 @@ def compile_statistics( # noqa: C901 def list_statistic_ids(hass: HomeAssistant, statistic_type: str | None = None) -> dict: """Return statistic_ids and meta data.""" - entities = _get_entities(hass) + entities = _get_sensor_states(hass) statistic_ids = {} - for entity_id, state_class, device_class, native_unit in entities: + for state in entities: + state_class = state.attributes[ATTR_STATE_CLASS] + device_class = state.attributes.get(ATTR_DEVICE_CLASS) + native_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + if device_class in DEVICE_CLASS_STATISTICS[state_class]: provided_statistics = DEVICE_CLASS_STATISTICS[state_class][device_class] else: @@ -530,9 +540,6 @@ def list_statistic_ids(hass: HomeAssistant, statistic_type: str | None = None) - if statistic_type is not None and statistic_type not in provided_statistics: continue - state = hass.states.get(entity_id) - assert state - if ( "sum" in provided_statistics and ATTR_LAST_RESET not in state.attributes @@ -541,14 +548,14 @@ def list_statistic_ids(hass: HomeAssistant, statistic_type: str | None = None) - continue if device_class not in UNIT_CONVERSIONS: - statistic_ids[entity_id] = native_unit + statistic_ids[state.entity_id] = native_unit continue if native_unit not in UNIT_CONVERSIONS[device_class]: continue statistics_unit = DEVICE_CLASS_UNITS[device_class] - statistic_ids[entity_id] = statistics_unit + statistic_ids[state.entity_id] = statistics_unit return statistic_ids @@ -559,17 +566,11 @@ def validate_statistics( """Validate statistics.""" validation_result = defaultdict(list) - entities = _get_entities(hass) - - for ( - entity_id, - _state_class, - device_class, - _unit, - ) in entities: - state = hass.states.get(entity_id) - assert state is not None + sensor_states = _get_sensor_states(hass) + for state in sensor_states: + entity_id = state.entity_id + device_class = state.attributes.get(ATTR_DEVICE_CLASS) state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) if device_class not in UNIT_CONVERSIONS: diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index de4fc3f4fb6..1c9caf07982 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -119,6 +119,64 @@ def test_compile_hourly_statistics( assert "Error while processing event StatisticsTask" not in caplog.text +@pytest.mark.parametrize( + "device_class,unit,native_unit", + [ + (None, "%", "%"), + ], +) +def test_compile_hourly_statistics_purged_state_changes( + hass_recorder, caplog, device_class, unit, native_unit +): + """Test compiling hourly statistics.""" + zero = dt_util.utcnow() + hass = hass_recorder() + recorder = hass.data[DATA_INSTANCE] + setup_component(hass, "sensor", {}) + attributes = { + "device_class": device_class, + "state_class": "measurement", + "unit_of_measurement": unit, + } + four, states = record_states(hass, zero, "sensor.test1", attributes) + hist = history.get_significant_states(hass, zero, four) + assert dict(states) == dict(hist) + + mean = min = max = float(hist["sensor.test1"][-1].state) + + # Purge all states from the database + with patch("homeassistant.components.recorder.dt_util.utcnow", return_value=four): + hass.services.call("recorder", "purge", {"keep_days": 0}) + hass.block_till_done() + wait_recording_done(hass) + hist = history.get_significant_states(hass, zero, four) + assert not hist + + recorder.do_adhoc_statistics(start=zero) + wait_recording_done(hass) + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + {"statistic_id": "sensor.test1", "unit_of_measurement": native_unit} + ] + stats = statistics_during_period(hass, zero, period="5minute") + assert stats == { + "sensor.test1": [ + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(minutes=5)), + "mean": approx(mean), + "min": approx(min), + "max": approx(max), + "last_reset": None, + "state": None, + "sum": None, + } + ] + } + assert "Error while processing event StatisticsTask" not in caplog.text + + @pytest.mark.parametrize("attributes", [TEMPERATURE_SENSOR_ATTRIBUTES]) def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes): """Test compiling hourly statistics for unsupported sensor.""" From fa716d92ade62aa12455718f1e0b512b4008731d Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 30 Sep 2021 00:04:24 +0200 Subject: [PATCH 709/843] Manage s2 keys in zwave_js (#56783) --- homeassistant/components/zwave_js/__init__.py | 52 +- homeassistant/components/zwave_js/addon.py | 59 ++- .../components/zwave_js/config_flow.py | 116 ++++- homeassistant/components/zwave_js/const.py | 8 + tests/components/zwave_js/test_config_flow.py | 469 +++++++++++++++--- tests/components/zwave_js/test_init.py | 86 +++- 6 files changed, 683 insertions(+), 107 deletions(-) diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index 77fcf44b4d8..c50292ea427 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -55,9 +55,17 @@ from .const import ( ATTR_VALUE_RAW, CONF_ADDON_DEVICE, CONF_ADDON_NETWORK_KEY, + CONF_ADDON_S0_LEGACY_KEY, + CONF_ADDON_S2_ACCESS_CONTROL_KEY, + CONF_ADDON_S2_AUTHENTICATED_KEY, + CONF_ADDON_S2_UNAUTHENTICATED_KEY, CONF_DATA_COLLECTION_OPTED_IN, CONF_INTEGRATION_CREATED_ADDON, CONF_NETWORK_KEY, + CONF_S0_LEGACY_KEY, + CONF_S2_ACCESS_CONTROL_KEY, + CONF_S2_AUTHENTICATED_KEY, + CONF_S2_UNAUTHENTICATED_KEY, CONF_USB_PATH, CONF_USE_ADDON, DATA_CLIENT, @@ -653,29 +661,61 @@ async def async_ensure_addon_running(hass: HomeAssistant, entry: ConfigEntry) -> raise ConfigEntryNotReady from err usb_path: str = entry.data[CONF_USB_PATH] - network_key: str = entry.data[CONF_NETWORK_KEY] + # s0_legacy_key was saved as network_key before s2 was added. + s0_legacy_key: str = entry.data.get(CONF_S0_LEGACY_KEY, "") + if not s0_legacy_key: + s0_legacy_key = entry.data.get(CONF_NETWORK_KEY, "") + s2_access_control_key: str = entry.data.get(CONF_S2_ACCESS_CONTROL_KEY, "") + s2_authenticated_key: str = entry.data.get(CONF_S2_AUTHENTICATED_KEY, "") + s2_unauthenticated_key: str = entry.data.get(CONF_S2_UNAUTHENTICATED_KEY, "") addon_state = addon_info.state if addon_state == AddonState.NOT_INSTALLED: addon_manager.async_schedule_install_setup_addon( - usb_path, network_key, catch_error=True + usb_path, + s0_legacy_key, + s2_access_control_key, + s2_authenticated_key, + s2_unauthenticated_key, + catch_error=True, ) raise ConfigEntryNotReady if addon_state == AddonState.NOT_RUNNING: addon_manager.async_schedule_setup_addon( - usb_path, network_key, catch_error=True + usb_path, + s0_legacy_key, + s2_access_control_key, + s2_authenticated_key, + s2_unauthenticated_key, + catch_error=True, ) raise ConfigEntryNotReady addon_options = addon_info.options addon_device = addon_options[CONF_ADDON_DEVICE] - addon_network_key = addon_options[CONF_ADDON_NETWORK_KEY] + # s0_legacy_key was saved as network_key before s2 was added. + addon_s0_legacy_key = addon_options.get(CONF_ADDON_S0_LEGACY_KEY, "") + if not addon_s0_legacy_key: + addon_s0_legacy_key = addon_options.get(CONF_ADDON_NETWORK_KEY, "") + addon_s2_access_control_key = addon_options.get( + CONF_ADDON_S2_ACCESS_CONTROL_KEY, "" + ) + addon_s2_authenticated_key = addon_options.get(CONF_ADDON_S2_AUTHENTICATED_KEY, "") + addon_s2_unauthenticated_key = addon_options.get( + CONF_ADDON_S2_UNAUTHENTICATED_KEY, "" + ) updates = {} if usb_path != addon_device: updates[CONF_USB_PATH] = addon_device - if network_key != addon_network_key: - updates[CONF_NETWORK_KEY] = addon_network_key + if s0_legacy_key != addon_s0_legacy_key: + updates[CONF_S0_LEGACY_KEY] = addon_s0_legacy_key + if s2_access_control_key != addon_s2_access_control_key: + updates[CONF_S2_ACCESS_CONTROL_KEY] = addon_s2_access_control_key + if s2_authenticated_key != addon_s2_authenticated_key: + updates[CONF_S2_AUTHENTICATED_KEY] = addon_s2_authenticated_key + if s2_unauthenticated_key != addon_s2_unauthenticated_key: + updates[CONF_S2_UNAUTHENTICATED_KEY] = addon_s2_unauthenticated_key if updates: hass.config_entries.async_update_entry(entry, data={**entry.data, **updates}) diff --git a/homeassistant/components/zwave_js/addon.py b/homeassistant/components/zwave_js/addon.py index 29ae887b4bc..38fdee9a051 100644 --- a/homeassistant/components/zwave_js/addon.py +++ b/homeassistant/components/zwave_js/addon.py @@ -24,7 +24,16 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.singleton import singleton -from .const import ADDON_SLUG, CONF_ADDON_DEVICE, CONF_ADDON_NETWORK_KEY, DOMAIN, LOGGER +from .const import ( + ADDON_SLUG, + CONF_ADDON_DEVICE, + CONF_ADDON_S0_LEGACY_KEY, + CONF_ADDON_S2_ACCESS_CONTROL_KEY, + CONF_ADDON_S2_AUTHENTICATED_KEY, + CONF_ADDON_S2_UNAUTHENTICATED_KEY, + DOMAIN, + LOGGER, +) F = TypeVar("F", bound=Callable[..., Any]) # pylint: disable=invalid-name @@ -170,7 +179,13 @@ class AddonManager: @callback def async_schedule_install_setup_addon( - self, usb_path: str, network_key: str, catch_error: bool = False + self, + usb_path: str, + s0_legacy_key: str, + s2_access_control_key: str, + s2_authenticated_key: str, + s2_unauthenticated_key: str, + catch_error: bool = False, ) -> asyncio.Task: """Schedule a task that installs and sets up the Z-Wave JS add-on. @@ -180,7 +195,14 @@ class AddonManager: LOGGER.info("Z-Wave JS add-on is not installed. Installing add-on") self._install_task = self._async_schedule_addon_operation( self.async_install_addon, - partial(self.async_configure_addon, usb_path, network_key), + partial( + self.async_configure_addon, + usb_path, + s0_legacy_key, + s2_access_control_key, + s2_authenticated_key, + s2_unauthenticated_key, + ), self.async_start_addon, catch_error=catch_error, ) @@ -260,13 +282,23 @@ class AddonManager: """Stop the Z-Wave JS add-on.""" await async_stop_addon(self._hass, ADDON_SLUG) - async def async_configure_addon(self, usb_path: str, network_key: str) -> None: + async def async_configure_addon( + self, + usb_path: str, + s0_legacy_key: str, + s2_access_control_key: str, + s2_authenticated_key: str, + s2_unauthenticated_key: str, + ) -> None: """Configure and start Z-Wave JS add-on.""" addon_info = await self.async_get_addon_info() new_addon_options = { CONF_ADDON_DEVICE: usb_path, - CONF_ADDON_NETWORK_KEY: network_key, + CONF_ADDON_S0_LEGACY_KEY: s0_legacy_key, + CONF_ADDON_S2_ACCESS_CONTROL_KEY: s2_access_control_key, + CONF_ADDON_S2_AUTHENTICATED_KEY: s2_authenticated_key, + CONF_ADDON_S2_UNAUTHENTICATED_KEY: s2_unauthenticated_key, } if new_addon_options != addon_info.options: @@ -274,7 +306,13 @@ class AddonManager: @callback def async_schedule_setup_addon( - self, usb_path: str, network_key: str, catch_error: bool = False + self, + usb_path: str, + s0_legacy_key: str, + s2_access_control_key: str, + s2_authenticated_key: str, + s2_unauthenticated_key: str, + catch_error: bool = False, ) -> asyncio.Task: """Schedule a task that configures and starts the Z-Wave JS add-on. @@ -283,7 +321,14 @@ class AddonManager: if not self._start_task or self._start_task.done(): LOGGER.info("Z-Wave JS add-on is not running. Starting add-on") self._start_task = self._async_schedule_addon_operation( - partial(self.async_configure_addon, usb_path, network_key), + partial( + self.async_configure_addon, + usb_path, + s0_legacy_key, + s2_access_control_key, + s2_authenticated_key, + s2_unauthenticated_key, + ), self.async_start_addon, catch_error=catch_error, ) diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index c95078caf04..733dad50c52 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -31,8 +31,15 @@ from .const import ( CONF_ADDON_EMULATE_HARDWARE, CONF_ADDON_LOG_LEVEL, CONF_ADDON_NETWORK_KEY, + CONF_ADDON_S0_LEGACY_KEY, + CONF_ADDON_S2_ACCESS_CONTROL_KEY, + CONF_ADDON_S2_AUTHENTICATED_KEY, + CONF_ADDON_S2_UNAUTHENTICATED_KEY, CONF_INTEGRATION_CREATED_ADDON, - CONF_NETWORK_KEY, + CONF_S0_LEGACY_KEY, + CONF_S2_ACCESS_CONTROL_KEY, + CONF_S2_AUTHENTICATED_KEY, + CONF_S2_UNAUTHENTICATED_KEY, CONF_USB_PATH, CONF_USE_ADDON, DOMAIN, @@ -59,7 +66,10 @@ ADDON_LOG_LEVELS = { } ADDON_USER_INPUT_MAP = { CONF_ADDON_DEVICE: CONF_USB_PATH, - CONF_ADDON_NETWORK_KEY: CONF_NETWORK_KEY, + CONF_ADDON_S0_LEGACY_KEY: CONF_S0_LEGACY_KEY, + CONF_ADDON_S2_ACCESS_CONTROL_KEY: CONF_S2_ACCESS_CONTROL_KEY, + CONF_ADDON_S2_AUTHENTICATED_KEY: CONF_S2_AUTHENTICATED_KEY, + CONF_ADDON_S2_UNAUTHENTICATED_KEY: CONF_S2_UNAUTHENTICATED_KEY, CONF_ADDON_LOG_LEVEL: CONF_LOG_LEVEL, CONF_ADDON_EMULATE_HARDWARE: CONF_EMULATE_HARDWARE, } @@ -113,7 +123,10 @@ class BaseZwaveJSFlow(FlowHandler): def __init__(self) -> None: """Set up flow instance.""" - self.network_key: str | None = None + self.s0_legacy_key: str | None = None + self.s2_access_control_key: str | None = None + self.s2_authenticated_key: str | None = None + self.s2_unauthenticated_key: str | None = None self.usb_path: str | None = None self.ws_address: str | None = None self.restart_addon: bool = False @@ -460,7 +473,16 @@ class ConfigFlow(BaseZwaveJSFlow, config_entries.ConfigFlow, domain=DOMAIN): if addon_info.state == AddonState.RUNNING: addon_config = addon_info.options self.usb_path = addon_config[CONF_ADDON_DEVICE] - self.network_key = addon_config.get(CONF_ADDON_NETWORK_KEY, "") + self.s0_legacy_key = addon_config.get(CONF_ADDON_S0_LEGACY_KEY, "") + self.s2_access_control_key = addon_config.get( + CONF_ADDON_S2_ACCESS_CONTROL_KEY, "" + ) + self.s2_authenticated_key = addon_config.get( + CONF_ADDON_S2_AUTHENTICATED_KEY, "" + ) + self.s2_unauthenticated_key = addon_config.get( + CONF_ADDON_S2_UNAUTHENTICATED_KEY, "" + ) return await self.async_step_finish_addon_setup() if addon_info.state == AddonState.NOT_RUNNING: @@ -476,13 +498,19 @@ class ConfigFlow(BaseZwaveJSFlow, config_entries.ConfigFlow, domain=DOMAIN): addon_config = addon_info.options if user_input is not None: - self.network_key = user_input[CONF_NETWORK_KEY] + self.s0_legacy_key = user_input[CONF_S0_LEGACY_KEY] + self.s2_access_control_key = user_input[CONF_S2_ACCESS_CONTROL_KEY] + self.s2_authenticated_key = user_input[CONF_S2_AUTHENTICATED_KEY] + self.s2_unauthenticated_key = user_input[CONF_S2_UNAUTHENTICATED_KEY] self.usb_path = user_input[CONF_USB_PATH] new_addon_config = { **addon_config, CONF_ADDON_DEVICE: self.usb_path, - CONF_ADDON_NETWORK_KEY: self.network_key, + CONF_ADDON_S0_LEGACY_KEY: self.s0_legacy_key, + CONF_ADDON_S2_ACCESS_CONTROL_KEY: self.s2_access_control_key, + CONF_ADDON_S2_AUTHENTICATED_KEY: self.s2_authenticated_key, + CONF_ADDON_S2_UNAUTHENTICATED_KEY: self.s2_unauthenticated_key, } if new_addon_config != addon_config: @@ -491,12 +519,32 @@ class ConfigFlow(BaseZwaveJSFlow, config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_start_addon() usb_path = self.usb_path or addon_config.get(CONF_ADDON_DEVICE) or "" - network_key = addon_config.get(CONF_ADDON_NETWORK_KEY, self.network_key or "") + s0_legacy_key = addon_config.get( + CONF_ADDON_S0_LEGACY_KEY, self.s0_legacy_key or "" + ) + s2_access_control_key = addon_config.get( + CONF_ADDON_S2_ACCESS_CONTROL_KEY, self.s2_access_control_key or "" + ) + s2_authenticated_key = addon_config.get( + CONF_ADDON_S2_AUTHENTICATED_KEY, self.s2_authenticated_key or "" + ) + s2_unauthenticated_key = addon_config.get( + CONF_ADDON_S2_UNAUTHENTICATED_KEY, self.s2_unauthenticated_key or "" + ) data_schema = vol.Schema( { vol.Required(CONF_USB_PATH, default=usb_path): str, - vol.Optional(CONF_NETWORK_KEY, default=network_key): str, + vol.Optional(CONF_S0_LEGACY_KEY, default=s0_legacy_key): str, + vol.Optional( + CONF_S2_ACCESS_CONTROL_KEY, default=s2_access_control_key + ): str, + vol.Optional( + CONF_S2_AUTHENTICATED_KEY, default=s2_authenticated_key + ): str, + vol.Optional( + CONF_S2_UNAUTHENTICATED_KEY, default=s2_unauthenticated_key + ): str, } ) @@ -531,7 +579,10 @@ class ConfigFlow(BaseZwaveJSFlow, config_entries.ConfigFlow, domain=DOMAIN): updates={ CONF_URL: self.ws_address, CONF_USB_PATH: self.usb_path, - CONF_NETWORK_KEY: self.network_key, + CONF_S0_LEGACY_KEY: self.s0_legacy_key, + CONF_S2_ACCESS_CONTROL_KEY: self.s2_access_control_key, + CONF_S2_AUTHENTICATED_KEY: self.s2_authenticated_key, + CONF_S2_UNAUTHENTICATED_KEY: self.s2_unauthenticated_key, } ) return self._async_create_entry_from_vars() @@ -548,7 +599,10 @@ class ConfigFlow(BaseZwaveJSFlow, config_entries.ConfigFlow, domain=DOMAIN): data={ CONF_URL: self.ws_address, CONF_USB_PATH: self.usb_path, - CONF_NETWORK_KEY: self.network_key, + CONF_S0_LEGACY_KEY: self.s0_legacy_key, + CONF_S2_ACCESS_CONTROL_KEY: self.s2_access_control_key, + CONF_S2_AUTHENTICATED_KEY: self.s2_authenticated_key, + CONF_S2_UNAUTHENTICATED_KEY: self.s2_unauthenticated_key, CONF_USE_ADDON: self.use_addon, CONF_INTEGRATION_CREATED_ADDON: self.integration_created_addon, }, @@ -658,13 +712,19 @@ class OptionsFlowHandler(BaseZwaveJSFlow, config_entries.OptionsFlow): addon_config = addon_info.options if user_input is not None: - self.network_key = user_input[CONF_NETWORK_KEY] + self.s0_legacy_key = user_input[CONF_S0_LEGACY_KEY] + self.s2_access_control_key = user_input[CONF_S2_ACCESS_CONTROL_KEY] + self.s2_authenticated_key = user_input[CONF_S2_AUTHENTICATED_KEY] + self.s2_unauthenticated_key = user_input[CONF_S2_UNAUTHENTICATED_KEY] self.usb_path = user_input[CONF_USB_PATH] new_addon_config = { **addon_config, CONF_ADDON_DEVICE: self.usb_path, - CONF_ADDON_NETWORK_KEY: self.network_key, + CONF_ADDON_S0_LEGACY_KEY: self.s0_legacy_key, + CONF_ADDON_S2_ACCESS_CONTROL_KEY: self.s2_access_control_key, + CONF_ADDON_S2_AUTHENTICATED_KEY: self.s2_authenticated_key, + CONF_ADDON_S2_UNAUTHENTICATED_KEY: self.s2_unauthenticated_key, CONF_ADDON_LOG_LEVEL: user_input[CONF_LOG_LEVEL], CONF_ADDON_EMULATE_HARDWARE: user_input[CONF_EMULATE_HARDWARE], } @@ -674,6 +734,8 @@ class OptionsFlowHandler(BaseZwaveJSFlow, config_entries.OptionsFlow): self.restart_addon = True # Copy the add-on config to keep the objects separate. self.original_addon_config = dict(addon_config) + # Remove legacy network_key + new_addon_config.pop(CONF_ADDON_NETWORK_KEY, None) await self._async_set_addon_config(new_addon_config) if addon_info.state == AddonState.RUNNING and not self.restart_addon: @@ -689,14 +751,34 @@ class OptionsFlowHandler(BaseZwaveJSFlow, config_entries.OptionsFlow): return await self.async_step_start_addon() usb_path = addon_config.get(CONF_ADDON_DEVICE, self.usb_path or "") - network_key = addon_config.get(CONF_ADDON_NETWORK_KEY, self.network_key or "") + s0_legacy_key = addon_config.get( + CONF_ADDON_S0_LEGACY_KEY, self.s0_legacy_key or "" + ) + s2_access_control_key = addon_config.get( + CONF_ADDON_S2_ACCESS_CONTROL_KEY, self.s2_access_control_key or "" + ) + s2_authenticated_key = addon_config.get( + CONF_ADDON_S2_AUTHENTICATED_KEY, self.s2_authenticated_key or "" + ) + s2_unauthenticated_key = addon_config.get( + CONF_ADDON_S2_UNAUTHENTICATED_KEY, self.s2_unauthenticated_key or "" + ) log_level = addon_config.get(CONF_ADDON_LOG_LEVEL, "info") emulate_hardware = addon_config.get(CONF_ADDON_EMULATE_HARDWARE, False) data_schema = vol.Schema( { vol.Required(CONF_USB_PATH, default=usb_path): str, - vol.Optional(CONF_NETWORK_KEY, default=network_key): str, + vol.Optional(CONF_S0_LEGACY_KEY, default=s0_legacy_key): str, + vol.Optional( + CONF_S2_ACCESS_CONTROL_KEY, default=s2_access_control_key + ): str, + vol.Optional( + CONF_S2_AUTHENTICATED_KEY, default=s2_authenticated_key + ): str, + vol.Optional( + CONF_S2_UNAUTHENTICATED_KEY, default=s2_unauthenticated_key + ): str, vol.Optional(CONF_LOG_LEVEL, default=log_level): vol.In( ADDON_LOG_LEVELS ), @@ -746,7 +828,10 @@ class OptionsFlowHandler(BaseZwaveJSFlow, config_entries.OptionsFlow): **self.config_entry.data, CONF_URL: self.ws_address, CONF_USB_PATH: self.usb_path, - CONF_NETWORK_KEY: self.network_key, + CONF_S0_LEGACY_KEY: self.s0_legacy_key, + CONF_S2_ACCESS_CONTROL_KEY: self.s2_access_control_key, + CONF_S2_AUTHENTICATED_KEY: self.s2_authenticated_key, + CONF_S2_UNAUTHENTICATED_KEY: self.s2_unauthenticated_key, CONF_USE_ADDON: True, CONF_INTEGRATION_CREATED_ADDON: self.integration_created_addon, } @@ -779,6 +864,7 @@ class OptionsFlowHandler(BaseZwaveJSFlow, config_entries.OptionsFlow): addon_config_input = { ADDON_USER_INPUT_MAP[addon_key]: addon_val for addon_key, addon_val in self.original_addon_config.items() + if addon_key in ADDON_USER_INPUT_MAP } _LOGGER.debug("Reverting add-on options, reason: %s", reason) return await self.async_step_configure_addon(addon_config_input) diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index 21a7941f097..e484d01fccb 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -9,8 +9,16 @@ CONF_ADDON_DEVICE = "device" CONF_ADDON_EMULATE_HARDWARE = "emulate_hardware" CONF_ADDON_LOG_LEVEL = "log_level" CONF_ADDON_NETWORK_KEY = "network_key" +CONF_ADDON_S0_LEGACY_KEY = "s0_legacy_key" +CONF_ADDON_S2_ACCESS_CONTROL_KEY = "s2_access_control_key" +CONF_ADDON_S2_AUTHENTICATED_KEY = "s2_authenticated_key" +CONF_ADDON_S2_UNAUTHENTICATED_KEY = "s2_unauthenticated_key" CONF_INTEGRATION_CREATED_ADDON = "integration_created_addon" CONF_NETWORK_KEY = "network_key" +CONF_S0_LEGACY_KEY = "s0_legacy_key" +CONF_S2_ACCESS_CONTROL_KEY = "s2_access_control_key" +CONF_S2_AUTHENTICATED_KEY = "s2_authenticated_key" +CONF_S2_UNAUTHENTICATED_KEY = "s2_unauthenticated_key" CONF_USB_PATH = "usb_path" CONF_USE_ADDON = "use_addon" CONF_DATA_COLLECTION_OPTED_IN = "data_collection_opted_in" diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index c2f1b12ca15..4916b4fee7e 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -164,7 +164,10 @@ async def test_manual(hass): assert result2["data"] == { "url": "ws://localhost:3000", "usb_path": None, - "network_key": None, + "s0_legacy_key": None, + "s2_access_control_key": None, + "s2_authenticated_key": None, + "s2_unauthenticated_key": None, "use_addon": False, "integration_created_addon": False, } @@ -278,7 +281,10 @@ async def test_supervisor_discovery( await setup.async_setup_component(hass, "persistent_notification", {}) addon_options["device"] = "/test" - addon_options["network_key"] = "abc123" + addon_options["s0_legacy_key"] = "new123" + addon_options["s2_access_control_key"] = "new456" + addon_options["s2_authenticated_key"] = "new789" + addon_options["s2_unauthenticated_key"] = "new987" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -300,7 +306,10 @@ async def test_supervisor_discovery( assert result["data"] == { "url": "ws://host1:3001", "usb_path": "/test", - "network_key": "abc123", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", "use_addon": True, "integration_created_addon": False, } @@ -336,7 +345,10 @@ async def test_clean_discovery_on_user_create( await setup.async_setup_component(hass, "persistent_notification", {}) addon_options["device"] = "/test" - addon_options["network_key"] = "abc123" + addon_options["s0_legacy_key"] = "new123" + addon_options["s2_access_control_key"] = "new456" + addon_options["s2_authenticated_key"] = "new789" + addon_options["s2_unauthenticated_key"] = "new987" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -380,7 +392,10 @@ async def test_clean_discovery_on_user_create( assert result["data"] == { "url": "ws://localhost:3000", "usb_path": None, - "network_key": None, + "s0_legacy_key": None, + "s2_access_control_key": None, + "s2_authenticated_key": None, + "s2_unauthenticated_key": None, "use_addon": False, "integration_created_addon": False, } @@ -433,6 +448,7 @@ async def test_abort_hassio_discovery_with_existing_flow( assert result2["reason"] == "already_in_progress" +@pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) async def test_usb_discovery( hass, supervisor, @@ -467,11 +483,28 @@ async def test_usb_discovery( assert result["step_id"] == "configure_addon" result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"usb_path": "/test", "network_key": "abc123"} + result["flow_id"], + { + "usb_path": "/test", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + }, ) assert set_addon_options.call_args == call( - hass, "core_zwave_js", {"options": {"device": "/test", "network_key": "abc123"}} + hass, + "core_zwave_js", + { + "options": { + "device": "/test", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + } + }, ) assert result["type"] == "progress" @@ -491,14 +524,21 @@ async def test_usb_discovery( assert result["type"] == "create_entry" assert result["title"] == TITLE - assert result["data"]["usb_path"] == "/test" - assert result["data"]["integration_created_addon"] is True - assert result["data"]["use_addon"] is True - assert result["data"]["network_key"] == "abc123" + assert result["data"] == { + "url": "ws://host1:3001", + "usb_path": "/test", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + "use_addon": True, + "integration_created_addon": True, + } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) async def test_usb_discovery_addon_not_running( hass, supervisor, @@ -528,18 +568,35 @@ async def test_usb_discovery_addon_not_running( data_schema = result["data_schema"] assert data_schema({}) == { "usb_path": USB_DISCOVERY_INFO["device"], - "network_key": "", + "s0_legacy_key": "", + "s2_access_control_key": "", + "s2_authenticated_key": "", + "s2_unauthenticated_key": "", } result = await hass.config_entries.flow.async_configure( result["flow_id"], - {"usb_path": USB_DISCOVERY_INFO["device"], "network_key": "abc123"}, + { + "usb_path": USB_DISCOVERY_INFO["device"], + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + }, ) assert set_addon_options.call_args == call( hass, "core_zwave_js", - {"options": {"device": USB_DISCOVERY_INFO["device"], "network_key": "abc123"}}, + { + "options": { + "device": USB_DISCOVERY_INFO["device"], + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + } + }, ) assert result["type"] == "progress" @@ -559,10 +616,16 @@ async def test_usb_discovery_addon_not_running( assert result["type"] == "create_entry" assert result["title"] == TITLE - assert result["data"]["usb_path"] == USB_DISCOVERY_INFO["device"] - assert result["data"]["integration_created_addon"] is False - assert result["data"]["use_addon"] is True - assert result["data"]["network_key"] == "abc123" + assert result["data"] == { + "url": "ws://host1:3001", + "usb_path": USB_DISCOVERY_INFO["device"], + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + "use_addon": True, + "integration_created_addon": False, + } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -589,11 +652,28 @@ async def test_discovery_addon_not_running( assert result["step_id"] == "configure_addon" result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"usb_path": "/test", "network_key": "abc123"} + result["flow_id"], + { + "usb_path": "/test", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + }, ) assert set_addon_options.call_args == call( - hass, "core_zwave_js", {"options": {"device": "/test", "network_key": "abc123"}} + hass, + "core_zwave_js", + { + "options": { + "device": "/test", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + } + }, ) assert result["type"] == "progress" @@ -616,7 +696,10 @@ async def test_discovery_addon_not_running( assert result["data"] == { "url": "ws://host1:3001", "usb_path": "/test", - "network_key": "abc123", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", "use_addon": True, "integration_created_addon": False, } @@ -661,11 +744,28 @@ async def test_discovery_addon_not_installed( assert result["step_id"] == "configure_addon" result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"usb_path": "/test", "network_key": "abc123"} + result["flow_id"], + { + "usb_path": "/test", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + }, ) assert set_addon_options.call_args == call( - hass, "core_zwave_js", {"options": {"device": "/test", "network_key": "abc123"}} + hass, + "core_zwave_js", + { + "options": { + "device": "/test", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + } + }, ) assert result["type"] == "progress" @@ -688,7 +788,10 @@ async def test_discovery_addon_not_installed( assert result["data"] == { "url": "ws://host1:3001", "usb_path": "/test", - "network_key": "abc123", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", "use_addon": True, "integration_created_addon": True, } @@ -808,7 +911,10 @@ async def test_not_addon(hass, supervisor): assert result["data"] == { "url": "ws://localhost:3000", "usb_path": None, - "network_key": None, + "s0_legacy_key": None, + "s2_access_control_key": None, + "s2_authenticated_key": None, + "s2_unauthenticated_key": None, "use_addon": False, "integration_created_addon": False, } @@ -826,7 +932,10 @@ async def test_addon_running( ): """Test add-on already running on Supervisor.""" addon_options["device"] = "/test" - addon_options["network_key"] = "abc123" + addon_options["s0_legacy_key"] = "new123" + addon_options["s2_access_control_key"] = "new456" + addon_options["s2_authenticated_key"] = "new789" + addon_options["s2_unauthenticated_key"] = "new987" await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( @@ -852,7 +961,10 @@ async def test_addon_running( assert result["data"] == { "url": "ws://host1:3001", "usb_path": "/test", - "network_key": "abc123", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", "use_addon": True, "integration_created_addon": False, } @@ -928,13 +1040,20 @@ async def test_addon_running_already_configured( ): """Test that only one unique instance is allowed when add-on is running.""" addon_options["device"] = "/test_new" - addon_options["network_key"] = "def456" + addon_options["s0_legacy_key"] = "new123" + addon_options["s2_access_control_key"] = "new456" + addon_options["s2_authenticated_key"] = "new789" + addon_options["s2_unauthenticated_key"] = "new987" entry = MockConfigEntry( domain=DOMAIN, data={ "url": "ws://localhost:3000", "usb_path": "/test", - "network_key": "abc123", + "network_key": "old123", + "s0_legacy_key": "old123", + "s2_access_control_key": "old456", + "s2_authenticated_key": "old789", + "s2_unauthenticated_key": "old987", }, title=TITLE, unique_id=1234, @@ -957,7 +1076,10 @@ async def test_addon_running_already_configured( assert result["reason"] == "already_configured" assert entry.data["url"] == "ws://host1:3001" assert entry.data["usb_path"] == "/test_new" - assert entry.data["network_key"] == "def456" + assert entry.data["s0_legacy_key"] == "new123" + assert entry.data["s2_access_control_key"] == "new456" + assert entry.data["s2_authenticated_key"] == "new789" + assert entry.data["s2_unauthenticated_key"] == "new987" @pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) @@ -988,11 +1110,28 @@ async def test_addon_installed( assert result["step_id"] == "configure_addon" result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"usb_path": "/test", "network_key": "abc123"} + result["flow_id"], + { + "usb_path": "/test", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + }, ) assert set_addon_options.call_args == call( - hass, "core_zwave_js", {"options": {"device": "/test", "network_key": "abc123"}} + hass, + "core_zwave_js", + { + "options": { + "device": "/test", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + } + }, ) assert result["type"] == "progress" @@ -1015,7 +1154,10 @@ async def test_addon_installed( assert result["data"] == { "url": "ws://host1:3001", "usb_path": "/test", - "network_key": "abc123", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", "use_addon": True, "integration_created_addon": False, } @@ -1054,11 +1196,28 @@ async def test_addon_installed_start_failure( assert result["step_id"] == "configure_addon" result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"usb_path": "/test", "network_key": "abc123"} + result["flow_id"], + { + "usb_path": "/test", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + }, ) assert set_addon_options.call_args == call( - hass, "core_zwave_js", {"options": {"device": "/test", "network_key": "abc123"}} + hass, + "core_zwave_js", + { + "options": { + "device": "/test", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + } + }, ) assert result["type"] == "progress" @@ -1113,11 +1272,28 @@ async def test_addon_installed_failures( assert result["step_id"] == "configure_addon" result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"usb_path": "/test", "network_key": "abc123"} + result["flow_id"], + { + "usb_path": "/test", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + }, ) assert set_addon_options.call_args == call( - hass, "core_zwave_js", {"options": {"device": "/test", "network_key": "abc123"}} + hass, + "core_zwave_js", + { + "options": { + "device": "/test", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + } + }, ) assert result["type"] == "progress" @@ -1163,11 +1339,28 @@ async def test_addon_installed_set_options_failure( assert result["step_id"] == "configure_addon" result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"usb_path": "/test", "network_key": "abc123"} + result["flow_id"], + { + "usb_path": "/test", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + }, ) assert set_addon_options.call_args == call( - hass, "core_zwave_js", {"options": {"device": "/test", "network_key": "abc123"}} + hass, + "core_zwave_js", + { + "options": { + "device": "/test", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + } + }, ) assert result["type"] == "abort" @@ -1192,7 +1385,11 @@ async def test_addon_installed_already_configured( data={ "url": "ws://localhost:3000", "usb_path": "/test", - "network_key": "abc123", + "network_key": "old123", + "s0_legacy_key": "old123", + "s2_access_control_key": "old456", + "s2_authenticated_key": "old789", + "s2_unauthenticated_key": "old987", }, title=TITLE, unique_id=1234, @@ -1215,13 +1412,28 @@ async def test_addon_installed_already_configured( assert result["step_id"] == "configure_addon" result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"usb_path": "/test_new", "network_key": "def456"} + result["flow_id"], + { + "usb_path": "/test_new", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + }, ) assert set_addon_options.call_args == call( hass, "core_zwave_js", - {"options": {"device": "/test_new", "network_key": "def456"}}, + { + "options": { + "device": "/test_new", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + } + }, ) assert result["type"] == "progress" @@ -1236,7 +1448,10 @@ async def test_addon_installed_already_configured( assert result["reason"] == "already_configured" assert entry.data["url"] == "ws://host1:3001" assert entry.data["usb_path"] == "/test_new" - assert entry.data["network_key"] == "def456" + assert entry.data["s0_legacy_key"] == "new123" + assert entry.data["s2_access_control_key"] == "new456" + assert entry.data["s2_authenticated_key"] == "new789" + assert entry.data["s2_unauthenticated_key"] == "new987" @pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) @@ -1279,11 +1494,28 @@ async def test_addon_not_installed( assert result["step_id"] == "configure_addon" result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"usb_path": "/test", "network_key": "abc123"} + result["flow_id"], + { + "usb_path": "/test", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + }, ) assert set_addon_options.call_args == call( - hass, "core_zwave_js", {"options": {"device": "/test", "network_key": "abc123"}} + hass, + "core_zwave_js", + { + "options": { + "device": "/test", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + } + }, ) assert result["type"] == "progress" @@ -1306,7 +1538,10 @@ async def test_addon_not_installed( assert result["data"] == { "url": "ws://host1:3001", "usb_path": "/test", - "network_key": "abc123", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", "use_addon": True, "integration_created_addon": True, } @@ -1431,10 +1666,20 @@ async def test_options_not_addon(hass, client, supervisor, integration): ( {"config": ADDON_DISCOVERY_INFO}, {}, - {"device": "/test", "network_key": "abc123"}, + { + "device": "/test", + "network_key": "old123", + "s0_legacy_key": "old123", + "s2_access_control_key": "old456", + "s2_authenticated_key": "old789", + "s2_unauthenticated_key": "old987", + }, { "usb_path": "/new", - "network_key": "new123", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", "log_level": "info", "emulate_hardware": False, }, @@ -1443,10 +1688,20 @@ async def test_options_not_addon(hass, client, supervisor, integration): ( {"config": ADDON_DISCOVERY_INFO}, {"use_addon": True}, - {"device": "/test", "network_key": "abc123"}, + { + "device": "/test", + "network_key": "old123", + "s0_legacy_key": "old123", + "s2_access_control_key": "old456", + "s2_authenticated_key": "old789", + "s2_unauthenticated_key": "old987", + }, { "usb_path": "/new", - "network_key": "new123", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", "log_level": "info", "emulate_hardware": False, }, @@ -1519,7 +1774,18 @@ async def test_options_addon_running( assert result["type"] == "create_entry" assert entry.data["url"] == "ws://host1:3001" assert entry.data["usb_path"] == new_addon_options["device"] - assert entry.data["network_key"] == new_addon_options["network_key"] + assert entry.data["s0_legacy_key"] == new_addon_options["s0_legacy_key"] + assert ( + entry.data["s2_access_control_key"] + == new_addon_options["s2_access_control_key"] + ) + assert ( + entry.data["s2_authenticated_key"] == new_addon_options["s2_authenticated_key"] + ) + assert ( + entry.data["s2_unauthenticated_key"] + == new_addon_options["s2_unauthenticated_key"] + ) assert entry.data["use_addon"] is True assert entry.data["integration_created_addon"] is False assert client.connect.call_count == 2 @@ -1534,13 +1800,20 @@ async def test_options_addon_running( {}, { "device": "/test", - "network_key": "abc123", + "network_key": "old123", + "s0_legacy_key": "old123", + "s2_access_control_key": "old456", + "s2_authenticated_key": "old789", + "s2_unauthenticated_key": "old987", "log_level": "info", "emulate_hardware": False, }, { "usb_path": "/test", - "network_key": "abc123", + "s0_legacy_key": "old123", + "s2_access_control_key": "old456", + "s2_authenticated_key": "old789", + "s2_unauthenticated_key": "old987", "log_level": "info", "emulate_hardware": False, }, @@ -1599,7 +1872,18 @@ async def test_options_addon_running_no_changes( assert result["type"] == "create_entry" assert entry.data["url"] == "ws://host1:3001" assert entry.data["usb_path"] == new_addon_options["device"] - assert entry.data["network_key"] == new_addon_options["network_key"] + assert entry.data["s0_legacy_key"] == new_addon_options["s0_legacy_key"] + assert ( + entry.data["s2_access_control_key"] + == new_addon_options["s2_access_control_key"] + ) + assert ( + entry.data["s2_authenticated_key"] == new_addon_options["s2_authenticated_key"] + ) + assert ( + entry.data["s2_unauthenticated_key"] + == new_addon_options["s2_unauthenticated_key"] + ) assert entry.data["use_addon"] is True assert entry.data["integration_created_addon"] is False assert client.connect.call_count == 2 @@ -1625,13 +1909,20 @@ async def different_device_server_version(*args): {}, { "device": "/test", - "network_key": "abc123", + "network_key": "old123", + "s0_legacy_key": "old123", + "s2_access_control_key": "old456", + "s2_authenticated_key": "old789", + "s2_unauthenticated_key": "old987", "log_level": "info", "emulate_hardware": False, }, { "usb_path": "/new", - "network_key": "new123", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", "log_level": "info", "emulate_hardware": False, }, @@ -1705,6 +1996,9 @@ async def test_options_different_device( result = await hass.config_entries.options.async_configure(result["flow_id"]) await hass.async_block_till_done() + # Legacy network key is not reset. + old_addon_options.pop("network_key") + assert set_addon_options.call_count == 2 assert set_addon_options.call_args == call( hass, @@ -1737,13 +2031,20 @@ async def test_options_different_device( {}, { "device": "/test", - "network_key": "abc123", + "network_key": "old123", + "s0_legacy_key": "old123", + "s2_access_control_key": "old456", + "s2_authenticated_key": "old789", + "s2_unauthenticated_key": "old987", "log_level": "info", "emulate_hardware": False, }, { "usb_path": "/new", - "network_key": "new123", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", "log_level": "info", "emulate_hardware": False, }, @@ -1755,13 +2056,20 @@ async def test_options_different_device( {}, { "device": "/test", - "network_key": "abc123", + "network_key": "old123", + "s0_legacy_key": "old123", + "s2_access_control_key": "old456", + "s2_authenticated_key": "old789", + "s2_unauthenticated_key": "old987", "log_level": "info", "emulate_hardware": False, }, { "usb_path": "/new", - "network_key": "new123", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", "log_level": "info", "emulate_hardware": False, }, @@ -1838,6 +2146,8 @@ async def test_options_addon_restart_failed( result = await hass.config_entries.options.async_configure(result["flow_id"]) await hass.async_block_till_done() + # The legacy network key should not be reset. + old_addon_options.pop("network_key") assert set_addon_options.call_count == 2 assert set_addon_options.call_args == call( hass, @@ -1871,12 +2181,19 @@ async def test_options_addon_restart_failed( { "device": "/test", "network_key": "abc123", + "s0_legacy_key": "abc123", + "s2_access_control_key": "old456", + "s2_authenticated_key": "old789", + "s2_unauthenticated_key": "old987", "log_level": "info", "emulate_hardware": False, }, { "usb_path": "/test", - "network_key": "abc123", + "s0_legacy_key": "abc123", + "s2_access_control_key": "old456", + "s2_authenticated_key": "old789", + "s2_unauthenticated_key": "old987", "log_level": "info", "emulate_hardware": False, }, @@ -1945,10 +2262,20 @@ async def test_options_addon_running_server_info_failure( ( {"config": ADDON_DISCOVERY_INFO}, {}, - {"device": "/test", "network_key": "abc123"}, + { + "device": "/test", + "network_key": "abc123", + "s0_legacy_key": "abc123", + "s2_access_control_key": "old456", + "s2_authenticated_key": "old789", + "s2_unauthenticated_key": "old987", + }, { "usb_path": "/new", - "network_key": "new123", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", "log_level": "info", "emulate_hardware": False, }, @@ -1957,10 +2284,20 @@ async def test_options_addon_running_server_info_failure( ( {"config": ADDON_DISCOVERY_INFO}, {"use_addon": True}, - {"device": "/test", "network_key": "abc123"}, + { + "device": "/test", + "network_key": "abc123", + "s0_legacy_key": "abc123", + "s2_access_control_key": "old456", + "s2_authenticated_key": "old789", + "s2_unauthenticated_key": "old987", + }, { "usb_path": "/new", - "network_key": "new123", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", "log_level": "info", "emulate_hardware": False, }, @@ -2048,7 +2385,7 @@ async def test_options_addon_not_installed( assert result["type"] == "create_entry" assert entry.data["url"] == "ws://host1:3001" assert entry.data["usb_path"] == new_addon_options["device"] - assert entry.data["network_key"] == new_addon_options["network_key"] + assert entry.data["s0_legacy_key"] == new_addon_options["s0_legacy_key"] assert entry.data["use_addon"] is True assert entry.data["integration_created_addon"] is True assert client.connect.call_count == 2 diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 2e6a64f456e..b2cb7bc808e 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -272,15 +272,28 @@ async def test_start_addon( ): """Test start the Z-Wave JS add-on during entry setup.""" device = "/test" - network_key = "abc123" + s0_legacy_key = "s0_legacy" + s2_access_control_key = "s2_access_control" + s2_authenticated_key = "s2_authenticated" + s2_unauthenticated_key = "s2_unauthenticated" addon_options = { "device": device, - "network_key": network_key, + "s0_legacy_key": s0_legacy_key, + "s2_access_control_key": s2_access_control_key, + "s2_authenticated_key": s2_authenticated_key, + "s2_unauthenticated_key": s2_unauthenticated_key, } entry = MockConfigEntry( domain=DOMAIN, title="Z-Wave JS", - data={"use_addon": True, "usb_path": device, "network_key": network_key}, + data={ + "use_addon": True, + "usb_path": device, + "s0_legacy_key": s0_legacy_key, + "s2_access_control_key": s2_access_control_key, + "s2_authenticated_key": s2_authenticated_key, + "s2_unauthenticated_key": s2_unauthenticated_key, + }, ) entry.add_to_hass(hass) @@ -303,15 +316,28 @@ async def test_install_addon( """Test install and start the Z-Wave JS add-on during entry setup.""" addon_installed.return_value["version"] = None device = "/test" - network_key = "abc123" + s0_legacy_key = "s0_legacy" + s2_access_control_key = "s2_access_control" + s2_authenticated_key = "s2_authenticated" + s2_unauthenticated_key = "s2_unauthenticated" addon_options = { "device": device, - "network_key": network_key, + "s0_legacy_key": s0_legacy_key, + "s2_access_control_key": s2_access_control_key, + "s2_authenticated_key": s2_authenticated_key, + "s2_unauthenticated_key": s2_unauthenticated_key, } entry = MockConfigEntry( domain=DOMAIN, title="Z-Wave JS", - data={"use_addon": True, "usb_path": device, "network_key": network_key}, + data={ + "use_addon": True, + "usb_path": device, + "s0_legacy_key": s0_legacy_key, + "s2_access_control_key": s2_access_control_key, + "s2_authenticated_key": s2_authenticated_key, + "s2_unauthenticated_key": s2_unauthenticated_key, + }, ) entry.add_to_hass(hass) @@ -357,8 +383,27 @@ async def test_addon_info_failure( @pytest.mark.parametrize( - "old_device, new_device, old_network_key, new_network_key", - [("/old_test", "/new_test", "old123", "new123")], + ( + "old_device, new_device, " + "old_s0_legacy_key, new_s0_legacy_key, " + "old_s2_access_control_key, new_s2_access_control_key, " + "old_s2_authenticated_key, new_s2_authenticated_key, " + "old_s2_unauthenticated_key, new_s2_unauthenticated_key" + ), + [ + ( + "/old_test", + "/new_test", + "old123", + "new123", + "old456", + "new456", + "old789", + "new789", + "old987", + "new987", + ) + ], ) async def test_addon_options_changed( hass, @@ -370,12 +415,21 @@ async def test_addon_options_changed( start_addon, old_device, new_device, - old_network_key, - new_network_key, + old_s0_legacy_key, + new_s0_legacy_key, + old_s2_access_control_key, + new_s2_access_control_key, + old_s2_authenticated_key, + new_s2_authenticated_key, + old_s2_unauthenticated_key, + new_s2_unauthenticated_key, ): """Test update config entry data on entry setup if add-on options changed.""" addon_options["device"] = new_device - addon_options["network_key"] = new_network_key + addon_options["s0_legacy_key"] = new_s0_legacy_key + addon_options["s2_access_control_key"] = new_s2_access_control_key + addon_options["s2_authenticated_key"] = new_s2_authenticated_key + addon_options["s2_unauthenticated_key"] = new_s2_unauthenticated_key entry = MockConfigEntry( domain=DOMAIN, title="Z-Wave JS", @@ -383,7 +437,10 @@ async def test_addon_options_changed( "url": "ws://host1:3001", "use_addon": True, "usb_path": old_device, - "network_key": old_network_key, + "s0_legacy_key": old_s0_legacy_key, + "s2_access_control_key": old_s2_access_control_key, + "s2_authenticated_key": old_s2_authenticated_key, + "s2_unauthenticated_key": old_s2_unauthenticated_key, }, ) entry.add_to_hass(hass) @@ -393,7 +450,10 @@ async def test_addon_options_changed( assert entry.state == ConfigEntryState.LOADED assert entry.data["usb_path"] == new_device - assert entry.data["network_key"] == new_network_key + assert entry.data["s0_legacy_key"] == new_s0_legacy_key + assert entry.data["s2_access_control_key"] == new_s2_access_control_key + assert entry.data["s2_authenticated_key"] == new_s2_authenticated_key + assert entry.data["s2_unauthenticated_key"] == new_s2_unauthenticated_key assert install_addon.call_count == 0 assert start_addon.call_count == 0 From b9d81c3a7ef4a7577ec6476a7e508d3ff91aadf9 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 30 Sep 2021 00:11:22 +0200 Subject: [PATCH 710/843] Handle Fritz portmapping with same name (#56398) --- homeassistant/components/fritz/switch.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/fritz/switch.py b/homeassistant/components/fritz/switch.py index bde9cbcb4ce..c337c568d18 100644 --- a/homeassistant/components/fritz/switch.py +++ b/homeassistant/components/fritz/switch.py @@ -167,7 +167,7 @@ def port_entities_list( """Get list of port forwarding entities.""" _LOGGER.debug("Setting up %s switches", SWITCH_TYPE_PORTFORWARD) - entities_list: list = [] + entities_list: list[FritzBoxPortSwitch] = [] service_name = "Layer3Forwarding" connection_type = service_call_action( fritzbox_tools, service_name, "1", "GetDefaultConnectionService" @@ -219,11 +219,18 @@ def port_entities_list( # We can only handle port forwards of the given device if portmap["NewInternalClient"] == local_ip: + port_name = portmap["NewPortMappingDescription"] + for entity in entities_list: + if entity.port_mapping and ( + port_name in entity.port_mapping["NewPortMappingDescription"] + ): + port_name = f"{port_name} {portmap['NewExternalPort']}" entities_list.append( FritzBoxPortSwitch( fritzbox_tools, device_friendly_name, portmap, + port_name, i, con_type, ) @@ -430,6 +437,7 @@ class FritzBoxPortSwitch(FritzBoxBaseSwitch, SwitchEntity): fritzbox_tools: FritzBoxTools, device_friendly_name: str, port_mapping: dict[str, Any] | None, + port_name: str, idx: int, connection_type: str, ) -> None: @@ -445,7 +453,7 @@ class FritzBoxPortSwitch(FritzBoxBaseSwitch, SwitchEntity): return switch_info = SwitchInfo( - description=f'Port forward {port_mapping["NewPortMappingDescription"]}', + description=f"Port forward {port_name}", friendly_name=device_friendly_name, icon="mdi:check-network", type=SWITCH_TYPE_PORTFORWARD, From 12b2076351ff35dd7bdfc1a0030f0fee31b0bd88 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 30 Sep 2021 01:15:05 +0200 Subject: [PATCH 711/843] Fix zwave_js config flow import step (#56808) --- .../components/zwave_js/config_flow.py | 6 +++-- tests/components/zwave_js/test_config_flow.py | 23 +++++++++++++++++-- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 733dad50c52..37e6b7c9320 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -321,8 +321,10 @@ class ConfigFlow(BaseZwaveJSFlow, config_entries.ConfigFlow, domain=DOMAIN): This step will be used when importing data during Z-Wave to Z-Wave JS migration. """ - self.network_key = data.get(CONF_NETWORK_KEY) - self.usb_path = data.get(CONF_USB_PATH) + # Note that the data comes from the zwave integration. + # So we don't use our constants here. + self.s0_legacy_key = data.get("network_key") + self.usb_path = data.get("usb_path") return await self.async_step_user() async def async_step_user( diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index 4916b4fee7e..5dcbee4c5ee 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -2422,6 +2422,14 @@ async def test_import_addon_installed( # the default input should be the imported data default_input = result["data_schema"]({}) + assert default_input == { + "usb_path": "/test/imported", + "s0_legacy_key": "imported123", + "s2_access_control_key": "", + "s2_authenticated_key": "", + "s2_unauthenticated_key": "", + } + result = await hass.config_entries.flow.async_configure( result["flow_id"], default_input ) @@ -2429,7 +2437,15 @@ async def test_import_addon_installed( assert set_addon_options.call_args == call( hass, "core_zwave_js", - {"options": {"device": "/test/imported", "network_key": "imported123"}}, + { + "options": { + "device": "/test/imported", + "s0_legacy_key": "imported123", + "s2_access_control_key": "", + "s2_authenticated_key": "", + "s2_unauthenticated_key": "", + } + }, ) assert result["type"] == "progress" @@ -2452,7 +2468,10 @@ async def test_import_addon_installed( assert result["data"] == { "url": "ws://host1:3001", "usb_path": "/test/imported", - "network_key": "imported123", + "s0_legacy_key": "imported123", + "s2_access_control_key": "", + "s2_authenticated_key": "", + "s2_unauthenticated_key": "", "use_addon": True, "integration_created_addon": False, } From a967a1d1dfd3264b1ae4a3785302cb48b3cac984 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Hickmann?= Date: Wed, 29 Sep 2021 20:25:07 -0300 Subject: [PATCH 712/843] Get the currency from the api (#56806) --- .../components/growatt_server/sensor.py | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/growatt_server/sensor.py b/homeassistant/components/growatt_server/sensor.py index fa1d8b644d5..3f74710f090 100644 --- a/homeassistant/components/growatt_server/sensor.py +++ b/homeassistant/components/growatt_server/sensor.py @@ -5,7 +5,6 @@ from dataclasses import dataclass import datetime import json import logging -import re import growattServer @@ -19,7 +18,6 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_URL, CONF_USERNAME, - CURRENCY_EURO, DEVICE_CLASS_BATTERY, DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, @@ -57,6 +55,7 @@ class GrowattSensorEntityDescription(SensorEntityDescription, GrowattRequiredKey """Describes Growatt sensor entity.""" precision: int | None = None + currency: bool = False TOTAL_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( @@ -64,13 +63,13 @@ TOTAL_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( key="total_money_today", name="Total money today", api_key="plantMoneyText", - native_unit_of_measurement=CURRENCY_EURO, + currency=True, ), GrowattSensorEntityDescription( key="total_money_total", name="Money lifetime", api_key="totalMoneyText", - native_unit_of_measurement=CURRENCY_EURO, + currency=True, ), GrowattSensorEntityDescription( key="total_energy_today", @@ -975,6 +974,13 @@ class GrowattInverter(SensorEntity): result = round(result, self.entity_description.precision) return result + @property + def native_unit_of_measurement(self) -> str | None: + """Return the unit of measurement of the sensor, if any.""" + if self.entity_description.currency: + return self.probe.get_data("currency") + return super().native_unit_of_measurement + def update(self): """Get the latest data from the Growat API and updates the state.""" self.probe.update() @@ -1003,10 +1009,10 @@ class GrowattData: if self.growatt_type == "total": total_info = self.api.plant_info(self.device_id) del total_info["deviceList"] - # PlantMoneyText comes in as "3.1/€" remove anything that isn't part of the number - total_info["plantMoneyText"] = re.sub( - r"[^\d.,]", "", total_info["plantMoneyText"] - ) + # PlantMoneyText comes in as "3.1/€" split between value and currency + plant_money_text, currency = total_info["plantMoneyText"].split("/") + total_info["plantMoneyText"] = plant_money_text + total_info["currency"] = currency self.data = total_info elif self.growatt_type == "inverter": inverter_info = self.api.inverter_detail(self.device_id) From 2ff1fc83bc183d6ce00c45da13e19b9b4c0bf283 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Wed, 29 Sep 2021 19:11:53 -0500 Subject: [PATCH 713/843] Add latest added media as Plex library sensor attribute (#56235) --- homeassistant/components/plex/sensor.py | 17 +++ tests/components/plex/conftest.py | 80 ++++++++++ tests/components/plex/test_sensor.py | 137 ++++++++++++++++- .../plex/library_movies_collections.xml | 47 ++++++ .../fixtures/plex/library_movies_metadata.xml | 111 ++++++++++++++ tests/fixtures/plex/library_movies_size.xml | 3 + .../plex/library_music_collections.xml | 45 ++++++ .../fixtures/plex/library_music_metadata.xml | 143 ++++++++++++++++++ tests/fixtures/plex/library_music_size.xml | 3 + .../plex/library_tvshows_collections.xml | 45 ++++++ .../plex/library_tvshows_metadata.xml | 140 +++++++++++++++++ .../plex/library_tvshows_most_recent.xml | 12 ++ 12 files changed, 779 insertions(+), 4 deletions(-) create mode 100644 tests/fixtures/plex/library_movies_collections.xml create mode 100644 tests/fixtures/plex/library_movies_metadata.xml create mode 100644 tests/fixtures/plex/library_movies_size.xml create mode 100644 tests/fixtures/plex/library_music_collections.xml create mode 100644 tests/fixtures/plex/library_music_metadata.xml create mode 100644 tests/fixtures/plex/library_music_size.xml create mode 100644 tests/fixtures/plex/library_tvshows_collections.xml create mode 100644 tests/fixtures/plex/library_tvshows_metadata.xml create mode 100644 tests/fixtures/plex/library_tvshows_most_recent.xml diff --git a/homeassistant/components/plex/sensor.py b/homeassistant/components/plex/sensor.py index 0969967e673..db2ce15d395 100644 --- a/homeassistant/components/plex/sensor.py +++ b/homeassistant/components/plex/sensor.py @@ -16,6 +16,7 @@ from .const import ( PLEX_UPDATE_SENSOR_SIGNAL, SERVERS, ) +from .helpers import pretty_title LIBRARY_ATTRIBUTE_TYPES = { "artist": ["artist", "album"], @@ -28,6 +29,11 @@ LIBRARY_PRIMARY_LIBTYPE = { "artist": "track", } +LIBRARY_RECENT_LIBTYPE = { + "show": "episode", + "artist": "album", +} + LIBRARY_ICON_LOOKUP = { "artist": "mdi:music", "movie": "mdi:movie", @@ -174,6 +180,17 @@ class PlexLibrarySectionSensor(SensorEntity): libtype=libtype, includeCollections=False ) + recent_libtype = LIBRARY_RECENT_LIBTYPE.get( + self.library_type, self.library_type + ) + recently_added = self.library_section.recentlyAdded( + maxresults=1, libtype=recent_libtype + ) + if recently_added: + media = recently_added[0] + self._attr_extra_state_attributes["last_added_item"] = pretty_title(media) + self._attr_extra_state_attributes["last_added_timestamp"] = media.addedAt + @property def device_info(self): """Return a device description for device registry.""" diff --git a/tests/components/plex/conftest.py b/tests/components/plex/conftest.py index 6ed8eaaa94a..cdd0d4dff3e 100644 --- a/tests/components/plex/conftest.py +++ b/tests/components/plex/conftest.py @@ -78,18 +78,54 @@ def library_movies_all_fixture(): return load_fixture("plex/library_movies_all.xml") +@pytest.fixture(name="library_movies_metadata", scope="session") +def library_movies_metadata_fixture(): + """Load payload for metadata in the movies library and return it.""" + return load_fixture("plex/library_movies_metadata.xml") + + +@pytest.fixture(name="library_movies_collections", scope="session") +def library_movies_collections_fixture(): + """Load payload for collections in the movies library and return it.""" + return load_fixture("plex/library_movies_collections.xml") + + @pytest.fixture(name="library_tvshows_all", scope="session") def library_tvshows_all_fixture(): """Load payload for all items in the tvshows library and return it.""" return load_fixture("plex/library_tvshows_all.xml") +@pytest.fixture(name="library_tvshows_metadata", scope="session") +def library_tvshows_metadata_fixture(): + """Load payload for metadata in the TV shows library and return it.""" + return load_fixture("plex/library_tvshows_metadata.xml") + + +@pytest.fixture(name="library_tvshows_collections", scope="session") +def library_tvshows_collections_fixture(): + """Load payload for collections in the TV shows library and return it.""" + return load_fixture("plex/library_tvshows_collections.xml") + + @pytest.fixture(name="library_music_all", scope="session") def library_music_all_fixture(): """Load payload for all items in the music library and return it.""" return load_fixture("plex/library_music_all.xml") +@pytest.fixture(name="library_music_metadata", scope="session") +def library_music_metadata_fixture(): + """Load payload for metadata in the music library and return it.""" + return load_fixture("plex/library_music_metadata.xml") + + +@pytest.fixture(name="library_music_collections", scope="session") +def library_music_collections_fixture(): + """Load payload for collections in the music library and return it.""" + return load_fixture("plex/library_music_collections.xml") + + @pytest.fixture(name="library_movies_sort", scope="session") def library_movies_sort_fixture(): """Load sorting payload for movie library and return it.""" @@ -120,6 +156,18 @@ def library_fixture(): return load_fixture("plex/library.xml") +@pytest.fixture(name="library_movies_size", scope="session") +def library_movies_size_fixture(): + """Load movie library size payload and return it.""" + return load_fixture("plex/library_movies_size.xml") + + +@pytest.fixture(name="library_music_size", scope="session") +def library_music_size_fixture(): + """Load music library size payload and return it.""" + return load_fixture("plex/library_music_size.xml") + + @pytest.fixture(name="library_tvshows_size", scope="session") def library_tvshows_size_fixture(): """Load tvshow library size payload and return it.""" @@ -352,10 +400,16 @@ def mock_plex_calls( library, library_sections, library_movies_all, + library_movies_collections, + library_movies_metadata, library_movies_sort, library_music_all, + library_music_collections, + library_music_metadata, library_music_sort, library_tvshows_all, + library_tvshows_collections, + library_tvshows_metadata, library_tvshows_sort, media_1, media_30, @@ -396,6 +450,32 @@ def mock_plex_calls( requests_mock.get(f"{url}/library/sections/2/all", text=library_tvshows_all) requests_mock.get(f"{url}/library/sections/3/all", text=library_music_all) + requests_mock.get( + f"{url}/library/sections/1/all?includeMeta=1&includeAdvanced=1&X-Plex-Container-Start=0&X-Plex-Container-Size=0", + text=library_movies_metadata, + ) + requests_mock.get( + f"{url}/library/sections/2/all?includeMeta=1&includeAdvanced=1&X-Plex-Container-Start=0&X-Plex-Container-Size=0", + text=library_tvshows_metadata, + ) + requests_mock.get( + f"{url}/library/sections/3/all?includeMeta=1&includeAdvanced=1&X-Plex-Container-Start=0&X-Plex-Container-Size=0", + text=library_music_metadata, + ) + + requests_mock.get( + f"{url}/library/sections/1/collections?includeMeta=1&includeAdvanced=1&X-Plex-Container-Start=0&X-Plex-Container-Size=0", + text=library_movies_collections, + ) + requests_mock.get( + f"{url}/library/sections/2/collections?includeMeta=1&includeAdvanced=1&X-Plex-Container-Start=0&X-Plex-Container-Size=0", + text=library_tvshows_collections, + ) + requests_mock.get( + f"{url}/library/sections/3/collections?includeMeta=1&includeAdvanced=1&X-Plex-Container-Start=0&X-Plex-Container-Size=0", + text=library_music_collections, + ) + requests_mock.get(f"{url}/library/metadata/200/children", text=children_200) requests_mock.get(f"{url}/library/metadata/300/children", text=children_300) requests_mock.get(f"{url}/library/metadata/300/allLeaves", text=grandchildren_300) diff --git a/tests/components/plex/test_sensor.py b/tests/components/plex/test_sensor.py index 39a2901e72d..0e87f25850f 100644 --- a/tests/components/plex/test_sensor.py +++ b/tests/components/plex/test_sensor.py @@ -1,11 +1,14 @@ """Tests for Plex sensors.""" -from datetime import timedelta +from datetime import datetime, timedelta +from unittest.mock import patch import requests.exceptions +from homeassistant.components.plex.const import PLEX_UPDATE_LIBRARY_SIGNAL from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import STATE_UNAVAILABLE from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.util import dt from .helpers import trigger_plex_update, wait_for_debouncer @@ -14,6 +17,51 @@ from tests.common import async_fire_time_changed LIBRARY_UPDATE_PAYLOAD = {"StatusNotification": [{"title": "Library scan complete"}]} +TIMESTAMP = datetime(2021, 9, 1) + + +class MockPlexMedia: + """Minimal mock of base plexapi media object.""" + + key = "key" + addedAt = str(TIMESTAMP) + listType = "video" + year = 2021 + + +class MockPlexClip(MockPlexMedia): + """Minimal mock of plexapi clip object.""" + + type = "clip" + title = "Clip 1" + + +class MockPlexMovie(MockPlexMedia): + """Minimal mock of plexapi movie object.""" + + type = "movie" + title = "Movie 1" + + +class MockPlexMusic(MockPlexMedia): + """Minimal mock of plexapi album object.""" + + listType = "audio" + type = "album" + title = "Album" + parentTitle = "Artist" + + +class MockPlexTVEpisode(MockPlexMedia): + """Minimal mock of plexapi episode object.""" + + type = "episode" + title = "Episode 5" + grandparentTitle = "TV Show" + seasonEpisode = "s01e05" + year = None + parentYear = 2021 + async def test_library_sensor_values( hass, @@ -21,11 +69,18 @@ async def test_library_sensor_values( setup_plex_server, mock_websocket, requests_mock, + library_movies_size, + library_music_size, library_tvshows_size, library_tvshows_size_episodes, library_tvshows_size_seasons, ): """Test the library sensors.""" + requests_mock.get( + "/library/sections/1/all?includeCollections=0", + text=library_movies_size, + ) + requests_mock.get( "/library/sections/2/all?includeCollections=0&type=2", text=library_tvshows_size, @@ -39,7 +94,12 @@ async def test_library_sensor_values( text=library_tvshows_size_episodes, ) - await setup_plex_server() + requests_mock.get( + "/library/sections/3/all?includeCollections=0", + text=library_music_size, + ) + + mock_plex_server = await setup_plex_server() await wait_for_debouncer(hass) activity_sensor = hass.states.get("sensor.plex_plex_server_1") @@ -59,12 +119,20 @@ async def test_library_sensor_values( hass, dt.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), ) - await hass.async_block_till_done() + + media = [MockPlexTVEpisode()] + with patch("plexapi.library.LibrarySection.recentlyAdded", return_value=media): + await hass.async_block_till_done() library_tv_sensor = hass.states.get("sensor.plex_server_1_library_tv_shows") assert library_tv_sensor.state == "10" assert library_tv_sensor.attributes["seasons"] == 1 assert library_tv_sensor.attributes["shows"] == 1 + assert ( + library_tv_sensor.attributes["last_added_item"] + == "TV Show - S01E05 - Episode 5" + ) + assert library_tv_sensor.attributes["last_added_timestamp"] == str(TIMESTAMP) # Handle `requests` exception requests_mock.get( @@ -89,7 +157,8 @@ async def test_library_sensor_values( trigger_plex_update( mock_websocket, msgtype="status", payload=LIBRARY_UPDATE_PAYLOAD ) - await hass.async_block_till_done() + with patch("plexapi.library.LibrarySection.recentlyAdded", return_value=media): + await hass.async_block_till_done() library_tv_sensor = hass.states.get("sensor.plex_server_1_library_tv_shows") assert library_tv_sensor.state == "10" @@ -105,3 +174,63 @@ async def test_library_sensor_values( library_tv_sensor = hass.states.get("sensor.plex_server_1_library_tv_shows") assert library_tv_sensor.state == STATE_UNAVAILABLE + + # Test movie library sensor + entity_registry.async_update_entity( + entity_id="sensor.plex_server_1_library_tv_shows", disabled_by="user" + ) + entity_registry.async_update_entity( + entity_id="sensor.plex_server_1_library_movies", disabled_by=None + ) + await hass.async_block_till_done() + + async_fire_time_changed( + hass, + dt.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + ) + + media = [MockPlexMovie()] + with patch("plexapi.library.LibrarySection.recentlyAdded", return_value=media): + await hass.async_block_till_done() + + library_movies_sensor = hass.states.get("sensor.plex_server_1_library_movies") + assert library_movies_sensor.state == "1" + assert library_movies_sensor.attributes["last_added_item"] == "Movie 1 (2021)" + assert library_movies_sensor.attributes["last_added_timestamp"] == str(TIMESTAMP) + + # Test with clip + media = [MockPlexClip()] + with patch("plexapi.library.LibrarySection.recentlyAdded", return_value=media): + async_dispatcher_send( + hass, PLEX_UPDATE_LIBRARY_SIGNAL.format(mock_plex_server.machine_identifier) + ) + async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=3)) + await hass.async_block_till_done() + + library_movies_sensor = hass.states.get("sensor.plex_server_1_library_movies") + assert library_movies_sensor.attributes["last_added_item"] == "Clip 1" + + # Test music library sensor + entity_registry.async_update_entity( + entity_id="sensor.plex_server_1_library_movies", disabled_by="user" + ) + entity_registry.async_update_entity( + entity_id="sensor.plex_server_1_library_music", disabled_by=None + ) + await hass.async_block_till_done() + + async_fire_time_changed( + hass, + dt.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + ) + + media = [MockPlexMusic()] + with patch("plexapi.library.LibrarySection.recentlyAdded", return_value=media): + await hass.async_block_till_done() + + library_music_sensor = hass.states.get("sensor.plex_server_1_library_music") + assert library_music_sensor.state == "1" + assert library_music_sensor.attributes["artists"] == 1 + assert library_music_sensor.attributes["albums"] == 1 + assert library_music_sensor.attributes["last_added_item"] == "Artist - Album (2021)" + assert library_music_sensor.attributes["last_added_timestamp"] == str(TIMESTAMP) diff --git a/tests/fixtures/plex/library_movies_collections.xml b/tests/fixtures/plex/library_movies_collections.xml new file mode 100644 index 00000000000..a5ca772f36f --- /dev/null +++ b/tests/fixtures/plex/library_movies_collections.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/fixtures/plex/library_movies_metadata.xml b/tests/fixtures/plex/library_movies_metadata.xml new file mode 100644 index 00000000000..e05dfabaaae --- /dev/null +++ b/tests/fixtures/plex/library_movies_metadata.xml @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/fixtures/plex/library_movies_size.xml b/tests/fixtures/plex/library_movies_size.xml new file mode 100644 index 00000000000..3ad67aed531 --- /dev/null +++ b/tests/fixtures/plex/library_movies_size.xml @@ -0,0 +1,3 @@ + + + diff --git a/tests/fixtures/plex/library_music_collections.xml b/tests/fixtures/plex/library_music_collections.xml new file mode 100644 index 00000000000..59f36c153b2 --- /dev/null +++ b/tests/fixtures/plex/library_music_collections.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/fixtures/plex/library_music_metadata.xml b/tests/fixtures/plex/library_music_metadata.xml new file mode 100644 index 00000000000..d9c6a511f82 --- /dev/null +++ b/tests/fixtures/plex/library_music_metadata.xml @@ -0,0 +1,143 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/fixtures/plex/library_music_size.xml b/tests/fixtures/plex/library_music_size.xml new file mode 100644 index 00000000000..a7418df8488 --- /dev/null +++ b/tests/fixtures/plex/library_music_size.xml @@ -0,0 +1,3 @@ + + + diff --git a/tests/fixtures/plex/library_tvshows_collections.xml b/tests/fixtures/plex/library_tvshows_collections.xml new file mode 100644 index 00000000000..914d99bfa91 --- /dev/null +++ b/tests/fixtures/plex/library_tvshows_collections.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/fixtures/plex/library_tvshows_metadata.xml b/tests/fixtures/plex/library_tvshows_metadata.xml new file mode 100644 index 00000000000..6aace99f521 --- /dev/null +++ b/tests/fixtures/plex/library_tvshows_metadata.xml @@ -0,0 +1,140 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/fixtures/plex/library_tvshows_most_recent.xml b/tests/fixtures/plex/library_tvshows_most_recent.xml new file mode 100644 index 00000000000..3e9bd49f66e --- /dev/null +++ b/tests/fixtures/plex/library_tvshows_most_recent.xml @@ -0,0 +1,12 @@ + + + + + + + + From e9d25974b84e19e1975ff1f2d324caa867a27acd Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Wed, 29 Sep 2021 20:21:53 -0400 Subject: [PATCH 714/843] Switch to using constants wherever possible in zwave_js (#56518) --- .../components/zwave_js/binary_sensor.py | 13 +-- homeassistant/components/zwave_js/climate.py | 3 +- homeassistant/components/zwave_js/cover.py | 29 ++++--- .../components/zwave_js/discovery.py | 67 +++++++++----- homeassistant/components/zwave_js/fan.py | 5 +- homeassistant/components/zwave_js/light.py | 87 +++++++++++-------- homeassistant/components/zwave_js/number.py | 3 +- homeassistant/components/zwave_js/select.py | 4 +- homeassistant/components/zwave_js/switch.py | 19 ++-- 9 files changed, 147 insertions(+), 83 deletions(-) diff --git a/homeassistant/components/zwave_js/binary_sensor.py b/homeassistant/components/zwave_js/binary_sensor.py index 9d72a804ca0..4007064109d 100644 --- a/homeassistant/components/zwave_js/binary_sensor.py +++ b/homeassistant/components/zwave_js/binary_sensor.py @@ -6,6 +6,10 @@ from typing import TypedDict from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import CommandClass +from zwave_js_server.const.command_class.lock import DOOR_STATUS_PROPERTY +from zwave_js_server.const.command_class.notification import ( + CC_SPECIFIC_NOTIFICATION_TYPE, +) from homeassistant.components.binary_sensor import ( DEVICE_CLASS_BATTERY, @@ -196,9 +200,6 @@ NOTIFICATION_SENSOR_MAPPINGS: list[NotificationSensorMapping] = [ ] -PROPERTY_DOOR_STATUS = "doorStatus" - - class PropertySensorMapping(TypedDict, total=False): """Represent a property sensor mapping dict type.""" @@ -211,7 +212,7 @@ class PropertySensorMapping(TypedDict, total=False): # Mappings for property sensors PROPERTY_SENSOR_MAPPINGS: list[PropertySensorMapping] = [ { - "property_name": PROPERTY_DOOR_STATUS, + "property_name": DOOR_STATUS_PROPERTY, "on_states": ["open"], "device_class": DEVICE_CLASS_DOOR, "enabled": True, @@ -327,7 +328,9 @@ class ZWaveNotificationBinarySensor(ZWaveBaseEntity, BinarySensorEntity): for mapping in NOTIFICATION_SENSOR_MAPPINGS: if ( mapping["type"] - != self.info.primary_value.metadata.cc_specific["notificationType"] + != self.info.primary_value.metadata.cc_specific[ + CC_SPECIFIC_NOTIFICATION_TYPE + ] ): continue if not mapping.get("states") or self.state_key in mapping["states"]: diff --git a/homeassistant/components/zwave_js/climate.py b/homeassistant/components/zwave_js/climate.py index 1ec5ccbcc01..f4fd8d10886 100644 --- a/homeassistant/components/zwave_js/climate.py +++ b/homeassistant/components/zwave_js/climate.py @@ -7,6 +7,7 @@ from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import CommandClass from zwave_js_server.const.command_class.thermostat import ( THERMOSTAT_CURRENT_TEMP_PROPERTY, + THERMOSTAT_HUMIDITY_PROPERTY, THERMOSTAT_MODE_PROPERTY, THERMOSTAT_MODE_SETPOINT_MAP, THERMOSTAT_MODES, @@ -176,7 +177,7 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): if not self._unit_value: self._unit_value = self._current_temp self._current_humidity = self.get_zwave_value( - "Humidity", + THERMOSTAT_HUMIDITY_PROPERTY, command_class=CommandClass.SENSOR_MULTILEVEL, add_to_watched_value_ids=True, check_all_endpoints=True, diff --git a/homeassistant/components/zwave_js/cover.py b/homeassistant/components/zwave_js/cover.py index 9060e13a9a5..e9759dbb171 100644 --- a/homeassistant/components/zwave_js/cover.py +++ b/homeassistant/components/zwave_js/cover.py @@ -5,7 +5,16 @@ import logging from typing import Any from zwave_js_server.client import Client as ZwaveClient +from zwave_js_server.const import TARGET_STATE_PROPERTY, TARGET_VALUE_PROPERTY from zwave_js_server.const.command_class.barrier_operator import BarrierState +from zwave_js_server.const.command_class.multilevel_switch import ( + COVER_CLOSE_PROPERTY, + COVER_DOWN_PROPERTY, + COVER_OFF_PROPERTY, + COVER_ON_PROPERTY, + COVER_OPEN_PROPERTY, + COVER_UP_PROPERTY, +) from zwave_js_server.model.value import Value as ZwaveValue from homeassistant.components.cover import ( @@ -105,36 +114,36 @@ class ZWaveCover(ZWaveBaseEntity, CoverEntity): async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" - target_value = self.get_zwave_value("targetValue") + target_value = self.get_zwave_value(TARGET_VALUE_PROPERTY) await self.info.node.async_set_value( target_value, percent_to_zwave_position(kwargs[ATTR_POSITION]) ) async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - target_value = self.get_zwave_value("targetValue") + target_value = self.get_zwave_value(TARGET_VALUE_PROPERTY) await self.info.node.async_set_value(target_value, 99) async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" - target_value = self.get_zwave_value("targetValue") + target_value = self.get_zwave_value(TARGET_VALUE_PROPERTY) await self.info.node.async_set_value(target_value, 0) async def async_stop_cover(self, **kwargs: Any) -> None: """Stop cover.""" open_value = ( - self.get_zwave_value("Open") - or self.get_zwave_value("Up") - or self.get_zwave_value("On") + self.get_zwave_value(COVER_OPEN_PROPERTY) + or self.get_zwave_value(COVER_UP_PROPERTY) + or self.get_zwave_value(COVER_ON_PROPERTY) ) if open_value: # Stop the cover if it's opening await self.info.node.async_set_value(open_value, False) close_value = ( - self.get_zwave_value("Close") - or self.get_zwave_value("Down") - or self.get_zwave_value("Off") + self.get_zwave_value(COVER_CLOSE_PROPERTY) + or self.get_zwave_value(COVER_DOWN_PROPERTY) + or self.get_zwave_value(COVER_OFF_PROPERTY) ) if close_value: # Stop the cover if it's closing @@ -156,7 +165,7 @@ class ZwaveMotorizedBarrier(ZWaveBaseEntity, CoverEntity): """Initialize a ZwaveMotorizedBarrier entity.""" super().__init__(config_entry, client, info) self._target_state: ZwaveValue = self.get_zwave_value( - "targetState", add_to_watched_value_ids=False + TARGET_STATE_PROPERTY, add_to_watched_value_ids=False ) @property diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 7fa15f9f95a..23053804aae 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -6,9 +6,32 @@ from dataclasses import asdict, dataclass, field from typing import Any from awesomeversion import AwesomeVersion -from zwave_js_server.const import CommandClass +from zwave_js_server.const import ( + CURRENT_STATE_PROPERTY, + CURRENT_VALUE_PROPERTY, + TARGET_STATE_PROPERTY, + TARGET_VALUE_PROPERTY, + CommandClass, +) +from zwave_js_server.const.command_class.barrier_operator import ( + SIGNALING_STATE_PROPERTY, +) +from zwave_js_server.const.command_class.lock import ( + CURRENT_MODE_PROPERTY, + DOOR_STATUS_PROPERTY, + LOCKED_PROPERTY, +) +from zwave_js_server.const.command_class.meter import VALUE_PROPERTY +from zwave_js_server.const.command_class.protection import LOCAL_PROPERTY, RF_PROPERTY +from zwave_js_server.const.command_class.sound_switch import ( + DEFAULT_TONE_ID_PROPERTY, + DEFAULT_VOLUME_PROPERTY, + TONE_ID_PROPERTY, +) from zwave_js_server.const.command_class.thermostat import ( THERMOSTAT_CURRENT_TEMP_PROPERTY, + THERMOSTAT_MODE_PROPERTY, + THERMOSTAT_SETPOINT_PROPERTY, ) from zwave_js_server.exceptions import UnknownValueData from zwave_js_server.model.device_class import DeviceClassItem @@ -179,16 +202,18 @@ def get_config_parameter_discovery_schema( SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA = ZWaveValueDiscoverySchema( command_class={CommandClass.SWITCH_MULTILEVEL}, - property={"currentValue"}, + property={CURRENT_VALUE_PROPERTY}, type={"number"}, ) SWITCH_BINARY_CURRENT_VALUE_SCHEMA = ZWaveValueDiscoverySchema( - command_class={CommandClass.SWITCH_BINARY}, property={"currentValue"} + command_class={CommandClass.SWITCH_BINARY}, property={CURRENT_VALUE_PROPERTY} ) SIREN_TONE_SCHEMA = ZWaveValueDiscoverySchema( - command_class={CommandClass.SOUND_SWITCH}, property={"toneId"}, type={"number"} + command_class={CommandClass.SOUND_SWITCH}, + property={TONE_ID_PROPERTY}, + type={"number"}, ) # For device class mapping see: @@ -229,7 +254,7 @@ DISCOVERY_SCHEMAS = [ primary_value=ZWaveValueDiscoverySchema( command_class={CommandClass.SWITCH_MULTILEVEL}, endpoint={2}, - property={"currentValue"}, + property={CURRENT_VALUE_PROPERTY}, type={"number"}, ), ), @@ -287,7 +312,7 @@ DISCOVERY_SCHEMAS = [ product_type={0x0003}, primary_value=ZWaveValueDiscoverySchema( command_class={CommandClass.THERMOSTAT_MODE}, - property={"mode"}, + property={THERMOSTAT_MODE_PROPERTY}, type={"number"}, ), data_template=DynamicCurrentTempClimateDataTemplate( @@ -334,7 +359,7 @@ DISCOVERY_SCHEMAS = [ firmware_version_range=FirmwareVersionRange(min="3.0"), primary_value=ZWaveValueDiscoverySchema( command_class={CommandClass.THERMOSTAT_MODE}, - property={"mode"}, + property={THERMOSTAT_MODE_PROPERTY}, type={"number"}, ), data_template=DynamicCurrentTempClimateDataTemplate( @@ -393,7 +418,7 @@ DISCOVERY_SCHEMAS = [ CommandClass.LOCK, CommandClass.DOOR_LOCK, }, - property={"currentMode", "locked"}, + property={CURRENT_MODE_PROPERTY, LOCKED_PROPERTY}, type={"number", "boolean"}, ), ), @@ -406,7 +431,7 @@ DISCOVERY_SCHEMAS = [ CommandClass.LOCK, CommandClass.DOOR_LOCK, }, - property={"doorStatus"}, + property={DOOR_STATUS_PROPERTY}, type={"any"}, ), ), @@ -416,7 +441,7 @@ DISCOVERY_SCHEMAS = [ platform="climate", primary_value=ZWaveValueDiscoverySchema( command_class={CommandClass.THERMOSTAT_MODE}, - property={"mode"}, + property={THERMOSTAT_MODE_PROPERTY}, type={"number"}, ), ), @@ -425,13 +450,13 @@ DISCOVERY_SCHEMAS = [ platform="climate", primary_value=ZWaveValueDiscoverySchema( command_class={CommandClass.THERMOSTAT_SETPOINT}, - property={"setpoint"}, + property={THERMOSTAT_SETPOINT_PROPERTY}, type={"number"}, ), absent_values=[ # mode must not be present to prevent dupes ZWaveValueDiscoverySchema( command_class={CommandClass.THERMOSTAT_MODE}, - property={"mode"}, + property={THERMOSTAT_MODE_PROPERTY}, type={"number"}, ), ], @@ -532,7 +557,7 @@ DISCOVERY_SCHEMAS = [ CommandClass.METER, }, type={"number"}, - property={"value"}, + property={VALUE_PROPERTY}, ), data_template=NumericSensorDataTemplate(), ), @@ -558,7 +583,7 @@ DISCOVERY_SCHEMAS = [ CommandClass.BASIC, }, type={"number"}, - property={"currentValue"}, + property={CURRENT_VALUE_PROPERTY}, ), required_values=[ ZWaveValueDiscoverySchema( @@ -566,7 +591,7 @@ DISCOVERY_SCHEMAS = [ CommandClass.BASIC, }, type={"number"}, - property={"targetValue"}, + property={TARGET_VALUE_PROPERTY}, ) ], data_template=NumericSensorDataTemplate(), @@ -584,7 +609,7 @@ DISCOVERY_SCHEMAS = [ hint="barrier_event_signaling_state", primary_value=ZWaveValueDiscoverySchema( command_class={CommandClass.BARRIER_OPERATOR}, - property={"signalingState"}, + property={SIGNALING_STATE_PROPERTY}, type={"number"}, ), ), @@ -609,13 +634,13 @@ DISCOVERY_SCHEMAS = [ hint="motorized_barrier", primary_value=ZWaveValueDiscoverySchema( command_class={CommandClass.BARRIER_OPERATOR}, - property={"currentState"}, + property={CURRENT_STATE_PROPERTY}, type={"number"}, ), required_values=[ ZWaveValueDiscoverySchema( command_class={CommandClass.BARRIER_OPERATOR}, - property={"targetState"}, + property={TARGET_STATE_PROPERTY}, type={"number"}, ), ], @@ -657,7 +682,7 @@ DISCOVERY_SCHEMAS = [ hint="Default tone", primary_value=ZWaveValueDiscoverySchema( command_class={CommandClass.SOUND_SWITCH}, - property={"defaultToneId"}, + property={DEFAULT_TONE_ID_PROPERTY}, type={"number"}, ), required_values=[SIREN_TONE_SCHEMA], @@ -669,7 +694,7 @@ DISCOVERY_SCHEMAS = [ hint="volume", primary_value=ZWaveValueDiscoverySchema( command_class={CommandClass.SOUND_SWITCH}, - property={"defaultVolume"}, + property={DEFAULT_VOLUME_PROPERTY}, type={"number"}, ), required_values=[SIREN_TONE_SCHEMA], @@ -680,7 +705,7 @@ DISCOVERY_SCHEMAS = [ platform="select", primary_value=ZWaveValueDiscoverySchema( command_class={CommandClass.PROTECTION}, - property={"local", "rf"}, + property={LOCAL_PROPERTY, RF_PROPERTY}, type={"number"}, ), ), diff --git a/homeassistant/components/zwave_js/fan.py b/homeassistant/components/zwave_js/fan.py index 71f483c548f..6ee709893cb 100644 --- a/homeassistant/components/zwave_js/fan.py +++ b/homeassistant/components/zwave_js/fan.py @@ -5,6 +5,7 @@ import math from typing import Any from zwave_js_server.client import Client as ZwaveClient +from zwave_js_server.const import TARGET_VALUE_PROPERTY from homeassistant.components.fan import ( DOMAIN as FAN_DOMAIN, @@ -59,7 +60,7 @@ class ZwaveFan(ZWaveBaseEntity, FanEntity): async def async_set_percentage(self, percentage: int | None) -> None: """Set the speed percentage of the fan.""" - target_value = self.get_zwave_value("targetValue") + target_value = self.get_zwave_value(TARGET_VALUE_PROPERTY) if percentage is None: # Value 255 tells device to return to previous value @@ -83,7 +84,7 @@ class ZwaveFan(ZWaveBaseEntity, FanEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" - target_value = self.get_zwave_value("targetValue") + target_value = self.get_zwave_value(TARGET_VALUE_PROPERTY) await self.info.node.async_set_value(target_value, 0) @property diff --git a/homeassistant/components/zwave_js/light.py b/homeassistant/components/zwave_js/light.py index 0857b43e4ee..caba0f5de36 100644 --- a/homeassistant/components/zwave_js/light.py +++ b/homeassistant/components/zwave_js/light.py @@ -5,8 +5,24 @@ import logging from typing import Any from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const import CommandClass -from zwave_js_server.const.command_class.color_switch import ColorComponent +from zwave_js_server.const import ( + TARGET_VALUE_PROPERTY, + TRANSITION_DURATION_OPTION, + CommandClass, +) +from zwave_js_server.const.command_class.color_switch import ( + COLOR_SWITCH_COMBINED_AMBER, + COLOR_SWITCH_COMBINED_BLUE, + COLOR_SWITCH_COMBINED_COLD_WHITE, + COLOR_SWITCH_COMBINED_CYAN, + COLOR_SWITCH_COMBINED_GREEN, + COLOR_SWITCH_COMBINED_PURPLE, + COLOR_SWITCH_COMBINED_RED, + COLOR_SWITCH_COMBINED_WARM_WHITE, + CURRENT_COLOR_PROPERTY, + TARGET_COLOR_PROPERTY, + ColorComponent, +) from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -35,18 +51,16 @@ from .entity import ZWaveBaseEntity LOGGER = logging.getLogger(__name__) MULTI_COLOR_MAP = { - ColorComponent.WARM_WHITE: "warmWhite", - ColorComponent.COLD_WHITE: "coldWhite", - ColorComponent.RED: "red", - ColorComponent.GREEN: "green", - ColorComponent.BLUE: "blue", - ColorComponent.AMBER: "amber", - ColorComponent.CYAN: "cyan", - ColorComponent.PURPLE: "purple", + ColorComponent.WARM_WHITE: COLOR_SWITCH_COMBINED_WARM_WHITE, + ColorComponent.COLD_WHITE: COLOR_SWITCH_COMBINED_COLD_WHITE, + ColorComponent.RED: COLOR_SWITCH_COMBINED_RED, + ColorComponent.GREEN: COLOR_SWITCH_COMBINED_GREEN, + ColorComponent.BLUE: COLOR_SWITCH_COMBINED_BLUE, + ColorComponent.AMBER: COLOR_SWITCH_COMBINED_AMBER, + ColorComponent.CYAN: COLOR_SWITCH_COMBINED_CYAN, + ColorComponent.PURPLE: COLOR_SWITCH_COMBINED_PURPLE, } -TRANSITION_DURATION = "transitionDuration" - async def async_setup_entry( hass: HomeAssistant, @@ -100,12 +114,12 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): self._min_mireds = 153 # 6500K as a safe default self._max_mireds = 370 # 2700K as a safe default self._warm_white = self.get_zwave_value( - "targetColor", + TARGET_COLOR_PROPERTY, CommandClass.SWITCH_COLOR, value_property_key=ColorComponent.WARM_WHITE, ) self._cold_white = self.get_zwave_value( - "targetColor", + TARGET_COLOR_PROPERTY, CommandClass.SWITCH_COLOR, value_property_key=ColorComponent.COLD_WHITE, ) @@ -113,10 +127,12 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): # get additional (optional) values and set features self._target_brightness = self.get_zwave_value( - "targetValue", add_to_watched_value_ids=False + TARGET_VALUE_PROPERTY, add_to_watched_value_ids=False ) self._target_color = self.get_zwave_value( - "targetColor", CommandClass.SWITCH_COLOR, add_to_watched_value_ids=False + TARGET_COLOR_PROPERTY, + CommandClass.SWITCH_COLOR, + add_to_watched_value_ids=False, ) self._calculate_color_values() @@ -133,12 +149,13 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): self._attr_supported_features = 0 self.supports_brightness_transition = bool( self._target_brightness is not None - and TRANSITION_DURATION + and TRANSITION_DURATION_OPTION in self._target_brightness.metadata.value_change_options ) self.supports_color_transition = bool( self._target_color is not None - and TRANSITION_DURATION in self._target_color.metadata.value_change_options + and TRANSITION_DURATION_OPTION + in self._target_color.metadata.value_change_options ) if self.supports_brightness_transition or self.supports_color_transition: @@ -284,9 +301,9 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): if self.supports_color_transition: if transition is not None: - zwave_transition = {TRANSITION_DURATION: f"{int(transition)}s"} + zwave_transition = {TRANSITION_DURATION_OPTION: f"{int(transition)}s"} else: - zwave_transition = {TRANSITION_DURATION: "default"} + zwave_transition = {TRANSITION_DURATION_OPTION: "default"} colors_dict = {} for color, value in colors.items(): @@ -312,9 +329,9 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): zwave_transition = None if self.supports_brightness_transition: if transition is not None: - zwave_transition = {TRANSITION_DURATION: f"{int(transition)}s"} + zwave_transition = {TRANSITION_DURATION_OPTION: f"{int(transition)}s"} else: - zwave_transition = {TRANSITION_DURATION: "default"} + zwave_transition = {TRANSITION_DURATION_OPTION: "default"} # setting a value requires setting targetValue await self.info.node.async_set_value( @@ -328,34 +345,34 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): # to find out what colors are supported # as this is a simple lookup by key, this not heavy red_val = self.get_zwave_value( - "currentColor", + CURRENT_COLOR_PROPERTY, CommandClass.SWITCH_COLOR, value_property_key=ColorComponent.RED.value, ) green_val = self.get_zwave_value( - "currentColor", + CURRENT_COLOR_PROPERTY, CommandClass.SWITCH_COLOR, value_property_key=ColorComponent.GREEN.value, ) blue_val = self.get_zwave_value( - "currentColor", + CURRENT_COLOR_PROPERTY, CommandClass.SWITCH_COLOR, value_property_key=ColorComponent.BLUE.value, ) ww_val = self.get_zwave_value( - "currentColor", + CURRENT_COLOR_PROPERTY, CommandClass.SWITCH_COLOR, value_property_key=ColorComponent.WARM_WHITE.value, ) cw_val = self.get_zwave_value( - "currentColor", + CURRENT_COLOR_PROPERTY, CommandClass.SWITCH_COLOR, value_property_key=ColorComponent.COLD_WHITE.value, ) # prefer the (new) combined color property # https://github.com/zwave-js/node-zwave-js/pull/1782 combined_color_val = self.get_zwave_value( - "currentColor", + CURRENT_COLOR_PROPERTY, CommandClass.SWITCH_COLOR, value_property_key=None, ) @@ -370,9 +387,9 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): # RGB support if red_val and green_val and blue_val: # prefer values from the multicolor property - red = multi_color.get("red", red_val.value) - green = multi_color.get("green", green_val.value) - blue = multi_color.get("blue", blue_val.value) + red = multi_color.get(COLOR_SWITCH_COMBINED_RED, red_val.value) + green = multi_color.get(COLOR_SWITCH_COMBINED_GREEN, green_val.value) + blue = multi_color.get(COLOR_SWITCH_COMBINED_BLUE, blue_val.value) self._supports_color = True if None not in (red, green, blue): # convert to HS @@ -383,8 +400,8 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): # color temperature support if ww_val and cw_val: self._supports_color_temp = True - warm_white = multi_color.get("warmWhite", ww_val.value) - cold_white = multi_color.get("coldWhite", cw_val.value) + warm_white = multi_color.get(COLOR_SWITCH_COMBINED_WARM_WHITE, ww_val.value) + cold_white = multi_color.get(COLOR_SWITCH_COMBINED_COLD_WHITE, cw_val.value) # Calculate color temps based on whites if cold_white or warm_white: self._color_temp = round( @@ -398,14 +415,14 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): # only one white channel (warm white) = rgbw support elif red_val and green_val and blue_val and ww_val: self._supports_rgbw = True - white = multi_color.get("warmWhite", ww_val.value) + white = multi_color.get(COLOR_SWITCH_COMBINED_WARM_WHITE, ww_val.value) self._rgbw_color = (red, green, blue, white) # Light supports rgbw, set color mode to rgbw self._color_mode = COLOR_MODE_RGBW # only one white channel (cool white) = rgbw support elif cw_val: self._supports_rgbw = True - white = multi_color.get("coldWhite", cw_val.value) + white = multi_color.get(COLOR_SWITCH_COMBINED_COLD_WHITE, cw_val.value) self._rgbw_color = (red, green, blue, white) # Light supports rgbw, set color mode to rgbw self._color_mode = COLOR_MODE_RGBW diff --git a/homeassistant/components/zwave_js/number.py b/homeassistant/components/zwave_js/number.py index 675a396fb7b..16434b51108 100644 --- a/homeassistant/components/zwave_js/number.py +++ b/homeassistant/components/zwave_js/number.py @@ -2,6 +2,7 @@ from __future__ import annotations from zwave_js_server.client import Client as ZwaveClient +from zwave_js_server.const import TARGET_VALUE_PROPERTY from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, NumberEntity from homeassistant.config_entries import ConfigEntry @@ -52,7 +53,7 @@ class ZwaveNumberEntity(ZWaveBaseEntity, NumberEntity): if self.info.primary_value.metadata.writeable: self._target_value = self.info.primary_value else: - self._target_value = self.get_zwave_value("targetValue") + self._target_value = self.get_zwave_value(TARGET_VALUE_PROPERTY) # Entity class attributes self._attr_name = self.generate_name( diff --git a/homeassistant/components/zwave_js/select.py b/homeassistant/components/zwave_js/select.py index 9ec4d02bfec..15223419ced 100644 --- a/homeassistant/components/zwave_js/select.py +++ b/homeassistant/components/zwave_js/select.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Dict, cast from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const import CommandClass +from zwave_js_server.const import TARGET_VALUE_PROPERTY, CommandClass from zwave_js_server.const.command_class.sound_switch import ToneID from homeassistant.components.select import DOMAIN as SELECT_DOMAIN, SelectEntity @@ -142,7 +142,7 @@ class ZwaveMultilevelSwitchSelectEntity(ZWaveBaseEntity, SelectEntity): ) -> None: """Initialize a ZwaveSelectEntity entity.""" super().__init__(config_entry, client, info) - self._target_value = self.get_zwave_value("targetValue") + self._target_value = self.get_zwave_value(TARGET_VALUE_PROPERTY) assert self.info.platform_data_template self._lookup_map = cast( Dict[int, str], self.info.platform_data_template.static_data diff --git a/homeassistant/components/zwave_js/switch.py b/homeassistant/components/zwave_js/switch.py index 44aa3a5566f..390ba6eaf0b 100644 --- a/homeassistant/components/zwave_js/switch.py +++ b/homeassistant/components/zwave_js/switch.py @@ -5,6 +5,7 @@ import logging from typing import Any from zwave_js_server.client import Client as ZwaveClient +from zwave_js_server.const import TARGET_VALUE_PROPERTY from zwave_js_server.const.command_class.barrier_operator import ( BarrierEventSignalingSubsystemState, ) @@ -55,6 +56,14 @@ async def async_setup_entry( class ZWaveSwitch(ZWaveBaseEntity, SwitchEntity): """Representation of a Z-Wave switch.""" + def __init__( + self, config_entry: ConfigEntry, client: ZwaveClient, info: ZwaveDiscoveryInfo + ) -> None: + """Initialize the switch.""" + super().__init__(config_entry, client, info) + + self._target_value = self.get_zwave_value(TARGET_VALUE_PROPERTY) + @property def is_on(self) -> bool | None: # type: ignore """Return a boolean for the state of the switch.""" @@ -65,15 +74,13 @@ class ZWaveSwitch(ZWaveBaseEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" - target_value = self.get_zwave_value("targetValue") - if target_value is not None: - await self.info.node.async_set_value(target_value, True) + if self._target_value is not None: + await self.info.node.async_set_value(self._target_value, True) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" - target_value = self.get_zwave_value("targetValue") - if target_value is not None: - await self.info.node.async_set_value(target_value, False) + if self._target_value is not None: + await self.info.node.async_set_value(self._target_value, False) class ZWaveBarrierEventSignalingSwitch(ZWaveBaseEntity, SwitchEntity): From a7f554e6da18305c98a457e45b5368f2baa30b06 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Wed, 29 Sep 2021 22:47:15 -0400 Subject: [PATCH 715/843] Bump ZHA quirks module to 0.0.62 (#56809) --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index fe489d42f06..1a9fa64c453 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -7,7 +7,7 @@ "bellows==0.28.0", "pyserial==3.5", "pyserial-asyncio==0.5", - "zha-quirks==0.0.61", + "zha-quirks==0.0.62", "zigpy-deconz==0.13.0", "zigpy==0.38.0", "zigpy-xbee==0.14.0", diff --git a/requirements_all.txt b/requirements_all.txt index 56a183c57de..91143cac731 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2477,7 +2477,7 @@ zengge==0.2 zeroconf==0.36.7 # homeassistant.components.zha -zha-quirks==0.0.61 +zha-quirks==0.0.62 # homeassistant.components.zhong_hong zhong_hong_hvac==1.0.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c49eeffa9ab..1b040845dbd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1412,7 +1412,7 @@ youless-api==0.13 zeroconf==0.36.7 # homeassistant.components.zha -zha-quirks==0.0.61 +zha-quirks==0.0.62 # homeassistant.components.zha zigpy-deconz==0.13.0 From 2ed35debdceced266c94477c9edabb360e1e6bdc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Sep 2021 23:50:21 -0500 Subject: [PATCH 716/843] Fix dhcp discovery matching due to deferred imports (#56814) --- homeassistant/components/dhcp/__init__.py | 50 +++++----- tests/components/dhcp/test_init.py | 114 ++++++++++++---------- 2 files changed, 83 insertions(+), 81 deletions(-) diff --git a/homeassistant/components/dhcp/__init__.py b/homeassistant/components/dhcp/__init__.py index e6debfea2eb..61208ac6423 100644 --- a/homeassistant/components/dhcp/__init__.py +++ b/homeassistant/components/dhcp/__init__.py @@ -282,6 +282,9 @@ class DHCPWatcher(WatcherBase): from scapy import ( # pylint: disable=import-outside-toplevel,unused-import # noqa: F401 arch, ) + from scapy.layers.dhcp import DHCP # pylint: disable=import-outside-toplevel + from scapy.layers.inet import IP # pylint: disable=import-outside-toplevel + from scapy.layers.l2 import Ether # pylint: disable=import-outside-toplevel # # Importing scapy.sendrecv will cause a scapy resync which will @@ -294,6 +297,24 @@ class DHCPWatcher(WatcherBase): AsyncSniffer, ) + def _handle_dhcp_packet(packet): + """Process a dhcp packet.""" + if DHCP not in packet: + return + + options = packet[DHCP].options + request_type = _decode_dhcp_option(options, MESSAGE_TYPE) + if request_type != DHCP_REQUEST: + # Not a DHCP request + return + + ip_address = _decode_dhcp_option(options, REQUESTED_ADDR) or packet[IP].src + hostname = _decode_dhcp_option(options, HOSTNAME) or "" + mac_address = _format_mac(packet[Ether].src) + + if ip_address is not None and mac_address is not None: + self.process_client(ip_address, hostname, mac_address) + # disable scapy promiscuous mode as we do not need it conf.sniff_promisc = 0 @@ -320,7 +341,7 @@ class DHCPWatcher(WatcherBase): self._sniffer = AsyncSniffer( filter=FILTER, started_callback=self._started.set, - prn=self.handle_dhcp_packet, + prn=_handle_dhcp_packet, store=0, ) @@ -328,33 +349,6 @@ class DHCPWatcher(WatcherBase): if self._sniffer.thread: self._sniffer.thread.name = self.__class__.__name__ - def handle_dhcp_packet(self, packet): - """Process a dhcp packet.""" - # Local import because importing from scapy has side effects such as opening - # sockets - from scapy.layers.dhcp import DHCP # pylint: disable=import-outside-toplevel - from scapy.layers.inet import IP # pylint: disable=import-outside-toplevel - from scapy.layers.l2 import Ether # pylint: disable=import-outside-toplevel - - if DHCP not in packet: - return - - options = packet[DHCP].options - - request_type = _decode_dhcp_option(options, MESSAGE_TYPE) - if request_type != DHCP_REQUEST: - # DHCP request - return - - ip_address = _decode_dhcp_option(options, REQUESTED_ADDR) or packet[IP].src - hostname = _decode_dhcp_option(options, HOSTNAME) or "" - mac_address = _format_mac(packet[Ether].src) - - if ip_address is None or mac_address is None: - return - - self.process_client(ip_address, hostname, mac_address) - def create_task(self, task): """Pass a task to hass.add_job since we are in a thread.""" return self.hass.add_job(task) diff --git a/tests/components/dhcp/test_init.py b/tests/components/dhcp/test_init.py index 90ce1ebbf20..f00a0135e8d 100644 --- a/tests/components/dhcp/test_init.py +++ b/tests/components/dhcp/test_init.py @@ -1,7 +1,7 @@ """Test the DHCP discovery integration.""" import datetime import threading -from unittest.mock import patch +from unittest.mock import MagicMock, patch from scapy.error import Scapy_Exception from scapy.layers.dhcp import DHCP @@ -123,20 +123,39 @@ RAW_DHCP_REQUEST_WITHOUT_HOSTNAME = ( ) -async def test_dhcp_match_hostname_and_macaddress(hass): - """Test matching based on hostname and macaddress.""" +async def _async_get_handle_dhcp_packet(hass, integration_matchers): dhcp_watcher = dhcp.DHCPWatcher( hass, {}, - [{"domain": "mock-domain", "hostname": "connect", "macaddress": "B8B7F1*"}], + integration_matchers, ) + handle_dhcp_packet = None + def _mock_sniffer(*args, **kwargs): + nonlocal handle_dhcp_packet + handle_dhcp_packet = kwargs["prn"] + return MagicMock() + + with patch("homeassistant.components.dhcp._verify_l2socket_setup",), patch( + "scapy.arch.common.compile_filter" + ), patch("scapy.sendrecv.AsyncSniffer", _mock_sniffer): + await dhcp_watcher.async_start() + + return handle_dhcp_packet + + +async def test_dhcp_match_hostname_and_macaddress(hass): + """Test matching based on hostname and macaddress.""" + integration_matchers = [ + {"domain": "mock-domain", "hostname": "connect", "macaddress": "B8B7F1*"} + ] packet = Ether(RAW_DHCP_REQUEST) + handle_dhcp_packet = await _async_get_handle_dhcp_packet(hass, integration_matchers) with patch.object(hass.config_entries.flow, "async_init") as mock_init: - dhcp_watcher.handle_dhcp_packet(packet) + handle_dhcp_packet(packet) # Ensure no change is ignored - dhcp_watcher.handle_dhcp_packet(packet) + handle_dhcp_packet(packet) assert len(mock_init.mock_calls) == 1 assert mock_init.mock_calls[0][1][0] == "mock-domain" @@ -152,18 +171,17 @@ async def test_dhcp_match_hostname_and_macaddress(hass): async def test_dhcp_renewal_match_hostname_and_macaddress(hass): """Test renewal matching based on hostname and macaddress.""" - dhcp_watcher = dhcp.DHCPWatcher( - hass, - {}, - [{"domain": "mock-domain", "hostname": "irobot-*", "macaddress": "501479*"}], - ) + integration_matchers = [ + {"domain": "mock-domain", "hostname": "irobot-*", "macaddress": "501479*"} + ] packet = Ether(RAW_DHCP_RENEWAL) + handle_dhcp_packet = await _async_get_handle_dhcp_packet(hass, integration_matchers) with patch.object(hass.config_entries.flow, "async_init") as mock_init: - dhcp_watcher.handle_dhcp_packet(packet) + handle_dhcp_packet(packet) # Ensure no change is ignored - dhcp_watcher.handle_dhcp_packet(packet) + handle_dhcp_packet(packet) assert len(mock_init.mock_calls) == 1 assert mock_init.mock_calls[0][1][0] == "mock-domain" @@ -179,14 +197,13 @@ async def test_dhcp_renewal_match_hostname_and_macaddress(hass): async def test_dhcp_match_hostname(hass): """Test matching based on hostname only.""" - dhcp_watcher = dhcp.DHCPWatcher( - hass, {}, [{"domain": "mock-domain", "hostname": "connect"}] - ) + integration_matchers = [{"domain": "mock-domain", "hostname": "connect"}] packet = Ether(RAW_DHCP_REQUEST) + handle_dhcp_packet = await _async_get_handle_dhcp_packet(hass, integration_matchers) with patch.object(hass.config_entries.flow, "async_init") as mock_init: - dhcp_watcher.handle_dhcp_packet(packet) + handle_dhcp_packet(packet) assert len(mock_init.mock_calls) == 1 assert mock_init.mock_calls[0][1][0] == "mock-domain" @@ -202,14 +219,13 @@ async def test_dhcp_match_hostname(hass): async def test_dhcp_match_macaddress(hass): """Test matching based on macaddress only.""" - dhcp_watcher = dhcp.DHCPWatcher( - hass, {}, [{"domain": "mock-domain", "macaddress": "B8B7F1*"}] - ) + integration_matchers = [{"domain": "mock-domain", "macaddress": "B8B7F1*"}] packet = Ether(RAW_DHCP_REQUEST) + handle_dhcp_packet = await _async_get_handle_dhcp_packet(hass, integration_matchers) with patch.object(hass.config_entries.flow, "async_init") as mock_init: - dhcp_watcher.handle_dhcp_packet(packet) + handle_dhcp_packet(packet) assert len(mock_init.mock_calls) == 1 assert mock_init.mock_calls[0][1][0] == "mock-domain" @@ -225,14 +241,13 @@ async def test_dhcp_match_macaddress(hass): async def test_dhcp_match_macaddress_without_hostname(hass): """Test matching based on macaddress only.""" - dhcp_watcher = dhcp.DHCPWatcher( - hass, {}, [{"domain": "mock-domain", "macaddress": "606BBD*"}] - ) + integration_matchers = [{"domain": "mock-domain", "macaddress": "606BBD*"}] packet = Ether(RAW_DHCP_REQUEST_WITHOUT_HOSTNAME) + handle_dhcp_packet = await _async_get_handle_dhcp_packet(hass, integration_matchers) with patch.object(hass.config_entries.flow, "async_init") as mock_init: - dhcp_watcher.handle_dhcp_packet(packet) + handle_dhcp_packet(packet) assert len(mock_init.mock_calls) == 1 assert mock_init.mock_calls[0][1][0] == "mock-domain" @@ -248,51 +263,46 @@ async def test_dhcp_match_macaddress_without_hostname(hass): async def test_dhcp_nomatch(hass): """Test not matching based on macaddress only.""" - dhcp_watcher = dhcp.DHCPWatcher( - hass, {}, [{"domain": "mock-domain", "macaddress": "ABC123*"}] - ) + integration_matchers = [{"domain": "mock-domain", "macaddress": "ABC123*"}] packet = Ether(RAW_DHCP_REQUEST) + handle_dhcp_packet = await _async_get_handle_dhcp_packet(hass, integration_matchers) with patch.object(hass.config_entries.flow, "async_init") as mock_init: - dhcp_watcher.handle_dhcp_packet(packet) + handle_dhcp_packet(packet) assert len(mock_init.mock_calls) == 0 async def test_dhcp_nomatch_hostname(hass): """Test not matching based on hostname only.""" - dhcp_watcher = dhcp.DHCPWatcher( - hass, {}, [{"domain": "mock-domain", "hostname": "nomatch*"}] - ) + integration_matchers = [{"domain": "mock-domain", "hostname": "nomatch*"}] packet = Ether(RAW_DHCP_REQUEST) + handle_dhcp_packet = await _async_get_handle_dhcp_packet(hass, integration_matchers) with patch.object(hass.config_entries.flow, "async_init") as mock_init: - dhcp_watcher.handle_dhcp_packet(packet) + handle_dhcp_packet(packet) assert len(mock_init.mock_calls) == 0 async def test_dhcp_nomatch_non_dhcp_packet(hass): """Test matching does not throw on a non-dhcp packet.""" - dhcp_watcher = dhcp.DHCPWatcher( - hass, {}, [{"domain": "mock-domain", "hostname": "nomatch*"}] - ) + integration_matchers = [{"domain": "mock-domain", "hostname": "nomatch*"}] packet = Ether(b"") + handle_dhcp_packet = await _async_get_handle_dhcp_packet(hass, integration_matchers) with patch.object(hass.config_entries.flow, "async_init") as mock_init: - dhcp_watcher.handle_dhcp_packet(packet) + handle_dhcp_packet(packet) assert len(mock_init.mock_calls) == 0 async def test_dhcp_nomatch_non_dhcp_request_packet(hass): """Test nothing happens with the wrong message-type.""" - dhcp_watcher = dhcp.DHCPWatcher( - hass, {}, [{"domain": "mock-domain", "hostname": "nomatch*"}] - ) + integration_matchers = [{"domain": "mock-domain", "hostname": "nomatch*"}] packet = Ether(RAW_DHCP_REQUEST) @@ -305,17 +315,16 @@ async def test_dhcp_nomatch_non_dhcp_request_packet(hass): ("hostname", b"connect"), ] + handle_dhcp_packet = await _async_get_handle_dhcp_packet(hass, integration_matchers) with patch.object(hass.config_entries.flow, "async_init") as mock_init: - dhcp_watcher.handle_dhcp_packet(packet) + handle_dhcp_packet(packet) assert len(mock_init.mock_calls) == 0 async def test_dhcp_invalid_hostname(hass): """Test we ignore invalid hostnames.""" - dhcp_watcher = dhcp.DHCPWatcher( - hass, {}, [{"domain": "mock-domain", "hostname": "nomatch*"}] - ) + integration_matchers = [{"domain": "mock-domain", "hostname": "nomatch*"}] packet = Ether(RAW_DHCP_REQUEST) @@ -328,17 +337,16 @@ async def test_dhcp_invalid_hostname(hass): ("hostname", "connect"), ] + handle_dhcp_packet = await _async_get_handle_dhcp_packet(hass, integration_matchers) with patch.object(hass.config_entries.flow, "async_init") as mock_init: - dhcp_watcher.handle_dhcp_packet(packet) + handle_dhcp_packet(packet) assert len(mock_init.mock_calls) == 0 async def test_dhcp_missing_hostname(hass): """Test we ignore missing hostnames.""" - dhcp_watcher = dhcp.DHCPWatcher( - hass, {}, [{"domain": "mock-domain", "hostname": "nomatch*"}] - ) + integration_matchers = [{"domain": "mock-domain", "hostname": "nomatch*"}] packet = Ether(RAW_DHCP_REQUEST) @@ -351,17 +359,16 @@ async def test_dhcp_missing_hostname(hass): ("hostname", None), ] + handle_dhcp_packet = await _async_get_handle_dhcp_packet(hass, integration_matchers) with patch.object(hass.config_entries.flow, "async_init") as mock_init: - dhcp_watcher.handle_dhcp_packet(packet) + handle_dhcp_packet(packet) assert len(mock_init.mock_calls) == 0 async def test_dhcp_invalid_option(hass): """Test we ignore invalid hostname option.""" - dhcp_watcher = dhcp.DHCPWatcher( - hass, {}, [{"domain": "mock-domain", "hostname": "nomatch*"}] - ) + integration_matchers = [{"domain": "mock-domain", "hostname": "nomatch*"}] packet = Ether(RAW_DHCP_REQUEST) @@ -374,8 +381,9 @@ async def test_dhcp_invalid_option(hass): ("hostname"), ] + handle_dhcp_packet = await _async_get_handle_dhcp_packet(hass, integration_matchers) with patch.object(hass.config_entries.flow, "async_init") as mock_init: - dhcp_watcher.handle_dhcp_packet(packet) + handle_dhcp_packet(packet) assert len(mock_init.mock_calls) == 0 From 51addfc16480b852552bd6b4a27a64b4d827488e Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Thu, 30 Sep 2021 09:28:04 +0300 Subject: [PATCH 717/843] Add device_info to `Speedtestdotnet` and some code cleanup (#56612) * Apply code cleanup suggestions from previous PRs * Update homeassistant/components/speedtestdotnet/const.py Co-authored-by: Franck Nijhof * fix native_value, and ping value in test * use self._state instead of _attr_native_value * update identifiers and add more tests Co-authored-by: Franck Nijhof --- .../components/speedtestdotnet/__init__.py | 8 ++- .../components/speedtestdotnet/const.py | 23 ++++++--- .../components/speedtestdotnet/sensor.py | 50 ++++++++----------- tests/components/speedtestdotnet/__init__.py | 2 +- .../speedtestdotnet/test_config_flow.py | 22 ++++++++ tests/components/speedtestdotnet/test_init.py | 13 ++++- .../components/speedtestdotnet/test_sensor.py | 35 +++++++++++-- 7 files changed, 111 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/speedtestdotnet/__init__.py b/homeassistant/components/speedtestdotnet/__init__.py index 94c7f8fb039..fd49a2f6e3f 100644 --- a/homeassistant/components/speedtestdotnet/__init__.py +++ b/homeassistant/components/speedtestdotnet/__init__.py @@ -83,6 +83,11 @@ class SpeedTestDataCoordinator(DataUpdateCoordinator): update_method=self.async_update, ) + def initialize(self) -> None: + """Initialize speedtest api.""" + self.api = speedtest.Speedtest() + self.update_servers() + def update_servers(self): """Update list of test servers.""" test_servers = self.api.get_servers() @@ -131,8 +136,7 @@ class SpeedTestDataCoordinator(DataUpdateCoordinator): async def async_setup(self) -> None: """Set up SpeedTest.""" try: - self.api = await self.hass.async_add_executor_job(speedtest.Speedtest) - await self.hass.async_add_executor_job(self.update_servers) + await self.hass.async_add_executor_job(self.initialize) except speedtest.SpeedtestException as err: raise ConfigEntryNotReady from err diff --git a/homeassistant/components/speedtestdotnet/const.py b/homeassistant/components/speedtestdotnet/const.py index c9962362406..57beaf99eb9 100644 --- a/homeassistant/components/speedtestdotnet/const.py +++ b/homeassistant/components/speedtestdotnet/const.py @@ -1,7 +1,8 @@ -"""Consts used by Speedtest.net.""" +"""Constants used by Speedtest.net.""" from __future__ import annotations -from typing import Final +from dataclasses import dataclass +from typing import Callable, Final from homeassistant.components.sensor import ( STATE_CLASS_MEASUREMENT, @@ -13,24 +14,34 @@ DOMAIN: Final = "speedtestdotnet" SPEED_TEST_SERVICE: Final = "speedtest" -SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = ( - SensorEntityDescription( + +@dataclass +class SpeedtestSensorEntityDescription(SensorEntityDescription): + """Class describing Speedtest sensor entities.""" + + value: Callable = round + + +SENSOR_TYPES: Final[tuple[SpeedtestSensorEntityDescription, ...]] = ( + SpeedtestSensorEntityDescription( key="ping", name="Ping", native_unit_of_measurement=TIME_MILLISECONDS, state_class=STATE_CLASS_MEASUREMENT, ), - SensorEntityDescription( + SpeedtestSensorEntityDescription( key="download", name="Download", native_unit_of_measurement=DATA_RATE_MEGABITS_PER_SECOND, state_class=STATE_CLASS_MEASUREMENT, + value=lambda value: round(value / 10 ** 6, 2), ), - SensorEntityDescription( + SpeedtestSensorEntityDescription( key="upload", name="Upload", native_unit_of_measurement=DATA_RATE_MEGABITS_PER_SECOND, state_class=STATE_CLASS_MEASUREMENT, + value=lambda value: round(value / 10 ** 6, 2), ), ) diff --git a/homeassistant/components/speedtestdotnet/sensor.py b/homeassistant/components/speedtestdotnet/sensor.py index 8e2d5404438..fa9cd137ba1 100644 --- a/homeassistant/components/speedtestdotnet/sensor.py +++ b/homeassistant/components/speedtestdotnet/sensor.py @@ -1,15 +1,16 @@ """Support for Speedtest.net internet speed testing sensor.""" from __future__ import annotations -from typing import Any +from typing import Any, cast -from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.components.sensor import SensorEntity from homeassistant.components.speedtestdotnet import SpeedTestDataCoordinator from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ATTRIBUTION -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( @@ -23,6 +24,7 @@ from .const import ( DOMAIN, ICON, SENSOR_TYPES, + SpeedtestSensorEntityDescription, ) @@ -43,19 +45,34 @@ class SpeedtestSensor(CoordinatorEntity, RestoreEntity, SensorEntity): """Implementation of a speedtest.net sensor.""" coordinator: SpeedTestDataCoordinator + entity_description: SpeedtestSensorEntityDescription _attr_icon = ICON def __init__( self, coordinator: SpeedTestDataCoordinator, - description: SensorEntityDescription, + description: SpeedtestSensorEntityDescription, ) -> None: """Initialize the sensor.""" super().__init__(coordinator) self.entity_description = description self._attr_name = f"{DEFAULT_NAME} {description.name}" self._attr_unique_id = description.key + self._state: StateType = None self._attrs = {ATTR_ATTRIBUTION: ATTRIBUTION} + self._attr_device_info = { + "identifiers": {(DOMAIN, self.coordinator.config_entry.entry_id)}, + "name": DEFAULT_NAME, + "entry_type": "service", + } + + @property + def native_value(self) -> StateType: + """Return native value for entity.""" + if self.coordinator.data: + state = self.coordinator.data[self.entity_description.key] + self._state = cast(StateType, self.entity_description.value(state)) + return self._state @property def extra_state_attributes(self) -> dict[str, Any]: @@ -83,27 +100,4 @@ class SpeedtestSensor(CoordinatorEntity, RestoreEntity, SensorEntity): await super().async_added_to_hass() state = await self.async_get_last_state() if state: - self._attr_native_value = state.state - - @callback - def update() -> None: - """Update state.""" - self._update_state() - self.async_write_ha_state() - - self.async_on_remove(self.coordinator.async_add_listener(update)) - self._update_state() - - def _update_state(self): - """Update sensors state.""" - if self.coordinator.data: - if self.entity_description.key == "ping": - self._attr_native_value = self.coordinator.data["ping"] - elif self.entity_description.key == "download": - self._attr_native_value = round( - self.coordinator.data["download"] / 10 ** 6, 2 - ) - elif self.entity_description.key == "upload": - self._attr_native_value = round( - self.coordinator.data["upload"] / 10 ** 6, 2 - ) + self._state = state.state diff --git a/tests/components/speedtestdotnet/__init__.py b/tests/components/speedtestdotnet/__init__.py index f6f64b9c7bb..b5e297f25da 100644 --- a/tests/components/speedtestdotnet/__init__.py +++ b/tests/components/speedtestdotnet/__init__.py @@ -52,4 +52,4 @@ MOCK_RESULTS = { "share": None, } -MOCK_STATES = {"ping": "18.465", "download": "1.02", "upload": "1.02"} +MOCK_STATES = {"ping": "18", "download": "1.02", "upload": "1.02"} diff --git a/tests/components/speedtestdotnet/test_config_flow.py b/tests/components/speedtestdotnet/test_config_flow.py index c3c891f6784..7f6f6970c4d 100644 --- a/tests/components/speedtestdotnet/test_config_flow.py +++ b/tests/components/speedtestdotnet/test_config_flow.py @@ -65,6 +65,28 @@ async def test_options(hass: HomeAssistant, mock_api: MagicMock) -> None: assert hass.data[DOMAIN].update_interval is None + # test setting server name to "*Auto Detect" + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_SERVER_NAME: "*Auto Detect", + CONF_SCAN_INTERVAL: 30, + CONF_MANUAL: True, + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"] == { + CONF_SERVER_NAME: "*Auto Detect", + CONF_SERVER_ID: None, + CONF_SCAN_INTERVAL: 30, + CONF_MANUAL: True, + } + # test setting the option to update periodically result2 = await hass.config_entries.options.async_init(entry.entry_id) assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM diff --git a/tests/components/speedtestdotnet/test_init.py b/tests/components/speedtestdotnet/test_init.py index fcadb0e9931..61487ca8329 100644 --- a/tests/components/speedtestdotnet/test_init.py +++ b/tests/components/speedtestdotnet/test_init.py @@ -1,4 +1,5 @@ """Tests for SpeedTest integration.""" +from datetime import timedelta from unittest.mock import MagicMock import speedtest @@ -13,8 +14,9 @@ from homeassistant.components.speedtestdotnet.const import ( from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_SCAN_INTERVAL, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant +import homeassistant.util.dt as dt_util -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_successful_config_entry(hass: HomeAssistant) -> None: @@ -74,6 +76,10 @@ async def test_server_not_found(hass: HomeAssistant, mock_api: MagicMock) -> Non entry = MockConfigEntry( domain=DOMAIN, + options={ + CONF_MANUAL: False, + CONF_SCAN_INTERVAL: 60, + }, ) entry.add_to_hass(hass) @@ -82,7 +88,10 @@ async def test_server_not_found(hass: HomeAssistant, mock_api: MagicMock) -> Non assert hass.data[DOMAIN] mock_api.return_value.get_servers.side_effect = speedtest.NoMatchedServers - await hass.data[DOMAIN].async_refresh() + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(minutes=entry.options[CONF_SCAN_INTERVAL] + 1), + ) await hass.async_block_till_done() state = hass.states.get("sensor.speedtest_ping") assert state is not None diff --git a/tests/components/speedtestdotnet/test_sensor.py b/tests/components/speedtestdotnet/test_sensor.py index d0378731c28..06802a6cae7 100644 --- a/tests/components/speedtestdotnet/test_sensor.py +++ b/tests/components/speedtestdotnet/test_sensor.py @@ -3,12 +3,16 @@ from unittest.mock import MagicMock from homeassistant.components import speedtestdotnet from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.components.speedtestdotnet.const import DEFAULT_NAME, SENSOR_TYPES -from homeassistant.core import HomeAssistant +from homeassistant.components.speedtestdotnet.const import ( + CONF_MANUAL, + DEFAULT_NAME, + SENSOR_TYPES, +) +from homeassistant.core import HomeAssistant, State from . import MOCK_RESULTS, MOCK_SERVERS, MOCK_STATES -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, mock_restore_cache async def test_speedtestdotnet_sensors( @@ -30,3 +34,28 @@ async def test_speedtestdotnet_sensors( sensor = hass.states.get(f"sensor.{DEFAULT_NAME}_{description.name}") assert sensor assert sensor.state == MOCK_STATES[description.key] + + +async def test_restore_last_state(hass: HomeAssistant, mock_api: MagicMock) -> None: + """Test restoring last state for sensors.""" + mock_restore_cache( + hass, + [ + State(f"sensor.speedtest_{sensor}", state) + for sensor, state in MOCK_STATES.items() + ], + ) + entry = MockConfigEntry( + domain=speedtestdotnet.DOMAIN, data={}, options={CONF_MANUAL: True} + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 3 + + for description in SENSOR_TYPES: + sensor = hass.states.get(f"sensor.speedtest_{description.name}") + assert sensor + assert sensor.state == MOCK_STATES[description.key] From 54abd8046205382174ac04d4f53f30ca372e3f15 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 30 Sep 2021 09:15:09 +0200 Subject: [PATCH 718/843] Use EntityDescription - smappee (#56747) --- homeassistant/components/smappee/sensor.py | 652 +++++++++++---------- 1 file changed, 334 insertions(+), 318 deletions(-) diff --git a/homeassistant/components/smappee/sensor.py b/homeassistant/components/smappee/sensor.py index ec93501a508..af66b788a41 100644 --- a/homeassistant/components/smappee/sensor.py +++ b/homeassistant/components/smappee/sensor.py @@ -1,8 +1,13 @@ """Support for monitoring a Smappee energy sensor.""" +from __future__ import annotations + +from dataclasses import dataclass, field + from homeassistant.components.sensor import ( STATE_CLASS_MEASUREMENT, STATE_CLASS_TOTAL_INCREASING, SensorEntity, + SensorEntityDescription, ) from homeassistant.const import ( DEVICE_CLASS_ENERGY, @@ -16,141 +21,177 @@ from homeassistant.const import ( from .const import DOMAIN -TREND_SENSORS = { - "total_power": [ - "Total consumption - Active power", - None, - POWER_WATT, - "total_power", - DEVICE_CLASS_POWER, - True, # both cloud and local - ], - "alwayson": [ - "Always on - Active power", - None, - POWER_WATT, - "alwayson", - DEVICE_CLASS_POWER, - False, # cloud only - ], - "power_today": [ - "Total consumption - Today", - None, - ENERGY_WATT_HOUR, - "power_today", - DEVICE_CLASS_ENERGY, - False, # cloud only - ], - "power_current_hour": [ - "Total consumption - Current hour", - None, - ENERGY_WATT_HOUR, - "power_current_hour", - DEVICE_CLASS_ENERGY, - False, # cloud only - ], - "power_last_5_minutes": [ - "Total consumption - Last 5 minutes", - None, - ENERGY_WATT_HOUR, - "power_last_5_minutes", - DEVICE_CLASS_ENERGY, - False, # cloud only - ], - "alwayson_today": [ - "Always on - Today", - None, - ENERGY_WATT_HOUR, - "alwayson_today", - DEVICE_CLASS_ENERGY, - False, # cloud only - ], -} -REACTIVE_SENSORS = { - "total_reactive_power": [ - "Total consumption - Reactive power", - None, - POWER_WATT, - "total_reactive_power", - DEVICE_CLASS_POWER, - ] -} -SOLAR_SENSORS = { - "solar_power": [ - "Total production - Active power", - None, - POWER_WATT, - "solar_power", - DEVICE_CLASS_POWER, - True, # both cloud and local - ], - "solar_today": [ - "Total production - Today", - None, - ENERGY_WATT_HOUR, - "solar_today", - DEVICE_CLASS_ENERGY, - False, # cloud only - ], - "solar_current_hour": [ - "Total production - Current hour", - None, - ENERGY_WATT_HOUR, - "solar_current_hour", - DEVICE_CLASS_ENERGY, - False, # cloud only - ], -} -VOLTAGE_SENSORS = { - "phase_voltages_a": [ - "Phase voltages - A", - None, - ELECTRIC_POTENTIAL_VOLT, - "phase_voltage_a", - DEVICE_CLASS_VOLTAGE, - ["ONE", "TWO", "THREE_STAR", "THREE_DELTA"], - ], - "phase_voltages_b": [ - "Phase voltages - B", - None, - ELECTRIC_POTENTIAL_VOLT, - "phase_voltage_b", - DEVICE_CLASS_VOLTAGE, - ["TWO", "THREE_STAR", "THREE_DELTA"], - ], - "phase_voltages_c": [ - "Phase voltages - C", - None, - ELECTRIC_POTENTIAL_VOLT, - "phase_voltage_c", - DEVICE_CLASS_VOLTAGE, - ["THREE_STAR"], - ], - "line_voltages_a": [ - "Line voltages - A", - None, - ELECTRIC_POTENTIAL_VOLT, - "line_voltage_a", - DEVICE_CLASS_VOLTAGE, - ["ONE", "TWO", "THREE_STAR", "THREE_DELTA"], - ], - "line_voltages_b": [ - "Line voltages - B", - None, - ELECTRIC_POTENTIAL_VOLT, - "line_voltage_b", - DEVICE_CLASS_VOLTAGE, - ["TWO", "THREE_STAR", "THREE_DELTA"], - ], - "line_voltages_c": [ - "Line voltages - C", - None, - ELECTRIC_POTENTIAL_VOLT, - "line_voltage_c", - DEVICE_CLASS_VOLTAGE, - ["THREE_STAR", "THREE_DELTA"], - ], -} + +@dataclass +class SmappeeRequiredKeysMixin: + """Mixin for required keys.""" + + sensor_id: str + + +@dataclass +class SmappeeSensorEntityDescription(SensorEntityDescription, SmappeeRequiredKeysMixin): + """Describes Smappee sensor entity.""" + + +@dataclass +class SmappeePollingSensorEntityDescription(SmappeeSensorEntityDescription): + """Describes Smappee sensor entity.""" + + local_polling: bool = False + + +@dataclass +class SmappeeVoltageSensorEntityDescription(SmappeeSensorEntityDescription): + """Describes Smappee sensor entity.""" + + phase_types: set[str] = field(default_factory=set) + + +TREND_SENSORS: tuple[SmappeePollingSensorEntityDescription, ...] = ( + SmappeePollingSensorEntityDescription( + key="total_power", + name="Total consumption - Active power", + native_unit_of_measurement=POWER_WATT, + sensor_id="total_power", + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + local_polling=True, # both cloud and local + ), + SmappeePollingSensorEntityDescription( + key="alwayson", + name="Always on - Active power", + native_unit_of_measurement=POWER_WATT, + sensor_id="alwayson", + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + SmappeePollingSensorEntityDescription( + key="power_today", + name="Total consumption - Today", + native_unit_of_measurement=ENERGY_WATT_HOUR, + sensor_id="power_today", + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + SmappeePollingSensorEntityDescription( + key="power_current_hour", + name="Total consumption - Current hour", + native_unit_of_measurement=ENERGY_WATT_HOUR, + sensor_id="power_current_hour", + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + SmappeePollingSensorEntityDescription( + key="power_last_5_minutes", + name="Total consumption - Last 5 minutes", + native_unit_of_measurement=ENERGY_WATT_HOUR, + sensor_id="power_last_5_minutes", + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + SmappeePollingSensorEntityDescription( + key="alwayson_today", + name="Always on - Today", + native_unit_of_measurement=ENERGY_WATT_HOUR, + sensor_id="alwayson_today", + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), +) +REACTIVE_SENSORS: tuple[SmappeeSensorEntityDescription, ...] = ( + SmappeeSensorEntityDescription( + key="total_reactive_power", + name="Total consumption - Reactive power", + native_unit_of_measurement=POWER_WATT, + sensor_id="total_reactive_power", + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), +) +SOLAR_SENSORS: tuple[SmappeePollingSensorEntityDescription, ...] = ( + SmappeePollingSensorEntityDescription( + key="solar_power", + name="Total production - Active power", + native_unit_of_measurement=POWER_WATT, + sensor_id="solar_power", + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + local_polling=True, # both cloud and local + ), + SmappeePollingSensorEntityDescription( + key="solar_today", + name="Total production - Today", + native_unit_of_measurement=ENERGY_WATT_HOUR, + sensor_id="solar_today", + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + SmappeePollingSensorEntityDescription( + key="solar_current_hour", + name="Total production - Current hour", + native_unit_of_measurement=ENERGY_WATT_HOUR, + sensor_id="solar_current_hour", + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), +) +VOLTAGE_SENSORS: tuple[SmappeeVoltageSensorEntityDescription, ...] = ( + SmappeeVoltageSensorEntityDescription( + key="phase_voltages_a", + name="Phase voltages - A", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + sensor_id="phase_voltage_a", + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + phase_types={"ONE", "TWO", "THREE_STAR", "THREE_DELTA"}, + ), + SmappeeVoltageSensorEntityDescription( + key="phase_voltages_b", + name="Phase voltages - B", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + sensor_id="phase_voltage_b", + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + phase_types={"TWO", "THREE_STAR", "THREE_DELTA"}, + ), + SmappeeVoltageSensorEntityDescription( + key="phase_voltages_c", + name="Phase voltages - C", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + sensor_id="phase_voltage_c", + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + phase_types={"THREE_STAR"}, + ), + SmappeeVoltageSensorEntityDescription( + key="line_voltages_a", + name="Line voltages - A", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + sensor_id="line_voltage_a", + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + phase_types={"ONE", "TWO", "THREE_STAR", "THREE_DELTA"}, + ), + SmappeeVoltageSensorEntityDescription( + key="line_voltages_b", + name="Line voltages - B", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + sensor_id="line_voltage_b", + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + phase_types={"TWO", "THREE_STAR", "THREE_DELTA"}, + ), + SmappeeVoltageSensorEntityDescription( + key="line_voltages_c", + name="Line voltages - C", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + sensor_id="line_voltage_c", + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + phase_types={"THREE_STAR", "THREE_DELTA"}, + ), +) async def async_setup_entry(hass, config_entry, async_add_entities): @@ -161,116 +202,125 @@ async def async_setup_entry(hass, config_entry, async_add_entities): for service_location in smappee_base.smappee.service_locations.values(): # Add all basic sensors (realtime values and aggregators) # Some are available in local only env - for sensor, attributes in TREND_SENSORS.items(): - if not service_location.local_polling or attributes[5]: - entities.append( - SmappeeSensor( - smappee_base=smappee_base, - service_location=service_location, - sensor=sensor, - attributes=attributes, - ) - ) - - if service_location.has_reactive_value: - for reactive_sensor, attributes in REACTIVE_SENSORS.items(): - entities.append( - SmappeeSensor( - smappee_base=smappee_base, - service_location=service_location, - sensor=reactive_sensor, - attributes=attributes, - ) - ) - - # Add solar sensors (some are available in local only env) - if service_location.has_solar_production: - for sensor, attributes in SOLAR_SENSORS.items(): - if not service_location.local_polling or attributes[5]: - entities.append( - SmappeeSensor( - smappee_base=smappee_base, - service_location=service_location, - sensor=sensor, - attributes=attributes, - ) - ) - - # Add all CT measurements - for measurement_id, measurement in service_location.measurements.items(): - entities.append( + entities.extend( + [ SmappeeSensor( smappee_base=smappee_base, service_location=service_location, - sensor="load", - attributes=[ - measurement.name, - None, - POWER_WATT, - measurement_id, - DEVICE_CLASS_POWER, - ], + description=description, ) + for description in TREND_SENSORS + if not service_location.local_polling or description.local_polling + ] + ) + + if service_location.has_reactive_value: + entities.extend( + [ + SmappeeSensor( + smappee_base=smappee_base, + service_location=service_location, + description=description, + ) + for description in REACTIVE_SENSORS + ] ) + # Add solar sensors (some are available in local only env) + if service_location.has_solar_production: + entities.extend( + [ + SmappeeSensor( + smappee_base=smappee_base, + service_location=service_location, + description=description, + ) + for description in SOLAR_SENSORS + if not service_location.local_polling or description.local_polling + ] + ) + + # Add all CT measurements + entities.extend( + [ + SmappeeSensor( + smappee_base=smappee_base, + service_location=service_location, + description=SmappeeSensorEntityDescription( + key="load", + name=measurement.name, + sensor_id=measurement_id, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + ) + for measurement_id, measurement in service_location.measurements.items() + ] + ) + # Add phase- and line voltages if available if service_location.has_voltage_values: - for sensor_name, sensor in VOLTAGE_SENSORS.items(): - if service_location.phase_type in sensor[5]: + entities.extend( + [ + SmappeeSensor( + smappee_base=smappee_base, + service_location=service_location, + description=description, + ) + for description in VOLTAGE_SENSORS if ( - sensor_name.startswith("line_") - and service_location.local_polling - ): - continue - entities.append( - SmappeeSensor( - smappee_base=smappee_base, - service_location=service_location, - sensor=sensor_name, - attributes=sensor, + service_location.phase_type in description.phase_types + and not ( + description.key.startswith("line_") + and service_location.local_polling ) ) + ] + ) # Add Gas and Water sensors - for sensor_id, sensor in service_location.sensors.items(): - for channel in sensor.channels: - gw_icon = "mdi:gas-cylinder" - if channel.get("type") == "water": - gw_icon = "mdi:water" - - entities.append( - SmappeeSensor( - smappee_base=smappee_base, - service_location=service_location, - sensor="sensor", - attributes=[ - channel.get("name"), - gw_icon, - channel.get("uom"), - f"{sensor_id}-{channel.get('channel')}", - None, - ], - ) + entities.extend( + [ + SmappeeSensor( + smappee_base=smappee_base, + service_location=service_location, + description=SmappeeSensorEntityDescription( + key="sensor", + name=channel.get("name"), + icon=( + "mdi:water" + if channel.get("type") == "water" + else "mdi:gas-cylinder" + ), + native_unit_of_measurement=channel.get("uom"), + sensor_id=f"{sensor_id}-{channel.get('channel')}", + state_class=STATE_CLASS_MEASUREMENT, + ), ) + for sensor_id, sensor in service_location.sensors.items() + for channel in sensor.channels + ] + ) # Add today_energy_kwh sensors for switches - for actuator_id, actuator in service_location.actuators.items(): - if actuator.type == "SWITCH": - entities.append( - SmappeeSensor( - smappee_base=smappee_base, - service_location=service_location, - sensor="switch", - attributes=[ - f"{actuator.name} - energy today", - None, - ENERGY_KILO_WATT_HOUR, - actuator_id, - DEVICE_CLASS_ENERGY, - False, # cloud only - ], - ) + entities.extend( + [ + SmappeeSensor( + smappee_base=smappee_base, + service_location=service_location, + description=SmappeeSensorEntityDescription( + key="switch", + name=f"{actuator.name} - energy today", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + sensor_id=actuator_id, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), ) + for actuator_id, actuator in service_location.actuators.items() + if actuator.type == "SWITCH" + ] + ) async_add_entities(entities, True) @@ -278,84 +328,47 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class SmappeeSensor(SensorEntity): """Implementation of a Smappee sensor.""" - def __init__(self, smappee_base, service_location, sensor, attributes): + entity_description: SmappeeSensorEntityDescription + + def __init__( + self, + smappee_base, + service_location, + description: SmappeeSensorEntityDescription, + ): """Initialize the Smappee sensor.""" + self.entity_description = description self._smappee_base = smappee_base self._service_location = service_location - self._sensor = sensor - self.data = None - self._state = None - self._name = attributes[0] - self._icon = attributes[1] - self._unit_of_measurement = attributes[2] - self._sensor_id = attributes[3] - self._device_class = attributes[4] @property def name(self): """Return the name for this sensor.""" - if self._sensor in ("sensor", "load", "switch"): + sensor_key = self.entity_description.key + sensor_name = self.entity_description.name + if sensor_key in ("sensor", "load", "switch"): return ( f"{self._service_location.service_location_name} - " - f"{self._sensor.title()} - {self._name}" + f"{sensor_key.title()} - {sensor_name}" ) - return f"{self._service_location.service_location_name} - {self._name}" + return f"{self._service_location.service_location_name} - {sensor_name}" @property - def icon(self): - """Icon to use in the frontend.""" - return self._icon - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return self._device_class - - @property - def state_class(self): - """Return the state class of this device.""" - scm = STATE_CLASS_MEASUREMENT - - if self._sensor in ( - "power_today", - "power_current_hour", - "power_last_5_minutes", - "solar_today", - "solar_current_hour", - "alwayson_today", - "switch", - ): - scm = STATE_CLASS_TOTAL_INCREASING - - return scm - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement - - @property - def unique_id( - self, - ): + def unique_id(self): """Return the unique ID for this sensor.""" - if self._sensor in ("load", "sensor", "switch"): + sensor_key = self.entity_description.key + if sensor_key in ("load", "sensor", "switch"): return ( f"{self._service_location.device_serial_number}-" f"{self._service_location.service_location_id}-" - f"{self._sensor}-{self._sensor_id}" + f"{sensor_key}-{self.entity_description.sensor_id}" ) return ( f"{self._service_location.device_serial_number}-" f"{self._service_location.service_location_id}-" - f"{self._sensor}" + f"{sensor_key}" ) @property @@ -373,37 +386,38 @@ class SmappeeSensor(SensorEntity): """Get the latest data from Smappee and update the state.""" await self._smappee_base.async_update() - if self._sensor == "total_power": - self._state = self._service_location.total_power - elif self._sensor == "total_reactive_power": - self._state = self._service_location.total_reactive_power - elif self._sensor == "solar_power": - self._state = self._service_location.solar_power - elif self._sensor == "alwayson": - self._state = self._service_location.alwayson - elif self._sensor in ( + sensor_key = self.entity_description.key + if sensor_key == "total_power": + self._attr_native_value = self._service_location.total_power + elif sensor_key == "total_reactive_power": + self._attr_native_value = self._service_location.total_reactive_power + elif sensor_key == "solar_power": + self._attr_native_value = self._service_location.solar_power + elif sensor_key == "alwayson": + self._attr_native_value = self._service_location.alwayson + elif sensor_key in ( "phase_voltages_a", "phase_voltages_b", "phase_voltages_c", ): phase_voltages = self._service_location.phase_voltages if phase_voltages is not None: - if self._sensor == "phase_voltages_a": - self._state = phase_voltages[0] - elif self._sensor == "phase_voltages_b": - self._state = phase_voltages[1] - elif self._sensor == "phase_voltages_c": - self._state = phase_voltages[2] - elif self._sensor in ("line_voltages_a", "line_voltages_b", "line_voltages_c"): + if sensor_key == "phase_voltages_a": + self._attr_native_value = phase_voltages[0] + elif sensor_key == "phase_voltages_b": + self._attr_native_value = phase_voltages[1] + elif sensor_key == "phase_voltages_c": + self._attr_native_value = phase_voltages[2] + elif sensor_key in ("line_voltages_a", "line_voltages_b", "line_voltages_c"): line_voltages = self._service_location.line_voltages if line_voltages is not None: - if self._sensor == "line_voltages_a": - self._state = line_voltages[0] - elif self._sensor == "line_voltages_b": - self._state = line_voltages[1] - elif self._sensor == "line_voltages_c": - self._state = line_voltages[2] - elif self._sensor in ( + if sensor_key == "line_voltages_a": + self._attr_native_value = line_voltages[0] + elif sensor_key == "line_voltages_b": + self._attr_native_value = line_voltages[1] + elif sensor_key == "line_voltages_c": + self._attr_native_value = line_voltages[2] + elif sensor_key in ( "power_today", "power_current_hour", "power_last_5_minutes", @@ -411,21 +425,23 @@ class SmappeeSensor(SensorEntity): "solar_current_hour", "alwayson_today", ): - trend_value = self._service_location.aggregated_values.get(self._sensor) - self._state = round(trend_value) if trend_value is not None else None - elif self._sensor == "load": - self._state = self._service_location.measurements.get( - self._sensor_id + trend_value = self._service_location.aggregated_values.get(sensor_key) + self._attr_native_value = ( + round(trend_value) if trend_value is not None else None + ) + elif sensor_key == "load": + self._attr_native_value = self._service_location.measurements.get( + self.entity_description.sensor_id ).active_total - elif self._sensor == "sensor": - sensor_id, channel_id = self._sensor_id.split("-") + elif sensor_key == "sensor": + sensor_id, channel_id = self.entity_description.sensor_id.split("-") sensor = self._service_location.sensors.get(int(sensor_id)) for channel in sensor.channels: if channel.get("channel") == int(channel_id): - self._state = channel.get("value_today") - elif self._sensor == "switch": + self._attr_native_value = channel.get("value_today") + elif sensor_key == "switch": cons = self._service_location.actuators.get( - self._sensor_id + self.entity_description.sensor_id ).consumption_today if cons is not None: - self._state = round(cons / 1000.0, 2) + self._attr_native_value = round(cons / 1000.0, 2) From 2e029458332c34b440a5c0118baa98f91f74f051 Mon Sep 17 00:00:00 2001 From: Ian Foster Date: Thu, 30 Sep 2021 00:17:57 -0700 Subject: [PATCH 719/843] Add keyboard event type to keyboard_remote (#56668) * added keyboard event type to keyboard_remote * fix emulated hold event * Update homeassistant/components/keyboard_remote/__init__.py Co-authored-by: Erik Montnemery * removed event value * set key_hold to use string constant * don't use dict.get() for keyboard event type Co-authored-by: Erik Montnemery --- CODEOWNERS | 2 +- homeassistant/components/keyboard_remote/__init__.py | 10 +++++++++- homeassistant/components/keyboard_remote/manifest.json | 2 +- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 01bb9abf77f..84b359182dd 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -268,7 +268,7 @@ homeassistant/components/kaiterra/* @Michsior14 homeassistant/components/keba/* @dannerph homeassistant/components/keenetic_ndms2/* @foxel homeassistant/components/kef/* @basnijholt -homeassistant/components/keyboard_remote/* @bendavid +homeassistant/components/keyboard_remote/* @bendavid @lanrat homeassistant/components/kmtronic/* @dgomes homeassistant/components/knx/* @Julius2342 @farmio @marvin-w homeassistant/components/kodi/* @OnFreund @cgtobi diff --git a/homeassistant/components/keyboard_remote/__init__.py b/homeassistant/components/keyboard_remote/__init__.py index 1d16dd12cc2..1c62dcb7575 100644 --- a/homeassistant/components/keyboard_remote/__init__.py +++ b/homeassistant/components/keyboard_remote/__init__.py @@ -18,11 +18,13 @@ DEVICE_DESCRIPTOR = "device_descriptor" DEVICE_ID_GROUP = "Device description" DEVICE_NAME = "device_name" DOMAIN = "keyboard_remote" +VALUE = "value" ICON = "mdi:remote" KEY_CODE = "key_code" KEY_VALUE = {"key_up": 0, "key_down": 1, "key_hold": 2} +KEY_VALUE_NAME = {value: key for key, value in KEY_VALUE.items()} KEYBOARD_REMOTE_COMMAND_RECEIVED = "keyboard_remote_command_received" KEYBOARD_REMOTE_CONNECTED = "keyboard_remote_connected" KEYBOARD_REMOTE_DISCONNECTED = "keyboard_remote_disconnected" @@ -236,7 +238,12 @@ class KeyboardRemote: while True: self.hass.bus.async_fire( KEYBOARD_REMOTE_COMMAND_RECEIVED, - {KEY_CODE: code, DEVICE_DESCRIPTOR: path, DEVICE_NAME: name}, + { + KEY_CODE: code, + TYPE: "key_hold", + DEVICE_DESCRIPTOR: path, + DEVICE_NAME: name, + }, ) await asyncio.sleep(repeat) @@ -294,6 +301,7 @@ class KeyboardRemote: KEYBOARD_REMOTE_COMMAND_RECEIVED, { KEY_CODE: event.code, + TYPE: KEY_VALUE_NAME[event.value], DEVICE_DESCRIPTOR: dev.path, DEVICE_NAME: dev.name, }, diff --git a/homeassistant/components/keyboard_remote/manifest.json b/homeassistant/components/keyboard_remote/manifest.json index b63873bd165..1fc34f47000 100644 --- a/homeassistant/components/keyboard_remote/manifest.json +++ b/homeassistant/components/keyboard_remote/manifest.json @@ -3,6 +3,6 @@ "name": "Keyboard Remote", "documentation": "https://www.home-assistant.io/integrations/keyboard_remote", "requirements": ["evdev==1.4.0", "aionotify==0.2.0"], - "codeowners": ["@bendavid"], + "codeowners": ["@bendavid", "@lanrat"], "iot_class": "local_push" } From a6a3745413f55a2e9fd6e73a8bcc0ea204fe4884 Mon Sep 17 00:00:00 2001 From: ehendrix23 Date: Thu, 30 Sep 2021 02:41:28 -0600 Subject: [PATCH 720/843] Handle UpnpError exceptions when getting WAN status and external IP address (#56744) --- homeassistant/components/upnp/device.py | 28 +++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/upnp/device.py b/homeassistant/components/upnp/device.py index 7205da71c84..1a6f50004cd 100644 --- a/homeassistant/components/upnp/device.py +++ b/homeassistant/components/upnp/device.py @@ -8,6 +8,7 @@ from urllib.parse import urlparse from async_upnp_client import UpnpDevice, UpnpFactory from async_upnp_client.aiohttp import AiohttpSessionRequester +from async_upnp_client.exceptions import UpnpError from async_upnp_client.profiles.igd import IgdDevice from homeassistant.components import ssdp @@ -46,7 +47,7 @@ class Device: """Create UPnP device.""" # Build async_upnp_client requester. session = async_get_clientsession(hass) - requester = AiohttpSessionRequester(session, True, 10) + requester = AiohttpSessionRequester(session, True, 20) # Create async_upnp_client device. factory = UpnpFactory(requester, disable_state_variable_validation=True) @@ -168,10 +169,29 @@ class Device: values = await asyncio.gather( self._igd_device.async_get_status_info(), self._igd_device.async_get_external_ip_address(), + return_exceptions=True, ) + result = [] + for idx, value in enumerate(values): + if isinstance(value, UpnpError): + # Not all routers support some of these items although based + # on defined standard they should. + _LOGGER.debug( + "Exception occurred while trying to get status %s for device %s: %s", + "status" if idx == 1 else "external IP address", + self, + str(value), + ) + result.append(None) + continue + + if isinstance(value, Exception): + raise value + + result.append(value) return { - WAN_STATUS: values[0][0] if values[0] is not None else None, - ROUTER_UPTIME: values[0][2] if values[0] is not None else None, - ROUTER_IP: values[1], + WAN_STATUS: result[0][0] if result[0] is not None else None, + ROUTER_UPTIME: result[0][2] if result[0] is not None else None, + ROUTER_IP: result[1], } From 8993ff037749a2343acc7ee6cc53712f0489566d Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 30 Sep 2021 11:18:04 +0200 Subject: [PATCH 721/843] Fritz new binary sensor for link and firmware status + code cleanup (#55446) --- .../components/fritz/binary_sensor.py | 111 ++++++------ homeassistant/components/fritz/common.py | 44 ++++- homeassistant/components/fritz/const.py | 4 +- homeassistant/components/fritz/sensor.py | 171 ++++++++++-------- tests/components/fritz/test_config_flow.py | 17 +- 5 files changed, 198 insertions(+), 149 deletions(-) diff --git a/homeassistant/components/fritz/binary_sensor.py b/homeassistant/components/fritz/binary_sensor.py index 7655df6e298..edde8c0c22a 100644 --- a/homeassistant/components/fritz/binary_sensor.py +++ b/homeassistant/components/fritz/binary_sensor.py @@ -1,11 +1,14 @@ """AVM FRITZ!Box connectivity sensor.""" -import logging +from __future__ import annotations -from fritzconnection.core.exceptions import FritzConnectionException +import logging from homeassistant.components.binary_sensor import ( DEVICE_CLASS_CONNECTIVITY, + DEVICE_CLASS_PLUG, + DEVICE_CLASS_UPDATE, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -17,6 +20,25 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( + BinarySensorEntityDescription( + key="is_connected", + name="Connection", + device_class=DEVICE_CLASS_CONNECTIVITY, + ), + BinarySensorEntityDescription( + key="is_linked", + name="Link", + device_class=DEVICE_CLASS_PLUG, + ), + BinarySensorEntityDescription( + key="firmware_update", + name="Firmware Update", + device_class=DEVICE_CLASS_UPDATE, + ), +) + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: @@ -24,72 +46,47 @@ async def async_setup_entry( _LOGGER.debug("Setting up FRITZ!Box binary sensors") fritzbox_tools: FritzBoxTools = hass.data[DOMAIN][entry.entry_id] - if fritzbox_tools.connection and "WANIPConn1" in fritzbox_tools.connection.services: + if ( + not fritzbox_tools.connection + or "WANIPConn1" not in fritzbox_tools.connection.services + ): # Only routers are supported at the moment - async_add_entities( - [FritzBoxConnectivitySensor(fritzbox_tools, entry.title)], True - ) + return + + entities = [ + FritzBoxBinarySensor(fritzbox_tools, entry.title, description) + for description in SENSOR_TYPES + ] + + async_add_entities(entities, True) -class FritzBoxConnectivitySensor(FritzBoxBaseEntity, BinarySensorEntity): +class FritzBoxBinarySensor(FritzBoxBaseEntity, BinarySensorEntity): """Define FRITZ!Box connectivity class.""" def __init__( - self, fritzbox_tools: FritzBoxTools, device_friendly_name: str + self, + fritzbox_tools: FritzBoxTools, + device_friendly_name: str, + description: BinarySensorEntityDescription, ) -> None: """Init FRITZ!Box connectivity class.""" - self._unique_id = f"{fritzbox_tools.unique_id}-connectivity" - self._name = f"{device_friendly_name} Connectivity" - self._is_on = True - self._is_available = True + self.entity_description = description + self._attr_name = f"{device_friendly_name} {description.name}" + self._attr_unique_id = f"{fritzbox_tools.unique_id}-{description.key}" super().__init__(fritzbox_tools, device_friendly_name) - @property - def name(self) -> str: - """Return name.""" - return self._name - - @property - def device_class(self) -> str: - """Return device class.""" - return DEVICE_CLASS_CONNECTIVITY - - @property - def is_on(self) -> bool: - """Return status.""" - return self._is_on - - @property - def unique_id(self) -> str: - """Return unique id.""" - return self._unique_id - - @property - def available(self) -> bool: - """Return availability.""" - return self._is_available - def update(self) -> None: """Update data.""" _LOGGER.debug("Updating FRITZ!Box binary sensors") - self._is_on = True - try: - if ( - self._fritzbox_tools.connection - and "WANCommonInterfaceConfig1" - in self._fritzbox_tools.connection.services - ): - link_props = self._fritzbox_tools.connection.call_action( - "WANCommonInterfaceConfig1", "GetCommonLinkProperties" - ) - is_up = link_props["NewPhysicalLinkStatus"] - self._is_on = is_up == "Up" - else: - if self._fritzbox_tools.fritz_status: - self._is_on = self._fritzbox_tools.fritz_status.is_connected - self._is_available = True - - except FritzConnectionException: - _LOGGER.error("Error getting the state from the FRITZ!Box", exc_info=True) - self._is_available = False + if self.entity_description.key == "is_connected": + self._attr_is_on = bool(self._fritzbox_tools.fritz_status.is_connected) + elif self.entity_description.key == "is_linked": + self._attr_is_on = bool(self._fritzbox_tools.fritz_status.is_linked) + elif self.entity_description.key == "firmware_update": + self._attr_is_on = self._fritzbox_tools.update_available + self._attr_extra_state_attributes = { + "installed_version": self._fritzbox_tools.current_firmware, + "latest_available_version:": self._fritzbox_tools.latest_firmware, + } diff --git a/homeassistant/components/fritz/common.py b/homeassistant/components/fritz/common.py index 6b0f0873c85..acb733709a3 100644 --- a/homeassistant/components/fritz/common.py +++ b/homeassistant/components/fritz/common.py @@ -125,7 +125,9 @@ class FritzBoxTools: self.username = username self._mac: str | None = None self._model: str | None = None - self._sw_version: str | None = None + self._current_firmware: str | None = None + self._latest_firmware: str | None = None + self._update_available: bool = False async def async_setup(self) -> None: """Wrap up FritzboxTools class setup.""" @@ -152,7 +154,9 @@ class FritzBoxTools: self._unique_id = info["NewSerialNumber"] self._model = info.get("NewModelName") - self._sw_version = info.get("NewSoftwareVersion") + self._current_firmware = info.get("NewSoftwareVersion") + + self._update_available, self._latest_firmware = self._update_device_info() async def async_start(self, options: MappingProxyType[str, Any]) -> None: """Start FritzHosts connection.""" @@ -187,11 +191,21 @@ class FritzBoxTools: return self._model @property - def sw_version(self) -> str: - """Return SW version.""" - if not self._sw_version: + def current_firmware(self) -> str: + """Return current SW version.""" + if not self._current_firmware: raise ClassSetupMissing() - return self._sw_version + return self._current_firmware + + @property + def latest_firmware(self) -> str | None: + """Return latest SW version.""" + return self._latest_firmware + + @property + def update_available(self) -> bool: + """Return if new SW version is available.""" + return self._update_available @property def mac(self) -> str: @@ -215,10 +229,17 @@ class FritzBoxTools: """Event specific per FRITZ!Box entry to signal updates in devices.""" return f"{DOMAIN}-device-update-{self._unique_id}" - def _update_info(self) -> list[HostInfo]: - """Retrieve latest information from the FRITZ!Box.""" + def _update_hosts_info(self) -> list[HostInfo]: + """Retrieve latest hosts information from the FRITZ!Box.""" return self.fritz_hosts.get_hosts_info() # type: ignore [no-any-return] + def _update_device_info(self) -> tuple[bool, str | None]: + """Retrieve latest device information from the FRITZ!Box.""" + userinterface = self.connection.call_action("UserInterface1", "GetInfo") + return userinterface.get("NewUpgradeAvailable"), userinterface.get( + "NewX_AVM-DE_Version" + ) + def scan_devices(self, now: datetime | None = None) -> None: """Scan for new devices and return a list of found device ids.""" _LOGGER.debug("Checking devices for FRITZ!Box router %s", self.host) @@ -232,7 +253,7 @@ class FritzBoxTools: consider_home = _default_consider_home new_device = False - for known_host in self._update_info(): + for known_host in self._update_hosts_info(): if not known_host.get("mac"): continue @@ -255,6 +276,9 @@ class FritzBoxTools: if new_device: dispatcher_send(self.hass, self.signal_device_new) + _LOGGER.debug("Checking host info for FRITZ!Box router %s", self.host) + self._update_available, self._latest_firmware = self._update_device_info() + async def service_fritzbox(self, service: str) -> None: """Define FRITZ!Box services.""" _LOGGER.debug("FRITZ!Box router: %s", service) @@ -460,5 +484,5 @@ class FritzBoxBaseEntity: "name": self._device_name, "manufacturer": "AVM", "model": self._fritzbox_tools.model, - "sw_version": self._fritzbox_tools.sw_version, + "sw_version": self._fritzbox_tools.current_firmware, } diff --git a/homeassistant/components/fritz/const.py b/homeassistant/components/fritz/const.py index 4ae8314113f..3ed4e705730 100644 --- a/homeassistant/components/fritz/const.py +++ b/homeassistant/components/fritz/const.py @@ -1,12 +1,14 @@ """Constants for the FRITZ!Box Tools integration.""" +from typing import Literal + DOMAIN = "fritz" PLATFORMS = ["binary_sensor", "device_tracker", "sensor", "switch"] DATA_FRITZ = "fritz_data" -DSL_CONNECTION = "dsl" +DSL_CONNECTION: Literal["dsl"] = "dsl" DEFAULT_DEVICE_NAME = "Unknown device" DEFAULT_HOST = "192.168.178.1" diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py index 15aed604ffc..fd82d245b9a 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -1,10 +1,10 @@ """AVM FRITZ!Box binary sensors.""" from __future__ import annotations -from collections.abc import Callable +from dataclasses import dataclass import datetime import logging -from typing import TypedDict +from typing import Any, Callable, Literal from fritzconnection.core.exceptions import ( FritzActionError, @@ -19,6 +19,7 @@ from homeassistant.components.sensor import ( STATE_CLASS_MEASUREMENT, STATE_CLASS_TOTAL_INCREASING, SensorEntity, + SensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -139,117 +140,134 @@ def _retrieve_link_attenuation_received_state( return status.attenuation[1] / 10 # type: ignore[no-any-return] -class SensorData(TypedDict, total=False): - """Sensor data class.""" +@dataclass +class FritzRequireKeysMixin: + """Fritz sensor data class.""" - name: str - device_class: str | None - state_class: str | None - unit_of_measurement: str | None - icon: str | None - state_provider: Callable - connection_type: str | None + value_fn: Callable[[FritzStatus, Any], Any] -SENSOR_DATA = { - "external_ip": SensorData( +@dataclass +class FritzSensorEntityDescription(SensorEntityDescription, FritzRequireKeysMixin): + """Describes Fritz sensor entity.""" + + connection_type: Literal["dsl"] | None = None + + +SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( + FritzSensorEntityDescription( + key="external_ip", name="External IP", icon="mdi:earth", - state_provider=_retrieve_external_ip_state, + value_fn=_retrieve_external_ip_state, ), - "device_uptime": SensorData( + FritzSensorEntityDescription( + key="device_uptime", name="Device Uptime", device_class=DEVICE_CLASS_TIMESTAMP, - state_provider=_retrieve_device_uptime_state, + value_fn=_retrieve_device_uptime_state, ), - "connection_uptime": SensorData( + FritzSensorEntityDescription( + key="connection_uptime", name="Connection Uptime", device_class=DEVICE_CLASS_TIMESTAMP, - state_provider=_retrieve_connection_uptime_state, + value_fn=_retrieve_connection_uptime_state, ), - "kb_s_sent": SensorData( + FritzSensorEntityDescription( + key="kb_s_sent", name="Upload Throughput", state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=DATA_RATE_KILOBYTES_PER_SECOND, + native_unit_of_measurement=DATA_RATE_KILOBYTES_PER_SECOND, icon="mdi:upload", - state_provider=_retrieve_kb_s_sent_state, + value_fn=_retrieve_kb_s_sent_state, ), - "kb_s_received": SensorData( + FritzSensorEntityDescription( + key="kb_s_received", name="Download Throughput", state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=DATA_RATE_KILOBYTES_PER_SECOND, + native_unit_of_measurement=DATA_RATE_KILOBYTES_PER_SECOND, icon="mdi:download", - state_provider=_retrieve_kb_s_received_state, + value_fn=_retrieve_kb_s_received_state, ), - "max_kb_s_sent": SensorData( + FritzSensorEntityDescription( + key="max_kb_s_sent", name="Max Connection Upload Throughput", - unit_of_measurement=DATA_RATE_KILOBITS_PER_SECOND, + native_unit_of_measurement=DATA_RATE_KILOBITS_PER_SECOND, icon="mdi:upload", - state_provider=_retrieve_max_kb_s_sent_state, + value_fn=_retrieve_max_kb_s_sent_state, ), - "max_kb_s_received": SensorData( + FritzSensorEntityDescription( + key="max_kb_s_received", name="Max Connection Download Throughput", - unit_of_measurement=DATA_RATE_KILOBITS_PER_SECOND, + native_unit_of_measurement=DATA_RATE_KILOBITS_PER_SECOND, icon="mdi:download", - state_provider=_retrieve_max_kb_s_received_state, + value_fn=_retrieve_max_kb_s_received_state, ), - "gb_sent": SensorData( + FritzSensorEntityDescription( + key="gb_sent", name="GB sent", state_class=STATE_CLASS_TOTAL_INCREASING, - unit_of_measurement=DATA_GIGABYTES, + native_unit_of_measurement=DATA_GIGABYTES, icon="mdi:upload", - state_provider=_retrieve_gb_sent_state, + value_fn=_retrieve_gb_sent_state, ), - "gb_received": SensorData( + FritzSensorEntityDescription( + key="gb_received", name="GB received", state_class=STATE_CLASS_TOTAL_INCREASING, - unit_of_measurement=DATA_GIGABYTES, + native_unit_of_measurement=DATA_GIGABYTES, icon="mdi:download", - state_provider=_retrieve_gb_received_state, + value_fn=_retrieve_gb_received_state, ), - "link_kb_s_sent": SensorData( + FritzSensorEntityDescription( + key="link_kb_s_sent", name="Link Upload Throughput", - unit_of_measurement=DATA_RATE_KILOBITS_PER_SECOND, + native_unit_of_measurement=DATA_RATE_KILOBITS_PER_SECOND, icon="mdi:upload", - state_provider=_retrieve_link_kb_s_sent_state, + value_fn=_retrieve_link_kb_s_sent_state, connection_type=DSL_CONNECTION, ), - "link_kb_s_received": SensorData( + FritzSensorEntityDescription( + key="link_kb_s_received", name="Link Download Throughput", - unit_of_measurement=DATA_RATE_KILOBITS_PER_SECOND, + native_unit_of_measurement=DATA_RATE_KILOBITS_PER_SECOND, icon="mdi:download", - state_provider=_retrieve_link_kb_s_received_state, + value_fn=_retrieve_link_kb_s_received_state, connection_type=DSL_CONNECTION, ), - "link_noise_margin_sent": SensorData( + FritzSensorEntityDescription( + key="link_noise_margin_sent", name="Link Upload Noise Margin", - unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, icon="mdi:upload", - state_provider=_retrieve_link_noise_margin_sent_state, + value_fn=_retrieve_link_noise_margin_sent_state, connection_type=DSL_CONNECTION, ), - "link_noise_margin_received": SensorData( + FritzSensorEntityDescription( + key="link_noise_margin_received", name="Link Download Noise Margin", - unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, icon="mdi:download", - state_provider=_retrieve_link_noise_margin_received_state, + value_fn=_retrieve_link_noise_margin_received_state, connection_type=DSL_CONNECTION, ), - "link_attenuation_sent": SensorData( + FritzSensorEntityDescription( + key="link_attenuation_sent", name="Link Upload Power Attenuation", - unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, icon="mdi:upload", - state_provider=_retrieve_link_attenuation_sent_state, + value_fn=_retrieve_link_attenuation_sent_state, connection_type=DSL_CONNECTION, ), - "link_attenuation_received": SensorData( + FritzSensorEntityDescription( + key="link_attenuation_received", name="Link Download Power Attenuation", - unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, icon="mdi:download", - state_provider=_retrieve_link_attenuation_received_state, + value_fn=_retrieve_link_attenuation_received_state, connection_type=DSL_CONNECTION, ), -} +) async def async_setup_entry( @@ -266,7 +284,6 @@ async def async_setup_entry( # Only routers are supported at the moment return - entities = [] dsl: bool = False try: dslinterface = await hass.async_add_executor_job( @@ -283,40 +300,34 @@ async def async_setup_entry( ): pass - for sensor_type, sensor_data in SENSOR_DATA.items(): - if not dsl and sensor_data.get("connection_type") == DSL_CONNECTION: - continue - entities.append(FritzBoxSensor(fritzbox_tools, entry.title, sensor_type)) + entities = [ + FritzBoxSensor(fritzbox_tools, entry.title, description) + for description in SENSOR_TYPES + if dsl or description.connection_type != DSL_CONNECTION + ] - if entities: - async_add_entities(entities, True) + async_add_entities(entities, True) class FritzBoxSensor(FritzBoxBaseEntity, SensorEntity): """Define FRITZ!Box connectivity class.""" + entity_description: FritzSensorEntityDescription + def __init__( - self, fritzbox_tools: FritzBoxTools, device_friendly_name: str, sensor_type: str + self, + fritzbox_tools: FritzBoxTools, + device_friendly_name: str, + description: FritzSensorEntityDescription, ) -> None: """Init FRITZ!Box connectivity class.""" - self._sensor_data: SensorData = SENSOR_DATA[sensor_type] + self.entity_description = description self._last_device_value: str | None = None self._attr_available = True - self._attr_device_class = self._sensor_data.get("device_class") - self._attr_icon = self._sensor_data.get("icon") - self._attr_name = f"{device_friendly_name} {self._sensor_data['name']}" - self._attr_state_class = self._sensor_data.get("state_class") - self._attr_native_unit_of_measurement = self._sensor_data.get( - "unit_of_measurement" - ) - self._attr_unique_id = f"{fritzbox_tools.unique_id}-{sensor_type}" + self._attr_name = f"{device_friendly_name} {description.name}" + self._attr_unique_id = f"{fritzbox_tools.unique_id}-{description.key}" super().__init__(fritzbox_tools, device_friendly_name) - @property - def _state_provider(self) -> Callable: - """Return the state provider for the binary sensor.""" - return self._sensor_data["state_provider"] - def update(self) -> None: """Update data.""" _LOGGER.debug("Updating FRITZ!Box sensors") @@ -329,6 +340,6 @@ class FritzBoxSensor(FritzBoxBaseEntity, SensorEntity): self._attr_available = False return - self._attr_native_value = self._last_device_value = self._state_provider( - status, self._last_device_value - ) + self._attr_native_value = ( + self._last_device_value + ) = self.entity_description.value_fn(status, self._last_device_value) diff --git a/tests/components/fritz/test_config_flow.py b/tests/components/fritz/test_config_flow.py index 1b2a89f0450..0aecefedf0d 100644 --- a/tests/components/fritz/test_config_flow.py +++ b/tests/components/fritz/test_config_flow.py @@ -42,7 +42,7 @@ ATTR_NEW_SERIAL_NUMBER = "NewSerialNumber" MOCK_HOST = "fake_host" MOCK_SERIAL_NUMBER = "fake_serial_number" - +MOCK_FIRMWARE_INFO = [True, "1.1.1"] MOCK_USER_DATA = MOCK_CONFIG[DOMAIN][CONF_DEVICES][0] MOCK_DEVICE_INFO = { @@ -73,6 +73,9 @@ async def test_user(hass: HomeAssistant, fc_class_mock, mock_get_source_ip): "homeassistant.components.fritz.common.FritzConnection", side_effect=fc_class_mock, ), patch("homeassistant.components.fritz.common.FritzStatus"), patch( + "homeassistant.components.fritz.common.FritzBoxTools._update_device_info", + return_value=MOCK_FIRMWARE_INFO, + ), patch( "homeassistant.components.fritz.async_setup_entry" ) as mock_setup_entry, patch( "requests.get" @@ -120,6 +123,9 @@ async def test_user_already_configured( "homeassistant.components.fritz.common.FritzConnection", side_effect=fc_class_mock, ), patch("homeassistant.components.fritz.common.FritzStatus"), patch( + "homeassistant.components.fritz.common.FritzBoxTools._update_device_info", + return_value=MOCK_FIRMWARE_INFO, + ), patch( "requests.get" ) as mock_request_get, patch( "requests.post" @@ -225,6 +231,9 @@ async def test_reauth_successful( "homeassistant.components.fritz.common.FritzConnection", side_effect=fc_class_mock, ), patch("homeassistant.components.fritz.common.FritzStatus"), patch( + "homeassistant.components.fritz.common.FritzBoxTools._update_device_info", + return_value=MOCK_FIRMWARE_INFO, + ), patch( "homeassistant.components.fritz.async_setup_entry" ) as mock_setup_entry, patch( "requests.get" @@ -397,6 +406,9 @@ async def test_ssdp(hass: HomeAssistant, fc_class_mock, mock_get_source_ip): "homeassistant.components.fritz.common.FritzConnection", side_effect=fc_class_mock, ), patch("homeassistant.components.fritz.common.FritzStatus"), patch( + "homeassistant.components.fritz.common.FritzBoxTools._update_device_info", + return_value=MOCK_FIRMWARE_INFO, + ), patch( "homeassistant.components.fritz.async_setup_entry" ) as mock_setup_entry, patch( "requests.get" @@ -462,6 +474,9 @@ async def test_import(hass: HomeAssistant, fc_class_mock, mock_get_source_ip): "homeassistant.components.fritz.common.FritzConnection", side_effect=fc_class_mock, ), patch("homeassistant.components.fritz.common.FritzStatus"), patch( + "homeassistant.components.fritz.common.FritzBoxTools._update_device_info", + return_value=MOCK_FIRMWARE_INFO, + ), patch( "homeassistant.components.fritz.async_setup_entry" ) as mock_setup_entry, patch( "requests.get" From 4b68700763721c269a0092533d09721337ad5c39 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Thu, 30 Sep 2021 03:20:14 -0600 Subject: [PATCH 722/843] Add long-term statistics for Ambient PWS sensors (#55412) --- .../components/ambient_station/__init__.py | 6 +- .../components/ambient_station/sensor.py | 78 ++++++++++++++++++- 2 files changed, 76 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/ambient_station/__init__.py b/homeassistant/components/ambient_station/__init__.py index 4a73d699d59..1f1b21b4346 100644 --- a/homeassistant/components/ambient_station/__init__.py +++ b/homeassistant/components/ambient_station/__init__.py @@ -178,9 +178,7 @@ class AmbientStation: def on_subscribed(data: dict) -> None: """Define a handler to fire when the subscription is set.""" for station in data["devices"]: - mac = station["macAddress"] - - if mac in self.stations: + if (mac := station["macAddress"]) in self.stations: continue LOGGER.debug("New station subscription: %s", data) @@ -226,7 +224,7 @@ class AmbientWeatherEntity(Entity): station_name: str, description: EntityDescription, ) -> None: - """Initialize the sensor.""" + """Initialize the entity.""" self._ambient = ambient self._attr_device_info = { "identifiers": {(DOMAIN, mac_address)}, diff --git a/homeassistant/components/ambient_station/sensor.py b/homeassistant/components/ambient_station/sensor.py index 0a77f6c7dd6..0247d03b6fd 100644 --- a/homeassistant/components/ambient_station/sensor.py +++ b/homeassistant/components/ambient_station/sensor.py @@ -1,7 +1,12 @@ """Support for Ambient Weather Station sensors.""" from __future__ import annotations -from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_NAME, @@ -25,9 +30,15 @@ from homeassistant.const import ( TEMP_FAHRENHEIT, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import TYPE_SOLARRADIATION, TYPE_SOLARRADIATION_LX, AmbientWeatherEntity +from . import ( + TYPE_SOLARRADIATION, + TYPE_SOLARRADIATION_LX, + AmbientStation, + AmbientWeatherEntity, +) from .const import ATTR_LAST_DATA, DATA_CLIENT, DOMAIN TYPE_24HOURRAININ = "24hourrainin" @@ -109,54 +120,63 @@ SENSOR_DESCRIPTIONS = ( name="24 Hr Rain", icon="mdi:water", native_unit_of_measurement=PRECIPITATION_INCHES, + state_class=STATE_CLASS_TOTAL_INCREASING, ), SensorEntityDescription( key=TYPE_BAROMABSIN, name="Abs Pressure", native_unit_of_measurement=PRESSURE_INHG, device_class=DEVICE_CLASS_PRESSURE, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=TYPE_BAROMRELIN, name="Rel Pressure", native_unit_of_measurement=PRESSURE_INHG, device_class=DEVICE_CLASS_PRESSURE, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=TYPE_CO2, name="co2", native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, device_class=DEVICE_CLASS_CO2, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=TYPE_DAILYRAININ, name="Daily Rain", icon="mdi:water", native_unit_of_measurement=PRECIPITATION_INCHES, + state_class=STATE_CLASS_TOTAL_INCREASING, ), SensorEntityDescription( key=TYPE_DEWPOINT, name="Dew Point", native_unit_of_measurement=TEMP_FAHRENHEIT, device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=TYPE_EVENTRAININ, name="Event Rain", icon="mdi:water", native_unit_of_measurement=PRECIPITATION_INCHES, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=TYPE_FEELSLIKE, name="Feels Like", native_unit_of_measurement=TEMP_FAHRENHEIT, device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=TYPE_HOURLYRAININ, name="Hourly Rain Rate", icon="mdi:water", native_unit_of_measurement=PRECIPITATION_INCHES_PER_HOUR, + state_class=STATE_CLASS_TOTAL_INCREASING, ), SensorEntityDescription( key=TYPE_HUMIDITY10, @@ -241,12 +261,14 @@ SENSOR_DESCRIPTIONS = ( name="Max Gust", icon="mdi:weather-windy", native_unit_of_measurement=SPEED_MILES_PER_HOUR, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=TYPE_MONTHLYRAININ, name="Monthly Rain", icon="mdi:water", native_unit_of_measurement=PRECIPITATION_INCHES, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=TYPE_PM25_24H, @@ -259,6 +281,7 @@ SENSOR_DESCRIPTIONS = ( name="PM25 Indoor", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, device_class=DEVICE_CLASS_PM25, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=TYPE_PM25_IN_24H, @@ -271,6 +294,7 @@ SENSOR_DESCRIPTIONS = ( name="PM25", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, device_class=DEVICE_CLASS_PM25, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=TYPE_SOILHUM10, @@ -337,162 +361,189 @@ SENSOR_DESCRIPTIONS = ( name="Soil Temp 10", native_unit_of_measurement=TEMP_FAHRENHEIT, device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=TYPE_SOILTEMP1F, name="Soil Temp 1", native_unit_of_measurement=TEMP_FAHRENHEIT, device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=TYPE_SOILTEMP2F, name="Soil Temp 2", native_unit_of_measurement=TEMP_FAHRENHEIT, device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=TYPE_SOILTEMP3F, name="Soil Temp 3", native_unit_of_measurement=TEMP_FAHRENHEIT, device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=TYPE_SOILTEMP4F, name="Soil Temp 4", native_unit_of_measurement=TEMP_FAHRENHEIT, device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=TYPE_SOILTEMP5F, name="Soil Temp 5", native_unit_of_measurement=TEMP_FAHRENHEIT, device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=TYPE_SOILTEMP6F, name="Soil Temp 6", native_unit_of_measurement=TEMP_FAHRENHEIT, device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=TYPE_SOILTEMP7F, name="Soil Temp 7", native_unit_of_measurement=TEMP_FAHRENHEIT, device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=TYPE_SOILTEMP8F, name="Soil Temp 8", native_unit_of_measurement=TEMP_FAHRENHEIT, device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=TYPE_SOILTEMP9F, name="Soil Temp 9", native_unit_of_measurement=TEMP_FAHRENHEIT, device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=TYPE_SOLARRADIATION, name="Solar Rad", native_unit_of_measurement=IRRADIATION_WATTS_PER_SQUARE_METER, device_class=DEVICE_CLASS_ILLUMINANCE, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=TYPE_SOLARRADIATION_LX, - name="Solar Rad (lx)", + name="Solar Rad", native_unit_of_measurement=LIGHT_LUX, device_class=DEVICE_CLASS_ILLUMINANCE, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=TYPE_TEMP10F, name="Temp 10", native_unit_of_measurement=TEMP_FAHRENHEIT, device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=TYPE_TEMP1F, name="Temp 1", native_unit_of_measurement=TEMP_FAHRENHEIT, device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=TYPE_TEMP2F, name="Temp 2", native_unit_of_measurement=TEMP_FAHRENHEIT, device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=TYPE_TEMP3F, name="Temp 3", native_unit_of_measurement=TEMP_FAHRENHEIT, device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=TYPE_TEMP4F, name="Temp 4", native_unit_of_measurement=TEMP_FAHRENHEIT, device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=TYPE_TEMP5F, name="Temp 5", native_unit_of_measurement=TEMP_FAHRENHEIT, device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=TYPE_TEMP6F, name="Temp 6", native_unit_of_measurement=TEMP_FAHRENHEIT, device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=TYPE_TEMP7F, name="Temp 7", native_unit_of_measurement=TEMP_FAHRENHEIT, device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=TYPE_TEMP8F, name="Temp 8", native_unit_of_measurement=TEMP_FAHRENHEIT, device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=TYPE_TEMP9F, name="Temp 9", native_unit_of_measurement=TEMP_FAHRENHEIT, device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=TYPE_TEMPF, name="Temp", native_unit_of_measurement=TEMP_FAHRENHEIT, device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=TYPE_TEMPINF, name="Inside Temp", native_unit_of_measurement=TEMP_FAHRENHEIT, device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=TYPE_TOTALRAININ, name="Lifetime Rain", icon="mdi:water", native_unit_of_measurement=PRECIPITATION_INCHES, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=TYPE_UV, name="UV Index", native_unit_of_measurement="Index", device_class=DEVICE_CLASS_ILLUMINANCE, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=TYPE_WEEKLYRAININ, name="Weekly Rain", icon="mdi:water", native_unit_of_measurement=PRECIPITATION_INCHES, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=TYPE_WINDDIR, @@ -510,7 +561,7 @@ SENSOR_DESCRIPTIONS = ( key=TYPE_WINDDIR_AVG2M, name="Wind Dir Avg 2m", icon="mdi:weather-windy", - native_unit_of_measurement=SPEED_MILES_PER_HOUR, + native_unit_of_measurement=DEGREE, ), SensorEntityDescription( key=TYPE_WINDGUSTDIR, @@ -523,6 +574,7 @@ SENSOR_DESCRIPTIONS = ( name="Wind Gust", icon="mdi:weather-windy", native_unit_of_measurement=SPEED_MILES_PER_HOUR, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=TYPE_WINDSPDMPH_AVG10M, @@ -541,12 +593,14 @@ SENSOR_DESCRIPTIONS = ( name="Wind Speed", icon="mdi:weather-windy", native_unit_of_measurement=SPEED_MILES_PER_HOUR, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=TYPE_YEARLYRAININ, name="Yearly Rain", icon="mdi:water", native_unit_of_measurement=PRECIPITATION_INCHES, + state_class=STATE_CLASS_TOTAL_INCREASING, ), ) @@ -570,6 +624,22 @@ async def async_setup_entry( class AmbientWeatherSensor(AmbientWeatherEntity, SensorEntity): """Define an Ambient sensor.""" + def __init__( + self, + ambient: AmbientStation, + mac_address: str, + station_name: str, + description: EntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(ambient, mac_address, station_name, description) + + if description.key == TYPE_SOLARRADIATION_LX: + # Since TYPE_SOLARRADIATION and TYPE_SOLARRADIATION_LX will have the same + # name in the UI, we influence the entity ID of TYPE_SOLARRADIATION_LX here + # to differentiate them: + self.entity_id = f"sensor.{station_name}_solar_rad_lx" + @callback def update_from_latest_data(self) -> None: """Fetch new state data for the sensor.""" From a035615016a3aa9c393ec8d4c04dfc48b0c6d4fe Mon Sep 17 00:00:00 2001 From: Chris Talkington Date: Thu, 30 Sep 2021 04:25:42 -0500 Subject: [PATCH 723/843] Use entity descriptions for sonarr (#55818) --- homeassistant/components/sonarr/sensor.py | 395 +++++++--------------- 1 file changed, 130 insertions(+), 265 deletions(-) diff --git a/homeassistant/components/sonarr/sensor.py b/homeassistant/components/sonarr/sensor.py index 374791304c7..8911927d732 100644 --- a/homeassistant/components/sonarr/sensor.py +++ b/homeassistant/components/sonarr/sensor.py @@ -3,22 +3,16 @@ from __future__ import annotations from datetime import timedelta import logging +from typing import Any from sonarr import Sonarr, SonarrConnectionError, SonarrError -from sonarr.models import ( - CommandItem, - Disk, - Episode, - QueueItem, - SeriesItem, - WantedResults, -) -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import DATA_GIGABYTES from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType import homeassistant.util.dt as dt_util from .const import CONF_UPCOMING_DAYS, CONF_WANTED_MAX_ITEMS, DATA_SONARR, DOMAIN @@ -26,6 +20,50 @@ from .entity import SonarrEntity _LOGGER = logging.getLogger(__name__) +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="commands", + name="Sonarr Commands", + icon="mdi:code-braces", + native_unit_of_measurement="Commands", + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="diskspace", + name="Sonarr Disk Space", + icon="mdi:harddisk", + native_unit_of_measurement=DATA_GIGABYTES, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="queue", + name="Sonarr Queue", + icon="mdi:download", + native_unit_of_measurement="Episodes", + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="series", + name="Sonarr Shows", + icon="mdi:television", + native_unit_of_measurement="Series", + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="upcoming", + name="Sonarr Upcoming", + icon="mdi:television", + native_unit_of_measurement="Episodes", + ), + SensorEntityDescription( + key="wanted", + name="Sonarr Wanted", + icon="mdi:television", + native_unit_of_measurement="Episodes", + entity_registry_enabled_default=False, + ), +) + async def async_setup_entry( hass: HomeAssistant, @@ -33,18 +71,12 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Sonarr sensors based on a config entry.""" - options = entry.options - sonarr = hass.data[DOMAIN][entry.entry_id][DATA_SONARR] + sonarr: Sonarr = hass.data[DOMAIN][entry.entry_id][DATA_SONARR] + options: dict[str, Any] = dict(entry.options) entities = [ - SonarrCommandsSensor(sonarr, entry.entry_id), - SonarrDiskspaceSensor(sonarr, entry.entry_id), - SonarrQueueSensor(sonarr, entry.entry_id), - SonarrSeriesSensor(sonarr, entry.entry_id), - SonarrUpcomingSensor(sonarr, entry.entry_id, days=options[CONF_UPCOMING_DAYS]), - SonarrWantedSensor( - sonarr, entry.entry_id, max_items=options[CONF_WANTED_MAX_ITEMS] - ), + SonarrSensor(sonarr, entry.entry_id, description, options) + for description in SENSOR_TYPES ] async_add_entities(entities, True) @@ -78,23 +110,19 @@ class SonarrSensor(SonarrEntity, SensorEntity): def __init__( self, - *, sonarr: Sonarr, entry_id: str, - enabled_default: bool = True, - icon: str, - key: str, - name: str, - unit_of_measurement: str | None = None, + description: SensorEntityDescription, + options: dict[str, Any], ) -> None: """Initialize Sonarr sensor.""" - self._key = key - self._attr_name = name - self._attr_icon = icon - self._attr_unique_id = f"{entry_id}_{key}" - self._attr_native_unit_of_measurement = unit_of_measurement - self._attr_entity_registry_enabled_default = enabled_default - self.last_update_success = False + self.entity_description = description + self._attr_unique_id = f"{entry_id}_{description.key}" + + self.data: dict[str, Any] = {} + self.last_update_success: bool = False + self.upcoming_days: int = options[CONF_UPCOMING_DAYS] + self.wanted_max_items: int = options[CONF_WANTED_MAX_ITEMS] super().__init__( sonarr=sonarr, @@ -107,253 +135,90 @@ class SonarrSensor(SonarrEntity, SensorEntity): """Return sensor availability.""" return self.last_update_success - -class SonarrCommandsSensor(SonarrSensor): - """Defines a Sonarr Commands sensor.""" - - def __init__(self, sonarr: Sonarr, entry_id: str) -> None: - """Initialize Sonarr Commands sensor.""" - self._commands: list[CommandItem] = [] - - super().__init__( - sonarr=sonarr, - entry_id=entry_id, - icon="mdi:code-braces", - key="commands", - name=f"{sonarr.app.info.app_name} Commands", - unit_of_measurement="Commands", - enabled_default=False, - ) - @sonarr_exception_handler async def async_update(self) -> None: """Update entity.""" - self._commands = await self.sonarr.commands() + key = self.entity_description.key + + if key == "diskspace": + await self.sonarr.update() + elif key == "commands": + self.data[key] = await self.sonarr.commands() + elif key == "queue": + self.data[key] = await self.sonarr.queue() + elif key == "series": + self.data[key] = await self.sonarr.series() + elif key == "upcoming": + local = dt_util.start_of_local_day().replace(microsecond=0) + start = dt_util.as_utc(local) + end = start + timedelta(days=self.upcoming_days) + + self.data[key] = await self.sonarr.calendar( + start=start.isoformat(), end=end.isoformat() + ) + elif key == "wanted": + self.data[key] = await self.sonarr.wanted(page_size=self.wanted_max_items) @property def extra_state_attributes(self) -> dict[str, str] | None: """Return the state attributes of the entity.""" attrs = {} + key = self.entity_description.key - for command in self._commands: - attrs[command.name] = command.state + if key == "diskspace": + for disk in self.sonarr.app.disks: + free = disk.free / 1024 ** 3 + total = disk.total / 1024 ** 3 + usage = free / total * 100 - return attrs - - @property - def native_value(self) -> int: - """Return the state of the sensor.""" - return len(self._commands) - - -class SonarrDiskspaceSensor(SonarrSensor): - """Defines a Sonarr Disk Space sensor.""" - - def __init__(self, sonarr: Sonarr, entry_id: str) -> None: - """Initialize Sonarr Disk Space sensor.""" - self._disks: list[Disk] = [] - self._total_free = 0 - - super().__init__( - sonarr=sonarr, - entry_id=entry_id, - icon="mdi:harddisk", - key="diskspace", - name=f"{sonarr.app.info.app_name} Disk Space", - unit_of_measurement=DATA_GIGABYTES, - enabled_default=False, - ) - - @sonarr_exception_handler - async def async_update(self) -> None: - """Update entity.""" - app = await self.sonarr.update() - self._disks = app.disks - self._total_free = sum(disk.free for disk in self._disks) - - @property - def extra_state_attributes(self) -> dict[str, str] | None: - """Return the state attributes of the entity.""" - attrs = {} - - for disk in self._disks: - free = disk.free / 1024 ** 3 - total = disk.total / 1024 ** 3 - usage = free / total * 100 - - attrs[ - disk.path - ] = f"{free:.2f}/{total:.2f}{self.unit_of_measurement} ({usage:.2f}%)" - - return attrs - - @property - def native_value(self) -> str: - """Return the state of the sensor.""" - free = self._total_free / 1024 ** 3 - return f"{free:.2f}" - - -class SonarrQueueSensor(SonarrSensor): - """Defines a Sonarr Queue sensor.""" - - def __init__(self, sonarr: Sonarr, entry_id: str) -> None: - """Initialize Sonarr Queue sensor.""" - self._queue: list[QueueItem] = [] - - super().__init__( - sonarr=sonarr, - entry_id=entry_id, - icon="mdi:download", - key="queue", - name=f"{sonarr.app.info.app_name} Queue", - unit_of_measurement="Episodes", - enabled_default=False, - ) - - @sonarr_exception_handler - async def async_update(self) -> None: - """Update entity.""" - self._queue = await self.sonarr.queue() - - @property - def extra_state_attributes(self) -> dict[str, str] | None: - """Return the state attributes of the entity.""" - attrs = {} - - for item in self._queue: - remaining = 1 if item.size == 0 else item.size_remaining / item.size - remaining_pct = 100 * (1 - remaining) - name = f"{item.episode.series.title} {item.episode.identifier}" - attrs[name] = f"{remaining_pct:.2f}%" - - return attrs - - @property - def native_value(self) -> int: - """Return the state of the sensor.""" - return len(self._queue) - - -class SonarrSeriesSensor(SonarrSensor): - """Defines a Sonarr Series sensor.""" - - def __init__(self, sonarr: Sonarr, entry_id: str) -> None: - """Initialize Sonarr Series sensor.""" - self._items: list[SeriesItem] = [] - - super().__init__( - sonarr=sonarr, - entry_id=entry_id, - icon="mdi:television", - key="series", - name=f"{sonarr.app.info.app_name} Shows", - unit_of_measurement="Series", - enabled_default=False, - ) - - @sonarr_exception_handler - async def async_update(self) -> None: - """Update entity.""" - self._items = await self.sonarr.series() - - @property - def extra_state_attributes(self) -> dict[str, str] | None: - """Return the state attributes of the entity.""" - attrs = {} - - for item in self._items: - attrs[item.series.title] = f"{item.downloaded}/{item.episodes} Episodes" - - return attrs - - @property - def native_value(self) -> int: - """Return the state of the sensor.""" - return len(self._items) - - -class SonarrUpcomingSensor(SonarrSensor): - """Defines a Sonarr Upcoming sensor.""" - - def __init__(self, sonarr: Sonarr, entry_id: str, days: int = 1) -> None: - """Initialize Sonarr Upcoming sensor.""" - self._days = days - self._upcoming: list[Episode] = [] - - super().__init__( - sonarr=sonarr, - entry_id=entry_id, - icon="mdi:television", - key="upcoming", - name=f"{sonarr.app.info.app_name} Upcoming", - unit_of_measurement="Episodes", - ) - - @sonarr_exception_handler - async def async_update(self) -> None: - """Update entity.""" - local = dt_util.start_of_local_day().replace(microsecond=0) - start = dt_util.as_utc(local) - end = start + timedelta(days=self._days) - self._upcoming = await self.sonarr.calendar( - start=start.isoformat(), end=end.isoformat() - ) - - @property - def extra_state_attributes(self) -> dict[str, str] | None: - """Return the state attributes of the entity.""" - attrs = {} - - for episode in self._upcoming: - attrs[episode.series.title] = episode.identifier - - return attrs - - @property - def native_value(self) -> int: - """Return the state of the sensor.""" - return len(self._upcoming) - - -class SonarrWantedSensor(SonarrSensor): - """Defines a Sonarr Wanted sensor.""" - - def __init__(self, sonarr: Sonarr, entry_id: str, max_items: int = 10) -> None: - """Initialize Sonarr Wanted sensor.""" - self._max_items = max_items - self._results: WantedResults | None = None - self._total: int | None = None - - super().__init__( - sonarr=sonarr, - entry_id=entry_id, - icon="mdi:television", - key="wanted", - name=f"{sonarr.app.info.app_name} Wanted", - unit_of_measurement="Episodes", - enabled_default=False, - ) - - @sonarr_exception_handler - async def async_update(self) -> None: - """Update entity.""" - self._results = await self.sonarr.wanted(page_size=self._max_items) - self._total = self._results.total - - @property - def extra_state_attributes(self) -> dict[str, str] | None: - """Return the state attributes of the entity.""" - attrs: dict[str, str] = {} - - if self._results is not None: - for episode in self._results.episodes: + attrs[ + disk.path + ] = f"{free:.2f}/{total:.2f}{self.unit_of_measurement} ({usage:.2f}%)" + elif key == "commands" and self.data.get(key) is not None: + for command in self.data[key]: + attrs[command.name] = command.state + elif key == "queue" and self.data.get(key) is not None: + for item in self.data[key]: + remaining = 1 if item.size == 0 else item.size_remaining / item.size + remaining_pct = 100 * (1 - remaining) + name = f"{item.episode.series.title} {item.episode.identifier}" + attrs[name] = f"{remaining_pct:.2f}%" + elif key == "series" and self.data.get(key) is not None: + for item in self.data[key]: + attrs[item.series.title] = f"{item.downloaded}/{item.episodes} Episodes" + elif key == "upcoming" and self.data.get(key) is not None: + for episode in self.data[key]: + attrs[episode.series.title] = episode.identifier + elif key == "wanted" and self.data.get(key) is not None: + for episode in self.data[key].episodes: name = f"{episode.series.title} {episode.identifier}" attrs[name] = episode.airdate return attrs @property - def native_value(self) -> int | None: + def native_value(self) -> StateType: """Return the state of the sensor.""" - return self._total + key = self.entity_description.key + + if key == "diskspace": + total_free = sum(disk.free for disk in self.sonarr.app.disks) + free = total_free / 1024 ** 3 + return f"{free:.2f}" + + if key == "commands" and self.data.get(key) is not None: + return len(self.data[key]) + + if key == "queue" and self.data.get(key) is not None: + return len(self.data[key]) + + if key == "series" and self.data.get(key) is not None: + return len(self.data[key]) + + if key == "upcoming" and self.data.get(key) is not None: + return len(self.data[key]) + + if key == "wanted" and self.data.get(key) is not None: + return self.data[key].total + + return None From 4c854a06d9d68716c7649f737ee19796fcbad97a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Thu, 30 Sep 2021 12:27:52 +0300 Subject: [PATCH 724/843] Add some huawei_lte sensor state classifications (#55601) --- homeassistant/components/huawei_lte/sensor.py | 46 ++++++++++++++++--- 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py index f62450088ae..568f7c31a53 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -13,6 +13,8 @@ from homeassistant.components.sensor import ( DEVICE_CLASS_BATTERY, DEVICE_CLASS_SIGNAL_STRENGTH, DOMAIN as SENSOR_DOMAIN, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntity, ) from homeassistant.config_entries import ConfigEntry @@ -54,6 +56,7 @@ class SensorMeta(NamedTuple): device_class: str | None = None icon: str | Callable[[StateType], str] | None = None unit: str | None = None + state_class: str | None = None enabled_default: bool = False include: re.Pattern[str] | None = None exclude: re.Pattern[str] | None = None @@ -123,6 +126,7 @@ SENSOR_META: dict[str | tuple[str, str], SensorMeta] = { "mdi:signal-cellular-2", "mdi:signal-cellular-3", )[bisect((-11, -8, -5), x if x is not None else -1000)], + state_class=STATE_CLASS_MEASUREMENT, enabled_default=True, ), (KEY_DEVICE_SIGNAL, "rsrp"): SensorMeta( @@ -135,6 +139,7 @@ SENSOR_META: dict[str | tuple[str, str], SensorMeta] = { "mdi:signal-cellular-2", "mdi:signal-cellular-3", )[bisect((-110, -95, -80), x if x is not None else -1000)], + state_class=STATE_CLASS_MEASUREMENT, enabled_default=True, ), (KEY_DEVICE_SIGNAL, "rssi"): SensorMeta( @@ -147,6 +152,7 @@ SENSOR_META: dict[str | tuple[str, str], SensorMeta] = { "mdi:signal-cellular-2", "mdi:signal-cellular-3", )[bisect((-80, -70, -60), x if x is not None else -1000)], + state_class=STATE_CLASS_MEASUREMENT, enabled_default=True, ), (KEY_DEVICE_SIGNAL, "sinr"): SensorMeta( @@ -159,6 +165,7 @@ SENSOR_META: dict[str | tuple[str, str], SensorMeta] = { "mdi:signal-cellular-2", "mdi:signal-cellular-3", )[bisect((0, 5, 10), x if x is not None else -1000)], + state_class=STATE_CLASS_MEASUREMENT, enabled_default=True, ), (KEY_DEVICE_SIGNAL, "rscp"): SensorMeta( @@ -171,6 +178,7 @@ SENSOR_META: dict[str | tuple[str, str], SensorMeta] = { "mdi:signal-cellular-2", "mdi:signal-cellular-3", )[bisect((-95, -85, -75), x if x is not None else -1000)], + state_class=STATE_CLASS_MEASUREMENT, ), (KEY_DEVICE_SIGNAL, "ecio"): SensorMeta( name="EC/IO", @@ -182,6 +190,7 @@ SENSOR_META: dict[str | tuple[str, str], SensorMeta] = { "mdi:signal-cellular-2", "mdi:signal-cellular-3", )[bisect((-20, -10, -6), x if x is not None else -1000)], + state_class=STATE_CLASS_MEASUREMENT, ), (KEY_DEVICE_SIGNAL, "transmode"): SensorMeta(name="Transmission mode"), (KEY_DEVICE_SIGNAL, "cqi0"): SensorMeta( @@ -219,10 +228,16 @@ SENSOR_META: dict[str | tuple[str, str], SensorMeta] = { exclude=re.compile(r"^month(duration|lastcleartime)$", re.IGNORECASE) ), (KEY_MONITORING_MONTH_STATISTICS, "CurrentMonthDownload"): SensorMeta( - name="Current month download", unit=DATA_BYTES, icon="mdi:download" + name="Current month download", + unit=DATA_BYTES, + icon="mdi:download", + state_class=STATE_CLASS_TOTAL_INCREASING, ), (KEY_MONITORING_MONTH_STATISTICS, "CurrentMonthUpload"): SensorMeta( - name="Current month upload", unit=DATA_BYTES, icon="mdi:upload" + name="Current month upload", + unit=DATA_BYTES, + icon="mdi:upload", + state_class=STATE_CLASS_TOTAL_INCREASING, ), KEY_MONITORING_STATUS: SensorMeta( include=re.compile( @@ -257,29 +272,43 @@ SENSOR_META: dict[str | tuple[str, str], SensorMeta] = { name="Current connection duration", unit=TIME_SECONDS, icon="mdi:timer-outline" ), (KEY_MONITORING_TRAFFIC_STATISTICS, "CurrentDownload"): SensorMeta( - name="Current connection download", unit=DATA_BYTES, icon="mdi:download" + name="Current connection download", + unit=DATA_BYTES, + icon="mdi:download", + state_class=STATE_CLASS_TOTAL_INCREASING, ), (KEY_MONITORING_TRAFFIC_STATISTICS, "CurrentDownloadRate"): SensorMeta( name="Current download rate", unit=DATA_RATE_BYTES_PER_SECOND, icon="mdi:download", + state_class=STATE_CLASS_MEASUREMENT, ), (KEY_MONITORING_TRAFFIC_STATISTICS, "CurrentUpload"): SensorMeta( - name="Current connection upload", unit=DATA_BYTES, icon="mdi:upload" + name="Current connection upload", + unit=DATA_BYTES, + icon="mdi:upload", + state_class=STATE_CLASS_TOTAL_INCREASING, ), (KEY_MONITORING_TRAFFIC_STATISTICS, "CurrentUploadRate"): SensorMeta( name="Current upload rate", unit=DATA_RATE_BYTES_PER_SECOND, icon="mdi:upload", + state_class=STATE_CLASS_MEASUREMENT, ), (KEY_MONITORING_TRAFFIC_STATISTICS, "TotalConnectTime"): SensorMeta( name="Total connected duration", unit=TIME_SECONDS, icon="mdi:timer-outline" ), (KEY_MONITORING_TRAFFIC_STATISTICS, "TotalDownload"): SensorMeta( - name="Total download", unit=DATA_BYTES, icon="mdi:download" + name="Total download", + unit=DATA_BYTES, + icon="mdi:download", + state_class=STATE_CLASS_TOTAL_INCREASING, ), (KEY_MONITORING_TRAFFIC_STATISTICS, "TotalUpload"): SensorMeta( - name="Total upload", unit=DATA_BYTES, icon="mdi:upload" + name="Total upload", + unit=DATA_BYTES, + icon="mdi:upload", + state_class=STATE_CLASS_TOTAL_INCREASING, ), KEY_NET_CURRENT_PLMN: SensorMeta( exclude=re.compile(r"^(Rat|ShortName|Spn)$", re.IGNORECASE) @@ -455,6 +484,11 @@ class HuaweiLteSensor(HuaweiLteBaseEntity, SensorEntity): return icon(self.state) return icon + @property + def state_class(self) -> str | None: + """Return sensor state class.""" + return self.meta.state_class + @property def entity_registry_enabled_default(self) -> bool: """Return if the entity should be enabled when first added to the entity registry.""" From 6a266ae3c03b5dd2fb73005bb85e757573a4a2f6 Mon Sep 17 00:00:00 2001 From: Greg Date: Thu, 30 Sep 2021 02:34:41 -0700 Subject: [PATCH 725/843] Change state_class so older Envoys can use Energy Dashboard (#55383) --- homeassistant/components/enphase_envoy/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/enphase_envoy/const.py b/homeassistant/components/enphase_envoy/const.py index ff42ef23746..ea67b5b633b 100644 --- a/homeassistant/components/enphase_envoy/const.py +++ b/homeassistant/components/enphase_envoy/const.py @@ -27,7 +27,7 @@ SENSORS = ( key="daily_production", name="Today's Energy Production", native_unit_of_measurement=ENERGY_WATT_HOUR, - state_class=STATE_CLASS_MEASUREMENT, + state_class=STATE_CLASS_TOTAL_INCREASING, device_class=DEVICE_CLASS_ENERGY, ), SensorEntityDescription( From 0c1c1f7845759b01ac1aea5503156a788673b3ec Mon Sep 17 00:00:00 2001 From: Tim Niemueller Date: Thu, 30 Sep 2021 11:36:49 +0200 Subject: [PATCH 726/843] Fix Onvif PTZ for Imou cameras (#56592) --- homeassistant/components/onvif/device.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/onvif/device.py b/homeassistant/components/onvif/device.py index 9ebf87a4132..1d08ec04f46 100644 --- a/homeassistant/components/onvif/device.py +++ b/homeassistant/components/onvif/device.py @@ -373,10 +373,13 @@ class ONVIFDevice: ) return - req.Velocity = { - "PanTilt": {"x": pan_val, "y": tilt_val}, - "Zoom": {"x": zoom_val}, - } + velocity = {} + if pan is not None or tilt is not None: + velocity["PanTilt"] = {"x": pan_val, "y": tilt_val} + if zoom is not None: + velocity["Zoom"] = {"x": zoom_val} + + req.Velocity = velocity await ptz_service.ContinuousMove(req) await asyncio.sleep(continuous_duration) From 1f5720199caa44df0db8d6c3d5a474efd25f9a99 Mon Sep 17 00:00:00 2001 From: Mas2112 Date: Thu, 30 Sep 2021 11:41:55 +0200 Subject: [PATCH 727/843] Add DC voltage and current to Kostal inverter (#54878) --- .../components/kostal_plenticore/const.py | 70 +++++++++++++++++++ .../components/kostal_plenticore/helper.py | 8 +++ 2 files changed, 78 insertions(+) diff --git a/homeassistant/components/kostal_plenticore/const.py b/homeassistant/components/kostal_plenticore/const.py index 9f902da7d2f..68c2baffbdb 100644 --- a/homeassistant/components/kostal_plenticore/const.py +++ b/homeassistant/components/kostal_plenticore/const.py @@ -10,8 +10,12 @@ from homeassistant.const import ( ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, DEVICE_CLASS_BATTERY, + DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, + DEVICE_CLASS_VOLTAGE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, ENERGY_KILO_WATT_HOUR, PERCENTAGE, POWER_WATT, @@ -135,6 +139,28 @@ SENSOR_PROCESS_DATA = [ }, "format_round", ), + ( + "devices:local:pv1", + "U", + "DC1 Voltage", + { + ATTR_UNIT_OF_MEASUREMENT: ELECTRIC_POTENTIAL_VOLT, + ATTR_DEVICE_CLASS: DEVICE_CLASS_VOLTAGE, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + }, + "format_round", + ), + ( + "devices:local:pv1", + "I", + "DC1 Current", + { + ATTR_UNIT_OF_MEASUREMENT: ELECTRIC_CURRENT_AMPERE, + ATTR_DEVICE_CLASS: DEVICE_CLASS_CURRENT, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + }, + "format_float", + ), ( "devices:local:pv2", "P", @@ -146,6 +172,28 @@ SENSOR_PROCESS_DATA = [ }, "format_round", ), + ( + "devices:local:pv2", + "U", + "DC2 Voltage", + { + ATTR_UNIT_OF_MEASUREMENT: ELECTRIC_POTENTIAL_VOLT, + ATTR_DEVICE_CLASS: DEVICE_CLASS_VOLTAGE, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + }, + "format_round", + ), + ( + "devices:local:pv2", + "I", + "DC2 Current", + { + ATTR_UNIT_OF_MEASUREMENT: ELECTRIC_CURRENT_AMPERE, + ATTR_DEVICE_CLASS: DEVICE_CLASS_CURRENT, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + }, + "format_float", + ), ( "devices:local:pv3", "P", @@ -157,6 +205,28 @@ SENSOR_PROCESS_DATA = [ }, "format_round", ), + ( + "devices:local:pv3", + "U", + "DC3 Voltage", + { + ATTR_UNIT_OF_MEASUREMENT: ELECTRIC_POTENTIAL_VOLT, + ATTR_DEVICE_CLASS: DEVICE_CLASS_VOLTAGE, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + }, + "format_round", + ), + ( + "devices:local:pv3", + "I", + "DC3 Current", + { + ATTR_UNIT_OF_MEASUREMENT: ELECTRIC_CURRENT_AMPERE, + ATTR_DEVICE_CLASS: DEVICE_CLASS_CURRENT, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + }, + "format_float", + ), ( "devices:local", "PV2Bat_P", diff --git a/homeassistant/components/kostal_plenticore/helper.py b/homeassistant/components/kostal_plenticore/helper.py index eb4f6ce44a6..2a21cb4ee55 100644 --- a/homeassistant/components/kostal_plenticore/helper.py +++ b/homeassistant/components/kostal_plenticore/helper.py @@ -231,6 +231,14 @@ class PlenticoreDataFormatter: except (TypeError, ValueError): return state + @staticmethod + def format_float(state: str) -> int | str: + """Return the given state value as float rounded to three decimal places.""" + try: + return round(float(state), 3) + except (TypeError, ValueError): + return state + @staticmethod def format_energy(state: str) -> float | str: """Return the given state value as energy value, scaled to kWh.""" From 4ae887ad346613896623b3192f33ccb180b50515 Mon Sep 17 00:00:00 2001 From: acshef Date: Thu, 30 Sep 2021 03:52:21 -0600 Subject: [PATCH 728/843] Correct unit of measurement for qbittorrent data rate sensors (#55758) --- homeassistant/components/qbittorrent/sensor.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py index 4663b203248..6dd52af5631 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -17,7 +17,7 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_URL, CONF_USERNAME, - DATA_RATE_KILOBYTES_PER_SECOND, + DATA_RATE_KIBIBYTES_PER_SECOND, STATE_IDLE, ) from homeassistant.exceptions import PlatformNotReady @@ -39,12 +39,12 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key=SENSOR_TYPE_DOWNLOAD_SPEED, name="Down Speed", - native_unit_of_measurement=DATA_RATE_KILOBYTES_PER_SECOND, + native_unit_of_measurement=DATA_RATE_KIBIBYTES_PER_SECOND, ), SensorEntityDescription( key=SENSOR_TYPE_UPLOAD_SPEED, name="Up Speed", - native_unit_of_measurement=DATA_RATE_KILOBYTES_PER_SECOND, + native_unit_of_measurement=DATA_RATE_KIBIBYTES_PER_SECOND, ), ) From 26042bdad7433e489a80a2d56f216e472429a008 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20L=C3=B6hr?= Date: Thu, 30 Sep 2021 11:56:38 +0200 Subject: [PATCH 729/843] Add Fritz!DECT 440 humidity sensor (#54597) Co-authored-by: Michael <35783820+mib1185@users.noreply.github.com> --- homeassistant/components/fritzbox/sensor.py | 10 ++++++++++ tests/components/fritzbox/__init__.py | 1 + tests/components/fritzbox/test_sensor.py | 7 +++++++ tests/components/fritzbox/test_switch.py | 3 +++ 4 files changed, 21 insertions(+) diff --git a/homeassistant/components/fritzbox/sensor.py b/homeassistant/components/fritzbox/sensor.py index 7ff66f193c9..0745ddc8331 100644 --- a/homeassistant/components/fritzbox/sensor.py +++ b/homeassistant/components/fritzbox/sensor.py @@ -17,6 +17,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( DEVICE_CLASS_BATTERY, DEVICE_CLASS_ENERGY, + DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_POWER, DEVICE_CLASS_TEMPERATURE, ENERGY_KILO_WATT_HOUR, @@ -58,6 +59,15 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( ), native_value=lambda device: device.temperature, # type: ignore[no-any-return] ), + FritzSensorEntityDescription( + key="humidity", + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + suitable=lambda device: device.rel_humidity is not None, + native_value=lambda device: device.rel_humidity, # type: ignore[no-any-return] + ), FritzSensorEntityDescription( key="battery", name="Battery", diff --git a/tests/components/fritzbox/__init__.py b/tests/components/fritzbox/__init__.py index dfa266dc15b..2b9a1a783f9 100644 --- a/tests/components/fritzbox/__init__.py +++ b/tests/components/fritzbox/__init__.py @@ -100,6 +100,7 @@ class FritzDeviceSensorMock(FritzDeviceBaseMock): lock = "fake_locked" present = True temperature = 1.23 + rel_humidity = 42 class FritzDeviceSwitchMock(FritzDeviceBaseMock): diff --git a/tests/components/fritzbox/test_sensor.py b/tests/components/fritzbox/test_sensor.py index 664b6765c03..f7a3ef9ae2a 100644 --- a/tests/components/fritzbox/test_sensor.py +++ b/tests/components/fritzbox/test_sensor.py @@ -49,6 +49,13 @@ async def test_setup(hass: HomeAssistant, fritz: Mock): assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == TEMP_CELSIUS assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_MEASUREMENT + state = hass.states.get(f"{ENTITY_ID}_humidity") + assert state + assert state.state == "42" + assert state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Humidity" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_MEASUREMENT + state = hass.states.get(f"{ENTITY_ID}_battery") assert state assert state.state == "23" diff --git a/tests/components/fritzbox/test_switch.py b/tests/components/fritzbox/test_switch.py index b44a4ffc088..fb7221262d3 100644 --- a/tests/components/fritzbox/test_switch.py +++ b/tests/components/fritzbox/test_switch.py @@ -64,6 +64,9 @@ async def test_setup(hass: HomeAssistant, fritz: Mock): assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == TEMP_CELSIUS assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_MEASUREMENT + state = hass.states.get(f"{ENTITY_ID}_humidity") + assert state is None + state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_power_consumption") assert state assert state.state == "5.678" From d3b1ccb66819988ec3131827bd2cdd649332b013 Mon Sep 17 00:00:00 2001 From: Oliver Ou Date: Thu, 30 Sep 2021 18:02:56 +0800 Subject: [PATCH 730/843] Tuya v2 Integration Release (#56820) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 乾启 <18442047+tsutsuku@users.noreply.github.com> Co-authored-by: dengweijun Co-authored-by: dengweijun Co-authored-by: erchuan Co-authored-by: erchuan Co-authored-by: Paulus Schoutsen --- .coveragerc | 2 +- CODEOWNERS | 2 +- homeassistant/components/tuya/__init__.py | 502 +++++--------- homeassistant/components/tuya/base.py | 76 +++ homeassistant/components/tuya/climate.py | 635 ++++++++++++------ homeassistant/components/tuya/config_flow.py | 463 +++---------- homeassistant/components/tuya/const.py | 58 +- homeassistant/components/tuya/cover.py | 118 ---- homeassistant/components/tuya/fan.py | 290 +++++--- homeassistant/components/tuya/light.py | 485 +++++++++---- homeassistant/components/tuya/manifest.json | 22 +- homeassistant/components/tuya/scene.py | 109 +-- homeassistant/components/tuya/services.yaml | 9 - homeassistant/components/tuya/strings.json | 69 +- homeassistant/components/tuya/switch.py | 185 +++-- .../components/tuya/translations/af.json | 8 - .../components/tuya/translations/ca.json | 65 -- .../components/tuya/translations/cs.json | 60 -- .../components/tuya/translations/de.json | 65 -- .../components/tuya/translations/en.json | 68 +- .../components/tuya/translations/es.json | 65 -- .../components/tuya/translations/et.json | 65 -- .../components/tuya/translations/fi.json | 17 - .../components/tuya/translations/fr.json | 65 -- .../components/tuya/translations/he.json | 30 - .../components/tuya/translations/hu.json | 65 -- .../components/tuya/translations/id.json | 65 -- .../components/tuya/translations/it.json | 65 -- .../components/tuya/translations/ka.json | 37 - .../components/tuya/translations/ko.json | 65 -- .../components/tuya/translations/lb.json | 59 -- .../components/tuya/translations/nl.json | 65 -- .../components/tuya/translations/no.json | 65 -- .../components/tuya/translations/pl.json | 65 -- .../components/tuya/translations/pt-BR.json | 17 - .../components/tuya/translations/pt.json | 25 - .../components/tuya/translations/ru.json | 65 -- .../components/tuya/translations/sl.json | 11 - .../components/tuya/translations/sv.json | 17 - .../components/tuya/translations/tr.json | 60 -- .../components/tuya/translations/uk.json | 63 -- .../components/tuya/translations/zh-Hans.json | 67 +- .../components/tuya/translations/zh-Hant.json | 65 -- homeassistant/generated/dhcp.py | 24 - requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/tuya/test_config_flow.py | 337 ++++------ 47 files changed, 1732 insertions(+), 3107 deletions(-) create mode 100644 homeassistant/components/tuya/base.py delete mode 100644 homeassistant/components/tuya/cover.py delete mode 100644 homeassistant/components/tuya/services.yaml delete mode 100644 homeassistant/components/tuya/translations/af.json delete mode 100644 homeassistant/components/tuya/translations/ca.json delete mode 100644 homeassistant/components/tuya/translations/cs.json delete mode 100644 homeassistant/components/tuya/translations/de.json delete mode 100644 homeassistant/components/tuya/translations/es.json delete mode 100644 homeassistant/components/tuya/translations/et.json delete mode 100644 homeassistant/components/tuya/translations/fi.json delete mode 100644 homeassistant/components/tuya/translations/fr.json delete mode 100644 homeassistant/components/tuya/translations/he.json delete mode 100644 homeassistant/components/tuya/translations/hu.json delete mode 100644 homeassistant/components/tuya/translations/id.json delete mode 100644 homeassistant/components/tuya/translations/it.json delete mode 100644 homeassistant/components/tuya/translations/ka.json delete mode 100644 homeassistant/components/tuya/translations/ko.json delete mode 100644 homeassistant/components/tuya/translations/lb.json delete mode 100644 homeassistant/components/tuya/translations/nl.json delete mode 100644 homeassistant/components/tuya/translations/no.json delete mode 100644 homeassistant/components/tuya/translations/pl.json delete mode 100644 homeassistant/components/tuya/translations/pt-BR.json delete mode 100644 homeassistant/components/tuya/translations/pt.json delete mode 100644 homeassistant/components/tuya/translations/ru.json delete mode 100644 homeassistant/components/tuya/translations/sl.json delete mode 100644 homeassistant/components/tuya/translations/sv.json delete mode 100644 homeassistant/components/tuya/translations/tr.json delete mode 100644 homeassistant/components/tuya/translations/uk.json delete mode 100644 homeassistant/components/tuya/translations/zh-Hant.json diff --git a/.coveragerc b/.coveragerc index e22611edf51..a5d7eec9115 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1115,9 +1115,9 @@ omit = homeassistant/components/transmission/errors.py homeassistant/components/travisci/sensor.py homeassistant/components/tuya/__init__.py + homeassistant/components/tuya/base.py homeassistant/components/tuya/climate.py homeassistant/components/tuya/const.py - homeassistant/components/tuya/cover.py homeassistant/components/tuya/fan.py homeassistant/components/tuya/light.py homeassistant/components/tuya/scene.py diff --git a/CODEOWNERS b/CODEOWNERS index 84b359182dd..515aec64774 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -544,7 +544,7 @@ homeassistant/components/trafikverket_train/* @endor-force homeassistant/components/trafikverket_weatherstation/* @endor-force homeassistant/components/transmission/* @engrbm87 @JPHutchins homeassistant/components/tts/* @pvizeli -homeassistant/components/tuya/* @ollo69 +homeassistant/components/tuya/* @Tuya homeassistant/components/twentemilieu/* @frenck homeassistant/components/twinkly/* @dr1rrb homeassistant/components/ubus/* @noltari diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index 7a639665948..c59e29ba348 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -1,376 +1,208 @@ +#!/usr/bin/env python3 """Support for Tuya Smart devices.""" -from datetime import timedelta + +import itertools import logging -from tuyaha import TuyaApi -from tuyaha.tuyaapi import ( - TuyaAPIException, - TuyaAPIRateLimitException, - TuyaFrequentlyInvokeException, - TuyaNetException, - TuyaServerException, +from tuya_iot import ( + ProjectType, + TuyaDevice, + TuyaDeviceListener, + TuyaDeviceManager, + TuyaHomeManager, + TuyaOpenAPI, + TuyaOpenMQ, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_PASSWORD, - CONF_PLATFORM, - CONF_USERNAME, - EVENT_HOMEASSISTANT_STOP, -) from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) -from homeassistant.helpers.entity import Entity -from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers import device_registry +from homeassistant.helpers.dispatcher import dispatcher_send from .const import ( - CONF_COUNTRYCODE, - CONF_DISCOVERY_INTERVAL, - CONF_QUERY_DEVICE, - CONF_QUERY_INTERVAL, - DEFAULT_DISCOVERY_INTERVAL, - DEFAULT_QUERY_INTERVAL, + CONF_ACCESS_ID, + CONF_ACCESS_SECRET, + CONF_APP_TYPE, + CONF_COUNTRY_CODE, + CONF_ENDPOINT, + CONF_PASSWORD, + CONF_PROJECT_TYPE, + CONF_USERNAME, DOMAIN, - SIGNAL_CONFIG_ENTITY, - SIGNAL_DELETE_ENTITY, - SIGNAL_UPDATE_ENTITY, - TUYA_DATA, - TUYA_DEVICES_CONF, + PLATFORMS, + TUYA_DEVICE_MANAGER, TUYA_DISCOVERY_NEW, - TUYA_PLATFORMS, - TUYA_TYPE_NOT_QUERY, + TUYA_HA_DEVICES, + TUYA_HA_SIGNAL_UPDATE_ENTITY, + TUYA_HA_TUYA_MAP, + TUYA_HOME_MANAGER, + TUYA_MQTT_LISTENER, ) _LOGGER = logging.getLogger(__name__) -ATTR_TUYA_DEV_ID = "tuya_device_id" -ENTRY_IS_SETUP = "tuya_entry_is_setup" - -SERVICE_FORCE_UPDATE = "force_update" -SERVICE_PULL_DEVICES = "pull_devices" - -TUYA_TYPE_TO_HA = { - "climate": "climate", - "cover": "cover", - "fan": "fan", - "light": "light", - "scene": "scene", - "switch": "switch", -} - -TUYA_TRACKER = "tuya_tracker" -STOP_CANCEL = "stop_event_cancel" - -CONFIG_SCHEMA = cv.deprecated(DOMAIN) - - -def _update_discovery_interval(hass, interval): - tuya = hass.data[DOMAIN].get(TUYA_DATA) - if not tuya: - return - - try: - tuya.discovery_interval = interval - _LOGGER.info("Tuya discovery device poll interval set to %s seconds", interval) - except ValueError as ex: - _LOGGER.warning(ex) - - -def _update_query_interval(hass, interval): - tuya = hass.data[DOMAIN].get(TUYA_DATA) - if not tuya: - return - - try: - tuya.query_interval = interval - _LOGGER.info("Tuya query device poll interval set to %s seconds", interval) - except ValueError as ex: - _LOGGER.warning(ex) - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up Tuya platform.""" + """Async setup hass config entry.""" - tuya = TuyaApi() - username = entry.data[CONF_USERNAME] - password = entry.data[CONF_PASSWORD] - country_code = entry.data[CONF_COUNTRYCODE] - platform = entry.data[CONF_PLATFORM] + _LOGGER.debug("tuya.__init__.async_setup_entry-->%s", entry.data) - try: - await hass.async_add_executor_job( - tuya.init, username, password, country_code, platform - ) - except ( - TuyaNetException, - TuyaServerException, - TuyaFrequentlyInvokeException, - ) as exc: - raise ConfigEntryNotReady() from exc + hass.data[DOMAIN] = {entry.entry_id: {TUYA_HA_TUYA_MAP: {}, TUYA_HA_DEVICES: set()}} - except TuyaAPIRateLimitException as exc: - raise ConfigEntryNotReady("Tuya login rate limited") from exc - - except TuyaAPIException as exc: - _LOGGER.error( - "Connection error during integration setup. Error: %s", - exc, - ) + success = await _init_tuya_sdk(hass, entry) + if not success: return False - domain_data = hass.data[DOMAIN] = { - TUYA_DATA: tuya, - TUYA_DEVICES_CONF: entry.options.copy(), - TUYA_TRACKER: None, - ENTRY_IS_SETUP: set(), - "entities": {}, - "pending": {}, - "listener": entry.add_update_listener(update_listener), - } - - _update_discovery_interval( - hass, entry.options.get(CONF_DISCOVERY_INTERVAL, DEFAULT_DISCOVERY_INTERVAL) - ) - - _update_query_interval( - hass, entry.options.get(CONF_QUERY_INTERVAL, DEFAULT_QUERY_INTERVAL) - ) - - async def async_load_devices(device_list): - """Load new devices by device_list.""" - device_type_list = {} - for device in device_list: - dev_type = device.device_type() - if ( - dev_type in TUYA_TYPE_TO_HA - and device.object_id() not in domain_data["entities"] - ): - ha_type = TUYA_TYPE_TO_HA[dev_type] - if ha_type not in device_type_list: - device_type_list[ha_type] = [] - device_type_list[ha_type].append(device.object_id()) - domain_data["entities"][device.object_id()] = None - - for ha_type, dev_ids in device_type_list.items(): - config_entries_key = f"{ha_type}.tuya" - if config_entries_key not in domain_data[ENTRY_IS_SETUP]: - domain_data["pending"][ha_type] = dev_ids - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, ha_type) - ) - domain_data[ENTRY_IS_SETUP].add(config_entries_key) - else: - async_dispatcher_send(hass, TUYA_DISCOVERY_NEW.format(ha_type), dev_ids) - - await async_load_devices(tuya.get_all_devices()) - - def _get_updated_devices(): - try: - tuya.poll_devices_update() - except TuyaFrequentlyInvokeException as exc: - _LOGGER.error(exc) - return tuya.get_all_devices() - - async def async_poll_devices_update(event_time): - """Check if accesstoken is expired and pull device list from server.""" - _LOGGER.debug("Pull devices from Tuya") - # Add new discover device. - device_list = await hass.async_add_executor_job(_get_updated_devices) - await async_load_devices(device_list) - # Delete not exist device. - newlist_ids = [] - for device in device_list: - newlist_ids.append(device.object_id()) - for dev_id in list(domain_data["entities"]): - if dev_id not in newlist_ids: - async_dispatcher_send(hass, SIGNAL_DELETE_ENTITY, dev_id) - domain_data["entities"].pop(dev_id) - - domain_data[TUYA_TRACKER] = async_track_time_interval( - hass, async_poll_devices_update, timedelta(minutes=2) - ) - - @callback - def _async_cancel_tuya_tracker(event): - domain_data[TUYA_TRACKER]() # pylint: disable=not-callable - - domain_data[STOP_CANCEL] = hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STOP, _async_cancel_tuya_tracker - ) - - hass.services.async_register( - DOMAIN, SERVICE_PULL_DEVICES, async_poll_devices_update - ) - - async def async_force_update(call): - """Force all devices to pull data.""" - async_dispatcher_send(hass, SIGNAL_UPDATE_ENTITY) - - hass.services.async_register(DOMAIN, SERVICE_FORCE_UPDATE, async_force_update) - return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): +async def _init_tuya_sdk(hass: HomeAssistant, entry: ConfigEntry) -> bool: + project_type = ProjectType(entry.data[CONF_PROJECT_TYPE]) + api = TuyaOpenAPI( + entry.data[CONF_ENDPOINT], + entry.data[CONF_ACCESS_ID], + entry.data[CONF_ACCESS_SECRET], + project_type, + ) + + api.set_dev_channel("hass") + + if project_type == ProjectType.INDUSTY_SOLUTIONS: + response = await hass.async_add_executor_job( + api.login, entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD] + ) + else: + response = await hass.async_add_executor_job( + api.login, + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + entry.data[CONF_COUNTRY_CODE], + entry.data[CONF_APP_TYPE], + ) + + if response.get("success", False) is False: + _LOGGER.error("Tuya login error response: %s", response) + return False + + tuya_mq = TuyaOpenMQ(api) + tuya_mq.start() + + device_manager = TuyaDeviceManager(api, tuya_mq) + + # Get device list + home_manager = TuyaHomeManager(api, tuya_mq, device_manager) + await hass.async_add_executor_job(home_manager.update_device_cache) + hass.data[DOMAIN][entry.entry_id][TUYA_HOME_MANAGER] = home_manager + + listener = DeviceListener(hass, entry) + hass.data[DOMAIN][entry.entry_id][TUYA_MQTT_LISTENER] = listener + device_manager.add_device_listener(listener) + hass.data[DOMAIN][entry.entry_id][TUYA_DEVICE_MANAGER] = device_manager + + # Clean up device entities + await cleanup_device_registry(hass, entry) + + _LOGGER.debug("init support type->%s", PLATFORMS) + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def cleanup_device_registry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Remove deleted device registry entry if there are no remaining entities.""" + + device_registry_object = device_registry.async_get(hass) + device_manager = hass.data[DOMAIN][entry.entry_id][TUYA_DEVICE_MANAGER] + + for dev_id, device_entry in list(device_registry_object.devices.items()): + for item in device_entry.identifiers: + if DOMAIN == item[0] and item[1] not in device_manager.device_map: + device_registry_object.async_remove_device(dev_id) + break + + +@callback +def async_remove_hass_device(hass: HomeAssistant, device_id: str) -> None: + """Remove device from hass cache.""" + device_registry_object = device_registry.async_get(hass) + for device_entry in list(device_registry_object.devices.values()): + if device_id in list(device_entry.identifiers)[0]: + device_registry_object.async_remove_device(device_entry.id) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unloading the Tuya platforms.""" - domain_data = hass.data[DOMAIN] - platforms = [platform.split(".", 1)[0] for platform in domain_data[ENTRY_IS_SETUP]] - unload_ok = await hass.config_entries.async_unload_platforms(entry, platforms) - if unload_ok: - domain_data["listener"]() - domain_data[STOP_CANCEL]() - domain_data[TUYA_TRACKER]() - hass.services.async_remove(DOMAIN, SERVICE_FORCE_UPDATE) - hass.services.async_remove(DOMAIN, SERVICE_PULL_DEVICES) + _LOGGER.debug("integration unload") + unload = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload: + device_manager = hass.data[DOMAIN][entry.entry_id][TUYA_DEVICE_MANAGER] + device_manager.mq.stop() + device_manager.remove_device_listener( + hass.data[DOMAIN][entry.entry_id][TUYA_MQTT_LISTENER] + ) + hass.data.pop(DOMAIN) - return unload_ok + return unload -async def update_listener(hass: HomeAssistant, entry: ConfigEntry): - """Update when config_entry options update.""" - hass.data[DOMAIN][TUYA_DEVICES_CONF] = entry.options.copy() - _update_discovery_interval( - hass, entry.options.get(CONF_DISCOVERY_INTERVAL, DEFAULT_DISCOVERY_INTERVAL) - ) - _update_query_interval( - hass, entry.options.get(CONF_QUERY_INTERVAL, DEFAULT_QUERY_INTERVAL) - ) - async_dispatcher_send(hass, SIGNAL_CONFIG_ENTITY) +class DeviceListener(TuyaDeviceListener): + """Device Update Listener.""" + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Init DeviceListener.""" -async def cleanup_device_registry(hass: HomeAssistant, device_id): - """Remove device registry entry if there are no remaining entities.""" + self.hass = hass + self.entry = entry - device_registry = await hass.helpers.device_registry.async_get_registry() - entity_registry = await hass.helpers.entity_registry.async_get_registry() - if device_id and not hass.helpers.entity_registry.async_entries_for_device( - entity_registry, device_id, include_disabled_entities=True - ): - device_registry.async_remove_device(device_id) - - -class TuyaDevice(Entity): - """Tuya base device.""" - - _dev_can_query_count = 0 - - def __init__(self, tuya, platform): - """Init Tuya devices.""" - self._tuya = tuya - self._tuya_platform = platform - - def _device_can_query(self): - """Check if device can also use query method.""" - dev_type = self._tuya.device_type() - return dev_type not in TUYA_TYPE_NOT_QUERY - - def _inc_device_count(self): - """Increment static variable device count.""" - if not self._device_can_query(): - return - TuyaDevice._dev_can_query_count += 1 - - def _dec_device_count(self): - """Decrement static variable device count.""" - if not self._device_can_query(): - return - TuyaDevice._dev_can_query_count -= 1 - - def _get_device_config(self): - """Get updated device options.""" - devices_config = self.hass.data[DOMAIN].get(TUYA_DEVICES_CONF) - if not devices_config: - return {} - dev_conf = devices_config.get(self.object_id, {}) - if dev_conf: + def update_device(self, device: TuyaDevice) -> None: + """Update device status.""" + if device.id in self.hass.data[DOMAIN][self.entry.entry_id][TUYA_HA_DEVICES]: _LOGGER.debug( - "Configuration for deviceID %s: %s", self.object_id, str(dev_conf) + "_update-->%s;->>%s", + self, + device.id, ) - return dev_conf + dispatcher_send(self.hass, f"{TUYA_HA_SIGNAL_UPDATE_ENTITY}_{device.id}") - async def async_added_to_hass(self): - """Call when entity is added to hass.""" - self.hass.data[DOMAIN]["entities"][self.object_id] = self.entity_id - self.async_on_remove( - async_dispatcher_connect( - self.hass, SIGNAL_DELETE_ENTITY, self._delete_callback - ) - ) - self.async_on_remove( - async_dispatcher_connect( - self.hass, SIGNAL_UPDATE_ENTITY, self._update_callback - ) - ) - self._inc_device_count() + def add_device(self, device: TuyaDevice) -> None: + """Add device added listener.""" + device_add = False - async def async_will_remove_from_hass(self): - """Call when entity is removed from hass.""" - self._dec_device_count() + if device.category in itertools.chain( + *self.hass.data[DOMAIN][self.entry.entry_id][TUYA_HA_TUYA_MAP].values() + ): + ha_tuya_map = self.hass.data[DOMAIN][self.entry.entry_id][TUYA_HA_TUYA_MAP] + self.hass.add_job(async_remove_hass_device, self.hass, device.id) - @property - def object_id(self): - """Return Tuya device id.""" - return self._tuya.object_id() + for domain, tuya_list in ha_tuya_map.items(): + if device.category in tuya_list: + device_add = True + _LOGGER.debug( + "Add device category->%s; domain-> %s", + device.category, + domain, + ) + self.hass.data[DOMAIN][self.entry.entry_id][TUYA_HA_DEVICES].add( + device.id + ) + dispatcher_send( + self.hass, TUYA_DISCOVERY_NEW.format(domain), [device.id] + ) - @property - def unique_id(self): - """Return a unique ID.""" - return f"tuya.{self._tuya.object_id()}" + if device_add: + device_manager = self.hass.data[DOMAIN][self.entry.entry_id][ + TUYA_DEVICE_MANAGER + ] + device_manager.mq.stop() + tuya_mq = TuyaOpenMQ(device_manager.api) + tuya_mq.start() - @property - def name(self): - """Return Tuya device name.""" - return self._tuya.name() + device_manager.mq = tuya_mq + tuya_mq.add_message_listener(device_manager.on_message) - @property - def available(self): - """Return if the device is available.""" - return self._tuya.available() - - @property - def device_info(self): - """Return a device description for device registry.""" - _device_info = { - "identifiers": {(DOMAIN, f"{self.unique_id}")}, - "manufacturer": TUYA_PLATFORMS.get( - self._tuya_platform, self._tuya_platform - ), - "name": self.name, - "model": self._tuya.object_type(), - } - return _device_info - - def update(self): - """Refresh Tuya device data.""" - query_dev = self.hass.data[DOMAIN][TUYA_DEVICES_CONF].get(CONF_QUERY_DEVICE, "") - use_discovery = ( - TuyaDevice._dev_can_query_count > 1 and self.object_id != query_dev - ) - try: - self._tuya.update(use_discovery=use_discovery) - except TuyaFrequentlyInvokeException as exc: - _LOGGER.error(exc) - - async def _delete_callback(self, dev_id): - """Remove this entity.""" - if dev_id == self.object_id: - entity_registry = ( - await self.hass.helpers.entity_registry.async_get_registry() - ) - if entity_registry.async_is_registered(self.entity_id): - entity_entry = entity_registry.async_get(self.entity_id) - entity_registry.async_remove(self.entity_id) - await cleanup_device_registry(self.hass, entity_entry.device_id) - else: - await self.async_remove(force_remove=True) - - @callback - def _update_callback(self): - """Call update method.""" - self.async_schedule_update_ha_state(True) + def remove_device(self, device_id: str) -> None: + """Add device removed listener.""" + _LOGGER.debug("tuya remove device:%s", device_id) + self.hass.add_job(async_remove_hass_device, self.hass, device_id) diff --git a/homeassistant/components/tuya/base.py b/homeassistant/components/tuya/base.py new file mode 100644 index 00000000000..572c452a920 --- /dev/null +++ b/homeassistant/components/tuya/base.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Tuya Home Assistant Base Device Model.""" +from __future__ import annotations + +from typing import Any + +from tuya_iot import TuyaDevice, TuyaDeviceManager + +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN, TUYA_HA_SIGNAL_UPDATE_ENTITY + + +class TuyaHaEntity(Entity): + """Tuya base device.""" + + def __init__(self, device: TuyaDevice, device_manager: TuyaDeviceManager) -> None: + """Init TuyaHaEntity.""" + super().__init__() + + self.tuya_device = device + self.tuya_device_manager = device_manager + + @staticmethod + def remap(old_value, old_min, old_max, new_min, new_max): + """Remap old_value to new_value.""" + new_value = ((old_value - old_min) / (old_max - old_min)) * ( + new_max - new_min + ) + new_min + return new_value + + @property + def should_poll(self) -> bool: + """Hass should not poll.""" + return False + + @property + def unique_id(self) -> str | None: + """Return a unique ID.""" + return f"tuya.{self.tuya_device.id}" + + @property + def name(self) -> str | None: + """Return Tuya device name.""" + return self.tuya_device.name + + @property + def device_info(self): + """Return a device description for device registry.""" + _device_info = { + "identifiers": {(DOMAIN, f"{self.tuya_device.id}")}, + "manufacturer": "Tuya", + "name": self.tuya_device.name, + "model": self.tuya_device.product_name, + } + return _device_info + + @property + def available(self) -> bool: + """Return if the device is available.""" + return self.tuya_device.online + + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{TUYA_HA_SIGNAL_UPDATE_ENTITY}_{self.tuya_device.id}", + self.async_write_ha_state, + ) + ) + + def _send_command(self, commands: list[dict[str, Any]]) -> None: + """Send command to the device.""" + self.tuya_device_manager.send_commands(self.tuya_device.id, commands) diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py index 73ba69da797..368a65b8499 100644 --- a/homeassistant/components/tuya/climate.py +++ b/homeassistant/components/tuya/climate.py @@ -1,261 +1,484 @@ -"""Support for the Tuya climate devices.""" -from datetime import timedelta +"""Support for Tuya Climate.""" -from homeassistant.components.climate import ( - DOMAIN as SENSOR_DOMAIN, - ENTITY_ID_FORMAT, - ClimateEntity, -) +from __future__ import annotations + +import json +import logging +from typing import Any + +from tuya_iot import TuyaDevice, TuyaDeviceManager + +from homeassistant.components.climate import DOMAIN as DEVICE_DOMAIN, ClimateEntity from homeassistant.components.climate.const import ( - FAN_HIGH, - FAN_LOW, - FAN_MEDIUM, HVAC_MODE_AUTO, HVAC_MODE_COOL, + HVAC_MODE_DRY, HVAC_MODE_FAN_ONLY, HVAC_MODE_HEAT, HVAC_MODE_OFF, SUPPORT_FAN_MODE, + SUPPORT_SWING_MODE, + SUPPORT_TARGET_HUMIDITY, SUPPORT_TARGET_TEMPERATURE, ) -from homeassistant.const import ( - ATTR_TEMPERATURE, - CONF_PLATFORM, - CONF_UNIT_OF_MEASUREMENT, - TEMP_CELSIUS, - TEMP_FAHRENHEIT, -) -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import TuyaDevice +from .base import TuyaHaEntity from .const import ( - CONF_CURR_TEMP_DIVIDER, - CONF_MAX_TEMP, - CONF_MIN_TEMP, - CONF_SET_TEMP_DIVIDED, - CONF_TEMP_DIVIDER, - CONF_TEMP_STEP_OVERRIDE, DOMAIN, - SIGNAL_CONFIG_ENTITY, - TUYA_DATA, + TUYA_DEVICE_MANAGER, TUYA_DISCOVERY_NEW, + TUYA_HA_DEVICES, + TUYA_HA_TUYA_MAP, ) -DEVICE_TYPE = "climate" +_LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = timedelta(seconds=15) -HA_STATE_TO_TUYA = { - HVAC_MODE_AUTO: "auto", - HVAC_MODE_COOL: "cold", - HVAC_MODE_FAN_ONLY: "wind", - HVAC_MODE_HEAT: "hot", +# Air Conditioner +# https://developer.tuya.com/en/docs/iot/f?id=K9gf46qujdmwb +DPCODE_SWITCH = "switch" +DPCODE_TEMP_SET = "temp_set" +DPCODE_TEMP_SET_F = "temp_set_f" +DPCODE_MODE = "mode" +DPCODE_HUMIDITY_SET = "humidity_set" +DPCODE_FAN_SPEED_ENUM = "fan_speed_enum" + +# Temperature unit +DPCODE_TEMP_UNIT_CONVERT = "temp_unit_convert" +DPCODE_C_F = "c_f" + +# swing flap switch +DPCODE_SWITCH_HORIZONTAL = "switch_horizontal" +DPCODE_SWITCH_VERTICAL = "switch_vertical" + +# status +DPCODE_TEMP_CURRENT = "temp_current" +DPCODE_TEMP_CURRENT_F = "temp_current_f" +DPCODE_HUMIDITY_CURRENT = "humidity_current" + +SWING_OFF = "swing_off" +SWING_VERTICAL = "swing_vertical" +SWING_HORIZONTAL = "swing_horizontal" +SWING_BOTH = "swing_both" + +DEFAULT_MIN_TEMP = 7 +DEFAULT_MAX_TEMP = 35 + +TUYA_HVAC_TO_HA = { + "hot": HVAC_MODE_HEAT, + "cold": HVAC_MODE_COOL, + "wet": HVAC_MODE_DRY, + "wind": HVAC_MODE_FAN_ONLY, + "auto": HVAC_MODE_AUTO, } -TUYA_STATE_TO_HA = {value: key for key, value in HA_STATE_TO_TUYA.items()} - -FAN_MODES = {FAN_LOW, FAN_MEDIUM, FAN_HIGH} +TUYA_SUPPORT_TYPE = { + "kt", # Air conditioner + "qn", # Heater + "wk", # Thermostat +} -async def async_setup_entry(hass, config_entry, async_add_entities): - """Set up tuya sensors dynamically through tuya discovery.""" +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up tuya climate dynamically through tuya discovery.""" + _LOGGER.debug("climate init") - platform = config_entry.data[CONF_PLATFORM] + hass.data[DOMAIN][entry.entry_id][TUYA_HA_TUYA_MAP][ + DEVICE_DOMAIN + ] = TUYA_SUPPORT_TYPE - async def async_discover_sensor(dev_ids): - """Discover and add a discovered tuya sensor.""" + @callback + def async_discover_device(dev_ids: list[str]) -> None: + """Discover and add a discovered tuya climate.""" + _LOGGER.debug("climate add-> %s", dev_ids) if not dev_ids: return - entities = await hass.async_add_executor_job( - _setup_entities, - hass, - dev_ids, - platform, - ) + entities = _setup_entities(hass, entry, dev_ids) async_add_entities(entities) - async_dispatcher_connect( - hass, TUYA_DISCOVERY_NEW.format(SENSOR_DOMAIN), async_discover_sensor + entry.async_on_unload( + async_dispatcher_connect( + hass, TUYA_DISCOVERY_NEW.format(DEVICE_DOMAIN), async_discover_device + ) ) - devices_ids = hass.data[DOMAIN]["pending"].pop(SENSOR_DOMAIN) - await async_discover_sensor(devices_ids) + device_manager = hass.data[DOMAIN][entry.entry_id][TUYA_DEVICE_MANAGER] + device_ids = [] + for (device_id, device) in device_manager.device_map.items(): + if device.category in TUYA_SUPPORT_TYPE: + device_ids.append(device_id) + async_discover_device(device_ids) -def _setup_entities(hass, dev_ids, platform): - """Set up Tuya Climate device.""" - tuya = hass.data[DOMAIN][TUYA_DATA] - entities = [] - for dev_id in dev_ids: - device = tuya.get_device_by_id(dev_id) +def _setup_entities( + hass: HomeAssistant, entry: ConfigEntry, device_ids: list[str] +) -> list[Entity]: + """Set up Tuya Climate.""" + device_manager = hass.data[DOMAIN][entry.entry_id][TUYA_DEVICE_MANAGER] + entities: list[Entity] = [] + for device_id in device_ids: + device = device_manager.device_map[device_id] if device is None: continue - entities.append(TuyaClimateEntity(device, platform)) + entities.append(TuyaHaClimate(device, device_manager)) + hass.data[DOMAIN][entry.entry_id][TUYA_HA_DEVICES].add(device_id) return entities -class TuyaClimateEntity(TuyaDevice, ClimateEntity): - """Tuya climate devices,include air conditioner,heater.""" +class TuyaHaClimate(TuyaHaEntity, ClimateEntity): + """Tuya Switch Device.""" - def __init__(self, tuya, platform): - """Init climate device.""" - super().__init__(tuya, platform) - self.entity_id = ENTITY_ID_FORMAT.format(tuya.object_id()) - self.operations = [HVAC_MODE_OFF] - self._has_operation = False - self._def_hvac_mode = HVAC_MODE_AUTO - self._set_temp_divided = True - self._temp_step_override = None - self._min_temp = None - self._max_temp = None - - @callback - def _process_config(self): - """Set device config parameter.""" - config = self._get_device_config() - if not config: - return - unit = config.get(CONF_UNIT_OF_MEASUREMENT) - if unit: - self._tuya.set_unit("FAHRENHEIT" if unit == TEMP_FAHRENHEIT else "CELSIUS") - self._tuya.temp_divider = config.get(CONF_TEMP_DIVIDER, 0) - self._tuya.curr_temp_divider = config.get(CONF_CURR_TEMP_DIVIDER, 0) - self._set_temp_divided = config.get(CONF_SET_TEMP_DIVIDED, True) - self._temp_step_override = config.get(CONF_TEMP_STEP_OVERRIDE) - min_temp = config.get(CONF_MIN_TEMP, 0) - max_temp = config.get(CONF_MAX_TEMP, 0) - if min_temp >= max_temp: - self._min_temp = self._max_temp = None + def __init__(self, device: TuyaDevice, device_manager: TuyaDeviceManager) -> None: + """Init Tuya Ha Climate.""" + super().__init__(device, device_manager) + if DPCODE_C_F in self.tuya_device.status: + self.dp_temp_unit = DPCODE_C_F else: - self._min_temp = min_temp - self._max_temp = max_temp + self.dp_temp_unit = DPCODE_TEMP_UNIT_CONVERT - async def async_added_to_hass(self): - """Create operation list when add to hass.""" - await super().async_added_to_hass() - self._process_config() - self.async_on_remove( - async_dispatcher_connect( - self.hass, SIGNAL_CONFIG_ENTITY, self._process_config - ) - ) - - modes = self._tuya.operation_list() - if modes is None: - if self._def_hvac_mode not in self.operations: - self.operations.append(self._def_hvac_mode) - return - - for mode in modes: - if mode not in TUYA_STATE_TO_HA: - continue - ha_mode = TUYA_STATE_TO_HA[mode] - if ha_mode not in self.operations: - self.operations.append(ha_mode) - self._has_operation = True - - @property - def temperature_unit(self): - """Return the unit of measurement used by the platform.""" - unit = self._tuya.temperature_unit() - if unit == "FAHRENHEIT": - return TEMP_FAHRENHEIT - return TEMP_CELSIUS - - @property - def hvac_mode(self): - """Return current operation ie. heat, cool, idle.""" - if not self._tuya.state(): - return HVAC_MODE_OFF - - if not self._has_operation: - return self._def_hvac_mode - - mode = self._tuya.current_operation() - if mode is None: + def get_temp_set_scale(self) -> int | None: + """Get temperature set scale.""" + dp_temp_set = DPCODE_TEMP_SET if self.is_celsius() else DPCODE_TEMP_SET_F + temp_set_value_range_item = self.tuya_device.status_range.get(dp_temp_set) + if not temp_set_value_range_item: return None - return TUYA_STATE_TO_HA.get(mode) - @property - def hvac_modes(self): - """Return the list of available operation modes.""" - return self.operations + temp_set_value_range = json.loads(temp_set_value_range_item.values) + return temp_set_value_range.get("scale") - @property - def current_temperature(self): - """Return the current temperature.""" - return self._tuya.current_temperature() + def get_temp_current_scale(self) -> int | None: + """Get temperature current scale.""" + dp_temp_current = ( + DPCODE_TEMP_CURRENT if self.is_celsius() else DPCODE_TEMP_CURRENT_F + ) + temp_current_value_range_item = self.tuya_device.status_range.get( + dp_temp_current + ) + if not temp_current_value_range_item: + return None - @property - def target_temperature(self): - """Return the temperature we try to reach.""" - return self._tuya.target_temperature() + temp_current_value_range = json.loads(temp_current_value_range_item.values) + return temp_current_value_range.get("scale") - @property - def target_temperature_step(self): - """Return the supported step of target temperature.""" - if self._temp_step_override: - return self._temp_step_override - return self._tuya.target_temperature_step() + # Functions - @property - def fan_mode(self): - """Return the fan setting.""" - return self._tuya.current_fan_mode() - - @property - def fan_modes(self): - """Return the list of available fan modes.""" - return self._tuya.fan_list() - - def set_temperature(self, **kwargs): - """Set new target temperature.""" - if ATTR_TEMPERATURE in kwargs: - self._tuya.set_temperature(kwargs[ATTR_TEMPERATURE], self._set_temp_divided) - - def set_fan_mode(self, fan_mode): - """Set new target fan mode.""" - self._tuya.set_fan_mode(fan_mode) - - def set_hvac_mode(self, hvac_mode): - """Set new target operation mode.""" + def set_hvac_mode(self, hvac_mode: str) -> None: + """Set new target hvac mode.""" + commands = [] if hvac_mode == HVAC_MODE_OFF: - self._tuya.turn_off() + commands.append({"code": DPCODE_SWITCH, "value": False}) + else: + commands.append({"code": DPCODE_SWITCH, "value": True}) + + for tuya_mode, ha_mode in TUYA_HVAC_TO_HA.items(): + if ha_mode == hvac_mode: + commands.append({"code": DPCODE_MODE, "value": tuya_mode}) + break + + self._send_command(commands) + + def set_fan_mode(self, fan_mode: str) -> None: + """Set new target fan mode.""" + self._send_command([{"code": DPCODE_FAN_SPEED_ENUM, "value": fan_mode}]) + + def set_humidity(self, humidity: float) -> None: + """Set new target humidity.""" + self._send_command([{"code": DPCODE_HUMIDITY_SET, "value": int(humidity)}]) + + def set_swing_mode(self, swing_mode: str) -> None: + """Set new target swing operation.""" + if swing_mode == SWING_BOTH: + commands = [ + {"code": DPCODE_SWITCH_VERTICAL, "value": True}, + {"code": DPCODE_SWITCH_HORIZONTAL, "value": True}, + ] + elif swing_mode == SWING_HORIZONTAL: + commands = [ + {"code": DPCODE_SWITCH_VERTICAL, "value": False}, + {"code": DPCODE_SWITCH_HORIZONTAL, "value": True}, + ] + elif swing_mode == SWING_VERTICAL: + commands = [ + {"code": DPCODE_SWITCH_VERTICAL, "value": True}, + {"code": DPCODE_SWITCH_HORIZONTAL, "value": False}, + ] + else: + commands = [ + {"code": DPCODE_SWITCH_VERTICAL, "value": False}, + {"code": DPCODE_SWITCH_HORIZONTAL, "value": False}, + ] + + self._send_command(commands) + + def set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + _LOGGER.debug("climate temp-> %s", kwargs) + code = DPCODE_TEMP_SET if self.is_celsius() else DPCODE_TEMP_SET_F + temp_set_scale = self.get_temp_set_scale() + if not temp_set_scale: return - if not self._tuya.state(): - self._tuya.turn_on() - - if self._has_operation: - self._tuya.set_operation_mode(HA_STATE_TO_TUYA.get(hvac_mode)) - - @property - def supported_features(self): - """Return the list of supported features.""" - supports = 0 - if self._tuya.support_target_temperature(): - supports = supports | SUPPORT_TARGET_TEMPERATURE - if self._tuya.support_wind_speed(): - supports = supports | SUPPORT_FAN_MODE - return supports - - @property - def min_temp(self): - """Return the minimum temperature.""" - min_temp = ( - self._min_temp if self._min_temp is not None else self._tuya.min_temp() + self._send_command( + [ + { + "code": code, + "value": int(kwargs["temperature"] * (10 ** temp_set_scale)), + } + ] ) - if min_temp is not None: - return min_temp - return super().min_temp + + def is_celsius(self) -> bool: + """Return True if device reports in Celsius.""" + if ( + self.dp_temp_unit in self.tuya_device.status + and self.tuya_device.status.get(self.dp_temp_unit).lower() == "c" + ): + return True + if ( + DPCODE_TEMP_SET in self.tuya_device.status + or DPCODE_TEMP_CURRENT in self.tuya_device.status + ): + return True + return False @property - def max_temp(self): + def temperature_unit(self) -> str: + """Return true if fan is on.""" + if self.is_celsius(): + return TEMP_CELSIUS + return TEMP_FAHRENHEIT + + @property + def current_temperature(self) -> float | None: + """Return the current temperature.""" + if ( + DPCODE_TEMP_CURRENT not in self.tuya_device.status + and DPCODE_TEMP_CURRENT_F not in self.tuya_device.status + ): + return None + + temp_current_scale = self.get_temp_current_scale() + if not temp_current_scale: + return None + + if self.is_celsius(): + temperature = self.tuya_device.status.get(DPCODE_TEMP_CURRENT) + if not temperature: + return None + return temperature * 1.0 / (10 ** temp_current_scale) + + temperature = self.tuya_device.status.get(DPCODE_TEMP_CURRENT_F) + if not temperature: + return None + return temperature * 1.0 / (10 ** temp_current_scale) + + @property + def current_humidity(self) -> int: + """Return the current humidity.""" + return int(self.tuya_device.status.get(DPCODE_HUMIDITY_CURRENT, 0)) + + @property + def target_temperature(self) -> float | None: + """Return the temperature currently set to be reached.""" + temp_set_scale = self.get_temp_set_scale() + if temp_set_scale is None: + return None + + dpcode_temp_set = self.tuya_device.status.get(DPCODE_TEMP_SET) + if dpcode_temp_set is None: + return None + + return dpcode_temp_set * 1.0 / (10 ** temp_set_scale) + + @property + def max_temp(self) -> float: """Return the maximum temperature.""" - max_temp = ( - self._max_temp if self._max_temp is not None else self._tuya.max_temp() + scale = self.get_temp_set_scale() + if scale is None: + return DEFAULT_MAX_TEMP + + if self.is_celsius(): + if DPCODE_TEMP_SET not in self.tuya_device.function: + return DEFAULT_MAX_TEMP + + function_item = self.tuya_device.function.get(DPCODE_TEMP_SET) + if function_item is None: + return DEFAULT_MAX_TEMP + + temp_value = json.loads(function_item.values) + + temp_max = temp_value.get("max") + if temp_max is None: + return DEFAULT_MAX_TEMP + return temp_max * 1.0 / (10 ** scale) + if DPCODE_TEMP_SET_F not in self.tuya_device.function: + return DEFAULT_MAX_TEMP + + function_item_f = self.tuya_device.function.get(DPCODE_TEMP_SET_F) + if function_item_f is None: + return DEFAULT_MAX_TEMP + + temp_value_f = json.loads(function_item_f.values) + + temp_max_f = temp_value_f.get("max") + if temp_max_f is None: + return DEFAULT_MAX_TEMP + return temp_max_f * 1.0 / (10 ** scale) + + @property + def min_temp(self) -> float: + """Return the minimum temperature.""" + temp_set_scal = self.get_temp_set_scale() + if temp_set_scal is None: + return DEFAULT_MIN_TEMP + + if self.is_celsius(): + if DPCODE_TEMP_SET not in self.tuya_device.function: + return DEFAULT_MIN_TEMP + + function_temp_item = self.tuya_device.function.get(DPCODE_TEMP_SET) + if function_temp_item is None: + return DEFAULT_MIN_TEMP + temp_value = json.loads(function_temp_item.values) + temp_min = temp_value.get("min") + if temp_min is None: + return DEFAULT_MIN_TEMP + return temp_min * 1.0 / (10 ** temp_set_scal) + + if DPCODE_TEMP_SET_F not in self.tuya_device.function: + return DEFAULT_MIN_TEMP + + temp_value_temp_f = self.tuya_device.function.get(DPCODE_TEMP_SET_F) + if temp_value_temp_f is None: + return DEFAULT_MIN_TEMP + temp_value_f = json.loads(temp_value_temp_f.values) + + temp_min_f = temp_value_f.get("min") + if temp_min_f is None: + return DEFAULT_MIN_TEMP + + return temp_min_f * 1.0 / (10 ** temp_set_scal) + + @property + def target_temperature_step(self) -> float | None: + """Return target temperature setp.""" + if ( + DPCODE_TEMP_SET not in self.tuya_device.status_range + and DPCODE_TEMP_SET_F not in self.tuya_device.status_range + ): + return 1.0 + temp_set_value_range = json.loads( + self.tuya_device.status_range.get( + DPCODE_TEMP_SET if self.is_celsius() else DPCODE_TEMP_SET_F + ).values ) - if max_temp is not None: - return max_temp - return super().max_temp + step = temp_set_value_range.get("step") + if step is None: + return None + + temp_set_scale = self.get_temp_set_scale() + if temp_set_scale is None: + return None + + return step * 1.0 / (10 ** temp_set_scale) + + @property + def target_humidity(self) -> int: + """Return target humidity.""" + return int(self.tuya_device.status.get(DPCODE_HUMIDITY_SET, 0)) + + @property + def hvac_mode(self) -> str: + """Return hvac mode.""" + if not self.tuya_device.status.get(DPCODE_SWITCH, False): + return HVAC_MODE_OFF + if DPCODE_MODE not in self.tuya_device.status: + return HVAC_MODE_OFF + if self.tuya_device.status.get(DPCODE_MODE) is not None: + return TUYA_HVAC_TO_HA[self.tuya_device.status[DPCODE_MODE]] + return HVAC_MODE_OFF + + @property + def hvac_modes(self) -> list[str]: + """Return hvac modes for select.""" + if DPCODE_MODE not in self.tuya_device.function: + return [] + modes = json.loads(self.tuya_device.function.get(DPCODE_MODE, {}).values).get( + "range" + ) + + hvac_modes = [HVAC_MODE_OFF] + for tuya_mode, ha_mode in TUYA_HVAC_TO_HA.items(): + if tuya_mode in modes: + hvac_modes.append(ha_mode) + + return hvac_modes + + @property + def fan_mode(self) -> str | None: + """Return fan mode.""" + return self.tuya_device.status.get(DPCODE_FAN_SPEED_ENUM) + + @property + def fan_modes(self) -> list[str]: + """Return fan modes for select.""" + data = json.loads( + self.tuya_device.function.get(DPCODE_FAN_SPEED_ENUM, {}).values + ).get("range") + return data + + @property + def swing_mode(self) -> str: + """Return swing mode.""" + mode = 0 + if ( + DPCODE_SWITCH_HORIZONTAL in self.tuya_device.status + and self.tuya_device.status.get(DPCODE_SWITCH_HORIZONTAL) + ): + mode += 1 + if ( + DPCODE_SWITCH_VERTICAL in self.tuya_device.status + and self.tuya_device.status.get(DPCODE_SWITCH_VERTICAL) + ): + mode += 2 + + if mode == 3: + return SWING_BOTH + if mode == 2: + return SWING_VERTICAL + if mode == 1: + return SWING_HORIZONTAL + return SWING_OFF + + @property + def swing_modes(self) -> list[str]: + """Return swing mode for select.""" + return [SWING_OFF, SWING_HORIZONTAL, SWING_VERTICAL, SWING_BOTH] + + @property + def supported_features(self) -> int: + """Flag supported features.""" + supports = 0 + if ( + DPCODE_TEMP_SET in self.tuya_device.status + or DPCODE_TEMP_SET_F in self.tuya_device.status + ): + supports |= SUPPORT_TARGET_TEMPERATURE + if DPCODE_FAN_SPEED_ENUM in self.tuya_device.status: + supports |= SUPPORT_FAN_MODE + if DPCODE_HUMIDITY_SET in self.tuya_device.status: + supports |= SUPPORT_TARGET_HUMIDITY + if ( + DPCODE_SWITCH_HORIZONTAL in self.tuya_device.status + or DPCODE_SWITCH_VERTICAL in self.tuya_device.status + ): + supports |= SUPPORT_SWING_MODE + return supports diff --git a/homeassistant/components/tuya/config_flow.py b/homeassistant/components/tuya/config_flow.py index 476a2295fc4..9761b1b6c96 100644 --- a/homeassistant/components/tuya/config_flow.py +++ b/homeassistant/components/tuya/config_flow.py @@ -1,409 +1,140 @@ +#!/usr/bin/env python3 """Config flow for Tuya.""" -from __future__ import annotations import logging -from typing import Any -from tuyaha import TuyaApi -from tuyaha.tuyaapi import ( - TuyaAPIException, - TuyaAPIRateLimitException, - TuyaNetException, - TuyaServerException, -) +from tuya_iot import ProjectType, TuyaOpenAPI import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import ( - CONF_PASSWORD, - CONF_PLATFORM, - CONF_UNIT_OF_MEASUREMENT, - CONF_USERNAME, - ENTITY_MATCH_NONE, - TEMP_CELSIUS, - TEMP_FAHRENHEIT, -) -from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv from .const import ( - CONF_BRIGHTNESS_RANGE_MODE, - CONF_COUNTRYCODE, - CONF_CURR_TEMP_DIVIDER, - CONF_DISCOVERY_INTERVAL, - CONF_MAX_KELVIN, - CONF_MAX_TEMP, - CONF_MIN_KELVIN, - CONF_MIN_TEMP, - CONF_QUERY_DEVICE, - CONF_QUERY_INTERVAL, - CONF_SET_TEMP_DIVIDED, - CONF_SUPPORT_COLOR, - CONF_TEMP_DIVIDER, - CONF_TEMP_STEP_OVERRIDE, - CONF_TUYA_MAX_COLTEMP, - DEFAULT_DISCOVERY_INTERVAL, - DEFAULT_QUERY_INTERVAL, - DEFAULT_TUYA_MAX_COLTEMP, + CONF_ACCESS_ID, + CONF_ACCESS_SECRET, + CONF_APP_TYPE, + CONF_COUNTRY_CODE, + CONF_ENDPOINT, + CONF_PASSWORD, + CONF_PROJECT_TYPE, + CONF_USERNAME, DOMAIN, - TUYA_DATA, - TUYA_PLATFORMS, - TUYA_TYPE_NOT_QUERY, + TUYA_APP_TYPE, + TUYA_ENDPOINT, + TUYA_PROJECT_TYPE, ) +RESULT_SINGLE_INSTANCE = "single_instance_allowed" +RESULT_AUTH_FAILED = "invalid_auth" +TUYA_ENDPOINT_BASE = "https://openapi.tuyacn.com" + _LOGGER = logging.getLogger(__name__) -CONF_LIST_DEVICES = "list_devices" +# Project Type +DATA_SCHEMA_PROJECT_TYPE = vol.Schema( + {vol.Required(CONF_PROJECT_TYPE, default=0): vol.In(TUYA_PROJECT_TYPE)} +) -DATA_SCHEMA_USER = vol.Schema( +# INDUSTY_SOLUTIONS Schema +DATA_SCHEMA_INDUSTRY_SOLUTIONS = vol.Schema( { + vol.Required(CONF_ENDPOINT): vol.In(TUYA_ENDPOINT), + vol.Required(CONF_ACCESS_ID): str, + vol.Required(CONF_ACCESS_SECRET): str, vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str, - vol.Required(CONF_COUNTRYCODE): vol.Coerce(int), - vol.Required(CONF_PLATFORM): vol.In(TUYA_PLATFORMS), } ) -ERROR_DEV_MULTI_TYPE = "dev_multi_type" -ERROR_DEV_NOT_CONFIG = "dev_not_config" -ERROR_DEV_NOT_FOUND = "dev_not_found" - -RESULT_AUTH_FAILED = "invalid_auth" -RESULT_CONN_ERROR = "cannot_connect" -RESULT_SINGLE_INSTANCE = "single_instance_allowed" -RESULT_SUCCESS = "success" - -RESULT_LOG_MESSAGE = { - RESULT_AUTH_FAILED: "Invalid credential", - RESULT_CONN_ERROR: "Connection error", -} - -TUYA_TYPE_CONFIG = ["climate", "light"] +# SMART_HOME Schema +DATA_SCHEMA_SMART_HOME = vol.Schema( + { + vol.Required(CONF_ACCESS_ID): str, + vol.Required(CONF_ACCESS_SECRET): str, + vol.Required(CONF_APP_TYPE): vol.In(TUYA_APP_TYPE), + vol.Required(CONF_COUNTRY_CODE): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) class TuyaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): - """Handle a tuya config flow.""" - - VERSION = 1 + """Tuya Config Flow.""" def __init__(self) -> None: - """Initialize flow.""" - self._country_code = None - self._password = None - self._platform = None - self._username = None + """Init tuya config flow.""" + super().__init__() + self.conf_project_type = None - def _save_entry(self): - return self.async_create_entry( - title=self._username, - data={ - CONF_COUNTRYCODE: self._country_code, - CONF_PASSWORD: self._password, - CONF_PLATFORM: self._platform, - CONF_USERNAME: self._username, - }, + @staticmethod + def _try_login(user_input): + project_type = ProjectType(user_input[CONF_PROJECT_TYPE]) + api = TuyaOpenAPI( + user_input[CONF_ENDPOINT] + if project_type == ProjectType.INDUSTY_SOLUTIONS + else "", + user_input[CONF_ACCESS_ID], + user_input[CONF_ACCESS_SECRET], + project_type, ) + api.set_dev_channel("hass") - def _try_connect(self): - """Try to connect and check auth.""" - tuya = TuyaApi() - try: - tuya.init( - self._username, self._password, self._country_code, self._platform + if project_type == ProjectType.INDUSTY_SOLUTIONS: + response = api.login(user_input[CONF_USERNAME], user_input[CONF_PASSWORD]) + else: + api.endpoint = TUYA_ENDPOINT_BASE + response = api.login( + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + user_input[CONF_COUNTRY_CODE], + user_input[CONF_APP_TYPE], ) - except (TuyaAPIRateLimitException, TuyaNetException, TuyaServerException): - return RESULT_CONN_ERROR - except TuyaAPIException: - return RESULT_AUTH_FAILED + if response.get("success", False) and isinstance( + api.token_info.platform_url, str + ): + api.endpoint = api.token_info.platform_url + user_input[CONF_ENDPOINT] = api.token_info.platform_url - return RESULT_SUCCESS + _LOGGER.debug("TuyaConfigFlow._try_login finish, response:, %s", response) + return response async def async_step_user(self, user_input=None): - """Handle a flow initialized by the user.""" - if self._async_current_entries(): - return self.async_abort(reason=RESULT_SINGLE_INSTANCE) + """Step user.""" + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA_PROJECT_TYPE + ) + self.conf_project_type = user_input[CONF_PROJECT_TYPE] + + return await self.async_step_login() + + async def async_step_login(self, user_input=None): + """Step login.""" errors = {} - if user_input is not None: + assert self.conf_project_type is not None + user_input[CONF_PROJECT_TYPE] = self.conf_project_type - self._country_code = str(user_input[CONF_COUNTRYCODE]) - self._password = user_input[CONF_PASSWORD] - self._platform = user_input[CONF_PLATFORM] - self._username = user_input[CONF_USERNAME] - - result = await self.hass.async_add_executor_job(self._try_connect) - - if result == RESULT_SUCCESS: - return self._save_entry() - if result != RESULT_AUTH_FAILED: - return self.async_abort(reason=result) - errors["base"] = result - - return self.async_show_form( - step_id="user", data_schema=DATA_SCHEMA_USER, errors=errors - ) - - @staticmethod - @callback - def async_get_options_flow(config_entry): - """Get the options flow for this handler.""" - return OptionsFlowHandler(config_entry) - - -class OptionsFlowHandler(config_entries.OptionsFlow): - """Handle a option flow for Tuya.""" - - def __init__(self, config_entry: config_entries.ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - self._conf_devs_id = None - self._conf_devs_option: dict[str, Any] = {} - self._form_error = None - - def _get_form_error(self): - """Set the error to be shown in the options form.""" - errors = {} - if self._form_error: - errors["base"] = self._form_error - self._form_error = None - return errors - - def _get_tuya_devices_filtered(self, types, exclude_mode=False, type_prefix=True): - """Get the list of Tuya device to filtered by types.""" - config_list = {} - types_filter = set(types) - tuya = self.hass.data[DOMAIN][TUYA_DATA] - devices_list = tuya.get_all_devices() - for device in devices_list: - dev_type = device.device_type() - exclude = ( - dev_type in types_filter - if exclude_mode - else dev_type not in types_filter - ) - if exclude: - continue - dev_id = device.object_id() - if type_prefix: - dev_id = f"{dev_type}-{dev_id}" - config_list[dev_id] = f"{device.name()} ({dev_type})" - - return config_list - - def _get_device(self, dev_id): - """Get specific device from tuya library.""" - tuya = self.hass.data[DOMAIN][TUYA_DATA] - return tuya.get_device_by_id(dev_id) - - def _save_config(self, data): - """Save the updated options.""" - curr_conf = self.config_entry.options.copy() - curr_conf.update(data) - curr_conf.update(self._conf_devs_option) - - return self.async_create_entry(title="", data=curr_conf) - - async def _async_device_form(self, devs_id): - """Return configuration form for devices.""" - conf_devs_id = [] - for count, dev_id in enumerate(devs_id): - device_info = dev_id.split("-") - if count == 0: - device_type = device_info[0] - device_id = device_info[1] - elif device_type != device_info[0]: - self._form_error = ERROR_DEV_MULTI_TYPE - return await self.async_step_init() - conf_devs_id.append(device_info[1]) - - device = self._get_device(device_id) - if not device: - self._form_error = ERROR_DEV_NOT_FOUND - return await self.async_step_init() - - curr_conf = self._conf_devs_option.get( - device_id, self.config_entry.options.get(device_id, {}) - ) - - config_schema = self._get_device_schema(device_type, curr_conf, device) - if not config_schema: - self._form_error = ERROR_DEV_NOT_CONFIG - return await self.async_step_init() - - self._conf_devs_id = conf_devs_id - device_name = ( - "(multiple devices selected)" if len(conf_devs_id) > 1 else device.name() - ) - - return self.async_show_form( - step_id="device", - data_schema=config_schema, - description_placeholders={ - "device_type": device_type, - "device_name": device_name, - }, - ) - - async def async_step_init(self, user_input=None): - """Handle options flow.""" - - if self.config_entry.state is not config_entries.ConfigEntryState.LOADED: - _LOGGER.error("Tuya integration not yet loaded") - return self.async_abort(reason=RESULT_CONN_ERROR) - - if user_input is not None: - dev_ids = user_input.get(CONF_LIST_DEVICES) - if dev_ids: - return await self.async_step_device(None, dev_ids) - - user_input.pop(CONF_LIST_DEVICES, []) - return self._save_config(data=user_input) - - data_schema = vol.Schema( - { - vol.Optional( - CONF_DISCOVERY_INTERVAL, - default=self.config_entry.options.get( - CONF_DISCOVERY_INTERVAL, DEFAULT_DISCOVERY_INTERVAL - ), - ): vol.All(vol.Coerce(int), vol.Clamp(min=30, max=900)), - } - ) - - query_devices = self._get_tuya_devices_filtered( - TUYA_TYPE_NOT_QUERY, True, False - ) - if query_devices: - devices = {ENTITY_MATCH_NONE: "Default"} - devices.update(query_devices) - def_val = self.config_entry.options.get(CONF_QUERY_DEVICE) - if not def_val or not query_devices.get(def_val): - def_val = ENTITY_MATCH_NONE - data_schema = data_schema.extend( - { - vol.Optional( - CONF_QUERY_INTERVAL, - default=self.config_entry.options.get( - CONF_QUERY_INTERVAL, DEFAULT_QUERY_INTERVAL - ), - ): vol.All(vol.Coerce(int), vol.Clamp(min=30, max=240)), - vol.Optional(CONF_QUERY_DEVICE, default=def_val): vol.In(devices), - } + response = await self.hass.async_add_executor_job( + self._try_login, user_input ) - config_devices = self._get_tuya_devices_filtered(TUYA_TYPE_CONFIG, False, True) - if config_devices: - data_schema = data_schema.extend( - {vol.Optional(CONF_LIST_DEVICES): cv.multi_select(config_devices)} + if response.get("success", False): + _LOGGER.debug("TuyaConfigFlow.async_step_user login success") + return self.async_create_entry( + title=user_input[CONF_USERNAME], + data=user_input, + ) + errors["base"] = RESULT_AUTH_FAILED + + if ProjectType(self.conf_project_type) == ProjectType.SMART_HOME: + return self.async_show_form( + step_id="login", data_schema=DATA_SCHEMA_SMART_HOME, errors=errors ) return self.async_show_form( - step_id="init", - data_schema=data_schema, - errors=self._get_form_error(), + step_id="login", + data_schema=DATA_SCHEMA_INDUSTRY_SOLUTIONS, + errors=errors, ) - - async def async_step_device(self, user_input=None, dev_ids=None): - """Handle options flow for device.""" - if dev_ids is not None: - return await self._async_device_form(dev_ids) - if user_input is not None: - for device_id in self._conf_devs_id: - self._conf_devs_option[device_id] = user_input - - return await self.async_step_init() - - def _get_device_schema(self, device_type, curr_conf, device): - """Return option schema for device.""" - if device_type != device.device_type(): - return None - schema = None - if device_type == "light": - schema = self._get_light_schema(curr_conf, device) - elif device_type == "climate": - schema = self._get_climate_schema(curr_conf, device) - return schema - - @staticmethod - def _get_light_schema(curr_conf, device): - """Create option schema for light device.""" - min_kelvin = device.max_color_temp() - max_kelvin = device.min_color_temp() - - config_schema = vol.Schema( - { - vol.Optional( - CONF_SUPPORT_COLOR, - default=curr_conf.get(CONF_SUPPORT_COLOR, False), - ): bool, - vol.Optional( - CONF_BRIGHTNESS_RANGE_MODE, - default=curr_conf.get(CONF_BRIGHTNESS_RANGE_MODE, 0), - ): vol.In({0: "Range 1-255", 1: "Range 10-1000"}), - vol.Optional( - CONF_MIN_KELVIN, - default=curr_conf.get(CONF_MIN_KELVIN, min_kelvin), - ): vol.All(vol.Coerce(int), vol.Clamp(min=min_kelvin, max=max_kelvin)), - vol.Optional( - CONF_MAX_KELVIN, - default=curr_conf.get(CONF_MAX_KELVIN, max_kelvin), - ): vol.All(vol.Coerce(int), vol.Clamp(min=min_kelvin, max=max_kelvin)), - vol.Optional( - CONF_TUYA_MAX_COLTEMP, - default=curr_conf.get( - CONF_TUYA_MAX_COLTEMP, DEFAULT_TUYA_MAX_COLTEMP - ), - ): vol.All( - vol.Coerce(int), - vol.Clamp( - min=DEFAULT_TUYA_MAX_COLTEMP, max=DEFAULT_TUYA_MAX_COLTEMP * 10 - ), - ), - } - ) - - return config_schema - - @staticmethod - def _get_climate_schema(curr_conf, device): - """Create option schema for climate device.""" - unit = device.temperature_unit() - def_unit = TEMP_FAHRENHEIT if unit == "FAHRENHEIT" else TEMP_CELSIUS - supported_steps = device.supported_temperature_steps() - default_step = device.target_temperature_step() - - config_schema = vol.Schema( - { - vol.Optional( - CONF_UNIT_OF_MEASUREMENT, - default=curr_conf.get(CONF_UNIT_OF_MEASUREMENT, def_unit), - ): vol.In({TEMP_CELSIUS: "Celsius", TEMP_FAHRENHEIT: "Fahrenheit"}), - vol.Optional( - CONF_TEMP_DIVIDER, - default=curr_conf.get(CONF_TEMP_DIVIDER, 0), - ): vol.All(vol.Coerce(int), vol.Clamp(min=0)), - vol.Optional( - CONF_CURR_TEMP_DIVIDER, - default=curr_conf.get(CONF_CURR_TEMP_DIVIDER, 0), - ): vol.All(vol.Coerce(int), vol.Clamp(min=0)), - vol.Optional( - CONF_SET_TEMP_DIVIDED, - default=curr_conf.get(CONF_SET_TEMP_DIVIDED, True), - ): bool, - vol.Optional( - CONF_TEMP_STEP_OVERRIDE, - default=curr_conf.get(CONF_TEMP_STEP_OVERRIDE, default_step), - ): vol.In(supported_steps), - vol.Optional( - CONF_MIN_TEMP, - default=curr_conf.get(CONF_MIN_TEMP, 0), - ): int, - vol.Optional( - CONF_MAX_TEMP, - default=curr_conf.get(CONF_MAX_TEMP, 0), - ): int, - } - ) - - return config_schema diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index 646bcc077cf..e259dd9190b 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -1,39 +1,37 @@ +#!/usr/bin/env python3 """Constants for the Tuya integration.""" -CONF_BRIGHTNESS_RANGE_MODE = "brightness_range_mode" -CONF_COUNTRYCODE = "country_code" -CONF_CURR_TEMP_DIVIDER = "curr_temp_divider" -CONF_DISCOVERY_INTERVAL = "discovery_interval" -CONF_MAX_KELVIN = "max_kelvin" -CONF_MAX_TEMP = "max_temp" -CONF_MIN_KELVIN = "min_kelvin" -CONF_MIN_TEMP = "min_temp" -CONF_QUERY_DEVICE = "query_device" -CONF_QUERY_INTERVAL = "query_interval" -CONF_SET_TEMP_DIVIDED = "set_temp_divided" -CONF_SUPPORT_COLOR = "support_color" -CONF_TEMP_DIVIDER = "temp_divider" -CONF_TEMP_STEP_OVERRIDE = "temp_step_override" -CONF_TUYA_MAX_COLTEMP = "tuya_max_coltemp" - -DEFAULT_DISCOVERY_INTERVAL = 605 -DEFAULT_QUERY_INTERVAL = 120 -DEFAULT_TUYA_MAX_COLTEMP = 10000 - DOMAIN = "tuya" -SIGNAL_CONFIG_ENTITY = "tuya_config" -SIGNAL_DELETE_ENTITY = "tuya_delete" -SIGNAL_UPDATE_ENTITY = "tuya_update" +CONF_PROJECT_TYPE = "tuya_project_type" +CONF_ENDPOINT = "endpoint" +CONF_ACCESS_ID = "access_id" +CONF_ACCESS_SECRET = "access_secret" +CONF_USERNAME = "username" +CONF_PASSWORD = "password" +CONF_COUNTRY_CODE = "country_code" +CONF_APP_TYPE = "tuya_app_type" -TUYA_DATA = "tuya_data" -TUYA_DEVICES_CONF = "devices_config" TUYA_DISCOVERY_NEW = "tuya_discovery_new_{}" +TUYA_DEVICE_MANAGER = "tuya_device_manager" +TUYA_HOME_MANAGER = "tuya_home_manager" +TUYA_MQTT_LISTENER = "tuya_mqtt_listener" +TUYA_HA_TUYA_MAP = "tuya_ha_tuya_map" +TUYA_HA_DEVICES = "tuya_ha_devices" -TUYA_PLATFORMS = { - "tuya": "Tuya", - "smart_life": "Smart Life", - "jinvoo_smart": "Jinvoo Smart", +TUYA_HA_SIGNAL_UPDATE_ENTITY = "tuya_entry_update" + +TUYA_ENDPOINT = { + "https://openapi.tuyaus.com": "America", + "https://openapi.tuyacn.com": "China", + "https://openapi.tuyaeu.com": "Europe", + "https://openapi.tuyain.com": "India", + "https://openapi-ueaz.tuyaus.com": "EasternAmerica", + "https://openapi-weaz.tuyaeu.com": "WesternEurope", } -TUYA_TYPE_NOT_QUERY = ["scene", "switch"] +TUYA_PROJECT_TYPE = {1: "Custom Development", 0: "Smart Home PaaS"} + +TUYA_APP_TYPE = {"tuyaSmart": "TuyaSmart", "smartlife": "Smart Life"} + +PLATFORMS = ["climate", "fan", "light", "scene", "switch"] diff --git a/homeassistant/components/tuya/cover.py b/homeassistant/components/tuya/cover.py deleted file mode 100644 index 08f1d92aca5..00000000000 --- a/homeassistant/components/tuya/cover.py +++ /dev/null @@ -1,118 +0,0 @@ -"""Support for Tuya covers.""" -from datetime import timedelta - -from homeassistant.components.cover import ( - DOMAIN as SENSOR_DOMAIN, - ENTITY_ID_FORMAT, - SUPPORT_CLOSE, - SUPPORT_OPEN, - SUPPORT_STOP, - CoverEntity, -) -from homeassistant.const import CONF_PLATFORM -from homeassistant.helpers.dispatcher import async_dispatcher_connect - -from . import TuyaDevice -from .const import DOMAIN, TUYA_DATA, TUYA_DISCOVERY_NEW - -SCAN_INTERVAL = timedelta(seconds=15) - - -async def async_setup_entry(hass, config_entry, async_add_entities): - """Set up tuya sensors dynamically through tuya discovery.""" - - platform = config_entry.data[CONF_PLATFORM] - - async def async_discover_sensor(dev_ids): - """Discover and add a discovered tuya sensor.""" - if not dev_ids: - return - entities = await hass.async_add_executor_job( - _setup_entities, - hass, - dev_ids, - platform, - ) - async_add_entities(entities) - - async_dispatcher_connect( - hass, TUYA_DISCOVERY_NEW.format(SENSOR_DOMAIN), async_discover_sensor - ) - - devices_ids = hass.data[DOMAIN]["pending"].pop(SENSOR_DOMAIN) - await async_discover_sensor(devices_ids) - - -def _setup_entities(hass, dev_ids, platform): - """Set up Tuya Cover device.""" - tuya = hass.data[DOMAIN][TUYA_DATA] - entities = [] - for dev_id in dev_ids: - device = tuya.get_device_by_id(dev_id) - if device is None: - continue - entities.append(TuyaCover(device, platform)) - return entities - - -class TuyaCover(TuyaDevice, CoverEntity): - """Tuya cover devices.""" - - def __init__(self, tuya, platform): - """Init tuya cover device.""" - super().__init__(tuya, platform) - self.entity_id = ENTITY_ID_FORMAT.format(tuya.object_id()) - self._was_closing = False - self._was_opening = False - - @property - def supported_features(self): - """Flag supported features.""" - if self._tuya.support_stop(): - return SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_STOP - return SUPPORT_OPEN | SUPPORT_CLOSE - - @property - def is_opening(self): - """Return if the cover is opening or not.""" - state = self._tuya.state() - if state == 1: - self._was_opening = True - self._was_closing = False - return True - return False - - @property - def is_closing(self): - """Return if the cover is closing or not.""" - state = self._tuya.state() - if state == 2: - self._was_opening = False - self._was_closing = True - return True - return False - - @property - def is_closed(self): - """Return if the cover is closed or not.""" - state = self._tuya.state() - if state != 2 and self._was_closing: - return True - if state != 1 and self._was_opening: - return False - return None - - def open_cover(self, **kwargs): - """Open the cover.""" - self._tuya.open_cover() - - def close_cover(self, **kwargs): - """Close cover.""" - self._tuya.close_cover() - - def stop_cover(self, **kwargs): - """Stop the cover.""" - if self.is_closed is None: - self._was_opening = False - self._was_closing = False - self._tuya.stop_cover() diff --git a/homeassistant/components/tuya/fan.py b/homeassistant/components/tuya/fan.py index ab361c6ac31..dcfde0ded0f 100644 --- a/homeassistant/components/tuya/fan.py +++ b/homeassistant/components/tuya/fan.py @@ -1,141 +1,259 @@ -"""Support for Tuya fans.""" +"""Support for Tuya Fan.""" from __future__ import annotations -from datetime import timedelta +import json +import logging +from typing import Any + +from tuya_iot import TuyaDevice, TuyaDeviceManager from homeassistant.components.fan import ( - DOMAIN as SENSOR_DOMAIN, - ENTITY_ID_FORMAT, + DIRECTION_FORWARD, + DIRECTION_REVERSE, + DOMAIN as DEVICE_DOMAIN, + SUPPORT_DIRECTION, SUPPORT_OSCILLATE, + SUPPORT_PRESET_MODE, SUPPORT_SET_SPEED, FanEntity, ) -from homeassistant.const import CONF_PLATFORM, STATE_OFF +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( ordered_list_item_to_percentage, percentage_to_ordered_list_item, ) -from . import TuyaDevice -from .const import DOMAIN, TUYA_DATA, TUYA_DISCOVERY_NEW +from .base import TuyaHaEntity +from .const import ( + DOMAIN, + TUYA_DEVICE_MANAGER, + TUYA_DISCOVERY_NEW, + TUYA_HA_DEVICES, + TUYA_HA_TUYA_MAP, +) -SCAN_INTERVAL = timedelta(seconds=15) +_LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass, config_entry, async_add_entities): - """Set up tuya sensors dynamically through tuya discovery.""" +# Fan +# https://developer.tuya.com/en/docs/iot/f?id=K9gf45vs7vkge +DPCODE_SWITCH = "switch" +DPCODE_FAN_SPEED = "fan_speed_percent" +DPCODE_MODE = "mode" +DPCODE_SWITCH_HORIZONTAL = "switch_horizontal" +DPCODE_FAN_DIRECTION = "fan_direction" - platform = config_entry.data[CONF_PLATFORM] +# Air Purifier +# https://developer.tuya.com/en/docs/iot/s?id=K9gf48r41mn81 +DPCODE_AP_FAN_SPEED = "speed" +DPCODE_AP_FAN_SPEED_ENUM = "fan_speed_enum" - async def async_discover_sensor(dev_ids): - """Discover and add a discovered tuya sensor.""" +TUYA_SUPPORT_TYPE = { + "fs", # Fan + "kj", # Air Purifier +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +): + """Set up tuya fan dynamically through tuya discovery.""" + _LOGGER.debug("fan init") + + hass.data[DOMAIN][entry.entry_id][TUYA_HA_TUYA_MAP][ + DEVICE_DOMAIN + ] = TUYA_SUPPORT_TYPE + + @callback + def async_discover_device(dev_ids: list[str]) -> None: + """Discover and add a discovered tuya fan.""" + _LOGGER.debug("fan add-> %s", dev_ids) if not dev_ids: return - entities = await hass.async_add_executor_job( - _setup_entities, - hass, - dev_ids, - platform, - ) + entities = _setup_entities(hass, entry, dev_ids) async_add_entities(entities) - async_dispatcher_connect( - hass, TUYA_DISCOVERY_NEW.format(SENSOR_DOMAIN), async_discover_sensor + entry.async_on_unload( + async_dispatcher_connect( + hass, TUYA_DISCOVERY_NEW.format(DEVICE_DOMAIN), async_discover_device + ) ) - devices_ids = hass.data[DOMAIN]["pending"].pop(SENSOR_DOMAIN) - await async_discover_sensor(devices_ids) + device_manager = hass.data[DOMAIN][entry.entry_id][TUYA_DEVICE_MANAGER] + device_ids = [] + for (device_id, device) in device_manager.device_map.items(): + if device.category in TUYA_SUPPORT_TYPE: + device_ids.append(device_id) + async_discover_device(device_ids) -def _setup_entities(hass, dev_ids, platform): - """Set up Tuya Fan device.""" - tuya = hass.data[DOMAIN][TUYA_DATA] +def _setup_entities( + hass: HomeAssistant, entry: ConfigEntry, device_ids: list[str] +) -> list[TuyaHaFan]: + """Set up Tuya Fan.""" + device_manager = hass.data[DOMAIN][entry.entry_id][TUYA_DEVICE_MANAGER] entities = [] - for dev_id in dev_ids: - device = tuya.get_device_by_id(dev_id) + for device_id in device_ids: + device = device_manager.device_map[device_id] if device is None: continue - entities.append(TuyaFanDevice(device, platform)) + entities.append(TuyaHaFan(device, device_manager)) + hass.data[DOMAIN][entry.entry_id][TUYA_HA_DEVICES].add(device_id) return entities -class TuyaFanDevice(TuyaDevice, FanEntity): - """Tuya fan devices.""" +class TuyaHaFan(TuyaHaEntity, FanEntity): + """Tuya Fan Device.""" - def __init__(self, tuya, platform): - """Init Tuya fan device.""" - super().__init__(tuya, platform) - self.entity_id = ENTITY_ID_FORMAT.format(tuya.object_id()) - self.speeds = [] + def __init__(self, device: TuyaDevice, device_manager: TuyaDeviceManager) -> None: + """Init Tuya Fan Device.""" + super().__init__(device, device_manager) - async def async_added_to_hass(self): - """Create fan list when add to hass.""" - await super().async_added_to_hass() - self.speeds.extend(self._tuya.speed_list()) + self.ha_preset_modes = [] + if DPCODE_MODE in self.tuya_device.function: + self.ha_preset_modes = json.loads( + self.tuya_device.function[DPCODE_MODE].values + ).get("range", []) + + # Air purifier fan can be controlled either via the ranged values or via the enum. + # We will always prefer the enumeration if available + # Enum is used for e.g. MEES SmartHIMOX-H06 + # Range is used for e.g. Concept CA3000 + self.air_purifier_speed_range_len = 0 + self.air_purifier_speed_range_enum = [] + if self.tuya_device.category == "kj" and ( + DPCODE_AP_FAN_SPEED_ENUM in self.tuya_device.function + or DPCODE_AP_FAN_SPEED in self.tuya_device.function + ): + if DPCODE_AP_FAN_SPEED_ENUM in self.tuya_device.function: + self.dp_code_speed_enum = DPCODE_AP_FAN_SPEED_ENUM + else: + self.dp_code_speed_enum = DPCODE_AP_FAN_SPEED + + data = json.loads( + self.tuya_device.function[self.dp_code_speed_enum].values + ).get("range") + if data: + self.air_purifier_speed_range_len = len(data) + self.air_purifier_speed_range_enum = data + + def set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode of the fan.""" + self._send_command([{"code": DPCODE_MODE, "value": preset_mode}]) + + def set_direction(self, direction: str) -> None: + """Set the direction of the fan.""" + self._send_command([{"code": DPCODE_FAN_DIRECTION, "value": direction}]) def set_percentage(self, percentage: int) -> None: - """Set the speed percentage of the fan.""" - if percentage == 0: - self.turn_off() + """Set the speed of the fan, as a percentage.""" + if self.tuya_device.category == "kj": + value_in_range = percentage_to_ordered_list_item( + self.air_purifier_speed_range_enum, percentage + ) + self._send_command( + [ + { + "code": self.dp_code_speed_enum, + "value": value_in_range, + } + ] + ) else: - tuya_speed = percentage_to_ordered_list_item(self.speeds, percentage) - self._tuya.set_speed(tuya_speed) + self._send_command([{"code": DPCODE_FAN_SPEED, "value": percentage}]) + + def turn_off(self, **kwargs: Any) -> None: + """Turn the fan off.""" + self._send_command([{"code": DPCODE_SWITCH, "value": False}]) def turn_on( self, speed: str = None, percentage: int = None, preset_mode: str = None, - **kwargs, + **kwargs: Any, ) -> None: """Turn on the fan.""" - if percentage is not None: - self.set_percentage(percentage) - else: - self._tuya.turn_on() + self._send_command([{"code": DPCODE_SWITCH, "value": True}]) - def turn_off(self, **kwargs) -> None: - """Turn the entity off.""" - self._tuya.turn_off() - - def oscillate(self, oscillating) -> None: + def oscillate(self, oscillating: bool) -> None: """Oscillate the fan.""" - self._tuya.oscillate(oscillating) + self._send_command([{"code": DPCODE_SWITCH_HORIZONTAL, "value": oscillating}]) + + @property + def is_on(self) -> bool: + """Return true if fan is on.""" + return self.tuya_device.status.get(DPCODE_SWITCH, False) + + @property + def current_direction(self) -> str: + """Return the current direction of the fan.""" + if self.tuya_device.status[DPCODE_FAN_DIRECTION]: + return DIRECTION_FORWARD + return DIRECTION_REVERSE + + @property + def oscillating(self) -> bool: + """Return true if the fan is oscillating.""" + return self.tuya_device.status.get(DPCODE_SWITCH_HORIZONTAL, False) + + @property + def preset_modes(self) -> list[str]: + """Return the list of available preset_modes.""" + return self.ha_preset_modes + + @property + def preset_mode(self) -> str: + """Return the current preset_mode.""" + return self.tuya_device.status[DPCODE_MODE] + + @property + def percentage(self) -> int: + """Return the current speed.""" + if not self.is_on: + return 0 + + if ( + self.tuya_device.category == "kj" + and self.air_purifier_speed_range_len > 1 + and not self.air_purifier_speed_range_enum + and DPCODE_AP_FAN_SPEED_ENUM in self.tuya_device.status + ): + # if air-purifier speed enumeration is supported we will prefer it. + return ordered_list_item_to_percentage( + self.air_purifier_speed_range_enum, + self.tuya_device.status[DPCODE_AP_FAN_SPEED_ENUM], + ) + + return self.tuya_device.status[DPCODE_FAN_SPEED] @property def speed_count(self) -> int: """Return the number of speeds the fan supports.""" - if self.speeds is None: - return super().speed_count - return len(self.speeds) + if self.tuya_device.category == "kj": + return self.air_purifier_speed_range_len + return super().speed_count @property - def oscillating(self): - """Return current oscillating status.""" - if self.supported_features & SUPPORT_OSCILLATE == 0: - return None - if self.speed == STATE_OFF: - return False - return self._tuya.oscillating() - - @property - def is_on(self): - """Return true if the entity is on.""" - return self._tuya.state() - - @property - def percentage(self) -> int | None: - """Return the current speed.""" - if not self.is_on: - return 0 - if self.speeds is None: - return None - return ordered_list_item_to_percentage(self.speeds, self._tuya.speed()) - - @property - def supported_features(self) -> int: + def supported_features(self): """Flag supported features.""" - if self._tuya.support_oscillate(): - return SUPPORT_SET_SPEED | SUPPORT_OSCILLATE - return SUPPORT_SET_SPEED + supports = 0 + if DPCODE_MODE in self.tuya_device.status: + supports |= SUPPORT_PRESET_MODE + if DPCODE_FAN_SPEED in self.tuya_device.status: + supports |= SUPPORT_SET_SPEED + if DPCODE_SWITCH_HORIZONTAL in self.tuya_device.status: + supports |= SUPPORT_OSCILLATE + if DPCODE_FAN_DIRECTION in self.tuya_device.status: + supports |= SUPPORT_DIRECTION + + # Air Purifier specific + if ( + DPCODE_AP_FAN_SPEED in self.tuya_device.status + or DPCODE_AP_FAN_SPEED_ENUM in self.tuya_device.status + ): + supports |= SUPPORT_SET_SPEED + return supports diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index 4602e65a4d5..180e3a68450 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -1,198 +1,387 @@ """Support for the Tuya lights.""" -from datetime import timedelta +from __future__ import annotations + +import json +import logging +from typing import Any + +from tuya_iot import TuyaDevice, TuyaDeviceManager from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, - DOMAIN as SENSOR_DOMAIN, - ENTITY_ID_FORMAT, - SUPPORT_BRIGHTNESS, - SUPPORT_COLOR, - SUPPORT_COLOR_TEMP, + COLOR_MODE_BRIGHTNESS, + COLOR_MODE_COLOR_TEMP, + COLOR_MODE_HS, + COLOR_MODE_ONOFF, + DOMAIN as DEVICE_DOMAIN, LightEntity, ) -from homeassistant.const import CONF_PLATFORM -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.util import color as colorutil +from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import TuyaDevice +from .base import TuyaHaEntity from .const import ( - CONF_BRIGHTNESS_RANGE_MODE, - CONF_MAX_KELVIN, - CONF_MIN_KELVIN, - CONF_SUPPORT_COLOR, - CONF_TUYA_MAX_COLTEMP, - DEFAULT_TUYA_MAX_COLTEMP, DOMAIN, - SIGNAL_CONFIG_ENTITY, - TUYA_DATA, + TUYA_DEVICE_MANAGER, TUYA_DISCOVERY_NEW, + TUYA_HA_DEVICES, + TUYA_HA_TUYA_MAP, ) -SCAN_INTERVAL = timedelta(seconds=15) +_LOGGER = logging.getLogger(__name__) -TUYA_BRIGHTNESS_RANGE0 = (1, 255) -TUYA_BRIGHTNESS_RANGE1 = (10, 1000) -BRIGHTNESS_MODES = { - 0: TUYA_BRIGHTNESS_RANGE0, - 1: TUYA_BRIGHTNESS_RANGE1, +# Light(dj) +# https://developer.tuya.com/en/docs/iot/f?id=K9i5ql3v98hn3 +DPCODE_SWITCH = "switch_led" +DPCODE_WORK_MODE = "work_mode" +DPCODE_BRIGHT_VALUE = "bright_value" +DPCODE_TEMP_VALUE = "temp_value" +DPCODE_COLOUR_DATA = "colour_data" +DPCODE_COLOUR_DATA_V2 = "colour_data_v2" +DPCODE_LIGHT = "light" + +MIREDS_MAX = 500 +MIREDS_MIN = 153 + +HSV_HA_HUE_MIN = 0 +HSV_HA_HUE_MAX = 360 +HSV_HA_SATURATION_MIN = 0 +HSV_HA_SATURATION_MAX = 100 + +WORK_MODE_WHITE = "white" +WORK_MODE_COLOUR = "colour" + +TUYA_SUPPORT_TYPE = { + "dj", # Light + "dd", # Light strip + "fwl", # Ambient light + "dc", # Light string + "jsq", # Humidifier's light + "xdd", # Ceiling Light + "xxj", # Diffuser's light + "fs", # Fan +} + +DEFAULT_HSV = { + "h": {"min": 1, "scale": 0, "unit": "", "max": 360, "step": 1}, + "s": {"min": 1, "scale": 0, "unit": "", "max": 255, "step": 1}, + "v": {"min": 1, "scale": 0, "unit": "", "max": 255, "step": 1}, +} + +DEFAULT_HSV_V2 = { + "h": {"min": 1, "scale": 0, "unit": "", "max": 360, "step": 1}, + "s": {"min": 1, "scale": 0, "unit": "", "max": 1000, "step": 1}, + "v": {"min": 1, "scale": 0, "unit": "", "max": 1000, "step": 1}, } -async def async_setup_entry(hass, config_entry, async_add_entities): - """Set up tuya sensors dynamically through tuya discovery.""" +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up tuya light dynamically through tuya discovery.""" + _LOGGER.debug("light init") - platform = config_entry.data[CONF_PLATFORM] + hass.data[DOMAIN][entry.entry_id][TUYA_HA_TUYA_MAP][ + DEVICE_DOMAIN + ] = TUYA_SUPPORT_TYPE - async def async_discover_sensor(dev_ids): - """Discover and add a discovered tuya sensor.""" + @callback + def async_discover_device(dev_ids: list[str]): + """Discover and add a discovered tuya light.""" + _LOGGER.debug("light add-> %s", dev_ids) if not dev_ids: return - entities = await hass.async_add_executor_job( - _setup_entities, - hass, - dev_ids, - platform, - ) + entities = _setup_entities(hass, entry, dev_ids) async_add_entities(entities) - async_dispatcher_connect( - hass, TUYA_DISCOVERY_NEW.format(SENSOR_DOMAIN), async_discover_sensor + entry.async_on_unload( + async_dispatcher_connect( + hass, TUYA_DISCOVERY_NEW.format(DEVICE_DOMAIN), async_discover_device + ) ) - devices_ids = hass.data[DOMAIN]["pending"].pop(SENSOR_DOMAIN) - await async_discover_sensor(devices_ids) + device_manager = hass.data[DOMAIN][entry.entry_id][TUYA_DEVICE_MANAGER] + device_ids = [] + for (device_id, device) in device_manager.device_map.items(): + if device.category in TUYA_SUPPORT_TYPE: + device_ids.append(device_id) + async_discover_device(device_ids) -def _setup_entities(hass, dev_ids, platform): +def _setup_entities( + hass, entry: ConfigEntry, device_ids: list[str] +) -> list[TuyaHaLight]: """Set up Tuya Light device.""" - tuya = hass.data[DOMAIN][TUYA_DATA] + device_manager = hass.data[DOMAIN][entry.entry_id][TUYA_DEVICE_MANAGER] entities = [] - for dev_id in dev_ids: - device = tuya.get_device_by_id(dev_id) + for device_id in device_ids: + device = device_manager.device_map[device_id] if device is None: continue - entities.append(TuyaLight(device, platform)) + + tuya_ha_light = TuyaHaLight(device, device_manager) + entities.append(tuya_ha_light) + hass.data[DOMAIN][entry.entry_id][TUYA_HA_DEVICES].add( + tuya_ha_light.tuya_device.id + ) + return entities -class TuyaLight(TuyaDevice, LightEntity): +class TuyaHaLight(TuyaHaEntity, LightEntity): """Tuya light device.""" - def __init__(self, tuya, platform): - """Init Tuya light device.""" - super().__init__(tuya, platform) - self.entity_id = ENTITY_ID_FORMAT.format(tuya.object_id()) - self._min_kelvin = tuya.max_color_temp() - self._max_kelvin = tuya.min_color_temp() + def __init__(self, device: TuyaDevice, device_manager: TuyaDeviceManager) -> None: + """Init TuyaHaLight.""" + self.dp_code_bright = DPCODE_BRIGHT_VALUE + self.dp_code_temp = DPCODE_TEMP_VALUE + self.dp_code_colour = DPCODE_COLOUR_DATA - @callback - def _process_config(self): - """Set device config parameter.""" - config = self._get_device_config() - if not config: - return + for key in device.function: + if key.startswith(DPCODE_BRIGHT_VALUE): + self.dp_code_bright = key + elif key.startswith(DPCODE_TEMP_VALUE): + self.dp_code_temp = key + elif key.startswith(DPCODE_COLOUR_DATA): + self.dp_code_colour = key - # support color config - supp_color = config.get(CONF_SUPPORT_COLOR, False) - if supp_color: - self._tuya.force_support_color() - # brightness range config - self._tuya.brightness_white_range = BRIGHTNESS_MODES.get( - config.get(CONF_BRIGHTNESS_RANGE_MODE, 0), - TUYA_BRIGHTNESS_RANGE0, - ) - # color set temp range - min_tuya = self._tuya.max_color_temp() - min_kelvin = config.get(CONF_MIN_KELVIN, min_tuya) - max_tuya = self._tuya.min_color_temp() - max_kelvin = config.get(CONF_MAX_KELVIN, max_tuya) - self._min_kelvin = min(max(min_kelvin, min_tuya), max_tuya) - self._max_kelvin = min(max(max_kelvin, self._min_kelvin), max_tuya) - # color shown temp range - max_color_temp = max( - config.get(CONF_TUYA_MAX_COLTEMP, DEFAULT_TUYA_MAX_COLTEMP), - DEFAULT_TUYA_MAX_COLTEMP, - ) - self._tuya.color_temp_range = (1000, max_color_temp) - - async def async_added_to_hass(self): - """Set config parameter when add to hass.""" - await super().async_added_to_hass() - self._process_config() - self.async_on_remove( - async_dispatcher_connect( - self.hass, SIGNAL_CONFIG_ENTITY, self._process_config - ) - ) - return + super().__init__(device, device_manager) @property - def brightness(self): - """Return the brightness of the light.""" - if self._tuya.brightness() is None: - return None - return int(self._tuya.brightness()) - - @property - def hs_color(self): - """Return the hs_color of the light.""" - return tuple(map(int, self._tuya.hs_color())) - - @property - def color_temp(self): - """Return the color_temp of the light.""" - color_temp = int(self._tuya.color_temp()) - if color_temp is None: - return None - return colorutil.color_temperature_kelvin_to_mired(color_temp) - - @property - def is_on(self): + def is_on(self) -> bool: """Return true if light is on.""" - return self._tuya.state() + return self.tuya_device.status.get(DPCODE_SWITCH, False) - @property - def min_mireds(self): - """Return color temperature min mireds.""" - return colorutil.color_temperature_kelvin_to_mired(self._max_kelvin) - - @property - def max_mireds(self): - """Return color temperature max mireds.""" - return colorutil.color_temperature_kelvin_to_mired(self._min_kelvin) - - def turn_on(self, **kwargs): + def turn_on(self, **kwargs: Any) -> None: """Turn on or control the light.""" - if ( - ATTR_BRIGHTNESS not in kwargs - and ATTR_HS_COLOR not in kwargs - and ATTR_COLOR_TEMP not in kwargs - ): - self._tuya.turn_on() - if ATTR_BRIGHTNESS in kwargs: - self._tuya.set_brightness(kwargs[ATTR_BRIGHTNESS]) - if ATTR_HS_COLOR in kwargs: - self._tuya.set_color(kwargs[ATTR_HS_COLOR]) - if ATTR_COLOR_TEMP in kwargs: - color_temp = colorutil.color_temperature_mired_to_kelvin( - kwargs[ATTR_COLOR_TEMP] - ) - self._tuya.set_color_temp(color_temp) + commands = [] + _LOGGER.debug("light kwargs-> %s", kwargs) - def turn_off(self, **kwargs): + if ( + DPCODE_LIGHT in self.tuya_device.status + and DPCODE_SWITCH not in self.tuya_device.status + ): + commands += [{"code": DPCODE_LIGHT, "value": True}] + else: + commands += [{"code": DPCODE_SWITCH, "value": True}] + + if ATTR_BRIGHTNESS in kwargs: + if self._work_mode().startswith(WORK_MODE_COLOUR): + colour_data = self._get_hsv() + v_range = self._tuya_hsv_v_range() + colour_data["v"] = int( + self.remap(kwargs[ATTR_BRIGHTNESS], 0, 255, v_range[0], v_range[1]) + ) + commands += [ + {"code": self.dp_code_colour, "value": json.dumps(colour_data)} + ] + else: + new_range = self._tuya_brightness_range() + tuya_brightness = int( + self.remap( + kwargs[ATTR_BRIGHTNESS], 0, 255, new_range[0], new_range[1] + ) + ) + commands += [{"code": self.dp_code_bright, "value": tuya_brightness}] + + if ATTR_HS_COLOR in kwargs: + colour_data = self._get_hsv() + # hsv h + colour_data["h"] = int(kwargs[ATTR_HS_COLOR][0]) + # hsv s + ha_s = kwargs[ATTR_HS_COLOR][1] + s_range = self._tuya_hsv_s_range() + colour_data["s"] = int( + self.remap( + ha_s, + HSV_HA_SATURATION_MIN, + HSV_HA_SATURATION_MAX, + s_range[0], + s_range[1], + ) + ) + # hsv v + ha_v = self.brightness + v_range = self._tuya_hsv_v_range() + colour_data["v"] = int(self.remap(ha_v, 0, 255, v_range[0], v_range[1])) + + commands += [ + {"code": self.dp_code_colour, "value": json.dumps(colour_data)} + ] + if self.tuya_device.status[DPCODE_WORK_MODE] != "colour": + commands += [{"code": DPCODE_WORK_MODE, "value": "colour"}] + + if ATTR_COLOR_TEMP in kwargs: + # temp color + new_range = self._tuya_temp_range() + color_temp = self.remap( + self.max_mireds - kwargs[ATTR_COLOR_TEMP] + self.min_mireds, + self.min_mireds, + self.max_mireds, + new_range[0], + new_range[1], + ) + commands += [{"code": self.dp_code_temp, "value": int(color_temp)}] + + # brightness + ha_brightness = self.brightness + new_range = self._tuya_brightness_range() + tuya_brightness = self.remap( + ha_brightness, 0, 255, new_range[0], new_range[1] + ) + commands += [{"code": self.dp_code_bright, "value": int(tuya_brightness)}] + + if self.tuya_device.status[DPCODE_WORK_MODE] != "white": + commands += [{"code": DPCODE_WORK_MODE, "value": "white"}] + + self._send_command(commands) + + def turn_off(self, **kwargs: Any) -> None: """Instruct the light to turn off.""" - self._tuya.turn_off() + if ( + DPCODE_LIGHT in self.tuya_device.status + and DPCODE_SWITCH not in self.tuya_device.status + ): + commands = [{"code": DPCODE_LIGHT, "value": False}] + else: + commands = [{"code": DPCODE_SWITCH, "value": False}] + self._send_command(commands) @property - def supported_features(self): - """Flag supported features.""" - supports = SUPPORT_BRIGHTNESS - if self._tuya.support_color(): - supports = supports | SUPPORT_COLOR - if self._tuya.support_color_temp(): - supports = supports | SUPPORT_COLOR_TEMP - return supports + def brightness(self) -> int | None: + """Return the brightness of the light.""" + old_range = self._tuya_brightness_range() + brightness = self.tuya_device.status.get(self.dp_code_bright, 0) + + if self._work_mode().startswith(WORK_MODE_COLOUR): + colour_json = self.tuya_device.status.get(self.dp_code_colour) + if not colour_json: + return None + colour_data = json.loads(colour_json) + v_range = self._tuya_hsv_v_range() + hsv_v = colour_data.get("v", 0) + return int(self.remap(hsv_v, v_range[0], v_range[1], 0, 255)) + + return int(self.remap(brightness, old_range[0], old_range[1], 0, 255)) + + def _tuya_brightness_range(self) -> tuple[int, int]: + if self.dp_code_bright not in self.tuya_device.status: + return 0, 255 + bright_item = self.tuya_device.function.get(self.dp_code_bright) + if not bright_item: + return 0, 255 + bright_value = json.loads(bright_item.values) + return bright_value.get("min", 0), bright_value.get("max", 255) + + @property + def hs_color(self) -> tuple[float, float] | None: + """Return the hs_color of the light.""" + colour_json = self.tuya_device.status.get(self.dp_code_colour) + if not colour_json: + return None + colour_data = json.loads(colour_json) + s_range = self._tuya_hsv_s_range() + return colour_data.get("h", 0), self.remap( + colour_data.get("s", 0), + s_range[0], + s_range[1], + HSV_HA_SATURATION_MIN, + HSV_HA_SATURATION_MAX, + ) + + @property + def color_temp(self) -> int: + """Return the color_temp of the light.""" + new_range = self._tuya_temp_range() + tuya_color_temp = self.tuya_device.status.get(self.dp_code_temp, 0) + ha_color_temp = ( + self.max_mireds + - self.remap( + tuya_color_temp, + new_range[0], + new_range[1], + self.min_mireds, + self.max_mireds, + ) + + self.min_mireds + ) + return ha_color_temp + + @property + def min_mireds(self) -> int: + """Return color temperature min mireds.""" + return MIREDS_MIN + + @property + def max_mireds(self) -> int: + """Return color temperature max mireds.""" + return MIREDS_MAX + + def _tuya_temp_range(self) -> tuple[int, int]: + temp_item = self.tuya_device.function.get(self.dp_code_temp) + if not temp_item: + return 0, 255 + temp_value = json.loads(temp_item.values) + return temp_value.get("min", 0), temp_value.get("max", 255) + + def _tuya_hsv_s_range(self) -> tuple[int, int]: + hsv_data_range = self._tuya_hsv_function() + if hsv_data_range is not None: + hsv_s = hsv_data_range.get("s", {"min": 0, "max": 255}) + return hsv_s.get("min", 0), hsv_s.get("max", 255) + return 0, 255 + + def _tuya_hsv_v_range(self) -> tuple[int, int]: + hsv_data_range = self._tuya_hsv_function() + if hsv_data_range is not None: + hsv_v = hsv_data_range.get("v", {"min": 0, "max": 255}) + return hsv_v.get("min", 0), hsv_v.get("max", 255) + + return 0, 255 + + def _tuya_hsv_function(self) -> dict[str, dict] | None: + hsv_item = self.tuya_device.function.get(self.dp_code_colour) + if not hsv_item: + return None + hsv_data = json.loads(hsv_item.values) + if hsv_data: + return hsv_data + colour_json = self.tuya_device.status.get(self.dp_code_colour) + if not colour_json: + return None + colour_data = json.loads(colour_json) + if ( + self.dp_code_colour == DPCODE_COLOUR_DATA_V2 + or colour_data.get("v", 0) > 255 + or colour_data.get("s", 0) > 255 + ): + return DEFAULT_HSV_V2 + return DEFAULT_HSV + + def _work_mode(self) -> str: + return self.tuya_device.status.get(DPCODE_WORK_MODE, "") + + def _get_hsv(self) -> dict[str, int]: + return json.loads(self.tuya_device.status[self.dp_code_colour]) + + @property + def supported_color_modes(self) -> set[str] | None: + """Flag supported color modes.""" + color_modes = [COLOR_MODE_ONOFF] + if self.dp_code_bright in self.tuya_device.status: + color_modes.append(COLOR_MODE_BRIGHTNESS) + + if self.dp_code_temp in self.tuya_device.status: + color_modes.append(COLOR_MODE_COLOR_TEMP) + + if ( + self.dp_code_colour in self.tuya_device.status + and len(self.tuya_device.status[self.dp_code_colour]) > 0 + ): + color_modes.append(COLOR_MODE_HS) + return set(color_modes) diff --git a/homeassistant/components/tuya/manifest.json b/homeassistant/components/tuya/manifest.json index 5dae8e6a101..85370bdfcac 100644 --- a/homeassistant/components/tuya/manifest.json +++ b/homeassistant/components/tuya/manifest.json @@ -1,17 +1,13 @@ { "domain": "tuya", "name": "Tuya", - "documentation": "https://www.home-assistant.io/integrations/tuya", - "requirements": ["tuyaha==0.0.10"], - "codeowners": ["@ollo69"], + "documentation": "https://github.com/tuya/tuya-home-assistant", + "requirements": [ + "tuya-iot-py-sdk==0.4.1" + ], + "codeowners": [ + "@Tuya" + ], "config_flow": true, - "iot_class": "cloud_polling", - "dhcp": [ - {"macaddress": "508A06*"}, - {"macaddress": "7CF666*"}, - {"macaddress": "10D561*"}, - {"macaddress": "D4A651*"}, - {"macaddress": "68572D*"}, - {"macaddress": "1869D8*"} - ] -} + "iot_class": "cloud_push" +} \ No newline at end of file diff --git a/homeassistant/components/tuya/scene.py b/homeassistant/components/tuya/scene.py index 430b2bc7e27..c6010f9ef87 100644 --- a/homeassistant/components/tuya/scene.py +++ b/homeassistant/components/tuya/scene.py @@ -1,61 +1,74 @@ -"""Support for the Tuya scenes.""" +"""Support for Tuya scenes.""" +from __future__ import annotations + +import logging from typing import Any -from homeassistant.components.scene import DOMAIN as SENSOR_DOMAIN, Scene -from homeassistant.const import CONF_PLATFORM -from homeassistant.helpers.dispatcher import async_dispatcher_connect +from tuya_iot import TuyaHomeManager, TuyaScene -from . import TuyaDevice -from .const import DOMAIN, TUYA_DATA, TUYA_DISCOVERY_NEW +from homeassistant.components.scene import Scene +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback -ENTITY_ID_FORMAT = SENSOR_DOMAIN + ".{}" +from .const import DOMAIN, TUYA_HOME_MANAGER + +_LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass, config_entry, async_add_entities): - """Set up tuya sensors dynamically through tuya discovery.""" - - platform = config_entry.data[CONF_PLATFORM] - - async def async_discover_sensor(dev_ids): - """Discover and add a discovered tuya sensor.""" - if not dev_ids: - return - entities = await hass.async_add_executor_job( - _setup_entities, - hass, - dev_ids, - platform, - ) - async_add_entities(entities) - - async_dispatcher_connect( - hass, TUYA_DISCOVERY_NEW.format(SENSOR_DOMAIN), async_discover_sensor - ) - - devices_ids = hass.data[DOMAIN]["pending"].pop(SENSOR_DOMAIN) - await async_discover_sensor(devices_ids) - - -def _setup_entities(hass, dev_ids, platform): - """Set up Tuya Scene.""" - tuya = hass.data[DOMAIN][TUYA_DATA] +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up tuya scenes.""" entities = [] - for dev_id in dev_ids: - device = tuya.get_device_by_id(dev_id) - if device is None: - continue - entities.append(TuyaScene(device, platform)) - return entities + + home_manager = hass.data[DOMAIN][entry.entry_id][TUYA_HOME_MANAGER] + scenes = await hass.async_add_executor_job(home_manager.query_scenes) + for scene in scenes: + entities.append(TuyaHAScene(home_manager, scene)) + + async_add_entities(entities) -class TuyaScene(TuyaDevice, Scene): - """Tuya Scene.""" +class TuyaHAScene(Scene): + """Tuya Scene Remote.""" - def __init__(self, tuya, platform): - """Init Tuya scene.""" - super().__init__(tuya, platform) - self.entity_id = ENTITY_ID_FORMAT.format(tuya.object_id()) + def __init__(self, home_manager: TuyaHomeManager, scene: TuyaScene) -> None: + """Init Tuya Scene.""" + super().__init__() + self.home_manager = home_manager + self.scene = scene + + @property + def should_poll(self) -> bool: + """Hass should not poll.""" + return False + + @property + def unique_id(self) -> str | None: + """Return a unique ID.""" + return f"tys{self.scene.scene_id}" + + @property + def name(self) -> str | None: + """Return Tuya scene name.""" + return self.scene.name + + @property + def device_info(self): + """Return a device description for device registry.""" + return { + "identifiers": {(DOMAIN, f"{self.unique_id}")}, + "manufacturer": "tuya", + "name": self.scene.name, + "model": "Tuya Scene", + } + + @property + def available(self) -> bool: + """Return if the scene is enabled.""" + return self.scene.enabled def activate(self, **kwargs: Any) -> None: """Activate the scene.""" - self._tuya.activate() + self.home_manager.trigger_scene(self.scene.home_id, self.scene.scene_id) diff --git a/homeassistant/components/tuya/services.yaml b/homeassistant/components/tuya/services.yaml deleted file mode 100644 index 42fba3ad37b..00000000000 --- a/homeassistant/components/tuya/services.yaml +++ /dev/null @@ -1,9 +0,0 @@ -# Describes the format for available Tuya services - -pull_devices: - name: Pull devices - description: Pull device list from Tuya server. - -force_update: - name: Force update - description: Force all Tuya devices to pull data. diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index 61ea46c6a9f..91ca045e1f5 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -1,64 +1,29 @@ { "config": { + "flow_title": "Tuya configuration", "step": { - "user": { + "user":{ + "title":"Tuya Integration", + "data":{ + "tuya_project_type": "Tuya cloud project type" + } + }, + "login": { "title": "Tuya", - "description": "Enter your Tuya credentials.", + "description": "Enter your Tuya credential", "data": { - "country_code": "Your account country code (e.g., 1 for USA or 86 for China)", - "password": "[%key:common::config_flow::data::password%]", - "platform": "The app where your account is registered", - "username": "[%key:common::config_flow::data::username%]" + "endpoint": "Availability Zone", + "access_id": "Access ID", + "access_secret": "Access Secret", + "tuya_app_type": "Mobile App", + "country_code": "Country Code", + "username": "Account", + "password": "Password" } } }, - "abort": { - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" - }, "error": { "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" } - }, - "options": { - "abort": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" - }, - "step": { - "init": { - "title": "Configure Tuya Options", - "description": "Do not set pollings interval values too low or the calls will fail generating error message in the log", - "data": { - "discovery_interval": "Discovery device polling interval in seconds", - "query_device": "Select device that will use query method for faster status update", - "query_interval": "Query device polling interval in seconds", - "list_devices": "Select the devices to configure or leave empty to save configuration" - } - }, - "device": { - "title": "Configure Tuya Device", - "description": "Configure options to adjust displayed information for {device_type} device `{device_name}`", - "data": { - "support_color": "Force color support", - "brightness_range_mode": "Brightness range used by device", - "min_kelvin": "Min color temperature supported in kelvin", - "max_kelvin": "Max color temperature supported in kelvin", - "tuya_max_coltemp": "Max color temperature reported by device", - "unit_of_measurement": "Temperature unit used by device", - "temp_divider": "Temperature values divider (0 = use default)", - "curr_temp_divider": "Current Temperature value divider (0 = use default)", - "set_temp_divided": "Use divided Temperature value for set temperature command", - "temp_step_override": "Target Temperature step", - "min_temp": "Min target temperature (use min and max = 0 for default)", - "max_temp": "Max target temperature (use min and max = 0 for default)" - } - } - }, - "error": { - "dev_multi_type": "Multiple selected devices to configure must be of the same type", - "dev_not_config": "Device type not configurable", - "dev_not_found": "Device not found" - } } -} +} \ No newline at end of file diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index 3f5ff6db163..ab34ebbdfc0 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -1,74 +1,177 @@ +#!/usr/bin/env python3 """Support for Tuya switches.""" -from datetime import timedelta +from __future__ import annotations -from homeassistant.components.switch import ( - DOMAIN as SENSOR_DOMAIN, - ENTITY_ID_FORMAT, - SwitchEntity, -) -from homeassistant.const import CONF_PLATFORM +import logging +from typing import Any + +from tuya_iot import TuyaDevice, TuyaDeviceManager + +from homeassistant.components.switch import DOMAIN as DEVICE_DOMAIN, SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import TuyaDevice -from .const import DOMAIN, TUYA_DATA, TUYA_DISCOVERY_NEW +from .base import TuyaHaEntity +from .const import ( + DOMAIN, + TUYA_DEVICE_MANAGER, + TUYA_DISCOVERY_NEW, + TUYA_HA_DEVICES, + TUYA_HA_TUYA_MAP, +) -SCAN_INTERVAL = timedelta(seconds=15) +_LOGGER = logging.getLogger(__name__) + +TUYA_SUPPORT_TYPE = { + "kg", # Switch + "cz", # Socket + "pc", # Power Strip + "bh", # Smart Kettle + "dlq", # Breaker + "cwysj", # Pet Water Feeder + "kj", # Air Purifier + "xxj", # Diffuser +} + +# Switch(kg), Socket(cz), Power Strip(pc) +# https://developer.tuya.com/en/docs/iot/categorykgczpc?id=Kaiuz08zj1l4y +DPCODE_SWITCH = "switch" + +# Air Purifier +# https://developer.tuya.com/en/docs/iot/categorykj?id=Kaiuz1atqo5l7 +# Pet Water Feeder +# https://developer.tuya.com/en/docs/iot/f?id=K9gf46aewxem5 +DPCODE_ANION = "anion" # Air Purifier - Ionizer unit +# Air Purifier - Filter cartridge resetting; Pet Water Feeder - Filter cartridge resetting +DPCODE_FRESET = "filter_reset" +DPCODE_LIGHT = "light" # Air Purifier - Light +DPCODE_LOCK = "lock" # Air Purifier - Child lock +# Air Purifier - UV sterilization; Pet Water Feeder - UV sterilization +DPCODE_UV = "uv" +DPCODE_WET = "wet" # Air Purifier - Humidification unit +DPCODE_PRESET = "pump_reset" # Pet Water Feeder - Water pump resetting +DPCODE_WRESET = "water_reset" # Pet Water Feeder - Resetting of water usage days -async def async_setup_entry(hass, config_entry, async_add_entities): +DPCODE_START = "start" + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up tuya sensors dynamically through tuya discovery.""" + _LOGGER.debug("switch init") - platform = config_entry.data[CONF_PLATFORM] + hass.data[DOMAIN][entry.entry_id][TUYA_HA_TUYA_MAP][ + DEVICE_DOMAIN + ] = TUYA_SUPPORT_TYPE - async def async_discover_sensor(dev_ids): + async def async_discover_device(dev_ids): """Discover and add a discovered tuya sensor.""" + _LOGGER.debug("switch add-> %s", dev_ids) if not dev_ids: return - entities = await hass.async_add_executor_job( - _setup_entities, - hass, - dev_ids, - platform, - ) + entities = _setup_entities(hass, entry, dev_ids) async_add_entities(entities) - async_dispatcher_connect( - hass, TUYA_DISCOVERY_NEW.format(SENSOR_DOMAIN), async_discover_sensor + entry.async_on_unload( + async_dispatcher_connect( + hass, TUYA_DISCOVERY_NEW.format(DEVICE_DOMAIN), async_discover_device + ) ) - devices_ids = hass.data[DOMAIN]["pending"].pop(SENSOR_DOMAIN) - await async_discover_sensor(devices_ids) + device_manager = hass.data[DOMAIN][entry.entry_id][TUYA_DEVICE_MANAGER] + device_ids = [] + for (device_id, device) in device_manager.device_map.items(): + if device.category in TUYA_SUPPORT_TYPE: + device_ids.append(device_id) + await async_discover_device(device_ids) -def _setup_entities(hass, dev_ids, platform): +def _setup_entities(hass, entry: ConfigEntry, device_ids: list[str]) -> list[Entity]: """Set up Tuya Switch device.""" - tuya = hass.data[DOMAIN][TUYA_DATA] - entities = [] - for dev_id in dev_ids: - device = tuya.get_device_by_id(dev_id) + device_manager = hass.data[DOMAIN][entry.entry_id][TUYA_DEVICE_MANAGER] + entities: list[Entity] = [] + for device_id in device_ids: + device = device_manager.device_map[device_id] if device is None: continue - entities.append(TuyaSwitch(device, platform)) + + for function in device.function: + tuya_ha_switch = None + if device.category == "kj": + if function in [ + DPCODE_ANION, + DPCODE_FRESET, + DPCODE_LIGHT, + DPCODE_LOCK, + DPCODE_UV, + DPCODE_WET, + ]: + tuya_ha_switch = TuyaHaSwitch(device, device_manager, function) + # Main device switch is handled by the Fan object + elif device.category == "cwysj": + if function in [DPCODE_FRESET, DPCODE_UV, DPCODE_PRESET, DPCODE_WRESET]: + tuya_ha_switch = TuyaHaSwitch(device, device_manager, function) + + if function.startswith(DPCODE_SWITCH): + # Main device switch + tuya_ha_switch = TuyaHaSwitch(device, device_manager, function) + else: + if function.startswith(DPCODE_START): + tuya_ha_switch = TuyaHaSwitch(device, device_manager, function) + if function.startswith(DPCODE_SWITCH): + tuya_ha_switch = TuyaHaSwitch(device, device_manager, function) + + if tuya_ha_switch is not None: + entities.append(tuya_ha_switch) + hass.data[DOMAIN][entry.entry_id][TUYA_HA_DEVICES].add( + tuya_ha_switch.tuya_device.id + ) return entities -class TuyaSwitch(TuyaDevice, SwitchEntity): +class TuyaHaSwitch(TuyaHaEntity, SwitchEntity): """Tuya Switch Device.""" - def __init__(self, tuya, platform): - """Init Tuya switch device.""" - super().__init__(tuya, platform) - self.entity_id = ENTITY_ID_FORMAT.format(tuya.object_id()) + dp_code_switch = DPCODE_SWITCH + dp_code_start = DPCODE_START + + def __init__( + self, device: TuyaDevice, device_manager: TuyaDeviceManager, dp_code: str = "" + ) -> None: + """Init TuyaHaSwitch.""" + super().__init__(device, device_manager) + + self.dp_code = dp_code + self.channel = ( + dp_code.replace(DPCODE_SWITCH, "") + if dp_code.startswith(DPCODE_SWITCH) + else dp_code + ) @property - def is_on(self): + def unique_id(self) -> str | None: + """Return a unique ID.""" + return f"{super().unique_id}{self.channel}" + + @property + def name(self) -> str | None: + """Return Tuya device name.""" + return f"{self.tuya_device.name}{self.channel}" + + @property + def is_on(self) -> bool: """Return true if switch is on.""" - return self._tuya.state() + return self.tuya_device.status.get(self.dp_code, False) - def turn_on(self, **kwargs): + def turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" - self._tuya.turn_on() + self._send_command([{"code": self.dp_code, "value": True}]) - def turn_off(self, **kwargs): - """Turn the device off.""" - self._tuya.turn_off() + def turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + self._send_command([{"code": self.dp_code, "value": False}]) diff --git a/homeassistant/components/tuya/translations/af.json b/homeassistant/components/tuya/translations/af.json deleted file mode 100644 index 71ac741b6b8..00000000000 --- a/homeassistant/components/tuya/translations/af.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "options": { - "error": { - "dev_not_config": "Ger\u00e4tetyp nicht konfigurierbar", - "dev_not_found": "Ger\u00e4t nicht gefunden" - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/ca.json b/homeassistant/components/tuya/translations/ca.json deleted file mode 100644 index 62fad2ad47f..00000000000 --- a/homeassistant/components/tuya/translations/ca.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "config": { - "abort": { - "cannot_connect": "Ha fallat la connexi\u00f3", - "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", - "single_instance_allowed": "Ja configurat. Nom\u00e9s \u00e9s possible una sola configuraci\u00f3." - }, - "error": { - "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida" - }, - "flow_title": "Configuraci\u00f3 de Tuya", - "step": { - "user": { - "data": { - "country_code": "El teu codi de pa\u00eds (per exemple, 1 per l'EUA o 86 per la Xina)", - "password": "Contrasenya", - "platform": "L'aplicaci\u00f3 on es registra el teu compte", - "username": "Nom d'usuari" - }, - "description": "Introdueix les teves credencial de Tuya.", - "title": "Tuya" - } - } - }, - "options": { - "abort": { - "cannot_connect": "Ha fallat la connexi\u00f3" - }, - "error": { - "dev_multi_type": "Per configurar una selecci\u00f3 de m\u00faltiples dispositius, aquests han de ser del mateix tipus", - "dev_not_config": "El tipus d'aquest dispositiu no \u00e9s configurable", - "dev_not_found": "No s'ha trobat el dispositiu." - }, - "step": { - "device": { - "data": { - "brightness_range_mode": "Rang de brillantor utilitzat pel dispositiu", - "curr_temp_divider": "Divisor del valor de temperatura actual (0 = predeterminat)", - "max_kelvin": "Temperatura del color m\u00e0xima suportada, en Kelvin", - "max_temp": "Temperatura desitjada m\u00e0xima (utilitza min i max = 0 per defecte)", - "min_kelvin": "Temperatura del color m\u00ednima suportada, en Kelvin", - "min_temp": "Temperatura desitjada m\u00ednima (utilitza min i max = 0 per defecte)", - "set_temp_divided": "Utilitza el valor de temperatura dividit per a ordres de configuraci\u00f3 de temperatura", - "support_color": "For\u00e7a el suport de color", - "temp_divider": "Divisor del valor de temperatura (0 = predeterminat)", - "temp_step_override": "Pas de temperatura objectiu", - "tuya_max_coltemp": "Temperatura de color m\u00e0xima enviada pel dispositiu", - "unit_of_measurement": "Unitat de temperatura utilitzada pel dispositiu" - }, - "description": "Configura les opcions per ajustar la informaci\u00f3 mostrada pel dispositiu {device_type} `{device_name}`", - "title": "Configuraci\u00f3 de dispositiu Tuya" - }, - "init": { - "data": { - "discovery_interval": "Interval de sondeig del dispositiu de descoberta, en segons", - "list_devices": "Selecciona els dispositius a configurar o deixa-ho buit per desar la configuraci\u00f3", - "query_device": "Selecciona el dispositiu que utilitzar\u00e0 m\u00e8tode de consulta, per actualitzacions d'estat m\u00e9s freq\u00fcents", - "query_interval": "Interval de sondeig de consultes del dispositiu, en segons" - }, - "description": "No estableixis valors d'interval de sondeig massa baixos ja que les crides fallaran i generaran missatges d'error al registre", - "title": "Configuraci\u00f3 d'opcions de Tuya" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/cs.json b/homeassistant/components/tuya/translations/cs.json deleted file mode 100644 index 1dda4ea6df7..00000000000 --- a/homeassistant/components/tuya/translations/cs.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "config": { - "abort": { - "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", - "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed", - "single_instance_allowed": "Ji\u017e nastaveno. Je mo\u017en\u00e1 pouze jedin\u00e1 konfigurace." - }, - "error": { - "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed" - }, - "flow_title": "Konfigurace Tuya", - "step": { - "user": { - "data": { - "country_code": "K\u00f3d zem\u011b va\u0161eho \u00fa\u010dtu (nap\u0159. 1 pro USA nebo 86 pro \u010c\u00ednu)", - "password": "Heslo", - "platform": "Aplikace, ve kter\u00e9 m\u00e1te zaregistrovan\u00fd \u00fa\u010det", - "username": "U\u017eivatelsk\u00e9 jm\u00e9no" - }, - "description": "Zadejte sv\u00e9 p\u0159ihla\u0161ovac\u00ed \u00fadaje k Tuya.", - "title": "Tuya" - } - } - }, - "options": { - "abort": { - "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit" - }, - "error": { - "dev_multi_type": "V\u00edce vybran\u00fdch za\u0159\u00edzen\u00ed k nastaven\u00ed mus\u00ed b\u00fdt stejn\u00e9ho typu", - "dev_not_config": "Typ za\u0159\u00edzen\u00ed nelze nastavit", - "dev_not_found": "Za\u0159\u00edzen\u00ed nenalezeno" - }, - "step": { - "device": { - "data": { - "brightness_range_mode": "Rozsah jasu pou\u017e\u00edvan\u00fd za\u0159\u00edzen\u00edm", - "max_kelvin": "Maxim\u00e1ln\u00ed podporovan\u00e1 teplota barev v kelvinech", - "max_temp": "Maxim\u00e1ln\u00ed c\u00edlov\u00e1 teplota (pou\u017eijte min a max = 0 jako v\u00fdchoz\u00ed)", - "min_kelvin": "Maxim\u00e1ln\u00ed podporovan\u00e1 teplota barev v kelvinech", - "min_temp": "Minim\u00e1ln\u00ed c\u00edlov\u00e1 teplota (pou\u017eijte min a max = 0 jako v\u00fdchoz\u00ed)", - "support_color": "Vynutit podporu barev", - "tuya_max_coltemp": "Maxim\u00e1ln\u00ed teplota barev nahl\u00e1\u0161en\u00e1 za\u0159\u00edzen\u00edm", - "unit_of_measurement": "Jednotka teploty pou\u017e\u00edvan\u00e1 za\u0159\u00edzen\u00edm" - }, - "title": "Nastavte za\u0159\u00edzen\u00ed Tuya" - }, - "init": { - "data": { - "discovery_interval": "Interval objevov\u00e1n\u00ed za\u0159\u00edzen\u00ed v sekund\u00e1ch", - "list_devices": "Vyberte za\u0159\u00edzen\u00ed, kter\u00e1 chcete nastavit, nebo ponechte pr\u00e1zdn\u00e9, abyste konfiguraci ulo\u017eili", - "query_device": "Vyberte za\u0159\u00edzen\u00ed, kter\u00e9 bude pou\u017e\u00edvat metodu dotaz\u016f pro rychlej\u0161\u00ed aktualizaci stavu", - "query_interval": "Interval dotazov\u00e1n\u00ed za\u0159\u00edzen\u00ed v sekund\u00e1ch" - }, - "description": "Nenastavujte intervalu dotazov\u00e1n\u00ed p\u0159\u00edli\u0161 n\u00edzk\u00e9 hodnoty, jinak se dotazov\u00e1n\u00ed nezda\u0159\u00ed a bude generovat chybov\u00e9 zpr\u00e1vy do logu", - "title": "Nastavte mo\u017enosti Tuya" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/de.json b/homeassistant/components/tuya/translations/de.json deleted file mode 100644 index 54fd3de7cbf..00000000000 --- a/homeassistant/components/tuya/translations/de.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "config": { - "abort": { - "cannot_connect": "Verbindung fehlgeschlagen", - "invalid_auth": "Ung\u00fcltige Authentifizierung", - "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich." - }, - "error": { - "invalid_auth": "Ung\u00fcltige Authentifizierung" - }, - "flow_title": "Tuya Konfiguration", - "step": { - "user": { - "data": { - "country_code": "L\u00e4ndercode deines Kontos (z. B. 1 f\u00fcr USA oder 86 f\u00fcr China)", - "password": "Passwort", - "platform": "Die App, in der dein Konto registriert ist", - "username": "Benutzername" - }, - "description": "Gib deine Tuya-Anmeldeinformationen ein.", - "title": "Tuya" - } - } - }, - "options": { - "abort": { - "cannot_connect": "Verbindung fehlgeschlagen" - }, - "error": { - "dev_multi_type": "Mehrere ausgew\u00e4hlte Ger\u00e4te zur Konfiguration m\u00fcssen vom gleichen Typ sein", - "dev_not_config": "Ger\u00e4tetyp nicht konfigurierbar", - "dev_not_found": "Ger\u00e4t nicht gefunden" - }, - "step": { - "device": { - "data": { - "brightness_range_mode": "Vom Ger\u00e4t genutzter Helligkeitsbereich", - "curr_temp_divider": "Aktueller Temperaturwert-Teiler (0 = Standard verwenden)", - "max_kelvin": "Maximal unterst\u00fctzte Farbtemperatur in Kelvin", - "max_temp": "Maximale Solltemperatur (f\u00fcr Voreinstellung min und max = 0 verwenden)", - "min_kelvin": "Minimale unterst\u00fctzte Farbtemperatur in Kelvin", - "min_temp": "Minimal Solltemperatur (f\u00fcr Voreinstellung min und max = 0 verwenden)", - "set_temp_divided": "Geteilten Temperaturwert f\u00fcr Solltemperaturbefehl verwenden", - "support_color": "Farbunterst\u00fctzung erzwingen", - "temp_divider": "Teiler f\u00fcr Temperaturwerte (0 = Standard verwenden)", - "temp_step_override": "Zieltemperaturschritt", - "tuya_max_coltemp": "Vom Ger\u00e4t gemeldete maximale Farbtemperatur", - "unit_of_measurement": "Vom Ger\u00e4t verwendete Temperatureinheit" - }, - "description": "Optionen zur Anpassung der angezeigten Informationen f\u00fcr das Ger\u00e4t `{device_name}` vom Typ: {device_type}konfigurieren", - "title": "Tuya-Ger\u00e4t konfigurieren" - }, - "init": { - "data": { - "discovery_interval": "Abfrageintervall f\u00fcr Ger\u00e4teabruf in Sekunden", - "list_devices": "W\u00e4hle die zu konfigurierenden Ger\u00e4te aus oder lasse sie leer, um die Konfiguration zu speichern", - "query_device": "W\u00e4hle ein Ger\u00e4t aus, das die Abfragemethode f\u00fcr eine schnellere Statusaktualisierung verwendet.", - "query_interval": "Ger\u00e4teabrufintervall in Sekunden" - }, - "description": "Stelle das Abfrageintervall nicht zu niedrig ein, sonst schlagen die Aufrufe fehl und erzeugen eine Fehlermeldung im Protokoll", - "title": "Tuya-Optionen konfigurieren" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/en.json b/homeassistant/components/tuya/translations/en.json index ee304ff30cd..631f0b7172f 100644 --- a/homeassistant/components/tuya/translations/en.json +++ b/homeassistant/components/tuya/translations/en.json @@ -1,65 +1,29 @@ { "config": { - "abort": { - "cannot_connect": "Failed to connect", - "invalid_auth": "Invalid authentication", - "single_instance_allowed": "Already configured. Only a single configuration possible." - }, "error": { "invalid_auth": "Invalid authentication" }, "flow_title": "Tuya configuration", "step": { - "user": { + "user":{ + "title":"Tuya Integration", + "data":{ + "tuya_project_type": "Tuya cloud project type" + } + }, + "login": { "data": { - "country_code": "Your account country code (e.g., 1 for USA or 86 for China)", - "password": "Password", - "platform": "The app where your account is registered", - "username": "Username" + "endpoint": "Availability Zone", + "access_id": "Access ID", + "access_secret": "Access Secret", + "tuya_app_type": "Mobile App", + "country_code": "Country Code", + "username": "Account", + "password": "Password" }, - "description": "Enter your Tuya credentials.", + "description": "Enter your Tuya credential.", "title": "Tuya" } } - }, - "options": { - "abort": { - "cannot_connect": "Failed to connect" - }, - "error": { - "dev_multi_type": "Multiple selected devices to configure must be of the same type", - "dev_not_config": "Device type not configurable", - "dev_not_found": "Device not found" - }, - "step": { - "device": { - "data": { - "brightness_range_mode": "Brightness range used by device", - "curr_temp_divider": "Current Temperature value divider (0 = use default)", - "max_kelvin": "Max color temperature supported in kelvin", - "max_temp": "Max target temperature (use min and max = 0 for default)", - "min_kelvin": "Min color temperature supported in kelvin", - "min_temp": "Min target temperature (use min and max = 0 for default)", - "set_temp_divided": "Use divided Temperature value for set temperature command", - "support_color": "Force color support", - "temp_divider": "Temperature values divider (0 = use default)", - "temp_step_override": "Target Temperature step", - "tuya_max_coltemp": "Max color temperature reported by device", - "unit_of_measurement": "Temperature unit used by device" - }, - "description": "Configure options to adjust displayed information for {device_type} device `{device_name}`", - "title": "Configure Tuya Device" - }, - "init": { - "data": { - "discovery_interval": "Discovery device polling interval in seconds", - "list_devices": "Select the devices to configure or leave empty to save configuration", - "query_device": "Select device that will use query method for faster status update", - "query_interval": "Query device polling interval in seconds" - }, - "description": "Do not set pollings interval values too low or the calls will fail generating error message in the log", - "title": "Configure Tuya Options" - } - } } -} \ No newline at end of file +} diff --git a/homeassistant/components/tuya/translations/es.json b/homeassistant/components/tuya/translations/es.json deleted file mode 100644 index 9c57a216888..00000000000 --- a/homeassistant/components/tuya/translations/es.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "config": { - "abort": { - "cannot_connect": "No se pudo conectar", - "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", - "single_instance_allowed": "Ya est\u00e1 configurado. Solo es posible una \u00fanica configuraci\u00f3n." - }, - "error": { - "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida" - }, - "flow_title": "Configuraci\u00f3n Tuya", - "step": { - "user": { - "data": { - "country_code": "C\u00f3digo de pais de tu cuenta (por ejemplo, 1 para USA o 86 para China)", - "password": "Contrase\u00f1a", - "platform": "La aplicaci\u00f3n en la cual registraste tu cuenta", - "username": "Usuario" - }, - "description": "Introduce tu credencial Tuya.", - "title": "Tuya" - } - } - }, - "options": { - "abort": { - "cannot_connect": "No se pudo conectar" - }, - "error": { - "dev_multi_type": "Los m\u00faltiples dispositivos seleccionados para configurar deben ser del mismo tipo", - "dev_not_config": "Tipo de dispositivo no configurable", - "dev_not_found": "Dispositivo no encontrado" - }, - "step": { - "device": { - "data": { - "brightness_range_mode": "Rango de brillo utilizado por el dispositivo", - "curr_temp_divider": "Divisor del valor de la temperatura actual (0 = usar valor por defecto)", - "max_kelvin": "Temperatura de color m\u00e1xima admitida en kelvin", - "max_temp": "Temperatura objetivo m\u00e1xima (usa m\u00edn. y m\u00e1x. = 0 por defecto)", - "min_kelvin": "Temperatura de color m\u00ednima soportada en kelvin", - "min_temp": "Temperatura objetivo m\u00ednima (usa m\u00edn. y m\u00e1x. = 0 por defecto)", - "set_temp_divided": "Use el valor de temperatura dividido para el comando de temperatura establecida", - "support_color": "Forzar soporte de color", - "temp_divider": "Divisor de los valores de temperatura (0 = usar valor por defecto)", - "temp_step_override": "Temperatura deseada", - "tuya_max_coltemp": "Temperatura de color m\u00e1xima notificada por dispositivo", - "unit_of_measurement": "Unidad de temperatura utilizada por el dispositivo" - }, - "description": "Configura las opciones para ajustar la informaci\u00f3n mostrada para {device_type} dispositivo `{device_name}`", - "title": "Configurar dispositivo Tuya" - }, - "init": { - "data": { - "discovery_interval": "Intervalo de sondeo del descubrimiento al dispositivo en segundos", - "list_devices": "Selecciona los dispositivos a configurar o d\u00e9jalos en blanco para guardar la configuraci\u00f3n", - "query_device": "Selecciona el dispositivo que utilizar\u00e1 el m\u00e9todo de consulta para una actualizaci\u00f3n de estado m\u00e1s r\u00e1pida", - "query_interval": "Intervalo de sondeo de la consulta al dispositivo en segundos" - }, - "description": "No establezcas valores de intervalo de sondeo demasiado bajos o las llamadas fallar\u00e1n generando un mensaje de error en el registro", - "title": "Configurar opciones de Tuya" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/et.json b/homeassistant/components/tuya/translations/et.json deleted file mode 100644 index 48161f552b8..00000000000 --- a/homeassistant/components/tuya/translations/et.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "config": { - "abort": { - "cannot_connect": "\u00dchendamine nurjus", - "invalid_auth": "Tuvastamise viga", - "single_instance_allowed": "Juba seadistatud. V\u00f5imalik on ainult \u00fcks sidumine." - }, - "error": { - "invalid_auth": "Tuvastamise viga" - }, - "flow_title": "Tuya seaded", - "step": { - "user": { - "data": { - "country_code": "Konto riigikood (nt 1 USA v\u00f5i 372 Eesti)", - "password": "Salas\u00f5na", - "platform": "\u00c4pp kus konto registreeriti", - "username": "Kasutajanimi" - }, - "description": "Sisesta oma Tuya konto andmed.", - "title": "" - } - } - }, - "options": { - "abort": { - "cannot_connect": "\u00dchendamine nurjus" - }, - "error": { - "dev_multi_type": "Mitu h\u00e4\u00e4lestatavat seadet peavad olema sama t\u00fc\u00fcpi", - "dev_not_config": "Seda t\u00fc\u00fcpi seade pole seadistatav", - "dev_not_found": "Seadet ei leitud" - }, - "step": { - "device": { - "data": { - "brightness_range_mode": "Seadme kasutatav heledusvahemik", - "curr_temp_divider": "Praeguse temperatuuri v\u00e4\u00e4rtuse eraldaja (0 = kasuta vaikev\u00e4\u00e4rtust)", - "max_kelvin": "Maksimaalne v\u00f5imalik v\u00e4rvitemperatuur (Kelvinites)", - "max_temp": "Maksimaalne sihttemperatuur (vaikimisi kasuta min ja max = 0)", - "min_kelvin": "Minimaalne v\u00f5imalik v\u00e4rvitemperatuur (Kelvinites)", - "min_temp": "Minimaalne sihttemperatuur (vaikimisi kasuta min ja max = 0)", - "set_temp_divided": "M\u00e4\u00e4ratud temperatuuri k\u00e4su jaoks kasuta jagatud temperatuuri v\u00e4\u00e4rtust", - "support_color": "Luba v\u00e4rvuse juhtimine", - "temp_divider": "Temperatuuri v\u00e4\u00e4rtuse eraldaja (0 = kasuta vaikev\u00e4\u00e4rtust)", - "temp_step_override": "Sihttemperatuuri samm", - "tuya_max_coltemp": "Seadme teatatud maksimaalne v\u00e4rvitemperatuur", - "unit_of_measurement": "Seadme temperatuuri\u00fchik" - }, - "description": "Suvandid \u00fcksuse {device_type} {device_name} kuvatava teabe muutmiseks", - "title": "H\u00e4\u00e4lesta Tuya seade" - }, - "init": { - "data": { - "discovery_interval": "Seadme leidmisp\u00e4ringute intervall (sekundites)", - "list_devices": "Vali seadistatavad seadmed v\u00f5i j\u00e4ta s\u00e4tete salvestamiseks t\u00fchjaks", - "query_device": "Vali seade, mis kasutab oleku kiiremaks v\u00e4rskendamiseks p\u00e4ringumeetodit", - "query_interval": "P\u00e4ringute intervall (sekundites)" - }, - "description": "\u00c4ra m\u00e4\u00e4ra k\u00fcsitlusintervalli v\u00e4\u00e4rtusi liiga madalaks, vastasel korral v\u00f5ivad p\u00e4ringud logis t\u00f5rketeate genereerida", - "title": "Tuya suvandite seadistamine" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/fi.json b/homeassistant/components/tuya/translations/fi.json deleted file mode 100644 index 3c74a9b8eeb..00000000000 --- a/homeassistant/components/tuya/translations/fi.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "config": { - "flow_title": "Tuya-asetukset", - "step": { - "user": { - "data": { - "country_code": "Tilisi maakoodi (esim. 1 Yhdysvalloissa, 358 Suomessa)", - "password": "Salasana", - "platform": "Sovellus, johon tili rekister\u00f6id\u00e4\u00e4n", - "username": "K\u00e4ytt\u00e4j\u00e4tunnus" - }, - "description": "Anna Tuya-tunnistetietosi.", - "title": "Tuya" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/fr.json b/homeassistant/components/tuya/translations/fr.json deleted file mode 100644 index b741d3f9377..00000000000 --- a/homeassistant/components/tuya/translations/fr.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "config": { - "abort": { - "cannot_connect": "\u00c9chec de connexion", - "invalid_auth": "Authentification invalide", - "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." - }, - "error": { - "invalid_auth": "Authentification invalide" - }, - "flow_title": "Configuration Tuya", - "step": { - "user": { - "data": { - "country_code": "Le code de pays de votre compte (par exemple, 1 pour les \u00c9tats-Unis ou 86 pour la Chine)", - "password": "Mot de passe", - "platform": "L'application dans laquelle votre compte est enregistr\u00e9", - "username": "Nom d'utilisateur" - }, - "description": "Saisissez vos informations d'identification Tuya.", - "title": "Tuya" - } - } - }, - "options": { - "abort": { - "cannot_connect": "\u00c9chec de connexion" - }, - "error": { - "dev_multi_type": "Plusieurs p\u00e9riph\u00e9riques s\u00e9lectionn\u00e9s \u00e0 configurer doivent \u00eatre du m\u00eame type", - "dev_not_config": "Type d'appareil non configurable", - "dev_not_found": "Appareil non trouv\u00e9" - }, - "step": { - "device": { - "data": { - "brightness_range_mode": "Plage de luminosit\u00e9 utilis\u00e9e par l'appareil", - "curr_temp_divider": "Diviseur de valeur de temp\u00e9rature actuelle (0 = utiliser la valeur par d\u00e9faut)", - "max_kelvin": "Temp\u00e9rature de couleur maximale prise en charge en Kelvin", - "max_temp": "Temp\u00e9rature cible maximale (utilisez min et max = 0 par d\u00e9faut)", - "min_kelvin": "Temp\u00e9rature de couleur minimale prise en charge en kelvin", - "min_temp": "Temp\u00e9rature cible minimale (utilisez min et max = 0 par d\u00e9faut)", - "set_temp_divided": "Utilisez la valeur de temp\u00e9rature divis\u00e9e pour la commande de temp\u00e9rature d\u00e9finie", - "support_color": "Forcer la prise en charge des couleurs", - "temp_divider": "Diviseur de valeurs de temp\u00e9rature (0 = utiliser la valeur par d\u00e9faut)", - "temp_step_override": "Pas de temp\u00e9rature cible", - "tuya_max_coltemp": "Temp\u00e9rature de couleur maximale rapport\u00e9e par l'appareil", - "unit_of_measurement": "Unit\u00e9 de temp\u00e9rature utilis\u00e9e par l'appareil" - }, - "description": "Configurer les options pour ajuster les informations affich\u00e9es pour l'appareil {device_type} ` {device_name} `", - "title": "Configurer l'appareil Tuya" - }, - "init": { - "data": { - "discovery_interval": "Intervalle de d\u00e9couverte de l'appareil en secondes", - "list_devices": "S\u00e9lectionnez les appareils \u00e0 configurer ou laissez vide pour enregistrer la configuration", - "query_device": "S\u00e9lectionnez l'appareil qui utilisera la m\u00e9thode de requ\u00eate pour une mise \u00e0 jour plus rapide de l'\u00e9tat", - "query_interval": "Intervalle d'interrogation de l'appareil en secondes" - }, - "description": "Ne d\u00e9finissez pas des valeurs d'intervalle d'interrogation trop faibles ou les appels \u00e9choueront \u00e0 g\u00e9n\u00e9rer un message d'erreur dans le journal", - "title": "Configurer les options de Tuya" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/he.json b/homeassistant/components/tuya/translations/he.json deleted file mode 100644 index 44a7699e511..00000000000 --- a/homeassistant/components/tuya/translations/he.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "config": { - "abort": { - "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", - "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", - "single_instance_allowed": "\u05ea\u05e6\u05d5\u05e8\u05ea\u05d5 \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e8\u05e7 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05d0\u05e4\u05e9\u05e8\u05d9\u05ea." - }, - "error": { - "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9" - }, - "flow_title": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d8\u05d5\u05d9\u05d4", - "step": { - "user": { - "data": { - "country_code": "\u05e7\u05d5\u05d3 \u05de\u05d3\u05d9\u05e0\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05e9\u05dc\u05da (\u05dc\u05de\u05e9\u05dc, 1 \u05dc\u05d0\u05e8\u05d4\"\u05d1 \u05d0\u05d5 972 \u05dc\u05d9\u05e9\u05e8\u05d0\u05dc)", - "password": "\u05e1\u05d9\u05e1\u05de\u05d4", - "platform": "\u05d4\u05d9\u05d9\u05e9\u05d5\u05dd \u05d1\u05d5 \u05e8\u05e9\u05d5\u05dd \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05e9\u05dc\u05da", - "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" - }, - "description": "\u05d4\u05d6\u05df \u05d0\u05ea \u05d0\u05d9\u05e9\u05d5\u05e8\u05d9 \u05d8\u05d5\u05d9\u05d4 \u05e9\u05dc\u05da.", - "title": "Tuya" - } - } - }, - "options": { - "abort": { - "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/hu.json b/homeassistant/components/tuya/translations/hu.json deleted file mode 100644 index 054e6443d2a..00000000000 --- a/homeassistant/components/tuya/translations/hu.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "config": { - "abort": { - "cannot_connect": "Sikertelen csatlakoz\u00e1s", - "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", - "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges." - }, - "error": { - "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s" - }, - "flow_title": "Tuya konfigur\u00e1ci\u00f3", - "step": { - "user": { - "data": { - "country_code": "A fi\u00f3k orsz\u00e1gk\u00f3dja (pl. 1 USA, 36 Magyarorsz\u00e1g, vagy 86 K\u00edna)", - "password": "Jelsz\u00f3", - "platform": "Az alkalmaz\u00e1s, ahol a fi\u00f3k regisztr\u00e1lt", - "username": "Felhaszn\u00e1l\u00f3n\u00e9v" - }, - "description": "Adja meg Tuya hiteles\u00edt\u0151 adatait.", - "title": "Tuya" - } - } - }, - "options": { - "abort": { - "cannot_connect": "A kapcsol\u00f3d\u00e1s nem siker\u00fclt" - }, - "error": { - "dev_multi_type": "T\u00f6bb kiv\u00e1lasztott konfigur\u00e1land\u00f3 eszk\u00f6znek azonos t\u00edpus\u00fanak kell lennie", - "dev_not_config": "Ez az eszk\u00f6zt\u00edpus nem konfigur\u00e1lhat\u00f3", - "dev_not_found": "Eszk\u00f6z nem tal\u00e1lhat\u00f3" - }, - "step": { - "device": { - "data": { - "brightness_range_mode": "Az eszk\u00f6z \u00e1ltal haszn\u00e1lt f\u00e9nyer\u0151 tartom\u00e1ny", - "curr_temp_divider": "Aktu\u00e1lis h\u0151m\u00e9rs\u00e9klet-\u00e9rt\u00e9k oszt\u00f3 (0 = alap\u00e9rtelmezetten)", - "max_kelvin": "Maxim\u00e1lis t\u00e1mogatott sz\u00ednh\u0151m\u00e9rs\u00e9klet kelvinben", - "max_temp": "Maxim\u00e1lis c\u00e9l-sz\u00ednh\u0151m\u00e9rs\u00e9klet (haszn\u00e1lja a min-t \u00e9s a max-ot = 0-t az alap\u00e9rtelmezett be\u00e1ll\u00edt\u00e1shoz)", - "min_kelvin": "Minimum t\u00e1mogatott sz\u00ednh\u0151m\u00e9rs\u00e9klet kelvinben", - "min_temp": "Min. C\u00e9l-sz\u00ednh\u0151m\u00e9rs\u00e9klet (alap\u00e9rtelmez\u00e9s szerint haszn\u00e1ljon min-t \u00e9s max-ot = 0-t az alap\u00e9rtelmezett be\u00e1ll\u00edt\u00e1shoz)", - "set_temp_divided": "A h\u0151m\u00e9rs\u00e9klet be\u00e1ll\u00edt\u00e1s\u00e1hoz osztott h\u0151m\u00e9rs\u00e9kleti \u00e9rt\u00e9ket haszn\u00e1ljon", - "support_color": "Sz\u00ednt\u00e1mogat\u00e1s k\u00e9nyszer\u00edt\u00e9se", - "temp_divider": "Sz\u00ednh\u0151m\u00e9rs\u00e9klet-\u00e9rt\u00e9kek oszt\u00f3ja (0 = alap\u00e9rtelmezett)", - "temp_step_override": "C\u00e9lh\u0151m\u00e9rs\u00e9klet l\u00e9pcs\u0151", - "tuya_max_coltemp": "Az eszk\u00f6z \u00e1ltal megadott maxim\u00e1lis sz\u00ednh\u0151m\u00e9rs\u00e9klet", - "unit_of_measurement": "Az eszk\u00f6z \u00e1ltal haszn\u00e1lt h\u0151m\u00e9rs\u00e9kleti egys\u00e9g" - }, - "description": "Konfigur\u00e1l\u00e1si lehet\u0151s\u00e9gek a(z) {device_type} t\u00edpus\u00fa `{device_name}` eszk\u00f6z megjelen\u00edtett inform\u00e1ci\u00f3inak be\u00e1ll\u00edt\u00e1s\u00e1hoz", - "title": "Tuya eszk\u00f6z konfigur\u00e1l\u00e1sa" - }, - "init": { - "data": { - "discovery_interval": "Felfedez\u0151 eszk\u00f6z lek\u00e9rdez\u00e9si intervalluma m\u00e1sodpercben", - "list_devices": "V\u00e1laszd ki a konfigur\u00e1lni k\u00edv\u00e1nt eszk\u00f6z\u00f6ket, vagy hagyd \u00fcresen a konfigur\u00e1ci\u00f3 ment\u00e9s\u00e9hez", - "query_device": "V\u00e1laszd ki azt az eszk\u00f6zt, amely a lek\u00e9rdez\u00e9si m\u00f3dszert haszn\u00e1lja a gyorsabb \u00e1llapotfriss\u00edt\u00e9shez", - "query_interval": "Eszk\u00f6z lek\u00e9rdez\u00e9si id\u0151k\u00f6ze m\u00e1sodpercben" - }, - "description": "Ne \u00e1ll\u00edtsd t\u00fal alacsonyra a lek\u00e9rdez\u00e9si intervallum \u00e9rt\u00e9keit, k\u00fcl\u00f6nben a h\u00edv\u00e1sok nem fognak hiba\u00fczenetet gener\u00e1lni a napl\u00f3ban", - "title": "Tuya be\u00e1ll\u00edt\u00e1sok konfigur\u00e1l\u00e1sa" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/id.json b/homeassistant/components/tuya/translations/id.json deleted file mode 100644 index bb338e12752..00000000000 --- a/homeassistant/components/tuya/translations/id.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "config": { - "abort": { - "cannot_connect": "Gagal terhubung", - "invalid_auth": "Autentikasi tidak valid", - "single_instance_allowed": "Sudah dikonfigurasi. Hanya satu konfigurasi yang diizinkan." - }, - "error": { - "invalid_auth": "Autentikasi tidak valid" - }, - "flow_title": "Konfigurasi Tuya", - "step": { - "user": { - "data": { - "country_code": "Kode negara akun Anda (mis., 1 untuk AS atau 86 untuk China)", - "password": "Kata Sandi", - "platform": "Aplikasi tempat akun Anda mendaftar", - "username": "Nama Pengguna" - }, - "description": "Masukkan kredensial Tuya Anda.", - "title": "Tuya" - } - } - }, - "options": { - "abort": { - "cannot_connect": "Gagal terhubung" - }, - "error": { - "dev_multi_type": "Untuk konfigurasi sekaligus, beberapa perangkat yang dipilih harus berjenis sama", - "dev_not_config": "Jenis perangkat tidak dapat dikonfigurasi", - "dev_not_found": "Perangkat tidak ditemukan" - }, - "step": { - "device": { - "data": { - "brightness_range_mode": "Rentang kecerahan yang digunakan oleh perangkat", - "curr_temp_divider": "Pembagi nilai suhu saat ini (0 = gunakan bawaan)", - "max_kelvin": "Suhu warna maksimal yang didukung dalam Kelvin", - "max_temp": "Suhu target maksimal (gunakan min dan maks = 0 untuk bawaan)", - "min_kelvin": "Suhu warna minimal yang didukung dalam Kelvin", - "min_temp": "Suhu target minimal (gunakan min dan maks = 0 untuk bawaan)", - "set_temp_divided": "Gunakan nilai suhu terbagi untuk mengirimkan perintah mengatur suhu", - "support_color": "Paksa dukungan warna", - "temp_divider": "Pembagi nilai suhu (0 = gunakan bawaan)", - "temp_step_override": "Langkah Suhu Target", - "tuya_max_coltemp": "Suhu warna maksimal yang dilaporkan oleh perangkat", - "unit_of_measurement": "Satuan suhu yang digunakan oleh perangkat" - }, - "description": "Konfigurasikan opsi untuk menyesuaikan informasi yang ditampilkan untuk perangkat {device_type} `{device_name}`", - "title": "Konfigurasi Perangkat Tuya" - }, - "init": { - "data": { - "discovery_interval": "Interval polling penemuan perangkat dalam detik", - "list_devices": "Pilih perangkat yang akan dikonfigurasi atau biarkan kosong untuk menyimpan konfigurasi", - "query_device": "Pilih perangkat yang akan menggunakan metode kueri untuk pembaruan status lebih cepat", - "query_interval": "Interval polling perangkat kueri dalam detik" - }, - "description": "Jangan atur nilai interval polling terlalu rendah karena panggilan akan gagal menghasilkan pesan kesalahan dalam log", - "title": "Konfigurasikan Opsi Tuya" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/it.json b/homeassistant/components/tuya/translations/it.json deleted file mode 100644 index a2a8dc87473..00000000000 --- a/homeassistant/components/tuya/translations/it.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "config": { - "abort": { - "cannot_connect": "Impossibile connettersi", - "invalid_auth": "Autenticazione non valida", - "single_instance_allowed": "Gi\u00e0 configurato. \u00c8 possibile una sola configurazione." - }, - "error": { - "invalid_auth": "Autenticazione non valida" - }, - "flow_title": "Configurazione di Tuya", - "step": { - "user": { - "data": { - "country_code": "Prefisso internazionale del tuo account (ad es. 1 per gli Stati Uniti o 86 per la Cina)", - "password": "Password", - "platform": "L'app in cui \u00e8 registrato il tuo account", - "username": "Nome utente" - }, - "description": "Inserisci le tue credenziali Tuya.", - "title": "Tuya" - } - } - }, - "options": { - "abort": { - "cannot_connect": "Impossibile connettersi" - }, - "error": { - "dev_multi_type": "Pi\u00f9 dispositivi selezionati da configurare devono essere dello stesso tipo", - "dev_not_config": "Tipo di dispositivo non configurabile", - "dev_not_found": "Dispositivo non trovato" - }, - "step": { - "device": { - "data": { - "brightness_range_mode": "Intervallo di luminosit\u00e0 utilizzato dal dispositivo", - "curr_temp_divider": "Divisore del valore della temperatura corrente (0 = usa il valore predefinito)", - "max_kelvin": "Temperatura colore massima supportata in kelvin", - "max_temp": "Temperatura di destinazione massima (utilizzare min e max = 0 per impostazione predefinita)", - "min_kelvin": "Temperatura colore minima supportata in kelvin", - "min_temp": "Temperatura di destinazione minima (utilizzare min e max = 0 per impostazione predefinita)", - "set_temp_divided": "Utilizzare il valore temperatura diviso per impostare il comando temperatura", - "support_color": "Forza il supporto del colore", - "temp_divider": "Divisore dei valori di temperatura (0 = utilizzare il valore predefinito)", - "temp_step_override": "Passo della temperatura da raggiungere", - "tuya_max_coltemp": "Temperatura di colore massima riportata dal dispositivo", - "unit_of_measurement": "Unit\u00e0 di temperatura utilizzata dal dispositivo" - }, - "description": "Configura le opzioni per regolare le informazioni visualizzate per il dispositivo {device_type} `{device_name}`", - "title": "Configura il dispositivo Tuya" - }, - "init": { - "data": { - "discovery_interval": "Intervallo di scansione di rilevamento dispositivo in secondi", - "list_devices": "Selezionare i dispositivi da configurare o lasciare vuoto per salvare la configurazione", - "query_device": "Selezionare il dispositivo che utilizzer\u00e0 il metodo di interrogazione per un pi\u00f9 rapido aggiornamento dello stato", - "query_interval": "Intervallo di scansione di interrogazione dispositivo in secondi" - }, - "description": "Non impostare valori dell'intervallo di scansione troppo bassi o le chiamate non riusciranno a generare un messaggio di errore nel registro", - "title": "Configura le opzioni Tuya" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/ka.json b/homeassistant/components/tuya/translations/ka.json deleted file mode 100644 index 7c80ef1ffba..00000000000 --- a/homeassistant/components/tuya/translations/ka.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "options": { - "error": { - "dev_multi_type": "\u10d9\u10dd\u10dc\u10e4\u10d8\u10d2\u10e3\u10e0\u10d0\u10ea\u10d8\u10d8\u10e1\u10d7\u10d5\u10d8\u10e1 \u10e8\u10d4\u10e0\u10e9\u10d4\u10e3\u10da\u10d8 \u10db\u10e0\u10d0\u10d5\u10da\u10dd\u10d1\u10d8\u10d7\u10d8 \u10db\u10dd\u10ec\u10e7\u10dd\u10d1\u10d8\u10da\u10dd\u10d1\u10d0 \u10e3\u10dc\u10d3\u10d0 \u10d8\u10e7\u10dd\u10e1 \u10d4\u10e0\u10d7\u10dc\u10d0\u10d8\u10e0\u10d8 \u10e2\u10d8\u10de\u10d8\u10e1", - "dev_not_config": "\u10db\u10dd\u10ec\u10e7\u10dd\u10d1\u10d8\u10da\u10dd\u10d1\u10d8\u10e1 \u10e2\u10d8\u10de\u10d8 \u10d0\u10e0 \u10d0\u10e0\u10d8\u10e1 \u10d9\u10dd\u10dc\u10e4\u10d8\u10d2\u10e3\u10e0\u10d8\u10e0\u10d4\u10d1\u10d0\u10d3\u10d8", - "dev_not_found": "\u10db\u10dd\u10ec\u10e7\u10dd\u10d1\u10d8\u10da\u10dd\u10d1\u10d0 \u10d5\u10d4\u10e0 \u10db\u10dd\u10d8\u10eb\u10d4\u10d1\u10dc\u10d0" - }, - "step": { - "device": { - "data": { - "brightness_range_mode": "\u10db\u10dd\u10ec\u10e7\u10dd\u10d1\u10d8\u10da\u10dd\u10d1\u10d8\u10e1 \u10db\u10d8\u10d4\u10e0 \u10d2\u10d0\u10db\u10dd\u10e7\u10d4\u10dc\u10d4\u10d1\u10e3\u10da\u10d8 \u10e1\u10d8\u10d9\u10d0\u10e8\u10d9\u10d0\u10e8\u10d8\u10e1 \u10d3\u10d8\u10d0\u10de\u10d0\u10d6\u10dd\u10dc\u10d8", - "curr_temp_divider": "\u10db\u10d8\u10db\u10d3\u10d8\u10dc\u10d0\u10e0\u10d4 \u10e2\u10d4\u10db\u10d4\u10de\u10e0\u10d0\u10e2\u10e3\u10e0\u10d8\u10e1 \u10db\u10dc\u10d8\u10e8\u10d5\u10dc\u10d4\u10da\u10d8\u10e1 \u10d2\u10d0\u10db\u10e7\u10dd\u10e4\u10d8 (0 - \u10dc\u10d0\u10d2\u10e3\u10da\u10d8\u10e1\u10ee\u10db\u10d4\u10d5\u10d8)", - "max_kelvin": "\u10db\u10ee\u10d0\u10e0\u10d3\u10d0\u10ed\u10d4\u10e0\u10d8\u10da\u10d8 \u10db\u10d0\u10e5\u10e1\u10d8\u10db\u10d0\u10da\u10e3\u10e0\u10d8 \u10e2\u10d4\u10db\u10de\u10d4\u10e0\u10d0\u10e2\u10e3\u10e0\u10d8\u10e1 \u10e4\u10d4\u10e0\u10d8 \u10d9\u10d4\u10da\u10d5\u10d8\u10dc\u10d4\u10d1\u10e8\u10d8", - "max_temp": "\u10db\u10d0\u10e5\u10e1\u10d8\u10db\u10d0\u10da\u10e3\u10e0\u10d8 \u10db\u10d8\u10d6\u10dc\u10dd\u10d1\u10e0\u10d8\u10d5\u10d8 \u10e2\u10d4\u10db\u10de\u10d4\u10e0\u10d0\u10e2\u10e3\u10e0\u10d0 (\u10db\u10d8\u10dc\u10d8\u10db\u10d0\u10da\u10e3\u10e0\u10d8 \u10d3\u10d0 \u10db\u10d0\u10e5\u10e1\u10d8\u10db\u10d0\u10da\u10e3\u10e0\u10d8\u10e1\u10d0\u10d7\u10d5\u10d8\u10e1 \u10dc\u10d0\u10d2\u10e3\u10da\u10d8\u10e1\u10ee\u10db\u10d4\u10d5\u10d8\u10d0 0)", - "min_kelvin": "\u10db\u10ee\u10d0\u10e0\u10d3\u10d0\u10ed\u10d4\u10e0\u10d8\u10da\u10d8 \u10db\u10d8\u10dc\u10d8\u10db\u10d0\u10da\u10e3\u10e0\u10d8 \u10e2\u10d4\u10db\u10de\u10d4\u10e0\u10d0\u10e2\u10e3\u10e0\u10d8\u10e1 \u10e4\u10d4\u10e0\u10d8 \u10d9\u10d4\u10da\u10d5\u10d8\u10dc\u10d4\u10d1\u10e8\u10d8", - "min_temp": "\u10db\u10d8\u10dc\u10d8\u10db\u10d0\u10da\u10e3\u10e0\u10d8 \u10db\u10d8\u10d6\u10dc\u10dd\u10d1\u10e0\u10d8\u10d5\u10d8 \u10e2\u10d4\u10db\u10de\u10d4\u10e0\u10d0\u10e2\u10e3\u10e0\u10d0 (\u10db\u10d8\u10dc\u10d8\u10db\u10d0\u10da\u10e3\u10e0\u10d8 \u10d3\u10d0 \u10db\u10d0\u10e5\u10e1\u10d8\u10db\u10d0\u10da\u10e3\u10e0\u10d8\u10e1\u10d0\u10d7\u10d5\u10d8\u10e1 \u10dc\u10d0\u10d2\u10e3\u10da\u10d8\u10e1\u10ee\u10db\u10d4\u10d5\u10d8\u10d0 0)", - "support_color": "\u10e4\u10d4\u10e0\u10d8\u10e1 \u10db\u10ee\u10d0\u10e0\u10d3\u10d0\u10ed\u10d4\u10e0\u10d0 \u10d8\u10eb\u10e3\u10da\u10d4\u10d1\u10d8\u10d7", - "temp_divider": "\u10e2\u10d4\u10db\u10de\u10d4\u10e0\u10d0\u10e2\u10e3\u10e0\u10e3\u10da\u10d8 \u10db\u10dc\u10d8\u10e8\u10d5\u10dc\u10d4\u10da\u10d8\u10e1 \u10d2\u10d0\u10db\u10e7\u10dd\u10e4\u10d8 (0 = \u10dc\u10d0\u10d2\u10e3\u10da\u10d8\u10e1\u10ee\u10db\u10d4\u10d5\u10d8)", - "tuya_max_coltemp": "\u10db\u10dd\u10ec\u10e7\u10dd\u10d1\u10d8\u10da\u10dd\u10d1\u10d8\u10e1 \u10db\u10d8\u10d4\u10e0 \u10db\u10dd\u10ec\u10dd\u10d3\u10d4\u10d1\u10e3\u10da\u10d8 \u10e4\u10d4\u10e0\u10d8\u10e1 \u10db\u10d0\u10e5\u10e1\u10d8\u10db\u10d0\u10da\u10e3\u10e0\u10d8 \u10e2\u10d4\u10db\u10de\u10d4\u10e0\u10d0\u10e2\u10e3\u10e0\u10d0", - "unit_of_measurement": "\u10db\u10dd\u10ec\u10e7\u10dd\u10d1\u10d8\u10da\u10dd\u10d1\u10d8\u10e1 \u10db\u10d8\u10d4\u10e0 \u10d2\u10d0\u10db\u10dd\u10e7\u10d4\u10dc\u10d4\u10d1\u10e3\u10da\u10d8 \u10e2\u10d4\u10db\u10de\u10d4\u10e0\u10d0\u10e2\u10e3\u10e0\u10e3\u10da\u10d8 \u10d4\u10e0\u10d7\u10d4\u10e3\u10da\u10d8" - }, - "description": "\u10d3\u10d0\u10d0\u10d9\u10dd\u10dc\u10e4\u10d8\u10d2\u10e3\u10e0\u10d8\u10e0\u10d4\u10d3 {device_type} `{device_name}` \u10de\u10d0\u10e0\u10d0\u10db\u10d4\u10e2\u10d4\u10e0\u10d1\u10d8 \u10d8\u10dc\u10e4\u10dd\u10e0\u10db\u10d0\u10ea\u10d8\u10d8\u10e1 \u10e9\u10d5\u10d4\u10dc\u10d4\u10d1\u10d8\u10e1 \u10db\u10dd\u10e1\u10d0\u10e0\u10d2\u10d4\u10d1\u10d0\u10d3", - "title": "Tuya-\u10e1 \u10db\u10dd\u10ec\u10e7\u10dd\u10d1\u10d8\u10da\u10dd\u10d1\u10d8\u10e1 \u10d9\u10dd\u10dc\u10e4\u10d8\u10d2\u10e3\u10e0\u10d8\u10e0\u10d4\u10d1\u10d0" - }, - "init": { - "data": { - "discovery_interval": "\u10db\u10dd\u10ec\u10e7\u10dd\u10d1\u10d8\u10da\u10dd\u10d1\u10d8\u10e1 \u10d0\u10e6\u10db\u10dd\u10e9\u10d4\u10dc\u10d8\u10e1 \u10d2\u10d0\u10db\u10dd\u10d9\u10d8\u10d7\u10ee\u10d5\u10d8\u10e1 \u10d8\u10dc\u10e2\u10d4\u10e0\u10d5\u10d0\u10da\u10d8 \u10ec\u10d0\u10db\u10d4\u10d1\u10e8\u10d8", - "list_devices": "\u10d0\u10d8\u10e0\u10e9\u10d8\u10d4\u10d7 \u10db\u10dd\u10ec\u10e7\u10dd\u10d1\u10d8\u10da\u10dd\u10d1\u10d0 \u10d9\u10dd\u10dc\u10e4\u10d8\u10d2\u10e3\u10e0\u10d0\u10ea\u10d8\u10d8\u10e1\u10d7\u10d5\u10d8\u10e1 \u10d0\u10dc \u10d3\u10d0\u10e2\u10dd\u10d5\u10d4\u10d7 \u10ea\u10d0\u10e0\u10d8\u10d4\u10da\u10d8 \u10d9\u10dd\u10dc\u10e4\u10d8\u10d2\u10e3\u10e0\u10d0\u10ea\u10d8\u10d8\u10e1 \u10e8\u10d4\u10e1\u10d0\u10dc\u10d0\u10ee\u10d0\u10d3", - "query_device": "\u10d0\u10d8\u10e0\u10e9\u10d8\u10d4\u10d7 \u10db\u10dd\u10ec\u10e7\u10dd\u10d1\u10d8\u10da\u10dd\u10d1\u10d0, \u10e0\u10dd\u10db\u10d4\u10da\u10d8\u10ea \u10d2\u10d0\u10db\u10dd\u10d8\u10e7\u10d4\u10dc\u10d4\u10d1\u10e1 \u10db\u10dd\u10d7\u10ee\u10dd\u10d5\u10dc\u10d8\u10e1 \u10db\u10d4\u10d7\u10dd\u10d3\u10e1 \u10e1\u10e2\u10d0\u10e2\u10e3\u10e1\u10d8\u10e1 \u10e1\u10ec\u10e0\u10d0\u10e4\u10d8 \u10d2\u10d0\u10dc\u10d0\u10ee\u10da\u10d4\u10d1\u10d8\u10e1\u10d7\u10d5\u10d8\u10e1", - "query_interval": "\u10db\u10dd\u10ec\u10e7\u10dd\u10d1\u10d8\u10da\u10dd\u10d1\u10d8\u10e1 \u10d2\u10d0\u10db\u10dd\u10d9\u10d8\u10d7\u10ee\u10d5\u10d8\u10e1 \u10db\u10dd\u10d7\u10ee\u10dd\u10d5\u10dc\u10d8\u10e1 \u10d8\u10dc\u10e2\u10d4\u10e0\u10d5\u10d0\u10da\u10d8 \u10ec\u10d0\u10db\u10d4\u10d1\u10e8\u10d8" - }, - "description": "\u10d0\u10e0 \u10d3\u10d0\u10d0\u10e7\u10d4\u10dc\u10dd\u10d7 \u10d2\u10d0\u10db\u10dd\u10d9\u10d8\u10d7\u10ee\u10d5\u10d8\u10e1 \u10d8\u10dc\u10e2\u10d4\u10e0\u10d5\u10d0\u10da\u10d8\u10e1 \u10db\u10dc\u10d8\u10e8\u10d5\u10dc\u10d4\u10da\u10dd\u10d1\u10d4\u10d1\u10d8 \u10eb\u10d0\u10da\u10d8\u10d0\u10dc \u10db\u10ea\u10d8\u10e0\u10d4 \u10d7\u10dd\u10e0\u10d4\u10d1 \u10d2\u10d0\u10db\u10dd\u10eb\u10d0\u10ee\u10d4\u10d1\u10d4\u10d1\u10d8 \u10d3\u10d0\u10d0\u10d2\u10d4\u10dc\u10d4\u10e0\u10d8\u10e0\u10d4\u10d1\u10d4\u10dc \u10e8\u10d4\u10ea\u10d3\u10dd\u10db\u10d4\u10d1\u10e1 \u10da\u10dd\u10d2\u10e8\u10d8", - "title": "Tuya-\u10e1 \u10de\u10d0\u10e0\u10d0\u10db\u10d4\u10e2\u10e0\u10d4\u10d1\u10d8\u10e1 \u10d9\u10dd\u10dc\u10e4\u10d8\u10d2\u10e3\u10e0\u10d8\u10e0\u10d4\u10d1\u10d0" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/ko.json b/homeassistant/components/tuya/translations/ko.json deleted file mode 100644 index afa2541e7b9..00000000000 --- a/homeassistant/components/tuya/translations/ko.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "config": { - "abort": { - "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", - "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", - "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uc778\uc2a4\ud134\uc2a4\ub9cc \uad6c\uc131\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4." - }, - "error": { - "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" - }, - "flow_title": "Tuya \uad6c\uc131\ud558\uae30", - "step": { - "user": { - "data": { - "country_code": "\uacc4\uc815 \uad6d\uac00 \ucf54\ub4dc (\uc608 : \ubbf8\uad6d\uc758 \uacbd\uc6b0 1, \uc911\uad6d\uc758 \uacbd\uc6b0 86)", - "password": "\ube44\ubc00\ubc88\ud638", - "platform": "\uacc4\uc815\uc774 \ub4f1\ub85d\ub41c \uc571", - "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" - }, - "description": "Tuya \uc790\uaca9 \uc99d\uba85\uc744 \uc785\ub825\ud574\uc8fc\uc138\uc694.", - "title": "Tuya" - } - } - }, - "options": { - "abort": { - "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4" - }, - "error": { - "dev_multi_type": "\uc120\ud0dd\ud55c \uc5ec\ub7ec \uae30\uae30\ub97c \uad6c\uc131\ud558\ub824\uba74 \uc720\ud615\uc774 \ub3d9\uc77c\ud574\uc57c \ud569\ub2c8\ub2e4", - "dev_not_config": "\uae30\uae30 \uc720\ud615\uc744 \uad6c\uc131\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4", - "dev_not_found": "\uae30\uae30\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4" - }, - "step": { - "device": { - "data": { - "brightness_range_mode": "\uae30\uae30\uc5d0\uc11c \uc0ac\uc6a9\ud558\ub294 \ubc1d\uae30 \ubc94\uc704", - "curr_temp_divider": "\ud604\uc7ac \uc628\ub3c4 \uac12 \ubd84\ud560 (0 = \uae30\ubcf8\uac12 \uc0ac\uc6a9)", - "max_kelvin": "\uce98\ube48 \ub2e8\uc704\uc758 \ucd5c\ub300 \uc0c9\uc628\ub3c4", - "max_temp": "\ucd5c\ub300 \ubaa9\ud45c \uc628\ub3c4 (\uae30\ubcf8\uac12\uc758 \uacbd\uc6b0 \ucd5c\uc19f\uac12 \ubc0f \ucd5c\ub313\uac12 = 0)", - "min_kelvin": "\uce98\ube48 \ub2e8\uc704\uc758 \ucd5c\uc18c \uc0c9\uc628\ub3c4", - "min_temp": "\ucd5c\uc18c \ubaa9\ud45c \uc628\ub3c4 (\uae30\ubcf8\uac12\uc758 \uacbd\uc6b0 \ucd5c\uc19f\uac12 \ubc0f \ucd5c\ub313\uac12 = 0)", - "set_temp_divided": "\uc124\uc815 \uc628\ub3c4 \uba85\ub839\uc5d0 \ubd84\ud560\ub41c \uc628\ub3c4 \uac12 \uc0ac\uc6a9\ud558\uae30", - "support_color": "\uc0c9\uc0c1 \uc9c0\uc6d0 \uac15\uc81c \uc801\uc6a9\ud558\uae30", - "temp_divider": "\uc628\ub3c4 \uac12 \ubd84\ud560 (0 = \uae30\ubcf8\uac12 \uc0ac\uc6a9)", - "temp_step_override": "\ud76c\ub9dd \uc628\ub3c4 \ub2e8\uacc4", - "tuya_max_coltemp": "\uae30\uae30\uc5d0\uc11c \ubcf4\uace0\ud55c \ucd5c\ub300 \uc0c9\uc628\ub3c4", - "unit_of_measurement": "\uae30\uae30\uc5d0\uc11c \uc0ac\uc6a9\ud558\ub294 \uc628\ub3c4 \ub2e8\uc704" - }, - "description": "{device_type} `{device_name}` \uae30\uae30\uc5d0 \ub300\ud574 \ud45c\uc2dc\ub418\ub294 \uc815\ubcf4\ub97c \uc870\uc815\ud558\ub294 \uc635\uc158 \uad6c\uc131\ud558\uae30", - "title": "Tuya \uae30\uae30 \uad6c\uc131\ud558\uae30" - }, - "init": { - "data": { - "discovery_interval": "\uae30\uae30 \uac80\uc0c9 \ud3f4\ub9c1 \uac04\uaca9 (\ucd08)", - "list_devices": "\uad6c\uc131\uc744 \uc800\uc7a5\ud558\ub824\uba74 \uad6c\uc131\ud560 \uae30\uae30\ub97c \uc120\ud0dd\ud558\uac70\ub098 \ube44\uc6cc \ub450\uc138\uc694", - "query_device": "\ube60\ub978 \uc0c1\ud0dc \uc5c5\ub370\uc774\ud2b8\ub97c \uc704\ud574 \ucffc\ub9ac \ubc29\ubc95\uc744 \uc0ac\uc6a9\ud560 \uae30\uae30\ub97c \uc120\ud0dd\ud574\uc8fc\uc138\uc694", - "query_interval": "\uae30\uae30 \ucffc\ub9ac \ud3f4\ub9c1 \uac04\uaca9 (\ucd08)" - }, - "description": "\ud3f4\ub9c1 \uac04\uaca9 \uac12\uc744 \ub108\ubb34 \ub0ae\uac8c \uc124\uc815\ud558\uc9c0 \ub9d0\uc544 \uc8fc\uc138\uc694. \uadf8\ub807\uc9c0 \uc54a\uc73c\uba74 \ud638\ucd9c\uc5d0 \uc2e4\ud328\ud558\uace0 \ub85c\uadf8\uc5d0 \uc624\ub958 \uba54\uc2dc\uc9c0\uac00 \uc0dd\uc131\ub429\ub2c8\ub2e4.", - "title": "Tuya \uc635\uc158 \uad6c\uc131\ud558\uae30" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/lb.json b/homeassistant/components/tuya/translations/lb.json deleted file mode 100644 index 0000f9ef6e6..00000000000 --- a/homeassistant/components/tuya/translations/lb.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "config": { - "abort": { - "cannot_connect": "Feeler beim verbannen", - "invalid_auth": "Ong\u00eblteg Authentifikatioun", - "single_instance_allowed": "Scho konfigur\u00e9iert. N\u00ebmmen eng eenzeg Konfiguratioun ass m\u00e9iglech." - }, - "error": { - "invalid_auth": "Ong\u00eblteg Authentifikatioun" - }, - "flow_title": "Tuya Konfiguratioun", - "step": { - "user": { - "data": { - "country_code": "De L\u00e4nner Code fir d\u00e4i Kont (beispill 1 fir USA oder 86 fir China)", - "password": "Passwuert", - "platform": "d'App wou den Kont registr\u00e9iert ass", - "username": "Benotzernumm" - }, - "description": "F\u00ebll deng Tuya Umeldungs Informatiounen aus.", - "title": "Tuya" - } - } - }, - "options": { - "abort": { - "cannot_connect": "Feeler beim verbannen" - }, - "error": { - "dev_multi_type": "Multiple ausgewielte Ger\u00e4ter fir ze konfigur\u00e9ieren musse vum selwechten Typ sinn", - "dev_not_config": "Typ vun Apparat net konfigur\u00e9ierbar", - "dev_not_found": "Apparat net fonnt" - }, - "step": { - "device": { - "data": { - "brightness_range_mode": "Hellegkeetsber\u00e4ich vum Apparat", - "curr_temp_divider": "Aktuell Temperatur W\u00e4erter Deeler (0= benotz Standard)", - "max_kelvin": "Maximal Faarftemperatur \u00ebnnerst\u00ebtzt a Kelvin", - "max_temp": "Maximal Zil Temperatur (benotz min a max = 0 fir standard)", - "min_kelvin": "Minimal Faarftemperatur \u00ebnnerst\u00ebtzt a Kelvin", - "min_temp": "Minimal Zil Temperatur (benotz min a max = 0 fir standard)", - "support_color": "Forc\u00e9ier Faarf \u00cbnnerst\u00ebtzung", - "temp_divider": "Temperatur W\u00e4erter Deeler (0= benotz Standard)", - "tuya_max_coltemp": "Max Faarftemperatur vum Apparat gemellt", - "unit_of_measurement": "Temperatur Eenheet vum Apparat" - }, - "description": "Konfigur\u00e9ier Optioune fir ugewisen Informatioune fir {device_type} Apparat `{device_name}` unzepassen", - "title": "Tuya Apparat ariichten" - }, - "init": { - "data": { - "list_devices": "Wiel d'Apparater fir ze konfigur\u00e9ieren aus oder loss se eidel fir d'Konfiguratioun ze sp\u00e4icheren" - }, - "title": "Tuya Optioune konfigur\u00e9ieren" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/nl.json b/homeassistant/components/tuya/translations/nl.json deleted file mode 100644 index 56b2ae8236f..00000000000 --- a/homeassistant/components/tuya/translations/nl.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "config": { - "abort": { - "cannot_connect": "Kan geen verbinding maken", - "invalid_auth": "Ongeldige authenticatie", - "single_instance_allowed": "Al geconfigureerd. Er is maar een configuratie mogelijk." - }, - "error": { - "invalid_auth": "Ongeldige authenticatie" - }, - "flow_title": "Tuya-configuratie", - "step": { - "user": { - "data": { - "country_code": "De landcode van uw account (bijvoorbeeld 1 voor de VS of 86 voor China)", - "password": "Wachtwoord", - "platform": "De app waar uw account is geregistreerd", - "username": "Gebruikersnaam" - }, - "description": "Voer uw Tuya-inloggegevens in.", - "title": "Tuya" - } - } - }, - "options": { - "abort": { - "cannot_connect": "Kan geen verbinding maken" - }, - "error": { - "dev_multi_type": "Meerdere geselecteerde apparaten om te configureren moeten van hetzelfde type zijn", - "dev_not_config": "Apparaattype kan niet worden geconfigureerd", - "dev_not_found": "Apparaat niet gevonden" - }, - "step": { - "device": { - "data": { - "brightness_range_mode": "Helderheidsbereik gebruikt door apparaat", - "curr_temp_divider": "Huidige temperatuurwaarde deler (0 = standaardwaarde)", - "max_kelvin": "Max kleurtemperatuur in kelvin", - "max_temp": "Maximale doeltemperatuur (gebruik min en max = 0 voor standaardwaarde)", - "min_kelvin": "Minimaal ondersteunde kleurtemperatuur in kelvin", - "min_temp": "Min. gewenste temperatuur (gebruik min en max = 0 voor standaard)", - "set_temp_divided": "Gedeelde temperatuurwaarde gebruiken voor ingestelde temperatuuropdracht", - "support_color": "Forceer kleurenondersteuning", - "temp_divider": "Temperatuurwaarde deler (0 = standaardwaarde)", - "temp_step_override": "Doeltemperatuur stap", - "tuya_max_coltemp": "Max. kleurtemperatuur gerapporteerd door apparaat", - "unit_of_measurement": "Temperatuureenheid gebruikt door apparaat" - }, - "description": "Configureer opties om weergegeven informatie aan te passen voor {device_type} apparaat `{device_name}`", - "title": "Configureer Tuya Apparaat" - }, - "init": { - "data": { - "discovery_interval": "Polling-interval van nieuwe apparaten in seconden", - "list_devices": "Selecteer de te configureren apparaten of laat leeg om de configuratie op te slaan", - "query_device": "Selecteer apparaat dat query-methode zal gebruiken voor snellere statusupdate", - "query_interval": "Peilinginterval van het apparaat in seconden" - }, - "description": "Stel de waarden voor het pollinginterval niet te laag in, anders zullen de oproepen geen foutmelding in het logboek genereren", - "title": "Configureer Tuya opties" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/no.json b/homeassistant/components/tuya/translations/no.json deleted file mode 100644 index eedf24be696..00000000000 --- a/homeassistant/components/tuya/translations/no.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "config": { - "abort": { - "cannot_connect": "Tilkobling mislyktes", - "invalid_auth": "Ugyldig godkjenning", - "single_instance_allowed": "Allerede konfigurert. Bare \u00e9n enkelt konfigurasjon er mulig." - }, - "error": { - "invalid_auth": "Ugyldig godkjenning" - }, - "flow_title": "Tuya konfigurasjon", - "step": { - "user": { - "data": { - "country_code": "Din landskode for kontoen din (f.eks. 1 for USA eller 86 for Kina)", - "password": "Passord", - "platform": "Appen der kontoen din er registrert", - "username": "Brukernavn" - }, - "description": "Angi Tuya-legitimasjonen din.", - "title": "" - } - } - }, - "options": { - "abort": { - "cannot_connect": "Tilkobling mislyktes" - }, - "error": { - "dev_multi_type": "Flere valgte enheter som skal konfigureres, m\u00e5 v\u00e6re av samme type", - "dev_not_config": "Enhetstype kan ikke konfigureres", - "dev_not_found": "Finner ikke enheten" - }, - "step": { - "device": { - "data": { - "brightness_range_mode": "Lysstyrkeomr\u00e5de som brukes av enheten", - "curr_temp_divider": "N\u00e5v\u00e6rende temperaturverdi (0 = bruk standard)", - "max_kelvin": "Maks fargetemperatur st\u00f8ttet i kelvin", - "max_temp": "Maks m\u00e5ltemperatur (bruk min og maks = 0 for standard)", - "min_kelvin": "Min fargetemperatur st\u00f8ttet i kelvin", - "min_temp": "Min m\u00e5ltemperatur (bruk min og maks = 0 for standard)", - "set_temp_divided": "Bruk delt temperaturverdi for innstilt temperaturkommando", - "support_color": "Tving fargest\u00f8tte", - "temp_divider": "Deler temperaturverdier (0 = bruk standard)", - "temp_step_override": "Trinn for m\u00e5ltemperatur", - "tuya_max_coltemp": "Maks fargetemperatur rapportert av enheten", - "unit_of_measurement": "Temperaturenhet som brukes av enheten" - }, - "description": "Konfigurer alternativer for \u00e5 justere vist informasjon for {device_type} device ` {device_name} `", - "title": "Konfigurere Tuya-enhet" - }, - "init": { - "data": { - "discovery_interval": "Avsp\u00f8rringsintervall for discovery-enheten i l\u00f8pet av sekunder", - "list_devices": "Velg enhetene du vil konfigurere, eller la de v\u00e6re tomme for \u00e5 lagre konfigurasjonen", - "query_device": "Velg enhet som skal bruke sp\u00f8rringsmetode for raskere statusoppdatering", - "query_interval": "Sp\u00f8rringsintervall for intervall i sekunder" - }, - "description": "Ikke angi pollingsintervallverdiene for lave, ellers vil ikke anropene generere feilmelding i loggen", - "title": "Konfigurer Tuya-alternativer" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/pl.json b/homeassistant/components/tuya/translations/pl.json deleted file mode 100644 index 92ced00e733..00000000000 --- a/homeassistant/components/tuya/translations/pl.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "config": { - "abort": { - "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", - "invalid_auth": "Niepoprawne uwierzytelnienie", - "single_instance_allowed": "Ju\u017c skonfigurowano. Mo\u017cliwa jest tylko jedna konfiguracja." - }, - "error": { - "invalid_auth": "Niepoprawne uwierzytelnienie" - }, - "flow_title": "Konfiguracja integracji Tuya", - "step": { - "user": { - "data": { - "country_code": "Kod kraju twojego konta (np. 1 dla USA lub 86 dla Chin)", - "password": "Has\u0142o", - "platform": "Aplikacja, w kt\u00f3rej zarejestrowane jest Twoje konto", - "username": "Nazwa u\u017cytkownika" - }, - "description": "Wprowad\u017a dane uwierzytelniaj\u0105ce", - "title": "Tuya" - } - } - }, - "options": { - "abort": { - "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia" - }, - "error": { - "dev_multi_type": "Wybrane urz\u0105dzenia do skonfigurowania musz\u0105 by\u0107 tego samego typu", - "dev_not_config": "Typ urz\u0105dzenia nie jest konfigurowalny", - "dev_not_found": "Nie znaleziono urz\u0105dzenia" - }, - "step": { - "device": { - "data": { - "brightness_range_mode": "Zakres jasno\u015bci u\u017cywany przez urz\u0105dzenie", - "curr_temp_divider": "Dzielnik aktualnej warto\u015bci temperatury (0 = u\u017cyj warto\u015bci domy\u015blnej)", - "max_kelvin": "Maksymalna obs\u0142ugiwana temperatura barwy w kelwinach", - "max_temp": "Maksymalna temperatura docelowa (u\u017cyj min i max = 0 dla warto\u015bci domy\u015blnej)", - "min_kelvin": "Minimalna obs\u0142ugiwana temperatura barwy w kelwinach", - "min_temp": "Minimalna temperatura docelowa (u\u017cyj min i max = 0 dla warto\u015bci domy\u015blnej)", - "set_temp_divided": "U\u017cyj podzielonej warto\u015bci temperatury dla polecenia ustawienia temperatury", - "support_color": "Wymu\u015b obs\u0142ug\u0119 kolor\u00f3w", - "temp_divider": "Dzielnik warto\u015bci temperatury (0 = u\u017cyj warto\u015bci domy\u015blnej)", - "temp_step_override": "Krok docelowej temperatury", - "tuya_max_coltemp": "Maksymalna temperatura barwy raportowana przez urz\u0105dzenie", - "unit_of_measurement": "Jednostka temperatury u\u017cywana przez urz\u0105dzenie" - }, - "description": "Skonfiguruj opcje, aby dostosowa\u0107 wy\u015bwietlane informacje dla urz\u0105dzenia {device_type} `{device_name}'", - "title": "Konfiguracja urz\u0105dzenia Tuya" - }, - "init": { - "data": { - "discovery_interval": "Cz\u0119stotliwo\u015b\u0107 skanowania nowych urz\u0105dze\u0144 (w sekundach)", - "list_devices": "Wybierz urz\u0105dzenia do skonfigurowania lub pozostaw puste, aby zapisa\u0107 konfiguracj\u0119", - "query_device": "Wybierz urz\u0105dzenie, kt\u00f3re b\u0119dzie u\u017cywa\u0107 metody odpytywania w celu szybszej aktualizacji statusu", - "query_interval": "Cz\u0119stotliwo\u015b\u0107 skanowania odpytywanego urz\u0105dzenia w sekundach" - }, - "description": "Nie ustawiaj zbyt niskich warto\u015bci skanowania, bo zako\u0144cz\u0105 si\u0119 niepowodzeniem, generuj\u0105c komunikat o b\u0142\u0119dzie w logu", - "title": "Konfiguracja opcji Tuya" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/pt-BR.json b/homeassistant/components/tuya/translations/pt-BR.json deleted file mode 100644 index 8dc537e7549..00000000000 --- a/homeassistant/components/tuya/translations/pt-BR.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "config": { - "flow_title": "Configura\u00e7\u00e3o Tuya", - "step": { - "user": { - "data": { - "country_code": "O c\u00f3digo do pa\u00eds da sua conta (por exemplo, 1 para os EUA ou 86 para a China)", - "password": "Senha", - "platform": "O aplicativo onde sua conta \u00e9 registrada", - "username": "Nome de usu\u00e1rio" - }, - "description": "Digite sua credencial Tuya.", - "title": "Tuya" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/pt.json b/homeassistant/components/tuya/translations/pt.json deleted file mode 100644 index 566746538c0..00000000000 --- a/homeassistant/components/tuya/translations/pt.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "config": { - "abort": { - "cannot_connect": "Falha na liga\u00e7\u00e3o", - "invalid_auth": "Autentica\u00e7\u00e3o inv\u00e1lida", - "single_instance_allowed": "J\u00e1 configurado. Apenas uma \u00fanica configura\u00e7\u00e3o \u00e9 poss\u00edvel." - }, - "error": { - "invalid_auth": "Autentica\u00e7\u00e3o inv\u00e1lida" - }, - "step": { - "user": { - "data": { - "password": "Palavra-passe", - "username": "Nome de Utilizador" - } - } - } - }, - "options": { - "abort": { - "cannot_connect": "Falha na liga\u00e7\u00e3o" - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/ru.json b/homeassistant/components/tuya/translations/ru.json deleted file mode 100644 index 7b46689bc50..00000000000 --- a/homeassistant/components/tuya/translations/ru.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "config": { - "abort": { - "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", - "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", - "single_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430. \u0412\u043e\u0437\u043c\u043e\u0436\u043d\u043e \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0442\u043e\u043b\u044c\u043a\u043e \u043e\u0434\u043d\u0443 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e." - }, - "error": { - "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438." - }, - "flow_title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Tuya", - "step": { - "user": { - "data": { - "country_code": "\u041a\u043e\u0434 \u0441\u0442\u0440\u0430\u043d\u044b \u0430\u043a\u043a\u0430\u0443\u043d\u0442\u0430 (1 \u0434\u043b\u044f \u0421\u0428\u0410 \u0438\u043b\u0438 86 \u0434\u043b\u044f \u041a\u0438\u0442\u0430\u044f)", - "password": "\u041f\u0430\u0440\u043e\u043b\u044c", - "platform": "\u041f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435, \u0432 \u043a\u043e\u0442\u043e\u0440\u043e\u043c \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u043d\u0430 \u0443\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c", - "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" - }, - "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 Tuya.", - "title": "Tuya" - } - } - }, - "options": { - "abort": { - "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f." - }, - "error": { - "dev_multi_type": "\u041d\u0435\u0441\u043a\u043e\u043b\u044c\u043a\u043e \u0432\u044b\u0431\u0440\u0430\u043d\u043d\u044b\u0445 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432 \u0434\u043b\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0434\u043e\u043b\u0436\u043d\u044b \u0431\u044b\u0442\u044c \u043e\u0434\u043d\u043e\u0433\u043e \u0442\u0438\u043f\u0430.", - "dev_not_config": "\u0422\u0438\u043f \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u043d\u0435 \u043d\u0430\u0441\u0442\u0440\u0430\u0438\u0432\u0430\u0435\u0442\u0441\u044f.", - "dev_not_found": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u043e." - }, - "step": { - "device": { - "data": { - "brightness_range_mode": "\u0414\u0438\u0430\u043f\u0430\u0437\u043e\u043d \u044f\u0440\u043a\u043e\u0441\u0442\u0438, \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u043c\u044b\u0439 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u043c", - "curr_temp_divider": "\u0414\u0435\u043b\u0438\u0442\u0435\u043b\u044c \u0442\u0435\u043a\u0443\u0449\u0435\u0433\u043e \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f \u0442\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u044b (0 = \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c \u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e)", - "max_kelvin": "\u041c\u0430\u043a\u0441\u0438\u043c\u0430\u043b\u044c\u043d\u0430\u044f \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u043c\u0430\u044f \u0446\u0432\u0435\u0442\u043e\u0432\u0430\u044f \u0442\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u0430 (\u0432 \u043a\u0435\u043b\u044c\u0432\u0438\u043d\u0430\u0445)", - "max_temp": "\u041c\u0430\u043a\u0441\u0438\u043c\u0430\u043b\u044c\u043d\u0430\u044f \u0446\u0435\u043b\u0435\u0432\u0430\u044f \u0442\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u0430 (\u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0439\u0442\u0435 min \u0438 max = 0)", - "min_kelvin": "\u041c\u0438\u043d\u0438\u043c\u0430\u043b\u044c\u043d\u0430\u044f \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u043c\u0430\u044f \u0446\u0432\u0435\u0442\u043e\u0432\u0430\u044f \u0442\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u0430 (\u0432 \u043a\u0435\u043b\u044c\u0432\u0438\u043d\u0430\u0445)", - "min_temp": "\u041c\u0438\u043d\u0438\u043c\u0430\u043b\u044c\u043d\u0430\u044f \u0446\u0435\u043b\u0435\u0432\u0430\u044f \u0442\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u0430 (\u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0439\u0442\u0435 min \u0438 max = 0)", - "set_temp_divided": "\u0418\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c \u0440\u0430\u0437\u0434\u0435\u043b\u0435\u043d\u043d\u043e\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0442\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u044b \u0434\u043b\u044f \u043a\u043e\u043c\u0430\u043d\u0434\u044b \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0438 \u0442\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u044b", - "support_color": "\u041f\u0440\u0438\u043d\u0443\u0434\u0438\u0442\u0435\u043b\u044c\u043d\u0430\u044f \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u043a\u0430 \u0446\u0432\u0435\u0442\u0430", - "temp_divider": "\u0414\u0435\u043b\u0438\u0442\u0435\u043b\u044c \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439 \u0442\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u044b (0 = \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c \u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e)", - "temp_step_override": "\u0428\u0430\u0433 \u0446\u0435\u043b\u0435\u0432\u043e\u0439 \u0442\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u044b", - "tuya_max_coltemp": "\u041c\u0430\u043a\u0441\u0438\u043c\u0430\u043b\u044c\u043d\u0430\u044f \u0446\u0432\u0435\u0442\u043e\u0432\u0430\u044f \u0442\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u0430, \u0441\u043e\u043e\u0431\u0449\u0430\u0435\u043c\u0430\u044f \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u043c", - "unit_of_measurement": "\u0415\u0434\u0438\u043d\u0438\u0446\u0430 \u0438\u0437\u043c\u0435\u0440\u0435\u043d\u0438\u044f \u0442\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u044b, \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u043c\u0430\u044f \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u043c" - }, - "description": "\u041e\u043f\u0446\u0438\u0438 \u0434\u043b\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0430\u0435\u043c\u043e\u0439 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u0438 \u0434\u043b\u044f {device_type} \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 `{device_name}`", - "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 Tuya" - }, - "init": { - "data": { - "discovery_interval": "\u0418\u043d\u0442\u0435\u0440\u0432\u0430\u043b \u043e\u043f\u0440\u043e\u0441\u0430 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u0438\u044f \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 (\u0432 \u0441\u0435\u043a\u0443\u043d\u0434\u0430\u0445)", - "list_devices": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0434\u043b\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0438\u043b\u0438 \u043e\u0441\u0442\u0430\u0432\u044c\u0442\u0435 \u043f\u0443\u0441\u0442\u044b\u043c \u0434\u043b\u044f \u0441\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u0438\u044f \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438", - "query_device": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e, \u043a\u043e\u0442\u043e\u0440\u043e\u0435 \u0431\u0443\u0434\u0435\u0442 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c \u043c\u0435\u0442\u043e\u0434 \u0437\u0430\u043f\u0440\u043e\u0441\u0430 \u0434\u043b\u044f \u0431\u043e\u043b\u0435\u0435 \u0431\u044b\u0441\u0442\u0440\u043e\u0433\u043e \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u044f \u0441\u0442\u0430\u0442\u0443\u0441\u0430", - "query_interval": "\u0418\u043d\u0442\u0435\u0440\u0432\u0430\u043b \u043e\u043f\u0440\u043e\u0441\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 (\u0432 \u0441\u0435\u043a\u0443\u043d\u0434\u0430\u0445)" - }, - "description": "\u041d\u0435 \u0443\u0441\u0442\u0430\u043d\u0430\u0432\u043b\u0438\u0432\u0430\u0439\u0442\u0435 \u0441\u043b\u0438\u0448\u043a\u043e\u043c \u043d\u0438\u0437\u043a\u0438\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f \u0438\u043d\u0442\u0435\u0440\u0432\u0430\u043b\u0430 \u043e\u043f\u0440\u043e\u0441\u0430, \u0438\u043d\u0430\u0447\u0435 \u0432\u044b\u0437\u043e\u0432\u044b \u043d\u0435 \u0431\u0443\u0434\u0443\u0442 \u0433\u0435\u043d\u0435\u0440\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435 \u043e\u0431 \u043e\u0448\u0438\u0431\u043a\u0435 \u0432 \u0436\u0443\u0440\u043d\u0430\u043b\u0435.", - "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 Tuya" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/sl.json b/homeassistant/components/tuya/translations/sl.json deleted file mode 100644 index b07ad70adac..00000000000 --- a/homeassistant/components/tuya/translations/sl.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "options": { - "abort": { - "cannot_connect": "Povezovanje ni uspelo." - }, - "error": { - "dev_not_config": "Vrsta naprave ni nastavljiva", - "dev_not_found": "Naprave ni mogo\u010de najti" - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/sv.json b/homeassistant/components/tuya/translations/sv.json deleted file mode 100644 index 85cc9c57fd3..00000000000 --- a/homeassistant/components/tuya/translations/sv.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "config": { - "flow_title": "Tuya-konfiguration", - "step": { - "user": { - "data": { - "country_code": "Landskod f\u00f6r ditt konto (t.ex. 1 f\u00f6r USA eller 86 f\u00f6r Kina)", - "password": "L\u00f6senord", - "platform": "Appen d\u00e4r ditt konto registreras", - "username": "Anv\u00e4ndarnamn" - }, - "description": "Ange dina Tuya anv\u00e4ndaruppgifter.", - "title": "Tuya" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/tr.json b/homeassistant/components/tuya/translations/tr.json deleted file mode 100644 index 2edf3276b6c..00000000000 --- a/homeassistant/components/tuya/translations/tr.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "config": { - "abort": { - "cannot_connect": "Ba\u011flanma hatas\u0131", - "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", - "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr." - }, - "error": { - "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama" - }, - "flow_title": "Tuya yap\u0131land\u0131rmas\u0131", - "step": { - "user": { - "data": { - "country_code": "Hesap \u00fclke kodunuz (\u00f6r. ABD i\u00e7in 1 veya \u00c7in i\u00e7in 86)", - "password": "Parola", - "platform": "Hesab\u0131n\u0131z\u0131n kay\u0131tl\u0131 oldu\u011fu uygulama", - "username": "Kullan\u0131c\u0131 Ad\u0131" - }, - "description": "Tuya kimlik bilgilerinizi girin.", - "title": "Tuya" - } - } - }, - "options": { - "abort": { - "cannot_connect": "Ba\u011flanma hatas\u0131" - }, - "error": { - "dev_not_config": "Cihaz t\u00fcr\u00fc yap\u0131land\u0131r\u0131lamaz", - "dev_not_found": "Cihaz bulunamad\u0131" - }, - "step": { - "device": { - "data": { - "brightness_range_mode": "Cihaz\u0131n kulland\u0131\u011f\u0131 parlakl\u0131k aral\u0131\u011f\u0131", - "max_temp": "Maksimum hedef s\u0131cakl\u0131k (varsay\u0131lan olarak min ve maks = 0 kullan\u0131n)", - "min_kelvin": "Kelvin destekli min renk s\u0131cakl\u0131\u011f\u0131", - "min_temp": "Minimum hedef s\u0131cakl\u0131k (varsay\u0131lan i\u00e7in min ve maks = 0 kullan\u0131n)", - "support_color": "Vurgu rengi", - "temp_divider": "S\u0131cakl\u0131k de\u011ferleri ay\u0131r\u0131c\u0131 (0 = varsay\u0131lan\u0131 kullan)", - "tuya_max_coltemp": "Cihaz taraf\u0131ndan bildirilen maksimum renk s\u0131cakl\u0131\u011f\u0131", - "unit_of_measurement": "Cihaz\u0131n kulland\u0131\u011f\u0131 s\u0131cakl\u0131k birimi" - }, - "description": "{device_type} ayg\u0131t\u0131 '{device_name}' i\u00e7in g\u00f6r\u00fcnt\u00fclenen bilgileri ayarlamak i\u00e7in se\u00e7enekleri yap\u0131land\u0131r\u0131n", - "title": "Tuya Cihaz\u0131n\u0131 Yap\u0131land\u0131r\u0131n" - }, - "init": { - "data": { - "discovery_interval": "Cihaz\u0131 yoklama aral\u0131\u011f\u0131 saniye cinsinden", - "list_devices": "Yap\u0131land\u0131rmay\u0131 kaydetmek i\u00e7in yap\u0131land\u0131r\u0131lacak veya bo\u015f b\u0131rak\u0131lacak cihazlar\u0131 se\u00e7in", - "query_device": "Daha h\u0131zl\u0131 durum g\u00fcncellemesi i\u00e7in sorgu y\u00f6ntemini kullanacak cihaz\u0131 se\u00e7in", - "query_interval": "Ayg\u0131t yoklama aral\u0131\u011f\u0131 saniye cinsinden" - }, - "description": "Yoklama aral\u0131\u011f\u0131 de\u011ferlerini \u00e7ok d\u00fc\u015f\u00fck ayarlamay\u0131n, aksi takdirde \u00e7a\u011fr\u0131lar g\u00fcnl\u00fckte hata mesaj\u0131 olu\u015fturarak ba\u015far\u0131s\u0131z olur", - "title": "Tuya Se\u00e7eneklerini Konfig\u00fcre Et" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/uk.json b/homeassistant/components/tuya/translations/uk.json deleted file mode 100644 index 1d2709d260a..00000000000 --- a/homeassistant/components/tuya/translations/uk.json +++ /dev/null @@ -1,63 +0,0 @@ -{ - "config": { - "abort": { - "cannot_connect": "\u041d\u0435 \u0432\u0434\u0430\u043b\u043e\u0441\u044f \u043f\u0456\u0434'\u0454\u0434\u043d\u0430\u0442\u0438\u0441\u044f", - "invalid_auth": "\u041d\u0435\u0432\u0456\u0440\u043d\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0456\u043a\u0430\u0446\u0456\u044f.", - "single_instance_allowed": "\u041d\u0430\u043b\u0430\u0448\u0442\u0443\u0432\u0430\u043d\u043d\u044f \u0432\u0436\u0435 \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u043e. \u041c\u043e\u0436\u043d\u0430 \u0434\u043e\u0434\u0430\u0442\u0438 \u043b\u0438\u0448\u0435 \u043e\u0434\u043d\u0443 \u043a\u043e\u043d\u0444\u0456\u0433\u0443\u0440\u0430\u0446\u0456\u044e." - }, - "error": { - "invalid_auth": "\u041d\u0435\u0432\u0456\u0440\u043d\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0456\u043a\u0430\u0446\u0456\u044f." - }, - "flow_title": "\u041d\u0430\u043b\u0430\u0448\u0442\u0443\u0432\u0430\u043d\u043d\u044f Tuya", - "step": { - "user": { - "data": { - "country_code": "\u041a\u043e\u0434 \u043a\u0440\u0430\u0457\u043d\u0438 \u043e\u0431\u043b\u0456\u043a\u043e\u0432\u043e\u0433\u043e \u0437\u0430\u043f\u0438\u0441\u0443 (1 \u0434\u043b\u044f \u0421\u0428\u0410 \u0430\u0431\u043e 86 \u0434\u043b\u044f \u041a\u0438\u0442\u0430\u044e)", - "password": "\u041f\u0430\u0440\u043e\u043b\u044c", - "platform": "\u0414\u043e\u0434\u0430\u0442\u043e\u043a, \u0432 \u044f\u043a\u043e\u043c\u0443 \u0437\u0430\u0440\u0435\u0454\u0441\u0442\u0440\u043e\u0432\u0430\u043d\u043e \u043e\u0431\u043b\u0456\u043a\u043e\u0432\u0438\u0439 \u0437\u0430\u043f\u0438\u0441", - "username": "\u0406\u043c'\u044f \u043a\u043e\u0440\u0438\u0441\u0442\u0443\u0432\u0430\u0447\u0430" - }, - "description": "\u041d\u0430\u043b\u0430\u0448\u0442\u0443\u0439\u0442\u0435 Home Assistant \u0434\u043b\u044f \u0456\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0456\u0457 \u0437 Tuya.", - "title": "Tuya" - } - } - }, - "options": { - "abort": { - "cannot_connect": "\u041d\u0435 \u0432\u0434\u0430\u043b\u043e\u0441\u044f \u043f\u0456\u0434'\u0454\u0434\u043d\u0430\u0442\u0438\u0441\u044f" - }, - "error": { - "dev_multi_type": "\u041a\u0456\u043b\u044c\u043a\u0430 \u043e\u0431\u0440\u0430\u043d\u0438\u0445 \u043f\u0440\u0438\u0441\u0442\u0440\u043e\u0457\u0432 \u0434\u043b\u044f \u043d\u0430\u043b\u0430\u0448\u0442\u0443\u0432\u0430\u043d\u043d\u044f \u043f\u043e\u0432\u0438\u043d\u043d\u0456 \u0431\u0443\u0442\u0438 \u043e\u0434\u043d\u043e\u0433\u043e \u0442\u0438\u043f\u0443.", - "dev_not_config": "\u0422\u0438\u043f \u043f\u0440\u0438\u0441\u0442\u0440\u043e\u044e \u043d\u0435 \u043d\u0430\u043b\u0430\u0448\u0442\u043e\u0432\u0443\u0454\u0442\u044c\u0441\u044f.", - "dev_not_found": "\u041f\u0440\u0438\u0441\u0442\u0440\u0456\u0439 \u043d\u0435 \u0437\u043d\u0430\u0439\u0434\u0435\u043d\u043e." - }, - "step": { - "device": { - "data": { - "brightness_range_mode": "\u0414\u0456\u0430\u043f\u0430\u0437\u043e\u043d \u044f\u0441\u043a\u0440\u0430\u0432\u043e\u0441\u0442\u0456, \u044f\u043a\u0438\u0439 \u0432\u0438\u043a\u043e\u0440\u0438\u0441\u0442\u043e\u0432\u0443\u0454\u0442\u044c\u0441\u044f \u043f\u0440\u0438\u0441\u0442\u0440\u043e\u0454\u043c", - "curr_temp_divider": "\u0414\u0456\u043b\u044c\u043d\u0438\u043a \u043f\u043e\u0442\u043e\u0447\u043d\u043e\u0433\u043e \u0437\u043d\u0430\u0447\u0435\u043d\u043d\u044f \u0442\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u0438 (0 = \u0432\u0438\u043a\u043e\u0440\u0438\u0441\u0442\u043e\u0432\u0443\u0432\u0430\u0442\u0438 \u0437\u0430 \u0437\u0430\u043c\u043e\u0432\u0447\u0443\u0432\u0430\u043d\u043d\u044f\u043c)", - "max_kelvin": "\u041c\u0430\u043a\u0441\u0438\u043c\u0430\u043b\u044c\u043d\u0430 \u043f\u0456\u0434\u0442\u0440\u0438\u043c\u0443\u0432\u0430\u043d\u0430 \u043a\u043e\u043b\u0456\u0440\u043d\u0430 \u0442\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u0430 (\u0432 \u043a\u0435\u043b\u044c\u0432\u0456\u043d\u0430\u0445)", - "max_temp": "\u041c\u0430\u043a\u0441\u0438\u043c\u0430\u043b\u044c\u043d\u0430 \u0446\u0456\u043b\u044c\u043e\u0432\u0430 \u0442\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u0430 (\u0437\u0430 \u0437\u0430\u043c\u043e\u0432\u0447\u0443\u0432\u0430\u043d\u043d\u044f\u043c \u0432\u0438\u043a\u043e\u0440\u0438\u0441\u0442\u043e\u0432\u0443\u0439\u0442\u0435 min \u0456 max = 0)", - "min_kelvin": "\u041c\u0456\u043d\u0456\u043c\u0430\u043b\u044c\u043d\u0430 \u043f\u0456\u0434\u0442\u0440\u0438\u043c\u0443\u0432\u0430\u043d\u0430 \u043a\u043e\u043b\u0456\u0440\u043d\u0430 \u0442\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u0430 (\u0432 \u043a\u0435\u043b\u044c\u0432\u0456\u043d\u0430\u0445)", - "min_temp": "\u041c\u0456\u043d\u0456\u043c\u0430\u043b\u044c\u043d\u0430 \u0446\u0456\u043b\u044c\u043e\u0432\u0430 \u0442\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u0430 (\u0437\u0430 \u0437\u0430\u043c\u043e\u0432\u0447\u0443\u0432\u0430\u043d\u043d\u044f\u043c \u0432\u0438\u043a\u043e\u0440\u0438\u0441\u0442\u043e\u0432\u0443\u0439\u0442\u0435 min \u0456 max = 0)", - "support_color": "\u041f\u0440\u0438\u043c\u0443\u0441\u043e\u0432\u0430 \u043f\u0456\u0434\u0442\u0440\u0438\u043c\u043a\u0430 \u043a\u043e\u043b\u044c\u043e\u0440\u0443", - "temp_divider": "\u0414\u0456\u043b\u044c\u043d\u0438\u043a \u0437\u043d\u0430\u0447\u0435\u043d\u044c \u0442\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u0438 (0 = \u0432\u0438\u043a\u043e\u0440\u0438\u0441\u0442\u043e\u0432\u0443\u0432\u0430\u0442\u0438 \u0437\u0430 \u0437\u0430\u043c\u043e\u0432\u0447\u0443\u0432\u0430\u043d\u043d\u044f\u043c)", - "tuya_max_coltemp": "\u041c\u0430\u043a\u0441\u0438\u043c\u0430\u043b\u044c\u043d\u0430 \u043a\u043e\u043b\u0456\u0440\u043d\u0430 \u0442\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u0430, \u044f\u043a\u0430 \u043f\u043e\u0432\u0456\u0434\u043e\u043c\u043b\u044f\u0454\u0442\u044c\u0441\u044f \u043f\u0440\u0438\u0441\u0442\u0440\u043e\u0454\u043c", - "unit_of_measurement": "\u041e\u0434\u0438\u043d\u0438\u0446\u044f \u0432\u0438\u043c\u0456\u0440\u0443 \u0442\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u0438, \u044f\u043a\u0430 \u0432\u0438\u043a\u043e\u0440\u0438\u0441\u0442\u043e\u0432\u0443\u0454\u0442\u044c\u0441\u044f \u043f\u0440\u0438\u0441\u0442\u0440\u043e\u0454\u043c" - }, - "description": "\u041f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0438 \u0434\u043b\u044f \u043d\u0430\u043b\u0430\u0448\u0442\u0443\u0432\u0430\u043d\u043d\u044f \u0432\u0438\u0434\u0438\u043c\u043e\u0457 \u0456\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0456\u0457 \u0434\u043b\u044f {device_type} \u043f\u0440\u0438\u0441\u0442\u0440\u043e\u044e '{device_name}'", - "title": "\u041d\u0430\u043b\u0430\u0448\u0442\u0443\u0432\u0430\u043d\u043d\u044f \u043f\u0440\u0438\u0441\u0442\u0440\u043e\u044e Tuya" - }, - "init": { - "data": { - "discovery_interval": "\u0406\u043d\u0442\u0435\u0440\u0432\u0430\u043b \u043e\u043f\u0438\u0442\u0443\u0432\u0430\u043d\u043d\u044f \u0432\u0438\u044f\u0432\u043b\u0435\u043d\u043d\u044f \u043f\u0440\u0438\u0441\u0442\u0440\u043e\u044e (\u0432 \u0441\u0435\u043a\u0443\u043d\u0434\u0430\u0445)", - "list_devices": "\u0412\u0438\u0431\u0435\u0440\u0456\u0442\u044c \u043f\u0440\u0438\u0441\u0442\u0440\u043e\u0457 \u0434\u043b\u044f \u043d\u0430\u043b\u0430\u0448\u0442\u0443\u0432\u0430\u043d\u043d\u044f \u0430\u0431\u043e \u0437\u0430\u043b\u0438\u0448\u0442\u0435 \u043f\u043e\u0440\u043e\u0436\u043d\u0456\u043c \u0434\u043b\u044f \u0437\u0431\u0435\u0440\u0435\u0436\u0435\u043d\u043d\u044f \u043a\u043e\u043d\u0444\u0456\u0433\u0443\u0440\u0430\u0446\u0456\u0457", - "query_device": "\u0412\u0438\u0431\u0435\u0440\u0456\u0442\u044c \u043f\u0440\u0438\u0441\u0442\u0440\u0456\u0439, \u044f\u043a\u0438\u0439 \u0431\u0443\u0434\u0435 \u0432\u0438\u043a\u043e\u0440\u0438\u0441\u0442\u043e\u0432\u0443\u0432\u0430\u0442\u0438 \u043c\u0435\u0442\u043e\u0434 \u0437\u0430\u043f\u0438\u0442\u0443 \u0434\u043b\u044f \u0431\u0456\u043b\u044c\u0448 \u0448\u0432\u0438\u0434\u043a\u043e\u0433\u043e \u043e\u043d\u043e\u0432\u043b\u0435\u043d\u043d\u044f \u0441\u0442\u0430\u0442\u0443\u0441\u0443", - "query_interval": "\u0406\u043d\u0442\u0435\u0440\u0432\u0430\u043b \u043e\u043f\u0438\u0442\u0443\u0432\u0430\u043d\u043d\u044f \u043f\u0440\u0438\u0441\u0442\u0440\u043e\u044e (\u0432 \u0441\u0435\u043a\u0443\u043d\u0434\u0430\u0445)" - }, - "description": "\u041d\u0435 \u0432\u0441\u0442\u0430\u043d\u043e\u0432\u043b\u044e\u0439\u0442\u0435 \u0437\u0430\u043d\u0430\u0434\u0442\u043e \u043d\u0438\u0437\u044c\u043a\u0456 \u0437\u043d\u0430\u0447\u0435\u043d\u043d\u044f \u0456\u043d\u0442\u0435\u0440\u0432\u0430\u043b\u0443 \u043e\u043f\u0438\u0442\u0443\u0432\u0430\u043d\u043d\u044f, \u0456\u043d\u0430\u043a\u0448\u0435 \u0432\u0438\u043a\u043b\u0438\u043a\u0438 \u043d\u0435 \u0431\u0443\u0434\u0443\u0442\u044c \u0433\u0435\u043d\u0435\u0440\u0443\u0432\u0430\u0442\u0438 \u043f\u043e\u0432\u0456\u0434\u043e\u043c\u043b\u0435\u043d\u043d\u044f \u043f\u0440\u043e \u043f\u043e\u043c\u0438\u043b\u043a\u0443 \u0432 \u0436\u0443\u0440\u043d\u0430\u043b\u0456.", - "title": "\u041d\u0430\u043b\u0430\u0448\u0442\u0443\u0432\u0430\u043d\u043d\u044f Tuya" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/zh-Hans.json b/homeassistant/components/tuya/translations/zh-Hans.json index ff3887c840d..e1acb5453aa 100644 --- a/homeassistant/components/tuya/translations/zh-Hans.json +++ b/homeassistant/components/tuya/translations/zh-Hans.json @@ -1,60 +1,29 @@ { "config": { - "abort": { - "cannot_connect": "\u8fde\u63a5\u5931\u8d25", - "invalid_auth": "\u8eab\u4efd\u8ba4\u8bc1\u65e0\u6548", - "single_instance_allowed": "\u5df2\u7ecf\u914d\u7f6e\u8fc7\u4e86\uff0c\u4e14\u53ea\u80fd\u914d\u7f6e\u4e00\u6b21\u3002" - }, "error": { - "invalid_auth": "\u8eab\u4efd\u8ba4\u8bc1\u65e0\u6548" + "invalid_auth": "身份认证无效" }, - "flow_title": "\u6d82\u9e26\u914d\u7f6e", + "flow_title": "涂鸦配置", "step": { - "user": { - "data": { - "country_code": "\u60a8\u7684\u5e10\u6237\u56fd\u5bb6(\u5730\u533a)\u4ee3\u7801\uff08\u4f8b\u5982\u4e2d\u56fd\u4e3a 86\uff0c\u7f8e\u56fd\u4e3a 1\uff09", - "password": "\u5bc6\u7801", - "platform": "\u60a8\u6ce8\u518c\u5e10\u6237\u7684\u5e94\u7528", - "username": "\u7528\u6237\u540d" - }, - "description": "\u8bf7\u8f93\u5165\u6d82\u9e26\u8d26\u6237\u4fe1\u606f\u3002", - "title": "\u6d82\u9e26" - } - } - }, - "options": { - "abort": { - "cannot_connect": "\u8fde\u63a5\u5931\u8d25" - }, - "error": { - "dev_multi_type": "\u591a\u4e2a\u8981\u914d\u7f6e\u7684\u8bbe\u5907\u5fc5\u987b\u5177\u6709\u76f8\u540c\u7684\u7c7b\u578b", - "dev_not_config": "\u8bbe\u5907\u7c7b\u578b\u4e0d\u53ef\u914d\u7f6e", - "dev_not_found": "\u672a\u627e\u5230\u8bbe\u5907" - }, - "step": { - "device": { - "data": { - "brightness_range_mode": "\u8bbe\u5907\u4f7f\u7528\u7684\u4eae\u5ea6\u8303\u56f4", - "max_kelvin": "\u6700\u9ad8\u652f\u6301\u8272\u6e29\uff08\u5f00\u6c0f\uff09", - "max_temp": "\u6700\u9ad8\u76ee\u6807\u6e29\u5ea6\uff08min \u548c max \u4e3a 0 \u65f6\u4f7f\u7528\u9ed8\u8ba4\uff09", - "min_kelvin": "\u6700\u4f4e\u652f\u6301\u8272\u6e29\uff08\u5f00\u6c0f\uff09", - "min_temp": "\u6700\u4f4e\u76ee\u6807\u6e29\u5ea6\uff08min \u548c max \u4e3a 0 \u65f6\u4f7f\u7528\u9ed8\u8ba4\uff09", - "support_color": "\u5f3a\u5236\u652f\u6301\u8c03\u8272", - "tuya_max_coltemp": "\u8bbe\u5907\u62a5\u544a\u7684\u6700\u9ad8\u8272\u6e29", - "unit_of_measurement": "\u8bbe\u5907\u4f7f\u7528\u7684\u6e29\u5ea6\u5355\u4f4d" - }, - "title": "\u914d\u7f6e\u6d82\u9e26\u8bbe\u5907" + "user":{ + "title":"Tuya插件", + "data":{ + "tuya_project_type": "涂鸦云项目类型" + } }, - "init": { + "login": { "data": { - "discovery_interval": "\u53d1\u73b0\u8bbe\u5907\u8f6e\u8be2\u95f4\u9694\uff08\u4ee5\u79d2\u4e3a\u5355\u4f4d\uff09", - "list_devices": "\u8bf7\u9009\u62e9\u8981\u914d\u7f6e\u7684\u8bbe\u5907\uff0c\u6216\u7559\u7a7a\u4ee5\u4fdd\u5b58\u914d\u7f6e", - "query_device": "\u8bf7\u9009\u62e9\u4f7f\u7528\u67e5\u8be2\u65b9\u6cd5\u7684\u8bbe\u5907\uff0c\u4ee5\u4fbf\u66f4\u5feb\u5730\u66f4\u65b0\u72b6\u6001", - "query_interval": "\u67e5\u8be2\u8bbe\u5907\u8f6e\u8be2\u95f4\u9694\uff08\u4ee5\u79d2\u4e3a\u5355\u4f4d\uff09" + "endpoint": "可用区域", + "access_id": "Access ID", + "access_secret": "Access Secret", + "tuya_app_type": "移动应用", + "country_code": "国家码", + "username": "账号", + "password": "密码" }, - "description": "\u8bf7\u4e0d\u8981\u5c06\u8f6e\u8be2\u95f4\u9694\u8bbe\u7f6e\u5f97\u592a\u4f4e\uff0c\u5426\u5219\u5c06\u8c03\u7528\u5931\u8d25\u5e76\u5728\u65e5\u5fd7\u751f\u6210\u9519\u8bef\u6d88\u606f", - "title": "\u914d\u7f6e\u6d82\u9e26\u9009\u9879" + "description": "请输入涂鸦账户信息。", + "title": "涂鸦" } } } -} \ No newline at end of file +} diff --git a/homeassistant/components/tuya/translations/zh-Hant.json b/homeassistant/components/tuya/translations/zh-Hant.json deleted file mode 100644 index 7221c86eb63..00000000000 --- a/homeassistant/components/tuya/translations/zh-Hant.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "config": { - "abort": { - "cannot_connect": "\u9023\u7dda\u5931\u6557", - "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", - "single_instance_allowed": "\u50c5\u80fd\u8a2d\u5b9a\u4e00\u7d44\u88dd\u7f6e\u3002" - }, - "error": { - "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548" - }, - "flow_title": "Tuya \u8a2d\u5b9a", - "step": { - "user": { - "data": { - "country_code": "\u5e33\u865f\u570b\u5bb6\u4ee3\u78bc\uff08\u4f8b\u5982\uff1a\u7f8e\u570b 1 \u6216\u4e2d\u570b 86\uff09", - "password": "\u5bc6\u78bc", - "platform": "\u5e33\u6236\u8a3b\u518a\u6240\u5728\u4f4d\u7f6e", - "username": "\u4f7f\u7528\u8005\u540d\u7a31" - }, - "description": "\u8f38\u5165 Tuya \u6191\u8b49\u3002", - "title": "Tuya" - } - } - }, - "options": { - "abort": { - "cannot_connect": "\u9023\u7dda\u5931\u6557" - }, - "error": { - "dev_multi_type": "\u591a\u91cd\u9078\u64c7\u8a2d\u88dd\u7f6e\u4ee5\u8a2d\u5b9a\u4f7f\u7528\u76f8\u540c\u985e\u578b", - "dev_not_config": "\u88dd\u7f6e\u985e\u578b\u7121\u6cd5\u8a2d\u5b9a", - "dev_not_found": "\u627e\u4e0d\u5230\u88dd\u7f6e" - }, - "step": { - "device": { - "data": { - "brightness_range_mode": "\u88dd\u7f6e\u6240\u4f7f\u7528\u4e4b\u4eae\u5ea6\u7bc4\u570d", - "curr_temp_divider": "\u76ee\u524d\u8272\u6eab\u503c\u5206\u914d\u5668\uff080 = \u4f7f\u7528\u9810\u8a2d\uff09", - "max_kelvin": "Kelvin \u652f\u63f4\u6700\u9ad8\u8272\u6eab", - "max_temp": "\u6700\u9ad8\u76ee\u6a19\u8272\u6eab\uff08\u4f7f\u7528\u6700\u4f4e\u8207\u6700\u9ad8 = 0 \u4f7f\u7528\u9810\u8a2d\uff09", - "min_kelvin": "Kelvin \u652f\u63f4\u6700\u4f4e\u8272\u6eab", - "min_temp": "\u6700\u4f4e\u76ee\u6a19\u8272\u6eab\uff08\u4f7f\u7528\u6700\u4f4e\u8207\u6700\u9ad8 = 0 \u4f7f\u7528\u9810\u8a2d\uff09", - "set_temp_divided": "\u4f7f\u7528\u5206\u9694\u865f\u6eab\u5ea6\u503c\u4ee5\u57f7\u884c\u8a2d\u5b9a\u6eab\u5ea6\u6307\u4ee4", - "support_color": "\u5f37\u5236\u8272\u6eab\u652f\u63f4", - "temp_divider": "\u8272\u6eab\u503c\u5206\u914d\u5668\uff080 = \u4f7f\u7528\u9810\u8a2d\uff09", - "temp_step_override": "\u76ee\u6a19\u6eab\u5ea6\u8a2d\u5b9a", - "tuya_max_coltemp": "\u88dd\u7f6e\u56de\u5831\u6700\u9ad8\u8272\u6eab", - "unit_of_measurement": "\u88dd\u7f6e\u6240\u4f7f\u7528\u4e4b\u6eab\u5ea6\u55ae\u4f4d" - }, - "description": "\u8a2d\u5b9a\u9078\u9805\u4ee5\u8abf\u6574 {device_type} \u88dd\u7f6e `{device_name}` \u986f\u793a\u8cc7\u8a0a", - "title": "\u8a2d\u5b9a Tuya \u88dd\u7f6e" - }, - "init": { - "data": { - "discovery_interval": "\u63a2\u7d22\u88dd\u7f6e\u66f4\u65b0\u79d2\u9593\u8ddd", - "list_devices": "\u9078\u64c7\u88dd\u7f6e\u4ee5\u8a2d\u5b9a\u3001\u6216\u4fdd\u6301\u7a7a\u767d\u4ee5\u5132\u5b58\u8a2d\u5b9a", - "query_device": "\u9078\u64c7\u88dd\u7f6e\u5c07\u4f7f\u7528\u67e5\u8a62\u65b9\u5f0f\u4ee5\u7372\u5f97\u66f4\u5feb\u7684\u72c0\u614b\u66f4\u65b0", - "query_interval": "\u67e5\u8a62\u88dd\u7f6e\u66f4\u65b0\u79d2\u9593\u8ddd" - }, - "description": "\u66f4\u65b0\u9593\u8ddd\u4e0d\u8981\u8a2d\u5b9a\u7684\u904e\u4f4e\u3001\u53ef\u80fd\u6703\u5c0e\u81f4\u65bc\u65e5\u8a8c\u4e2d\u7522\u751f\u932f\u8aa4\u8a0a\u606f", - "title": "\u8a2d\u5b9a Tuya \u9078\u9805" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 710f4d84d2c..370a87e2575 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -359,30 +359,6 @@ DHCP = [ "hostname": "lb*", "macaddress": "B09575*" }, - { - "domain": "tuya", - "macaddress": "508A06*" - }, - { - "domain": "tuya", - "macaddress": "7CF666*" - }, - { - "domain": "tuya", - "macaddress": "10D561*" - }, - { - "domain": "tuya", - "macaddress": "D4A651*" - }, - { - "domain": "tuya", - "macaddress": "68572D*" - }, - { - "domain": "tuya", - "macaddress": "1869D8*" - }, { "domain": "verisure", "macaddress": "0023C1*" diff --git a/requirements_all.txt b/requirements_all.txt index 91143cac731..dd27ed60898 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2332,7 +2332,7 @@ tp-connected==0.0.4 transmissionrpc==0.11 # homeassistant.components.tuya -tuyaha==0.0.10 +tuya-iot-py-sdk==0.4.1 # homeassistant.components.twentemilieu twentemilieu==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1b040845dbd..961cea61a75 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1315,7 +1315,7 @@ total_connect_client==0.57 transmissionrpc==0.11 # homeassistant.components.tuya -tuyaha==0.0.10 +tuya-iot-py-sdk==0.4.1 # homeassistant.components.twentemilieu twentemilieu==0.3.0 diff --git a/tests/components/tuya/test_config_flow.py b/tests/components/tuya/test_config_flow.py index 6ea28cf8d2b..a15cfcc0fdf 100644 --- a/tests/components/tuya/test_config_flow.py +++ b/tests/components/tuya/test_config_flow.py @@ -1,68 +1,81 @@ """Tests for the Tuya config flow.""" -from unittest.mock import Mock, patch +from unittest.mock import MagicMock, Mock, patch import pytest -from tuyaha.devices.climate import STEP_HALVES -from tuyaha.tuyaapi import TuyaAPIException, TuyaNetException from homeassistant import config_entries, data_entry_flow -from homeassistant.components.tuya.config_flow import ( - CONF_LIST_DEVICES, - ERROR_DEV_MULTI_TYPE, - ERROR_DEV_NOT_CONFIG, - ERROR_DEV_NOT_FOUND, - RESULT_AUTH_FAILED, - RESULT_CONN_ERROR, - RESULT_SINGLE_INSTANCE, -) +from homeassistant.components.tuya.config_flow import RESULT_AUTH_FAILED from homeassistant.components.tuya.const import ( - CONF_BRIGHTNESS_RANGE_MODE, - CONF_COUNTRYCODE, - CONF_CURR_TEMP_DIVIDER, - CONF_DISCOVERY_INTERVAL, - CONF_MAX_KELVIN, - CONF_MAX_TEMP, - CONF_MIN_KELVIN, - CONF_MIN_TEMP, - CONF_QUERY_DEVICE, - CONF_QUERY_INTERVAL, - CONF_SET_TEMP_DIVIDED, - CONF_SUPPORT_COLOR, - CONF_TEMP_DIVIDER, - CONF_TEMP_STEP_OVERRIDE, - CONF_TUYA_MAX_COLTEMP, - DOMAIN, - TUYA_DATA, -) -from homeassistant.const import ( + CONF_ACCESS_ID, + CONF_ACCESS_SECRET, + CONF_APP_TYPE, + CONF_COUNTRY_CODE, + CONF_ENDPOINT, CONF_PASSWORD, - CONF_PLATFORM, - CONF_UNIT_OF_MEASUREMENT, + CONF_PROJECT_TYPE, CONF_USERNAME, - TEMP_CELSIUS, + DOMAIN, ) -from .common import CLIMATE_ID, LIGHT_ID, LIGHT_ID_FAKE1, LIGHT_ID_FAKE2, MockTuya +MOCK_SMART_HOME_PROJECT_TYPE = 0 +MOCK_INDUSTRY_PROJECT_TYPE = 1 -from tests.common import MockConfigEntry +MOCK_ACCESS_ID = "myAccessId" +MOCK_ACCESS_SECRET = "myAccessSecret" +MOCK_USERNAME = "myUsername" +MOCK_PASSWORD = "myPassword" +MOCK_COUNTRY_CODE = "1" +MOCK_APP_TYPE = "smartlife" +MOCK_ENDPOINT = "https://openapi-ueaz.tuyaus.com" -USERNAME = "myUsername" -PASSWORD = "myPassword" -COUNTRY_CODE = "1" -TUYA_PLATFORM = "tuya" +TUYA_SMART_HOME_PROJECT_DATA = { + CONF_PROJECT_TYPE: MOCK_SMART_HOME_PROJECT_TYPE, +} +TUYA_INDUSTRY_PROJECT_DATA = { + CONF_PROJECT_TYPE: MOCK_INDUSTRY_PROJECT_TYPE, +} -TUYA_USER_DATA = { - CONF_USERNAME: USERNAME, - CONF_PASSWORD: PASSWORD, - CONF_COUNTRYCODE: COUNTRY_CODE, - CONF_PLATFORM: TUYA_PLATFORM, +TUYA_INPUT_SMART_HOME_DATA = { + CONF_ACCESS_ID: MOCK_ACCESS_ID, + CONF_ACCESS_SECRET: MOCK_ACCESS_SECRET, + CONF_USERNAME: MOCK_USERNAME, + CONF_PASSWORD: MOCK_PASSWORD, + CONF_COUNTRY_CODE: MOCK_COUNTRY_CODE, + CONF_APP_TYPE: MOCK_APP_TYPE, +} + +TUYA_INPUT_INDUSTRY_DATA = { + CONF_ENDPOINT: MOCK_ENDPOINT, + CONF_ACCESS_ID: MOCK_ACCESS_ID, + CONF_ACCESS_SECRET: MOCK_ACCESS_SECRET, + CONF_USERNAME: MOCK_USERNAME, + CONF_PASSWORD: MOCK_PASSWORD, +} + +TUYA_IMPORT_SMART_HOME_DATA = { + CONF_ACCESS_ID: MOCK_ACCESS_ID, + CONF_ACCESS_SECRET: MOCK_ACCESS_SECRET, + CONF_USERNAME: MOCK_USERNAME, + CONF_PASSWORD: MOCK_PASSWORD, + CONF_COUNTRY_CODE: MOCK_COUNTRY_CODE, + CONF_APP_TYPE: MOCK_APP_TYPE, +} + + +TUYA_IMPORT_INDUSTRY_DATA = { + CONF_PROJECT_TYPE: MOCK_SMART_HOME_PROJECT_TYPE, + CONF_ENDPOINT: MOCK_ENDPOINT, + CONF_ACCESS_ID: MOCK_ACCESS_ID, + CONF_ACCESS_SECRET: MOCK_ACCESS_SECRET, + CONF_USERNAME: MOCK_USERNAME, + CONF_PASSWORD: MOCK_PASSWORD, } @pytest.fixture(name="tuya") def tuya_fixture() -> Mock: """Patch libraries.""" - with patch("homeassistant.components.tuya.config_flow.TuyaApi") as tuya: + with patch("homeassistant.components.tuya.config_flow.TuyaOpenAPI") as tuya: yield tuya @@ -73,8 +86,8 @@ def tuya_setup_fixture(): yield -async def test_user(hass, tuya): - """Test user config.""" +async def test_industry_user(hass, tuya): + """Test industry user config.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -83,192 +96,92 @@ async def test_user(hass, tuya): assert result["step_id"] == "user" result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=TUYA_USER_DATA + result["flow_id"], user_input=TUYA_INDUSTRY_PROJECT_DATA + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "login" + + tuya().login = MagicMock(return_value={"success": True, "errorCode": 1024}) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=TUYA_INPUT_INDUSTRY_DATA ) await hass.async_block_till_done() assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == USERNAME - assert result["data"][CONF_USERNAME] == USERNAME - assert result["data"][CONF_PASSWORD] == PASSWORD - assert result["data"][CONF_COUNTRYCODE] == COUNTRY_CODE - assert result["data"][CONF_PLATFORM] == TUYA_PLATFORM + assert result["title"] == MOCK_USERNAME + assert result["data"][CONF_ACCESS_ID] == MOCK_ACCESS_ID + assert result["data"][CONF_ACCESS_SECRET] == MOCK_ACCESS_SECRET + assert result["data"][CONF_USERNAME] == MOCK_USERNAME + assert result["data"][CONF_PASSWORD] == MOCK_PASSWORD assert not result["result"].unique_id -async def test_abort_if_already_setup(hass, tuya): - """Test we abort if Tuya is already setup.""" - MockConfigEntry(domain=DOMAIN, data=TUYA_USER_DATA).add_to_hass(hass) - - # Should fail, config exist (import) +async def test_smart_home_user(hass, tuya): + """Test smart home user config.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=TUYA_USER_DATA - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == RESULT_SINGLE_INSTANCE - - -async def test_abort_on_invalid_credentials(hass, tuya): - """Test when we have invalid credentials.""" - tuya().init.side_effect = TuyaAPIException("Boom") - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=TUYA_USER_DATA + DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {"base": RESULT_AUTH_FAILED} + assert result["step_id"] == "user" - -async def test_abort_on_connection_error(hass, tuya): - """Test when we have a network error.""" - tuya().init.side_effect = TuyaNetException("Boom") - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=TUYA_USER_DATA + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=TUYA_SMART_HOME_PROJECT_DATA ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == RESULT_CONN_ERROR - - -async def test_options_flow(hass): - """Test config flow options.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data=TUYA_USER_DATA, - ) - config_entry.add_to_hass(hass) - - # Set up the integration to make sure the config flow module is loaded. - assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - # Unload the integration to prepare for the test. - with patch("homeassistant.components.tuya.async_unload_entry", return_value=True): - assert await hass.config_entries.async_unload(config_entry.entry_id) - await hass.async_block_till_done() + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "login" - # Test check for integration not loaded - result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == RESULT_CONN_ERROR - - # Load integration and enter options - await hass.config_entries.async_setup(config_entry.entry_id) + tuya().login = MagicMock(return_value={"success": False, "errorCode": 1024}) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=TUYA_IMPORT_SMART_HOME_DATA + ) await hass.async_block_till_done() - hass.data[DOMAIN] = {TUYA_DATA: MockTuya()} - result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "init" + assert result["errors"]["base"] == RESULT_AUTH_FAILED - # Test dev not found error - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={CONF_LIST_DEVICES: [f"light-{LIGHT_ID_FAKE1}"]}, + tuya().login = MagicMock(return_value={"success": True, "errorCode": 1024}) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=TUYA_IMPORT_SMART_HOME_DATA ) + await hass.async_block_till_done() - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "init" - assert result["errors"] == {"base": ERROR_DEV_NOT_FOUND} - - # Test dev type error - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={CONF_LIST_DEVICES: [f"light-{LIGHT_ID_FAKE2}"]}, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "init" - assert result["errors"] == {"base": ERROR_DEV_NOT_CONFIG} - - # Test multi dev error - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={CONF_LIST_DEVICES: [f"climate-{CLIMATE_ID}", f"light-{LIGHT_ID}"]}, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "init" - assert result["errors"] == {"base": ERROR_DEV_MULTI_TYPE} - - # Test climate options form - result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={CONF_LIST_DEVICES: [f"climate-{CLIMATE_ID}"]} - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "device" - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - CONF_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, - CONF_TEMP_DIVIDER: 10, - CONF_CURR_TEMP_DIVIDER: 5, - CONF_SET_TEMP_DIVIDED: False, - CONF_TEMP_STEP_OVERRIDE: STEP_HALVES, - CONF_MIN_TEMP: 12, - CONF_MAX_TEMP: 22, - }, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "init" - - # Test light options form - result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={CONF_LIST_DEVICES: [f"light-{LIGHT_ID}"]} - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "device" - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - CONF_SUPPORT_COLOR: True, - CONF_BRIGHTNESS_RANGE_MODE: 1, - CONF_MIN_KELVIN: 4000, - CONF_MAX_KELVIN: 5000, - CONF_TUYA_MAX_COLTEMP: 12000, - }, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "init" - - # Test common options - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - CONF_DISCOVERY_INTERVAL: 100, - CONF_QUERY_INTERVAL: 50, - CONF_QUERY_DEVICE: LIGHT_ID, - }, - ) - - # Verify results assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == MOCK_USERNAME + assert result["data"][CONF_ACCESS_ID] == MOCK_ACCESS_ID + assert result["data"][CONF_ACCESS_SECRET] == MOCK_ACCESS_SECRET + assert result["data"][CONF_USERNAME] == MOCK_USERNAME + assert result["data"][CONF_PASSWORD] == MOCK_PASSWORD + assert result["data"][CONF_COUNTRY_CODE] == MOCK_COUNTRY_CODE + assert result["data"][CONF_APP_TYPE] == MOCK_APP_TYPE + assert not result["result"].unique_id - climate_options = config_entry.options[CLIMATE_ID] - assert climate_options[CONF_UNIT_OF_MEASUREMENT] == TEMP_CELSIUS - assert climate_options[CONF_TEMP_DIVIDER] == 10 - assert climate_options[CONF_CURR_TEMP_DIVIDER] == 5 - assert climate_options[CONF_SET_TEMP_DIVIDED] is False - assert climate_options[CONF_TEMP_STEP_OVERRIDE] == STEP_HALVES - assert climate_options[CONF_MIN_TEMP] == 12 - assert climate_options[CONF_MAX_TEMP] == 22 - light_options = config_entry.options[LIGHT_ID] - assert light_options[CONF_SUPPORT_COLOR] is True - assert light_options[CONF_BRIGHTNESS_RANGE_MODE] == 1 - assert light_options[CONF_MIN_KELVIN] == 4000 - assert light_options[CONF_MAX_KELVIN] == 5000 - assert light_options[CONF_TUYA_MAX_COLTEMP] == 12000 +async def test_error_on_invalid_credentials(hass, tuya): + """Test when we have invalid credentials.""" - assert config_entry.options[CONF_DISCOVERY_INTERVAL] == 100 - assert config_entry.options[CONF_QUERY_INTERVAL] == 50 - assert config_entry.options[CONF_QUERY_DEVICE] == LIGHT_ID + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=TUYA_INDUSTRY_PROJECT_DATA + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "login" + + tuya().login = MagicMock(return_value={"success": False, "errorCode": 1024}) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=TUYA_INPUT_INDUSTRY_DATA + ) + await hass.async_block_till_done() + + assert result["errors"]["base"] == RESULT_AUTH_FAILED From c6f48056fd0eb37f3553eacb57491a0a5fcbb113 Mon Sep 17 00:00:00 2001 From: Oxan van Leeuwen Date: Thu, 30 Sep 2021 12:12:37 +0200 Subject: [PATCH 731/843] Remove dead code from ESPHome light entity (#55519) --- homeassistant/components/esphome/light.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/homeassistant/components/esphome/light.py b/homeassistant/components/esphome/light.py index 9e7f544f610..b8fe4bd74c7 100644 --- a/homeassistant/components/esphome/light.py +++ b/homeassistant/components/esphome/light.py @@ -247,11 +247,6 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): ) try_keep_current_mode = False - if self._supports_color_mode and color_modes: - # try the color mode with the least complexity (fewest capabilities set) - # popcount with bin() function because it appears to be the best way: https://stackoverflow.com/a/9831671 - color_modes.sort(key=lambda mode: bin(mode).count("1")) - data["color_mode"] = color_modes[0] if self._supports_color_mode and color_modes: if ( try_keep_current_mode From ee28dd57c1af8f5f21d3b011b617001c2293702f Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 30 Sep 2021 12:15:17 +0200 Subject: [PATCH 732/843] Rename var to compliant name in August integration (#56812) --- homeassistant/components/august/binary_sensor.py | 12 +++++------- homeassistant/components/august/sensor.py | 10 +++++----- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/august/binary_sensor.py b/homeassistant/components/august/binary_sensor.py index 804a9810a94..9a38cd1e301 100644 --- a/homeassistant/components/august/binary_sensor.py +++ b/homeassistant/components/august/binary_sensor.py @@ -93,7 +93,7 @@ def _native_datetime() -> datetime: class AugustRequiredKeysMixin: """Mixin for required keys.""" - state_provider: Callable[[AugustData, DoorbellDetail], bool] + value_fn: Callable[[AugustData, DoorbellDetail], bool] is_time_based: bool @@ -115,21 +115,21 @@ SENSOR_TYPES_DOORBELL: tuple[AugustBinarySensorEntityDescription, ...] = ( key="doorbell_ding", name="Ding", device_class=DEVICE_CLASS_OCCUPANCY, - state_provider=_retrieve_ding_state, + value_fn=_retrieve_ding_state, is_time_based=True, ), AugustBinarySensorEntityDescription( key="doorbell_motion", name="Motion", device_class=DEVICE_CLASS_MOTION, - state_provider=_retrieve_motion_state, + value_fn=_retrieve_motion_state, is_time_based=True, ), AugustBinarySensorEntityDescription( key="doorbell_online", name="Online", device_class=DEVICE_CLASS_CONNECTIVITY, - state_provider=_retrieve_online_state, + value_fn=_retrieve_online_state, is_time_based=False, ), ) @@ -225,9 +225,7 @@ class AugustDoorbellBinarySensor(AugustEntityMixin, BinarySensorEntity): def _update_from_data(self): """Get the latest state of the sensor.""" self._cancel_any_pending_updates() - self._attr_is_on = self.entity_description.state_provider( - self._data, self._detail - ) + self._attr_is_on = self.entity_description.value_fn(self._data, self._detail) if self.entity_description.is_time_based: self._attr_available = _retrieve_online_state(self._data, self._detail) diff --git a/homeassistant/components/august/sensor.py b/homeassistant/components/august/sensor.py index 263d20be1b6..b6fa767edb7 100644 --- a/homeassistant/components/august/sensor.py +++ b/homeassistant/components/august/sensor.py @@ -55,7 +55,7 @@ T = TypeVar("T", LockDetail, KeypadDetail) class AugustRequiredKeysMixin(Generic[T]): """Mixin for required keys.""" - state_provider: Callable[[T], int | None] + value_fn: Callable[[T], int | None] @dataclass @@ -68,13 +68,13 @@ class AugustSensorEntityDescription( SENSOR_TYPE_DEVICE_BATTERY = AugustSensorEntityDescription[LockDetail]( key="device_battery", name="Battery", - state_provider=_retrieve_device_battery_state, + value_fn=_retrieve_device_battery_state, ) SENSOR_TYPE_KEYPAD_BATTERY = AugustSensorEntityDescription[KeypadDetail]( key="linked_keypad_battery", name="Battery", - state_provider=_retrieve_linked_keypad_battery_state, + value_fn=_retrieve_linked_keypad_battery_state, ) @@ -97,7 +97,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): for device in batteries["device_battery"]: detail = data.get_device_detail(device.device_id) - if detail is None or SENSOR_TYPE_DEVICE_BATTERY.state_provider(detail) is None: + if detail is None or SENSOR_TYPE_DEVICE_BATTERY.value_fn(detail) is None: _LOGGER.debug( "Not adding battery sensor for %s because it is not present", device.device_name, @@ -268,7 +268,7 @@ class AugustBatterySensor(AugustEntityMixin, SensorEntity, Generic[T]): @callback def _update_from_data(self): """Get the latest state of the sensor.""" - self._attr_native_value = self.entity_description.state_provider(self._detail) + self._attr_native_value = self.entity_description.value_fn(self._detail) self._attr_available = self._attr_native_value is not None @property From cf6398a949ad7e87c9d0f130d33787ebde8664fa Mon Sep 17 00:00:00 2001 From: logan893 Date: Thu, 30 Sep 2021 19:22:42 +0900 Subject: [PATCH 733/843] Fix hue turning on eWeLink switch (#56318) --- homeassistant/components/hue/light.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/hue/light.py b/homeassistant/components/hue/light.py index ea89d91113b..cc3144b99ca 100644 --- a/homeassistant/components/hue/light.py +++ b/homeassistant/components/hue/light.py @@ -282,6 +282,7 @@ class HueLight(CoordinatorEntity, LightEntity): self.is_osram = False self.is_philips = False self.is_innr = False + self.is_ewelink = False self.is_livarno = False self.gamut_typ = GAMUT_TYPE_UNAVAILABLE self.gamut = None @@ -289,6 +290,7 @@ class HueLight(CoordinatorEntity, LightEntity): self.is_osram = light.manufacturername == "OSRAM" self.is_philips = light.manufacturername == "Philips" self.is_innr = light.manufacturername == "innr" + self.is_ewelink = light.manufacturername == "eWeLink" self.is_livarno = light.manufacturername.startswith("_TZ3000_") self.gamut_typ = self.light.colorgamuttype self.gamut = self.light.colorgamut @@ -497,7 +499,7 @@ class HueLight(CoordinatorEntity, LightEntity): elif flash == FLASH_SHORT: command["alert"] = "select" del command["on"] - elif not self.is_innr and not self.is_livarno: + elif not self.is_innr and not self.is_ewelink and not self.is_livarno: command["alert"] = "none" if ATTR_EFFECT in kwargs: From d4ed0f9637af69020bc9ef2344ad876688583666 Mon Sep 17 00:00:00 2001 From: deosrc Date: Thu, 30 Sep 2021 11:31:06 +0100 Subject: [PATCH 734/843] Fix OVO Energy reporting consumption as cost (#55856) --- homeassistant/components/ovo_energy/sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ovo_energy/sensor.py b/homeassistant/components/ovo_energy/sensor.py index 0afca4f84f2..17f92a3b2e2 100644 --- a/homeassistant/components/ovo_energy/sensor.py +++ b/homeassistant/components/ovo_energy/sensor.py @@ -58,7 +58,7 @@ SENSOR_TYPES_ELECTRICITY: tuple[OVOEnergySensorEntityDescription, ...] = ( name="OVO Last Electricity Cost", device_class=DEVICE_CLASS_MONETARY, state_class=STATE_CLASS_TOTAL_INCREASING, - value=lambda usage: usage.electricity[-1].consumption, + value=lambda usage: usage.electricity[-1].cost.amount, ), OVOEnergySensorEntityDescription( key="last_electricity_start_time", @@ -92,7 +92,7 @@ SENSOR_TYPES_GAS: tuple[OVOEnergySensorEntityDescription, ...] = ( device_class=DEVICE_CLASS_MONETARY, state_class=STATE_CLASS_TOTAL_INCREASING, icon="mdi:cash-multiple", - value=lambda usage: usage.gas[-1].consumption, + value=lambda usage: usage.gas[-1].cost.amount, ), OVOEnergySensorEntityDescription( key="last_gas_start_time", From c9e1a03fe27e43dc5570293cd26e2014dede3afa Mon Sep 17 00:00:00 2001 From: Marko Korhonen Date: Thu, 30 Sep 2021 14:03:38 +0300 Subject: [PATCH 735/843] Remove webostv service description github link (#53502) Co-authored-by: Shay Levy Co-authored-by: Franck Nijhof --- homeassistant/components/webostv/services.yaml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/webostv/services.yaml b/homeassistant/components/webostv/services.yaml index f9d56cd1921..0fb3cd1ae16 100644 --- a/homeassistant/components/webostv/services.yaml +++ b/homeassistant/components/webostv/services.yaml @@ -38,9 +38,7 @@ command: domain: media_player command: name: Command - description: >- - Endpoint of the command. Known valid endpoints are listed in - https://github.com/TheRealLink/pylgtv/blob/master/pylgtv/endpoints.py + description: Endpoint of the command. required: true example: "system.launcher/open" selector: From ef4b6d7bdf7406d7bf5b7ecd9ffcc8d6a13e750e Mon Sep 17 00:00:00 2001 From: Fabrizio Tarizzo Date: Thu, 30 Sep 2021 13:22:43 +0200 Subject: [PATCH 736/843] Update viaggiatreno component due to API changes (#56463) --- homeassistant/components/viaggiatreno/sensor.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/viaggiatreno/sensor.py b/homeassistant/components/viaggiatreno/sensor.py index ddfbb9f20dd..dc20ea3edbc 100644 --- a/homeassistant/components/viaggiatreno/sensor.py +++ b/homeassistant/components/viaggiatreno/sensor.py @@ -1,6 +1,7 @@ """Support for the Italian train system using ViaggiaTreno API.""" import asyncio import logging +import time import aiohttp import async_timeout @@ -17,7 +18,7 @@ ATTRIBUTION = "Powered by ViaggiaTreno Data" VIAGGIATRENO_ENDPOINT = ( "http://www.viaggiatreno.it/viaggiatrenonew/" "resteasy/viaggiatreno/andamentoTreno/" - "{station_id}/{train_id}" + "{station_id}/{train_id}/{timestamp}" ) REQUEST_TIMEOUT = 5 # seconds @@ -94,7 +95,7 @@ class ViaggiaTrenoSensor(SensorEntity): self._name = name self.uri = VIAGGIATRENO_ENDPOINT.format( - station_id=station_id, train_id=train_id + station_id=station_id, train_id=train_id, timestamp=int(time.time()) * 1000 ) @property From dd52ec78c7162c4994f8ed4a9cf91285881b3155 Mon Sep 17 00:00:00 2001 From: Kevin Stillhammer Date: Thu, 30 Sep 2021 13:23:46 +0200 Subject: [PATCH 737/843] Add Kraken delay after first update to avoid limit (#55736) * Add delay after first update to avoid limit * Apply suggestions --- homeassistant/components/kraken/__init__.py | 4 +- homeassistant/components/kraken/sensor.py | 6 +- tests/components/kraken/const.py | 70 ++++++++++++++++++++ tests/components/kraken/test_sensor.py | 73 ++++++++++++--------- 4 files changed, 120 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/kraken/__init__.py b/homeassistant/components/kraken/__init__.py index 76a4976f163..5b1fd2626e3 100644 --- a/homeassistant/components/kraken/__init__.py +++ b/homeassistant/components/kraken/__init__.py @@ -127,7 +127,7 @@ class KrakenData: self._config_entry, options=options ) await self._async_refresh_tradable_asset_pairs() - # Wait 1 second to avoid triggering the CallRateLimiter + # Wait 1 second to avoid triggering the KrakenAPI CallRateLimiter await asyncio.sleep(CALL_RATE_LIMIT_SLEEP) self.coordinator = DataUpdateCoordinator( self._hass, @@ -139,6 +139,8 @@ class KrakenData: ), ) await self.coordinator.async_config_entry_first_refresh() + # Wait 1 second to avoid triggering the KrakenAPI CallRateLimiter + await asyncio.sleep(CALL_RATE_LIMIT_SLEEP) def _get_websocket_name_asset_pairs(self) -> str: return ",".join(wsname for wsname in self.tradable_asset_pairs.values()) diff --git a/homeassistant/components/kraken/sensor.py b/homeassistant/components/kraken/sensor.py index 9c2030766f7..b7d38d4796b 100644 --- a/homeassistant/components/kraken/sensor.py +++ b/homeassistant/components/kraken/sensor.py @@ -129,12 +129,14 @@ class KrakenSensor(CoordinatorEntity[Optional[KrakenResponse]], SensorEntity): super()._handle_coordinator_update() def _update_internal_state(self) -> None: + if not self.coordinator.data: + return try: self._attr_native_value = self.entity_description.value_fn( self.coordinator, self.tracked_asset_pair_wsname # type: ignore[arg-type] ) - self._received_data_at_least_once = True # Received data at least one time. - except TypeError: + self._received_data_at_least_once = True + except KeyError: if self._received_data_at_least_once: if self._available: _LOGGER.warning( diff --git a/tests/components/kraken/const.py b/tests/components/kraken/const.py index 6e3174a9ae7..78658ffd660 100644 --- a/tests/components/kraken/const.py +++ b/tests/components/kraken/const.py @@ -7,6 +7,12 @@ TRADEABLE_ASSET_PAIR_RESPONSE = pandas.DataFrame( index=["ADAXBT", "ADAETH", "XBTEUR", "XXBTZGBP", "XXBTZUSD", "XXBTZJPY"], ) +MISSING_PAIR_TRADEABLE_ASSET_PAIR_RESPONSE = pandas.DataFrame( + {"wsname": ["ADA/XBT", "ADA/ETH", "XBT/EUR", "XBT/GBP", "XBT/JPY"]}, + columns=["wsname"], + index=["ADAXBT", "ADAETH", "XBTEUR", "XXBTZGBP", "XXBTZJPY"], +) + TICKER_INFORMATION_RESPONSE = pandas.DataFrame( { "a": [ @@ -78,3 +84,67 @@ TICKER_INFORMATION_RESPONSE = pandas.DataFrame( columns=["a", "b", "c", "h", "l", "o", "p", "t", "v"], index=["ADAXBT", "ADAETH", "XBTEUR", "XXBTZGBP", "XXBTZUSD", "XXBTZJPY"], ) + +MISSING_PAIR_TICKER_INFORMATION_RESPONSE = pandas.DataFrame( + { + "a": [ + [0.000349400, 15949, 15949.000], + [0.000349400, 15949, 15949.000], + [0.000349400, 15949, 15949.000], + [0.000349400, 15949, 15949.000], + [0.000349400, 15949, 15949.000], + ], + "b": [ + [0.000348400, 20792, 20792.000], + [0.000348400, 20792, 20792.000], + [0.000348400, 20792, 20792.000], + [0.000348400, 20792, 20792.000], + [0.000348400, 20792, 20792.000], + ], + "c": [ + [0.000347800, 2809.36384377], + [0.000347800, 2809.36384377], + [0.000347800, 2809.36384377], + [0.000347800, 2809.36384377], + [0.000347800, 2809.36384377], + ], + "h": [ + [0.000351600, 0.000352100], + [0.000351600, 0.000352100], + [0.000351600, 0.000352100], + [0.000351600, 0.000352100], + [0.000351600, 0.000352100], + ], + "l": [ + [0.000344600, 0.000344600], + [0.000344600, 0.000344600], + [0.000344600, 0.000344600], + [0.000344600, 0.000344600], + [0.000344600, 0.000344600], + ], + "o": [ + 0.000351300, + 0.000351300, + 0.000351300, + 0.000351300, + 0.000351300, + ], + "p": [ + [0.000348573, 0.000344881], + [0.000348573, 0.000344881], + [0.000348573, 0.000344881], + [0.000348573, 0.000344881], + [0.000348573, 0.000344881], + ], + "t": [[82, 128], [82, 128], [82, 128], [82, 128], [82, 128]], + "v": [ + [146300.24906838, 253478.04715403], + [146300.24906838, 253478.04715403], + [146300.24906838, 253478.04715403], + [146300.24906838, 253478.04715403], + [146300.24906838, 253478.04715403], + ], + }, + columns=["a", "b", "c", "h", "l", "o", "p", "t", "v"], + index=["ADAXBT", "ADAETH", "XBTEUR", "XXBTZGBP", "XXBTZJPY"], +) diff --git a/tests/components/kraken/test_sensor.py b/tests/components/kraken/test_sensor.py index 98760a3002d..110a944a4d5 100644 --- a/tests/components/kraken/test_sensor.py +++ b/tests/components/kraken/test_sensor.py @@ -13,7 +13,12 @@ from homeassistant.components.kraken.const import ( from homeassistant.const import CONF_SCAN_INTERVAL, EVENT_HOMEASSISTANT_START import homeassistant.util.dt as dt_util -from .const import TICKER_INFORMATION_RESPONSE, TRADEABLE_ASSET_PAIR_RESPONSE +from .const import ( + MISSING_PAIR_TICKER_INFORMATION_RESPONSE, + MISSING_PAIR_TRADEABLE_ASSET_PAIR_RESPONSE, + TICKER_INFORMATION_RESPONSE, + TRADEABLE_ASSET_PAIR_RESPONSE, +) from tests.common import MockConfigEntry, async_fire_time_changed @@ -230,38 +235,46 @@ async def test_missing_pair_marks_sensor_unavailable(hass): with patch("homeassistant.util.dt.utcnow", return_value=utcnow), patch( "pykrakenapi.KrakenAPI.get_tradable_asset_pairs", return_value=TRADEABLE_ASSET_PAIR_RESPONSE, - ): - with patch( - "pykrakenapi.KrakenAPI.get_ticker_information", - return_value=TICKER_INFORMATION_RESPONSE, - ): - entry = MockConfigEntry( - domain=DOMAIN, - options={ - CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL, - CONF_TRACKED_ASSET_PAIRS: [DEFAULT_TRACKED_ASSET_PAIR], - }, - ) - entry.add_to_hass(hass) + ) as tradeable_asset_pairs_mock, patch( + "pykrakenapi.KrakenAPI.get_ticker_information", + return_value=TICKER_INFORMATION_RESPONSE, + ) as ticket_information_mock: + entry = MockConfigEntry( + domain=DOMAIN, + options={ + CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL, + CONF_TRACKED_ASSET_PAIRS: [DEFAULT_TRACKED_ASSET_PAIR], + }, + ) + entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) + await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + await hass.async_block_till_done() - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() - sensor = hass.states.get("sensor.xbt_usd_ask") - assert sensor.state == "0.0003494" + sensor = hass.states.get("sensor.xbt_usd_ask") + assert sensor.state == "0.0003494" - with patch( - "pykrakenapi.KrakenAPI.get_ticker_information", - side_effect=KrakenAPIError("EQuery:Unknown asset pair"), - ): - async_fire_time_changed( - hass, utcnow + timedelta(seconds=DEFAULT_SCAN_INTERVAL * 2) - ) - await hass.async_block_till_done() + tradeable_asset_pairs_mock.return_value = ( + MISSING_PAIR_TRADEABLE_ASSET_PAIR_RESPONSE + ) + ticket_information_mock.side_effect = KrakenAPIError( + "EQuery:Unknown asset pair" + ) + async_fire_time_changed( + hass, utcnow + timedelta(seconds=DEFAULT_SCAN_INTERVAL * 2) + ) + await hass.async_block_till_done() - sensor = hass.states.get("sensor.xbt_usd_ask") - assert sensor.state == "unavailable" + ticket_information_mock.side_effect = None + ticket_information_mock.return_value = MISSING_PAIR_TICKER_INFORMATION_RESPONSE + async_fire_time_changed( + hass, utcnow + timedelta(seconds=DEFAULT_SCAN_INTERVAL * 2) + ) + await hass.async_block_till_done() + + sensor = hass.states.get("sensor.xbt_usd_ask") + assert sensor.state == "unavailable" From f18e4bab60604e286c03d7d64003ab159cadb9a7 Mon Sep 17 00:00:00 2001 From: Sean Vig Date: Thu, 30 Sep 2021 07:38:18 -0400 Subject: [PATCH 738/843] Add resolution to Amcrest camera unique id (#56207) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Joakim Sørensen --- homeassistant/components/amcrest/__init__.py | 1 + .../components/amcrest/binary_sensor.py | 12 +++++--- homeassistant/components/amcrest/camera.py | 28 +++++++++++++++++-- homeassistant/components/amcrest/sensor.py | 9 +++++- 4 files changed, 42 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/amcrest/__init__.py b/homeassistant/components/amcrest/__init__.py index bb8956f8b15..18aa2006f72 100644 --- a/homeassistant/components/amcrest/__init__.py +++ b/homeassistant/components/amcrest/__init__.py @@ -380,3 +380,4 @@ class AmcrestDevice: stream_source: str resolution: int control_light: bool + channel: int = 0 diff --git a/homeassistant/components/amcrest/binary_sensor.py b/homeassistant/components/amcrest/binary_sensor.py index ea8f15d838c..8d2535a142b 100644 --- a/homeassistant/components/amcrest/binary_sensor.py +++ b/homeassistant/components/amcrest/binary_sensor.py @@ -170,7 +170,7 @@ class AmcrestBinarySensor(BinarySensorEntity): """Initialize entity.""" self._signal_name = name self._api = device.api - self._channel = 0 # Used in unique id, reserved for future use + self._channel = device.channel self.entity_description: AmcrestSensorEntityDescription = entity_description self._attr_name = f"{name} {entity_description.name}" @@ -195,14 +195,13 @@ class AmcrestBinarySensor(BinarySensorEntity): return _LOGGER.debug(_UPDATE_MSG, self.name) - self._update_unique_id() - if self._api.available: # Send a command to the camera to test if we can still communicate with it. # Override of Http.command() in __init__.py will set self._api.available # accordingly. with suppress(AmcrestError): self._api.current_time # pylint: disable=pointless-statement + self._update_unique_id() self._attr_is_on = self._api.available def _update_others(self) -> None: @@ -210,7 +209,11 @@ class AmcrestBinarySensor(BinarySensorEntity): return _LOGGER.debug(_UPDATE_MSG, self.name) - self._update_unique_id() + try: + self._update_unique_id() + except AmcrestError as error: + log_update_error(_LOGGER, "update", self.name, "binary sensor", error) + return event_code = self.entity_description.event_code if event_code is None: @@ -221,6 +224,7 @@ class AmcrestBinarySensor(BinarySensorEntity): self._attr_is_on = len(self._api.event_channels_happened(event_code)) > 0 except AmcrestError as error: log_update_error(_LOGGER, "update", self.name, "binary sensor", error) + return def _update_unique_id(self) -> None: """Set the unique id.""" diff --git a/homeassistant/components/amcrest/camera.py b/homeassistant/components/amcrest/camera.py index 3c91607f96d..8333ece1030 100644 --- a/homeassistant/components/amcrest/camera.py +++ b/homeassistant/components/amcrest/camera.py @@ -14,9 +14,11 @@ from haffmpeg.camera import CameraMjpeg import voluptuous as vol from homeassistant.components.camera import SUPPORT_ON_OFF, SUPPORT_STREAM, Camera +from homeassistant.components.camera.const import DOMAIN as CAMERA_DOMAIN from homeassistant.components.ffmpeg import DATA_FFMPEG, FFmpegManager from homeassistant.const import ATTR_ENTITY_ID, CONF_NAME, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry from homeassistant.helpers.aiohttp_client import ( async_aiohttp_proxy_stream, async_aiohttp_proxy_web, @@ -33,6 +35,7 @@ from .const import ( COMM_TIMEOUT, DATA_AMCREST, DEVICES, + DOMAIN, SERVICE_UPDATE, SNAPSHOT_TIMEOUT, ) @@ -133,7 +136,21 @@ async def async_setup_platform( name = discovery_info[CONF_NAME] device = hass.data[DATA_AMCREST][DEVICES][name] - async_add_entities([AmcrestCam(name, device, hass.data[DATA_FFMPEG])], True) + entity = AmcrestCam(name, device, hass.data[DATA_FFMPEG]) + + # 2021.9.0 introduced unique id's for the camera entity, but these were not + # unique for different resolution streams. If any cameras were configured + # with this version, update the old entity with the new unique id. + serial_number = await hass.async_add_executor_job(lambda: device.api.serial_number) # type: ignore[no-any-return] + serial_number = serial_number.strip() + registry = entity_registry.async_get(hass) + entity_id = registry.async_get_entity_id(CAMERA_DOMAIN, DOMAIN, serial_number) + if entity_id is not None: + _LOGGER.debug("Updating unique id for camera %s", entity_id) + new_unique_id = f"{serial_number}-{device.resolution}-{device.channel}" + registry.async_update_entity(entity_id, new_unique_id=new_unique_id) + + async_add_entities([entity], True) class CannotSnapshot(Exception): @@ -156,6 +173,7 @@ class AmcrestCam(Camera): self._ffmpeg_arguments = device.ffmpeg_arguments self._stream_source = device.stream_source self._resolution = device.resolution + self._channel = device.channel self._token = self._auth = device.authentication self._control_light = device.control_light self._is_recording: bool = False @@ -388,8 +406,12 @@ class AmcrestCam(Camera): else: self._model = "unknown" if self._attr_unique_id is None: - self._attr_unique_id = self._api.serial_number.strip() - _LOGGER.debug("Assigned unique_id=%s", self._attr_unique_id) + serial_number = self._api.serial_number.strip() + if serial_number: + self._attr_unique_id = ( + f"{serial_number}-{self._resolution}-{self._channel}" + ) + _LOGGER.debug("Assigned unique_id=%s", self._attr_unique_id) self.is_streaming = self._get_video() self._is_recording = self._get_recording() self._motion_detection_enabled = self._get_motion_detection() diff --git a/homeassistant/components/amcrest/sensor.py b/homeassistant/components/amcrest/sensor.py index 752aabc2c92..f2048654da6 100644 --- a/homeassistant/components/amcrest/sensor.py +++ b/homeassistant/components/amcrest/sensor.py @@ -78,7 +78,7 @@ class AmcrestSensor(SensorEntity): self.entity_description = description self._signal_name = name self._api = device.api - self._channel = 0 # Used in unique id, reserved for future use + self._channel = device.channel self._unsub_dispatcher: Callable[[], None] | None = None self._attr_name = f"{name} {description.name}" @@ -102,6 +102,13 @@ class AmcrestSensor(SensorEntity): self._attr_unique_id = f"{serial_number}-{sensor_type}-{self._channel}" try: + if self._attr_unique_id is None: + serial_number = self._api.serial_number + if serial_number: + self._attr_unique_id = ( + f"{serial_number}-{sensor_type}-{self._channel}" + ) + if sensor_type == SENSOR_PTZ_PRESET: self._attr_native_value = self._api.ptz_presets_count From 942db3fcbc0e2bf826b514437bb3ccc048bc3662 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 30 Sep 2021 13:38:33 +0200 Subject: [PATCH 739/843] Adjust state class of solaredge lifetime energy sensor (#56825) --- homeassistant/components/solaredge/const.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/solaredge/const.py b/homeassistant/components/solaredge/const.py index c9c7136fb94..644f1861c05 100644 --- a/homeassistant/components/solaredge/const.py +++ b/homeassistant/components/solaredge/const.py @@ -2,10 +2,7 @@ from datetime import timedelta import logging -from homeassistant.components.sensor import ( - STATE_CLASS_MEASUREMENT, - STATE_CLASS_TOTAL_INCREASING, -) +from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, STATE_CLASS_TOTAL from homeassistant.const import ( DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, @@ -42,7 +39,7 @@ SENSOR_TYPES = [ json_key="lifeTimeData", name="Lifetime energy", icon="mdi:solar-power", - state_class=STATE_CLASS_TOTAL_INCREASING, + state_class=STATE_CLASS_TOTAL, native_unit_of_measurement=ENERGY_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, ), From 55328d2c6fb66deb876e5c4647f9446ad279d350 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 30 Sep 2021 13:48:01 +0200 Subject: [PATCH 740/843] Adjust state class of growatt_server lifetime energy sensors (#56826) --- .../components/growatt_server/sensor.py | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/growatt_server/sensor.py b/homeassistant/components/growatt_server/sensor.py index 3f74710f090..9f0fa509105 100644 --- a/homeassistant/components/growatt_server/sensor.py +++ b/homeassistant/components/growatt_server/sensor.py @@ -9,6 +9,7 @@ import logging import growattServer from homeassistant.components.sensor import ( + STATE_CLASS_TOTAL, STATE_CLASS_TOTAL_INCREASING, SensorEntity, SensorEntityDescription, @@ -91,7 +92,7 @@ TOTAL_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="totalEnergy", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, + state_class=STATE_CLASS_TOTAL, ), GrowattSensorEntityDescription( key="total_maximum_output", @@ -118,7 +119,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, precision=1, - state_class=STATE_CLASS_TOTAL_INCREASING, + state_class=STATE_CLASS_TOTAL, ), GrowattSensorEntityDescription( key="inverter_voltage_input_1", @@ -272,7 +273,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="eacTotal", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, + state_class=STATE_CLASS_TOTAL, precision=1, ), GrowattSensorEntityDescription( @@ -281,7 +282,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="epv1Total", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, + state_class=STATE_CLASS_TOTAL, precision=1, ), GrowattSensorEntityDescription( @@ -323,7 +324,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="epv2Total", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, + state_class=STATE_CLASS_TOTAL, precision=1, ), GrowattSensorEntityDescription( @@ -446,7 +447,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="eBatDisChargeTotal", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, + state_class=STATE_CLASS_TOTAL, ), GrowattSensorEntityDescription( key="storage_grid_discharge_today", @@ -468,7 +469,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="eopDischrTotal", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, + state_class=STATE_CLASS_TOTAL, ), GrowattSensorEntityDescription( key="storage_grid_charged_today", @@ -483,7 +484,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="eChargeTotal", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, + state_class=STATE_CLASS_TOTAL, ), GrowattSensorEntityDescription( key="storage_solar_production", @@ -539,7 +540,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="eToUserTotal", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, + state_class=STATE_CLASS_TOTAL, ), GrowattSensorEntityDescription( key="storage_load_consumption", @@ -658,7 +659,7 @@ MIX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="eBatChargeTotal", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, + state_class=STATE_CLASS_TOTAL, ), GrowattSensorEntityDescription( key="mix_battery_discharge_today", @@ -673,7 +674,7 @@ MIX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="eBatDisChargeTotal", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, + state_class=STATE_CLASS_TOTAL, ), GrowattSensorEntityDescription( key="mix_solar_generation_today", @@ -688,7 +689,7 @@ MIX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="epvTotal", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, + state_class=STATE_CLASS_TOTAL, ), GrowattSensorEntityDescription( key="mix_battery_discharge_w", @@ -732,7 +733,7 @@ MIX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="elocalLoadTotal", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, + state_class=STATE_CLASS_TOTAL, ), GrowattSensorEntityDescription( key="mix_export_to_grid_today", @@ -747,7 +748,7 @@ MIX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="etogridTotal", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, + state_class=STATE_CLASS_TOTAL, ), # Values from 'mix_system_status' API call GrowattSensorEntityDescription( From 53e130d9a88bf35a59a1ca8363d4163288950ca0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Thu, 30 Sep 2021 14:19:46 +0200 Subject: [PATCH 741/843] Deprecated open garage yaml config (#56829) --- homeassistant/components/opengarage/cover.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/opengarage/cover.py b/homeassistant/components/opengarage/cover.py index 5323ae7b0d3..bf23d3286ad 100644 --- a/homeassistant/components/opengarage/cover.py +++ b/homeassistant/components/opengarage/cover.py @@ -50,7 +50,10 @@ COVER_SCHEMA = vol.Schema( ) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - {vol.Required(CONF_COVERS): cv.schema_with_slug_keys(COVER_SCHEMA)} + vol.All( + cv.deprecated(DOMAIN), + {vol.Required(CONF_COVERS): cv.schema_with_slug_keys(COVER_SCHEMA)}, + ), ) From e7293395388127951055f4d334c3a3cb9a99df1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Thu, 30 Sep 2021 14:33:21 +0200 Subject: [PATCH 742/843] Bump surepy to 0.7.2 (#56828) --- homeassistant/components/surepetcare/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/surepetcare/manifest.json b/homeassistant/components/surepetcare/manifest.json index 466f73644b6..13def08280a 100644 --- a/homeassistant/components/surepetcare/manifest.json +++ b/homeassistant/components/surepetcare/manifest.json @@ -7,7 +7,7 @@ "@danielhiversen" ], "requirements": [ - "surepy==0.7.1" + "surepy==0.7.2" ], "iot_class": "cloud_polling", "config_flow": true diff --git a/requirements_all.txt b/requirements_all.txt index dd27ed60898..fa742db898f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2260,7 +2260,7 @@ sucks==0.9.4 sunwatcher==0.2.1 # homeassistant.components.surepetcare -surepy==0.7.1 +surepy==0.7.2 # homeassistant.components.swiss_hydrological_data swisshydrodata==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 961cea61a75..d5e02428222 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1294,7 +1294,7 @@ subarulink==0.3.12 sunwatcher==0.2.1 # homeassistant.components.surepetcare -surepy==0.7.1 +surepy==0.7.2 # homeassistant.components.system_bridge systembridge==2.1.0 From d61a9e8b72f505eb4a907e06001fd231bc33a173 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Thu, 30 Sep 2021 14:38:29 +0200 Subject: [PATCH 743/843] Service to remove clients from UniFi Controller (#56717) --- homeassistant/components/unifi/__init__.py | 6 + homeassistant/components/unifi/manifest.json | 10 +- homeassistant/components/unifi/services.py | 69 +++++++++ homeassistant/components/unifi/services.yaml | 3 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/unifi/test_services.py | 151 +++++++++++++++++++ 7 files changed, 238 insertions(+), 5 deletions(-) create mode 100644 homeassistant/components/unifi/services.py create mode 100644 homeassistant/components/unifi/services.yaml create mode 100644 tests/components/unifi/test_services.py diff --git a/homeassistant/components/unifi/__init__.py b/homeassistant/components/unifi/__init__.py index 0877cda7475..2394dfe92d8 100644 --- a/homeassistant/components/unifi/__init__.py +++ b/homeassistant/components/unifi/__init__.py @@ -11,6 +11,7 @@ from .const import ( UNIFI_WIRELESS_CLIENTS, ) from .controller import UniFiController +from .services import async_setup_services, async_unload_services SAVE_DELAY = 10 STORAGE_KEY = "unifi_data" @@ -43,6 +44,7 @@ async def async_setup_entry(hass, config_entry): ) hass.data[UNIFI_DOMAIN][config_entry.entry_id] = controller + await async_setup_services(hass) config_entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, controller.shutdown) @@ -68,6 +70,10 @@ async def async_setup_entry(hass, config_entry): async def async_unload_entry(hass, config_entry): """Unload a config entry.""" controller = hass.data[UNIFI_DOMAIN].pop(config_entry.entry_id) + + if not hass.data[UNIFI_DOMAIN]: + await async_unload_services(hass) + return await controller.async_reset() diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index 7f70d4c9f37..a32fc42715f 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -3,8 +3,12 @@ "name": "Ubiquiti UniFi", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/unifi", - "requirements": ["aiounifi==26"], - "codeowners": ["@Kane610"], + "requirements": [ + "aiounifi==27" + ], + "codeowners": [ + "@Kane610" + ], "quality_scale": "platinum", "ssdp": [ { @@ -19,4 +23,4 @@ } ], "iot_class": "local_push" -} +} \ No newline at end of file diff --git a/homeassistant/components/unifi/services.py b/homeassistant/components/unifi/services.py new file mode 100644 index 00000000000..dca95a764c3 --- /dev/null +++ b/homeassistant/components/unifi/services.py @@ -0,0 +1,69 @@ +"""UniFi services.""" + +from .const import DOMAIN as UNIFI_DOMAIN + +UNIFI_SERVICES = "unifi_services" + +SERVICE_REMOVE_CLIENTS = "remove_clients" + + +async def async_setup_services(hass) -> None: + """Set up services for UniFi integration.""" + if hass.data.get(UNIFI_SERVICES, False): + return + + hass.data[UNIFI_SERVICES] = True + + async def async_call_unifi_service(service_call) -> None: + """Call correct UniFi service.""" + service = service_call.service + service_data = service_call.data + + controllers = hass.data[UNIFI_DOMAIN].values() + + if service == SERVICE_REMOVE_CLIENTS: + await async_remove_clients(controllers, service_data) + + hass.services.async_register( + UNIFI_DOMAIN, + SERVICE_REMOVE_CLIENTS, + async_call_unifi_service, + ) + + +async def async_unload_services(hass) -> None: + """Unload UniFi services.""" + if not hass.data.get(UNIFI_SERVICES): + return + + hass.data[UNIFI_SERVICES] = False + + hass.services.async_remove(UNIFI_DOMAIN, SERVICE_REMOVE_CLIENTS) + + +async def async_remove_clients(controllers, data) -> None: + """Remove select clients from controller. + + Validates based on: + - Total time between first seen and last seen is less than 15 minutes. + - Neither IP, hostname nor name is configured. + """ + for controller in controllers: + + if not controller.available: + continue + + clients_to_remove = [] + + for client in controller.api.clients_all.values(): + + if client.last_seen - client.first_seen > 900: + continue + + if any({client.fixed_ip, client.hostname, client.name}): + continue + + clients_to_remove.append(client.mac) + + if clients_to_remove: + await controller.api.clients.remove_clients(macs=clients_to_remove) diff --git a/homeassistant/components/unifi/services.yaml b/homeassistant/components/unifi/services.yaml new file mode 100644 index 00000000000..435661afd4a --- /dev/null +++ b/homeassistant/components/unifi/services.yaml @@ -0,0 +1,3 @@ +remove_clients: + name: Remove clients from the UniFi Controller + description: Clean up clients that has only been associated with the controller for a short period of time. diff --git a/requirements_all.txt b/requirements_all.txt index fa742db898f..def12433ec2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -255,7 +255,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.2 # homeassistant.components.unifi -aiounifi==26 +aiounifi==27 # homeassistant.components.watttime aiowatttime==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d5e02428222..3edea51b6ca 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -179,7 +179,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.2 # homeassistant.components.unifi -aiounifi==26 +aiounifi==27 # homeassistant.components.watttime aiowatttime==0.1.1 diff --git a/tests/components/unifi/test_services.py b/tests/components/unifi/test_services.py new file mode 100644 index 00000000000..388a33a4c64 --- /dev/null +++ b/tests/components/unifi/test_services.py @@ -0,0 +1,151 @@ +"""deCONZ service tests.""" + +from unittest.mock import Mock, patch + +from homeassistant.components.unifi.const import DOMAIN as UNIFI_DOMAIN +from homeassistant.components.unifi.services import ( + SERVICE_REMOVE_CLIENTS, + UNIFI_SERVICES, + async_setup_services, + async_unload_services, +) + +from .test_controller import setup_unifi_integration + + +async def test_service_setup(hass): + """Verify service setup works.""" + assert UNIFI_SERVICES not in hass.data + with patch( + "homeassistant.core.ServiceRegistry.async_register", return_value=Mock(True) + ) as async_register: + await async_setup_services(hass) + assert hass.data[UNIFI_SERVICES] is True + assert async_register.call_count == 1 + + +async def test_service_setup_already_registered(hass): + """Make sure that services are only registered once.""" + hass.data[UNIFI_SERVICES] = True + with patch( + "homeassistant.core.ServiceRegistry.async_register", return_value=Mock(True) + ) as async_register: + await async_setup_services(hass) + async_register.assert_not_called() + + +async def test_service_unload(hass): + """Verify service unload works.""" + hass.data[UNIFI_SERVICES] = True + with patch( + "homeassistant.core.ServiceRegistry.async_remove", return_value=Mock(True) + ) as async_remove: + await async_unload_services(hass) + assert hass.data[UNIFI_SERVICES] is False + assert async_remove.call_count == 1 + + +async def test_service_unload_not_registered(hass): + """Make sure that services can only be unloaded once.""" + with patch( + "homeassistant.core.ServiceRegistry.async_remove", return_value=Mock(True) + ) as async_remove: + await async_unload_services(hass) + assert UNIFI_SERVICES not in hass.data + async_remove.assert_not_called() + + +async def test_remove_clients(hass, aioclient_mock): + """Verify removing different variations of clients work.""" + clients = [ + { + "first_seen": 100, + "last_seen": 500, + "mac": "00:00:00:00:00:01", + }, + { + "first_seen": 100, + "last_seen": 1100, + "mac": "00:00:00:00:00:02", + }, + { + "first_seen": 100, + "last_seen": 500, + "fixed_ip": "1.2.3.4", + "mac": "00:00:00:00:00:03", + }, + { + "first_seen": 100, + "last_seen": 500, + "hostname": "hostname", + "mac": "00:00:00:00:00:04", + }, + { + "first_seen": 100, + "last_seen": 500, + "name": "name", + "mac": "00:00:00:00:00:05", + }, + ] + config_entry = await setup_unifi_integration( + hass, aioclient_mock, clients_all_response=clients + ) + controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] + + aioclient_mock.clear_requests() + aioclient_mock.post( + f"https://{controller.host}:1234/api/s/{controller.site}/cmd/stamgr", + ) + + await hass.services.async_call(UNIFI_DOMAIN, SERVICE_REMOVE_CLIENTS, blocking=True) + assert aioclient_mock.mock_calls[0][2] == { + "cmd": "forget-sta", + "macs": ["00:00:00:00:00:01"], + } + + +async def test_remove_clients_controller_unavailable(hass, aioclient_mock): + """Verify no call is made if controller is unavailable.""" + clients = [ + { + "first_seen": 100, + "last_seen": 500, + "mac": "00:00:00:00:00:01", + } + ] + config_entry = await setup_unifi_integration( + hass, aioclient_mock, clients_all_response=clients + ) + controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] + controller.available = False + + aioclient_mock.clear_requests() + aioclient_mock.post( + f"https://{controller.host}:1234/api/s/{controller.site}/cmd/stamgr", + ) + + await hass.services.async_call(UNIFI_DOMAIN, SERVICE_REMOVE_CLIENTS, blocking=True) + assert aioclient_mock.call_count == 0 + + +async def test_remove_clients_no_call_on_empty_list(hass, aioclient_mock): + """Verify no call is made if no fitting client has been added to the list.""" + clients = [ + { + "first_seen": 100, + "last_seen": 1100, + "mac": "00:00:00:00:00:01", + } + ] + config_entry = await setup_unifi_integration( + hass, aioclient_mock, clients_all_response=clients + ) + controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] + + aioclient_mock.clear_requests() + aioclient_mock.post( + f"https://{controller.host}:1234/api/s/{controller.site}/cmd/stamgr", + ) + + await hass.services.async_call(UNIFI_DOMAIN, SERVICE_REMOVE_CLIENTS, blocking=True) + assert aioclient_mock.call_count == 0 From 8196a84538d87e1d445af348eac819b1157ca7fc Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 30 Sep 2021 16:22:36 +0200 Subject: [PATCH 744/843] Update frontend to 20210930.0 (#56827) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 47753067822..cf1f8f052af 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", "requirements": [ - "home-assistant-frontend==20210922.0" + "home-assistant-frontend==20210930.0" ], "dependencies": [ "api", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ba6fa1e587c..f8c047e81f0 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -15,7 +15,7 @@ ciso8601==2.2.0 cryptography==3.4.8 emoji==1.5.0 hass-nabucasa==0.50.0 -home-assistant-frontend==20210922.0 +home-assistant-frontend==20210930.0 httpx==0.19.0 ifaddr==0.1.7 jinja2==3.0.1 diff --git a/requirements_all.txt b/requirements_all.txt index def12433ec2..6b396e0609f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -810,7 +810,7 @@ hole==0.5.1 holidays==0.11.3.1 # homeassistant.components.frontend -home-assistant-frontend==20210922.0 +home-assistant-frontend==20210930.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3edea51b6ca..7f5cd0a7a37 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -485,7 +485,7 @@ hole==0.5.1 holidays==0.11.3.1 # homeassistant.components.frontend -home-assistant-frontend==20210922.0 +home-assistant-frontend==20210930.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 From 3a56e3a8233d4f90a140041b94a6025025241add Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20M=C3=BCndler?= Date: Thu, 30 Sep 2021 16:29:51 +0200 Subject: [PATCH 745/843] Correctly handle offline and unsupported printers during setup (#55894) --- .github/workflows/ci.yaml | 2 +- homeassistant/components/syncthru/__init__.py | 28 +++++++++++++------ .../components/syncthru/config_flow.py | 8 ++++-- .../components/syncthru/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/syncthru/test_config_flow.py | 4 ++- 7 files changed, 31 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3cecb157d07..a04e815af7f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -580,7 +580,7 @@ jobs: python -m venv venv . venv/bin/activate - pip install -U "pip<20.3" "setuptools<58" wheel + pip install -U "pip<20.3" setuptools wheel pip install -r requirements_all.txt pip install -r requirements_test.txt pip install -e . diff --git a/homeassistant/components/syncthru/__init__.py b/homeassistant/components/syncthru/__init__.py index c422bfa6f33..ef3e8c4419d 100644 --- a/homeassistant/components/syncthru/__init__.py +++ b/homeassistant/components/syncthru/__init__.py @@ -5,14 +5,13 @@ from datetime import timedelta import logging import async_timeout -from pysyncthru import SyncThru +from pysyncthru import ConnectionMode, SyncThru, SyncThruAPINotSupported from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client, device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -28,22 +27,29 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: session = aiohttp_client.async_get_clientsession(hass) hass.data.setdefault(DOMAIN, {}) - printer = SyncThru(entry.data[CONF_URL], session) + printer = SyncThru( + entry.data[CONF_URL], session, connection_mode=ConnectionMode.API + ) async def async_update_data() -> SyncThru: """Fetch data from the printer.""" try: async with async_timeout.timeout(10): await printer.update() - except ValueError as value_error: + except SyncThruAPINotSupported as api_error: # if an exception is thrown, printer does not support syncthru - raise UpdateFailed( - f"Configured printer at {printer.url} does not respond. " - "Please make sure it supports SyncThru and check your configuration." - ) from value_error + _LOGGER.info( + "Configured printer at %s does not provide SyncThru JSON API", + printer.url, + exc_info=api_error, + ) + raise api_error else: + # if the printer is offline, we raise an UpdateFailed if printer.is_unknown_state(): - raise ConfigEntryNotReady + raise UpdateFailed( + f"Configured printer at {printer.url} does not respond." + ) return printer coordinator: DataUpdateCoordinator = DataUpdateCoordinator( @@ -55,6 +61,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) hass.data[DOMAIN][entry.entry_id] = coordinator await coordinator.async_config_entry_first_refresh() + if isinstance(coordinator.last_exception, SyncThruAPINotSupported): + # this means that the printer does not support the syncthru JSON API + # and the config should simply be discarded + return False device_registry = await dr.async_get_registry(hass) device_registry.async_get_or_create( diff --git a/homeassistant/components/syncthru/config_flow.py b/homeassistant/components/syncthru/config_flow.py index 31bf8c97edc..db822f650dc 100644 --- a/homeassistant/components/syncthru/config_flow.py +++ b/homeassistant/components/syncthru/config_flow.py @@ -3,7 +3,7 @@ import re from urllib.parse import urlparse -from pysyncthru import SyncThru +from pysyncthru import ConnectionMode, SyncThru, SyncThruAPINotSupported from url_normalize import url_normalize import voluptuous as vol @@ -109,7 +109,9 @@ class SyncThruConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): break session = aiohttp_client.async_get_clientsession(self.hass) - printer = SyncThru(user_input[CONF_URL], session) + printer = SyncThru( + user_input[CONF_URL], session, connection_mode=ConnectionMode.API + ) errors = {} try: await printer.update() @@ -117,7 +119,7 @@ class SyncThruConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): user_input[CONF_NAME] = DEFAULT_NAME_TEMPLATE.format( printer.model() or DEFAULT_MODEL ) - except ValueError: + except SyncThruAPINotSupported: errors[CONF_URL] = "syncthru_not_supported" else: if printer.is_unknown_state(): diff --git a/homeassistant/components/syncthru/manifest.json b/homeassistant/components/syncthru/manifest.json index 9fd3c2afe06..37b7ed311cb 100644 --- a/homeassistant/components/syncthru/manifest.json +++ b/homeassistant/components/syncthru/manifest.json @@ -3,7 +3,7 @@ "name": "Samsung SyncThru Printer", "documentation": "https://www.home-assistant.io/integrations/syncthru", "config_flow": true, - "requirements": ["pysyncthru==0.7.3", "url-normalize==1.4.1"], + "requirements": ["pysyncthru==0.7.10", "url-normalize==1.4.1"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:Printer:1", diff --git a/requirements_all.txt b/requirements_all.txt index 6b396e0609f..1fbc0b445e6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1835,7 +1835,7 @@ pystiebeleltron==0.0.1.dev2 pysuez==0.1.19 # homeassistant.components.syncthru -pysyncthru==0.7.3 +pysyncthru==0.7.10 # homeassistant.components.tankerkoenig pytankerkoenig==0.0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7f5cd0a7a37..62b4ac0cbef 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1082,7 +1082,7 @@ pyspcwebgw==0.4.0 pysqueezebox==0.5.5 # homeassistant.components.syncthru -pysyncthru==0.7.3 +pysyncthru==0.7.10 # homeassistant.components.ecobee python-ecobee-api==0.2.11 diff --git a/tests/components/syncthru/test_config_flow.py b/tests/components/syncthru/test_config_flow.py index 70e8a80ad0f..61bc2992f52 100644 --- a/tests/components/syncthru/test_config_flow.py +++ b/tests/components/syncthru/test_config_flow.py @@ -3,6 +3,8 @@ import re from unittest.mock import patch +from pysyncthru import SyncThruAPINotSupported + from homeassistant import config_entries, data_entry_flow, setup from homeassistant.components import ssdp from homeassistant.components.syncthru.config_flow import SyncThru @@ -71,7 +73,7 @@ async def test_already_configured_by_url(hass, aioclient_mock): async def test_syncthru_not_supported(hass): """Test we show user form on unsupported device.""" - with patch.object(SyncThru, "update", side_effect=ValueError): + with patch.object(SyncThru, "update", side_effect=SyncThruAPINotSupported): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER}, From 6954614e62559e700bfe06f6edb994e1bcff4784 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 30 Sep 2021 16:49:16 +0200 Subject: [PATCH 746/843] Warn if total_increasing sensor has negative states (#56564) --- homeassistant/components/sensor/recorder.py | 71 ++++++++--- homeassistant/helpers/entity.py | 5 +- tests/components/sensor/test_recorder.py | 115 +++++++++++++++++- .../components/websocket_api/test_commands.py | 18 ++- tests/helpers/test_entity.py | 6 +- 5 files changed, 188 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index e09d1fad4c3..3f3e88a0166 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -47,6 +47,7 @@ from homeassistant.const import ( VOLUME_CUBIC_METERS, ) from homeassistant.core import HomeAssistant, State +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import entity_sources import homeassistant.util.dt as dt_util import homeassistant.util.pressure as pressure_util @@ -120,6 +121,8 @@ UNIT_CONVERSIONS: dict[str, dict[str, Callable]] = { # Keep track of entities for which a warning about decreasing value has been logged SEEN_DIP = "sensor_seen_total_increasing_dip" WARN_DIP = "sensor_warn_total_increasing_dip" +# Keep track of entities for which a warning about negative value has been logged +WARN_NEGATIVE = "sensor_warn_total_increasing_negative" # Keep track of entities for which a warning about unsupported unit has been logged WARN_UNSUPPORTED_UNIT = "sensor_warn_unsupported_unit" WARN_UNSTABLE_UNIT = "sensor_warn_unstable_unit" @@ -256,6 +259,24 @@ def _normalize_states( return DEVICE_CLASS_UNITS[device_class], fstates +def _suggest_report_issue(hass: HomeAssistant, entity_id: str) -> str: + """Suggest to report an issue.""" + domain = entity_sources(hass).get(entity_id, {}).get("domain") + custom_component = entity_sources(hass).get(entity_id, {}).get("custom_component") + report_issue = "" + if custom_component: + report_issue = "report it to the custom component author." + else: + report_issue = ( + "create a bug report at " + "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue" + ) + if domain: + report_issue += f"+label%3A%22integration%3A+{domain}%22" + + return report_issue + + def warn_dip(hass: HomeAssistant, entity_id: str) -> None: """Log a warning once if a sensor with state_class_total has a decreasing value. @@ -277,11 +298,26 @@ def warn_dip(hass: HomeAssistant, entity_id: str) -> None: return _LOGGER.warning( "Entity %s %shas state class total_increasing, but its state is " - "not strictly increasing. Please create a bug report at %s", + "not strictly increasing. Please %s", entity_id, f"from integration {domain} " if domain else "", - "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue" - "+label%3A%22integration%3A+recorder%22", + _suggest_report_issue(hass, entity_id), + ) + + +def warn_negative(hass: HomeAssistant, entity_id: str) -> None: + """Log a warning once if a sensor with state_class_total has a negative value.""" + if WARN_NEGATIVE not in hass.data: + hass.data[WARN_NEGATIVE] = set() + if entity_id not in hass.data[WARN_NEGATIVE]: + hass.data[WARN_NEGATIVE].add(entity_id) + domain = entity_sources(hass).get(entity_id, {}).get("domain") + _LOGGER.warning( + "Entity %s %shas state class total_increasing, but its state is " + "negative. Please %s", + entity_id, + f"from integration {domain} " if domain else "", + _suggest_report_issue(hass, entity_id), ) @@ -295,6 +331,10 @@ def reset_detected( if 0.9 * previous_state <= state < previous_state: warn_dip(hass, entity_id) + if state < 0: + warn_negative(hass, entity_id) + raise HomeAssistantError + return state < 0.9 * previous_state @@ -473,17 +513,20 @@ def compile_statistics( # noqa: C901 entity_id, fstate, ) - elif state_class == STATE_CLASS_TOTAL_INCREASING and ( - old_state is None - or reset_detected(hass, entity_id, fstate, new_state) - ): - reset = True - _LOGGER.info( - "Detected new cycle for %s, value dropped from %s to %s", - entity_id, - new_state, - fstate, - ) + elif state_class == STATE_CLASS_TOTAL_INCREASING: + try: + if old_state is None or reset_detected( + hass, entity_id, fstate, new_state + ): + reset = True + _LOGGER.info( + "Detected new cycle for %s, value dropped from %s to %s", + entity_id, + new_state, + fstate, + ) + except HomeAssistantError: + continue if reset: # The sensor has been reset, update the sum diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index be204ebaa6f..f04949c0caa 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -734,7 +734,10 @@ class Entity(ABC): Not to be extended by integrations. """ if self.platform: - info = {"domain": self.platform.platform_name} + info = { + "domain": self.platform.platform_name, + "custom_component": "custom_components" in type(self).__module__, + } if self.platform.config_entry: info["source"] = SOURCE_CONFIG_ENTRY diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 1c9caf07982..9baa0aaf460 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -8,6 +8,7 @@ from unittest.mock import patch import pytest from pytest import approx +from homeassistant import loader from homeassistant.components.recorder import history from homeassistant.components.recorder.const import DATA_INSTANCE from homeassistant.components.recorder.models import process_timestamp_to_utc_isoformat @@ -609,6 +610,114 @@ def test_compile_hourly_sum_statistics_nan_inf_state( assert "Error while processing event StatisticsTask" not in caplog.text +@pytest.mark.parametrize( + "entity_id,warning_1,warning_2", + [ + ( + "sensor.test1", + "", + "bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue", + ), + ( + "sensor.today_energy", + "from integration demo ", + "bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+demo%22", + ), + ( + "sensor.custom_sensor", + "from integration test ", + "report it to the custom component author", + ), + ], +) +@pytest.mark.parametrize("state_class", ["total_increasing"]) +@pytest.mark.parametrize( + "device_class,unit,native_unit,factor", + [ + ("energy", "kWh", "kWh", 1), + ], +) +def test_compile_hourly_sum_statistics_negative_state( + hass_recorder, + caplog, + entity_id, + warning_1, + warning_2, + state_class, + device_class, + unit, + native_unit, + factor, +): + """Test compiling hourly statistics with negative states.""" + zero = dt_util.utcnow() + hass = hass_recorder() + hass.data.pop(loader.DATA_CUSTOM_COMPONENTS) + recorder = hass.data[DATA_INSTANCE] + + platform = getattr(hass.components, "test.sensor") + platform.init(empty=True) + mocksensor = platform.MockSensor(name="custom_sensor") + mocksensor._attr_should_poll = False + platform.ENTITIES["custom_sensor"] = mocksensor + + setup_component( + hass, "sensor", {"sensor": [{"platform": "demo"}, {"platform": "test"}]} + ) + hass.block_till_done() + attributes = { + "device_class": device_class, + "state_class": state_class, + "unit_of_measurement": unit, + } + seq = [15, 16, 15, 16, 20, -20, 20, 10] + + states = {entity_id: []} + if state := hass.states.get(entity_id): + states[entity_id].append(state) + one = zero + for i in range(len(seq)): + one = one + timedelta(seconds=5) + _states = record_meter_state(hass, one, entity_id, attributes, seq[i : i + 1]) + states[entity_id].extend(_states[entity_id]) + + hist = history.get_significant_states( + hass, + zero - timedelta.resolution, + one + timedelta.resolution, + significant_changes_only=False, + ) + assert dict(states)[entity_id] == dict(hist)[entity_id] + + recorder.do_adhoc_statistics(start=zero) + wait_recording_done(hass) + statistic_ids = list_statistic_ids(hass) + assert { + "statistic_id": entity_id, + "unit_of_measurement": native_unit, + } in statistic_ids + stats = statistics_during_period(hass, zero, period="5minute") + assert stats[entity_id] == [ + { + "statistic_id": entity_id, + "start": process_timestamp_to_utc_isoformat(zero), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(minutes=5)), + "max": None, + "mean": None, + "min": None, + "last_reset": None, + "state": approx(factor * seq[7]), + "sum": approx(factor * 15), # (15 - 10) + (10 - 0) + }, + ] + assert "Error while processing event StatisticsTask" not in caplog.text + assert ( + f"Entity {entity_id} {warning_1}has state class total_increasing, but its state is negative" + in caplog.text + ) + assert warning_2 in caplog.text + + @pytest.mark.parametrize( "device_class,unit,native_unit,factor", [ @@ -823,16 +932,14 @@ def test_compile_hourly_sum_statistics_total_increasing_small_dip( assert ( "Entity sensor.test1 has state class total_increasing, but its state is not " "strictly increasing. Please create a bug report at https://github.com/" - "home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A" - "+recorder%22" + "home-assistant/core/issues?q=is%3Aopen+is%3Aissue" ) not in caplog.text recorder.do_adhoc_statistics(start=period2) wait_recording_done(hass) assert ( "Entity sensor.test1 has state class total_increasing, but its state is not " "strictly increasing. Please create a bug report at https://github.com/" - "home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A" - "+recorder%22" + "home-assistant/core/issues?q=is%3Aopen+is%3Aissue" ) in caplog.text statistic_ids = list_statistic_ids(hass) assert statistic_ids == [ diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 7c43d34d9a6..447f38f9a9c 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -922,12 +922,14 @@ async def test_entity_source_admin(hass, websocket_client, hass_admin_user): assert msg["success"] assert msg["result"] == { "test_domain.entity_1": { - "source": entity.SOURCE_PLATFORM_CONFIG, + "custom_component": False, "domain": "test_platform", + "source": entity.SOURCE_PLATFORM_CONFIG, }, "test_domain.entity_2": { - "source": entity.SOURCE_PLATFORM_CONFIG, + "custom_component": False, "domain": "test_platform", + "source": entity.SOURCE_PLATFORM_CONFIG, }, } @@ -942,8 +944,9 @@ async def test_entity_source_admin(hass, websocket_client, hass_admin_user): assert msg["success"] assert msg["result"] == { "test_domain.entity_2": { - "source": entity.SOURCE_PLATFORM_CONFIG, + "custom_component": False, "domain": "test_platform", + "source": entity.SOURCE_PLATFORM_CONFIG, }, } @@ -962,12 +965,14 @@ async def test_entity_source_admin(hass, websocket_client, hass_admin_user): assert msg["success"] assert msg["result"] == { "test_domain.entity_1": { - "source": entity.SOURCE_PLATFORM_CONFIG, + "custom_component": False, "domain": "test_platform", + "source": entity.SOURCE_PLATFORM_CONFIG, }, "test_domain.entity_2": { - "source": entity.SOURCE_PLATFORM_CONFIG, + "custom_component": False, "domain": "test_platform", + "source": entity.SOURCE_PLATFORM_CONFIG, }, } @@ -1001,8 +1006,9 @@ async def test_entity_source_admin(hass, websocket_client, hass_admin_user): assert msg["success"] assert msg["result"] == { "test_domain.entity_2": { - "source": entity.SOURCE_PLATFORM_CONFIG, + "custom_component": False, "domain": "test_platform", + "source": entity.SOURCE_PLATFORM_CONFIG, }, } diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 8142f563f01..21811c3bfdc 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -707,13 +707,15 @@ async def test_setup_source(hass): assert entity.entity_sources(hass) == { "test_domain.platform_config_source": { - "source": entity.SOURCE_PLATFORM_CONFIG, + "custom_component": False, "domain": "test_platform", + "source": entity.SOURCE_PLATFORM_CONFIG, }, "test_domain.config_entry_source": { - "source": entity.SOURCE_CONFIG_ENTRY, "config_entry": platform.config_entry.entry_id, + "custom_component": False, "domain": "test_platform", + "source": entity.SOURCE_CONFIG_ENTRY, }, } From 08719af794c02927836e7d723e8cde1d7f168c50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Thu, 30 Sep 2021 16:59:00 +0200 Subject: [PATCH 747/843] Bump Mill library (#56833) --- homeassistant/components/mill/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mill/manifest.json b/homeassistant/components/mill/manifest.json index 33a7c35c169..bf332487a88 100644 --- a/homeassistant/components/mill/manifest.json +++ b/homeassistant/components/mill/manifest.json @@ -2,7 +2,7 @@ "domain": "mill", "name": "Mill", "documentation": "https://www.home-assistant.io/integrations/mill", - "requirements": ["millheater==0.5.2"], + "requirements": ["millheater==0.6.0"], "codeowners": ["@danielhiversen"], "config_flow": true, "iot_class": "cloud_polling" diff --git a/requirements_all.txt b/requirements_all.txt index 1fbc0b445e6..fdb66ea982e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1005,7 +1005,7 @@ micloud==0.3 miflora==0.7.0 # homeassistant.components.mill -millheater==0.5.2 +millheater==0.6.0 # homeassistant.components.minio minio==4.0.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 62b4ac0cbef..03556b3659d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -585,7 +585,7 @@ mficlient==0.3.0 micloud==0.3 # homeassistant.components.mill -millheater==0.5.2 +millheater==0.6.0 # homeassistant.components.minio minio==4.0.9 From d5bda3ac14b0f9773b0dae0dac7a0eff82bbfc71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Thu, 30 Sep 2021 17:11:45 +0200 Subject: [PATCH 748/843] Surepetcare reauthorize (#56402) Co-authored-by: J. Nick Koston --- .../components/surepetcare/__init__.py | 10 +- .../components/surepetcare/config_flow.py | 53 +++++- .../surepetcare/test_config_flow.py | 162 +++++++++++++++++- 3 files changed, 210 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/surepetcare/__init__.py b/homeassistant/components/surepetcare/__init__.py index 368a548249d..adf3d07f79e 100644 --- a/homeassistant/components/surepetcare/__init__.py +++ b/homeassistant/components/surepetcare/__init__.py @@ -18,6 +18,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.service import ServiceCall @@ -99,12 +100,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry, hass, ) - except SurePetcareAuthenticationError: + except SurePetcareAuthenticationError as error: _LOGGER.error("Unable to connect to surepetcare.io: Wrong credentials!") - return False + raise ConfigEntryAuthFailed from error except SurePetcareError as error: - _LOGGER.error("Unable to connect to surepetcare.io: Wrong %s!", error) - return False + raise ConfigEntryNotReady from error await coordinator.async_config_entry_first_refresh() @@ -188,6 +188,8 @@ class SurePetcareDataCoordinator(DataUpdateCoordinator): """Get the latest data from Sure Petcare.""" try: return await self.surepy.get_entities(refresh=True) + except SurePetcareAuthenticationError as err: + raise ConfigEntryAuthFailed("Invalid username/password") from err except SurePetcareError as err: raise UpdateFailed(f"Unable to fetch data: {err}") from err diff --git a/homeassistant/components/surepetcare/config_flow.py b/homeassistant/components/surepetcare/config_flow.py index bc3589aa4bb..30f20257e8c 100644 --- a/homeassistant/components/surepetcare/config_flow.py +++ b/homeassistant/components/surepetcare/config_flow.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging from typing import Any -from surepy import Surepy +import surepy from surepy.exceptions import SurePetcareAuthenticationError, SurePetcareError import voluptuous as vol @@ -18,7 +18,7 @@ from .const import DOMAIN, SURE_API_TIMEOUT _LOGGER = logging.getLogger(__name__) -STEP_USER_DATA_SCHEMA = vol.Schema( +USER_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str, @@ -28,7 +28,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema( async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: """Validate the user input allows us to connect.""" - surepy = Surepy( + surepy_client = surepy.Surepy( data[CONF_USERNAME], data[CONF_PASSWORD], auth_token=None, @@ -36,7 +36,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, session=async_get_clientsession(hass), ) - token = await surepy.sac.get_token() + token = await surepy_client.sac.get_token() return {CONF_TOKEN: token} @@ -46,6 +46,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 + def __init__(self) -> None: + """Initialize.""" + self._username: str | None = None + async def async_step_import(self, import_info: dict[str, Any] | None) -> FlowResult: """Set the config entry up from yaml.""" return await self.async_step_user(import_info) @@ -55,9 +59,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) -> FlowResult: """Handle the initial step.""" if user_input is None: - return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA - ) + return self.async_show_form(step_id="user", data_schema=USER_DATA_SCHEMA) errors = {} @@ -81,5 +83,40 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + step_id="user", data_schema=USER_DATA_SCHEMA, errors=errors + ) + + async def async_step_reauth(self, config: dict[str, Any]) -> FlowResult: + """Handle configuration by re-auth.""" + self._username = config[CONF_USERNAME] + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Dialog that informs the user that reauth is required.""" + errors = {} + if user_input is not None: + user_input[CONF_USERNAME] = self._username + try: + await validate_input(self.hass, user_input) + except SurePetcareAuthenticationError: + errors["base"] = "invalid_auth" + except SurePetcareError: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + existing_entry = await self.async_set_unique_id( + user_input[CONF_USERNAME].lower() + ) + if existing_entry: + await self.hass.config_entries.async_reload(existing_entry.entry_id) + return self.async_abort(reason="reauth_successful") + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema({vol.Required(CONF_PASSWORD): str}), + errors=errors, ) diff --git a/tests/components/surepetcare/test_config_flow.py b/tests/components/surepetcare/test_config_flow.py index d397c9b121a..d52dd025148 100644 --- a/tests/components/surepetcare/test_config_flow.py +++ b/tests/components/surepetcare/test_config_flow.py @@ -14,6 +14,11 @@ from homeassistant.data_entry_flow import ( from tests.common import MockConfigEntry +INPUT_DATA = { + "username": "test-username", + "password": "test-password", +} + async def test_form(hass: HomeAssistant, surepetcare: NonCallableMagicMock) -> None: """Test we get the form.""" @@ -54,7 +59,7 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: ) with patch( - "surepy.client.SureAPIClient.get_token", + "homeassistant.components.surepetcare.config_flow.surepy.client.SureAPIClient.get_token", side_effect=SurePetcareAuthenticationError, ): result2 = await hass.config_entries.flow.async_configure( @@ -76,7 +81,7 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: ) with patch( - "surepy.client.SureAPIClient.get_token", + "homeassistant.components.surepetcare.config_flow.surepy.client.SureAPIClient.get_token", side_effect=SurePetcareError, ): result2 = await hass.config_entries.flow.async_configure( @@ -98,7 +103,7 @@ async def test_form_unknown_error(hass: HomeAssistant) -> None: ) with patch( - "surepy.client.SureAPIClient.get_token", + "homeassistant.components.surepetcare.config_flow.surepy.client.SureAPIClient.get_token", side_effect=Exception, ): result2 = await hass.config_entries.flow.async_configure( @@ -142,3 +147,154 @@ async def test_flow_entry_already_exists( assert result["type"] == RESULT_TYPE_ABORT assert result["reason"] == "already_configured" + + +async def test_reauthentication(hass): + """Test surepetcare reauthentication.""" + old_entry = MockConfigEntry( + domain="surepetcare", + data=INPUT_DATA, + unique_id="test-username", + ) + old_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": old_entry.unique_id, + "entry_id": old_entry.entry_id, + }, + data=old_entry.data, + ) + + assert result["type"] == "form" + assert result["errors"] == {} + assert result["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.surepetcare.config_flow.surepy.client.SureAPIClient.get_token", + return_value={"token": "token"}, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"password": "test-password"}, + ) + await hass.async_block_till_done() + + assert result2["type"] == "abort" + assert result2["reason"] == "reauth_successful" + + +async def test_reauthentication_failure(hass): + """Test surepetcare reauthentication failure.""" + old_entry = MockConfigEntry( + domain="surepetcare", + data=INPUT_DATA, + unique_id="USERID", + ) + old_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": old_entry.unique_id, + "entry_id": old_entry.entry_id, + }, + data=old_entry.data, + ) + + assert result["type"] == "form" + assert result["errors"] == {} + assert result["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.surepetcare.config_flow.surepy.client.SureAPIClient.get_token", + side_effect=SurePetcareAuthenticationError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"password": "test-password"}, + ) + await hass.async_block_till_done() + + assert result2["step_id"] == "reauth_confirm" + assert result["type"] == "form" + assert result2["errors"]["base"] == "invalid_auth" + + +async def test_reauthentication_cannot_connect(hass): + """Test surepetcare reauthentication failure.""" + old_entry = MockConfigEntry( + domain="surepetcare", + data=INPUT_DATA, + unique_id="USERID", + ) + old_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": old_entry.unique_id, + "entry_id": old_entry.entry_id, + }, + data=old_entry.data, + ) + + assert result["type"] == "form" + assert result["errors"] == {} + assert result["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.surepetcare.config_flow.surepy.client.SureAPIClient.get_token", + side_effect=SurePetcareError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"password": "test-password"}, + ) + await hass.async_block_till_done() + + assert result2["step_id"] == "reauth_confirm" + assert result["type"] == "form" + assert result2["errors"]["base"] == "cannot_connect" + + +async def test_reauthentication_unknown_failure(hass): + """Test surepetcare reauthentication failure.""" + old_entry = MockConfigEntry( + domain="surepetcare", + data=INPUT_DATA, + unique_id="USERID", + ) + old_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": old_entry.unique_id, + "entry_id": old_entry.entry_id, + }, + data=old_entry.data, + ) + + assert result["type"] == "form" + assert result["errors"] == {} + assert result["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.surepetcare.config_flow.surepy.client.SureAPIClient.get_token", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"password": "test-password"}, + ) + await hass.async_block_till_done() + + assert result2["step_id"] == "reauth_confirm" + assert result["type"] == "form" + assert result2["errors"]["base"] == "unknown" From 6af1a835e6e583e341213067a0ae0886b5e49b01 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 30 Sep 2021 17:14:36 +0200 Subject: [PATCH 749/843] Optimize statistics generation (#56821) * Optimize statistics generation * pylint --- homeassistant/components/history/__init__.py | 24 ++++----- homeassistant/components/recorder/history.py | 6 +-- .../components/recorder/migration.py | 4 +- .../components/recorder/statistics.py | 47 ++++++++-------- homeassistant/components/sensor/recorder.py | 54 ++++++++++++++----- tests/components/sensor/test_recorder.py | 30 +++++++---- 6 files changed, 101 insertions(+), 64 deletions(-) diff --git a/homeassistant/components/history/__init__.py b/homeassistant/components/history/__init__.py index 3ae71602dc1..7c3087d471f 100644 --- a/homeassistant/components/history/__init__.py +++ b/homeassistant/components/history/__init__.py @@ -54,7 +54,7 @@ CONFIG_SCHEMA = vol.Schema( @deprecated_function("homeassistant.components.recorder.history.get_significant_states") def get_significant_states(hass, *args, **kwargs): - """Wrap _get_significant_states with an sql session.""" + """Wrap get_significant_states_with_session with an sql session.""" return history.get_significant_states(hass, *args, **kwargs) @@ -268,18 +268,16 @@ class HistoryPeriodView(HomeAssistantView): timer_start = time.perf_counter() with session_scope(hass=hass) as session: - result = ( - history._get_significant_states( # pylint: disable=protected-access - hass, - session, - start_time, - end_time, - entity_ids, - self.filters, - include_start_time_state, - significant_changes_only, - minimal_response, - ) + result = history.get_significant_states_with_session( + hass, + session, + start_time, + end_time, + entity_ids, + self.filters, + include_start_time_state, + significant_changes_only, + minimal_response, ) result = list(result.values()) diff --git a/homeassistant/components/recorder/history.py b/homeassistant/components/recorder/history.py index 36a4f6d0696..72f820a0d3b 100644 --- a/homeassistant/components/recorder/history.py +++ b/homeassistant/components/recorder/history.py @@ -61,12 +61,12 @@ def async_setup(hass): def get_significant_states(hass, *args, **kwargs): - """Wrap _get_significant_states with a sql session.""" + """Wrap get_significant_states_with_session with an sql session.""" with session_scope(hass=hass) as session: - return _get_significant_states(hass, session, *args, **kwargs) + return get_significant_states_with_session(hass, session, *args, **kwargs) -def _get_significant_states( +def get_significant_states_with_session( hass, session, start_time, diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 0c9b7d767d8..0d40707d825 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -24,7 +24,7 @@ from .models import ( StatisticsShortTerm, process_timestamp, ) -from .statistics import _get_metadata, get_start_time +from .statistics import get_metadata_with_session, get_start_time from .util import session_scope _LOGGER = logging.getLogger(__name__) @@ -564,7 +564,7 @@ def _apply_update(instance, session, new_version, old_version): # noqa: C901 fake_start_time += timedelta(minutes=5) # Copy last hourly statistic to the newly created 5-minute statistics table - sum_statistics = _get_metadata( + sum_statistics = get_metadata_with_session( instance.hass, session, None, statistic_type="sum" ) for metadata_id in sum_statistics: diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 5a35625e837..7b7e349b843 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -195,7 +195,7 @@ def _update_or_add_metadata( Updating metadata source is not possible. """ statistic_id = new_metadata["statistic_id"] - old_metadata_dict = _get_metadata(hass, session, [statistic_id], None) + old_metadata_dict = get_metadata_with_session(hass, session, [statistic_id], None) if not old_metadata_dict: unit = new_metadata["unit_of_measurement"] has_mean = new_metadata["has_mean"] @@ -210,7 +210,7 @@ def _update_or_add_metadata( ) return meta.id # type: ignore[no-any-return] - metadata_id, old_metadata = next(iter(old_metadata_dict.items())) + metadata_id, old_metadata = old_metadata_dict[statistic_id] if ( old_metadata["has_mean"] != new_metadata["has_mean"] or old_metadata["has_sum"] != new_metadata["has_sum"] @@ -361,13 +361,15 @@ def compile_statistics(instance: Recorder, start: datetime) -> bool: return True -def _get_metadata( +def get_metadata_with_session( hass: HomeAssistant, session: scoped_session, - statistic_ids: list[str] | None, + statistic_ids: Iterable[str] | None, statistic_type: Literal["mean"] | Literal["sum"] | None, -) -> dict[int, StatisticMetaData]: - """Fetch meta data, returns a dict of StatisticMetaData indexed by statistic_id. +) -> dict[str, tuple[int, StatisticMetaData]]: + """Fetch meta data. + + Returns a dict of (metadata_id, StatisticMetaData) indexed by statistic_id. If statistic_ids is given, fetch metadata only for the listed statistics_ids. If statistic_type is given, fetch metadata only for statistic_ids supporting it. @@ -403,24 +405,21 @@ def _get_metadata( metadata_ids = [metadata[0] for metadata in result] # Prepare the result dict - metadata: dict[int, StatisticMetaData] = {} + metadata: dict[str, tuple[int, StatisticMetaData]] = {} for _id in metadata_ids: meta = _meta(result, _id) if meta: - metadata[_id] = meta + metadata[meta["statistic_id"]] = (_id, meta) return metadata def get_metadata( hass: HomeAssistant, - statistic_id: str, -) -> StatisticMetaData | None: - """Return metadata for a statistic_id.""" + statistic_ids: Iterable[str], +) -> dict[str, tuple[int, StatisticMetaData]]: + """Return metadata for statistic_ids.""" with session_scope(hass=hass) as session: - metadata = _get_metadata(hass, session, [statistic_id], None) - if not metadata: - return None - return next(iter(metadata.values())) + return get_metadata_with_session(hass, session, statistic_ids, None) def _configured_unit(unit: str, units: UnitSystem) -> str: @@ -469,9 +468,9 @@ def list_statistic_ids( # Query the database with session_scope(hass=hass) as session: - metadata = _get_metadata(hass, session, None, statistic_type) + metadata = get_metadata_with_session(hass, session, None, statistic_type) - for meta in metadata.values(): + for _, meta in metadata.values(): unit = meta["unit_of_measurement"] if unit is not None: # Display unit according to user settings @@ -480,7 +479,7 @@ def list_statistic_ids( statistic_ids = { meta["statistic_id"]: meta["unit_of_measurement"] - for meta in metadata.values() + for _, meta in metadata.values() } # Query all integrations with a registered recorder platform @@ -548,13 +547,13 @@ def statistics_during_period( metadata = None with session_scope(hass=hass) as session: # Fetch metadata for the given (or all) statistic_ids - metadata = _get_metadata(hass, session, statistic_ids, None) + metadata = get_metadata_with_session(hass, session, statistic_ids, None) if not metadata: return {} metadata_ids = None if statistic_ids is not None: - metadata_ids = list(metadata.keys()) + metadata_ids = [metadata_id for metadata_id, _ in metadata.values()] if period == "hour": bakery = STATISTICS_BAKERY @@ -589,7 +588,7 @@ def get_last_statistics( statistic_ids = [statistic_id] with session_scope(hass=hass) as session: # Fetch metadata for the given statistic_id - metadata = _get_metadata(hass, session, statistic_ids, None) + metadata = get_metadata_with_session(hass, session, statistic_ids, None) if not metadata: return {} @@ -598,7 +597,7 @@ def get_last_statistics( ) baked_query += lambda q: q.filter_by(metadata_id=bindparam("metadata_id")) - metadata_id = next(iter(metadata.keys())) + metadata_id = metadata[statistic_id][0] baked_query += lambda q: q.order_by( StatisticsShortTerm.metadata_id, StatisticsShortTerm.start.desc() @@ -629,7 +628,7 @@ def _sorted_statistics_to_dict( hass: HomeAssistant, stats: list, statistic_ids: list[str] | None, - metadata: dict[int, StatisticMetaData], + _metadata: dict[str, tuple[int, StatisticMetaData]], convert_units: bool, duration: timedelta, ) -> dict[str, list[dict]]: @@ -646,6 +645,8 @@ def _sorted_statistics_to_dict( for stat_id in statistic_ids: result[stat_id] = [] + metadata = dict(_metadata.values()) + # Append all statistic entries, and optionally do unit conversion for meta_id, group in groupby(stats, lambda stat: stat.metadata_id): # type: ignore unit = metadata[meta_id]["unit_of_measurement"] diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 3f3e88a0166..fd6cf5e0f2f 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -8,7 +8,14 @@ import itertools import logging import math -from homeassistant.components.recorder import history, is_entity_recorded, statistics +from sqlalchemy.orm.session import Session + +from homeassistant.components.recorder import ( + history, + is_entity_recorded, + statistics, + util as recorder_util, +) from homeassistant.components.recorder.models import ( StatisticData, StatisticMetaData, @@ -196,6 +203,8 @@ def _parse_float(state: str) -> float: def _normalize_states( hass: HomeAssistant, + session: Session, + old_metadatas: dict[str, tuple[int, StatisticMetaData]], entity_history: Iterable[State], device_class: str | None, entity_id: str, @@ -221,10 +230,10 @@ def _normalize_states( if entity_id not in hass.data[WARN_UNSTABLE_UNIT]: hass.data[WARN_UNSTABLE_UNIT].add(entity_id) extra = "" - if old_metadata := statistics.get_metadata(hass, entity_id): + if old_metadata := old_metadatas.get(entity_id): extra = ( " and matches the unit of already compiled statistics " - f"({old_metadata['unit_of_measurement']})" + f"({old_metadata[1]['unit_of_measurement']})" ) _LOGGER.warning( "The unit of %s is changing, got multiple %s, generation of long term " @@ -368,17 +377,32 @@ def _last_reset_as_utc_isoformat( return dt_util.as_utc(last_reset).isoformat() -def compile_statistics( # noqa: C901 +def compile_statistics( hass: HomeAssistant, start: datetime.datetime, end: datetime.datetime ) -> list[StatisticResult]: """Compile statistics for all entities during start-end. Note: This will query the database and must not be run in the event loop """ + with recorder_util.session_scope(hass=hass) as session: + result = _compile_statistics(hass, session, start, end) + return result + + +def _compile_statistics( # noqa: C901 + hass: HomeAssistant, + session: Session, + start: datetime.datetime, + end: datetime.datetime, +) -> list[StatisticResult]: + """Compile statistics for all entities during start-end.""" result: list[StatisticResult] = [] sensor_states = _get_sensor_states(hass) wanted_statistics = _wanted_statistics(sensor_states) + old_metadatas = statistics.get_metadata_with_session( + hass, session, [i.entity_id for i in sensor_states], None + ) # Get history between start and end entities_full_history = [ @@ -386,8 +410,9 @@ def compile_statistics( # noqa: C901 ] history_list = {} if entities_full_history: - history_list = history.get_significant_states( # type: ignore + history_list = history.get_significant_states_with_session( # type: ignore hass, + session, start - datetime.timedelta.resolution, end, entity_ids=entities_full_history, @@ -399,8 +424,9 @@ def compile_statistics( # noqa: C901 if "sum" not in wanted_statistics[i.entity_id] ] if entities_significant_history: - _history_list = history.get_significant_states( # type: ignore + _history_list = history.get_significant_states_with_session( # type: ignore hass, + session, start - datetime.timedelta.resolution, end, entity_ids=entities_significant_history, @@ -420,14 +446,16 @@ def compile_statistics( # noqa: C901 state_class = _state.attributes[ATTR_STATE_CLASS] device_class = _state.attributes.get(ATTR_DEVICE_CLASS) entity_history = history_list[entity_id] - unit, fstates = _normalize_states(hass, entity_history, device_class, entity_id) + unit, fstates = _normalize_states( + hass, session, old_metadatas, entity_history, device_class, entity_id + ) if not fstates: continue # Check metadata - if old_metadata := statistics.get_metadata(hass, entity_id): - if old_metadata["unit_of_measurement"] != unit: + if old_metadata := old_metadatas.get(entity_id): + if old_metadata[1]["unit_of_measurement"] != unit: if WARN_UNSTABLE_UNIT not in hass.data: hass.data[WARN_UNSTABLE_UNIT] = set() if entity_id not in hass.data[WARN_UNSTABLE_UNIT]: @@ -438,8 +466,8 @@ def compile_statistics( # noqa: C901 "will be suppressed unless the unit changes back to %s", entity_id, unit, - old_metadata["unit_of_measurement"], - old_metadata["unit_of_measurement"], + old_metadata[1]["unit_of_measurement"], + old_metadata[1]["unit_of_measurement"], ) continue @@ -617,10 +645,10 @@ def validate_statistics( state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) if device_class not in UNIT_CONVERSIONS: - metadata = statistics.get_metadata(hass, entity_id) + metadata = statistics.get_metadata(hass, (entity_id,)) if not metadata: continue - metadata_unit = metadata["unit_of_measurement"] + metadata_unit = metadata[entity_id][1]["unit_of_measurement"] if state_unit != metadata_unit: validation_result[entity_id].append( statistics.ValidationIssue( diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 9baa0aaf460..f13dc5084bb 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -1725,12 +1725,17 @@ def test_compile_hourly_statistics_changing_statistics( assert statistic_ids == [ {"statistic_id": "sensor.test1", "unit_of_measurement": None} ] - metadata = get_metadata(hass, "sensor.test1") + metadata = get_metadata(hass, ("sensor.test1",)) assert metadata == { - "has_mean": True, - "has_sum": False, - "statistic_id": "sensor.test1", - "unit_of_measurement": None, + "sensor.test1": ( + 1, + { + "has_mean": True, + "has_sum": False, + "statistic_id": "sensor.test1", + "unit_of_measurement": None, + }, + ) } # Add more states, with changed state class @@ -1745,12 +1750,17 @@ def test_compile_hourly_statistics_changing_statistics( assert statistic_ids == [ {"statistic_id": "sensor.test1", "unit_of_measurement": None} ] - metadata = get_metadata(hass, "sensor.test1") + metadata = get_metadata(hass, ("sensor.test1",)) assert metadata == { - "has_mean": False, - "has_sum": True, - "statistic_id": "sensor.test1", - "unit_of_measurement": None, + "sensor.test1": ( + 1, + { + "has_mean": False, + "has_sum": True, + "statistic_id": "sensor.test1", + "unit_of_measurement": None, + }, + ) } stats = statistics_during_period(hass, period0, period="5minute") assert stats == { From 4a2ed97e0da992da0726ebe485fc56cb73b66174 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Thu, 30 Sep 2021 17:16:35 +0200 Subject: [PATCH 750/843] Add locking state to surepetcare locks (#56830) --- homeassistant/components/surepetcare/lock.py | 24 +++++++++--- tests/components/surepetcare/test_lock.py | 40 ++++++++++++++++++++ 2 files changed, 58 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/surepetcare/lock.py b/homeassistant/components/surepetcare/lock.py index e5b31150152..8351eea161b 100644 --- a/homeassistant/components/surepetcare/lock.py +++ b/homeassistant/components/surepetcare/lock.py @@ -83,16 +83,28 @@ class SurePetcareLock(SurePetcareEntity, LockEntity): async def async_lock(self, **kwargs: Any) -> None: """Lock the lock.""" - if self.state == STATE_LOCKED: + if self.state != STATE_UNLOCKED: return - await self.coordinator.lock_states_callbacks[self._lock_state](self._id) - self._attr_is_locked = True + self._attr_is_locking = True self.async_write_ha_state() + try: + await self.coordinator.lock_states_callbacks[self._lock_state](self._id) + self._attr_is_locked = True + finally: + self._attr_is_locking = False + self.async_write_ha_state() + async def async_unlock(self, **kwargs: Any) -> None: """Unlock the lock.""" - if self.state == STATE_UNLOCKED: + if self.state != STATE_LOCKED: return - await self.coordinator.surepy.sac.unlock(self._id) - self._attr_is_locked = False + self._attr_is_unlocking = True self.async_write_ha_state() + + try: + await self.coordinator.surepy.sac.unlock(self._id) + self._attr_is_locked = False + finally: + self._attr_is_unlocking = False + self.async_write_ha_state() diff --git a/tests/components/surepetcare/test_lock.py b/tests/components/surepetcare/test_lock.py index 3a29ced9ace..a2c4ebad0b3 100644 --- a/tests/components/surepetcare/test_lock.py +++ b/tests/components/surepetcare/test_lock.py @@ -1,4 +1,6 @@ """The tests for the Sure Petcare lock platform.""" +import pytest +from surepy.exceptions import SurePetcareError from homeassistant.components.surepetcare.const import DOMAIN from homeassistant.helpers import entity_registry as er @@ -73,3 +75,41 @@ async def test_locks(hass, surepetcare) -> None: state = hass.states.get(entity_id) assert state.state == "unlocked" assert surepetcare.unlock.call_count == 1 + + +async def test_lock_failing(hass, surepetcare) -> None: + """Test handling of lock failing.""" + assert await async_setup_component(hass, DOMAIN, MOCK_CONFIG) + await hass.async_block_till_done() + + surepetcare.lock_in.side_effect = SurePetcareError + surepetcare.lock_out.side_effect = SurePetcareError + surepetcare.lock.side_effect = SurePetcareError + + for entity_id, unique_id in EXPECTED_ENTITY_IDS.items(): + with pytest.raises(SurePetcareError): + await hass.services.async_call( + "lock", "lock", {"entity_id": entity_id}, blocking=True + ) + state = hass.states.get(entity_id) + assert state.state == "unlocked" + + +async def test_unlock_failing(hass, surepetcare) -> None: + """Test handling of unlock failing.""" + assert await async_setup_component(hass, DOMAIN, MOCK_CONFIG) + await hass.async_block_till_done() + + entity_id = list(EXPECTED_ENTITY_IDS.keys())[0] + + await hass.services.async_call( + "lock", "lock", {"entity_id": entity_id}, blocking=True + ) + surepetcare.unlock.side_effect = SurePetcareError + + with pytest.raises(SurePetcareError): + await hass.services.async_call( + "lock", "unlock", {"entity_id": entity_id}, blocking=True + ) + state = hass.states.get(entity_id) + assert state.state == "locked" From 0c765a40aea0df2ab04003a765ed49e065f75bbd Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 30 Sep 2021 18:48:14 +0200 Subject: [PATCH 751/843] Bumped version to 2021.10.0b0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index f0aec3aaa79..407edbae5ed 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -5,7 +5,7 @@ from typing import Final MAJOR_VERSION: Final = 2021 MINOR_VERSION: Final = 10 -PATCH_VERSION: Final = "0.dev0" +PATCH_VERSION: Final = "0b0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0) From 6ed46bf549621149e7f665afc35a34b4bd5ba5a1 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Thu, 30 Sep 2021 16:32:17 -0400 Subject: [PATCH 752/843] Add strings for new zwave_js config flow keys (#56844) --- homeassistant/components/zwave_js/strings.json | 10 ++++++++-- .../components/zwave_js/translations/en.json | 13 +++++++++---- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 75c1ea76e9d..1446c1fc7aa 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -24,7 +24,10 @@ "title": "Enter the Z-Wave JS add-on configuration", "data": { "usb_path": "[%key:common::config_flow::data::usb_path%]", - "network_key": "Network Key" + "s0_legacy_key": "S0 Key (Legacy)", + "s2_authenticated_key": "S2 Authenticated Key", + "s2_unauthenticated_key": "S2 Unauthenticated Key", + "s2_access_control_key": "S2 Access Control Key" } }, "start_addon": { @@ -78,7 +81,10 @@ "title": "Enter the Z-Wave JS add-on configuration", "data": { "usb_path": "[%key:common::config_flow::data::usb_path%]", - "network_key": "Network Key", + "s0_legacy_key": "S0 Key (Legacy)", + "s2_authenticated_key": "S2 Authenticated Key", + "s2_unauthenticated_key": "S2 Unauthenticated Key", + "s2_access_control_key": "S2 Access Control Key", "log_level": "Log level", "emulate_hardware": "Emulate Hardware" } diff --git a/homeassistant/components/zwave_js/translations/en.json b/homeassistant/components/zwave_js/translations/en.json index abe37c4da04..b24d4f31b06 100644 --- a/homeassistant/components/zwave_js/translations/en.json +++ b/homeassistant/components/zwave_js/translations/en.json @@ -26,7 +26,10 @@ "step": { "configure_addon": { "data": { - "network_key": "Network Key", + "s0_legacy_key": "S0 Key (Legacy)", + "s2_access_control_key": "S2 Access Control Key", + "s2_authenticated_key": "S2 Authenticated Key", + "s2_unauthenticated_key": "S2 Unauthenticated Key", "usb_path": "USB Device Path" }, "title": "Enter the Z-Wave JS add-on configuration" @@ -108,7 +111,10 @@ "data": { "emulate_hardware": "Emulate Hardware", "log_level": "Log level", - "network_key": "Network Key", + "s0_legacy_key": "S0 Key (Legacy)", + "s2_access_control_key": "S2 Access Control Key", + "s2_authenticated_key": "S2 Authenticated Key", + "s2_unauthenticated_key": "S2 Unauthenticated Key", "usb_path": "USB Device Path" }, "title": "Enter the Z-Wave JS add-on configuration" @@ -132,6 +138,5 @@ "title": "The Z-Wave JS add-on is starting." } } - }, - "title": "Z-Wave JS" + } } \ No newline at end of file From baad8100f9b30324de8044732fe3ed5255097f9b Mon Sep 17 00:00:00 2001 From: Milan Meulemans Date: Thu, 30 Sep 2021 23:04:09 +0200 Subject: [PATCH 753/843] Upgrade aionanoleaf to 0.0.2 (#56845) --- homeassistant/components/nanoleaf/light.py | 4 ---- homeassistant/components/nanoleaf/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 3 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/nanoleaf/light.py b/homeassistant/components/nanoleaf/light.py index f5537d3dc1c..5902beba226 100644 --- a/homeassistant/components/nanoleaf/light.py +++ b/homeassistant/components/nanoleaf/light.py @@ -1,7 +1,6 @@ """Support for Nanoleaf Lights.""" from __future__ import annotations -from aiohttp import ServerDisconnectedError from aionanoleaf import Nanoleaf, Unavailable import voluptuous as vol @@ -176,9 +175,6 @@ class NanoleafLight(LightEntity): """Fetch new state data for this light.""" try: await self._nanoleaf.get_info() - except ServerDisconnectedError: - # Retry the request once if the device disconnected - await self._nanoleaf.get_info() except Unavailable: self._attr_available = False return diff --git a/homeassistant/components/nanoleaf/manifest.json b/homeassistant/components/nanoleaf/manifest.json index 31576fd73a7..133257dc7fe 100644 --- a/homeassistant/components/nanoleaf/manifest.json +++ b/homeassistant/components/nanoleaf/manifest.json @@ -3,7 +3,7 @@ "name": "Nanoleaf", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nanoleaf", - "requirements": ["aionanoleaf==0.0.1"], + "requirements": ["aionanoleaf==0.0.2"], "zeroconf": ["_nanoleafms._tcp.local.", "_nanoleafapi._tcp.local."], "homekit" : { "models": [ diff --git a/requirements_all.txt b/requirements_all.txt index fdb66ea982e..d77d32b2939 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -219,7 +219,7 @@ aiomodernforms==0.1.8 aiomusiccast==0.9.2 # homeassistant.components.nanoleaf -aionanoleaf==0.0.1 +aionanoleaf==0.0.2 # homeassistant.components.keyboard_remote aionotify==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 03556b3659d..e44eaba5719 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -146,7 +146,7 @@ aiomodernforms==0.1.8 aiomusiccast==0.9.2 # homeassistant.components.nanoleaf -aionanoleaf==0.0.1 +aionanoleaf==0.0.2 # homeassistant.components.notion aionotion==3.0.2 From a70daabcea30f6f98e4cfee0e26250aee2c60fc2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 30 Sep 2021 23:11:00 +0200 Subject: [PATCH 754/843] Correct database migration to schema version 22 (#56848) --- homeassistant/components/recorder/migration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 0d40707d825..1ced8b73207 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -567,7 +567,7 @@ def _apply_update(instance, session, new_version, old_version): # noqa: C901 sum_statistics = get_metadata_with_session( instance.hass, session, None, statistic_type="sum" ) - for metadata_id in sum_statistics: + for metadata_id, _ in sum_statistics.values(): last_statistic = ( session.query(Statistics) .filter_by(metadata_id=metadata_id) From 21aa635ad86e50535077d7ad10b835e448253fe5 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 30 Sep 2021 14:11:43 -0700 Subject: [PATCH 755/843] Bumped version to 2021.10.0b1 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 407edbae5ed..d4789598ff0 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -5,7 +5,7 @@ from typing import Final MAJOR_VERSION: Final = 2021 MINOR_VERSION: Final = 10 -PATCH_VERSION: Final = "0b0" +PATCH_VERSION: Final = "0b1" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0) From d97286bcd6b397fdf2d4c50ab6bbcce3669420ee Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 1 Oct 2021 16:42:42 +0200 Subject: [PATCH 756/843] Adjust state class of solarlog yield and consumption sensors (#56824) --- homeassistant/components/solarlog/const.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/solarlog/const.py b/homeassistant/components/solarlog/const.py index 3ee767f1513..0e9e5e8e5e0 100644 --- a/homeassistant/components/solarlog/const.py +++ b/homeassistant/components/solarlog/const.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from homeassistant.components.sensor import ( STATE_CLASS_MEASUREMENT, - STATE_CLASS_TOTAL_INCREASING, + STATE_CLASS_TOTAL, SensorEntityDescription, ) from homeassistant.const import ( @@ -19,7 +19,6 @@ from homeassistant.const import ( PERCENTAGE, POWER_WATT, ) -from homeassistant.util import dt DOMAIN = "solarlog" @@ -102,7 +101,7 @@ SENSOR_TYPES: tuple[SolarLogSensorEntityDescription, ...] = ( name="yield total", icon="mdi:solar-power", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, - state_class=STATE_CLASS_TOTAL_INCREASING, + state_class=STATE_CLASS_TOTAL, factor=0.001, ), SolarLogSensorEntityDescription( @@ -145,8 +144,7 @@ SENSOR_TYPES: tuple[SolarLogSensorEntityDescription, ...] = ( name="consumption total", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_MEASUREMENT, - last_reset=dt.utc_from_timestamp(0), + state_class=STATE_CLASS_TOTAL, factor=0.001, ), SolarLogSensorEntityDescription( From 74a1f604411f059f0bf9e282fe1b67069cb99f59 Mon Sep 17 00:00:00 2001 From: Ricardo Steijn <61013287+RicArch97@users.noreply.github.com> Date: Fri, 1 Oct 2021 17:42:32 +0200 Subject: [PATCH 757/843] Handle missing serial extended parameters in crownstone (#56864) --- .../components/crownstone/helpers.py | 4 +- .../components/crownstone/test_config_flow.py | 41 +++++++++++++++---- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/crownstone/helpers.py b/homeassistant/components/crownstone/helpers.py index ad12c28d464..58b4dcdba47 100644 --- a/homeassistant/components/crownstone/helpers.py +++ b/homeassistant/components/crownstone/helpers.py @@ -31,8 +31,8 @@ def list_ports_as_str( port.serial_number, port.manufacturer, port.description, - f"{hex(port.vid)[2:]:0>4}".upper(), - f"{hex(port.pid)[2:]:0>4}".upper(), + f"{hex(port.vid)[2:]:0>4}".upper() if port.vid else None, + f"{hex(port.pid)[2:]:0>4}".upper() if port.pid else None, ) ) ports_as_string.append(MANUAL_PATH) diff --git a/tests/components/crownstone/test_config_flow.py b/tests/components/crownstone/test_config_flow.py index 7b05c8ba530..05fde6109e7 100644 --- a/tests/components/crownstone/test_config_flow.py +++ b/tests/components/crownstone/test_config_flow.py @@ -51,6 +51,16 @@ def usb_comports() -> MockFixture: yield comports_mock +@pytest.fixture(name="pyserial_comports_none_types") +def usb_comports_none_types() -> MockFixture: + """Mock pyserial comports.""" + with patch( + "serial.tools.list_ports.comports", + MagicMock(return_value=[get_mocked_com_port_none_types()]), + ) as comports_mock: + yield comports_mock + + @pytest.fixture(name="usb_path") def usb_path() -> MockFixture: """Mock usb serial path.""" @@ -104,6 +114,19 @@ def get_mocked_com_port(): return port +def get_mocked_com_port_none_types(): + """Mock of a serial port with NoneTypes.""" + port = ListPortInfo("/dev/ttyUSB1234") + port.device = "/dev/ttyUSB1234" + port.serial_number = None + port.manufacturer = None + port.description = "crownstone dongle - crownstone dongle" + port.vid = None + port.pid = None + + return port + + def create_mocked_entry_data_conf(email: str, password: str): """Set a result for the entry data for comparison.""" mock_data: dict[str, str | None] = {} @@ -262,7 +285,7 @@ async def test_successful_login_no_usb( async def test_successful_login_with_usb( crownstone_setup: MockFixture, - pyserial_comports: MockFixture, + pyserial_comports_none_types: MockFixture, usb_path: MockFixture, hass: HomeAssistant, ): @@ -282,17 +305,18 @@ async def test_successful_login_with_usb( # should show usb form assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "usb_config" - assert pyserial_comports.call_count == 1 + assert pyserial_comports_none_types.call_count == 1 - # create a mocked port - port = get_mocked_com_port() + # create a mocked port which should be in + # the list returned from list_ports_as_str, from .helpers + port = get_mocked_com_port_none_types() port_select = usb.human_readable_device_name( port.device, port.serial_number, port.manufacturer, port.description, - f"{hex(port.vid)[2:]:0>4}".upper(), - f"{hex(port.pid)[2:]:0>4}".upper(), + port.vid, + port.pid, ) # select a port from the list @@ -301,7 +325,7 @@ async def test_successful_login_with_usb( ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "usb_sphere_config" - assert pyserial_comports.call_count == 2 + assert pyserial_comports_none_types.call_count == 2 assert usb_path.call_count == 1 # select a sphere @@ -406,7 +430,8 @@ async def test_options_flow_setup_usb( assert result["step_id"] == "usb_config" assert pyserial_comports.call_count == 1 - # create a mocked port + # create a mocked port which should be in + # the list returned from list_ports_as_str, from .helpers port = get_mocked_com_port() port_select = usb.human_readable_device_name( port.device, From 1f1f2a681122132e078fc4ca508d53b5185d8966 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Fri, 1 Oct 2021 09:12:45 +0200 Subject: [PATCH 758/843] Opengarage bug fix (#56869) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Opengarage bug fix Signed-off-by: Daniel Hjelseth Høyer * Opengarage bug fix Signed-off-by: Daniel Hjelseth Høyer * Deprecated open garage config Signed-off-by: Daniel Hjelseth Høyer * Deprecated open garage config Signed-off-by: Daniel Hjelseth Høyer --- homeassistant/components/opengarage/config_flow.py | 13 +++++++------ homeassistant/components/opengarage/cover.py | 9 +++++---- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/opengarage/config_flow.py b/homeassistant/components/opengarage/config_flow.py index 9121391b4e0..6ddc186cb9c 100644 --- a/homeassistant/components/opengarage/config_flow.py +++ b/homeassistant/components/opengarage/config_flow.py @@ -60,13 +60,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_import(self, import_info): """Set the config entry up from yaml.""" - import_info[CONF_HOST] = ( - f"{'https' if import_info[CONF_SSL] else 'http'}://" - f"{import_info.get(CONF_HOST)}" - ) - del import_info[CONF_SSL] - return await self.async_step_user(import_info) + user_input = { + CONF_DEVICE_KEY: import_info[CONF_DEVICE_KEY], + CONF_HOST: f"{'https' if import_info.get(CONF_SSL, False) else 'http'}://{import_info[CONF_HOST]}", + CONF_PORT: import_info.get(CONF_PORT, DEFAULT_PORT), + CONF_VERIFY_SSL: import_info.get(CONF_VERIFY_SSL, False), + } + return await self.async_step_user(user_input) async def async_step_user( self, user_input: dict[str, Any] | None = None diff --git a/homeassistant/components/opengarage/cover.py b/homeassistant/components/opengarage/cover.py index bf23d3286ad..12a1103f7df 100644 --- a/homeassistant/components/opengarage/cover.py +++ b/homeassistant/components/opengarage/cover.py @@ -50,15 +50,16 @@ COVER_SCHEMA = vol.Schema( ) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - vol.All( - cv.deprecated(DOMAIN), - {vol.Required(CONF_COVERS): cv.schema_with_slug_keys(COVER_SCHEMA)}, - ), + {vol.Required(CONF_COVERS): cv.schema_with_slug_keys(COVER_SCHEMA)} ) async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the OpenGarage covers.""" + _LOGGER.warning( + "Open Garage YAML configuration is deprecated, " + "it has been imported into the UI automatically and can be safely removed" + ) devices = config.get(CONF_COVERS) for device_config in devices.values(): hass.async_create_task( From 666fbc98770f67d066c7ba6a83a370a2798182b7 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 1 Oct 2021 11:50:49 +0200 Subject: [PATCH 759/843] Fix check_control_message short description (#56876) --- homeassistant/components/bmw_connected_drive/binary_sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/bmw_connected_drive/binary_sensor.py b/homeassistant/components/bmw_connected_drive/binary_sensor.py index a7fd72fc1a7..225ec5f7f99 100644 --- a/homeassistant/components/bmw_connected_drive/binary_sensor.py +++ b/homeassistant/components/bmw_connected_drive/binary_sensor.py @@ -122,7 +122,7 @@ class BMWConnectedDriveSensor(BMWConnectedDriveBaseEntity, BinarySensorEntity): if has_check_control_messages: cbs_list = [] for message in check_control_messages: - cbs_list.append(message["ccmDescriptionShort"]) + cbs_list.append(message.description_short) result["check_control_messages"] = cbs_list else: result["check_control_messages"] = "OK" From 91b8d5fcd1cfd7a45d45051ffa54b7455e1b0d9d Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Fri, 1 Oct 2021 12:11:06 +0200 Subject: [PATCH 760/843] Bump aioesphomeapi from 9.1.0 to 9.1.2 (#56879) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 857aebdc4dd..28371e89d8e 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -3,7 +3,7 @@ "name": "ESPHome", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/esphome", - "requirements": ["aioesphomeapi==9.1.0"], + "requirements": ["aioesphomeapi==9.1.2"], "zeroconf": ["_esphomelib._tcp.local."], "codeowners": ["@OttoWinter", "@jesserockz"], "after_dependencies": ["zeroconf", "tag"], diff --git a/requirements_all.txt b/requirements_all.txt index d77d32b2939..dd2447b9bda 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -164,7 +164,7 @@ aioeagle==1.1.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==9.1.0 +aioesphomeapi==9.1.2 # homeassistant.components.flo aioflo==0.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e44eaba5719..8ba707f471c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -109,7 +109,7 @@ aioeagle==1.1.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==9.1.0 +aioesphomeapi==9.1.2 # homeassistant.components.flo aioflo==0.4.1 From 2856355c285f27b930e4993a5af57fb9c3457e41 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 1 Oct 2021 18:27:32 +0200 Subject: [PATCH 761/843] Fix bmw_connected_drive battery icon (#56884) --- homeassistant/components/bmw_connected_drive/sensor.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/sensor.py b/homeassistant/components/bmw_connected_drive/sensor.py index 76d183bf8e8..104a2eb78d9 100644 --- a/homeassistant/components/bmw_connected_drive/sensor.py +++ b/homeassistant/components/bmw_connected_drive/sensor.py @@ -513,6 +513,9 @@ class BMWConnectedDriveSensor(BMWConnectedDriveBaseEntity, SensorEntity): self._attr_entity_registry_enabled_default = attribute_info.get( attribute, [None, None, None, True] )[3] + self._attr_icon = self._attribute_info.get( + self._attribute, [None, None, None, None] + )[0] self._attr_device_class = attribute_info.get( attribute, [None, None, None, None] )[1] @@ -570,6 +573,3 @@ class BMWConnectedDriveSensor(BMWConnectedDriveBaseEntity, SensorEntity): self._attr_icon = icon_for_battery_level( battery_level=vehicle_state.charging_level_hv, charging=charging_state ) - self._attr_icon = self._attribute_info.get( - self._attribute, [None, None, None, None] - )[0] From c9346e9af1d948727d4f9800ac0c2b231bd02d59 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 1 Oct 2021 16:18:49 +0200 Subject: [PATCH 762/843] Revert fritz pref_disable_new_entities handling (#56891) --- homeassistant/components/fritz/common.py | 3 --- homeassistant/components/fritz/device_tracker.py | 9 ++------- homeassistant/components/fritz/switch.py | 13 +++---------- 3 files changed, 5 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/fritz/common.py b/homeassistant/components/fritz/common.py index acb733709a3..0fb062af2d7 100644 --- a/homeassistant/components/fritz/common.py +++ b/homeassistant/components/fritz/common.py @@ -54,7 +54,6 @@ def _is_tracked(mac: str, current_devices: ValuesView) -> bool: def device_filter_out_from_trackers( mac: str, device: FritzDevice, - pref_disable_new_entities: bool, current_devices: ValuesView, ) -> bool: """Check if device should be filtered out from trackers.""" @@ -63,8 +62,6 @@ def device_filter_out_from_trackers( reason = "Missing IP" elif _is_tracked(mac, current_devices): reason = "Already tracked" - elif pref_disable_new_entities: - reason = "Disabled System Options" if reason: _LOGGER.debug( diff --git a/homeassistant/components/fritz/device_tracker.py b/homeassistant/components/fritz/device_tracker.py index f3134f32a27..9483d8163e0 100644 --- a/homeassistant/components/fritz/device_tracker.py +++ b/homeassistant/components/fritz/device_tracker.py @@ -80,9 +80,7 @@ async def async_setup_entry( @callback def update_router() -> None: """Update the values of the router.""" - _async_add_entities( - router, async_add_entities, data_fritz, entry.pref_disable_new_entities - ) + _async_add_entities(router, async_add_entities, data_fritz) entry.async_on_unload( async_dispatcher_connect(hass, router.signal_device_new, update_router) @@ -96,7 +94,6 @@ def _async_add_entities( router: FritzBoxTools, async_add_entities: AddEntitiesCallback, data_fritz: FritzData, - pref_disable_new_entities: bool, ) -> None: """Add new tracker entities from the router.""" @@ -105,9 +102,7 @@ def _async_add_entities( data_fritz.tracked[router.unique_id] = set() for mac, device in router.devices.items(): - if device_filter_out_from_trackers( - mac, device, pref_disable_new_entities, data_fritz.tracked.values() - ): + if device_filter_out_from_trackers(mac, device, data_fritz.tracked.values()): continue new_tracked.append(FritzBoxTracker(router, device)) diff --git a/homeassistant/components/fritz/switch.py b/homeassistant/components/fritz/switch.py index c337c568d18..a53d0867a3c 100644 --- a/homeassistant/components/fritz/switch.py +++ b/homeassistant/components/fritz/switch.py @@ -277,7 +277,6 @@ def wifi_entities_list( def profile_entities_list( router: FritzBoxTools, data_fritz: FritzData, - pref_disable_new_entities: bool, ) -> list[FritzBoxProfileSwitch]: """Add new tracker entities from the router.""" @@ -291,7 +290,7 @@ def profile_entities_list( for mac, device in router.devices.items(): if device_filter_out_from_trackers( - mac, device, pref_disable_new_entities, data_fritz.profile_switches.values() + mac, device, data_fritz.profile_switches.values() ): continue @@ -306,14 +305,13 @@ def all_entities_list( device_friendly_name: str, data_fritz: FritzData, local_ip: str, - pref_disable_new_entities: bool, ) -> list[Entity]: """Get a list of all entities.""" return [ *deflection_entities_list(fritzbox_tools, device_friendly_name), *port_entities_list(fritzbox_tools, device_friendly_name, local_ip), *wifi_entities_list(fritzbox_tools, device_friendly_name), - *profile_entities_list(fritzbox_tools, data_fritz, pref_disable_new_entities), + *profile_entities_list(fritzbox_tools, data_fritz), ] @@ -337,7 +335,6 @@ async def async_setup_entry( entry.title, data_fritz, local_ip, - entry.pref_disable_new_entities, ) async_add_entities(entities_list) @@ -345,11 +342,7 @@ async def async_setup_entry( @callback def update_router() -> None: """Update the values of the router.""" - async_add_entities( - profile_entities_list( - fritzbox_tools, data_fritz, entry.pref_disable_new_entities - ) - ) + async_add_entities(profile_entities_list(fritzbox_tools, data_fritz)) entry.async_on_unload( async_dispatcher_connect(hass, fritzbox_tools.signal_device_new, update_router) From f102bf3850768d108b94eccef15b124c3fb5609f Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Fri, 1 Oct 2021 16:50:09 +0200 Subject: [PATCH 763/843] Use native unit of measurement in deCONZ sensors (#56897) --- homeassistant/components/deconz/sensor.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/deconz/sensor.py b/homeassistant/components/deconz/sensor.py index e0e979ccf0b..b486f6b8677 100644 --- a/homeassistant/components/deconz/sensor.py +++ b/homeassistant/components/deconz/sensor.py @@ -70,13 +70,13 @@ ENTITY_DESCRIPTIONS = { key="battery", device_class=DEVICE_CLASS_BATTERY, state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, ), Consumption: SensorEntityDescription( key="consumption", device_class=DEVICE_CLASS_ENERGY, state_class=STATE_CLASS_TOTAL_INCREASING, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, ), Daylight: SensorEntityDescription( key="daylight", @@ -87,30 +87,30 @@ ENTITY_DESCRIPTIONS = { key="humidity", device_class=DEVICE_CLASS_HUMIDITY, state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, ), LightLevel: SensorEntityDescription( key="lightlevel", device_class=DEVICE_CLASS_ILLUMINANCE, - unit_of_measurement=LIGHT_LUX, + native_unit_of_measurement=LIGHT_LUX, ), Power: SensorEntityDescription( key="power", device_class=DEVICE_CLASS_POWER, state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=POWER_WATT, + native_unit_of_measurement=POWER_WATT, ), Pressure: SensorEntityDescription( key="pressure", device_class=DEVICE_CLASS_PRESSURE, state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=PRESSURE_HPA, + native_unit_of_measurement=PRESSURE_HPA, ), Temperature: SensorEntityDescription( key="temperature", device_class=DEVICE_CLASS_TEMPERATURE, state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=TEMP_CELSIUS, ), } From 9a218ff2410004072336e6426620bc31eb9ef15e Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Fri, 1 Oct 2021 17:10:01 +0200 Subject: [PATCH 764/843] CLIPGenericFlag should be deCONZ sensor not binary sensor (#56901) --- .../components/deconz/binary_sensor.py | 2 -- homeassistant/components/deconz/sensor.py | 2 ++ tests/components/deconz/test_binary_sensor.py | 18 ++++++++++++++++-- tests/components/deconz/test_sensor.py | 18 ++++++++++++++++-- 4 files changed, 34 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/deconz/binary_sensor.py b/homeassistant/components/deconz/binary_sensor.py index 96de780c137..33b68f25cab 100644 --- a/homeassistant/components/deconz/binary_sensor.py +++ b/homeassistant/components/deconz/binary_sensor.py @@ -4,7 +4,6 @@ from pydeconz.sensor import ( CarbonMonoxide, Fire, GenericFlag, - GenericStatus, OpenClose, Presence, Vibration, @@ -36,7 +35,6 @@ DECONZ_BINARY_SENSORS = ( CarbonMonoxide, Fire, GenericFlag, - GenericStatus, OpenClose, Presence, Vibration, diff --git a/homeassistant/components/deconz/sensor.py b/homeassistant/components/deconz/sensor.py index b486f6b8677..8b82c2fa7bf 100644 --- a/homeassistant/components/deconz/sensor.py +++ b/homeassistant/components/deconz/sensor.py @@ -4,6 +4,7 @@ from pydeconz.sensor import ( Battery, Consumption, Daylight, + GenericStatus, Humidity, LightLevel, Power, @@ -52,6 +53,7 @@ DECONZ_SENSORS = ( AirQuality, Consumption, Daylight, + GenericStatus, Humidity, LightLevel, Power, diff --git a/tests/components/deconz/test_binary_sensor.py b/tests/components/deconz/test_binary_sensor.py index 2a1b3c154f0..7f986ce4b81 100644 --- a/tests/components/deconz/test_binary_sensor.py +++ b/tests/components/deconz/test_binary_sensor.py @@ -181,6 +181,17 @@ async def test_allow_clip_sensor(hass, aioclient_mock): "config": {}, "uniqueid": "00:00:00:00:00:00:00:02-00", }, + "3": { + "config": {"on": True, "reachable": True}, + "etag": "fda064fca03f17389d0799d7cb1883ee", + "manufacturername": "Philips", + "modelid": "CLIPGenericFlag", + "name": "Clip Flag Boot Time", + "state": {"flag": True, "lastupdated": "2021-09-30T07:09:06.281"}, + "swversion": "1.0", + "type": "CLIPGenericFlag", + "uniqueid": "/sensors/3", + }, } } @@ -189,9 +200,10 @@ async def test_allow_clip_sensor(hass, aioclient_mock): hass, aioclient_mock, options={CONF_ALLOW_CLIP_SENSOR: True} ) - assert len(hass.states.async_all()) == 2 + assert len(hass.states.async_all()) == 3 assert hass.states.get("binary_sensor.presence_sensor").state == STATE_OFF assert hass.states.get("binary_sensor.clip_presence_sensor").state == STATE_OFF + assert hass.states.get("binary_sensor.clip_flag_boot_time").state == STATE_ON # Disallow clip sensors @@ -202,6 +214,7 @@ async def test_allow_clip_sensor(hass, aioclient_mock): assert len(hass.states.async_all()) == 1 assert not hass.states.get("binary_sensor.clip_presence_sensor") + assert not hass.states.get("binary_sensor.clip_flag_boot_time") # Allow clip sensors @@ -210,8 +223,9 @@ async def test_allow_clip_sensor(hass, aioclient_mock): ) await hass.async_block_till_done() - assert len(hass.states.async_all()) == 2 + assert len(hass.states.async_all()) == 3 assert hass.states.get("binary_sensor.clip_presence_sensor").state == STATE_OFF + assert hass.states.get("binary_sensor.clip_flag_boot_time").state == STATE_ON async def test_add_new_binary_sensor(hass, aioclient_mock, mock_deconz_websocket): diff --git a/tests/components/deconz/test_sensor.py b/tests/components/deconz/test_sensor.py index 33f9c8c6a2c..624a1bec7ff 100644 --- a/tests/components/deconz/test_sensor.py +++ b/tests/components/deconz/test_sensor.py @@ -196,6 +196,17 @@ async def test_allow_clip_sensors(hass, aioclient_mock): "config": {"reachable": True}, "uniqueid": "00:00:00:00:00:00:00:01-00", }, + "3": { + "config": {"on": True, "reachable": True}, + "etag": "a5ed309124d9b7a21ef29fc278f2625e", + "manufacturername": "Philips", + "modelid": "CLIPGenericStatus", + "name": "CLIP Flur", + "state": {"lastupdated": "2021-10-01T10:23:06.779", "status": 0}, + "swversion": "1.0", + "type": "CLIPGenericStatus", + "uniqueid": "/sensors/3", + }, } } with patch.dict(DECONZ_WEB_REQUEST, data): @@ -205,8 +216,9 @@ async def test_allow_clip_sensors(hass, aioclient_mock): options={CONF_ALLOW_CLIP_SENSOR: True}, ) - assert len(hass.states.async_all()) == 3 + assert len(hass.states.async_all()) == 4 assert hass.states.get("sensor.clip_light_level_sensor").state == "999.8" + assert hass.states.get("sensor.clip_flur").state == "0" # Disallow clip sensors @@ -217,6 +229,7 @@ async def test_allow_clip_sensors(hass, aioclient_mock): assert len(hass.states.async_all()) == 2 assert not hass.states.get("sensor.clip_light_level_sensor") + assert not hass.states.get("sensor.clip_flur") # Allow clip sensors @@ -225,8 +238,9 @@ async def test_allow_clip_sensors(hass, aioclient_mock): ) await hass.async_block_till_done() - assert len(hass.states.async_all()) == 3 + assert len(hass.states.async_all()) == 4 assert hass.states.get("sensor.clip_light_level_sensor").state == "999.8" + assert hass.states.get("sensor.clip_flur").state == "0" async def test_add_new_sensor(hass, aioclient_mock, mock_deconz_websocket): From d6357bcbbe84635c13c9ba25a3a9a1f47b397ffc Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 1 Oct 2021 09:31:17 -0700 Subject: [PATCH 765/843] Bumped version to 2021.10.0b2 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index d4789598ff0..6c6e56f5927 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -5,7 +5,7 @@ from typing import Final MAJOR_VERSION: Final = 2021 MINOR_VERSION: Final = 10 -PATCH_VERSION: Final = "0b1" +PATCH_VERSION: Final = "0b2" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0) From 94b8877e2a4f240454bbd125c36e174666430011 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sat, 2 Oct 2021 12:59:05 +0000 Subject: [PATCH 766/843] [ci skip] Translation update --- .../accuweather/translations/hu.json | 2 +- .../components/adax/translations/es.json | 9 +- .../components/adax/translations/hu.json | 2 +- .../components/adax/translations/id.json | 20 ++++ .../components/adguard/translations/he.json | 3 + .../components/adguard/translations/hu.json | 4 +- .../components/adguard/translations/id.json | 2 +- .../components/agent_dvr/translations/hu.json | 4 +- .../components/airthings/translations/ca.json | 21 ++++ .../components/airthings/translations/de.json | 21 ++++ .../components/airthings/translations/en.json | 21 ++++ .../components/airthings/translations/et.json | 21 ++++ .../components/airthings/translations/he.json | 20 ++++ .../components/airthings/translations/hu.json | 21 ++++ .../components/airthings/translations/it.json | 21 ++++ .../components/airthings/translations/nl.json | 21 ++++ .../components/airthings/translations/no.json | 21 ++++ .../components/airthings/translations/ru.json | 21 ++++ .../airthings/translations/zh-Hant.json | 21 ++++ .../components/airtouch4/translations/es.json | 6 +- .../components/airtouch4/translations/hu.json | 2 +- .../components/airtouch4/translations/id.json | 17 ++++ .../components/airvisual/translations/hu.json | 2 +- .../alarmdecoder/translations/hu.json | 2 +- .../components/almond/translations/hu.json | 6 +- .../components/almond/translations/id.json | 2 +- .../components/ambee/translations/es.json | 14 +++ .../components/ambee/translations/hu.json | 2 +- .../components/ambee/translations/id.json | 6 +- .../ambee/translations/sensor.id.json | 9 ++ .../amberelectric/translations/ca.json | 22 +++++ .../amberelectric/translations/de.json | 22 +++++ .../amberelectric/translations/et.json | 22 +++++ .../amberelectric/translations/hu.json | 22 +++++ .../amberelectric/translations/it.json | 22 +++++ .../amberelectric/translations/nl.json | 22 +++++ .../amberelectric/translations/no.json | 22 +++++ .../amberelectric/translations/ru.json | 22 +++++ .../amberelectric/translations/zh-Hant.json | 22 +++++ .../ambiclimate/translations/ca.json | 2 +- .../ambiclimate/translations/hu.json | 6 +- .../components/apple_tv/translations/hu.json | 8 +- .../components/apple_tv/translations/id.json | 2 +- .../components/arcam_fmj/translations/hu.json | 6 +- .../components/arcam_fmj/translations/id.json | 2 +- .../components/asuswrt/translations/hu.json | 2 +- .../components/asuswrt/translations/ru.json | 2 +- .../components/atag/translations/hu.json | 2 +- .../components/august/translations/ca.json | 2 +- .../components/august/translations/hu.json | 2 +- .../components/auth/translations/fi.json | 5 + .../components/auth/translations/hu.json | 8 +- .../automation/translations/hu.json | 2 +- .../components/awair/translations/ca.json | 2 +- .../components/awair/translations/hu.json | 2 +- .../components/axis/translations/hu.json | 6 +- .../azure_devops/translations/ca.json | 2 +- .../azure_devops/translations/id.json | 2 +- .../binary_sensor/translations/id.json | 3 + .../binary_sensor/translations/is.json | 2 +- .../components/blebox/translations/hu.json | 2 +- .../components/blebox/translations/id.json | 2 +- .../components/blink/translations/hu.json | 2 +- .../bmw_connected_drive/translations/ca.json | 2 +- .../components/bond/translations/es.json | 4 +- .../components/bond/translations/hu.json | 4 +- .../components/bond/translations/id.json | 2 +- .../components/bosch_shc/translations/es.json | 9 +- .../components/bosch_shc/translations/hu.json | 6 +- .../components/braviatv/translations/hu.json | 4 +- .../components/broadlink/translations/es.json | 2 +- .../components/broadlink/translations/hu.json | 10 +- .../components/brother/translations/hu.json | 6 +- .../components/brother/translations/id.json | 2 +- .../components/bsblan/translations/hu.json | 2 +- .../components/bsblan/translations/id.json | 2 +- .../buienradar/translations/id.json | 7 ++ .../components/canary/translations/id.json | 2 +- .../components/cast/translations/hu.json | 6 +- .../components/cast/translations/id.json | 6 +- .../components/cast/translations/nl.json | 2 +- .../cert_expiry/translations/hu.json | 6 +- .../components/climacell/translations/hu.json | 2 +- .../cloudflare/translations/he.json | 2 +- .../cloudflare/translations/id.json | 2 +- .../components/co2signal/translations/es.json | 4 + .../components/co2signal/translations/hu.json | 4 +- .../components/co2signal/translations/id.json | 30 ++++++ .../coolmaster/translations/hu.json | 2 +- .../coronavirus/translations/id.json | 3 +- .../crownstone/translations/ca.json | 96 +++++++++++++++++++ .../crownstone/translations/cs.json | 31 ++++++ .../crownstone/translations/de.json | 96 +++++++++++++++++++ .../crownstone/translations/en.json | 21 ++++ .../crownstone/translations/es.json | 69 +++++++++++++ .../crownstone/translations/et.json | 96 +++++++++++++++++++ .../crownstone/translations/he.json | 53 ++++++++++ .../crownstone/translations/hu.json | 96 +++++++++++++++++++ .../crownstone/translations/id.json | 38 ++++++++ .../crownstone/translations/it.json | 96 +++++++++++++++++++ .../crownstone/translations/ko.json | 16 ++++ .../crownstone/translations/nl.json | 96 +++++++++++++++++++ .../crownstone/translations/no.json | 96 +++++++++++++++++++ .../crownstone/translations/ru.json | 96 +++++++++++++++++++ .../crownstone/translations/zh-Hant.json | 96 +++++++++++++++++++ .../components/daikin/translations/hu.json | 4 +- .../components/deconz/translations/es.json | 2 +- .../components/deconz/translations/hu.json | 16 ++-- .../components/deconz/translations/id.json | 6 +- .../components/demo/translations/he.json | 3 + .../components/demo/translations/hu.json | 2 +- .../components/demo/translations/ro.json | 13 +++ .../components/denonavr/translations/hu.json | 2 +- .../components/denonavr/translations/id.json | 2 +- .../devolo_home_control/translations/ca.json | 2 +- .../devolo_home_control/translations/id.json | 10 +- .../devolo_home_control/translations/ko.json | 7 ++ .../components/dexcom/translations/ca.json | 2 +- .../dialogflow/translations/hu.json | 4 +- .../components/directv/translations/hu.json | 4 +- .../components/directv/translations/id.json | 2 +- .../components/dlna_dmr/translations/ca.json | 44 +++++++++ .../components/dlna_dmr/translations/de.json | 44 +++++++++ .../components/dlna_dmr/translations/et.json | 44 +++++++++ .../components/dlna_dmr/translations/hu.json | 44 +++++++++ .../components/dlna_dmr/translations/it.json | 44 +++++++++ .../components/dlna_dmr/translations/nl.json | 44 +++++++++ .../components/dlna_dmr/translations/no.json | 44 +++++++++ .../components/dlna_dmr/translations/ru.json | 44 +++++++++ .../dlna_dmr/translations/zh-Hant.json | 44 +++++++++ .../components/doorbird/translations/hu.json | 4 +- .../components/doorbird/translations/id.json | 2 +- .../components/dsmr/translations/ca.json | 2 +- .../components/dsmr/translations/hu.json | 2 +- .../components/dunehd/translations/hu.json | 2 +- .../components/elgato/translations/hu.json | 4 +- .../components/elgato/translations/id.json | 8 +- .../components/emonitor/translations/hu.json | 4 +- .../components/emonitor/translations/id.json | 2 +- .../emulated_roku/translations/hu.json | 4 +- .../components/energy/translations/el.json | 3 + .../enphase_envoy/translations/hu.json | 2 +- .../enphase_envoy/translations/id.json | 2 +- .../components/epson/translations/hu.json | 2 +- .../components/esphome/translations/ca.json | 16 +++- .../components/esphome/translations/cs.json | 3 +- .../components/esphome/translations/de.json | 16 +++- .../components/esphome/translations/en.json | 16 +++- .../components/esphome/translations/es.json | 16 +++- .../components/esphome/translations/et.json | 16 +++- .../components/esphome/translations/he.json | 8 +- .../components/esphome/translations/hu.json | 30 ++++-- .../components/esphome/translations/id.json | 5 +- .../components/esphome/translations/it.json | 16 +++- .../components/esphome/translations/nl.json | 16 +++- .../components/esphome/translations/no.json | 16 +++- .../components/esphome/translations/ru.json | 16 +++- .../esphome/translations/zh-Hant.json | 16 +++- .../components/ezviz/translations/ca.json | 2 +- .../components/ezviz/translations/hu.json | 2 +- .../fireservicerota/translations/ca.json | 2 +- .../fjaraskupan/translations/es.json | 8 ++ .../fjaraskupan/translations/id.json | 13 +++ .../flick_electric/translations/ca.json | 2 +- .../flick_electric/translations/he.json | 4 +- .../flick_electric/translations/id.json | 4 +- .../components/flipr/translations/es.json | 11 ++- .../components/flipr/translations/id.json | 20 ++++ .../components/flo/translations/hu.json | 2 +- .../components/flume/translations/ca.json | 2 +- .../forecast_solar/translations/es.json | 5 +- .../forecast_solar/translations/id.json | 4 +- .../forked_daapd/translations/hu.json | 6 +- .../forked_daapd/translations/id.json | 2 +- .../components/foscam/translations/hu.json | 2 +- .../components/freebox/translations/hu.json | 4 +- .../freedompro/translations/id.json | 7 ++ .../components/fritz/translations/es.json | 6 +- .../components/fritz/translations/hu.json | 4 +- .../components/fritz/translations/ko.json | 38 ++++++++ .../components/fritz/translations/ru.json | 2 +- .../components/fritzbox/translations/hu.json | 6 +- .../components/fritzbox/translations/id.json | 2 +- .../fritzbox_callmonitor/translations/hu.json | 2 +- .../fritzbox_callmonitor/translations/id.json | 2 +- .../garages_amsterdam/translations/es.json | 2 + .../components/geofency/translations/hu.json | 4 +- .../components/glances/translations/hu.json | 2 +- .../components/goalzero/translations/es.json | 1 + .../components/goalzero/translations/hu.json | 6 +- .../components/gogogate2/translations/id.json | 2 +- .../components/gpslogger/translations/hu.json | 4 +- .../components/gree/translations/hu.json | 2 +- .../components/gree/translations/nl.json | 2 +- .../growatt_server/translations/es.json | 1 + .../growatt_server/translations/id.json | 1 + .../components/guardian/translations/hu.json | 6 +- .../components/hangouts/translations/fi.json | 2 + .../components/hangouts/translations/hu.json | 2 +- .../components/harmony/translations/hu.json | 4 +- .../components/harmony/translations/id.json | 2 +- .../components/hassio/translations/he.json | 10 +- .../components/heos/translations/hu.json | 4 +- .../components/hive/translations/ca.json | 2 +- .../components/hive/translations/hu.json | 6 +- .../components/hlk_sw16/translations/hu.json | 2 +- .../home_connect/translations/hu.json | 2 +- .../home_plus_control/translations/ca.json | 2 +- .../home_plus_control/translations/hu.json | 4 +- .../components/homekit/translations/ca.json | 3 +- .../components/homekit/translations/de.json | 1 + .../components/homekit/translations/el.json | 11 +++ .../components/homekit/translations/es.json | 6 +- .../components/homekit/translations/et.json | 3 +- .../components/homekit/translations/he.json | 3 + .../components/homekit/translations/hu.json | 11 ++- .../components/homekit/translations/id.json | 8 +- .../components/homekit/translations/it.json | 3 +- .../components/homekit/translations/nl.json | 3 +- .../components/homekit/translations/no.json | 3 +- .../components/homekit/translations/ru.json | 5 +- .../homekit/translations/zh-Hant.json | 3 +- .../homekit_controller/translations/hu.json | 14 +-- .../homekit_controller/translations/id.json | 2 +- .../homematicip_cloud/translations/fi.json | 9 ++ .../homematicip_cloud/translations/hu.json | 2 +- .../components/honeywell/translations/es.json | 6 +- .../components/honeywell/translations/id.json | 15 +++ .../huawei_lte/translations/hu.json | 4 +- .../huawei_lte/translations/id.json | 4 +- .../components/hue/translations/hu.json | 12 +-- .../translations/hu.json | 2 +- .../hvv_departures/translations/hu.json | 2 +- .../components/hyperion/translations/hu.json | 8 +- .../components/ialarm/translations/hu.json | 2 +- .../components/iaqualink/translations/hu.json | 2 +- .../components/icloud/translations/ca.json | 2 +- .../components/icloud/translations/hu.json | 2 +- .../components/ifttt/translations/hu.json | 6 +- .../components/insteon/translations/ca.json | 2 +- .../components/insteon/translations/hu.json | 14 +-- .../components/ios/translations/hu.json | 2 +- .../components/ios/translations/nl.json | 2 +- .../components/iotawatt/translations/el.json | 3 +- .../components/iotawatt/translations/es.json | 3 +- .../components/iotawatt/translations/hu.json | 2 +- .../components/iotawatt/translations/id.json | 17 ++++ .../components/iotawatt/translations/nl.json | 9 ++ .../components/ipp/translations/hu.json | 8 +- .../components/ipp/translations/id.json | 2 +- .../components/isy994/translations/es.json | 2 +- .../components/isy994/translations/hu.json | 6 +- .../components/isy994/translations/id.json | 2 +- .../components/juicenet/translations/ca.json | 2 +- .../keenetic_ndms2/translations/ca.json | 2 +- .../keenetic_ndms2/translations/hu.json | 2 +- .../keenetic_ndms2/translations/id.json | 1 + .../keenetic_ndms2/translations/ru.json | 2 +- .../components/kmtronic/translations/hu.json | 2 +- .../components/kodi/translations/hu.json | 6 +- .../components/kodi/translations/id.json | 2 +- .../components/konnected/translations/hu.json | 6 +- .../components/konnected/translations/id.json | 2 +- .../kostal_plenticore/translations/hu.json | 2 +- .../components/kraken/translations/es.json | 6 +- .../components/kraken/translations/hu.json | 2 +- .../components/kraken/translations/id.json | 12 +++ .../components/kraken/translations/nl.json | 2 +- .../components/kulersky/translations/hu.json | 2 +- .../components/kulersky/translations/nl.json | 2 +- .../components/life360/translations/ca.json | 2 +- .../components/lifx/translations/hu.json | 2 +- .../litterrobot/translations/ca.json | 2 +- .../litterrobot/translations/hu.json | 2 +- .../components/local_ip/translations/hu.json | 2 +- .../components/local_ip/translations/nl.json | 2 +- .../components/locative/translations/hu.json | 4 +- .../components/locative/translations/nl.json | 2 +- .../logi_circle/translations/ca.json | 2 +- .../logi_circle/translations/hu.json | 6 +- .../lutron_caseta/translations/es.json | 4 +- .../lutron_caseta/translations/hu.json | 4 +- .../lutron_caseta/translations/id.json | 2 +- .../components/lyric/translations/hu.json | 2 +- .../components/lyric/translations/id.json | 6 +- .../components/mailgun/translations/hu.json | 6 +- .../components/mazda/translations/ca.json | 2 +- .../components/mazda/translations/hu.json | 2 +- .../meteo_france/translations/hu.json | 2 +- .../meteoclimatic/translations/es.json | 4 + .../meteoclimatic/translations/id.json | 11 +++ .../components/mikrotik/translations/hu.json | 2 +- .../components/mikrotik/translations/ru.json | 2 +- .../components/mill/translations/ca.json | 2 +- .../minecraft_server/translations/hu.json | 4 +- .../mobile_app/translations/hu.json | 4 +- .../modem_callerid/translations/ca.json | 26 +++++ .../modem_callerid/translations/cs.json | 19 ++++ .../modem_callerid/translations/de.json | 26 +++++ .../modem_callerid/translations/en.json | 12 +-- .../modem_callerid/translations/es.json | 16 ++++ .../modem_callerid/translations/et.json | 26 +++++ .../modem_callerid/translations/he.json | 19 ++++ .../modem_callerid/translations/hu.json | 26 +++++ .../modem_callerid/translations/id.json | 20 ++++ .../modem_callerid/translations/it.json | 26 +++++ .../modem_callerid/translations/nl.json | 26 +++++ .../modem_callerid/translations/no.json | 26 +++++ .../modem_callerid/translations/ru.json | 26 +++++ .../modem_callerid/translations/zh-Hant.json | 26 +++++ .../modern_forms/translations/es.json | 10 ++ .../modern_forms/translations/hu.json | 8 +- .../modern_forms/translations/id.json | 22 +++++ .../modern_forms/translations/nl.json | 2 +- .../motion_blinds/translations/hu.json | 2 +- .../components/motioneye/translations/hu.json | 4 +- .../components/motioneye/translations/ko.json | 25 +++++ .../components/mqtt/translations/es.json | 1 + .../components/mqtt/translations/fi.json | 3 + .../components/mqtt/translations/he.json | 34 +++++++ .../components/mqtt/translations/hu.json | 10 +- .../components/mqtt/translations/id.json | 5 +- .../components/mutesync/translations/hu.json | 2 +- .../components/mutesync/translations/id.json | 15 +++ .../components/myq/translations/id.json | 8 +- .../components/nam/translations/hu.json | 4 +- .../components/nanoleaf/translations/el.json | 1 + .../components/nanoleaf/translations/es.json | 8 ++ .../components/nanoleaf/translations/hu.json | 4 +- .../components/nanoleaf/translations/id.json | 22 +++++ .../components/neato/translations/es.json | 2 +- .../components/neato/translations/hu.json | 4 +- .../components/neato/translations/nl.json | 2 +- .../components/nest/translations/fi.json | 3 + .../components/nest/translations/he.json | 5 +- .../components/nest/translations/hu.json | 2 +- .../components/netatmo/translations/hu.json | 2 +- .../components/netgear/translations/ca.json | 34 +++++++ .../components/netgear/translations/cs.json | 15 +++ .../components/netgear/translations/de.json | 34 +++++++ .../components/netgear/translations/en.json | 18 ++-- .../components/netgear/translations/es.json | 18 ++++ .../components/netgear/translations/et.json | 34 +++++++ .../components/netgear/translations/he.json | 26 +++++ .../components/netgear/translations/hu.json | 34 +++++++ .../components/netgear/translations/id.json | 18 ++++ .../components/netgear/translations/it.json | 34 +++++++ .../components/netgear/translations/nl.json | 34 +++++++ .../components/netgear/translations/no.json | 34 +++++++ .../netgear/translations/pt-BR.json | 28 ++++++ .../components/netgear/translations/ru.json | 34 +++++++ .../netgear/translations/zh-Hant.json | 34 +++++++ .../nfandroidtv/translations/es.json | 7 +- .../nfandroidtv/translations/hu.json | 2 +- .../nfandroidtv/translations/id.json | 10 ++ .../nightscout/translations/hu.json | 2 +- .../nightscout/translations/id.json | 2 +- .../nightscout/translations/no.json | 2 +- .../nmap_tracker/translations/es.json | 1 + .../nmap_tracker/translations/hu.json | 8 +- .../nmap_tracker/translations/id.json | 1 + .../nmap_tracker/translations/nl.json | 1 + .../nmap_tracker/translations/ru.json | 2 +- .../components/notion/translations/ca.json | 13 ++- .../components/notion/translations/de.json | 13 ++- .../components/notion/translations/en.json | 1 + .../components/notion/translations/et.json | 13 ++- .../components/notion/translations/he.json | 12 ++- .../components/notion/translations/hu.json | 13 ++- .../components/notion/translations/it.json | 13 ++- .../components/notion/translations/nl.json | 13 ++- .../components/notion/translations/no.json | 13 ++- .../components/notion/translations/ru.json | 13 ++- .../notion/translations/zh-Hant.json | 13 ++- .../components/nuheat/translations/de.json | 2 +- .../components/nuki/translations/hu.json | 2 +- .../components/nut/translations/hu.json | 2 +- .../components/nws/translations/hu.json | 2 +- .../components/nzbget/translations/hu.json | 2 +- .../components/nzbget/translations/id.json | 2 +- .../ondilo_ico/translations/hu.json | 2 +- .../components/onewire/translations/hu.json | 2 +- .../components/onvif/translations/hu.json | 14 +-- .../components/onvif/translations/id.json | 9 ++ .../opengarage/translations/ca.json | 22 +++++ .../opengarage/translations/de.json | 22 +++++ .../opengarage/translations/en.json | 22 +++++ .../opengarage/translations/es.json | 11 +++ .../opengarage/translations/et.json | 22 +++++ .../opengarage/translations/hu.json | 22 +++++ .../opengarage/translations/it.json | 22 +++++ .../opengarage/translations/nl.json | 22 +++++ .../opengarage/translations/no.json | 22 +++++ .../opengarage/translations/ru.json | 22 +++++ .../opengarage/translations/zh-Hant.json | 22 +++++ .../components/openuv/translations/es.json | 4 + .../components/openuv/translations/nl.json | 4 + .../openweathermap/translations/hu.json | 2 +- .../ovo_energy/translations/ca.json | 2 +- .../ovo_energy/translations/id.json | 2 +- .../components/owntracks/translations/hu.json | 4 +- .../components/ozw/translations/ca.json | 2 +- .../components/ozw/translations/hu.json | 6 +- .../p1_monitor/translations/es.json | 3 +- .../p1_monitor/translations/hu.json | 2 +- .../p1_monitor/translations/id.json | 16 ++++ .../panasonic_viera/translations/hu.json | 4 +- .../philips_js/translations/hu.json | 2 +- .../components/pi_hole/translations/hu.json | 2 +- .../components/picnic/translations/id.json | 5 + .../components/picnic/translations/ko.json | 21 ++++ .../components/plaato/translations/ca.json | 2 +- .../components/plaato/translations/es.json | 2 +- .../components/plaato/translations/hu.json | 6 +- .../components/plaato/translations/nl.json | 2 +- .../components/plant/translations/hu.json | 2 +- .../components/plex/translations/hu.json | 10 +- .../components/plugwise/translations/id.json | 2 +- .../plum_lightpad/translations/ca.json | 2 +- .../components/point/translations/he.json | 3 +- .../components/point/translations/hu.json | 8 +- .../components/point/translations/nl.json | 2 +- .../components/poolsense/translations/hu.json | 2 +- .../components/poolsense/translations/nl.json | 2 +- .../components/powerwall/translations/id.json | 2 +- .../components/profiler/translations/hu.json | 2 +- .../components/profiler/translations/nl.json | 2 +- .../progettihwsw/translations/he.json | 28 +++--- .../progettihwsw/translations/hu.json | 2 +- .../components/prosegur/translations/el.json | 11 +++ .../components/prosegur/translations/es.json | 10 +- .../components/prosegur/translations/id.json | 27 ++++++ .../components/ps4/translations/he.json | 3 + .../components/ps4/translations/hu.json | 4 +- .../pvpc_hourly_pricing/translations/hu.json | 2 +- .../pvpc_hourly_pricing/translations/id.json | 6 +- .../rainforest_eagle/translations/es.json | 7 +- .../rainforest_eagle/translations/hu.json | 2 +- .../rainforest_eagle/translations/id.json | 19 ++++ .../rainmachine/translations/hu.json | 2 +- .../components/renault/translations/ca.json | 2 +- .../components/renault/translations/cs.json | 9 +- .../components/renault/translations/el.json | 9 ++ .../components/renault/translations/es.json | 16 +++- .../components/renault/translations/hu.json | 2 +- .../components/renault/translations/id.json | 26 +++++ .../components/renault/translations/nl.json | 9 +- .../components/rfxtrx/translations/ca.json | 12 ++- .../components/rfxtrx/translations/de.json | 10 ++ .../components/rfxtrx/translations/en.json | 25 +++-- .../components/rfxtrx/translations/es.json | 10 ++ .../components/rfxtrx/translations/et.json | 10 ++ .../components/rfxtrx/translations/hu.json | 12 ++- .../components/rfxtrx/translations/it.json | 10 ++ .../components/rfxtrx/translations/nl.json | 10 ++ .../components/rfxtrx/translations/no.json | 10 ++ .../components/rfxtrx/translations/ru.json | 10 ++ .../rfxtrx/translations/zh-Hant.json | 10 ++ .../components/risco/translations/hu.json | 4 +- .../components/roku/translations/hu.json | 10 +- .../components/roku/translations/id.json | 2 +- .../components/roomba/translations/es.json | 2 +- .../components/roomba/translations/hu.json | 10 +- .../components/roomba/translations/id.json | 10 +- .../components/roon/translations/hu.json | 8 +- .../components/rpi_power/translations/hu.json | 4 +- .../components/rpi_power/translations/nl.json | 2 +- .../ruckus_unleashed/translations/hu.json | 2 +- .../components/samsungtv/translations/bg.json | 7 ++ .../components/samsungtv/translations/ca.json | 1 + .../components/samsungtv/translations/de.json | 1 + .../components/samsungtv/translations/en.json | 4 +- .../components/samsungtv/translations/es.json | 12 ++- .../components/samsungtv/translations/et.json | 1 + .../components/samsungtv/translations/hu.json | 11 ++- .../components/samsungtv/translations/id.json | 13 ++- .../components/samsungtv/translations/it.json | 1 + .../components/samsungtv/translations/nl.json | 1 + .../components/samsungtv/translations/no.json | 1 + .../components/samsungtv/translations/ru.json | 1 + .../samsungtv/translations/zh-Hant.json | 1 + .../screenlogic/translations/hu.json | 2 +- .../screenlogic/translations/id.json | 2 +- .../components/sensor/translations/el.json | 6 +- .../components/sensor/translations/id.json | 20 ++++ .../components/sentry/translations/hu.json | 2 +- .../components/sharkiq/translations/ca.json | 2 +- .../components/shelly/translations/ca.json | 8 +- .../components/shelly/translations/cs.json | 3 +- .../components/shelly/translations/de.json | 8 +- .../components/shelly/translations/en.json | 10 +- .../components/shelly/translations/es.json | 8 +- .../components/shelly/translations/et.json | 8 +- .../components/shelly/translations/he.json | 28 +++++- .../components/shelly/translations/hu.json | 12 ++- .../components/shelly/translations/id.json | 2 + .../components/shelly/translations/it.json | 8 +- .../components/shelly/translations/nl.json | 8 +- .../components/shelly/translations/no.json | 8 +- .../components/shelly/translations/ru.json | 10 +- .../shelly/translations/zh-Hant.json | 8 +- .../shopping_list/translations/hu.json | 2 +- .../components/sia/translations/es.json | 13 ++- .../components/sia/translations/id.json | 50 ++++++++++ .../simplisafe/translations/hu.json | 2 +- .../simplisafe/translations/id.json | 2 +- .../components/sma/translations/hu.json | 2 +- .../components/smappee/translations/hu.json | 6 +- .../components/smappee/translations/id.json | 2 +- .../smartthings/translations/hu.json | 8 +- .../components/smarttub/translations/ca.json | 2 +- .../components/smarttub/translations/hu.json | 4 +- .../components/smarttub/translations/id.json | 1 + .../components/smarttub/translations/ko.json | 3 + .../components/solarlog/translations/hu.json | 2 +- .../components/soma/translations/hu.json | 2 +- .../components/somfy/translations/hu.json | 2 +- .../somfy_mylink/translations/hu.json | 2 +- .../somfy_mylink/translations/id.json | 2 +- .../components/sonarr/translations/hu.json | 2 +- .../components/sonarr/translations/id.json | 2 +- .../components/songpal/translations/hu.json | 2 +- .../components/songpal/translations/id.json | 2 +- .../components/sonos/translations/he.json | 1 + .../components/sonos/translations/hu.json | 2 +- .../components/sonos/translations/id.json | 1 + .../speedtestdotnet/translations/hu.json | 4 +- .../speedtestdotnet/translations/nl.json | 2 +- .../components/spotify/translations/hu.json | 2 +- .../squeezebox/translations/hu.json | 4 +- .../squeezebox/translations/id.json | 2 +- .../components/subaru/translations/ca.json | 2 +- .../surepetcare/translations/ca.json | 20 ++++ .../surepetcare/translations/cs.json | 20 ++++ .../surepetcare/translations/de.json | 20 ++++ .../surepetcare/translations/en.json | 20 ++++ .../surepetcare/translations/es.json | 20 ++++ .../surepetcare/translations/et.json | 20 ++++ .../surepetcare/translations/he.json | 20 ++++ .../surepetcare/translations/hu.json | 20 ++++ .../surepetcare/translations/id.json | 20 ++++ .../surepetcare/translations/it.json | 20 ++++ .../surepetcare/translations/nl.json | 20 ++++ .../surepetcare/translations/no.json | 20 ++++ .../surepetcare/translations/pt-BR.json | 20 ++++ .../surepetcare/translations/ru.json | 20 ++++ .../surepetcare/translations/zh-Hant.json | 20 ++++ .../components/switch/translations/he.json | 15 +++ .../components/switchbot/translations/ca.json | 37 +++++++ .../components/switchbot/translations/cs.json | 14 +++ .../components/switchbot/translations/de.json | 37 +++++++ .../components/switchbot/translations/en.json | 17 ++-- .../components/switchbot/translations/es.json | 35 +++++++ .../components/switchbot/translations/et.json | 37 +++++++ .../components/switchbot/translations/he.json | 33 +++++++ .../components/switchbot/translations/hu.json | 39 ++++++++ .../components/switchbot/translations/id.json | 35 +++++++ .../components/switchbot/translations/it.json | 37 +++++++ .../components/switchbot/translations/nl.json | 37 +++++++ .../components/switchbot/translations/no.json | 37 +++++++ .../components/switchbot/translations/ro.json | 7 ++ .../components/switchbot/translations/ru.json | 37 +++++++ .../switchbot/translations/zh-Hant.json | 37 +++++++ .../switcher_kis/translations/es.json | 13 +++ .../switcher_kis/translations/hu.json | 2 +- .../switcher_kis/translations/id.json | 13 +++ .../switcher_kis/translations/nl.json | 2 +- .../components/syncthru/translations/id.json | 2 +- .../synology_dsm/translations/es.json | 16 +++- .../synology_dsm/translations/hu.json | 11 ++- .../synology_dsm/translations/id.json | 17 +++- .../synology_dsm/translations/it.json | 7 ++ .../synology_dsm/translations/nl.json | 7 ++ .../system_bridge/translations/hu.json | 2 +- .../components/tasmota/translations/hu.json | 4 +- .../tellduslive/translations/he.json | 3 +- .../tellduslive/translations/hu.json | 6 +- .../components/tibber/translations/hu.json | 2 +- .../components/tile/translations/ca.json | 2 +- .../components/toon/translations/he.json | 3 +- .../components/toon/translations/hu.json | 4 +- .../totalconnect/translations/ca.json | 2 +- .../totalconnect/translations/es.json | 2 +- .../totalconnect/translations/hu.json | 2 +- .../totalconnect/translations/id.json | 2 +- .../components/tplink/translations/ca.json | 19 ++++ .../components/tplink/translations/de.json | 19 ++++ .../components/tplink/translations/en.json | 6 +- .../components/tplink/translations/et.json | 19 ++++ .../components/tplink/translations/hu.json | 21 +++- .../components/tplink/translations/it.json | 19 ++++ .../components/tplink/translations/nl.json | 19 ++++ .../components/tplink/translations/no.json | 19 ++++ .../components/tplink/translations/ru.json | 19 ++++ .../tplink/translations/zh-Hant.json | 19 ++++ .../components/traccar/translations/hu.json | 4 +- .../components/tractive/translations/es.json | 11 ++- .../components/tradfri/translations/fi.json | 3 + .../components/tradfri/translations/hu.json | 6 +- .../transmission/translations/hu.json | 4 +- .../components/tuya/translations/af.json | 8 ++ .../components/tuya/translations/ca.json | 79 +++++++++++++++ .../components/tuya/translations/cs.json | 60 ++++++++++++ .../components/tuya/translations/de.json | 79 +++++++++++++++ .../components/tuya/translations/en.json | 74 +++++++++++--- .../components/tuya/translations/es.json | 79 +++++++++++++++ .../components/tuya/translations/et.json | 79 +++++++++++++++ .../components/tuya/translations/fi.json | 17 ++++ .../components/tuya/translations/fr.json | 65 +++++++++++++ .../components/tuya/translations/he.json | 30 ++++++ .../components/tuya/translations/hu.json | 65 +++++++++++++ .../components/tuya/translations/id.json | 65 +++++++++++++ .../components/tuya/translations/it.json | 79 +++++++++++++++ .../components/tuya/translations/ka.json | 37 +++++++ .../components/tuya/translations/ko.json | 65 +++++++++++++ .../components/tuya/translations/lb.json | 59 ++++++++++++ .../components/tuya/translations/nl.json | 78 +++++++++++++++ .../components/tuya/translations/no.json | 65 +++++++++++++ .../components/tuya/translations/pl.json | 65 +++++++++++++ .../components/tuya/translations/pt-BR.json | 17 ++++ .../components/tuya/translations/pt.json | 25 +++++ .../components/tuya/translations/ru.json | 79 +++++++++++++++ .../components/tuya/translations/sl.json | 11 +++ .../components/tuya/translations/sv.json | 17 ++++ .../components/tuya/translations/tr.json | 60 ++++++++++++ .../components/tuya/translations/uk.json | 63 ++++++++++++ .../components/tuya/translations/zh-Hans.json | 71 ++++++++++---- .../components/tuya/translations/zh-Hant.json | 79 +++++++++++++++ .../components/twilio/translations/hu.json | 6 +- .../components/twilio/translations/nl.json | 2 +- .../components/twinkly/translations/hu.json | 2 +- .../components/unifi/translations/hu.json | 14 +-- .../components/unifi/translations/id.json | 2 +- .../components/unifi/translations/ru.json | 2 +- .../components/updater/translations/hu.json | 2 +- .../components/upnp/translations/fi.json | 3 + .../components/upnp/translations/hu.json | 2 +- .../components/upnp/translations/id.json | 2 +- .../uptimerobot/translations/ca.json | 2 +- .../uptimerobot/translations/es.json | 12 +-- .../uptimerobot/translations/id.json | 26 +++++ .../components/vera/translations/hu.json | 12 +-- .../components/verisure/translations/ca.json | 2 +- .../components/verisure/translations/hu.json | 2 +- .../components/vilfo/translations/hu.json | 2 +- .../components/vizio/translations/hu.json | 6 +- .../components/volumio/translations/hu.json | 4 +- .../components/wallbox/translations/es.json | 8 +- .../components/wallbox/translations/id.json | 6 +- .../components/watttime/translations/ca.json | 34 +++++++ .../components/watttime/translations/cs.json | 30 ++++++ .../components/watttime/translations/de.json | 34 +++++++ .../components/watttime/translations/es.json | 34 +++++++ .../components/watttime/translations/et.json | 34 +++++++ .../components/watttime/translations/he.json | 30 ++++++ .../components/watttime/translations/hu.json | 34 +++++++ .../components/watttime/translations/id.json | 26 +++++ .../components/watttime/translations/it.json | 34 +++++++ .../components/watttime/translations/nl.json | 34 +++++++ .../components/watttime/translations/no.json | 34 +++++++ .../components/watttime/translations/ru.json | 34 +++++++ .../watttime/translations/zh-Hant.json | 34 +++++++ .../waze_travel_time/translations/id.json | 1 + .../components/wemo/translations/hu.json | 2 +- .../components/wemo/translations/id.json | 5 + .../components/whirlpool/translations/ca.json | 17 ++++ .../components/whirlpool/translations/cs.json | 17 ++++ .../components/whirlpool/translations/de.json | 17 ++++ .../components/whirlpool/translations/en.json | 7 +- .../components/whirlpool/translations/es.json | 17 ++++ .../components/whirlpool/translations/et.json | 17 ++++ .../components/whirlpool/translations/he.json | 17 ++++ .../components/whirlpool/translations/hu.json | 17 ++++ .../components/whirlpool/translations/id.json | 17 ++++ .../components/whirlpool/translations/it.json | 17 ++++ .../components/whirlpool/translations/nl.json | 17 ++++ .../components/whirlpool/translations/no.json | 17 ++++ .../whirlpool/translations/pt-BR.json | 17 ++++ .../components/whirlpool/translations/ru.json | 17 ++++ .../whirlpool/translations/zh-Hant.json | 17 ++++ .../components/wilight/translations/hu.json | 2 +- .../components/wilight/translations/id.json | 2 +- .../components/withings/translations/ca.json | 2 +- .../components/withings/translations/hu.json | 6 +- .../components/withings/translations/id.json | 2 +- .../components/wled/translations/hu.json | 8 +- .../components/wled/translations/id.json | 2 +- .../components/xbox/translations/hu.json | 2 +- .../xiaomi_aqara/translations/hu.json | 2 +- .../xiaomi_aqara/translations/id.json | 2 +- .../xiaomi_miio/translations/es.json | 7 +- .../xiaomi_miio/translations/hu.json | 4 +- .../xiaomi_miio/translations/id.json | 5 +- .../xiaomi_miio/translations/select.id.json | 9 ++ .../yale_smart_alarm/translations/ca.json | 2 +- .../yale_smart_alarm/translations/el.json | 11 +++ .../yale_smart_alarm/translations/es.json | 12 +-- .../yale_smart_alarm/translations/id.json | 28 ++++++ .../yamaha_musiccast/translations/es.json | 4 + .../yamaha_musiccast/translations/hu.json | 4 +- .../yamaha_musiccast/translations/nl.json | 2 +- .../components/yeelight/translations/es.json | 2 +- .../components/yeelight/translations/hu.json | 8 +- .../components/yeelight/translations/id.json | 4 +- .../components/youless/translations/es.json | 2 +- .../components/youless/translations/hu.json | 2 +- .../components/youless/translations/id.json | 15 +++ .../components/zerproc/translations/hu.json | 2 +- .../components/zerproc/translations/nl.json | 2 +- .../components/zha/translations/es.json | 3 +- .../components/zha/translations/hu.json | 2 +- .../components/zha/translations/id.json | 10 +- .../zoneminder/translations/hu.json | 2 +- .../components/zwave/translations/ca.json | 2 +- .../components/zwave/translations/hu.json | 6 +- .../components/zwave_js/translations/ca.json | 21 +++- .../components/zwave_js/translations/de.json | 17 ++++ .../components/zwave_js/translations/el.json | 1 + .../components/zwave_js/translations/en.json | 5 +- .../components/zwave_js/translations/es.json | 6 ++ .../components/zwave_js/translations/et.json | 17 ++++ .../components/zwave_js/translations/hu.json | 17 +++- .../components/zwave_js/translations/id.json | 15 ++- .../components/zwave_js/translations/it.json | 17 ++++ .../components/zwave_js/translations/nl.json | 11 ++- .../components/zwave_js/translations/no.json | 9 ++ .../components/zwave_js/translations/ru.json | 17 ++++ .../zwave_js/translations/zh-Hant.json | 17 ++++ 728 files changed, 8458 insertions(+), 822 deletions(-) create mode 100644 homeassistant/components/adax/translations/id.json create mode 100644 homeassistant/components/airthings/translations/ca.json create mode 100644 homeassistant/components/airthings/translations/de.json create mode 100644 homeassistant/components/airthings/translations/en.json create mode 100644 homeassistant/components/airthings/translations/et.json create mode 100644 homeassistant/components/airthings/translations/he.json create mode 100644 homeassistant/components/airthings/translations/hu.json create mode 100644 homeassistant/components/airthings/translations/it.json create mode 100644 homeassistant/components/airthings/translations/nl.json create mode 100644 homeassistant/components/airthings/translations/no.json create mode 100644 homeassistant/components/airthings/translations/ru.json create mode 100644 homeassistant/components/airthings/translations/zh-Hant.json create mode 100644 homeassistant/components/airtouch4/translations/id.json create mode 100644 homeassistant/components/ambee/translations/sensor.id.json create mode 100644 homeassistant/components/amberelectric/translations/ca.json create mode 100644 homeassistant/components/amberelectric/translations/de.json create mode 100644 homeassistant/components/amberelectric/translations/et.json create mode 100644 homeassistant/components/amberelectric/translations/hu.json create mode 100644 homeassistant/components/amberelectric/translations/it.json create mode 100644 homeassistant/components/amberelectric/translations/nl.json create mode 100644 homeassistant/components/amberelectric/translations/no.json create mode 100644 homeassistant/components/amberelectric/translations/ru.json create mode 100644 homeassistant/components/amberelectric/translations/zh-Hant.json create mode 100644 homeassistant/components/co2signal/translations/id.json create mode 100644 homeassistant/components/crownstone/translations/ca.json create mode 100644 homeassistant/components/crownstone/translations/cs.json create mode 100644 homeassistant/components/crownstone/translations/de.json create mode 100644 homeassistant/components/crownstone/translations/es.json create mode 100644 homeassistant/components/crownstone/translations/et.json create mode 100644 homeassistant/components/crownstone/translations/he.json create mode 100644 homeassistant/components/crownstone/translations/hu.json create mode 100644 homeassistant/components/crownstone/translations/id.json create mode 100644 homeassistant/components/crownstone/translations/it.json create mode 100644 homeassistant/components/crownstone/translations/ko.json create mode 100644 homeassistant/components/crownstone/translations/nl.json create mode 100644 homeassistant/components/crownstone/translations/no.json create mode 100644 homeassistant/components/crownstone/translations/ru.json create mode 100644 homeassistant/components/crownstone/translations/zh-Hant.json create mode 100644 homeassistant/components/demo/translations/he.json create mode 100644 homeassistant/components/demo/translations/ro.json create mode 100644 homeassistant/components/dlna_dmr/translations/ca.json create mode 100644 homeassistant/components/dlna_dmr/translations/de.json create mode 100644 homeassistant/components/dlna_dmr/translations/et.json create mode 100644 homeassistant/components/dlna_dmr/translations/hu.json create mode 100644 homeassistant/components/dlna_dmr/translations/it.json create mode 100644 homeassistant/components/dlna_dmr/translations/nl.json create mode 100644 homeassistant/components/dlna_dmr/translations/no.json create mode 100644 homeassistant/components/dlna_dmr/translations/ru.json create mode 100644 homeassistant/components/dlna_dmr/translations/zh-Hant.json create mode 100644 homeassistant/components/energy/translations/el.json create mode 100644 homeassistant/components/fjaraskupan/translations/es.json create mode 100644 homeassistant/components/fjaraskupan/translations/id.json create mode 100644 homeassistant/components/flipr/translations/id.json create mode 100644 homeassistant/components/fritz/translations/ko.json create mode 100644 homeassistant/components/homekit/translations/el.json create mode 100644 homeassistant/components/honeywell/translations/id.json create mode 100644 homeassistant/components/iotawatt/translations/id.json create mode 100644 homeassistant/components/kraken/translations/id.json create mode 100644 homeassistant/components/meteoclimatic/translations/id.json create mode 100644 homeassistant/components/modem_callerid/translations/ca.json create mode 100644 homeassistant/components/modem_callerid/translations/cs.json create mode 100644 homeassistant/components/modem_callerid/translations/de.json create mode 100644 homeassistant/components/modem_callerid/translations/es.json create mode 100644 homeassistant/components/modem_callerid/translations/et.json create mode 100644 homeassistant/components/modem_callerid/translations/he.json create mode 100644 homeassistant/components/modem_callerid/translations/hu.json create mode 100644 homeassistant/components/modem_callerid/translations/id.json create mode 100644 homeassistant/components/modem_callerid/translations/it.json create mode 100644 homeassistant/components/modem_callerid/translations/nl.json create mode 100644 homeassistant/components/modem_callerid/translations/no.json create mode 100644 homeassistant/components/modem_callerid/translations/ru.json create mode 100644 homeassistant/components/modem_callerid/translations/zh-Hant.json create mode 100644 homeassistant/components/modern_forms/translations/id.json create mode 100644 homeassistant/components/motioneye/translations/ko.json create mode 100644 homeassistant/components/mutesync/translations/id.json create mode 100644 homeassistant/components/nanoleaf/translations/id.json create mode 100644 homeassistant/components/netgear/translations/ca.json create mode 100644 homeassistant/components/netgear/translations/cs.json create mode 100644 homeassistant/components/netgear/translations/de.json create mode 100644 homeassistant/components/netgear/translations/es.json create mode 100644 homeassistant/components/netgear/translations/et.json create mode 100644 homeassistant/components/netgear/translations/he.json create mode 100644 homeassistant/components/netgear/translations/hu.json create mode 100644 homeassistant/components/netgear/translations/id.json create mode 100644 homeassistant/components/netgear/translations/it.json create mode 100644 homeassistant/components/netgear/translations/nl.json create mode 100644 homeassistant/components/netgear/translations/no.json create mode 100644 homeassistant/components/netgear/translations/pt-BR.json create mode 100644 homeassistant/components/netgear/translations/ru.json create mode 100644 homeassistant/components/netgear/translations/zh-Hant.json create mode 100644 homeassistant/components/nfandroidtv/translations/id.json create mode 100644 homeassistant/components/opengarage/translations/ca.json create mode 100644 homeassistant/components/opengarage/translations/de.json create mode 100644 homeassistant/components/opengarage/translations/en.json create mode 100644 homeassistant/components/opengarage/translations/es.json create mode 100644 homeassistant/components/opengarage/translations/et.json create mode 100644 homeassistant/components/opengarage/translations/hu.json create mode 100644 homeassistant/components/opengarage/translations/it.json create mode 100644 homeassistant/components/opengarage/translations/nl.json create mode 100644 homeassistant/components/opengarage/translations/no.json create mode 100644 homeassistant/components/opengarage/translations/ru.json create mode 100644 homeassistant/components/opengarage/translations/zh-Hant.json create mode 100644 homeassistant/components/p1_monitor/translations/id.json create mode 100644 homeassistant/components/picnic/translations/ko.json create mode 100644 homeassistant/components/prosegur/translations/el.json create mode 100644 homeassistant/components/prosegur/translations/id.json create mode 100644 homeassistant/components/rainforest_eagle/translations/id.json create mode 100644 homeassistant/components/renault/translations/el.json create mode 100644 homeassistant/components/renault/translations/id.json create mode 100644 homeassistant/components/samsungtv/translations/bg.json create mode 100644 homeassistant/components/sia/translations/id.json create mode 100644 homeassistant/components/surepetcare/translations/ca.json create mode 100644 homeassistant/components/surepetcare/translations/cs.json create mode 100644 homeassistant/components/surepetcare/translations/de.json create mode 100644 homeassistant/components/surepetcare/translations/en.json create mode 100644 homeassistant/components/surepetcare/translations/es.json create mode 100644 homeassistant/components/surepetcare/translations/et.json create mode 100644 homeassistant/components/surepetcare/translations/he.json create mode 100644 homeassistant/components/surepetcare/translations/hu.json create mode 100644 homeassistant/components/surepetcare/translations/id.json create mode 100644 homeassistant/components/surepetcare/translations/it.json create mode 100644 homeassistant/components/surepetcare/translations/nl.json create mode 100644 homeassistant/components/surepetcare/translations/no.json create mode 100644 homeassistant/components/surepetcare/translations/pt-BR.json create mode 100644 homeassistant/components/surepetcare/translations/ru.json create mode 100644 homeassistant/components/surepetcare/translations/zh-Hant.json create mode 100644 homeassistant/components/switchbot/translations/ca.json create mode 100644 homeassistant/components/switchbot/translations/cs.json create mode 100644 homeassistant/components/switchbot/translations/de.json create mode 100644 homeassistant/components/switchbot/translations/es.json create mode 100644 homeassistant/components/switchbot/translations/et.json create mode 100644 homeassistant/components/switchbot/translations/he.json create mode 100644 homeassistant/components/switchbot/translations/hu.json create mode 100644 homeassistant/components/switchbot/translations/id.json create mode 100644 homeassistant/components/switchbot/translations/it.json create mode 100644 homeassistant/components/switchbot/translations/nl.json create mode 100644 homeassistant/components/switchbot/translations/no.json create mode 100644 homeassistant/components/switchbot/translations/ro.json create mode 100644 homeassistant/components/switchbot/translations/ru.json create mode 100644 homeassistant/components/switchbot/translations/zh-Hant.json create mode 100644 homeassistant/components/switcher_kis/translations/es.json create mode 100644 homeassistant/components/switcher_kis/translations/id.json create mode 100644 homeassistant/components/tuya/translations/af.json create mode 100644 homeassistant/components/tuya/translations/ca.json create mode 100644 homeassistant/components/tuya/translations/cs.json create mode 100644 homeassistant/components/tuya/translations/de.json create mode 100644 homeassistant/components/tuya/translations/es.json create mode 100644 homeassistant/components/tuya/translations/et.json create mode 100644 homeassistant/components/tuya/translations/fi.json create mode 100644 homeassistant/components/tuya/translations/fr.json create mode 100644 homeassistant/components/tuya/translations/he.json create mode 100644 homeassistant/components/tuya/translations/hu.json create mode 100644 homeassistant/components/tuya/translations/id.json create mode 100644 homeassistant/components/tuya/translations/it.json create mode 100644 homeassistant/components/tuya/translations/ka.json create mode 100644 homeassistant/components/tuya/translations/ko.json create mode 100644 homeassistant/components/tuya/translations/lb.json create mode 100644 homeassistant/components/tuya/translations/nl.json create mode 100644 homeassistant/components/tuya/translations/no.json create mode 100644 homeassistant/components/tuya/translations/pl.json create mode 100644 homeassistant/components/tuya/translations/pt-BR.json create mode 100644 homeassistant/components/tuya/translations/pt.json create mode 100644 homeassistant/components/tuya/translations/ru.json create mode 100644 homeassistant/components/tuya/translations/sl.json create mode 100644 homeassistant/components/tuya/translations/sv.json create mode 100644 homeassistant/components/tuya/translations/tr.json create mode 100644 homeassistant/components/tuya/translations/uk.json create mode 100644 homeassistant/components/tuya/translations/zh-Hant.json create mode 100644 homeassistant/components/uptimerobot/translations/id.json create mode 100644 homeassistant/components/watttime/translations/ca.json create mode 100644 homeassistant/components/watttime/translations/cs.json create mode 100644 homeassistant/components/watttime/translations/de.json create mode 100644 homeassistant/components/watttime/translations/es.json create mode 100644 homeassistant/components/watttime/translations/et.json create mode 100644 homeassistant/components/watttime/translations/he.json create mode 100644 homeassistant/components/watttime/translations/hu.json create mode 100644 homeassistant/components/watttime/translations/id.json create mode 100644 homeassistant/components/watttime/translations/it.json create mode 100644 homeassistant/components/watttime/translations/nl.json create mode 100644 homeassistant/components/watttime/translations/no.json create mode 100644 homeassistant/components/watttime/translations/ru.json create mode 100644 homeassistant/components/watttime/translations/zh-Hant.json create mode 100644 homeassistant/components/whirlpool/translations/ca.json create mode 100644 homeassistant/components/whirlpool/translations/cs.json create mode 100644 homeassistant/components/whirlpool/translations/de.json create mode 100644 homeassistant/components/whirlpool/translations/es.json create mode 100644 homeassistant/components/whirlpool/translations/et.json create mode 100644 homeassistant/components/whirlpool/translations/he.json create mode 100644 homeassistant/components/whirlpool/translations/hu.json create mode 100644 homeassistant/components/whirlpool/translations/id.json create mode 100644 homeassistant/components/whirlpool/translations/it.json create mode 100644 homeassistant/components/whirlpool/translations/nl.json create mode 100644 homeassistant/components/whirlpool/translations/no.json create mode 100644 homeassistant/components/whirlpool/translations/pt-BR.json create mode 100644 homeassistant/components/whirlpool/translations/ru.json create mode 100644 homeassistant/components/whirlpool/translations/zh-Hant.json create mode 100644 homeassistant/components/xiaomi_miio/translations/select.id.json create mode 100644 homeassistant/components/yale_smart_alarm/translations/el.json create mode 100644 homeassistant/components/yale_smart_alarm/translations/id.json create mode 100644 homeassistant/components/youless/translations/id.json diff --git a/homeassistant/components/accuweather/translations/hu.json b/homeassistant/components/accuweather/translations/hu.json index 7b4d270f78b..8b0409d1f22 100644 --- a/homeassistant/components/accuweather/translations/hu.json +++ b/homeassistant/components/accuweather/translations/hu.json @@ -6,7 +6,7 @@ "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", "invalid_api_key": "\u00c9rv\u00e9nytelen API kulcs", - "requests_exceeded": "T\u00fall\u00e9pt\u00e9k az Accuweather API-hoz beny\u00fajtott k\u00e9relmek megengedett sz\u00e1m\u00e1t. Meg kell v\u00e1rnia vagy m\u00f3dos\u00edtania kell az API-kulcsot." + "requests_exceeded": "Accuweather API-hoz enged\u00e9lyezett lek\u00e9r\u00e9sek sz\u00e1ma t\u00fal lett l\u00e9pve. Meg kell v\u00e1rnia m\u00edg a tilt\u00e1s lej\u00e1r vagy m\u00f3dos\u00edtania kell az API-kulcsot." }, "step": { "user": { diff --git a/homeassistant/components/adax/translations/es.json b/homeassistant/components/adax/translations/es.json index 20ecaaa0dd2..985d0ab663f 100644 --- a/homeassistant/components/adax/translations/es.json +++ b/homeassistant/components/adax/translations/es.json @@ -1,10 +1,17 @@ { "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado" + }, + "error": { + "cannot_connect": "No se pudo conectar", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida" + }, "step": { "user": { "data": { "account_id": "ID de la cuenta", - "host": "Anfitri\u00f3n", + "host": "Host", "password": "Contrase\u00f1a" } } diff --git a/homeassistant/components/adax/translations/hu.json b/homeassistant/components/adax/translations/hu.json index 726381a4dd7..94397487c87 100644 --- a/homeassistant/components/adax/translations/hu.json +++ b/homeassistant/components/adax/translations/hu.json @@ -11,7 +11,7 @@ "user": { "data": { "account_id": "Fi\u00f3k ID", - "host": "Gazdag\u00e9p", + "host": "C\u00edm", "password": "Jelsz\u00f3" } } diff --git a/homeassistant/components/adax/translations/id.json b/homeassistant/components/adax/translations/id.json new file mode 100644 index 00000000000..e554913bdc8 --- /dev/null +++ b/homeassistant/components/adax/translations/id.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi" + }, + "error": { + "cannot_connect": "Gagal terhubung", + "invalid_auth": "Autentikasi tidak valid" + }, + "step": { + "user": { + "data": { + "account_id": "ID Akun", + "host": "Host", + "password": "Kata Sandi" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/translations/he.json b/homeassistant/components/adguard/translations/he.json index e2114d19d97..9970667cf40 100644 --- a/homeassistant/components/adguard/translations/he.json +++ b/homeassistant/components/adguard/translations/he.json @@ -7,6 +7,9 @@ "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" }, "step": { + "hassio_confirm": { + "title": "AdGuard Home \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea \u05d4\u05e8\u05d7\u05d1\u05ea Assistant Assistant" + }, "user": { "data": { "host": "\u05de\u05d0\u05e8\u05d7", diff --git a/homeassistant/components/adguard/translations/hu.json b/homeassistant/components/adguard/translations/hu.json index 8a860caf79d..3939de8aea5 100644 --- a/homeassistant/components/adguard/translations/hu.json +++ b/homeassistant/components/adguard/translations/hu.json @@ -9,12 +9,12 @@ }, "step": { "hassio_confirm": { - "description": "Be szeretn\u00e9 \u00e1ll\u00edtani a Home Assistant-ot, hogy csatlakozzon az AdGuard Home-hoz, amelyet a kieg\u00e9sz\u00edt\u0151 biztos\u00edt: {addon} ?", + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani Home Assistant-ot AdGuard Home-hoz val\u00f3 csatlakoz\u00e1shoz, {addon} kieg\u00e9sz\u00edt\u0151 \u00e1ltal?", "title": "Az AdGuard Home a Home Assistant kieg\u00e9sz\u00edt\u0151 seg\u00edts\u00e9g\u00e9vel" }, "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "password": "Jelsz\u00f3", "port": "Port", "ssl": "SSL tan\u00fas\u00edtv\u00e1ny haszn\u00e1lata", diff --git a/homeassistant/components/adguard/translations/id.json b/homeassistant/components/adguard/translations/id.json index d3334997f59..91d06526184 100644 --- a/homeassistant/components/adguard/translations/id.json +++ b/homeassistant/components/adguard/translations/id.json @@ -9,7 +9,7 @@ }, "step": { "hassio_confirm": { - "description": "Ingin mengonfigurasi Home Assistant untuk terhubung ke AdGuard Home yang disediakan oleh add-on Supervisor {addon}?", + "description": "Ingin mengonfigurasi Home Assistant untuk terhubung ke AdGuard Home yang disediakan oleh add-on: {addon}?", "title": "AdGuard Home melalui add-on Home Assistant" }, "user": { diff --git a/homeassistant/components/agent_dvr/translations/hu.json b/homeassistant/components/agent_dvr/translations/hu.json index fff86517073..b8fec1c281d 100644 --- a/homeassistant/components/agent_dvr/translations/hu.json +++ b/homeassistant/components/agent_dvr/translations/hu.json @@ -4,13 +4,13 @@ "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" }, "error": { - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van.", "cannot_connect": "Sikertelen csatlakoz\u00e1s" }, "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "port": "Port" }, "title": "\u00c1ll\u00edtsa be az Agent DVR-t" diff --git a/homeassistant/components/airthings/translations/ca.json b/homeassistant/components/airthings/translations/ca.json new file mode 100644 index 00000000000..c90f9cc6364 --- /dev/null +++ b/homeassistant/components/airthings/translations/ca.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "El compte ja est\u00e0 configurat" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "unknown": "Error inesperat" + }, + "step": { + "user": { + "data": { + "description": "Inicia sessi\u00f3 a {url} per obtenir les credencials", + "id": "ID", + "secret": "Secret" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings/translations/de.json b/homeassistant/components/airthings/translations/de.json new file mode 100644 index 00000000000..7bd5e347776 --- /dev/null +++ b/homeassistant/components/airthings/translations/de.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Konto wurde bereits konfiguriert" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "user": { + "data": { + "description": "Melde dich unter {url} an, um deine Zugangsdaten zu finden", + "id": "ID", + "secret": "Geheimnis" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings/translations/en.json b/homeassistant/components/airthings/translations/en.json new file mode 100644 index 00000000000..a7430dedd81 --- /dev/null +++ b/homeassistant/components/airthings/translations/en.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Account is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "description": "Login at {url} to find your credentials", + "id": "ID", + "secret": "Secret" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings/translations/et.json b/homeassistant/components/airthings/translations/et.json new file mode 100644 index 00000000000..708416f16c1 --- /dev/null +++ b/homeassistant/components/airthings/translations/et.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Konto on juba h\u00e4\u00e4lestatud" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "invalid_auth": "Tuvastamise t\u00f5rge", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "user": { + "data": { + "description": "Logi sisse aadressil {url}, et leida oma mandaadid", + "id": "Kasutajatunnus", + "secret": "Salas\u00f5na" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings/translations/he.json b/homeassistant/components/airthings/translations/he.json new file mode 100644 index 00000000000..c6c0d910ae4 --- /dev/null +++ b/homeassistant/components/airthings/translations/he.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "user": { + "data": { + "id": "\u05de\u05d6\u05d4\u05d4", + "secret": "\u05e1\u05d5\u05d3" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings/translations/hu.json b/homeassistant/components/airthings/translations/hu.json new file mode 100644 index 00000000000..136348d38b4 --- /dev/null +++ b/homeassistant/components/airthings/translations/hu.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van" + }, + "error": { + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" + }, + "step": { + "user": { + "data": { + "description": "Jelentkezzen be a {url} c\u00edmen hogy megkapja hiteles\u00edt\u0151 adatait", + "id": "Azonos\u00edt\u00f3", + "secret": "Titok" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings/translations/it.json b/homeassistant/components/airthings/translations/it.json new file mode 100644 index 00000000000..68a0c152f56 --- /dev/null +++ b/homeassistant/components/airthings/translations/it.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "L'account \u00e8 gi\u00e0 configurato" + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "invalid_auth": "Autenticazione non valida", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "description": "Accedi a {url} per trovare le tue credenziali", + "id": "ID", + "secret": "Segreto" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings/translations/nl.json b/homeassistant/components/airthings/translations/nl.json new file mode 100644 index 00000000000..3f0e753b375 --- /dev/null +++ b/homeassistant/components/airthings/translations/nl.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Account is al geconfigureerd" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "invalid_auth": "Ongeldige authenticatie", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "description": "Log in op {url} om uw inloggegevens te vinden", + "id": "ID", + "secret": "Geheim" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings/translations/no.json b/homeassistant/components/airthings/translations/no.json new file mode 100644 index 00000000000..8609dff2e16 --- /dev/null +++ b/homeassistant/components/airthings/translations/no.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Kontoen er allerede konfigurert" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "description": "Logg p\u00e5 {url} \u00e5 finne legitimasjonen din", + "id": "ID", + "secret": "Hemmelig" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings/translations/ru.json b/homeassistant/components/airthings/translations/ru.json new file mode 100644 index 00000000000..6ec7077860e --- /dev/null +++ b/homeassistant/components/airthings/translations/ru.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u0430 \u0443\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430 \u0432 Home Assistant." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "user": { + "data": { + "description": "\u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u044b\u0435 \u0434\u043b\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0434\u0430\u043d\u043d\u044b\u0435 \u0412\u044b \u043c\u043e\u0436\u0435\u0442\u0435 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u043d\u0430 \u044d\u0442\u043e\u0439 \u0441\u0442\u0440\u0430\u043d\u0438\u0446\u0435: {url}", + "id": "ID", + "secret": "\u0421\u0435\u043a\u0440\u0435\u0442\u043d\u044b\u0439 \u043a\u043e\u0434" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings/translations/zh-Hant.json b/homeassistant/components/airthings/translations/zh-Hant.json new file mode 100644 index 00000000000..0cafeb9886d --- /dev/null +++ b/homeassistant/components/airthings/translations/zh-Hant.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "user": { + "data": { + "description": "\u767b\u5165 {url} \u4ee5\u53d6\u5f97\u6191\u8b49", + "id": "ID", + "secret": "\u5bc6\u78bc" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airtouch4/translations/es.json b/homeassistant/components/airtouch4/translations/es.json index eeae1153555..65616d2a2e9 100644 --- a/homeassistant/components/airtouch4/translations/es.json +++ b/homeassistant/components/airtouch4/translations/es.json @@ -1,12 +1,16 @@ { "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado" + }, "error": { + "cannot_connect": "No se pudo conectar", "no_units": "No se pudo encontrar ning\u00fan grupo AirTouch 4." }, "step": { "user": { "data": { - "host": "Anfitri\u00f3n" + "host": "Host" }, "title": "Configura los detalles de conexi\u00f3n de tu AirTouch 4." } diff --git a/homeassistant/components/airtouch4/translations/hu.json b/homeassistant/components/airtouch4/translations/hu.json index c5d54de31de..861582fad3e 100644 --- a/homeassistant/components/airtouch4/translations/hu.json +++ b/homeassistant/components/airtouch4/translations/hu.json @@ -10,7 +10,7 @@ "step": { "user": { "data": { - "host": "Gazdag\u00e9p" + "host": "C\u00edm" }, "title": "\u00c1ll\u00edtsa be az AirTouch 4 csatlakoz\u00e1si adatait." } diff --git a/homeassistant/components/airtouch4/translations/id.json b/homeassistant/components/airtouch4/translations/id.json new file mode 100644 index 00000000000..c8236f5ec73 --- /dev/null +++ b/homeassistant/components/airtouch4/translations/id.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi" + }, + "error": { + "cannot_connect": "Gagal terhubung" + }, + "step": { + "user": { + "data": { + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/hu.json b/homeassistant/components/airvisual/translations/hu.json index 043a2402283..48d4f5b98eb 100644 --- a/homeassistant/components/airvisual/translations/hu.json +++ b/homeassistant/components/airvisual/translations/hu.json @@ -32,7 +32,7 @@ }, "node_pro": { "data": { - "ip_address": "Hoszt", + "ip_address": "C\u00edm", "password": "Jelsz\u00f3" }, "description": "Szem\u00e9lyes AirVisual egys\u00e9g figyel\u00e9se. A jelsz\u00f3 lek\u00e9rhet\u0151 a k\u00e9sz\u00fcl\u00e9k felhaszn\u00e1l\u00f3i fel\u00fclet\u00e9r\u0151l.", diff --git a/homeassistant/components/alarmdecoder/translations/hu.json b/homeassistant/components/alarmdecoder/translations/hu.json index ace9c7059ca..3c9781672f4 100644 --- a/homeassistant/components/alarmdecoder/translations/hu.json +++ b/homeassistant/components/alarmdecoder/translations/hu.json @@ -14,7 +14,7 @@ "data": { "device_baudrate": "Eszk\u00f6z \u00e1tviteli sebess\u00e9ge", "device_path": "Eszk\u00f6z el\u00e9r\u00e9si \u00fatja", - "host": "Hoszt", + "host": "C\u00edm", "port": "Port" }, "title": "Konfigur\u00e1lja a csatlakoz\u00e1si be\u00e1ll\u00edt\u00e1sokat" diff --git a/homeassistant/components/almond/translations/hu.json b/homeassistant/components/almond/translations/hu.json index 568cd7270de..27932696561 100644 --- a/homeassistant/components/almond/translations/hu.json +++ b/homeassistant/components/almond/translations/hu.json @@ -2,14 +2,14 @@ "config": { "abort": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", - "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rlek, k\u00f6vesd a dokument\u00e1ci\u00f3t.", + "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rem, k\u00f6vesse a dokument\u00e1ci\u00f3t.", "no_url_available": "Nincs el\u00e9rhet\u0151 URL. A hib\u00e1r\u00f3l tov\u00e1bbi inform\u00e1ci\u00f3t [a s\u00fag\u00f3ban]({docs_url}) tal\u00e1lsz.", "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges." }, "step": { "hassio_confirm": { - "description": "Be szeretn\u00e9 \u00e1ll\u00edtani a Home Assistant alkalmaz\u00e1st az Almondhoz val\u00f3 csatlakoz\u00e1shoz, amelyet a Supervisor kieg\u00e9sz\u00edt\u0151 biztos\u00edt: {addon} ?", - "title": "Almond a Supervisor kieg\u00e9sz\u00edt\u0151n kereszt\u00fcl" + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani Home Assistant-ot az Almondhoz val\u00f3 csatlakoz\u00e1shoz, {addon} kieg\u00e9sz\u00edt\u0151 \u00e1ltal?", + "title": "Almond - Home Assistant kieg\u00e9sz\u00edt\u0151 \u00e1ltal" }, "pick_implementation": { "title": "V\u00e1lassz hiteles\u00edt\u00e9si m\u00f3dszert" diff --git a/homeassistant/components/almond/translations/id.json b/homeassistant/components/almond/translations/id.json index 21a627132c4..8e4302220b5 100644 --- a/homeassistant/components/almond/translations/id.json +++ b/homeassistant/components/almond/translations/id.json @@ -8,7 +8,7 @@ }, "step": { "hassio_confirm": { - "description": "Ingin mengonfigurasi Home Assistant untuk terhubung ke Almond yang disediakan oleh add-on Supervisor {addon}?", + "description": "Ingin mengonfigurasi Home Assistant untuk terhubung ke Almond yang disediakan oleh add-on: {addon}?", "title": "Almond melalui add-on Home Assistant" }, "pick_implementation": { diff --git a/homeassistant/components/ambee/translations/es.json b/homeassistant/components/ambee/translations/es.json index de5ce971fa0..7f4f8b75de5 100644 --- a/homeassistant/components/ambee/translations/es.json +++ b/homeassistant/components/ambee/translations/es.json @@ -1,12 +1,26 @@ { "config": { + "abort": { + "reauth_successful": "La reautenticaci\u00f3n se realiz\u00f3 correctamente" + }, + "error": { + "cannot_connect": "No se pudo conectar", + "invalid_api_key": "Clave API no v\u00e1lida" + }, "step": { "reauth_confirm": { "data": { + "api_key": "Clave API", "description": "Vuelva a autenticarse con su cuenta de Ambee." } }, "user": { + "data": { + "api_key": "Clave API", + "latitude": "Latitud", + "longitude": "Longitud", + "name": "Nombre" + }, "description": "Configure Ambee para que se integre con Home Assistant." } } diff --git a/homeassistant/components/ambee/translations/hu.json b/homeassistant/components/ambee/translations/hu.json index 4cf99c596f0..299d97914bc 100644 --- a/homeassistant/components/ambee/translations/hu.json +++ b/homeassistant/components/ambee/translations/hu.json @@ -21,7 +21,7 @@ "longitude": "Hossz\u00fas\u00e1g", "name": "N\u00e9v" }, - "description": "\u00c1ll\u00edtsa be az Ambee-t a Homeassistanttal val\u00f3 integr\u00e1ci\u00f3hoz." + "description": "\u00c1ll\u00edtsa be Ambee-t Home Assistant-tal val\u00f3 integr\u00e1ci\u00f3hoz." } } } diff --git a/homeassistant/components/ambee/translations/id.json b/homeassistant/components/ambee/translations/id.json index ecf627579fe..a5790d95ecd 100644 --- a/homeassistant/components/ambee/translations/id.json +++ b/homeassistant/components/ambee/translations/id.json @@ -10,7 +10,8 @@ "step": { "reauth_confirm": { "data": { - "api_key": "Kunci API" + "api_key": "Kunci API", + "description": "Autentikasi ulang dengan akun Ambee Anda." } }, "user": { @@ -19,7 +20,8 @@ "latitude": "Lintang", "longitude": "Bujur", "name": "Nama" - } + }, + "description": "Siapkan Ambee Anda untuk diintegrasikan dengan Home Assistant." } } } diff --git a/homeassistant/components/ambee/translations/sensor.id.json b/homeassistant/components/ambee/translations/sensor.id.json new file mode 100644 index 00000000000..61bdea468ee --- /dev/null +++ b/homeassistant/components/ambee/translations/sensor.id.json @@ -0,0 +1,9 @@ +{ + "state": { + "ambee__risk": { + "high": "Tinggi", + "low": "Rendah", + "moderate": "Sedang" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/amberelectric/translations/ca.json b/homeassistant/components/amberelectric/translations/ca.json new file mode 100644 index 00000000000..cf9bca64df6 --- /dev/null +++ b/homeassistant/components/amberelectric/translations/ca.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "site": { + "data": { + "site_name": "Nom del lloc", + "site_nmi": "NMI del lloc" + }, + "description": "Selecciona l'NMI del lloc que vulguis afegir", + "title": "Amber Electric" + }, + "user": { + "data": { + "api_token": "Token d'API", + "site_id": "ID del lloc" + }, + "description": "Ves a {api_url} per generar una clau API", + "title": "Amber Electric" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/amberelectric/translations/de.json b/homeassistant/components/amberelectric/translations/de.json new file mode 100644 index 00000000000..2143795f479 --- /dev/null +++ b/homeassistant/components/amberelectric/translations/de.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "site": { + "data": { + "site_name": "Name des Standorts", + "site_nmi": "Standort NMI" + }, + "description": "W\u00e4hle die NMI des Standorts, den du hinzuf\u00fcgen m\u00f6chtest", + "title": "Amber Electric" + }, + "user": { + "data": { + "api_token": "API-Token", + "site_id": "Site-ID" + }, + "description": "Gehe zu {api_url}, um einen API-Schl\u00fcssel zu generieren", + "title": "Amber Electric" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/amberelectric/translations/et.json b/homeassistant/components/amberelectric/translations/et.json new file mode 100644 index 00000000000..05a7e6c6dc2 --- /dev/null +++ b/homeassistant/components/amberelectric/translations/et.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "site": { + "data": { + "site_name": "Saidi nimi", + "site_nmi": "Saidi NMI" + }, + "description": "Vali lisatava saidi NMI", + "title": "Amber Electric" + }, + "user": { + "data": { + "api_token": "API v\u00f5ti", + "site_id": "Saidi ID" + }, + "description": "API-v\u00f5tme saamiseks ava {api_url}.", + "title": "Amber Electric" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/amberelectric/translations/hu.json b/homeassistant/components/amberelectric/translations/hu.json new file mode 100644 index 00000000000..9811f5a5f8f --- /dev/null +++ b/homeassistant/components/amberelectric/translations/hu.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "site": { + "data": { + "site_name": "Hely neve", + "site_nmi": "Hely NMI" + }, + "description": "V\u00e1lassza ki a hozz\u00e1adni k\u00edv\u00e1nt hely NMI-j\u00e9t.", + "title": "Amber Electric" + }, + "user": { + "data": { + "api_token": "API Token", + "site_id": "Hely ID" + }, + "description": "API-kulcs gener\u00e1l\u00e1s\u00e1hoz l\u00e1togasson el ide: {api_url}", + "title": "Amber Electric" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/amberelectric/translations/it.json b/homeassistant/components/amberelectric/translations/it.json new file mode 100644 index 00000000000..5b061561954 --- /dev/null +++ b/homeassistant/components/amberelectric/translations/it.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "site": { + "data": { + "site_name": "Nome del sito", + "site_nmi": "Sito NMI" + }, + "description": "Seleziona l'NMI del sito che desideri aggiungere", + "title": "Amber Electric" + }, + "user": { + "data": { + "api_token": "Token API", + "site_id": "ID sito" + }, + "description": "Vai su {api_url} per generare una chiave API", + "title": "Amber Electric" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/amberelectric/translations/nl.json b/homeassistant/components/amberelectric/translations/nl.json new file mode 100644 index 00000000000..a874c12f283 --- /dev/null +++ b/homeassistant/components/amberelectric/translations/nl.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "site": { + "data": { + "site_name": "Sitenaam", + "site_nmi": "Site NMI" + }, + "description": "Selecteer de NMI van de site die u wilt toevoegen", + "title": "Amber Electric" + }, + "user": { + "data": { + "api_token": "API Token", + "site_id": "Site ID" + }, + "description": "Ga naar {api_url} om een API sleutel aan te maken", + "title": "Amber Electric" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/amberelectric/translations/no.json b/homeassistant/components/amberelectric/translations/no.json new file mode 100644 index 00000000000..90d4bd930b9 --- /dev/null +++ b/homeassistant/components/amberelectric/translations/no.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "site": { + "data": { + "site_name": "Side navn", + "site_nmi": "Nettsted NMI" + }, + "description": "Velg NMI for nettstedet du vil legge til", + "title": "Amber Electric" + }, + "user": { + "data": { + "api_token": "API-token", + "site_id": "Nettsted -ID" + }, + "description": "G\u00e5 til {api_url} \u00e5 generere en API -n\u00f8kkel", + "title": "Amber Electric" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/amberelectric/translations/ru.json b/homeassistant/components/amberelectric/translations/ru.json new file mode 100644 index 00000000000..4b8caee72ee --- /dev/null +++ b/homeassistant/components/amberelectric/translations/ru.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "site": { + "data": { + "site_name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435 \u0443\u0447\u0430\u0441\u0442\u043a\u0430", + "site_nmi": "NMI \u0443\u0447\u0430\u0441\u0442\u043a\u0430" + }, + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 NMI \u0443\u0447\u0430\u0441\u0442\u043a\u0430, \u043a\u043e\u0442\u043e\u0440\u044b\u0439 \u0445\u043e\u0442\u0438\u0442\u0435 \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c", + "title": "Amber Electric" + }, + "user": { + "data": { + "api_token": "\u0422\u043e\u043a\u0435\u043d API", + "site_id": "ID \u0443\u0447\u0430\u0441\u0442\u043a\u0430" + }, + "description": "\u041f\u0435\u0440\u0435\u0439\u0434\u0438\u0442\u0435 \u043f\u043e \u0430\u0434\u0440\u0435\u0441\u0443 {api_url} \u0447\u0442\u043e\u0431\u044b \u0441\u0433\u0435\u043d\u0435\u0440\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u043a\u043b\u044e\u0447 API.", + "title": "Amber Electric" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/amberelectric/translations/zh-Hant.json b/homeassistant/components/amberelectric/translations/zh-Hant.json new file mode 100644 index 00000000000..0af0e5e60bb --- /dev/null +++ b/homeassistant/components/amberelectric/translations/zh-Hant.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "site": { + "data": { + "site_name": "\u4f4d\u5740\u540d\u7a31", + "site_nmi": "\u4f4d\u5740 NMI" + }, + "description": "\u9078\u64c7\u6240\u8981\u65b0\u589e\u7684\u4f4d\u5740 NMI", + "title": "Amber Electric" + }, + "user": { + "data": { + "api_token": "API \u6b0a\u6756", + "site_id": "\u4f4d\u5740 ID" + }, + "description": "\u9023\u7dda\u81f3 {api_url} \u4ee5\u7522\u751f API \u5bc6\u9470", + "title": "Amber Electric" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambiclimate/translations/ca.json b/homeassistant/components/ambiclimate/translations/ca.json index 8e54a222217..234cb1a413c 100644 --- a/homeassistant/components/ambiclimate/translations/ca.json +++ b/homeassistant/components/ambiclimate/translations/ca.json @@ -2,7 +2,7 @@ "config": { "abort": { "access_token": "S'ha produ\u00eft un error desconegut al generat un token d'acc\u00e9s.", - "already_configured": "El compte ja ha estat configurat", + "already_configured": "El compte ja est\u00e0 configurat", "missing_configuration": "El component no est\u00e0 configurat. Mira'n la documentaci\u00f3." }, "create_entry": { diff --git a/homeassistant/components/ambiclimate/translations/hu.json b/homeassistant/components/ambiclimate/translations/hu.json index 3898535c427..597645658d8 100644 --- a/homeassistant/components/ambiclimate/translations/hu.json +++ b/homeassistant/components/ambiclimate/translations/hu.json @@ -3,18 +3,18 @@ "abort": { "access_token": "Ismeretlen hiba a hozz\u00e1f\u00e9r\u00e9si token gener\u00e1l\u00e1s\u00e1ban.", "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van", - "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rlek, k\u00f6vesd a dokument\u00e1ci\u00f3t." + "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rem, k\u00f6vesse a dokument\u00e1ci\u00f3t." }, "create_entry": { "default": "Sikeres hiteles\u00edt\u00e9s" }, "error": { - "follow_link": "K\u00e9rlek, k\u00f6vesd a hivatkoz\u00e1st \u00e9s hiteles\u00edtsd magad miel\u0151tt megnyomod a K\u00fcld\u00e9s gombot", + "follow_link": "K\u00e9rem, k\u00f6vesse a hivatkoz\u00e1st \u00e9s hiteles\u00edtse mag\u00e1t miel\u0151tt megnyomn\u00e1 a K\u00fcld\u00e9s gombot", "no_token": "Nem hiteles\u00edtett Ambiclimate" }, "step": { "auth": { - "description": "K\u00e9rj\u00fck, k\u00f6vesse ezt a [link] ({authorization_url} Author_url}) \u00e9s ** Enged\u00e9lyezze ** a hozz\u00e1f\u00e9r\u00e9st Ambiclimate -fi\u00f3kj\u00e1hoz, majd t\u00e9rjen vissza, \u00e9s nyomja meg az al\u00e1bbi ** K\u00fcld\u00e9s ** gombot.\n (Gy\u0151z\u0151dj\u00f6n meg arr\u00f3l, hogy a megadott visszah\u00edv\u00e1si URL {cb_url})", + "description": "K\u00e9rj\u00fck, k\u00f6vesse ezt a [link]({authorization_url}}) \u00e9s ** Enged\u00e9lyezze ** a hozz\u00e1f\u00e9r\u00e9st Ambiclimate -fi\u00f3kj\u00e1hoz, majd t\u00e9rjen vissza, \u00e9s nyomja meg az al\u00e1bbi ** K\u00fcld\u00e9s ** gombot.\n(Gy\u0151z\u0151dj\u00f6n meg arr\u00f3l, hogy a megadott visszah\u00edv\u00e1si URL {cb_url})", "title": "Ambiclimate hiteles\u00edt\u00e9se" } } diff --git a/homeassistant/components/apple_tv/translations/hu.json b/homeassistant/components/apple_tv/translations/hu.json index 2b6275fc9f5..3d254422baf 100644 --- a/homeassistant/components/apple_tv/translations/hu.json +++ b/homeassistant/components/apple_tv/translations/hu.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured_device": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van.", "backoff": "Az eszk\u00f6z jelenleg nem fogadja el a p\u00e1ros\u00edt\u00e1si k\u00e9relmeket (lehet, hogy t\u00fal sokszor adott meg \u00e9rv\u00e9nytelen PIN-k\u00f3dot), pr\u00f3b\u00e1lkozzon \u00fajra k\u00e9s\u0151bb.", "device_did_not_pair": "A p\u00e1ros\u00edt\u00e1s folyamat\u00e1t az eszk\u00f6zr\u0151l nem pr\u00f3b\u00e1lt\u00e1k befejezni.", "invalid_config": "Az eszk\u00f6z konfigur\u00e1l\u00e1sa nem teljes. K\u00e9rj\u00fck, pr\u00f3b\u00e1lja meg \u00fajra hozz\u00e1adni.", @@ -19,7 +19,7 @@ "flow_title": "{name}", "step": { "confirm": { - "description": "Arra k\u00e9sz\u00fcl, hogy felvegye a (z) {name} nev\u0171 Apple TV-t a Home Assistant programba. \n\n ** A folyamat befejez\u00e9s\u00e9hez t\u00f6bb PIN-k\u00f3dot kell megadnia. ** \n\n Felh\u00edvjuk figyelm\u00e9t, hogy ezzel az integr\u00e1ci\u00f3val * nem fogja tudni kikapcsolni az Apple TV-t. Csak a Home Assistant m\u00e9dialej\u00e1tsz\u00f3ja kapcsol ki!", + "description": "Arra k\u00e9sz\u00fcl, hogy felvegye {name} nev\u0171 Apple TV-t a Home Assistant p\u00e9ld\u00e1ny\u00e1ba. \n\n ** A folyamat befejez\u00e9s\u00e9hez t\u00f6bb PIN-k\u00f3dot kell megadnia. ** \n\nFelh\u00edvjuk figyelm\u00e9t, hogy ezzel az integr\u00e1ci\u00f3val *nem* fogja tudni kikapcsolni az Apple TV-t. Csak a Home Assistant saj\u00e1t m\u00e9dialej\u00e1tsz\u00f3ja kapcsol ki!", "title": "Apple TV sikeresen hozz\u00e1adva" }, "pair_no_pin": { @@ -30,7 +30,7 @@ "data": { "pin": "PIN-k\u00f3d" }, - "description": "P\u00e1ros\u00edt\u00e1sra van sz\u00fcks\u00e9g a(z) {protocol} protokollhoz. K\u00e9rj\u00fck, adja meg a k\u00e9perny\u0151n megjelen\u0151 PIN-k\u00f3dot. A vezet\u0151 null\u00e1kat el kell hagyni, azaz \u00edrja be a 123 \u00e9rt\u00e9ket, ha a megjelen\u00edtett k\u00f3d 0123.", + "description": "P\u00e1ros\u00edt\u00e1sra van sz\u00fcks\u00e9g a(z) {protocol} protokollhoz. K\u00e9rj\u00fck, adja meg a k\u00e9perny\u0151n megjelen\u0151 PIN-k\u00f3dot. A vezet\u0151 null\u00e1kat el kell hagyni, pl. \u00edrja be a 123 \u00e9rt\u00e9ket, ha a megjelen\u00edtett k\u00f3d 0123.", "title": "P\u00e1ros\u00edt\u00e1s" }, "reconfigure": { @@ -45,7 +45,7 @@ "data": { "device_input": "Eszk\u00f6z" }, - "description": "El\u0151sz\u00f6r \u00edrja be a hozz\u00e1adni k\u00edv\u00e1nt Apple TV eszk\u00f6znev\u00e9t (pl. Konyha vagy H\u00e1l\u00f3szoba) vagy IP-c\u00edm\u00e9t. Ha valamilyen eszk\u00f6zt automatikusan tal\u00e1ltak a h\u00e1l\u00f3zat\u00e1n, az al\u00e1bb l\u00e1that\u00f3. \n\n Ha nem l\u00e1tja eszk\u00f6z\u00e9t, vagy b\u00e1rmilyen probl\u00e9m\u00e1t tapasztal, pr\u00f3b\u00e1lja meg megadni az eszk\u00f6z IP-c\u00edm\u00e9t. \n\n {devices}", + "description": "El\u0151sz\u00f6r \u00edrja be a hozz\u00e1adni k\u00edv\u00e1nt Apple TV eszk\u00f6znev\u00e9t (pl. Konyha vagy H\u00e1l\u00f3szoba) vagy IP-c\u00edm\u00e9t. Ha valamilyen eszk\u00f6zt automatikusan tal\u00e1ltak a h\u00e1l\u00f3zat\u00e1n, az al\u00e1bb l\u00e1that\u00f3. \n\nHa nem l\u00e1tja eszk\u00f6z\u00e9t, vagy b\u00e1rmilyen probl\u00e9m\u00e1t tapasztal, pr\u00f3b\u00e1lja meg megadni az eszk\u00f6z IP-c\u00edm\u00e9t. \n\n {devices}", "title": "\u00daj Apple TV be\u00e1ll\u00edt\u00e1sa" } } diff --git a/homeassistant/components/apple_tv/translations/id.json b/homeassistant/components/apple_tv/translations/id.json index 5646b498242..209ecbf8a83 100644 --- a/homeassistant/components/apple_tv/translations/id.json +++ b/homeassistant/components/apple_tv/translations/id.json @@ -16,7 +16,7 @@ "no_usable_service": "Perangkat ditemukan tetapi kami tidak dapat mengidentifikasi berbagai cara untuk membuat koneksi ke perangkat tersebut. Jika Anda terus melihat pesan ini, coba tentukan alamat IP-nya atau mulai ulang Apple TV Anda.", "unknown": "Kesalahan yang tidak diharapkan" }, - "flow_title": "Apple TV: {name}", + "flow_title": "{name}", "step": { "confirm": { "description": "Anda akan menambahkan Apple TV bernama `{name}` ke Home Assistant.\n\n** Untuk menyelesaikan proses, Anda mungkin harus memasukkan kode PIN beberapa kali.**\n\nPerhatikan bahwa Anda *tidak* akan dapat mematikan Apple TV dengan integrasi ini. Hanya pemutar media di Home Assistant yang akan dimatikan!", diff --git a/homeassistant/components/arcam_fmj/translations/hu.json b/homeassistant/components/arcam_fmj/translations/hu.json index 9539ad39bed..c7532f24b76 100644 --- a/homeassistant/components/arcam_fmj/translations/hu.json +++ b/homeassistant/components/arcam_fmj/translations/hu.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van.", "cannot_connect": "Sikertelen csatlakoz\u00e1s" }, "error": { @@ -16,10 +16,10 @@ }, "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "port": "Port" }, - "description": "K\u00e9rj\u00fck, adja meg az eszk\u00f6z gazdag\u00e9pnev\u00e9t vagy IP-c\u00edm\u00e9t." + "description": "K\u00e9rj\u00fck, adja meg az eszk\u00f6z hosztnev\u00e9t vagy c\u00edm\u00e9t" } } }, diff --git a/homeassistant/components/arcam_fmj/translations/id.json b/homeassistant/components/arcam_fmj/translations/id.json index 96b10140948..cee43cbb4e9 100644 --- a/homeassistant/components/arcam_fmj/translations/id.json +++ b/homeassistant/components/arcam_fmj/translations/id.json @@ -5,7 +5,7 @@ "already_in_progress": "Alur konfigurasi sedang berlangsung", "cannot_connect": "Gagal terhubung" }, - "flow_title": "Arcam FMJ di {host}", + "flow_title": "{host}", "step": { "confirm": { "description": "Ingin menambahkan Arcam FMJ `{host}` ke Home Assistant?" diff --git a/homeassistant/components/asuswrt/translations/hu.json b/homeassistant/components/asuswrt/translations/hu.json index 891150c1038..ff64372f1b0 100644 --- a/homeassistant/components/asuswrt/translations/hu.json +++ b/homeassistant/components/asuswrt/translations/hu.json @@ -14,7 +14,7 @@ "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "mode": "M\u00f3d", "name": "N\u00e9v", "password": "Jelsz\u00f3", diff --git a/homeassistant/components/asuswrt/translations/ru.json b/homeassistant/components/asuswrt/translations/ru.json index a2090b1faf6..f77fcb4fb3a 100644 --- a/homeassistant/components/asuswrt/translations/ru.json +++ b/homeassistant/components/asuswrt/translations/ru.json @@ -32,7 +32,7 @@ "step": { "init": { "data": { - "consider_home": "\u0412\u0440\u0435\u043c\u044f \u043e\u0436\u0438\u0434\u0430\u043d\u0438\u044f (\u0432 \u0441\u0435\u043a\u0443\u043d\u0434\u0430\u0445), \u043f\u043e \u0438\u0441\u0442\u0435\u0447\u0435\u043d\u0438\u044e \u043a\u043e\u0442\u043e\u0440\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043f\u043e\u043b\u0443\u0447\u0438\u0442 \u0441\u0442\u0430\u0442\u0443\u0441 \"\u041d\u0435 \u0434\u043e\u043c\u0430\"", + "consider_home": "\u0412\u0440\u0435\u043c\u044f \u043e\u0436\u0438\u0434\u0430\u043d\u0438\u044f (\u0432 \u0441\u0435\u043a\u0443\u043d\u0434\u0430\u0445), \u043f\u043e \u0438\u0441\u0442\u0435\u0447\u0435\u043d\u0438\u044e \u043a\u043e\u0442\u043e\u0440\u043e\u0433\u043e \u0441\u0447\u0438\u0442\u0430\u0442\u044c \u0447\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u0434\u043e\u043c\u0430", "dnsmasq": "\u0420\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u0432 \u043c\u0430\u0440\u0448\u0440\u0443\u0442\u0438\u0437\u0430\u0442\u043e\u0440\u0435 \u0444\u0430\u0439\u043b\u043e\u0432 dnsmasq.leases", "interface": "\u0418\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441, \u0441 \u043a\u043e\u0442\u043e\u0440\u043e\u0433\u043e \u043f\u043e\u043b\u0443\u0447\u0430\u0442\u044c \u0441\u0442\u0430\u0442\u0438\u0441\u0442\u0438\u043a\u0443 (\u043d\u0430\u043f\u0440\u0438\u043c\u0435\u0440, eth0, eth1 \u0438 \u0442. \u0434.)", "require_ip": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0434\u043e\u043b\u0436\u043d\u044b \u0431\u044b\u0442\u044c \u0441 IP-\u0430\u0434\u0440\u0435\u0441\u043e\u043c (\u0434\u043b\u044f \u0440\u0435\u0436\u0438\u043c\u0430 \u0442\u043e\u0447\u043a\u0438 \u0434\u043e\u0441\u0442\u0443\u043f\u0430)", diff --git a/homeassistant/components/atag/translations/hu.json b/homeassistant/components/atag/translations/hu.json index 8c3b4a055b0..aa605923dfd 100644 --- a/homeassistant/components/atag/translations/hu.json +++ b/homeassistant/components/atag/translations/hu.json @@ -10,7 +10,7 @@ "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "port": "Port" }, "title": "Csatlakoz\u00e1s az eszk\u00f6zh\u00f6z" diff --git a/homeassistant/components/august/translations/ca.json b/homeassistant/components/august/translations/ca.json index f0b1fa43c3d..ee9a860b1d1 100644 --- a/homeassistant/components/august/translations/ca.json +++ b/homeassistant/components/august/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat", + "already_configured": "El compte ja est\u00e0 configurat", "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" }, "error": { diff --git a/homeassistant/components/august/translations/hu.json b/homeassistant/components/august/translations/hu.json index aeaef514e71..22e16dda305 100644 --- a/homeassistant/components/august/translations/hu.json +++ b/homeassistant/components/august/translations/hu.json @@ -14,7 +14,7 @@ "data": { "password": "Jelsz\u00f3" }, - "description": "Add meg a(z) {username} jelszav\u00e1t.", + "description": "Adja meg a(z) {username} jelszav\u00e1t.", "title": "August fi\u00f3k \u00fajrahiteles\u00edt\u00e9se" }, "user_validate": { diff --git a/homeassistant/components/auth/translations/fi.json b/homeassistant/components/auth/translations/fi.json index 92e4f03c0f9..ca174d81e6e 100644 --- a/homeassistant/components/auth/translations/fi.json +++ b/homeassistant/components/auth/translations/fi.json @@ -12,6 +12,11 @@ "title": "Ilmoita kertaluonteinen salasana" }, "totp": { + "step": { + "init": { + "title": "M\u00e4\u00e4rit\u00e4 kaksivaiheinen todennus TOTP:n avulla" + } + }, "title": "TOTP" } } diff --git a/homeassistant/components/auth/translations/hu.json b/homeassistant/components/auth/translations/hu.json index 5e7b1835093..47ecf846e0f 100644 --- a/homeassistant/components/auth/translations/hu.json +++ b/homeassistant/components/auth/translations/hu.json @@ -9,11 +9,11 @@ }, "step": { "init": { - "description": "K\u00e9rlek, v\u00e1lassz egyet az \u00e9rtes\u00edt\u00e9si szolg\u00e1ltat\u00e1sok k\u00f6z\u00fcl:", + "description": "K\u00e9rem, v\u00e1lasszon egyet az \u00e9rtes\u00edt\u00e9si szolg\u00e1ltat\u00e1sok k\u00f6z\u00fcl:", "title": "\u00c1ll\u00edtsa be az \u00e9rtes\u00edt\u00e9si \u00f6sszetev\u0151 \u00e1ltal megadott egyszeri jelsz\u00f3t" }, "setup": { - "description": "Az egyszeri jelsz\u00f3 el lett k\u00fcldve a(z) **notify.{notify_service}** szolg\u00e1ltat\u00e1ssal. K\u00e9rlek, add meg al\u00e1bb:", + "description": "Az egyszeri jelsz\u00f3 el lett k\u00fcldve a(z) **notify.{notify_service}** szolg\u00e1ltat\u00e1ssal. K\u00e9rem, adja meg al\u00e1bb:", "title": "Be\u00e1ll\u00edt\u00e1s ellen\u0151rz\u00e9se" } }, @@ -21,11 +21,11 @@ }, "totp": { "error": { - "invalid_code": "\u00c9rv\u00e9nytelen k\u00f3d, pr\u00f3b\u00e1ld \u00fajra. Ha ez a hiba folyamatosan el\u0151fordul, akkor gy\u0151z\u0151dj meg r\u00f3la, hogy a Home Assistant rendszered \u00f3r\u00e1ja pontosan j\u00e1r." + "invalid_code": "\u00c9rv\u00e9nytelen k\u00f3d, pr\u00f3b\u00e1lja \u00fajra. Ha ez a hiba folyamatosan el\u0151fordul, akkor gy\u0151z\u0151dj\u00f6n meg r\u00f3la, hogy a Home Assistant rendszer\u00e9nek \u00f3r\u00e1ja pontosan j\u00e1r." }, "step": { "init": { - "description": "Ahhoz, hogy haszn\u00e1lhasd a k\u00e9tfaktoros hiteles\u00edt\u00e9st id\u0151alap\u00fa egyszeri jelszavakkal, szkenneld be a QR k\u00f3dot a hiteles\u00edt\u00e9si applik\u00e1ci\u00f3ddal. Ha m\u00e9g nincsen, akkor a [Google Hiteles\u00edt\u0151](https://support.google.com/accounts/answer/1066447)t vagy az [Authy](https://authy.com/)-t aj\u00e1nljuk.\n\n{qr_code}\n\nA k\u00f3d beolvas\u00e1sa ut\u00e1n add meg a hat sz\u00e1mjegy\u0171 k\u00f3dot az applik\u00e1ci\u00f3b\u00f3l a telep\u00edt\u00e9s ellen\u0151rz\u00e9s\u00e9hez. Ha probl\u00e9m\u00e1ba \u00fctk\u00f6z\u00f6l a QR k\u00f3d beolvas\u00e1s\u00e1n\u00e1l, akkor ind\u00edts egy k\u00e9zi be\u00e1ll\u00edt\u00e1st a **`{code}`** k\u00f3ddal.", + "description": "Ahhoz, hogy haszn\u00e1lhassa a k\u00e9tfaktoros hiteles\u00edt\u00e9st id\u0151alap\u00fa egyszeri jelszavakkal, szkennelje be a QR k\u00f3dot a hiteles\u00edt\u00e9si applik\u00e1ci\u00f3j\u00e1val. Ha m\u00e9g nincs ilyenje, akkor aj\u00e1nljuk figyelm\u00e9be a [Google Hiteles\u00edt\u0151](https://support.google.com/accounts/answer/1066447)t vagy az [Authy](https://authy.com/)-t.\n\n{qr_code}\n\nA k\u00f3d beolvas\u00e1sa ut\u00e1n adja meg a hat sz\u00e1mjegy\u0171 k\u00f3dot az applik\u00e1ci\u00f3b\u00f3l a telep\u00edt\u00e9s ellen\u0151rz\u00e9s\u00e9hez. Ha probl\u00e9m\u00e1ba \u00fctk\u00f6zne a QR k\u00f3d beolvas\u00e1s\u00e1n\u00e1l, akkor ind\u00edtson egy k\u00e9zi be\u00e1ll\u00edt\u00e1st a **`{code}`** k\u00f3ddal.", "title": "K\u00e9tfaktoros hiteles\u00edt\u00e9s be\u00e1ll\u00edt\u00e1sa TOTP haszn\u00e1lat\u00e1val" } }, diff --git a/homeassistant/components/automation/translations/hu.json b/homeassistant/components/automation/translations/hu.json index 85640af23ba..559523b1b12 100644 --- a/homeassistant/components/automation/translations/hu.json +++ b/homeassistant/components/automation/translations/hu.json @@ -5,5 +5,5 @@ "on": "Be" } }, - "title": "Automatiz\u00e1l\u00e1s" + "title": "Automatizmus" } \ No newline at end of file diff --git a/homeassistant/components/awair/translations/ca.json b/homeassistant/components/awair/translations/ca.json index 2e75af9e744..ac69e06df1e 100644 --- a/homeassistant/components/awair/translations/ca.json +++ b/homeassistant/components/awair/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat", + "already_configured": "El compte ja est\u00e0 configurat", "no_devices_found": "No s'han trobat dispositius a la xarxa", "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" }, diff --git a/homeassistant/components/awair/translations/hu.json b/homeassistant/components/awair/translations/hu.json index f465186a95b..62187becd37 100644 --- a/homeassistant/components/awair/translations/hu.json +++ b/homeassistant/components/awair/translations/hu.json @@ -15,7 +15,7 @@ "access_token": "Hozz\u00e1f\u00e9r\u00e9si token", "email": "E-mail" }, - "description": "Add meg \u00fajra az Awair fejleszt\u0151i hozz\u00e1f\u00e9r\u00e9si tokent." + "description": "Adja meg \u00fajra az Awair fejleszt\u0151i hozz\u00e1f\u00e9r\u00e9si tokent." }, "user": { "data": { diff --git a/homeassistant/components/axis/translations/hu.json b/homeassistant/components/axis/translations/hu.json index 709de5851ad..0cddf167437 100644 --- a/homeassistant/components/axis/translations/hu.json +++ b/homeassistant/components/axis/translations/hu.json @@ -7,15 +7,15 @@ }, "error": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van.", "cannot_connect": "Sikertelen csatlakoz\u00e1s", "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s" }, - "flow_title": "Axis eszk\u00f6z: {name} ({host})", + "flow_title": "{name} ({host})", "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "password": "Jelsz\u00f3", "port": "Port", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" diff --git a/homeassistant/components/azure_devops/translations/ca.json b/homeassistant/components/azure_devops/translations/ca.json index b3eb6e4eb8e..e6811e54078 100644 --- a/homeassistant/components/azure_devops/translations/ca.json +++ b/homeassistant/components/azure_devops/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat", + "already_configured": "El compte ja est\u00e0 configurat", "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" }, "error": { diff --git a/homeassistant/components/azure_devops/translations/id.json b/homeassistant/components/azure_devops/translations/id.json index 42292805b08..bad7c022b93 100644 --- a/homeassistant/components/azure_devops/translations/id.json +++ b/homeassistant/components/azure_devops/translations/id.json @@ -9,7 +9,7 @@ "invalid_auth": "Autentikasi tidak valid", "project_error": "Tidak bisa mendapatkan info proyek." }, - "flow_title": "Azure DevOps: {project_url}", + "flow_title": "{project_url}", "step": { "reauth": { "data": { diff --git a/homeassistant/components/binary_sensor/translations/id.json b/homeassistant/components/binary_sensor/translations/id.json index ac880aa28fa..54dcb66dd7a 100644 --- a/homeassistant/components/binary_sensor/translations/id.json +++ b/homeassistant/components/binary_sensor/translations/id.json @@ -178,6 +178,9 @@ "off": "Tidak ada", "on": "Terdeteksi" }, + "update": { + "on": "Pembaruan tersedia" + }, "vibration": { "off": "Tidak ada", "on": "Terdeteksi" diff --git a/homeassistant/components/binary_sensor/translations/is.json b/homeassistant/components/binary_sensor/translations/is.json index f53316ebd73..bd1ed9c389a 100644 --- a/homeassistant/components/binary_sensor/translations/is.json +++ b/homeassistant/components/binary_sensor/translations/is.json @@ -45,7 +45,7 @@ "on": "Hreyfing" }, "occupancy": { - "off": "Hreinsa", + "off": "Engin vi\u00f0vera", "on": "Uppg\u00f6tva\u00f0" }, "presence": { diff --git a/homeassistant/components/blebox/translations/hu.json b/homeassistant/components/blebox/translations/hu.json index ce51a8a0967..056402ea13f 100644 --- a/homeassistant/components/blebox/translations/hu.json +++ b/homeassistant/components/blebox/translations/hu.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt", - "unsupported_version": "A BleBox eszk\u00f6z elavult firmware-rel rendelkezik. El\u0151sz\u00f6r friss\u00edtsd." + "unsupported_version": "A BleBox eszk\u00f6z elavult firmware-rel rendelkezik. K\u00e9rem, friss\u00edtse el\u0151bb." }, "flow_title": "{name} ({host})", "step": { diff --git a/homeassistant/components/blebox/translations/id.json b/homeassistant/components/blebox/translations/id.json index 2ef604d1bff..f0bb4d34746 100644 --- a/homeassistant/components/blebox/translations/id.json +++ b/homeassistant/components/blebox/translations/id.json @@ -9,7 +9,7 @@ "unknown": "Kesalahan yang tidak diharapkan", "unsupported_version": "Firmware Perangkat BleBox sudah usang. Tingkatkan terlebih dulu." }, - "flow_title": "Perangkat BleBox: {name} ({host})", + "flow_title": "{name} ({host})", "step": { "user": { "data": { diff --git a/homeassistant/components/blink/translations/hu.json b/homeassistant/components/blink/translations/hu.json index 135a2f7ef2e..1822dfbcf50 100644 --- a/homeassistant/components/blink/translations/hu.json +++ b/homeassistant/components/blink/translations/hu.json @@ -14,7 +14,7 @@ "data": { "2fa": "K\u00e9tfaktoros k\u00f3d" }, - "description": "Add meg az e-mail c\u00edmedre k\u00fcld\u00f6tt pint", + "description": "Adja meg az e-mail c\u00edm\u00e9re k\u00fcld\u00f6tt PIN-t", "title": "K\u00e9tfaktoros hiteles\u00edt\u00e9s" }, "user": { diff --git a/homeassistant/components/bmw_connected_drive/translations/ca.json b/homeassistant/components/bmw_connected_drive/translations/ca.json index d6bd70064c3..eb12ac6fc3b 100644 --- a/homeassistant/components/bmw_connected_drive/translations/ca.json +++ b/homeassistant/components/bmw_connected_drive/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat" + "already_configured": "El compte ja est\u00e0 configurat" }, "error": { "cannot_connect": "Ha fallat la connexi\u00f3", diff --git a/homeassistant/components/bond/translations/es.json b/homeassistant/components/bond/translations/es.json index d9918238515..33d3dbb4408 100644 --- a/homeassistant/components/bond/translations/es.json +++ b/homeassistant/components/bond/translations/es.json @@ -9,13 +9,13 @@ "old_firmware": "Firmware antiguo no compatible en el dispositivo Bond - actual\u00edzalo antes de continuar", "unknown": "Error inesperado" }, - "flow_title": "Bond: {bond_id} ({host})", + "flow_title": "{name} ({host})", "step": { "confirm": { "data": { "access_token": "Token de acceso" }, - "description": "\u00bfQuieres configurar {bond_id}?" + "description": "\u00bfQuieres configurar {name}?" }, "user": { "data": { diff --git a/homeassistant/components/bond/translations/hu.json b/homeassistant/components/bond/translations/hu.json index 535d3586b93..c1bac971f4b 100644 --- a/homeassistant/components/bond/translations/hu.json +++ b/homeassistant/components/bond/translations/hu.json @@ -15,12 +15,12 @@ "data": { "access_token": "Hozz\u00e1f\u00e9r\u00e9si token" }, - "description": "Szeretn\u00e9d be\u00e1ll\u00edtani a(z) {name}-t?" + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani: {name}-t?" }, "user": { "data": { "access_token": "Hozz\u00e1f\u00e9r\u00e9si token", - "host": "Hoszt" + "host": "C\u00edm" } } } diff --git a/homeassistant/components/bond/translations/id.json b/homeassistant/components/bond/translations/id.json index 56c633cf31c..00a9dbac45d 100644 --- a/homeassistant/components/bond/translations/id.json +++ b/homeassistant/components/bond/translations/id.json @@ -9,7 +9,7 @@ "old_firmware": "Firmware lama yang tidak didukung pada perangkat Bond - tingkatkan versi sebelum melanjutkan", "unknown": "Kesalahan yang tidak diharapkan" }, - "flow_title": "Bond: {name} ({host})", + "flow_title": "{name} ({host})", "step": { "confirm": { "data": { diff --git a/homeassistant/components/bosch_shc/translations/es.json b/homeassistant/components/bosch_shc/translations/es.json index 6de8f923f5a..df180029c55 100644 --- a/homeassistant/components/bosch_shc/translations/es.json +++ b/homeassistant/components/bosch_shc/translations/es.json @@ -1,6 +1,12 @@ { "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado", + "reauth_successful": "La reautenticaci\u00f3n se realiz\u00f3 correctamente" + }, "error": { + "cannot_connect": "No se pudo conectar", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", "pairing_failed": "El emparejamiento ha fallado; compruebe que el Bosch Smart Home Controller est\u00e1 en modo de emparejamiento (el LED parpadea) y que su contrase\u00f1a es correcta.", "session_error": "Error de sesi\u00f3n: La API devuelve un resultado no correcto.", "unknown": "Error inesperado" @@ -16,7 +22,8 @@ } }, "reauth_confirm": { - "description": "La integraci\u00f3n bosch_shc necesita volver a autentificar su cuenta" + "description": "La integraci\u00f3n bosch_shc necesita volver a autentificar su cuenta", + "title": "Volver a autenticar la integraci\u00f3n" }, "user": { "data": { diff --git a/homeassistant/components/bosch_shc/translations/hu.json b/homeassistant/components/bosch_shc/translations/hu.json index 8b4ebc6be32..cf0090475b7 100644 --- a/homeassistant/components/bosch_shc/translations/hu.json +++ b/homeassistant/components/bosch_shc/translations/hu.json @@ -14,7 +14,7 @@ "flow_title": "Bosch SHC: {name}", "step": { "confirm_discovery": { - "description": "K\u00e9rj\u00fck, addig nyomja a Bosch Smart Home Controller el\u00fcls\u0151 gombj\u00e1t, am\u00edg a LED villogni nem kezd.\n K\u00e9szen \u00e1ll a (z) {model} @ {host} be\u00e1ll\u00edt\u00e1s\u00e1nak folytat\u00e1s\u00e1ra a Home Assistant seg\u00edts\u00e9g\u00e9vel?" + "description": "K\u00e9rj\u00fck, addig nyomja a Bosch Smart Home Controller el\u00fcls\u0151 gombj\u00e1t, am\u00edg a LED villogni nem kezd.\nK\u00e9szen \u00e1ll {model} @ {host} be\u00e1ll\u00edt\u00e1s\u00e1nak folytat\u00e1s\u00e1ra Home Assistant seg\u00edts\u00e9g\u00e9vel?" }, "credentials": { "data": { @@ -27,9 +27,9 @@ }, "user": { "data": { - "host": "Hoszt" + "host": "C\u00edm" }, - "description": "\u00c1ll\u00edtsa be a Bosch intelligens otthoni vez\u00e9rl\u0151t, hogy lehet\u0151v\u00e9 tegye a fel\u00fcgyeletet \u00e9s a vez\u00e9rl\u00e9st a Home Assistant seg\u00edts\u00e9g\u00e9vel.", + "description": "\u00c1ll\u00edtsa be a Bosch intelligens otthoni vez\u00e9rl\u0151t, hogy lehet\u0151v\u00e9 tegye a fel\u00fcgyeletet \u00e9s a vez\u00e9rl\u00e9st Home Assistant seg\u00edts\u00e9g\u00e9vel.", "title": "SHC hiteles\u00edt\u00e9si param\u00e9terek" } } diff --git a/homeassistant/components/braviatv/translations/hu.json b/homeassistant/components/braviatv/translations/hu.json index 5f96af8bad7..00e88955c81 100644 --- a/homeassistant/components/braviatv/translations/hu.json +++ b/homeassistant/components/braviatv/translations/hu.json @@ -14,12 +14,12 @@ "data": { "pin": "PIN-k\u00f3d" }, - "description": "\u00cdrja be a Sony Bravia TV -n l\u00e1that\u00f3 PIN -k\u00f3dot. \n\n Ha a PIN -k\u00f3d nem jelenik meg, t\u00f6r\u00f6lje a Home Assistant regisztr\u00e1ci\u00f3j\u00e1t a t\u00e9v\u00e9n, l\u00e9pjen a k\u00f6vetkez\u0151re: Be\u00e1ll\u00edt\u00e1sok - > H\u00e1l\u00f3zat - > T\u00e1voli eszk\u00f6z be\u00e1ll\u00edt\u00e1sai - > T\u00e1vol\u00edtsa el a t\u00e1voli eszk\u00f6z regisztr\u00e1ci\u00f3j\u00e1t.", + "description": "\u00cdrja be a Sony Bravia TV -n l\u00e1that\u00f3 PIN -k\u00f3dot. \n\nHa a PIN -k\u00f3d nem jelenik meg, t\u00f6r\u00f6lje a Home Assistant regisztr\u00e1ci\u00f3j\u00e1t a t\u00e9v\u00e9n, l\u00e9pjen a k\u00f6vetkez\u0151re: Be\u00e1ll\u00edt\u00e1sok - > H\u00e1l\u00f3zat - > T\u00e1voli eszk\u00f6z be\u00e1ll\u00edt\u00e1sai - > T\u00e1vol\u00edtsa el a t\u00e1voli eszk\u00f6z regisztr\u00e1ci\u00f3j\u00e1t.", "title": "Sony Bravia TV enged\u00e9lyez\u00e9se" }, "user": { "data": { - "host": "Hoszt" + "host": "C\u00edm" }, "description": "\u00c1ll\u00edtsa be a Sony Bravia TV integr\u00e1ci\u00f3t. Ha probl\u00e9m\u00e1i vannak a konfigur\u00e1ci\u00f3val, l\u00e1togasson el a k\u00f6vetkez\u0151 oldalra: https://www.home-assistant.io/integrations/braviatv \n\n Gy\u0151z\u0151dj\u00f6n meg arr\u00f3l, hogy a TV be van kapcsolva.", "title": "Sony Bravia TV" diff --git a/homeassistant/components/broadlink/translations/es.json b/homeassistant/components/broadlink/translations/es.json index e7a35c2876f..d0020c55bca 100644 --- a/homeassistant/components/broadlink/translations/es.json +++ b/homeassistant/components/broadlink/translations/es.json @@ -38,7 +38,7 @@ "user": { "data": { "host": "Host", - "timeout": "Se acab\u00f3 el tiempo" + "timeout": "L\u00edmite de tiempo" }, "title": "Conectarse al dispositivo" } diff --git a/homeassistant/components/broadlink/translations/hu.json b/homeassistant/components/broadlink/translations/hu.json index 8b8dce984e5..3d792f43597 100644 --- a/homeassistant/components/broadlink/translations/hu.json +++ b/homeassistant/components/broadlink/translations/hu.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van.", "cannot_connect": "Sikertelen csatlakoz\u00e1s", "invalid_host": "\u00c9rv\u00e9nytelen hosztn\u00e9v vagy IP-c\u00edm", "not_supported": "Az eszk\u00f6z nem t\u00e1mogatott", @@ -22,22 +22,22 @@ "data": { "name": "N\u00e9v" }, - "title": "V\u00e1lassz egy nevet az eszk\u00f6znek" + "title": "V\u00e1lasszonegy nevet az eszk\u00f6znek" }, "reset": { - "description": "{name} ({model} a {host} c\u00edmen) z\u00e1rolva van. A hiteles\u00edt\u00e9shez \u00e9s a konfigur\u00e1ci\u00f3 befejez\u00e9s\u00e9hez fel kell oldani az eszk\u00f6z z\u00e1rol\u00e1s\u00e1t. Utas\u00edt\u00e1sok:\n 1. Nyisd meg a Broadlink alkalmaz\u00e1st.\n 2. Kattints az eszk\u00f6zre.\n 3. Kattints a jobb fels\u0151 sarokban tal\u00e1lhat\u00f3 `...` gombra.\n 4. G\u00f6rgess az oldal alj\u00e1ra.\n 5. Kapcsold ki a z\u00e1rol\u00e1s\u00e1t.", + "description": "{name} ({model} a {host} c\u00edmen) z\u00e1rolva van. A hiteles\u00edt\u00e9shez \u00e9s a konfigur\u00e1ci\u00f3 befejez\u00e9s\u00e9hez fel kell oldani az eszk\u00f6z z\u00e1rol\u00e1s\u00e1t. Utas\u00edt\u00e1sok:\n 1. Nyissa meg a Broadlink alkalmaz\u00e1st.\n 2. Kattintson az eszk\u00f6zre.\n 3. Kattintson a jobb fels\u0151 sarokban tal\u00e1lhat\u00f3 `...` gombra.\n 4. G\u00f6rgessen az oldal alj\u00e1ra.\n 5. Kapcsolja ki a z\u00e1rol\u00e1s\u00e1t.", "title": "Az eszk\u00f6z felold\u00e1sa" }, "unlock": { "data": { "unlock": "Igen, csin\u00e1ld." }, - "description": "{name} ({model} a {host} c\u00edmen) z\u00e1rolva van. Ez hiteles\u00edt\u00e9si probl\u00e9m\u00e1khoz vezethet a Home Assistantban. Szeretn\u00e9d feloldani?", + "description": "{name} ({model} a {host} c\u00edmen) z\u00e1rolva van. Ez hiteles\u00edt\u00e9si probl\u00e9m\u00e1khoz vezethet Home Assistant-ban. Szeretn\u00e9 feloldani?", "title": "Az eszk\u00f6z felold\u00e1sa (opcion\u00e1lis)" }, "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "timeout": "Id\u0151t\u00fall\u00e9p\u00e9s" }, "title": "Csatlakoz\u00e1s az eszk\u00f6zh\u00f6z" diff --git a/homeassistant/components/brother/translations/hu.json b/homeassistant/components/brother/translations/hu.json index ae950f58f72..9d733e4cda6 100644 --- a/homeassistant/components/brother/translations/hu.json +++ b/homeassistant/components/brother/translations/hu.json @@ -9,11 +9,11 @@ "snmp_error": "Az SNMP szerver ki van kapcsolva, vagy a nyomtat\u00f3 nem t\u00e1mogatott.", "wrong_host": "\u00c9rv\u00e9nytelen \u00e1llom\u00e1sn\u00e9v vagy IP-c\u00edm." }, - "flow_title": "Brother nyomtat\u00f3: {model} {serial_number}", + "flow_title": "{model} {serial_number}", "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "type": "A nyomtat\u00f3 t\u00edpusa" }, "description": "A Brother nyomtat\u00f3 integr\u00e1ci\u00f3j\u00e1nak be\u00e1ll\u00edt\u00e1sa. Ha probl\u00e9m\u00e1id vannak a konfigur\u00e1ci\u00f3val, l\u00e1togass el a k\u00f6vetkez\u0151 oldalra: https://www.home-assistant.io/integrations/brother" @@ -22,7 +22,7 @@ "data": { "type": "A nyomtat\u00f3 t\u00edpusa" }, - "description": "Hozz\u00e1 akarja adni a {model} Brother nyomtat\u00f3t, amelynek sorsz\u00e1ma: {serial_number} `, a Home Assistant-hoz?", + "description": "Hozz\u00e1 szeretn\u00e9 adni a {model} Brother nyomtat\u00f3t, amelynek sorsz\u00e1ma: `{serial_number}`, Home Assistant-hoz?", "title": "Felfedezett Brother nyomtat\u00f3" } } diff --git a/homeassistant/components/brother/translations/id.json b/homeassistant/components/brother/translations/id.json index 5e0b562017c..ed02999710e 100644 --- a/homeassistant/components/brother/translations/id.json +++ b/homeassistant/components/brother/translations/id.json @@ -9,7 +9,7 @@ "snmp_error": "Server SNMP dimatikan atau printer tidak didukung.", "wrong_host": "Nama host atau alamat IP tidak valid." }, - "flow_title": "Printer Brother: {model} {serial_number}", + "flow_title": "{model} {serial_number}", "step": { "user": { "data": { diff --git a/homeassistant/components/bsblan/translations/hu.json b/homeassistant/components/bsblan/translations/hu.json index 51feb8b75d7..60a781cc758 100644 --- a/homeassistant/components/bsblan/translations/hu.json +++ b/homeassistant/components/bsblan/translations/hu.json @@ -10,7 +10,7 @@ "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "passkey": "Jelsz\u00f3 karakterl\u00e1nc", "password": "Jelsz\u00f3", "port": "Port", diff --git a/homeassistant/components/bsblan/translations/id.json b/homeassistant/components/bsblan/translations/id.json index 6e8ac0bd4cb..83fdb88aae4 100644 --- a/homeassistant/components/bsblan/translations/id.json +++ b/homeassistant/components/bsblan/translations/id.json @@ -6,7 +6,7 @@ "error": { "cannot_connect": "Gagal terhubung" }, - "flow_title": "BSB-Lan: {name}", + "flow_title": "{name}", "step": { "user": { "data": { diff --git a/homeassistant/components/buienradar/translations/id.json b/homeassistant/components/buienradar/translations/id.json index 194ecb51c12..a4331fced9f 100644 --- a/homeassistant/components/buienradar/translations/id.json +++ b/homeassistant/components/buienradar/translations/id.json @@ -1,8 +1,15 @@ { "config": { + "abort": { + "already_configured": "Lokasi sudah dikonfigurasi" + }, + "error": { + "already_configured": "Lokasi sudah dikonfigurasi" + }, "step": { "user": { "data": { + "latitude": "Lintang", "longitude": "Bujur" } } diff --git a/homeassistant/components/canary/translations/id.json b/homeassistant/components/canary/translations/id.json index 5f092847b4d..6fdc76feb72 100644 --- a/homeassistant/components/canary/translations/id.json +++ b/homeassistant/components/canary/translations/id.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "Gagal terhubung" }, - "flow_title": "Canary: {name}", + "flow_title": "{name}", "step": { "user": { "data": { diff --git a/homeassistant/components/cast/translations/hu.json b/homeassistant/components/cast/translations/hu.json index 0f64f8de6fe..2d74d3183c8 100644 --- a/homeassistant/components/cast/translations/hu.json +++ b/homeassistant/components/cast/translations/hu.json @@ -11,11 +11,11 @@ "data": { "known_hosts": "Ismert hosztok" }, - "description": "K\u00e9rj\u00fck, add meg a Google Cast konfigur\u00e1ci\u00f3t.", + "description": "Ismert c\u00edmek - A cast eszk\u00f6z\u00f6k hostneveinek vagy IP-c\u00edmeinek vessz\u0151vel elv\u00e1lasztott list\u00e1ja, akkor haszn\u00e1lja, ha az mDNS felder\u00edt\u00e9s nem m\u0171k\u00f6dik.", "title": "Google Cast konfigur\u00e1ci\u00f3" }, "confirm": { - "description": "El szeretn\u00e9d kezdeni a be\u00e1ll\u00edt\u00e1st?" + "description": "El szeretn\u00e9 kezdeni a be\u00e1ll\u00edt\u00e1st?" } } }, @@ -29,7 +29,7 @@ "ignore_cec": "A CEC figyelmen k\u00edv\u00fcl hagy\u00e1sa", "uuid": "Enged\u00e9lyezett UUID-k" }, - "description": "Enged\u00e9lyezett UUID - vessz\u0151vel elv\u00e1lasztott lista a Cast-eszk\u00f6z\u00f6k UUID-j\u00e9b\u0151l, amelyeket hozz\u00e1 lehet adni a Home Assistanthoz. Csak akkor haszn\u00e1lja, ha nem akarja hozz\u00e1adni az \u00f6sszes rendelkez\u00e9sre \u00e1ll\u00f3 cast eszk\u00f6zt.\n CEC figyelmen k\u00edv\u00fcl hagy\u00e1sa - vessz\u0151vel elv\u00e1lasztott Chromecast-lista, amelynek figyelmen k\u00edv\u00fcl kell hagynia a CEC-adatokat az akt\u00edv bemenet meghat\u00e1roz\u00e1s\u00e1hoz. Ezt tov\u00e1bb\u00edtjuk a pychromecast.IGNORE_CEC c\u00edmre.", + "description": "Enged\u00e9lyezett UUID - vessz\u0151vel elv\u00e1lasztott lista a Cast-eszk\u00f6z\u00f6k UUID-j\u00e9b\u0151l, amelyeket hozz\u00e1 lehet adni Home Assistant-hoz. Csak akkor haszn\u00e1lja, ha nem akarja hozz\u00e1adni az \u00f6sszes rendelkez\u00e9sre \u00e1ll\u00f3 cast eszk\u00f6zt.\nCEC figyelmen k\u00edv\u00fcl hagy\u00e1sa - vessz\u0151vel elv\u00e1lasztott Chromecast-lista, amelynek figyelmen k\u00edv\u00fcl kell hagynia a CEC-adatokat az akt\u00edv bemenet meghat\u00e1roz\u00e1s\u00e1hoz. Ezt tov\u00e1bb\u00edtjuk a pychromecast.IGNORE_CEC c\u00edmre.", "title": "Speci\u00e1lis Google Cast-konfigur\u00e1ci\u00f3" }, "basic_options": { diff --git a/homeassistant/components/cast/translations/id.json b/homeassistant/components/cast/translations/id.json index b2c8d515548..b0a54f52897 100644 --- a/homeassistant/components/cast/translations/id.json +++ b/homeassistant/components/cast/translations/id.json @@ -9,10 +9,10 @@ "step": { "config": { "data": { - "known_hosts": "Daftar opsional host yang diketahui jika penemuan mDNS tidak berfungsi." + "known_hosts": "Host yang dikenal" }, - "description": "Masukkan konfigurasi Google Cast.", - "title": "Google Cast" + "description": "Host yang Dikenal - Daftar nama host atau alamat IP perangkat cast, dipisahkan dengan tanda koma, gunakan jika penemuan mDNS tidak berfungsi.", + "title": "Konfigurasi Google Cast" }, "confirm": { "description": "Ingin memulai penyiapan?" diff --git a/homeassistant/components/cast/translations/nl.json b/homeassistant/components/cast/translations/nl.json index fec645993b9..26dc954ef13 100644 --- a/homeassistant/components/cast/translations/nl.json +++ b/homeassistant/components/cast/translations/nl.json @@ -15,7 +15,7 @@ "title": "Google Cast configuratie" }, "confirm": { - "description": "Wil je beginnen met instellen?" + "description": "Wilt u beginnen met instellen?" } } }, diff --git a/homeassistant/components/cert_expiry/translations/hu.json b/homeassistant/components/cert_expiry/translations/hu.json index de459c324df..26f31465115 100644 --- a/homeassistant/components/cert_expiry/translations/hu.json +++ b/homeassistant/components/cert_expiry/translations/hu.json @@ -6,13 +6,13 @@ }, "error": { "connection_refused": "A kapcsolat megtagadva a gazdag\u00e9phez val\u00f3 csatlakoz\u00e1skor", - "connection_timeout": "T\u00fall\u00e9p\u00e9s, amikor ehhez a gazdag\u00e9phez kapcsol\u00f3dik", - "resolve_failed": "Ez a gazdag\u00e9p nem oldhat\u00f3 fel" + "connection_timeout": "Id\u0151t\u00fall\u00e9p\u00e9s, ehhez a c\u00edmhez kapcsol\u00f3d\u00e1skor", + "resolve_failed": "Ez a c\u00edm nem oldhat\u00f3 fel" }, "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "name": "A tan\u00fas\u00edtv\u00e1ny neve", "port": "Port" }, diff --git a/homeassistant/components/climacell/translations/hu.json b/homeassistant/components/climacell/translations/hu.json index 909a5cdf1b5..3454a489455 100644 --- a/homeassistant/components/climacell/translations/hu.json +++ b/homeassistant/components/climacell/translations/hu.json @@ -15,7 +15,7 @@ "longitude": "Hossz\u00fas\u00e1g", "name": "N\u00e9v" }, - "description": "Ha a Sz\u00e9less\u00e9g \u00e9s Hossz\u00fas\u00e1g nincs megadva, akkor a Home Assistant konfigur\u00e1ci\u00f3j\u00e1ban l\u00e9v\u0151 alap\u00e9rtelmezett \u00e9rt\u00e9keket fogjuk haszn\u00e1lni. Minden el\u0151rejelz\u00e9si t\u00edpushoz l\u00e9trej\u00f6n egy entit\u00e1s, de alap\u00e9rtelmez\u00e9s szerint csak az \u00e1ltalad kiv\u00e1lasztottak lesznek enged\u00e9lyezve." + "description": "Ha a Sz\u00e9less\u00e9g \u00e9s Hossz\u00fas\u00e1g nincs megadva, akkor a Home Assistant konfigur\u00e1ci\u00f3j\u00e1ban l\u00e9v\u0151 alap\u00e9rtelmezett \u00e9rt\u00e9keket fogjuk haszn\u00e1lni. Minden el\u0151rejelz\u00e9si t\u00edpushoz l\u00e9trej\u00f6n egy entit\u00e1s, de alap\u00e9rtelmez\u00e9s szerint csak az \u00d6n \u00e1ltal kiv\u00e1lasztottak lesznek enged\u00e9lyezve." } } }, diff --git a/homeassistant/components/cloudflare/translations/he.json b/homeassistant/components/cloudflare/translations/he.json index fb0a20a223b..1f53e94240c 100644 --- a/homeassistant/components/cloudflare/translations/he.json +++ b/homeassistant/components/cloudflare/translations/he.json @@ -29,7 +29,7 @@ }, "zone": { "data": { - "zone": "\u05d0\u05b5\u05d6\u05d5\u05b9\u05e8" + "zone": "\u05d0\u05d6\u05d5\u05e8" } } } diff --git a/homeassistant/components/cloudflare/translations/id.json b/homeassistant/components/cloudflare/translations/id.json index c7878017de3..73f0455273c 100644 --- a/homeassistant/components/cloudflare/translations/id.json +++ b/homeassistant/components/cloudflare/translations/id.json @@ -10,7 +10,7 @@ "invalid_auth": "Autentikasi tidak valid", "invalid_zone": "Zona tidak valid" }, - "flow_title": "Cloudflare: {name}", + "flow_title": "{name}", "step": { "reauth_confirm": { "data": { diff --git a/homeassistant/components/co2signal/translations/es.json b/homeassistant/components/co2signal/translations/es.json index 61f21c21ca3..921dd22a76a 100644 --- a/homeassistant/components/co2signal/translations/es.json +++ b/homeassistant/components/co2signal/translations/es.json @@ -1,10 +1,13 @@ { "config": { "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado", "api_ratelimit": "Se ha superado el l\u00edmite de velocidad de la API", "unknown": "Error inesperado" }, "error": { + "api_ratelimit": "Excedida tasa l\u00edmite del API", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", "unknown": "Error inesperado" }, "step": { @@ -21,6 +24,7 @@ }, "user": { "data": { + "api_key": "Token de acceso", "location": "Obtener datos para" }, "description": "Visite https://co2signal.com/ para solicitar un token." diff --git a/homeassistant/components/co2signal/translations/hu.json b/homeassistant/components/co2signal/translations/hu.json index 00bc19e7b49..77dcbddb8f8 100644 --- a/homeassistant/components/co2signal/translations/hu.json +++ b/homeassistant/components/co2signal/translations/hu.json @@ -2,11 +2,11 @@ "config": { "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "api_ratelimit": "API D\u00edjkorl\u00e1t t\u00fall\u00e9pve", + "api_ratelimit": "API maxim\u00e1lis lek\u00e9r\u00e9ssz\u00e1m t\u00fall\u00e9pve", "unknown": "V\u00e1ratlan hiba" }, "error": { - "api_ratelimit": "API D\u00edjkorl\u00e1t t\u00fall\u00e9pve", + "api_ratelimit": "API maxim\u00e1lis lek\u00e9r\u00e9ssz\u00e1m t\u00fall\u00e9pve", "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", "unknown": "V\u00e1ratlan hiba" }, diff --git a/homeassistant/components/co2signal/translations/id.json b/homeassistant/components/co2signal/translations/id.json new file mode 100644 index 00000000000..76e72a93fd5 --- /dev/null +++ b/homeassistant/components/co2signal/translations/id.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "error": { + "invalid_auth": "Autentikasi tidak valid", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "coordinates": { + "data": { + "latitude": "Lintang", + "longitude": "Bujur" + } + }, + "country": { + "data": { + "country_code": "Kode Negara" + } + }, + "user": { + "data": { + "api_key": "Token Akses" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coolmaster/translations/hu.json b/homeassistant/components/coolmaster/translations/hu.json index d52dba6b4b4..5f6f2eb2824 100644 --- a/homeassistant/components/coolmaster/translations/hu.json +++ b/homeassistant/components/coolmaster/translations/hu.json @@ -12,7 +12,7 @@ "fan_only": "T\u00e1mogaott csak ventil\u00e1tor m\u00f3d(ok)", "heat": "T\u00e1mogatott f\u0171t\u00e9si m\u00f3d(ok)", "heat_cool": "T\u00e1mogatott f\u0171t\u00e9si/h\u0171t\u00e9si m\u00f3d(ok)", - "host": "Hoszt", + "host": "C\u00edm", "off": "Ki lehet kapcsolni" }, "title": "\u00c1ll\u00edtsa be a CoolMasterNet kapcsolat r\u00e9szleteit." diff --git a/homeassistant/components/coronavirus/translations/id.json b/homeassistant/components/coronavirus/translations/id.json index e2626d16abb..f6bef10f8c0 100644 --- a/homeassistant/components/coronavirus/translations/id.json +++ b/homeassistant/components/coronavirus/translations/id.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Layanan sudah dikonfigurasi" + "already_configured": "Layanan sudah dikonfigurasi", + "cannot_connect": "Gagal terhubung" }, "step": { "user": { diff --git a/homeassistant/components/crownstone/translations/ca.json b/homeassistant/components/crownstone/translations/ca.json new file mode 100644 index 00000000000..9de845d87c6 --- /dev/null +++ b/homeassistant/components/crownstone/translations/ca.json @@ -0,0 +1,96 @@ +{ + "config": { + "abort": { + "already_configured": "El compte ja est\u00e0 configurat", + "usb_setup_complete": "S'ha completat la configuraci\u00f3 USB de Crownstone.", + "usb_setup_unsuccessful": "La configuraci\u00f3 USB de Crownstone ha fallat." + }, + "error": { + "account_not_verified": "Compte no verificat. Activa el teu compte mitjan\u00e7ant el correu d'activaci\u00f3 de Crownstone.", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "unknown": "Error inesperat" + }, + "step": { + "usb_config": { + "data": { + "usb_path": "Ruta del dispositiu USB" + }, + "description": "Selecciona el port s\u00e8rie de l'adaptador USB Crownstone o selecciona 'No utilitzar USB' si no vols configurar l'adaptador USB.\n\nBusca un dispositiu amb VID 10C4 i PID EA60.", + "title": "Configuraci\u00f3 de l'adaptador USB Crownstone" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "Ruta del dispositiu USB" + }, + "description": "Introdueix manualment la ruta de l'adaptador USB Crownstone.", + "title": "Ruta manual de l'adaptador USB Crownstone" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Esfera Crownstone" + }, + "description": "Selecciona la esfera Crownstone on es troba l'USB.", + "title": "Esfera Crownstone USB" + }, + "user": { + "data": { + "email": "Correu electr\u00f2nic", + "password": "Contrasenya" + }, + "title": "Compte de Crownstone" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "usb_sphere_option": "Esfera Crownstone on es troba l'USB.", + "use_usb_option": "Utilitza un adaptador USB Crownstone per a la transmissi\u00f3 de dades locals" + } + }, + "usb_config": { + "data": { + "usb_path": "Ruta del dispositiu USB" + }, + "description": "Selecciona el port s\u00e8rie de l'adaptador USB Crownstone.\n\nBusca un dispositiu amb VID 10C4 i PID EA60.", + "title": "Configuraci\u00f3 de l'adaptador USB Crownstone" + }, + "usb_config_option": { + "data": { + "usb_path": "Ruta del dispositiu USB" + }, + "description": "Selecciona el port s\u00e8rie de l'adaptador USB Crownstone.\n\nBusca un dispositiu amb VID 10C4 i PID EA60.", + "title": "Configuraci\u00f3 de l'adaptador USB Crownstone" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "Ruta del dispositiu USB" + }, + "description": "Introdueix manualment la ruta de l'adaptador USB Crownstone.", + "title": "Ruta manual de l'adaptador USB Crownstone" + }, + "usb_manual_config_option": { + "data": { + "usb_manual_path": "Ruta del dispositiu USB" + }, + "description": "Introdueix manualment la ruta de l'adaptador USB Crownstone.", + "title": "Ruta manual de l'adaptador USB Crownstone" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Esfera Crownstone" + }, + "description": "Selecciona la esfera Crownstone on es troba l'USB.", + "title": "Esfera Crownstone USB" + }, + "usb_sphere_config_option": { + "data": { + "usb_sphere": "Esfera Crownstone" + }, + "description": "Selecciona la esfera Crownstone on es troba l'USB.", + "title": "Esfera Crownstone USB" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/crownstone/translations/cs.json b/homeassistant/components/crownstone/translations/cs.json new file mode 100644 index 00000000000..a7aaa1746f9 --- /dev/null +++ b/homeassistant/components/crownstone/translations/cs.json @@ -0,0 +1,31 @@ +{ + "config": { + "step": { + "usb_manual_config": { + "data": { + "usb_manual_path": "Cesta k USB za\u0159\u00edzen\u00ed" + } + }, + "user": { + "data": { + "email": "E-mail", + "password": "Heslo" + } + } + } + }, + "options": { + "step": { + "usb_config_option": { + "data": { + "usb_path": "Cesta k USB za\u0159\u00edzen\u00ed" + } + }, + "usb_manual_config_option": { + "data": { + "usb_manual_path": "Cesta k USB za\u0159\u00edzen\u00ed" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/crownstone/translations/de.json b/homeassistant/components/crownstone/translations/de.json new file mode 100644 index 00000000000..a969d9b2999 --- /dev/null +++ b/homeassistant/components/crownstone/translations/de.json @@ -0,0 +1,96 @@ +{ + "config": { + "abort": { + "already_configured": "Konto wurde bereits konfiguriert", + "usb_setup_complete": "Crownstone USB-Einrichtung abgeschlossen.", + "usb_setup_unsuccessful": "Crownstone USB-Einrichtung war nicht erfolgreich." + }, + "error": { + "account_not_verified": "Konto nicht verifiziert. Bitte aktiviere dein Konto \u00fcber die Aktivierungs-E-Mail von Crownstone.", + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "usb_config": { + "data": { + "usb_path": "USB-Ger\u00e4te-Pfad" + }, + "description": "W\u00e4hle den seriellen Anschluss des Crownstone-USB-Dongles aus, oder w\u00e4hle \"Don't use USB\", wenn du keinen USB-Dongle einrichten m\u00f6chtest.\n\nSuche nach einem Ger\u00e4t mit VID 10C4 und PID EA60.", + "title": "Crownstone USB-Dongle-Konfiguration" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "USB-Ger\u00e4te-Pfad" + }, + "description": "Gib den Pfad eines Crownstone USB-Dongles manuell ein.", + "title": "Crownstone USB-Dongle manueller Pfad" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "W\u00e4hle eine Crownstone Sphere aus, in der sich der USB-Stick befindet.", + "title": "Crownstone USB Sphere" + }, + "user": { + "data": { + "email": "E-Mail", + "password": "Passwort" + }, + "title": "Crownstone-Konto" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "usb_sphere_option": "Crownstone Sphere, wo sich der USB befindet", + "use_usb_option": "Verwende einen Crownstone USB-Dongle f\u00fcr die lokale Daten\u00fcbertragung" + } + }, + "usb_config": { + "data": { + "usb_path": "USB-Ger\u00e4te-Pfad" + }, + "description": "W\u00e4hle den seriellen Anschluss des Crownstone-USB-Dongles.\n\nSuche nach einem Ger\u00e4t mit VID 10C4 und PID EA60.", + "title": "Crownstone USB-Dongle-Konfiguration" + }, + "usb_config_option": { + "data": { + "usb_path": "USB-Ger\u00e4te-Pfad" + }, + "description": "W\u00e4hle den seriellen Anschluss des Crownstone-USB-Dongles.\n\nSuche nach einem Ger\u00e4t mit VID 10C4 und PID EA60.", + "title": "Crownstone USB-Dongle-Konfiguration" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "USB-Ger\u00e4te-Pfad" + }, + "description": "Gib den Pfad eines Crownstone USB-Dongles manuell ein.", + "title": "Crownstone USB-Dongle manueller Pfad" + }, + "usb_manual_config_option": { + "data": { + "usb_manual_path": "USB-Ger\u00e4te-Pfad" + }, + "description": "Gib den Pfad eines Crownstone USB-Dongles manuell ein.", + "title": "Crownstone USB-Dongle manueller Pfad" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "W\u00e4hle eine Crownstone Sphere aus, in der sich der USB-Stick befindet.", + "title": "Crownstone USB Sphere" + }, + "usb_sphere_config_option": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "W\u00e4hle eine Crownstone Sphere aus, in der sich der USB-Stick befindet.", + "title": "Crownstone USB Sphere" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/crownstone/translations/en.json b/homeassistant/components/crownstone/translations/en.json index 09a26b9739c..d6070c90a0f 100644 --- a/homeassistant/components/crownstone/translations/en.json +++ b/homeassistant/components/crownstone/translations/en.json @@ -56,6 +56,13 @@ "description": "Select the serial port of the Crownstone USB dongle.\n\nLook for a device with VID 10C4 and PID EA60.", "title": "Crownstone USB dongle configuration" }, + "usb_config_option": { + "data": { + "usb_path": "USB Device Path" + }, + "description": "Select the serial port of the Crownstone USB dongle.\n\nLook for a device with VID 10C4 and PID EA60.", + "title": "Crownstone USB dongle configuration" + }, "usb_manual_config": { "data": { "usb_manual_path": "USB Device Path" @@ -63,12 +70,26 @@ "description": "Manually enter the path of a Crownstone USB dongle.", "title": "Crownstone USB dongle manual path" }, + "usb_manual_config_option": { + "data": { + "usb_manual_path": "USB Device Path" + }, + "description": "Manually enter the path of a Crownstone USB dongle.", + "title": "Crownstone USB dongle manual path" + }, "usb_sphere_config": { "data": { "usb_sphere": "Crownstone Sphere" }, "description": "Select a Crownstone Sphere where the USB is located.", "title": "Crownstone USB Sphere" + }, + "usb_sphere_config_option": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "Select a Crownstone Sphere where the USB is located.", + "title": "Crownstone USB Sphere" } } } diff --git a/homeassistant/components/crownstone/translations/es.json b/homeassistant/components/crownstone/translations/es.json new file mode 100644 index 00000000000..f9038fb22b4 --- /dev/null +++ b/homeassistant/components/crownstone/translations/es.json @@ -0,0 +1,69 @@ +{ + "config": { + "abort": { + "already_configured": "La cuenta ya ha sido configurada" + }, + "error": { + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "unknown": "Error inesperado" + }, + "step": { + "usb_config": { + "data": { + "usb_path": "Ruta del dispositivo USB" + } + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "Ruta del dispositivo USB" + } + }, + "user": { + "data": { + "email": "Correo electr\u00f3nico", + "password": "Contrase\u00f1a" + } + } + } + }, + "options": { + "step": { + "usb_config": { + "data": { + "usb_path": "Ruta del dispositivo USB" + }, + "description": "Seleccione el puerto serie del dispositivo USB Crownstone.\n\nBusque un dispositivo con VID 10C4 y PID EA60.", + "title": "Configuraci\u00f3n del dispositivo USB Crownstone" + }, + "usb_config_option": { + "data": { + "usb_path": "Ruta del dispositivo USB" + } + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "Ruta del dispositivo USB" + }, + "description": "Introduzca manualmente la ruta de un dispositivo USB Crownstone.", + "title": "Ruta manual del dispositivo USB Crownstone" + }, + "usb_manual_config_option": { + "title": "Ruta manual del dispositivo USB Crownstone" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Esfera Crownstone" + }, + "description": "Selecciona una Esfera Crownstone donde se encuentra el USB.", + "title": "USB de Esfera Crownstone" + }, + "usb_sphere_config_option": { + "data": { + "usb_sphere": "Esfera Crownstone" + }, + "description": "Selecciona una Esfera Crownstone donde se encuentra el USB.", + "title": "USB de Esfera Crownstone" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/crownstone/translations/et.json b/homeassistant/components/crownstone/translations/et.json new file mode 100644 index 00000000000..3a651257e1a --- /dev/null +++ b/homeassistant/components/crownstone/translations/et.json @@ -0,0 +1,96 @@ +{ + "config": { + "abort": { + "already_configured": "Konto on juba h\u00e4\u00e4lestatud", + "usb_setup_complete": "Crownstone'i USB seadistamine on l\u00f5petatud.", + "usb_setup_unsuccessful": "Crownstone'i USB seadistamine nurjus." + }, + "error": { + "account_not_verified": "Konto pole kinnitatud. Aktiveeri oma konto Crownstone'i aktiveerimismeili kaudu.", + "invalid_auth": "Tuvastamine nurjus", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "usb_config": { + "data": { + "usb_path": "USB seadme rada" + }, + "description": "Vali Crownstone'i USB seadme jadaport v\u00f5i vali '\u00c4ra kasuta USB-d' kui ei soovi USB seadet h\u00e4\u00e4lestada. \n\n Otsi seadet mille VID on 10C4 ja PID on EA60.", + "title": "Crownstone'i USB seadme s\u00e4tted" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "USB seadme rada" + }, + "description": "Sisesta k\u00e4sitsi Crownstone'i USBseadme rada.", + "title": "Crownstone'i USB seadme rada" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "Vali Crownstone Sphere kus USB asub.", + "title": "Crownstone USB Sphere" + }, + "user": { + "data": { + "email": "E-posti aadress", + "password": "Salas\u00f5na" + }, + "title": "Crownstone'i konto" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "usb_sphere_option": "Crownstone Sphere kus USB asub.", + "use_usb_option": "Kasuta Crownstone'i USB seadet kohalikuks andmeedastuseks" + } + }, + "usb_config": { + "data": { + "usb_path": "USB seadme rada" + }, + "description": "Vali Crownstone'i USB seadme jadaport. \n\n Otsi seadet mille VID on 10C4 ja PID on EA60.", + "title": "Crownstone'i USB seadme s\u00e4tted" + }, + "usb_config_option": { + "data": { + "usb_path": "USB seadme rada" + }, + "description": "Vali Crownstone'i USB seadme jadaport. \n\n Otsi seadet mille VID on 10C4 ja PID on EA60.", + "title": "Crownstone'i USB seadme s\u00e4tted" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "USB seadme rada" + }, + "description": "Sisesta k\u00e4sitsi Crownstone'i USBseadme rada.", + "title": "Crownstone'i USB seadme rada" + }, + "usb_manual_config_option": { + "data": { + "usb_manual_path": "USB seadme rada" + }, + "description": "Sisesta k\u00e4sitsi Crownstone'i USBseadme rada.", + "title": "Crownstone'i USB seadme rada" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "Vali Crownstone Sphere kus USB asub.", + "title": "Crownstone USB Sphere" + }, + "usb_sphere_config_option": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "Vali Crownstone Sphere kus USB asub.", + "title": "Crownstone USB Sphere" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/crownstone/translations/he.json b/homeassistant/components/crownstone/translations/he.json new file mode 100644 index 00000000000..af11b65839b --- /dev/null +++ b/homeassistant/components/crownstone/translations/he.json @@ -0,0 +1,53 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "usb_config": { + "data": { + "usb_path": "\u05e0\u05ea\u05d9\u05d1 \u05d4\u05ea\u05e7\u05df USB" + } + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "\u05e0\u05ea\u05d9\u05d1 \u05d4\u05ea\u05e7\u05df USB" + } + }, + "user": { + "data": { + "email": "\u05d3\u05d5\u05d0\"\u05dc", + "password": "\u05e1\u05d9\u05e1\u05de\u05d4" + } + } + } + }, + "options": { + "step": { + "usb_config": { + "data": { + "usb_path": "\u05e0\u05ea\u05d9\u05d1 \u05d4\u05ea\u05e7\u05df USB" + } + }, + "usb_config_option": { + "data": { + "usb_path": "\u05e0\u05ea\u05d9\u05d1 \u05d4\u05ea\u05e7\u05df USB" + } + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "\u05e0\u05ea\u05d9\u05d1 \u05d4\u05ea\u05e7\u05df USB" + } + }, + "usb_manual_config_option": { + "data": { + "usb_manual_path": "\u05e0\u05ea\u05d9\u05d1 \u05d4\u05ea\u05e7\u05df USB" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/crownstone/translations/hu.json b/homeassistant/components/crownstone/translations/hu.json new file mode 100644 index 00000000000..2c2a2e34fe1 --- /dev/null +++ b/homeassistant/components/crownstone/translations/hu.json @@ -0,0 +1,96 @@ +{ + "config": { + "abort": { + "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van", + "usb_setup_complete": "A Crownstone USB be\u00e1ll\u00edt\u00e1sa befejez\u0151d\u00f6tt.", + "usb_setup_unsuccessful": "A Crownstone USB be\u00e1ll\u00edt\u00e1sa sikertelen volt." + }, + "error": { + "account_not_verified": "Nem ellen\u0151rz\u00f6tt fi\u00f3k. K\u00e9rj\u00fck, aktiv\u00e1lja fi\u00f3kj\u00e1t a Crownstone-t\u00f3l kapott aktiv\u00e1l\u00f3 e-mailben.", + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" + }, + "step": { + "usb_config": { + "data": { + "usb_path": "USB eszk\u00f6z el\u00e9r\u00e9si \u00fat" + }, + "description": "V\u00e1lassza ki a Crownstone USB kulcs soros portj\u00e1t, vagy v\u00e1lassza 'Ne haszn\u00e1ljon USB-t' ha nem szerenke egy USB kulcsot be\u00e1ll\u00edtani most.\n\nKeressen egy VID 10C4 \u00e9s PID EA60 azonos\u00edt\u00f3val rendelkez\u0151 eszk\u00f6zt.", + "title": "Crownstone USB kulcs konfigur\u00e1ci\u00f3" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "USB eszk\u00f6z el\u00e9r\u00e9si \u00fat" + }, + "description": "Adja meg manu\u00e1lisan a Crownstone USB kulcs \u00fatvonal\u00e1t.", + "title": "A Crownstone USB kulcs manu\u00e1lis el\u00e9r\u00e9si \u00fatja" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "V\u00e1lasszon egy Crownstone Sphere-t, ahol az USB tal\u00e1lhat\u00f3.", + "title": "Crownstone USB Sphere" + }, + "user": { + "data": { + "email": "E-mail", + "password": "Jelsz\u00f3" + }, + "title": "Crownstone fi\u00f3k" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "usb_sphere_option": "Crownstone Sphere, ahol az USB kulcs tal\u00e1lhat\u00f3", + "use_usb_option": "Crownstone USB-kulcs haszn\u00e1lata a helyi adat\u00e1tvitelhez" + } + }, + "usb_config": { + "data": { + "usb_path": "USB eszk\u00f6z el\u00e9r\u00e9si \u00fat" + }, + "description": "V\u00e1lassza ki a Crownstone USB kulcs soros portj\u00e1t.\n\nKeressen egy VID 10C4 \u00e9s PID EA60 azonos\u00edt\u00f3val rendelkez\u0151 eszk\u00f6zt.", + "title": "Crownstone USB kulcs konfigur\u00e1ci\u00f3" + }, + "usb_config_option": { + "data": { + "usb_path": "USB eszk\u00f6z el\u00e9r\u00e9si \u00fat" + }, + "description": "V\u00e1lassza ki a Crownstone USB kulcs soros portj\u00e1t.\n\nKeressen egy VID 10C4 \u00e9s PID EA60 azonos\u00edt\u00f3val rendelkez\u0151 eszk\u00f6zt.", + "title": "Crownstone USB kulcs konfigur\u00e1ci\u00f3" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "USB eszk\u00f6z el\u00e9r\u00e9si \u00fat" + }, + "description": "Adja meg manu\u00e1lisan a Crownstone USB kulcs \u00fatvonal\u00e1t.", + "title": "A Crownstone USB kulcs manu\u00e1lis el\u00e9r\u00e9si \u00fatja" + }, + "usb_manual_config_option": { + "data": { + "usb_manual_path": "USB eszk\u00f6z el\u00e9r\u00e9si \u00fat" + }, + "description": "Adja meg manu\u00e1lisan a Crownstone USB kulcs \u00fatvonal\u00e1t.", + "title": "A Crownstone USB kulcs manu\u00e1lis el\u00e9r\u00e9si \u00fatja" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "V\u00e1lasszon egy Crownstone Sphere-t, ahol az USB tal\u00e1lhat\u00f3.", + "title": "Crownstone USB Sphere" + }, + "usb_sphere_config_option": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "V\u00e1lasszon egy Crownstone Sphere-t, ahol az USB tal\u00e1lhat\u00f3.", + "title": "Crownstone USB Sphere" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/crownstone/translations/id.json b/homeassistant/components/crownstone/translations/id.json new file mode 100644 index 00000000000..5bd28168d9a --- /dev/null +++ b/homeassistant/components/crownstone/translations/id.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "Akun sudah dikonfigurasi" + }, + "error": { + "invalid_auth": "Autentikasi tidak valid", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "usb_config": { + "data": { + "usb_path": "Jalur Perangkat USB" + } + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "Jalur Perangkat USB" + } + }, + "user": { + "data": { + "email": "Email", + "password": "Kata Sandi" + } + } + } + }, + "options": { + "step": { + "usb_manual_config_option": { + "data": { + "usb_manual_path": "Jalur Perangkat USB" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/crownstone/translations/it.json b/homeassistant/components/crownstone/translations/it.json new file mode 100644 index 00000000000..1fb43e75684 --- /dev/null +++ b/homeassistant/components/crownstone/translations/it.json @@ -0,0 +1,96 @@ +{ + "config": { + "abort": { + "already_configured": "L'account \u00e8 gi\u00e0 configurato", + "usb_setup_complete": "Configurazione USB Crownstone completata.", + "usb_setup_unsuccessful": "La configurazione USB di Crownstone non ha avuto successo." + }, + "error": { + "account_not_verified": "Account non verificato. Attiva il tuo account tramite l'e-mail di attivazione di Crownstone.", + "invalid_auth": "Autenticazione non valida", + "unknown": "Errore imprevisto" + }, + "step": { + "usb_config": { + "data": { + "usb_path": "Percorso del dispositivo USB" + }, + "description": "Seleziona la porta seriale della chiavetta USB Crownstone. \n\nCerca un dispositivo con VID 10C4 e PID EA60.", + "title": "Configurazione della chiavetta USB Crownstone" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "Percorso del dispositivo USB" + }, + "description": "Immettere manualmente il percorso di una chiavetta USB Crownstone.", + "title": "Percorso manuale della chiavetta USB Crownstone" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Sfera Crownstone" + }, + "description": "Seleziona una sfera di Crownstone dove si trova l'USB.", + "title": "Sfera USB Crownstone" + }, + "user": { + "data": { + "email": "E-mail", + "password": "Password" + }, + "title": "Account Crownstone" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "usb_sphere_option": "Sfera di Crownstone dove si trova l'USB", + "use_usb_option": "Utilizzare una chiavetta USB Crownstone per la trasmissione locale dei dati" + } + }, + "usb_config": { + "data": { + "usb_path": "Percorso del dispositivo USB" + }, + "description": "Seleziona la porta seriale della chiavetta USB Crownstone. \n\nCerca un dispositivo con VID 10C4 e PID EA60.", + "title": "Configurazione della chiavetta USB Crownstone" + }, + "usb_config_option": { + "data": { + "usb_path": "Percorso del dispositivo USB" + }, + "description": "Seleziona la porta seriale della chiavetta USB Crownstone. \n\nCerca un dispositivo con VID 10C4 e PID EA60.", + "title": "Configurazione della chiavetta USB Crownstone" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "Percorso del dispositivo USB" + }, + "description": "Immettere manualmente il percorso di una chiavetta USB Crownstone.", + "title": "Percorso manuale della chiavetta USB Crownstone" + }, + "usb_manual_config_option": { + "data": { + "usb_manual_path": "Percorso del dispositivo USB" + }, + "description": "Immettere manualmente il percorso di una chiavetta USB Crownstone.", + "title": "Percorso manuale della chiavetta USB Crownstone" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Sfera Crownstone" + }, + "description": "Seleziona una sfera di Crownstone dove si trova l'USB.", + "title": "Sfera USB Crownstone" + }, + "usb_sphere_config_option": { + "data": { + "usb_sphere": "Sfera Crownstone" + }, + "description": "Seleziona una sfera Crownstone in cui si trova l'USB.", + "title": "Sfera USB Crownstone" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/crownstone/translations/ko.json b/homeassistant/components/crownstone/translations/ko.json new file mode 100644 index 00000000000..aadd2d3da42 --- /dev/null +++ b/homeassistant/components/crownstone/translations/ko.json @@ -0,0 +1,16 @@ +{ + "options": { + "step": { + "usb_config_option": { + "data": { + "usb_path": "USB \uc7a5\uce58 \uacbd\ub85c" + } + }, + "usb_manual_config_option": { + "data": { + "usb_manual_path": "USB \uc7a5\uce58 \uacbd\ub85c" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/crownstone/translations/nl.json b/homeassistant/components/crownstone/translations/nl.json new file mode 100644 index 00000000000..1da12c8f841 --- /dev/null +++ b/homeassistant/components/crownstone/translations/nl.json @@ -0,0 +1,96 @@ +{ + "config": { + "abort": { + "already_configured": "Account is al geconfigureerd", + "usb_setup_complete": "Crownstone USB installatie voltooid.", + "usb_setup_unsuccessful": "Crownstone USB installatie is mislukt." + }, + "error": { + "account_not_verified": "Account niet geverifieerd. Gelieve uw account te activeren via de activeringsmail van Crownstone.", + "invalid_auth": "Ongeldige authenticatie", + "unknown": "Onverwachte fout" + }, + "step": { + "usb_config": { + "data": { + "usb_path": "USB-apparaatpad" + }, + "description": "Selecteer de seri\u00eble poort van de Crownstone USB dongle, of selecteer 'Don't use USB' als u geen USB dongle wilt instellen.\n\nZoek naar een apparaat met VID 10C4 en PID EA60.", + "title": "Crownstone USB dongle configuratie" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "USB-apparaatpad" + }, + "description": "Voer handmatig het pad van een Crownstone USB dongle in.", + "title": "Crownstone USB-dongle handmatig pad" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "Selecteer een Crownstone Sphere waar de USB zich bevindt.", + "title": "Crownstone USB Sphere" + }, + "user": { + "data": { + "email": "E-mail", + "password": "Wachtwoord" + }, + "title": "Crownstone account" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "usb_sphere_option": "Crownstone Sphere waar de USB zich bevindt", + "use_usb_option": "Gebruik een Crownstone USB-dongle voor lokale gegevensoverdracht" + } + }, + "usb_config": { + "data": { + "usb_path": "USB-apparaatpad" + }, + "description": "Selecteer de seri\u00eble poort van de Crownstone USB dongle.\n\nZoek naar een apparaat met VID 10C4 en PID EA60.", + "title": "Crownstone USB dongle configuratie" + }, + "usb_config_option": { + "data": { + "usb_path": "USB-apparaatpad" + }, + "description": "Selecteer de seri\u00eble poort van de Crownstone USB dongle.\n\nZoek naar een apparaat met VID 10C4 en PID EA60.", + "title": "Crownstone USB dongle configuratie" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "USB-apparaatpad" + }, + "description": "Voer handmatig het pad van een Crownstone USB dongle in.", + "title": "Crownstone USB-dongle handmatig pad" + }, + "usb_manual_config_option": { + "data": { + "usb_manual_path": "USB-apparaatpad" + }, + "description": "Voer handmatig het pad van een Crownstone USB dongle in.", + "title": "Crownstone USB-dongle handmatig pad" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "Selecteer een Crownstone Sphere waar de USB zich bevindt.", + "title": "Crownstone USB Sphere" + }, + "usb_sphere_config_option": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "Selecteer een Crownstone Sphere waar de USB zich bevindt.", + "title": "Crownstone USB Sphere" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/crownstone/translations/no.json b/homeassistant/components/crownstone/translations/no.json new file mode 100644 index 00000000000..88f3578a9a4 --- /dev/null +++ b/homeassistant/components/crownstone/translations/no.json @@ -0,0 +1,96 @@ +{ + "config": { + "abort": { + "already_configured": "Kontoen er allerede konfigurert", + "usb_setup_complete": "Crownstone USB -oppsett fullf\u00f8rt.", + "usb_setup_unsuccessful": "Crownstone USB -oppsett mislyktes." + }, + "error": { + "account_not_verified": "Kontoen er ikke bekreftet. Vennligst aktiver kontoen din via aktiverings -e -posten fra Crownstone.", + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, + "step": { + "usb_config": { + "data": { + "usb_path": "USB enhetsbane" + }, + "description": "Velg den serielle porten p\u00e5 Crownstone USB -dongelen, eller velg 'Ikke bruk USB' hvis du ikke vil konfigurere en USB -dongle. \n\n Se etter en enhet med VID 10C4 og PID EA60.", + "title": "Crownstone USB -dongle -konfigurasjon" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "USB enhetsbane" + }, + "description": "Skriv inn banen til en Crownstone USB -dongle manuelt.", + "title": "Crownstone USB -dongle manuell bane" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone USB Sphere" + }, + "description": "Velg en Crownstone Sphere der USB -en er plassert.", + "title": "Crownstone USB Sphere" + }, + "user": { + "data": { + "email": "E-post", + "password": "Passord" + }, + "title": "Crownstone -konto" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "usb_sphere_option": "Velg en Crownstone Sphere der USB -en er plassert.", + "use_usb_option": "Bruk en Crownstone USB -dongle for lokal dataoverf\u00f8ring" + } + }, + "usb_config": { + "data": { + "usb_path": "USB enhetsbane" + }, + "description": "Velg serieporten til Crownstone USB -dongelen. \n\n Se etter en enhet med VID 10C4 og PID EA60.", + "title": "Crownstone USB -dongle -konfigurasjon" + }, + "usb_config_option": { + "data": { + "usb_path": "USB enhetsbane" + }, + "description": "Velg serieporten til Crownstone USB -dongelen. \n\n Se etter en enhet med VID 10C4 og PID EA60.", + "title": "Crownstone USB -dongle -konfigurasjon" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "USB enhetsbane" + }, + "description": "Skriv inn banen til en Crownstone USB -dongle manuelt.", + "title": "Crownstone USB -dongle manuell bane" + }, + "usb_manual_config_option": { + "data": { + "usb_manual_path": "USB enhetsbane" + }, + "description": "Skriv inn banen til en Crownstone USB -dongle manuelt.", + "title": "Crownstone USB -dongle manuell bane" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "Velg en Crownstone Sphere der USB -en er plassert.", + "title": "Crownstone USB Sphere" + }, + "usb_sphere_config_option": { + "data": { + "usb_sphere": "Crownstone USB Sphere" + }, + "description": "Velg en Crownstone Sphere der USB -en er plassert.", + "title": "Crownstone USB Sphere" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/crownstone/translations/ru.json b/homeassistant/components/crownstone/translations/ru.json new file mode 100644 index 00000000000..7dfd88bd63e --- /dev/null +++ b/homeassistant/components/crownstone/translations/ru.json @@ -0,0 +1,96 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u0430 \u0443\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430 \u0432 Home Assistant.", + "usb_setup_complete": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Crownstone USB \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u0430.", + "usb_setup_unsuccessful": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Crownstone USB \u043d\u0435 \u0443\u0434\u0430\u043b\u0430\u0441\u044c." + }, + "error": { + "account_not_verified": "\u0423\u0447\u0435\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u043d\u0435 \u043f\u0440\u043e\u0432\u0435\u0440\u0435\u043d\u0430. \u0410\u043a\u0442\u0438\u0432\u0438\u0440\u0443\u0439\u0442\u0435 \u0435\u0451 \u0441 \u043f\u043e\u043c\u043e\u0449\u044c\u044e \u043f\u0438\u0441\u044c\u043c\u0430, \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u043d\u043e\u0433\u043e \u043f\u043e \u044d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u043e\u0439 \u043f\u043e\u0447\u0442\u0435.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "usb_config": { + "data": { + "usb_path": "\u041f\u0443\u0442\u044c \u043a USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443" + }, + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u043f\u043e\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u043d\u044b\u0439 \u043f\u043e\u0440\u0442 USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 Crownstone \u0438\u043b\u0438 \u0432\u044b\u0431\u0435\u0440\u0438\u0442\u0435 'Don't use USB', \u0435\u0441\u043b\u0438 \u0412\u044b \u043d\u0435 \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u0430\u0438\u0432\u0430\u0442\u044c \u0435\u0433\u043e. \n\n\u0414\u043b\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0432\u044b\u0431\u0438\u0440\u0430\u0439\u0442\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0441 VID 10C4 \u0438 PID EA60.", + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 Crownstone" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "\u041f\u0443\u0442\u044c \u043a USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0432\u0440\u0443\u0447\u043d\u0443\u044e \u043f\u0443\u0442\u044c \u043a USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443 Crownstone.", + "title": "\u041f\u0443\u0442\u044c \u043a USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443 Crownstone" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 Crownstone Sphere, \u0432 \u043a\u043e\u0442\u043e\u0440\u043e\u0439 \u043d\u0430\u0445\u043e\u0434\u0438\u0442\u0441\u044f USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e.", + "title": "Crownstone USB Sphere" + }, + "user": { + "data": { + "email": "\u0410\u0434\u0440\u0435\u0441 \u044d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u043e\u0439 \u043f\u043e\u0447\u0442\u044b", + "password": "\u041f\u0430\u0440\u043e\u043b\u044c" + }, + "title": "\u0423\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c Crownstone" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "usb_sphere_option": "Crownstone Sphere, \u0432 \u043a\u043e\u0442\u043e\u0440\u043e\u0439 \u043d\u0430\u0445\u043e\u0434\u0438\u0442\u0441\u044f USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e", + "use_usb_option": "\u0418\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0439\u0442\u0435 USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e Crownstone \u0434\u043b\u044f \u043b\u043e\u043a\u0430\u043b\u044c\u043d\u043e\u0439 \u043f\u0435\u0440\u0435\u0434\u0430\u0447\u0438 \u0434\u0430\u043d\u043d\u044b\u0445" + } + }, + "usb_config": { + "data": { + "usb_path": "\u041f\u0443\u0442\u044c \u043a USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443" + }, + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u043f\u043e\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u043d\u044b\u0439 \u043f\u043e\u0440\u0442 USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 Crownstone. \n\n\u0414\u043b\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0432\u044b\u0431\u0438\u0440\u0430\u0439\u0442\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0441 VID 10C4 \u0438 PID EA60.", + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 Crownstone" + }, + "usb_config_option": { + "data": { + "usb_path": "\u041f\u0443\u0442\u044c \u043a USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443" + }, + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u043f\u043e\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u043d\u044b\u0439 \u043f\u043e\u0440\u0442 USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 Crownstone. \n\n\u0414\u043b\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0432\u044b\u0431\u0438\u0440\u0430\u0439\u0442\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0441 VID 10C4 \u0438 PID EA60.", + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 Crownstone" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "\u041f\u0443\u0442\u044c \u043a USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0432\u0440\u0443\u0447\u043d\u0443\u044e \u043f\u0443\u0442\u044c \u043a USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443 Crownstone.", + "title": "\u041f\u0443\u0442\u044c \u043a USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443 Crownstone" + }, + "usb_manual_config_option": { + "data": { + "usb_manual_path": "\u041f\u0443\u0442\u044c \u043a USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0432\u0440\u0443\u0447\u043d\u0443\u044e \u043f\u0443\u0442\u044c \u043a USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443 Crownstone.", + "title": "\u041f\u0443\u0442\u044c \u043a USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443 Crownstone" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 Crownstone Sphere, \u0432 \u043a\u043e\u0442\u043e\u0440\u043e\u0439 \u043d\u0430\u0445\u043e\u0434\u0438\u0442\u0441\u044f USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e.", + "title": "Crownstone USB Sphere" + }, + "usb_sphere_config_option": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 Crownstone Sphere, \u0432 \u043a\u043e\u0442\u043e\u0440\u043e\u0439 \u043d\u0430\u0445\u043e\u0434\u0438\u0442\u0441\u044f USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e.", + "title": "Crownstone USB Sphere" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/crownstone/translations/zh-Hant.json b/homeassistant/components/crownstone/translations/zh-Hant.json new file mode 100644 index 00000000000..2c362ba0bcb --- /dev/null +++ b/homeassistant/components/crownstone/translations/zh-Hant.json @@ -0,0 +1,96 @@ +{ + "config": { + "abort": { + "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "usb_setup_complete": "Crownstone USB \u8a2d\u5b9a\u5b8c\u6210\u3002", + "usb_setup_unsuccessful": "Crownstone USB \u8a2d\u5b9a\u6210\u529f\u3002" + }, + "error": { + "account_not_verified": "\u5e33\u865f\u5c1a\u672a\u9a57\u8b49\u3001\u8acb\u900f\u904e\u4f86\u81ea Crownstone \u7684\u9a57\u8b49\u90f5\u4ef6\u555f\u52d5\u60a8\u7684\u5e33\u865f\u3002", + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "usb_config": { + "data": { + "usb_path": "USB \u88dd\u7f6e\u8def\u5f91" + }, + "description": "\u9078\u64c7 Crownstone USB \u88dd\u7f6e\u5e8f\u5217\u57e0\uff0c\u6216\u5047\u5982\u60a8\u4e0d\u60f3\u8a2d\u5b9a USB \u88dd\u7f6e\u7684\u8a71\u3001\u8acb\u9078\u64c7 '\u4e0d\u4f7f\u7528 USB'\u3002\n\n\u8acb\u641c\u5c0b VID 10C4 \u53ca PID EA60 \u88dd\u7f6e\u3002", + "title": "Crownstone USB \u88dd\u7f6e\u8a2d\u5b9a" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "USB \u88dd\u7f6e\u8def\u5f91" + }, + "description": "\u624b\u52d5\u8f38\u5165 Crownstone USB \u88dd\u7f6e\u8def\u5f91\u3002", + "title": "Crownstone USB \u88dd\u7f6e\u624b\u52d5\u8def\u5f91" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "\u9078\u64c7 Crownstone Sphere \u6240\u5728 USB \u8def\u5f91\u3002", + "title": "Crownstone USB Sphere" + }, + "user": { + "data": { + "email": "\u96fb\u5b50\u90f5\u4ef6", + "password": "\u5bc6\u78bc" + }, + "title": "Crownstone \u5e33\u865f" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "usb_sphere_option": "Crownstone Sphere \u6240\u5728 USB \u8def\u5f91", + "use_usb_option": "\u4f7f\u7528 Crownstone USB \u88dd\u7f6e\u9032\u884c\u672c\u5730\u7aef\u8cc7\u6599\u50b3\u8f38" + } + }, + "usb_config": { + "data": { + "usb_path": "USB \u88dd\u7f6e\u8def\u5f91" + }, + "description": "\u9078\u64c7 Crownstone USB \u88dd\u7f6e\u5e8f\u5217\u57e0\u3002\n\n\u8acb\u641c\u5c0b VID 10C4 \u53ca PID EA60 \u88dd\u7f6e\u3002", + "title": "Crownstone USB \u88dd\u7f6e\u8a2d\u5b9a" + }, + "usb_config_option": { + "data": { + "usb_path": "USB \u88dd\u7f6e\u8def\u5f91" + }, + "description": "\u9078\u64c7 Crownstone USB \u88dd\u7f6e\u5e8f\u5217\u57e0\u3002\n\n\u8acb\u641c\u5c0b VID 10C4 \u53ca PID EA60 \u88dd\u7f6e\u3002", + "title": "Crownstone USB \u88dd\u7f6e\u8a2d\u5b9a" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "USB \u88dd\u7f6e\u8def\u5f91" + }, + "description": "\u624b\u52d5\u8f38\u5165 Crownstone USB \u88dd\u7f6e\u8def\u5f91\u3002", + "title": "Crownstone USB \u88dd\u7f6e\u624b\u52d5\u8def\u5f91" + }, + "usb_manual_config_option": { + "data": { + "usb_manual_path": "USB \u88dd\u7f6e\u8def\u5f91" + }, + "description": "\u624b\u52d5\u8f38\u5165 Crownstone USB \u88dd\u7f6e\u8def\u5f91\u3002", + "title": "Crownstone USB \u88dd\u7f6e\u624b\u52d5\u8def\u5f91" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "\u9078\u64c7 Crownstone Sphere \u6240\u5728 USB \u8def\u5f91\u3002", + "title": "Crownstone USB Sphere" + }, + "usb_sphere_config_option": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "\u9078\u64c7 Crownstone Sphere \u6240\u5728 USB \u8def\u5f91\u3002", + "title": "Crownstone USB Sphere" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/daikin/translations/hu.json b/homeassistant/components/daikin/translations/hu.json index f1cb7eab8f6..6049890cb53 100644 --- a/homeassistant/components/daikin/translations/hu.json +++ b/homeassistant/components/daikin/translations/hu.json @@ -13,10 +13,10 @@ "user": { "data": { "api_key": "API kulcs", - "host": "Hoszt", + "host": "C\u00edm", "password": "Jelsz\u00f3" }, - "description": "Add meg a Daikin l\u00e9gkond\u00edcion\u00e1l\u00f3 IP-c\u00edm\u00e9t.", + "description": "Adja meg Daikin k\u00e9sz\u00fcl\u00e9k\u00e9nek az IP c\u00edm\u00e9t.\n\nNe feledje, hogy z API kulcs \u00e9s a Jelsz\u00f3 funkci\u00f3t csak a BRP072Cxx \u00e9s a SKYFi eszk\u00f6z\u00f6k haszn\u00e1lj\u00e1k.", "title": "A Daikin l\u00e9gkond\u00edcion\u00e1l\u00f3 konfigur\u00e1l\u00e1sa" } } diff --git a/homeassistant/components/deconz/translations/es.json b/homeassistant/components/deconz/translations/es.json index 3670caf18d0..5608b95288d 100644 --- a/homeassistant/components/deconz/translations/es.json +++ b/homeassistant/components/deconz/translations/es.json @@ -81,7 +81,7 @@ "remote_flip_180_degrees": "Dispositivo volteado 180 grados", "remote_flip_90_degrees": "Dispositivo volteado 90 grados", "remote_gyro_activated": "Dispositivo sacudido", - "remote_moved": "Dispositivo movido con \"{subtipo}\" hacia arriba", + "remote_moved": "Dispositivo movido con \"{subtype}\" hacia arriba", "remote_moved_any_side": "Dispositivo movido con cualquier lado hacia arriba", "remote_rotate_from_side_1": "Dispositivo girado del \"lado 1\" al \" {subtype} \"", "remote_rotate_from_side_2": "Dispositivo girado del \"lado 2\" al \" {subtype} \"", diff --git a/homeassistant/components/deconz/translations/hu.json b/homeassistant/components/deconz/translations/hu.json index bc003a279e8..664f3768a22 100644 --- a/homeassistant/components/deconz/translations/hu.json +++ b/homeassistant/components/deconz/translations/hu.json @@ -2,8 +2,8 @@ "config": { "abort": { "already_configured": "A bridge m\u00e1r konfigur\u00e1lva van", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", - "no_bridges": "Nem tal\u00e1ltam deCONZ bridget", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van.", + "no_bridges": "Nem tal\u00e1lhat\u00f3 deCONZ \u00e1tj\u00e1r\u00f3", "no_hardware_available": "Nincs deCONZ-hoz csatlakoztatott r\u00e1di\u00f3hardver", "not_deconz_bridge": "Nem egy deCONZ \u00e1tj\u00e1r\u00f3", "updated_instance": "A deCONZ-p\u00e9ld\u00e1ny \u00faj \u00e1llom\u00e1sc\u00edmmel friss\u00edtve" @@ -11,19 +11,19 @@ "error": { "no_key": "API kulcs lek\u00e9r\u00e9se nem siker\u00fclt" }, - "flow_title": "deCONZ Zigbee \u00e1tj\u00e1r\u00f3 ({host})", + "flow_title": "{host}", "step": { "hassio_confirm": { - "description": "Be szeretn\u00e9 \u00e1ll\u00edtani a Home Assistant-ot, hogy csatlakozzon a (z) {addon} \u00e1ltal biztos\u00edtott deCONZ \u00e1tj\u00e1r\u00f3hoz?", - "title": "deCONZ Zigbee \u00e1tj\u00e1r\u00f3 a Supervisor kieg\u00e9sz\u00edt\u0151 seg\u00edts\u00e9g\u00e9vel" + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani Home Assistant-ot, hogy csatlakozzon {addon} \u00e1ltal biztos\u00edtott deCONZ \u00e1tj\u00e1r\u00f3hoz?", + "title": "deCONZ Zigbee \u00e1tj\u00e1r\u00f3 Home Assistant kieg\u00e9sz\u00edt\u0151 \u00e1ltal" }, "link": { - "description": "Oldja fel a deCONZ \u00e1tj\u00e1r\u00f3t a Home Assistant-ban val\u00f3 regisztr\u00e1l\u00e1shoz.\n\n1. Menjen a deCONZ rendszer be\u00e1ll\u00edt\u00e1sokhoz\n2. Nyomja meg az \"\u00c1tj\u00e1r\u00f3 felold\u00e1sa\" gombot", + "description": "Enged\u00e9lyezze fel a deCONZ \u00e1tj\u00e1r\u00f3ban a Home Assistant-hoz val\u00f3 regisztr\u00e1l\u00e1st.\n\n1. V\u00e1lassza ki a deCONZ rendszer be\u00e1ll\u00edt\u00e1sait\n2. Nyomja meg az \"Authenticate app\" gombot", "title": "Kapcsol\u00f3d\u00e1s a deCONZ-hoz" }, "manual_input": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "port": "Port" } }, @@ -71,7 +71,7 @@ "remote_button_quintuple_press": "\"{subtype}\" gombra \u00f6tsz\u00f6r kattintottak", "remote_button_rotated": "A gomb elforgatva: \"{subtype}\"", "remote_button_rotated_fast": "A gomb gyorsan elfordult: \"{subtype}\"", - "remote_button_rotation_stopped": "A (z) \"{subtype}\" gomb forg\u00e1sa le\u00e1llt", + "remote_button_rotation_stopped": "A(z) \"{subtype}\" gomb forg\u00e1sa le\u00e1llt", "remote_button_short_press": "\"{subtype}\" gomb lenyomva", "remote_button_short_release": "\"{subtype}\" gomb elengedve", "remote_button_triple_press": "\"{subtype}\" gombra h\u00e1romszor kattintottak", diff --git a/homeassistant/components/deconz/translations/id.json b/homeassistant/components/deconz/translations/id.json index c6d54beaec2..f63261e6e87 100644 --- a/homeassistant/components/deconz/translations/id.json +++ b/homeassistant/components/deconz/translations/id.json @@ -11,11 +11,11 @@ "error": { "no_key": "Tidak bisa mendapatkan kunci API" }, - "flow_title": "Gateway Zigbee deCONZ ({host})", + "flow_title": "{host}", "step": { "hassio_confirm": { - "description": "Ingin mengonfigurasi Home Assistant untuk terhubung ke gateway deCONZ yang disediakan oleh add-on Supervisor {addon}?", - "title": "Gateway Zigbee deCONZ melalui add-on Supervisor" + "description": "Ingin mengonfigurasi Home Assistant untuk terhubung ke gateway deCONZ yang disediakan oleh add-on: {addon}?", + "title": "Gateway Zigbee deCONZ melalui add-on Home Assistant" }, "link": { "description": "Buka gateway deCONZ Anda untuk mendaftarkan ke Home Assistant. \n\n1. Buka pengaturan sistem deCONZ \n2. Tekan tombol \"Authenticate app\"", diff --git a/homeassistant/components/demo/translations/he.json b/homeassistant/components/demo/translations/he.json new file mode 100644 index 00000000000..c3162b87a5e --- /dev/null +++ b/homeassistant/components/demo/translations/he.json @@ -0,0 +1,3 @@ +{ + "title": "\u05d4\u05d3\u05d2\u05de\u05d4" +} \ No newline at end of file diff --git a/homeassistant/components/demo/translations/hu.json b/homeassistant/components/demo/translations/hu.json index 3bfe095189a..e77c21294b8 100644 --- a/homeassistant/components/demo/translations/hu.json +++ b/homeassistant/components/demo/translations/hu.json @@ -17,7 +17,7 @@ "options_2": { "data": { "multi": "T\u00f6bbsz\u00f6r\u00f6s kijel\u00f6l\u00e9s", - "select": "V\u00e1lassz egy lehet\u0151s\u00e9get", + "select": "V\u00e1lasszon egy lehet\u0151s\u00e9get", "string": "Karakterl\u00e1nc \u00e9rt\u00e9k" } } diff --git a/homeassistant/components/demo/translations/ro.json b/homeassistant/components/demo/translations/ro.json new file mode 100644 index 00000000000..96e182c6d54 --- /dev/null +++ b/homeassistant/components/demo/translations/ro.json @@ -0,0 +1,13 @@ +{ + "options": { + "step": { + "init": { + "data": { + "few": "Cateva", + "one": "Unu", + "other": "Altele" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/denonavr/translations/hu.json b/homeassistant/components/denonavr/translations/hu.json index 43ee362d65a..c22d392dc8a 100644 --- a/homeassistant/components/denonavr/translations/hu.json +++ b/homeassistant/components/denonavr/translations/hu.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van.", "cannot_connect": "Nem siker\u00fclt csatlakozni, k\u00e9rj\u00fck, pr\u00f3b\u00e1lja \u00fajra. A h\u00e1l\u00f3zati \u00e9s Ethernet k\u00e1belek kih\u00faz\u00e1sa \u00e9s \u00fajracsatlakoztat\u00e1sa seg\u00edthet", "not_denonavr_manufacturer": "Nem egy Denon AVR h\u00e1l\u00f3zati vev\u0151, felfedezett gy\u00e1rt\u00f3 nem egyezik", "not_denonavr_missing": "Nem Denon AVR h\u00e1l\u00f3zati vev\u0151, a felfedez\u00e9si inform\u00e1ci\u00f3k nem teljesek" diff --git a/homeassistant/components/denonavr/translations/id.json b/homeassistant/components/denonavr/translations/id.json index d78f547ef35..0bafe289842 100644 --- a/homeassistant/components/denonavr/translations/id.json +++ b/homeassistant/components/denonavr/translations/id.json @@ -10,7 +10,7 @@ "error": { "discovery_error": "Gagal menemukan Network Receiver AVR Denon" }, - "flow_title": "Network Receiver Denon AVR: {name}", + "flow_title": "{name}", "step": { "confirm": { "description": "Konfirmasikan penambahan Receiver", diff --git a/homeassistant/components/devolo_home_control/translations/ca.json b/homeassistant/components/devolo_home_control/translations/ca.json index 968624e15c8..73417c5c564 100644 --- a/homeassistant/components/devolo_home_control/translations/ca.json +++ b/homeassistant/components/devolo_home_control/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat", + "already_configured": "El compte ja est\u00e0 configurat", "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" }, "error": { diff --git a/homeassistant/components/devolo_home_control/translations/id.json b/homeassistant/components/devolo_home_control/translations/id.json index 31f0f87dc00..41d2100b6ed 100644 --- a/homeassistant/components/devolo_home_control/translations/id.json +++ b/homeassistant/components/devolo_home_control/translations/id.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Akun sudah dikonfigurasi" + "already_configured": "Akun sudah dikonfigurasi", + "reauth_successful": "Autentikasi ulang berhasil" }, "error": { "invalid_auth": "Autentikasi tidak valid" @@ -13,6 +14,13 @@ "password": "Kata Sandi", "username": "Email/ID devolo" } + }, + "zeroconf_confirm": { + "data": { + "mydevolo_url": "URL mydevolo", + "password": "Kata Sandi", + "username": "Email/devolo ID" + } } } } diff --git a/homeassistant/components/devolo_home_control/translations/ko.json b/homeassistant/components/devolo_home_control/translations/ko.json index 9c9a21182cc..f3832dec7c1 100644 --- a/homeassistant/components/devolo_home_control/translations/ko.json +++ b/homeassistant/components/devolo_home_control/translations/ko.json @@ -13,6 +13,13 @@ "password": "\ube44\ubc00\ubc88\ud638", "username": "\uc774\uba54\uc77c / devolo ID" } + }, + "zeroconf_confirm": { + "data": { + "mydevolo_url": "mydevolo URL \uc8fc\uc18c", + "password": "\ube44\ubc00\ubc88\ud638", + "username": "\uc774\uba54\uc77c / devolo ID" + } } } } diff --git a/homeassistant/components/dexcom/translations/ca.json b/homeassistant/components/dexcom/translations/ca.json index e188718a71d..7b97a209e49 100644 --- a/homeassistant/components/dexcom/translations/ca.json +++ b/homeassistant/components/dexcom/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat" + "already_configured": "El compte ja est\u00e0 configurat" }, "error": { "cannot_connect": "Ha fallat la connexi\u00f3", diff --git a/homeassistant/components/dialogflow/translations/hu.json b/homeassistant/components/dialogflow/translations/hu.json index 17f38b0262f..23a6001d77c 100644 --- a/homeassistant/components/dialogflow/translations/hu.json +++ b/homeassistant/components/dialogflow/translations/hu.json @@ -2,14 +2,14 @@ "config": { "abort": { "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges.", - "webhook_not_internet_accessible": "A Home Assistant rendszerednek el\u00e9rhet\u0151nek kell lennie az internetr\u0151l a webhook \u00fczenetek fogad\u00e1s\u00e1hoz." + "webhook_not_internet_accessible": "A Home Assistant p\u00e9ld\u00e1ny\u00e1nak el\u00e9rhet\u0151nek kell lennie az internet fel\u0151l a webhook \u00fczenetek fogad\u00e1s\u00e1hoz." }, "create_entry": { "default": "Ha esem\u00e9nyeket szeretne k\u00fcldeni a Home Assistant alkalmaz\u00e1snak, be kell \u00e1ll\u00edtania a [Dialogflow webhook integr\u00e1ci\u00f3j\u00e1t]({dialogflow_url}). \n\n T\u00f6ltse ki a k\u00f6vetkez\u0151 inform\u00e1ci\u00f3kat: \n\n - URL: `{webhook_url}`\n - M\u00f3dszer: POST\n - Tartalom t\u00edpusa: application/json \n\n Tov\u00e1bbi r\u00e9szletek\u00e9rt l\u00e1sd a [dokument\u00e1ci\u00f3t]({docs_url})." }, "step": { "user": { - "description": "Biztosan be szeretn\u00e9d \u00e1ll\u00edtani az Dialogflowt?", + "description": "Biztos benne, hogy be szeretn\u00e9 \u00e1ll\u00edtani a Dialogflowt?", "title": "Dialogflow Webhook be\u00e1ll\u00edt\u00e1sa" } } diff --git a/homeassistant/components/directv/translations/hu.json b/homeassistant/components/directv/translations/hu.json index 3e0a7d5cb57..9e3aa3efb13 100644 --- a/homeassistant/components/directv/translations/hu.json +++ b/homeassistant/components/directv/translations/hu.json @@ -14,11 +14,11 @@ "one": "\u00dcres", "other": "\u00dcres" }, - "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a {name}-t?" + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani: {name}?" }, "user": { "data": { - "host": "Hoszt" + "host": "C\u00edm" } } } diff --git a/homeassistant/components/directv/translations/id.json b/homeassistant/components/directv/translations/id.json index 74f778d6cee..fcf7318d906 100644 --- a/homeassistant/components/directv/translations/id.json +++ b/homeassistant/components/directv/translations/id.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "Gagal terhubung" }, - "flow_title": "DirecTV: {name}", + "flow_title": "{name}", "step": { "ssdp_confirm": { "description": "Ingin menyiapkan {name}?" diff --git a/homeassistant/components/dlna_dmr/translations/ca.json b/homeassistant/components/dlna_dmr/translations/ca.json new file mode 100644 index 00000000000..cf3adc94405 --- /dev/null +++ b/homeassistant/components/dlna_dmr/translations/ca.json @@ -0,0 +1,44 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat", + "could_not_connect": "No s'ha pogut connectar amb el dispositiu DLNA", + "discovery_error": "No s'ha pogut descobrir cap dispositiu DLNA coincident", + "incomplete_config": "Falta una variable obligat\u00f2ria a la configuraci\u00f3", + "non_unique_id": "S'han trobat diversos dispositius amb el mateix identificador \u00fanic", + "not_dmr": "El dispositiu no \u00e9s un renderitzador de mitjans digitals" + }, + "error": { + "could_not_connect": "No s'ha pogut connectar amb el dispositiu DLNA", + "not_dmr": "El dispositiu no \u00e9s un renderitzador de mitjans digitals" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "Vols comen\u00e7ar la configuraci\u00f3?" + }, + "user": { + "data": { + "url": "URL" + }, + "description": "URL al fitxer XML de descripci\u00f3 de dispositiu", + "title": "Renderitzador de mitjans digitals DLNA" + } + } + }, + "options": { + "error": { + "invalid_url": "URL inv\u00e0lid" + }, + "step": { + "init": { + "data": { + "callback_url_override": "URL de crida de l'oient d'esdeveniments", + "listen_port": "Port de l'oient d'esdeveniments (aleatori si no es defineix)", + "poll_availability": "Sondeja per saber la disponibilitat del dispositiu" + }, + "title": "Configuraci\u00f3 del renderitzador de mitjans digitals DLNA" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dlna_dmr/translations/de.json b/homeassistant/components/dlna_dmr/translations/de.json new file mode 100644 index 00000000000..50f66761748 --- /dev/null +++ b/homeassistant/components/dlna_dmr/translations/de.json @@ -0,0 +1,44 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "could_not_connect": "Verbindung zum DLNA-Ger\u00e4t fehlgeschlagen", + "discovery_error": "Ein passendes DLNA-Ger\u00e4t konnte nicht gefunden werden", + "incomplete_config": "In der Konfiguration fehlt eine erforderliche Variable", + "non_unique_id": "Mehrere Ger\u00e4te mit derselben eindeutigen ID gefunden", + "not_dmr": "Ger\u00e4t ist kein Digital Media Renderer" + }, + "error": { + "could_not_connect": "Verbindung zum DLNA-Ger\u00e4t fehlgeschlagen", + "not_dmr": "Ger\u00e4t ist kein Digital Media Renderer" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "M\u00f6chtest Du mit der Einrichtung beginnen?" + }, + "user": { + "data": { + "url": "URL" + }, + "description": "URL zu einer XML-Datei mit Ger\u00e4tebeschreibung", + "title": "DLNA Digital Media Renderer" + } + } + }, + "options": { + "error": { + "invalid_url": "Ung\u00fcltige URL" + }, + "step": { + "init": { + "data": { + "callback_url_override": "R\u00fcckruf-URL des Ereignis-Listeners", + "listen_port": "Port des Ereignis-Listeners (zuf\u00e4llig, wenn nicht festgelegt)", + "poll_availability": "Abfrage der Ger\u00e4teverf\u00fcgbarkeit" + }, + "title": "DLNA Digital Media Renderer Konfiguration" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dlna_dmr/translations/et.json b/homeassistant/components/dlna_dmr/translations/et.json new file mode 100644 index 00000000000..e32101ab251 --- /dev/null +++ b/homeassistant/components/dlna_dmr/translations/et.json @@ -0,0 +1,44 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud", + "could_not_connect": "DLNA seadmega \u00fchenduse loomine nurjus", + "discovery_error": "Sobiva DLNA -seadme leidmine nurjus", + "incomplete_config": "Seadetes puudub n\u00f5utav muutuja", + "non_unique_id": "Leiti mitu sama unikaalse ID-ga seadet", + "not_dmr": "Seade ei ole digitaalse meedia renderdaja" + }, + "error": { + "could_not_connect": "DLNA seadmega \u00fchenduse loomine nurjus", + "not_dmr": "Seade ei ole digitaalse meedia renderdaja" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "Kas alustada seadistamist?" + }, + "user": { + "data": { + "url": "URL" + }, + "description": "URL aadress seadme kirjelduse XML-failile", + "title": "DLNA digitaalse meediumi renderdaja" + } + } + }, + "options": { + "error": { + "invalid_url": "Sobimatu URL" + }, + "step": { + "init": { + "data": { + "callback_url_override": "S\u00fcndmuse kuulaja URL", + "listen_port": "S\u00fcndmuste kuulaja port (juhuslik kui pole m\u00e4\u00e4ratud)", + "poll_availability": "K\u00fcsitle seadme saadavuse kohta" + }, + "title": "DLNA digitaalse meediumi renderdaja s\u00e4tted" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dlna_dmr/translations/hu.json b/homeassistant/components/dlna_dmr/translations/hu.json new file mode 100644 index 00000000000..faa7e73eb76 --- /dev/null +++ b/homeassistant/components/dlna_dmr/translations/hu.json @@ -0,0 +1,44 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r be van konfigur\u00e1lva", + "could_not_connect": "Nem siker\u00fclt csatlakozni a DLNA-eszk\u00f6zh\u00f6z", + "discovery_error": "Nem siker\u00fclt megfelel\u0151 DLNA-eszk\u00f6zt tal\u00e1lni", + "incomplete_config": "A konfigur\u00e1ci\u00f3b\u00f3l hi\u00e1nyzik egy sz\u00fcks\u00e9ges \u00e9rt\u00e9k", + "non_unique_id": "T\u00f6bb eszk\u00f6z tal\u00e1lhat\u00f3 ugyanazzal az egyedi azonos\u00edt\u00f3val", + "not_dmr": "Az eszk\u00f6z nem digit\u00e1lis m\u00e9dia renderel\u0151" + }, + "error": { + "could_not_connect": "Nem siker\u00fclt csatlakozni a DLNA-eszk\u00f6zh\u00f6z", + "not_dmr": "Az eszk\u00f6z nem digit\u00e1lis m\u00e9dia renderel\u0151" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "Kezd\u0151dhet a be\u00e1ll\u00edt\u00e1s?" + }, + "user": { + "data": { + "url": "URL" + }, + "description": "Az eszk\u00f6z le\u00edr\u00e1s\u00e1nak XML-f\u00e1jl URL-c\u00edme", + "title": "DLNA digit\u00e1lis m\u00e9dia renderel\u0151" + } + } + }, + "options": { + "error": { + "invalid_url": "\u00c9rv\u00e9nytelen URL" + }, + "step": { + "init": { + "data": { + "callback_url_override": "Esem\u00e9nyfigyel\u0151 visszah\u00edv\u00e1si URL (callback)", + "listen_port": "Esem\u00e9nyfigyel\u0151 port (v\u00e9letlenszer\u0171, ha nincs be\u00e1ll\u00edtva)", + "poll_availability": "Eszk\u00f6z el\u00e9r\u00e9s\u00e9nek tesztel\u00e9se lek\u00e9rdez\u00e9ssel" + }, + "title": "DLNA konfigur\u00e1ci\u00f3" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dlna_dmr/translations/it.json b/homeassistant/components/dlna_dmr/translations/it.json new file mode 100644 index 00000000000..5defd82a8be --- /dev/null +++ b/homeassistant/components/dlna_dmr/translations/it.json @@ -0,0 +1,44 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", + "could_not_connect": "Impossibile connettersi al dispositivo DLNA", + "discovery_error": "Impossibile individuare un dispositivo DLNA corrispondente", + "incomplete_config": "Nella configurazione manca una variabile richiesta", + "non_unique_id": "Pi\u00f9 dispositivi trovati con lo stesso ID univoco", + "not_dmr": "Il dispositivo non \u00e8 un Digital Media Renderer" + }, + "error": { + "could_not_connect": "Impossibile connettersi al dispositivo DLNA", + "not_dmr": "Il dispositivo non \u00e8 un Digital Media Renderer" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "Vuoi iniziare la configurazione?" + }, + "user": { + "data": { + "url": "URL" + }, + "description": "URL di un file XML di descrizione del dispositivo", + "title": "DLNA Digital Media Renderer" + } + } + }, + "options": { + "error": { + "invalid_url": "URL non valido" + }, + "step": { + "init": { + "data": { + "callback_url_override": "URL di richiamata dell'ascoltatore di eventi", + "listen_port": "Porta dell'ascoltatore di eventi (casuale se non impostata)", + "poll_availability": "Interrogazione per la disponibilit\u00e0 del dispositivo" + }, + "title": "Configurazione DLNA Digital Media Renderer" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dlna_dmr/translations/nl.json b/homeassistant/components/dlna_dmr/translations/nl.json new file mode 100644 index 00000000000..7387494b9b7 --- /dev/null +++ b/homeassistant/components/dlna_dmr/translations/nl.json @@ -0,0 +1,44 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd", + "could_not_connect": "Mislukt om te verbinden met DNLA apparaat", + "discovery_error": "Kan geen overeenkomend DLNA-apparaat vinden", + "incomplete_config": "Configuratie mist een vereiste variabele", + "non_unique_id": "Meerdere apparaten gevonden met hetzelfde unieke ID", + "not_dmr": "Apparaat is geen Digital Media Renderer" + }, + "error": { + "could_not_connect": "Mislukt om te verbinden met DNLA apparaat", + "not_dmr": "Apparaat is geen Digital Media Renderer" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "Wilt u beginnen met instellen?" + }, + "user": { + "data": { + "url": "URL" + }, + "description": "URL naar een XML-bestand met apparaatbeschrijvingen", + "title": "DLNA Digital Media Renderer" + } + } + }, + "options": { + "error": { + "invalid_url": "Ongeldige URL" + }, + "step": { + "init": { + "data": { + "callback_url_override": "Event listener callback URL", + "listen_port": "Poort om naar gebeurtenissen te luisteren (willekeurige poort indien niet ingesteld)", + "poll_availability": "Pollen voor apparaat beschikbaarheid" + }, + "title": "DLNA Digital Media Renderer instellingen" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dlna_dmr/translations/no.json b/homeassistant/components/dlna_dmr/translations/no.json new file mode 100644 index 00000000000..1ddbfc32afe --- /dev/null +++ b/homeassistant/components/dlna_dmr/translations/no.json @@ -0,0 +1,44 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert", + "could_not_connect": "Kunne ikke koble til DLNA -enhet", + "discovery_error": "Kunne ikke finne en matchende DLNA -enhet", + "incomplete_config": "Konfigurasjonen mangler en n\u00f8dvendig variabel", + "non_unique_id": "Flere enheter ble funnet med samme unike ID", + "not_dmr": "Enheten er ikke en Digital Media Renderer" + }, + "error": { + "could_not_connect": "Kunne ikke koble til DLNA -enhet", + "not_dmr": "Enheten er ikke en Digital Media Renderer" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "Vil du starte oppsettet?" + }, + "user": { + "data": { + "url": "URL" + }, + "description": "URL til en enhetsbeskrivelse XML -fil", + "title": "DLNA Digital Media Renderer" + } + } + }, + "options": { + "error": { + "invalid_url": "ugyldig URL" + }, + "step": { + "init": { + "data": { + "callback_url_override": "URL for tilbakeringing av hendelseslytter", + "listen_port": "Hendelseslytterport (tilfeldig hvis den ikke er angitt)", + "poll_availability": "Avstemning for tilgjengelighet av enheter" + }, + "title": "DLNA Digital Media Renderer -konfigurasjon" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dlna_dmr/translations/ru.json b/homeassistant/components/dlna_dmr/translations/ru.json new file mode 100644 index 00000000000..bf1be8f6c3d --- /dev/null +++ b/homeassistant/components/dlna_dmr/translations/ru.json @@ -0,0 +1,44 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant.", + "could_not_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443.", + "discovery_error": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0442\u044c \u043f\u043e\u0434\u0445\u043e\u0434\u044f\u0449\u0435\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e DLNA.", + "incomplete_config": "\u0412 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 \u043e\u0442\u0441\u0443\u0442\u0441\u0442\u0432\u0443\u0435\u0442 \u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u0430\u044f \u043f\u0435\u0440\u0435\u043c\u0435\u043d\u043d\u0430\u044f.", + "non_unique_id": "\u041e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u043e \u043d\u0435\u0441\u043a\u043e\u043b\u044c\u043a\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432 \u0441 \u043e\u0434\u0438\u043d\u0430\u043a\u043e\u0432\u044b\u043c \u0443\u043d\u0438\u043a\u0430\u043b\u044c\u043d\u044b\u043c \u0438\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440\u043e\u043c.", + "not_dmr": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u044f\u0432\u043b\u044f\u0435\u0442\u0441\u044f \u043c\u0435\u0434\u0438\u0430\u0440\u0435\u043d\u0434\u0435\u0440\u0435\u0440\u043e\u043c (DMR)." + }, + "error": { + "could_not_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443.", + "not_dmr": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u044f\u0432\u043b\u044f\u0435\u0442\u0441\u044f \u043c\u0435\u0434\u0438\u0430\u0440\u0435\u043d\u0434\u0435\u0440\u0435\u0440\u043e\u043c (DMR)." + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "\u0425\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0447\u0430\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0443?" + }, + "user": { + "data": { + "url": "URL-\u0430\u0434\u0440\u0435\u0441" + }, + "description": "URL-\u0430\u0434\u0440\u0435\u0441 XML-\u0444\u0430\u0439\u043b\u0430 \u043e\u043f\u0438\u0441\u0430\u043d\u0438\u044f \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430", + "title": "\u041c\u0435\u0434\u0438\u0430\u0440\u0435\u043d\u0434\u0435\u0440\u0435\u0440 DLNA" + } + } + }, + "options": { + "error": { + "invalid_url": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 URL-\u0430\u0434\u0440\u0435\u0441." + }, + "step": { + "init": { + "data": { + "callback_url_override": "Callback URL \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u0430 \u0441\u043e\u0431\u044b\u0442\u0438\u0439", + "listen_port": "\u041f\u043e\u0440\u0442 \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u0430 \u0441\u043e\u0431\u044b\u0442\u0438\u0439 (\u0441\u043b\u0443\u0447\u0430\u0439\u043d\u044b\u0439, \u0435\u0441\u043b\u0438 \u043d\u0435 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043b\u0435\u043d)", + "poll_availability": "\u041e\u043f\u0440\u043e\u0441 \u0434\u043e\u0441\u0442\u0443\u043f\u043d\u043e\u0441\u0442\u0438 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430" + }, + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u043c\u0435\u0434\u0438\u0430\u0440\u0435\u043d\u0434\u0435\u0440\u0435\u0440\u0430 DLNA" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dlna_dmr/translations/zh-Hant.json b/homeassistant/components/dlna_dmr/translations/zh-Hant.json new file mode 100644 index 00000000000..b7eab93d76d --- /dev/null +++ b/homeassistant/components/dlna_dmr/translations/zh-Hant.json @@ -0,0 +1,44 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "could_not_connect": "DLNA \u88dd\u7f6e\u9023\u7dda\u5931\u6557\u3002", + "discovery_error": "DLNA \u88dd\u7f6e\u63a2\u7d22\u5931\u6557", + "incomplete_config": "\u6240\u7f3a\u5c11\u7684\u8a2d\u5b9a\u70ba\u5fc5\u9808\u8b8a\u6578", + "non_unique_id": "\u627e\u5230\u591a\u7d44\u88dd\u7f6e\u4f7f\u7528\u4e86\u76f8\u540c\u552f\u4e00 ID", + "not_dmr": "\u88dd\u7f6e\u4e26\u975e Digital Media Renderer" + }, + "error": { + "could_not_connect": "DLNA \u88dd\u7f6e\u9023\u7dda\u5931\u6557\u3002", + "not_dmr": "\u88dd\u7f6e\u4e26\u975e Digital Media Renderer" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "\u662f\u5426\u8981\u958b\u59cb\u8a2d\u5b9a\uff1f" + }, + "user": { + "data": { + "url": "\u7db2\u5740" + }, + "description": "\u88dd\u7f6e\u8aaa\u660e XML \u6a94\u6848\u4e4b URL", + "title": "DLNA Digital Media Renderer" + } + } + }, + "options": { + "error": { + "invalid_url": "URL \u7121\u6548" + }, + "step": { + "init": { + "data": { + "callback_url_override": "\u4e8b\u4ef6\u76e3\u807d\u56de\u547c URL", + "listen_port": "\u4e8b\u4ef6\u76e3\u807d\u901a\u8a0a\u57e0\uff08\u672a\u8a2d\u7f6e\u5247\u70ba\u96a8\u6a5f\uff09", + "poll_availability": "\u67e5\u8a62\u88dd\u7f6e\u53ef\u7528\u6027" + }, + "title": "DLNA Digital Media Renderer \u8a2d\u5b9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/doorbird/translations/hu.json b/homeassistant/components/doorbird/translations/hu.json index cb4c46e699a..48a124b4f17 100644 --- a/homeassistant/components/doorbird/translations/hu.json +++ b/homeassistant/components/doorbird/translations/hu.json @@ -10,11 +10,11 @@ "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, - "flow_title": "DoorBird {name} ({host})", + "flow_title": "{name} ({host})", "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "name": "Eszk\u00f6z neve", "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" diff --git a/homeassistant/components/doorbird/translations/id.json b/homeassistant/components/doorbird/translations/id.json index f708780ce31..60348ec26a1 100644 --- a/homeassistant/components/doorbird/translations/id.json +++ b/homeassistant/components/doorbird/translations/id.json @@ -10,7 +10,7 @@ "invalid_auth": "Autentikasi tidak valid", "unknown": "Kesalahan yang tidak diharapkan" }, - "flow_title": "DoorBird {name} ({host})", + "flow_title": "{name} ({host})", "step": { "user": { "data": { diff --git a/homeassistant/components/dsmr/translations/ca.json b/homeassistant/components/dsmr/translations/ca.json index 263cb388980..1d61426ebea 100644 --- a/homeassistant/components/dsmr/translations/ca.json +++ b/homeassistant/components/dsmr/translations/ca.json @@ -28,7 +28,7 @@ }, "setup_serial_manual_path": { "data": { - "port": "Ruta del port USB del dispositiu" + "port": "Ruta del dispositiu USB" }, "title": "Ruta" }, diff --git a/homeassistant/components/dsmr/translations/hu.json b/homeassistant/components/dsmr/translations/hu.json index 86a15e99aab..1bca962e2f5 100644 --- a/homeassistant/components/dsmr/translations/hu.json +++ b/homeassistant/components/dsmr/translations/hu.json @@ -18,7 +18,7 @@ "setup_network": { "data": { "dsmr_version": "DSMR verzi\u00f3 kiv\u00e1laszt\u00e1sa", - "host": "Gazdag\u00e9p", + "host": "C\u00edm", "port": "Port" }, "title": "V\u00e1lassza ki a csatlakoz\u00e1si c\u00edmet" diff --git a/homeassistant/components/dunehd/translations/hu.json b/homeassistant/components/dunehd/translations/hu.json index 148a6fde0d0..15b2d297363 100644 --- a/homeassistant/components/dunehd/translations/hu.json +++ b/homeassistant/components/dunehd/translations/hu.json @@ -11,7 +11,7 @@ "step": { "user": { "data": { - "host": "Hoszt" + "host": "C\u00edm" }, "description": "\u00c1ll\u00edtsa be a Dune HD integr\u00e1ci\u00f3t. Ha probl\u00e9m\u00e1i vannak a konfigur\u00e1ci\u00f3val, l\u00e1togasson el a k\u00f6vetkez\u0151 oldalra: https://www.home-assistant.io/integrations/dunehd \n\n Gy\u0151z\u0151dj\u00f6n meg arr\u00f3l, hogy a lej\u00e1tsz\u00f3 be van kapcsolva.", "title": "Dune HD" diff --git a/homeassistant/components/elgato/translations/hu.json b/homeassistant/components/elgato/translations/hu.json index 0cd9f2589b8..26740a33f21 100644 --- a/homeassistant/components/elgato/translations/hu.json +++ b/homeassistant/components/elgato/translations/hu.json @@ -7,11 +7,11 @@ "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s" }, - "flow_title": "Elgato Key Light: {serial_number}", + "flow_title": "{serial_number}", "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "port": "Port" }, "description": "\u00c1ll\u00edtsa be az Elgato Light-ot, hogy integr\u00e1lhat\u00f3 legyen az HomeAssistantba." diff --git a/homeassistant/components/elgato/translations/id.json b/homeassistant/components/elgato/translations/id.json index b06691b9453..f9fa5690c1d 100644 --- a/homeassistant/components/elgato/translations/id.json +++ b/homeassistant/components/elgato/translations/id.json @@ -7,18 +7,18 @@ "error": { "cannot_connect": "Gagal terhubung" }, - "flow_title": "Elgato Key Light: {serial_number}", + "flow_title": "{serial_number}", "step": { "user": { "data": { "host": "Host", "port": "Port" }, - "description": "Siapkan Elgato Key Light Anda untuk diintegrasikan dengan Home Assistant." + "description": "Siapkan Elgato Light Anda untuk diintegrasikan dengan Home Assistant." }, "zeroconf_confirm": { - "description": "Ingin menambahkan Elgato Key Light dengan nomor seri `{serial_number}` ke Home Assistant?", - "title": "Perangkat Elgato Key Light yang ditemukan" + "description": "Ingin menambahkan Elgato Light dengan nomor seri `{serial_number}` ke Home Assistant?", + "title": "Perangkat Elgato Light yang ditemukan" } } } diff --git a/homeassistant/components/emonitor/translations/hu.json b/homeassistant/components/emonitor/translations/hu.json index 575e2a91d44..1a4fbb292e0 100644 --- a/homeassistant/components/emonitor/translations/hu.json +++ b/homeassistant/components/emonitor/translations/hu.json @@ -10,12 +10,12 @@ "flow_title": "{name}", "step": { "confirm": { - "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a {name}({host})-t?", + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani: {name} ({host})?", "title": "A SiteSage Emonitor be\u00e1ll\u00edt\u00e1sa" }, "user": { "data": { - "host": "Hoszt" + "host": "C\u00edm" } } } diff --git a/homeassistant/components/emonitor/translations/id.json b/homeassistant/components/emonitor/translations/id.json index 1365fed7d52..c967ad91d05 100644 --- a/homeassistant/components/emonitor/translations/id.json +++ b/homeassistant/components/emonitor/translations/id.json @@ -7,7 +7,7 @@ "cannot_connect": "Gagal terhubung", "unknown": "Kesalahan yang tidak diharapkan" }, - "flow_title": "SiteSage {name}", + "flow_title": "{name}", "step": { "confirm": { "description": "Ingin menyiapkan {name} ({host})?", diff --git a/homeassistant/components/emulated_roku/translations/hu.json b/homeassistant/components/emulated_roku/translations/hu.json index e733e9801df..53b66f6db19 100644 --- a/homeassistant/components/emulated_roku/translations/hu.json +++ b/homeassistant/components/emulated_roku/translations/hu.json @@ -8,8 +8,8 @@ "data": { "advertise_ip": "IP c\u00edm k\u00f6zl\u00e9se", "advertise_port": "Port k\u00f6zl\u00e9se", - "host_ip": "Hoszt IP c\u00edm", - "listen_port": "Port figyel\u00e9se", + "host_ip": "IP c\u00edm", + "listen_port": "Port", "name": "N\u00e9v", "upnp_bind_multicast": "K\u00f6t\u00f6tt multicast (igaz/hamis)" }, diff --git a/homeassistant/components/energy/translations/el.json b/homeassistant/components/energy/translations/el.json new file mode 100644 index 00000000000..cdc7b83c2ee --- /dev/null +++ b/homeassistant/components/energy/translations/el.json @@ -0,0 +1,3 @@ +{ + "title": "\u0395\u03bd\u03ad\u03c1\u03b3\u03b5\u03b9\u03b1" +} \ No newline at end of file diff --git a/homeassistant/components/enphase_envoy/translations/hu.json b/homeassistant/components/enphase_envoy/translations/hu.json index ab92a4ad2bb..38177f8930c 100644 --- a/homeassistant/components/enphase_envoy/translations/hu.json +++ b/homeassistant/components/enphase_envoy/translations/hu.json @@ -13,7 +13,7 @@ "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" } diff --git a/homeassistant/components/enphase_envoy/translations/id.json b/homeassistant/components/enphase_envoy/translations/id.json index ba3f8dd8cc6..31c8251820d 100644 --- a/homeassistant/components/enphase_envoy/translations/id.json +++ b/homeassistant/components/enphase_envoy/translations/id.json @@ -9,7 +9,7 @@ "invalid_auth": "Autentikasi tidak valid", "unknown": "Kesalahan yang tidak diharapkan" }, - "flow_title": "Envoy {serial} ({host})", + "flow_title": "{serial} ({host})", "step": { "user": { "data": { diff --git a/homeassistant/components/epson/translations/hu.json b/homeassistant/components/epson/translations/hu.json index 8e0d7ec9a18..e3aa507b7c1 100644 --- a/homeassistant/components/epson/translations/hu.json +++ b/homeassistant/components/epson/translations/hu.json @@ -7,7 +7,7 @@ "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "name": "N\u00e9v" } } diff --git a/homeassistant/components/esphome/translations/ca.json b/homeassistant/components/esphome/translations/ca.json index d0c59194528..4c990994e47 100644 --- a/homeassistant/components/esphome/translations/ca.json +++ b/homeassistant/components/esphome/translations/ca.json @@ -2,11 +2,13 @@ "config": { "abort": { "already_configured": "El dispositiu ja est\u00e0 configurat", - "already_in_progress": "El flux de configuraci\u00f3 ja est\u00e0 en curs" + "already_in_progress": "El flux de configuraci\u00f3 ja est\u00e0 en curs", + "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" }, "error": { "connection_error": "No s'ha pogut connectar amb ESP. Verifica que l'arxiu YAML cont\u00e9 la l\u00ednia 'api:'.", "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "invalid_psk": "La clau de xifratge de transport \u00e9s inv\u00e0lida. Assegura't que coincideix amb la de la configuraci\u00f3", "resolve_error": "No s'ha pogut trobar l'adre\u00e7a de l'ESP. Si l'error persisteix, configura una adre\u00e7a IP est\u00e0tica: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, "flow_title": "{name}", @@ -21,6 +23,18 @@ "description": "Vols afegir el node `{name}` d'ESPHome a Home Assistant?", "title": "Node d'ESPHome descobert" }, + "encryption_key": { + "data": { + "noise_psk": "Clau de xifrat" + }, + "description": "Introdueix la clau de xifrat de {name} establerta a la configuraci\u00f3." + }, + "reauth_confirm": { + "data": { + "noise_psk": "Clau de xifrat" + }, + "description": "El dispositiu ESPHome {name} ha activat el xifratge de transport o ha canviat la clau de xifrat. Introdueix la clau actualitzada." + }, "user": { "data": { "host": "Amfitri\u00f3", diff --git a/homeassistant/components/esphome/translations/cs.json b/homeassistant/components/esphome/translations/cs.json index 9a451a8537f..fc4a7d5bf8c 100644 --- a/homeassistant/components/esphome/translations/cs.json +++ b/homeassistant/components/esphome/translations/cs.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno", - "already_in_progress": "Konfigurace ji\u017e prob\u00edh\u00e1" + "already_in_progress": "Konfigurace ji\u017e prob\u00edh\u00e1", + "reauth_successful": "Op\u011btovn\u00e9 ov\u011b\u0159en\u00ed bylo \u00fasp\u011b\u0161n\u00e9" }, "error": { "connection_error": "Nelze se p\u0159ipojit k ESP. Zkontrolujte, zda va\u0161e YAML konfigurace obsahuje \u0159\u00e1dek 'api:'.", diff --git a/homeassistant/components/esphome/translations/de.json b/homeassistant/components/esphome/translations/de.json index 8084ef26f0e..6229c09a03e 100644 --- a/homeassistant/components/esphome/translations/de.json +++ b/homeassistant/components/esphome/translations/de.json @@ -2,11 +2,13 @@ "config": { "abort": { "already_configured": "Ger\u00e4t ist bereits konfiguriert", - "already_in_progress": "Der Konfigurationsablauf wird bereits ausgef\u00fchrt" + "already_in_progress": "Der Konfigurationsablauf wird bereits ausgef\u00fchrt", + "reauth_successful": "Die erneute Authentifizierung war erfolgreich" }, "error": { "connection_error": "Keine Verbindung zum ESP m\u00f6glich. Achte darauf, dass deine YAML-Datei eine Zeile 'api:' enth\u00e4lt.", "invalid_auth": "Ung\u00fcltige Authentifizierung", + "invalid_psk": "Der Transportverschl\u00fcsselungsschl\u00fcssel ist ung\u00fcltig. Bitte stelle sicher, dass es mit deiner Konfiguration \u00fcbereinstimmt", "resolve_error": "Adresse des ESP kann nicht aufgel\u00f6st werden. Wenn dieser Fehler weiterhin besteht, lege eine statische IP-Adresse fest: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, "flow_title": "{name}", @@ -21,6 +23,18 @@ "description": "Willst du den ESPHome-Knoten `{name}` zu Home Assistant hinzuf\u00fcgen?", "title": "Gefundener ESPHome-Knoten" }, + "encryption_key": { + "data": { + "noise_psk": "Verschl\u00fcsselungsschl\u00fcssel" + }, + "description": "Bitte gib den Verschl\u00fcsselungsschl\u00fcssel ein, den du in deiner Konfiguration f\u00fcr {name} festgelegt hast." + }, + "reauth_confirm": { + "data": { + "noise_psk": "Verschl\u00fcsselungsschl\u00fcssel" + }, + "description": "Das ESPHome-Ger\u00e4t {name} hat die Transportverschl\u00fcsselung aktiviert oder den Verschl\u00fcsselungscode ge\u00e4ndert. Bitte gib den aktualisierten Schl\u00fcssel ein." + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/esphome/translations/en.json b/homeassistant/components/esphome/translations/en.json index c57c9d1acb0..5ca5c03f8e9 100644 --- a/homeassistant/components/esphome/translations/en.json +++ b/homeassistant/components/esphome/translations/en.json @@ -2,11 +2,13 @@ "config": { "abort": { "already_configured": "Device is already configured", - "already_in_progress": "Configuration flow is already in progress" + "already_in_progress": "Configuration flow is already in progress", + "reauth_successful": "Re-authentication was successful" }, "error": { "connection_error": "Can't connect to ESP. Please make sure your YAML file contains an 'api:' line.", "invalid_auth": "Invalid authentication", + "invalid_psk": "The transport encryption key is invalid. Please ensure it matches what you have in your configuration", "resolve_error": "Can't resolve address of the ESP. If this error persists, please set a static IP address: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, "flow_title": "{name}", @@ -21,6 +23,18 @@ "description": "Do you want to add the ESPHome node `{name}` to Home Assistant?", "title": "Discovered ESPHome node" }, + "encryption_key": { + "data": { + "noise_psk": "Encryption key" + }, + "description": "Please enter the encryption key you set in your configuration for {name}." + }, + "reauth_confirm": { + "data": { + "noise_psk": "Encryption key" + }, + "description": "The ESPHome device {name} enabled transport encryption or changed the encryption key. Please enter the updated key." + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/esphome/translations/es.json b/homeassistant/components/esphome/translations/es.json index 9c4b3f52406..f7fd73cd227 100644 --- a/homeassistant/components/esphome/translations/es.json +++ b/homeassistant/components/esphome/translations/es.json @@ -2,11 +2,13 @@ "config": { "abort": { "already_configured": "ESP ya est\u00e1 configurado", - "already_in_progress": "La configuraci\u00f3n del ESP ya est\u00e1 en marcha" + "already_in_progress": "La configuraci\u00f3n del ESP ya est\u00e1 en marcha", + "reauth_successful": "La re-autenticaci\u00f3n ha funcionado" }, "error": { "connection_error": "No se puede conectar a ESP. Aseg\u00farate de que tu archivo YAML contenga una l\u00ednea 'api:'.", "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "invalid_psk": "La clave de transporte cifrado no es v\u00e1lida. Por favor, aseg\u00farese de que coincide con la que tiene en su configuraci\u00f3n", "resolve_error": "No se puede resolver la direcci\u00f3n de ESP. Si el error persiste, configura una direcci\u00f3n IP est\u00e1tica: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, "flow_title": "ESPHome: {name}", @@ -21,6 +23,18 @@ "description": "\u00bfQuieres a\u00f1adir el nodo `{name}` de ESPHome a Home Assistant?", "title": "Nodo ESPHome descubierto" }, + "encryption_key": { + "data": { + "noise_psk": "Clave de cifrado" + }, + "description": "Por favor, introduzca la clave de cifrado que estableci\u00f3 en su configuraci\u00f3n para {name}." + }, + "reauth_confirm": { + "data": { + "noise_psk": "Clave de cifrado" + }, + "description": "El dispositivo ESPHome {name} ha activado el transporte cifrado o ha cambiado la clave de cifrado. Por favor, introduzca la clave actualizada." + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/esphome/translations/et.json b/homeassistant/components/esphome/translations/et.json index 4f018931141..ea5119b190d 100644 --- a/homeassistant/components/esphome/translations/et.json +++ b/homeassistant/components/esphome/translations/et.json @@ -2,11 +2,13 @@ "config": { "abort": { "already_configured": "Seade on juba h\u00e4\u00e4lestatud", - "already_in_progress": "Seadistamine on juba k\u00e4imas" + "already_in_progress": "Seadistamine on juba k\u00e4imas", + "reauth_successful": "Taastuvastamine \u00f5nnestus" }, "error": { "connection_error": "ESP-ga ei saa \u00fchendust luua. Veendu, et YAML-fail sisaldab rida 'api:'.", "invalid_auth": "Tuvastamise viga", + "invalid_psk": "\u00dclekande kr\u00fcpteerimisv\u00f5ti on kehtetu. Veendu, et see vastab seadetes sisalduvale", "resolve_error": "ESP aadressi ei \u00f5nnestu lahendada. Kui see viga p\u00fcsib, m\u00e4\u00e4ra staatiline IP-aadress: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, "flow_title": "{name}", @@ -21,6 +23,18 @@ "description": "Kas soovid lisada ESPHome'i s\u00f5lme '{name}' Home Assistant-ile?", "title": "Avastastud ESPHome'i s\u00f5lm" }, + "encryption_key": { + "data": { + "noise_psk": "Kr\u00fcptimisv\u00f5ti" + }, + "description": "Sisesta kr\u00fcptimisv\u00f5ti mille m\u00e4\u00e4rasid oma {name} seadetes." + }, + "reauth_confirm": { + "data": { + "noise_psk": "Kr\u00fcptimisv\u00f5ti" + }, + "description": "ESPHome seade {name} lubas \u00fclekande kr\u00fcptimise v\u00f5i muutis kr\u00fcpteerimisv\u00f5tit. Palun sisesta uuendatud v\u00f5ti." + }, "user": { "data": { "host": "", diff --git a/homeassistant/components/esphome/translations/he.json b/homeassistant/components/esphome/translations/he.json index 5c0f832ba4c..11eaf41ff1a 100644 --- a/homeassistant/components/esphome/translations/he.json +++ b/homeassistant/components/esphome/translations/he.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", - "already_in_progress": "\u05d6\u05e8\u05d9\u05de\u05ea \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4 \u05db\u05d1\u05e8 \u05de\u05ea\u05d1\u05e6\u05e2\u05ea" + "already_in_progress": "\u05d6\u05e8\u05d9\u05de\u05ea \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4 \u05db\u05d1\u05e8 \u05de\u05ea\u05d1\u05e6\u05e2\u05ea", + "reauth_successful": "\u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7" }, "error": { "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9" @@ -15,6 +16,11 @@ }, "description": "\u05d0\u05e0\u05d0 \u05d4\u05d6\u05df \u05d0\u05ea \u05d4\u05e1\u05d9\u05e1\u05de\u05d4 \u05e9\u05d4\u05d2\u05d3\u05e8\u05ea \u05d1\u05ea\u05e6\u05d5\u05e8\u05d4 \u05e9\u05dc\u05da \u05e2\u05d1\u05d5\u05e8 {name}." }, + "encryption_key": { + "data": { + "noise_psk": "\u05de\u05e4\u05ea\u05d7 \u05d4\u05e6\u05e4\u05e0\u05d4" + } + }, "user": { "data": { "host": "\u05de\u05d0\u05e8\u05d7", diff --git a/homeassistant/components/esphome/translations/hu.json b/homeassistant/components/esphome/translations/hu.json index 6c4586fbd55..d7ac503d83c 100644 --- a/homeassistant/components/esphome/translations/hu.json +++ b/homeassistant/components/esphome/translations/hu.json @@ -2,31 +2,45 @@ "config": { "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van." + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van.", + "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt" }, "error": { - "connection_error": "Nem lehet csatlakozni az ESP-hez. K\u00e9rlek gy\u0151z\u0151dj meg r\u00f3la, hogy a YAML f\u00e1jl tartalmaz egy \"api:\" sort.", + "connection_error": "Nem lehet csatlakozni az ESP-hez. K\u00e9rem, gy\u0151z\u0151dj\u00f6n meg r\u00f3la, hogy a YAML f\u00e1jl tartalmaz egy \"api:\" sort.", "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", - "resolve_error": "Az ESP c\u00edme nem oldhat\u00f3 fel. Ha a hiba tov\u00e1bbra is fenn\u00e1ll, k\u00e9rlek, \u00e1ll\u00edts be egy statikus IP-c\u00edmet: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" + "invalid_psk": "Az adat\u00e1tviteli titkos\u00edt\u00e1si kulcs \u00e9rv\u00e9nytelen. K\u00e9rj\u00fck, gy\u0151z\u0151dj\u00f6n meg r\u00f3la, hogy megegyezik a konfigur\u00e1ci\u00f3ban l\u00e9v\u0151vel.", + "resolve_error": "Az ESP c\u00edme nem oldhat\u00f3 fel. Ha a hiba tov\u00e1bbra is fenn\u00e1ll, k\u00e9rem, \u00e1ll\u00edtson be egy statikus IP-c\u00edmet: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, - "flow_title": "ESPHome: {name}", + "flow_title": "{name}", "step": { "authenticate": { "data": { "password": "Jelsz\u00f3" }, - "description": "K\u00e9rlek, add meg a konfigur\u00e1ci\u00f3ban {name} n\u00e9vhez be\u00e1ll\u00edtott jelsz\u00f3t." + "description": "K\u00e9rem, adja meg a konfigur\u00e1ci\u00f3ban {name} n\u00e9vhez be\u00e1ll\u00edtott jelsz\u00f3t." }, "discovery_confirm": { - "description": "Szeretn\u00e9d hozz\u00e1adni a(z) `{name}` ESPHome csom\u00f3pontot a Home Assistant-hoz?", + "description": "Szeretn\u00e9 hozz\u00e1adni `{name}` ESPHome csom\u00f3pontot Home Assistant-hoz?", "title": "Felfedezett ESPHome csom\u00f3pont" }, + "encryption_key": { + "data": { + "noise_psk": "Titkos\u00edt\u00e1si kulcs" + }, + "description": "K\u00e9rj\u00fck, adja meg a {name} konfigur\u00e1ci\u00f3j\u00e1ban be\u00e1ll\u00edtott titkos\u00edt\u00e1si kulcsot." + }, + "reauth_confirm": { + "data": { + "noise_psk": "Titkos\u00edt\u00e1si kulcs" + }, + "description": "{name} ESPHome eszk\u00f6z enged\u00e9lyezte az adat\u00e1tviteli titkos\u00edt\u00e1st vagy megv\u00e1ltoztatta a titkos\u00edt\u00e1si kulcsot. K\u00e9rj\u00fck, adja meg az aktu\u00e1lis kulcsot." + }, "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "port": "Port" }, - "description": "K\u00e9rlek, add meg az [ESPHome](https://esphomelib.com/) csom\u00f3pontod kapcsol\u00f3d\u00e1si be\u00e1ll\u00edt\u00e1sait." + "description": "K\u00e9rem, adja meg az [ESPHome](https://esphomelib.com/) csom\u00f3pontj\u00e1nak kapcsol\u00f3d\u00e1si be\u00e1ll\u00edt\u00e1sait." } } } diff --git a/homeassistant/components/esphome/translations/id.json b/homeassistant/components/esphome/translations/id.json index a39a19e12db..530d86e2f56 100644 --- a/homeassistant/components/esphome/translations/id.json +++ b/homeassistant/components/esphome/translations/id.json @@ -2,14 +2,15 @@ "config": { "abort": { "already_configured": "Perangkat sudah dikonfigurasi", - "already_in_progress": "Alur konfigurasi sedang berlangsung" + "already_in_progress": "Alur konfigurasi sedang berlangsung", + "reauth_successful": "Autentikasi ulang berhasil" }, "error": { "connection_error": "Tidak dapat terhubung ke ESP. Pastikan file YAML Anda mengandung baris 'api:'.", "invalid_auth": "Autentikasi tidak valid", "resolve_error": "Tidak dapat menemukan alamat ESP. Jika kesalahan ini terus terjadi, atur alamat IP statis: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, - "flow_title": "ESPHome: {name}", + "flow_title": "{name}", "step": { "authenticate": { "data": { diff --git a/homeassistant/components/esphome/translations/it.json b/homeassistant/components/esphome/translations/it.json index 34d1ec78f6e..390054bc345 100644 --- a/homeassistant/components/esphome/translations/it.json +++ b/homeassistant/components/esphome/translations/it.json @@ -2,11 +2,13 @@ "config": { "abort": { "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", - "already_in_progress": "Il flusso di configurazione \u00e8 gi\u00e0 in corso" + "already_in_progress": "Il flusso di configurazione \u00e8 gi\u00e0 in corso", + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente" }, "error": { "connection_error": "Impossibile connettersi ad ESP. Assicurati che il tuo file YAML contenga una riga \"api:\".", "invalid_auth": "Autenticazione non valida", + "invalid_psk": "La chiave di crittografia del trasporto non \u00e8 valida. Assicurati che corrisponda a ci\u00f2 che hai nella tua configurazione", "resolve_error": "Impossibile risolvere l'indirizzo dell'ESP. Se questo errore persiste, impostare un indirizzo IP statico: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, "flow_title": "{name}", @@ -21,6 +23,18 @@ "description": "Vuoi aggiungere il nodo ESPHome `{name}` a Home Assistant?", "title": "Trovato nodo ESPHome" }, + "encryption_key": { + "data": { + "noise_psk": "Chiave di crittografia" + }, + "description": "Inserisci la chiave di crittografia che hai impostato nella configurazione per {name}." + }, + "reauth_confirm": { + "data": { + "noise_psk": "Chiave di crittografia" + }, + "description": "Il dispositivo ESPHome {name} ha abilitato la crittografia del trasporto o ha modificato la chiave di crittografia. Inserisci la chiave aggiornata." + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/esphome/translations/nl.json b/homeassistant/components/esphome/translations/nl.json index 019c33004e7..7f6f821104c 100644 --- a/homeassistant/components/esphome/translations/nl.json +++ b/homeassistant/components/esphome/translations/nl.json @@ -2,11 +2,13 @@ "config": { "abort": { "already_configured": "Apparaat is al geconfigureerd", - "already_in_progress": "De configuratiestroom is al begonnen" + "already_in_progress": "De configuratiestroom is al begonnen", + "reauth_successful": "Herauthenticatie was succesvol" }, "error": { "connection_error": "Kan geen verbinding maken met ESP. Zorg ervoor dat uw YAML-bestand een regel 'api:' bevat.", "invalid_auth": "Ongeldige authenticatie", + "invalid_psk": "De transportcoderingssleutel is ongeldig. Zorg ervoor dat het overeenkomt met wat u in uw configuratie heeft", "resolve_error": "Kan het adres van de ESP niet vinden. Als deze fout aanhoudt, stel dan een statisch IP-adres in: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, "flow_title": "{name}", @@ -21,6 +23,18 @@ "description": "Wil je de ESPHome-node `{name}` toevoegen aan de Home Assistant?", "title": "ESPHome node ontdekt" }, + "encryption_key": { + "data": { + "noise_psk": "Coderingssleutel" + }, + "description": "Voer de coderingssleutel in die u in uw configuratie voor {name}." + }, + "reauth_confirm": { + "data": { + "noise_psk": "Coderingssleutel" + }, + "description": "Het ESPHome-apparaat {name} heeft transportcodering ingeschakeld of de coderingssleutel gewijzigd. Voer de bijgewerkte sleutel in." + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/esphome/translations/no.json b/homeassistant/components/esphome/translations/no.json index 14b92500f41..0d583893570 100644 --- a/homeassistant/components/esphome/translations/no.json +++ b/homeassistant/components/esphome/translations/no.json @@ -2,11 +2,13 @@ "config": { "abort": { "already_configured": "Enheten er allerede konfigurert", - "already_in_progress": "Konfigurasjonsflyten p\u00e5g\u00e5r allerede" + "already_in_progress": "Konfigurasjonsflyten p\u00e5g\u00e5r allerede", + "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" }, "error": { "connection_error": "Kan ikke koble til ESP. Kontroller at YAML filen din inneholder en \"api:\" linje.", "invalid_auth": "Ugyldig godkjenning", + "invalid_psk": "Transportkrypteringsn\u00f8kkelen er ugyldig. S\u00f8rg for at den samsvarer med det du har i konfigurasjonen", "resolve_error": "Kan ikke l\u00f8se adressen til ESP. Hvis denne feilen vedvarer, vennligst [sett en statisk IP-adresse](https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips)" }, "flow_title": "{name}", @@ -21,6 +23,18 @@ "description": "\u00d8nsker du \u00e5 legge ESPHome noden `{name}` til Home Assistant?", "title": "Oppdaget ESPHome node" }, + "encryption_key": { + "data": { + "noise_psk": "Krypteringsn\u00f8kkel" + }, + "description": "Skriv inn krypteringsn\u00f8kkelen du angav i konfigurasjonen for {name} ." + }, + "reauth_confirm": { + "data": { + "noise_psk": "Krypteringsn\u00f8kkel" + }, + "description": "ESPHome -enheten {name} aktiverte transportkryptering eller endret krypteringsn\u00f8kkelen. Skriv inn den oppdaterte n\u00f8kkelen." + }, "user": { "data": { "host": "Vert", diff --git a/homeassistant/components/esphome/translations/ru.json b/homeassistant/components/esphome/translations/ru.json index 5987a7db13b..8ba4a573cec 100644 --- a/homeassistant/components/esphome/translations/ru.json +++ b/homeassistant/components/esphome/translations/ru.json @@ -2,11 +2,13 @@ "config": { "abort": { "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant.", - "already_in_progress": "\u041f\u0440\u043e\u0446\u0435\u0441\u0441 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u044f\u0435\u0442\u0441\u044f." + "already_in_progress": "\u041f\u0440\u043e\u0446\u0435\u0441\u0441 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u044f\u0435\u0442\u0441\u044f.", + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430 \u0443\u0441\u043f\u0435\u0448\u043d\u043e." }, "error": { "connection_error": "\u041d\u0435 \u0443\u0434\u0430\u0435\u0442\u0441\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a ESP. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u0443\u0431\u0435\u0434\u0438\u0442\u0435\u0441\u044c, \u0447\u0442\u043e \u0412\u0430\u0448 YAML-\u0444\u0430\u0439\u043b \u0441\u043e\u0434\u0435\u0440\u0436\u0438\u0442 \u0441\u0442\u0440\u043e\u043a\u0443 'api:'.", "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", + "invalid_psk": "\u041a\u043b\u044e\u0447 \u0442\u0440\u0430\u043d\u0441\u043f\u043e\u0440\u0442\u043d\u043e\u0433\u043e \u0448\u0438\u0444\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u043d\u0435\u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0442\u0435\u043b\u0435\u043d. \u0423\u0431\u0435\u0434\u0438\u0442\u0435\u0441\u044c, \u0447\u0442\u043e \u043e\u043d \u0441\u043e\u043e\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0443\u0435\u0442 \u0443\u043a\u0430\u0437\u0430\u043d\u043d\u043e\u043c\u0443 \u0432 \u0412\u0430\u0448\u0435\u0439 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438.", "resolve_error": "\u041d\u0435 \u0443\u0434\u0430\u0435\u0442\u0441\u044f \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0438\u0442\u044c \u0430\u0434\u0440\u0435\u0441 ESP. \u0415\u0441\u043b\u0438 \u044d\u0442\u0430 \u043e\u0448\u0438\u0431\u043a\u0430 \u043f\u043e\u0432\u0442\u043e\u0440\u044f\u0435\u0442\u0441\u044f, \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 \u0441\u0442\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u0438\u0439 IP-\u0430\u0434\u0440\u0435\u0441: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips." }, "flow_title": "{name}", @@ -21,6 +23,18 @@ "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c ESPHome `{name}`?", "title": "ESPHome" }, + "encryption_key": { + "data": { + "noise_psk": "\u041a\u043b\u044e\u0447 \u0448\u0438\u0444\u0440\u043e\u0432\u0430\u043d\u0438\u044f" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043a\u043b\u044e\u0447 \u0448\u0438\u0444\u0440\u043e\u0432\u0430\u043d\u0438\u044f, \u0443\u043a\u0430\u0437\u0430\u043d\u043d\u044b\u0439 \u0432 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 {name}." + }, + "reauth_confirm": { + "data": { + "noise_psk": "\u041a\u043b\u044e\u0447 \u0448\u0438\u0444\u0440\u043e\u0432\u0430\u043d\u0438\u044f" + }, + "description": "\u0414\u043b\u044f \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 {name} \u0430\u043a\u0442\u0438\u0432\u0438\u0440\u043e\u0432\u0430\u043d\u043e \u0448\u0438\u0444\u0440\u043e\u0432\u0430\u043d\u0438\u0435 \u0442\u0440\u0430\u043d\u0441\u043f\u043e\u0440\u0442\u043d\u043e\u0433\u043e \u0443\u0440\u043e\u0432\u043d\u044f \u0438\u043b\u0438 \u0438\u0437\u043c\u0435\u043d\u0451\u043d \u043a\u043b\u044e\u0447 \u0448\u0438\u0444\u0440\u043e\u0432\u0430\u043d\u0438\u044f. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u0432\u0432\u0435\u0434\u0438\u0442\u0435 \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u043d\u044b\u0439 \u043a\u043b\u044e\u0447." + }, "user": { "data": { "host": "\u0425\u043e\u0441\u0442", diff --git a/homeassistant/components/esphome/translations/zh-Hant.json b/homeassistant/components/esphome/translations/zh-Hant.json index 6ea440c02df..0b415a35c38 100644 --- a/homeassistant/components/esphome/translations/zh-Hant.json +++ b/homeassistant/components/esphome/translations/zh-Hant.json @@ -2,11 +2,13 @@ "config": { "abort": { "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", - "already_in_progress": "\u8a2d\u5b9a\u5df2\u7d93\u9032\u884c\u4e2d" + "already_in_progress": "\u8a2d\u5b9a\u5df2\u7d93\u9032\u884c\u4e2d", + "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f" }, "error": { "connection_error": "\u7121\u6cd5\u9023\u7dda\u81f3 ESP\uff0c\u8acb\u78ba\u5b9a\u60a8\u7684 YAML \u6a94\u6848\u5305\u542b\u300capi:\u300d\u8a2d\u5b9a\u5217\u3002", "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "invalid_psk": "\u50b3\u8f38\u5bc6\u9470\u7121\u6548\u3002\u8acb\u78ba\u5b9a\u8207\u8a2d\u5b9a\u5167\u6240\u8a2d\u5b9a\u4e4b\u5bc6\u9470\u76f8\u7b26\u5408", "resolve_error": "\u7121\u6cd5\u89e3\u6790 ESP \u4f4d\u5740\uff0c\u5047\u5982\u6b64\u932f\u8aa4\u6301\u7e8c\u767c\u751f\uff0c\u8acb\u53c3\u8003\u8aaa\u660e\u8a2d\u5b9a\u70ba\u975c\u614b\u56fa\u5b9a IP \uff1a https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, "flow_title": "{name}", @@ -21,6 +23,18 @@ "description": "\u662f\u5426\u8981\u5c07 ESPHome \u7bc0\u9ede `{name}` \u65b0\u589e\u81f3 Home Assistant\uff1f", "title": "\u81ea\u52d5\u63a2\u7d22\u5230 ESPHome \u7bc0\u9ede" }, + "encryption_key": { + "data": { + "noise_psk": "\u5bc6\u9470" + }, + "description": "\u8acb\u8f38\u5165 {name} \u8a2d\u5b9a\u4e2d\u6240\u8a2d\u5b9a\u4e4b\u5bc6\u9470\u3002" + }, + "reauth_confirm": { + "data": { + "noise_psk": "\u5bc6\u9470" + }, + "description": "ESPHome \u88dd\u7f6e {name} \u5df2\u958b\u555f\u50b3\u8f38\u52a0\u5bc6\u6216\u8b8a\u66f4\u5bc6\u9470\u3002\u8acb\u8f38\u5165\u66f4\u65b0\u5bc6\u9470\u3002" + }, "user": { "data": { "host": "\u4e3b\u6a5f\u7aef", diff --git a/homeassistant/components/ezviz/translations/ca.json b/homeassistant/components/ezviz/translations/ca.json index c7c71e07122..7c71de300f6 100644 --- a/homeassistant/components/ezviz/translations/ca.json +++ b/homeassistant/components/ezviz/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured_account": "El compte ja ha estat configurat", + "already_configured_account": "El compte ja est\u00e0 configurat", "ezviz_cloud_account_missing": "Falta el compte d'Ezviz cloud. Torna'l a configurar", "unknown": "Error inesperat" }, diff --git a/homeassistant/components/ezviz/translations/hu.json b/homeassistant/components/ezviz/translations/hu.json index 3ece0a79dcf..5907f66ceb4 100644 --- a/homeassistant/components/ezviz/translations/hu.json +++ b/homeassistant/components/ezviz/translations/hu.json @@ -8,7 +8,7 @@ "error": { "cannot_connect": "Nem siker\u00fclt csatlakozni", "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", - "invalid_host": "\u00c9rv\u00e9nytelen gazdag\u00e9pn\u00e9v vagy IP-c\u00edm" + "invalid_host": "\u00c9rv\u00e9nytelen C\u00edm" }, "flow_title": "{serial}", "step": { diff --git a/homeassistant/components/fireservicerota/translations/ca.json b/homeassistant/components/fireservicerota/translations/ca.json index 287bb81e51e..261350db3f8 100644 --- a/homeassistant/components/fireservicerota/translations/ca.json +++ b/homeassistant/components/fireservicerota/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat", + "already_configured": "El compte ja est\u00e0 configurat", "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" }, "create_entry": { diff --git a/homeassistant/components/fjaraskupan/translations/es.json b/homeassistant/components/fjaraskupan/translations/es.json new file mode 100644 index 00000000000..36ff1884048 --- /dev/null +++ b/homeassistant/components/fjaraskupan/translations/es.json @@ -0,0 +1,8 @@ +{ + "config": { + "abort": { + "no_devices_found": "No se encontraron dispositivos en la red", + "single_instance_allowed": "Ya est\u00e1 configurado. Solo es posible una \u00fanica configuraci\u00f3n." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fjaraskupan/translations/id.json b/homeassistant/components/fjaraskupan/translations/id.json new file mode 100644 index 00000000000..ed64894fff4 --- /dev/null +++ b/homeassistant/components/fjaraskupan/translations/id.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "Tidak ada perangkat yang ditemukan di jaringan", + "single_instance_allowed": "Sudah dikonfigurasi. Hanya satu konfigurasi yang diizinkan." + }, + "step": { + "confirm": { + "description": "Ingin menyiapkan Fj\u00e4r\u00e5skupan?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flick_electric/translations/ca.json b/homeassistant/components/flick_electric/translations/ca.json index 74fd0e79708..b98cfc742db 100644 --- a/homeassistant/components/flick_electric/translations/ca.json +++ b/homeassistant/components/flick_electric/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat" + "already_configured": "El compte ja est\u00e0 configurat" }, "error": { "cannot_connect": "Ha fallat la connexi\u00f3", diff --git a/homeassistant/components/flick_electric/translations/he.json b/homeassistant/components/flick_electric/translations/he.json index b1e5464047b..0cbd1ab331b 100644 --- a/homeassistant/components/flick_electric/translations/he.json +++ b/homeassistant/components/flick_electric/translations/he.json @@ -11,8 +11,8 @@ "step": { "user": { "data": { - "client_id": "Client ID (\u05d0\u05d5\u05e4\u05e6\u05d9\u05d5\u05e0\u05dc\u05d9)", - "client_secret": "Client Secret (\u05d0\u05d5\u05e4\u05e6\u05d9\u05d5\u05e0\u05dc\u05d9)", + "client_id": "\u05de\u05d6\u05d4\u05d4 \u05dc\u05e7\u05d5\u05d7 (\u05d0\u05d5\u05e4\u05e6\u05d9\u05d5\u05e0\u05dc\u05d9)", + "client_secret": "\u05e1\u05d5\u05d3 \u05dc\u05e7\u05d5\u05d7 (\u05d0\u05d5\u05e4\u05e6\u05d9\u05d5\u05e0\u05dc\u05d9)", "password": "\u05e1\u05d9\u05e1\u05de\u05d4", "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" } diff --git a/homeassistant/components/flick_electric/translations/id.json b/homeassistant/components/flick_electric/translations/id.json index 8c283cfd56e..3085534a862 100644 --- a/homeassistant/components/flick_electric/translations/id.json +++ b/homeassistant/components/flick_electric/translations/id.json @@ -11,8 +11,8 @@ "step": { "user": { "data": { - "client_id": "ID Klien (Opsional)", - "client_secret": "Kode Rahasia Klien (Opsional)", + "client_id": "ID Klien (opsional)", + "client_secret": "Kode Rahasia Klien (opsional)", "password": "Kata Sandi", "username": "Nama Pengguna" }, diff --git a/homeassistant/components/flipr/translations/es.json b/homeassistant/components/flipr/translations/es.json index 69ff84c2e69..0a066451b84 100644 --- a/homeassistant/components/flipr/translations/es.json +++ b/homeassistant/components/flipr/translations/es.json @@ -1,8 +1,13 @@ { "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado" + }, "error": { + "cannot_connect": "No se pudo conectar", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", "no_flipr_id_found": "Por ahora no hay ning\u00fan ID de Flipr asociado a tu cuenta. Deber\u00edas verificar que est\u00e1 funcionando con la aplicaci\u00f3n m\u00f3vil de Flipr primero.", - "unknown": "Error desconocido" + "unknown": "Error inesperado" }, "step": { "flipr_id": { @@ -14,8 +19,8 @@ }, "user": { "data": { - "email": "Correo-e", - "password": "Clave" + "email": "Correo electr\u00f3nico", + "password": "Contrase\u00f1a" }, "description": "Con\u00e9ctese usando su cuenta Flipr.", "title": "Conectarse a Flipr" diff --git a/homeassistant/components/flipr/translations/id.json b/homeassistant/components/flipr/translations/id.json new file mode 100644 index 00000000000..63751867097 --- /dev/null +++ b/homeassistant/components/flipr/translations/id.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi" + }, + "error": { + "cannot_connect": "Gagal terhubung", + "invalid_auth": "Autentikasi tidak valid", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "user": { + "data": { + "email": "Email", + "password": "Kata Sandi" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flo/translations/hu.json b/homeassistant/components/flo/translations/hu.json index 0abcc301f0c..9590d3c12be 100644 --- a/homeassistant/components/flo/translations/hu.json +++ b/homeassistant/components/flo/translations/hu.json @@ -11,7 +11,7 @@ "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" } diff --git a/homeassistant/components/flume/translations/ca.json b/homeassistant/components/flume/translations/ca.json index 04a7accf4a5..5cd81a00a67 100644 --- a/homeassistant/components/flume/translations/ca.json +++ b/homeassistant/components/flume/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat", + "already_configured": "El compte ja est\u00e0 configurat", "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" }, "error": { diff --git a/homeassistant/components/forecast_solar/translations/es.json b/homeassistant/components/forecast_solar/translations/es.json index 8a1b51a5084..d688c577024 100644 --- a/homeassistant/components/forecast_solar/translations/es.json +++ b/homeassistant/components/forecast_solar/translations/es.json @@ -5,7 +5,10 @@ "data": { "azimuth": "Acimut (360 grados, 0 = Norte, 90 = Este, 180 = Sur, 270 = Oeste)", "declination": "Declinaci\u00f3n (0 = Horizontal, 90 = Vertical)", - "modules power": "Potencia total en vatios pico de sus m\u00f3dulos solares" + "latitude": "Latitud", + "longitude": "Longitud", + "modules power": "Potencia total en vatios pico de sus m\u00f3dulos solares", + "name": "Nombre" }, "description": "Rellene los datos de sus paneles solares. Consulte la documentaci\u00f3n si alg\u00fan campo no est\u00e1 claro." } diff --git a/homeassistant/components/forecast_solar/translations/id.json b/homeassistant/components/forecast_solar/translations/id.json index b0a5ddcdc7e..130f66db7f5 100644 --- a/homeassistant/components/forecast_solar/translations/id.json +++ b/homeassistant/components/forecast_solar/translations/id.json @@ -3,7 +3,9 @@ "step": { "user": { "data": { - "latitude": "Lintang" + "latitude": "Lintang", + "longitude": "Bujur", + "name": "Nama" } } } diff --git a/homeassistant/components/forked_daapd/translations/hu.json b/homeassistant/components/forked_daapd/translations/hu.json index bbf8cb560ff..2058bbd1cbe 100644 --- a/homeassistant/components/forked_daapd/translations/hu.json +++ b/homeassistant/components/forked_daapd/translations/hu.json @@ -8,15 +8,15 @@ "forbidden": "Nem tud csatlakozni. K\u00e9rj\u00fck, ellen\u0151rizze a forked-daapd h\u00e1l\u00f3zati enged\u00e9lyeket.", "unknown_error": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt", "websocket_not_enabled": "forked-daapd szerver websocket nincs enged\u00e9lyezve.", - "wrong_host_or_port": "Nem tud csatlakozni. K\u00e9rj\u00fck, ellen\u0151rizze a gazdag\u00e9pet \u00e9s a portot.", + "wrong_host_or_port": "Nem tud csatlakozni. K\u00e9rj\u00fck, ellen\u0151rizze a c\u00edmet \u00e9s a portot.", "wrong_password": "Helytelen jelsz\u00f3.", - "wrong_server_type": "A forked-daapd integr\u00e1ci\u00f3hoz forked-daapd szerver sz\u00fcks\u00e9ges, amelynek verzi\u00f3ja> = 27.0." + "wrong_server_type": "A forked-daapd integr\u00e1ci\u00f3hoz forked-daapd szerver sz\u00fcks\u00e9ges, amelynek verzi\u00f3ja legal\u00e1bb 27.0." }, "flow_title": "{name} ({host})", "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "name": "Megjelen\u00edt\u00e9si n\u00e9v", "password": "API jelsz\u00f3 (hagyja \u00fcresen, ha nincs jelsz\u00f3)", "port": "API port" diff --git a/homeassistant/components/forked_daapd/translations/id.json b/homeassistant/components/forked_daapd/translations/id.json index 76787e2a19b..f57a8fb8566 100644 --- a/homeassistant/components/forked_daapd/translations/id.json +++ b/homeassistant/components/forked_daapd/translations/id.json @@ -12,7 +12,7 @@ "wrong_password": "Kata sandi salah.", "wrong_server_type": "Integrasi forked-daapd membutuhkan server forked-daapd dengan versi >= 27.0." }, - "flow_title": "forked-daapd server: {name} ({host})", + "flow_title": "{name} ({host})", "step": { "user": { "data": { diff --git a/homeassistant/components/foscam/translations/hu.json b/homeassistant/components/foscam/translations/hu.json index 63ea95210ff..b303db792bb 100644 --- a/homeassistant/components/foscam/translations/hu.json +++ b/homeassistant/components/foscam/translations/hu.json @@ -12,7 +12,7 @@ "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "password": "Jelsz\u00f3", "port": "Port", "rtsp_port": "RTSP port", diff --git a/homeassistant/components/freebox/translations/hu.json b/homeassistant/components/freebox/translations/hu.json index c929d56f38e..873e1057c15 100644 --- a/homeassistant/components/freebox/translations/hu.json +++ b/homeassistant/components/freebox/translations/hu.json @@ -10,12 +10,12 @@ }, "step": { "link": { - "description": "Kattintson a \u201eK\u00fcld\u00e9s\u201d gombra, majd \u00e9rintse meg a jobbra mutat\u00f3 nyilat az \u00fatv\u00e1laszt\u00f3n a Freebox regisztr\u00e1l\u00e1s\u00e1hoz a HomeAssistant seg\u00edts\u00e9g\u00e9vel. \n\n ! [A gomb helye az \u00fatv\u00e1laszt\u00f3n] (/static/images/config_freebox.png)", + "description": "Kattintson a \u201eK\u00fcld\u00e9s\u201d gombra, majd \u00e9rintse meg a jobbra mutat\u00f3 nyilat az \u00fatv\u00e1laszt\u00f3n a Freebox regisztr\u00e1l\u00e1s\u00e1hoz Home Assistant seg\u00edts\u00e9g\u00e9vel. \n\n![A gomb helye a routeren] (/static/images/config_freebox.png)", "title": "Freebox \u00fatv\u00e1laszt\u00f3 linkel\u00e9se" }, "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "port": "Port" }, "title": "Freebox" diff --git a/homeassistant/components/freedompro/translations/id.json b/homeassistant/components/freedompro/translations/id.json index 82523dc65d1..9676af6d8f9 100644 --- a/homeassistant/components/freedompro/translations/id.json +++ b/homeassistant/components/freedompro/translations/id.json @@ -1,5 +1,12 @@ { "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi" + }, + "error": { + "cannot_connect": "Gagal terhubung", + "invalid_auth": "Autentikasi tidak valid" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/fritz/translations/es.json b/homeassistant/components/fritz/translations/es.json index f9f586bfa71..45519eb7eb5 100644 --- a/homeassistant/components/fritz/translations/es.json +++ b/homeassistant/components/fritz/translations/es.json @@ -8,6 +8,7 @@ "error": { "already_configured": "El dispositivo ya est\u00e1 configurado", "already_in_progress": "El flujo de configuraci\u00f3n ya est\u00e1 en proceso", + "cannot_connect": "No se pudo conectar", "connection_error": "No se pudo conectar", "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida" }, @@ -18,7 +19,7 @@ "password": "Contrase\u00f1a", "username": "Usuario" }, - "description": "Descubierto FRITZ!Box: {nombre}\n\nConfigurar FRITZ!Box Tools para controlar tu {nombre}", + "description": "Descubierto FRITZ!Box: {name}\n\nConfigurar FRITZ!Box Tools para controlar tu {name}", "title": "Configurar FRITZ!Box Tools" }, "reauth_confirm": { @@ -43,7 +44,8 @@ "data": { "host": "Anfitri\u00f3n", "password": "Contrase\u00f1a", - "port": "Puerto" + "port": "Puerto", + "username": "Usuario" }, "description": "Configure las herramientas de FRITZ! Box para controlar su FRITZ! Box.\n M\u00ednimo necesario: nombre de usuario, contrase\u00f1a.", "title": "Configurar las herramientas de FRITZ! Box" diff --git a/homeassistant/components/fritz/translations/hu.json b/homeassistant/components/fritz/translations/hu.json index 1433860bfa6..733a4fb1a8e 100644 --- a/homeassistant/components/fritz/translations/hu.json +++ b/homeassistant/components/fritz/translations/hu.json @@ -32,7 +32,7 @@ }, "start_config": { "data": { - "host": "Gazdag\u00e9p", + "host": "C\u00edm", "password": "Jelsz\u00f3", "port": "Port", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" @@ -42,7 +42,7 @@ }, "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "password": "Jelsz\u00f3", "port": "Port", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" diff --git a/homeassistant/components/fritz/translations/ko.json b/homeassistant/components/fritz/translations/ko.json new file mode 100644 index 00000000000..718b105df33 --- /dev/null +++ b/homeassistant/components/fritz/translations/ko.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "already_in_progress": "\uae30\uae30 \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589 \uc911\uc785\ub2c8\ub2e4", + "reauth_successful": "\uc7ac\uc778\uc99d\uc5d0 \uc131\uacf5\ud588\uc2b5\ub2c8\ub2e4" + }, + "error": { + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "already_in_progress": "\uae30\uae30 \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589 \uc911\uc785\ub2c8\ub2e4", + "connection_error": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "data": { + "password": "\ube44\ubc00\ubc88\ud638", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" + } + }, + "reauth_confirm": { + "data": { + "password": "\ube44\ubc00\ubc88\ud638", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" + } + }, + "start_config": { + "data": { + "host": "\ud638\uc2a4\ud2b8", + "password": "\ube44\ubc00\ubc88\ud638", + "port": "\ud3ec\ud2b8", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fritz/translations/ru.json b/homeassistant/components/fritz/translations/ru.json index 7c030ca4d88..54619e22a36 100644 --- a/homeassistant/components/fritz/translations/ru.json +++ b/homeassistant/components/fritz/translations/ru.json @@ -56,7 +56,7 @@ "step": { "init": { "data": { - "consider_home": "\u0412\u0440\u0435\u043c\u044f \u043e\u0436\u0438\u0434\u0430\u043d\u0438\u044f (\u0432 \u0441\u0435\u043a\u0443\u043d\u0434\u0430\u0445), \u043f\u043e \u0438\u0441\u0442\u0435\u0447\u0435\u043d\u0438\u044e \u043a\u043e\u0442\u043e\u0440\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043f\u043e\u043b\u0443\u0447\u0438\u0442 \u0441\u0442\u0430\u0442\u0443\u0441 \"\u0414\u043e\u043c\u0430\"" + "consider_home": "\u0412\u0440\u0435\u043c\u044f, \u0432 \u0442\u0435\u0447\u0435\u043d\u0438\u0438 \u043a\u043e\u0442\u043e\u0440\u043e\u0433\u043e \u0441\u0447\u0438\u0442\u0430\u0442\u044c \u0447\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0434\u043e\u043c\u0430 (\u0432 \u0441\u0435\u043a\u0443\u043d\u0434\u0430\u0445)" } } } diff --git a/homeassistant/components/fritzbox/translations/hu.json b/homeassistant/components/fritzbox/translations/hu.json index 50a81601310..c5d5e495131 100644 --- a/homeassistant/components/fritzbox/translations/hu.json +++ b/homeassistant/components/fritzbox/translations/hu.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van.", "no_devices_found": "Nem tal\u00e1lhat\u00f3 eszk\u00f6z a h\u00e1l\u00f3zaton", "not_supported": "Csatlakoztatva az AVM FRITZ! Boxhoz, de nem tudja vez\u00e9relni az intelligens otthoni eszk\u00f6z\u00f6ket.", "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt" @@ -17,7 +17,7 @@ "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" }, - "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a {name}-t?" + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani: {name}?" }, "reauth_confirm": { "data": { @@ -28,7 +28,7 @@ }, "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" }, diff --git a/homeassistant/components/fritzbox/translations/id.json b/homeassistant/components/fritzbox/translations/id.json index 8dbd1f71534..f9c4f09b4ae 100644 --- a/homeassistant/components/fritzbox/translations/id.json +++ b/homeassistant/components/fritzbox/translations/id.json @@ -10,7 +10,7 @@ "error": { "invalid_auth": "Autentikasi tidak valid" }, - "flow_title": "AVM FRITZ!Box: {name}", + "flow_title": "{name}", "step": { "confirm": { "data": { diff --git a/homeassistant/components/fritzbox_callmonitor/translations/hu.json b/homeassistant/components/fritzbox_callmonitor/translations/hu.json index 5006dd77f14..86b4c637ca0 100644 --- a/homeassistant/components/fritzbox_callmonitor/translations/hu.json +++ b/homeassistant/components/fritzbox_callmonitor/translations/hu.json @@ -17,7 +17,7 @@ }, "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "password": "Jelsz\u00f3", "port": "Port", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" diff --git a/homeassistant/components/fritzbox_callmonitor/translations/id.json b/homeassistant/components/fritzbox_callmonitor/translations/id.json index 43bb4a16b47..1325edd720c 100644 --- a/homeassistant/components/fritzbox_callmonitor/translations/id.json +++ b/homeassistant/components/fritzbox_callmonitor/translations/id.json @@ -8,7 +8,7 @@ "error": { "invalid_auth": "Autentikasi tidak valid" }, - "flow_title": "Pantau panggilan AVM FRITZ!Box: {name}", + "flow_title": "{name}", "step": { "phonebook": { "data": { diff --git a/homeassistant/components/garages_amsterdam/translations/es.json b/homeassistant/components/garages_amsterdam/translations/es.json index bfea8be63c1..79433b6b854 100644 --- a/homeassistant/components/garages_amsterdam/translations/es.json +++ b/homeassistant/components/garages_amsterdam/translations/es.json @@ -1,6 +1,8 @@ { "config": { "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado", + "cannot_connect": "No se pudo conectar", "unknown": "Error inesperado" }, "step": { diff --git a/homeassistant/components/geofency/translations/hu.json b/homeassistant/components/geofency/translations/hu.json index 826b943e2f8..de8f368adb3 100644 --- a/homeassistant/components/geofency/translations/hu.json +++ b/homeassistant/components/geofency/translations/hu.json @@ -2,14 +2,14 @@ "config": { "abort": { "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges.", - "webhook_not_internet_accessible": "A Home Assistant rendszerednek el\u00e9rhet\u0151nek kell lennie az internetr\u0151l a webhook \u00fczenetek fogad\u00e1s\u00e1hoz." + "webhook_not_internet_accessible": "A Home Assistant p\u00e9ld\u00e1ny\u00e1nak el\u00e9rhet\u0151nek kell lennie az internet fel\u0151l a webhook \u00fczenetek fogad\u00e1s\u00e1hoz." }, "create_entry": { "default": "Az esem\u00e9ny Home Assistantnek val\u00f3 k\u00fcld\u00e9s\u00e9hez be kell \u00e1ll\u00edtanod a webhook funkci\u00f3t a Geofencyben. \n\n Az al\u00e1bbi inform\u00e1ci\u00f3kat haszn\u00e1ld: \n\n - URL: `{webhook_url}` \n - Method: POST \n\n Tov\u00e1bbi r\u00e9szletek a [dokument\u00e1ci\u00f3]({docs_url}) linken tal\u00e1lhat\u00f3k." }, "step": { "user": { - "description": "Biztosan be szeretn\u00e9d \u00e1ll\u00edtani a Geofency Webhookot?", + "description": "Biztos benne, hogy be szeretn\u00e9 \u00e1ll\u00edtani a Geofency Webhookot?", "title": "A Geofency Webhook be\u00e1ll\u00edt\u00e1sa" } } diff --git a/homeassistant/components/glances/translations/hu.json b/homeassistant/components/glances/translations/hu.json index d85baecb5ca..d93fa4bb66e 100644 --- a/homeassistant/components/glances/translations/hu.json +++ b/homeassistant/components/glances/translations/hu.json @@ -10,7 +10,7 @@ "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "name": "N\u00e9v", "password": "Jelsz\u00f3", "port": "Port", diff --git a/homeassistant/components/goalzero/translations/es.json b/homeassistant/components/goalzero/translations/es.json index 8c3aeb80965..fa54d6d6afc 100644 --- a/homeassistant/components/goalzero/translations/es.json +++ b/homeassistant/components/goalzero/translations/es.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "La cuenta ya ha sido configurada", + "invalid_host": "Nombre de host o direcci\u00f3n IP no v\u00e1lidos", "unknown": "Error inesperado" }, "error": { diff --git a/homeassistant/components/goalzero/translations/hu.json b/homeassistant/components/goalzero/translations/hu.json index f8c507a6625..62c0a1626f9 100644 --- a/homeassistant/components/goalzero/translations/hu.json +++ b/homeassistant/components/goalzero/translations/hu.json @@ -12,15 +12,15 @@ }, "step": { "confirm_discovery": { - "description": "DHCP foglal\u00e1s aj\u00e1nlott az \u00fatv\u00e1laszt\u00f3n. Ha nincs be\u00e1ll\u00edtva, akkor az eszk\u00f6z el\u00e9rhetetlenn\u00e9 v\u00e1lhat, am\u00edg a Home Assistant \u00e9szleli az \u00faj IP-c\u00edmet. Olvassa el az \u00fatv\u00e1laszt\u00f3 felhaszn\u00e1l\u00f3i k\u00e9zik\u00f6nyv\u00e9t.", + "description": "DHCP foglal\u00e1s aj\u00e1nlott az routeren. Ha nincs be\u00e1ll\u00edtva, akkor az eszk\u00f6z el\u00e9rhetetlenn\u00e9 v\u00e1lhat, am\u00edg Home Assistant \u00e9szleli az \u00faj IP-c\u00edmet. Olvassa el az router felhaszn\u00e1l\u00f3i k\u00e9zik\u00f6nyv\u00e9t.", "title": "Goal Zero Yeti" }, "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "name": "N\u00e9v" }, - "description": "El\u0151sz\u00f6r le kell t\u00f6ltenie a Goal Zero alkalmaz\u00e1st: https://www.goalzero.com/product-features/yeti-app/ \n\nK\u00f6vesse az utas\u00edt\u00e1sokat, hogy csatlakoztassa Yeti k\u00e9sz\u00fcl\u00e9k\u00e9t a Wi-Fi h\u00e1l\u00f3zathoz. DHCP foglal\u00e1s aj\u00e1nlott az \u00fatv\u00e1laszt\u00f3n. Ha nincs be\u00e1ll\u00edtva, akkor az eszk\u00f6z el\u00e9rhetetlenn\u00e9 v\u00e1lhat, am\u00edg a Home Assistant \u00e9szleli az \u00faj IP-c\u00edmet. Olvassa el az \u00fatv\u00e1laszt\u00f3 felhaszn\u00e1l\u00f3i k\u00e9zik\u00f6nyv\u00e9t.", + "description": "El\u0151sz\u00f6r le kell t\u00f6ltenie a Goal Zero alkalmaz\u00e1st: https://www.goalzero.com/product-features/yeti-app/ \n\nK\u00f6vesse az utas\u00edt\u00e1sokat, hogy csatlakoztassa Yeti k\u00e9sz\u00fcl\u00e9k\u00e9t a Wi-Fi h\u00e1l\u00f3zathoz. DHCP foglal\u00e1s aj\u00e1nlott az routeren. Ha nincs be\u00e1ll\u00edtva, akkor az eszk\u00f6z el\u00e9rhetetlenn\u00e9 v\u00e1lhat, am\u00edg a Home Assistant \u00e9szleli az \u00faj IP-c\u00edmet. Olvassa el az router felhaszn\u00e1l\u00f3i k\u00e9zik\u00f6nyv\u00e9t.", "title": "Goal Zero Yeti" } } diff --git a/homeassistant/components/gogogate2/translations/id.json b/homeassistant/components/gogogate2/translations/id.json index 89d25d74a48..04029205389 100644 --- a/homeassistant/components/gogogate2/translations/id.json +++ b/homeassistant/components/gogogate2/translations/id.json @@ -16,7 +16,7 @@ "username": "Nama Pengguna" }, "description": "Berikan informasi yang diperlukan di bawah ini.", - "title": "Siapkan GogoGate2 atau iSmartGate" + "title": "Siapkan GogoGate2 atau ismartgate" } } } diff --git a/homeassistant/components/gpslogger/translations/hu.json b/homeassistant/components/gpslogger/translations/hu.json index fe459ca3164..45832cf493f 100644 --- a/homeassistant/components/gpslogger/translations/hu.json +++ b/homeassistant/components/gpslogger/translations/hu.json @@ -2,14 +2,14 @@ "config": { "abort": { "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges.", - "webhook_not_internet_accessible": "A Home Assistant rendszerednek el\u00e9rhet\u0151nek kell lennie az internetr\u0151l a webhook \u00fczenetek fogad\u00e1s\u00e1hoz." + "webhook_not_internet_accessible": "A Home Assistant p\u00e9ld\u00e1ny\u00e1nak el\u00e9rhet\u0151nek kell lennie az internet fel\u0151l a webhook \u00fczenetek fogad\u00e1s\u00e1hoz." }, "create_entry": { "default": "Az esem\u00e9ny Home Assistantnek val\u00f3 k\u00fcld\u00e9s\u00e9hez be kell \u00e1ll\u00edtanod a webhook funkci\u00f3t a GPSLoggerben. \n\n Az al\u00e1bbi inform\u00e1ci\u00f3kat haszn\u00e1ld: \n\n - URL: `{webhook_url}` \n - Method: POST \n\n Tov\u00e1bbi r\u00e9szletek a [dokument\u00e1ci\u00f3]({docs_url}) linken tal\u00e1lhat\u00f3k." }, "step": { "user": { - "description": "Biztosan be szeretn\u00e9d \u00e1ll\u00edtani a GPSLogger Webhookot?", + "description": "Biztos benne, hogy be szeretn\u00e9 \u00e1ll\u00edtani a GPSLogger Webhookot?", "title": "GPSLogger Webhook be\u00e1ll\u00edt\u00e1sa" } } diff --git a/homeassistant/components/gree/translations/hu.json b/homeassistant/components/gree/translations/hu.json index 6c61530acbe..a56ebbfc906 100644 --- a/homeassistant/components/gree/translations/hu.json +++ b/homeassistant/components/gree/translations/hu.json @@ -6,7 +6,7 @@ }, "step": { "confirm": { - "description": "El szeretn\u00e9d kezdeni a be\u00e1ll\u00edt\u00e1st?" + "description": "El szeretn\u00e9 kezdeni a be\u00e1ll\u00edt\u00e1st?" } } } diff --git a/homeassistant/components/gree/translations/nl.json b/homeassistant/components/gree/translations/nl.json index d11896014fd..0671f0b3674 100644 --- a/homeassistant/components/gree/translations/nl.json +++ b/homeassistant/components/gree/translations/nl.json @@ -6,7 +6,7 @@ }, "step": { "confirm": { - "description": "Wil je beginnen met instellen?" + "description": "Wilt u beginnen met instellen?" } } } diff --git a/homeassistant/components/growatt_server/translations/es.json b/homeassistant/components/growatt_server/translations/es.json index 23860f225da..8fe4ae8b791 100644 --- a/homeassistant/components/growatt_server/translations/es.json +++ b/homeassistant/components/growatt_server/translations/es.json @@ -17,6 +17,7 @@ "data": { "name": "Nombre", "password": "Nombre", + "url": "URL", "username": "Usuario" }, "title": "Introduce tu informaci\u00f3n de Growatt." diff --git a/homeassistant/components/growatt_server/translations/id.json b/homeassistant/components/growatt_server/translations/id.json index 789d4e1732b..59975607fb7 100644 --- a/homeassistant/components/growatt_server/translations/id.json +++ b/homeassistant/components/growatt_server/translations/id.json @@ -8,6 +8,7 @@ "data": { "name": "Nama", "password": "Kata Sandi", + "url": "URL", "username": "Nama Pengguna" } } diff --git a/homeassistant/components/guardian/translations/hu.json b/homeassistant/components/guardian/translations/hu.json index 15469bead1e..ecd1b7de01b 100644 --- a/homeassistant/components/guardian/translations/hu.json +++ b/homeassistant/components/guardian/translations/hu.json @@ -2,12 +2,12 @@ "config": { "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van.", "cannot_connect": "Sikertelen csatlakoz\u00e1s" }, "step": { "discovery_confirm": { - "description": "Be akarja \u00e1ll\u00edtani ezt a Guardian eszk\u00f6zt?" + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani ezt a Guardian eszk\u00f6zt?" }, "user": { "data": { @@ -17,7 +17,7 @@ "description": "Konfigur\u00e1lja a helyi Elexa Guardian eszk\u00f6zt." }, "zeroconf_confirm": { - "description": "Be akarja \u00e1ll\u00edtani ezt a Guardian eszk\u00f6zt?" + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani ezt a Guardian eszk\u00f6zt?" } } } diff --git a/homeassistant/components/hangouts/translations/fi.json b/homeassistant/components/hangouts/translations/fi.json index 959a2c06a63..05b394c69f4 100644 --- a/homeassistant/components/hangouts/translations/fi.json +++ b/homeassistant/components/hangouts/translations/fi.json @@ -8,6 +8,7 @@ "data": { "2fa": "2FA-pin" }, + "description": "Tyhj\u00e4", "title": "Kaksivaiheinen tunnistus" }, "user": { @@ -15,6 +16,7 @@ "email": "S\u00e4hk\u00f6postiosoite", "password": "Salasana" }, + "description": "Tyhj\u00e4", "title": "Google Hangouts -kirjautuminen" } } diff --git a/homeassistant/components/hangouts/translations/hu.json b/homeassistant/components/hangouts/translations/hu.json index 2f02ba9f623..eda0144a818 100644 --- a/homeassistant/components/hangouts/translations/hu.json +++ b/homeassistant/components/hangouts/translations/hu.json @@ -12,7 +12,7 @@ "step": { "2fa": { "data": { - "2fa": "2FA Pin" + "2fa": "2FA PIN" }, "description": "\u00dcres", "title": "K\u00e9tfaktoros Hiteles\u00edt\u00e9s" diff --git a/homeassistant/components/harmony/translations/hu.json b/homeassistant/components/harmony/translations/hu.json index 4922bbd1ac6..900cd243247 100644 --- a/homeassistant/components/harmony/translations/hu.json +++ b/homeassistant/components/harmony/translations/hu.json @@ -10,12 +10,12 @@ "flow_title": "{name}", "step": { "link": { - "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a {name}({host})-t?", + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani: {name} ({host})?", "title": "A Logitech Harmony Hub be\u00e1ll\u00edt\u00e1sa" }, "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "name": "Hub neve" }, "title": "A Logitech Harmony Hub be\u00e1ll\u00edt\u00e1sa" diff --git a/homeassistant/components/harmony/translations/id.json b/homeassistant/components/harmony/translations/id.json index 0d2991b1feb..86ab0be3274 100644 --- a/homeassistant/components/harmony/translations/id.json +++ b/homeassistant/components/harmony/translations/id.json @@ -7,7 +7,7 @@ "cannot_connect": "Gagal terhubung", "unknown": "Kesalahan yang tidak diharapkan" }, - "flow_title": "Logitech Harmony Hub {name}", + "flow_title": "{name}", "step": { "link": { "description": "Ingin menyiapkan {name} ({host})?", diff --git a/homeassistant/components/hassio/translations/he.json b/homeassistant/components/hassio/translations/he.json index 17b7fcd0050..8926338221a 100644 --- a/homeassistant/components/hassio/translations/he.json +++ b/homeassistant/components/hassio/translations/he.json @@ -1,10 +1,18 @@ { "system_health": { "info": { + "board": "\u05dc\u05d5\u05d7", + "disk_total": "\u05e1\u05d4\"\u05db \u05d3\u05d9\u05e1\u05e7", + "disk_used": "\u05d3\u05d9\u05e1\u05e7 \u05d1\u05e9\u05d9\u05de\u05d5\u05e9", + "docker_version": "\u05d2\u05d9\u05e8\u05e1\u05ea Docker", + "healthy": "\u05d1\u05e8\u05d9\u05d0\u05d5\u05ea", "host_os": "\u05de\u05e2\u05e8\u05db\u05ea \u05d4\u05e4\u05e2\u05dc\u05d4 \u05de\u05d0\u05e8\u05d7\u05ea", + "installed_addons": "\u05d4\u05e8\u05d7\u05d1\u05d5\u05ea \u05de\u05d5\u05ea\u05e7\u05e0\u05d5\u05ea", "supervisor_api": "API \u05e9\u05dc \u05de\u05e4\u05e7\u05d7", "supervisor_version": "\u05d2\u05d9\u05e8\u05e1\u05ea \u05de\u05e4\u05e7\u05d7", - "update_channel": "\u05e2\u05e8\u05d5\u05e5 \u05e2\u05d3\u05db\u05d5\u05df" + "supported": "\u05e0\u05ea\u05de\u05da", + "update_channel": "\u05e2\u05e8\u05d5\u05e5 \u05e2\u05d3\u05db\u05d5\u05df", + "version_api": "\u05d2\u05e8\u05e1\u05ea API" } } } \ No newline at end of file diff --git a/homeassistant/components/heos/translations/hu.json b/homeassistant/components/heos/translations/hu.json index c487b49ee47..8996c2a4530 100644 --- a/homeassistant/components/heos/translations/hu.json +++ b/homeassistant/components/heos/translations/hu.json @@ -9,9 +9,9 @@ "step": { "user": { "data": { - "host": "Hoszt" + "host": "C\u00edm" }, - "description": "K\u00e9rj\u00fck, adja meg egy Heos-eszk\u00f6z gazdag\u00e9pnev\u00e9t vagy IP-c\u00edm\u00e9t (lehet\u0151leg egy vezet\u00e9kkel a h\u00e1l\u00f3zathoz csatlakoztatott eszk\u00f6zt).", + "description": "K\u00e9rj\u00fck, adja meg egy Heos-eszk\u00f6z hosztnev\u00e9t vagy c\u00edm\u00e9t (lehet\u0151leg egy vezet\u00e9kkel a h\u00e1l\u00f3zathoz csatlakoztatott eszk\u00f6zt).", "title": "Csatlakoz\u00e1s a Heos-hoz" } } diff --git a/homeassistant/components/hive/translations/ca.json b/homeassistant/components/hive/translations/ca.json index eacccda82e7..edebafba579 100644 --- a/homeassistant/components/hive/translations/ca.json +++ b/homeassistant/components/hive/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat", + "already_configured": "El compte ja est\u00e0 configurat", "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament", "unknown_entry": "No s'ha pogut trobar l'entrada existent." }, diff --git a/homeassistant/components/hive/translations/hu.json b/homeassistant/components/hive/translations/hu.json index ce07abcb338..469b99debe1 100644 --- a/homeassistant/components/hive/translations/hu.json +++ b/homeassistant/components/hive/translations/hu.json @@ -17,7 +17,7 @@ "data": { "2fa": "K\u00e9tfaktoros k\u00f3d" }, - "description": "Add meg a Hive hiteles\u00edt\u00e9si k\u00f3dj\u00e1t. \n \n\u00cdrd be a 0000 k\u00f3dot m\u00e1sik k\u00f3d k\u00e9r\u00e9s\u00e9hez.", + "description": "Adja meg a Hive hiteles\u00edt\u00e9si k\u00f3dj\u00e1t. \n \n\u00cdrd be a 0000 k\u00f3dot m\u00e1sik k\u00f3d k\u00e9r\u00e9s\u00e9hez.", "title": "Hive k\u00e9tfaktoros hiteles\u00edt\u00e9s." }, "reauth": { @@ -25,7 +25,7 @@ "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" }, - "description": "Add meg \u00fajra a Hive bejelentkez\u00e9si adatait.", + "description": "Adja meg \u00fajra a Hive bejelentkez\u00e9si adatait.", "title": "Hive Bejelentkez\u00e9s" }, "user": { @@ -34,7 +34,7 @@ "scan_interval": "Szkennel\u00e9si intervallum (m\u00e1sodperc)", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" }, - "description": "Add meg a Hive bejelentkez\u00e9si adatait \u00e9s konfigur\u00e1ci\u00f3j\u00e1t.", + "description": "Adja meg a Hive bejelentkez\u00e9si adatait \u00e9s konfigur\u00e1ci\u00f3j\u00e1t.", "title": "Hive Bejelentkez\u00e9s" } } diff --git a/homeassistant/components/hlk_sw16/translations/hu.json b/homeassistant/components/hlk_sw16/translations/hu.json index 0abcc301f0c..9590d3c12be 100644 --- a/homeassistant/components/hlk_sw16/translations/hu.json +++ b/homeassistant/components/hlk_sw16/translations/hu.json @@ -11,7 +11,7 @@ "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" } diff --git a/homeassistant/components/home_connect/translations/hu.json b/homeassistant/components/home_connect/translations/hu.json index aa43f65b520..ca5f3e1e9ae 100644 --- a/homeassistant/components/home_connect/translations/hu.json +++ b/homeassistant/components/home_connect/translations/hu.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rlek, k\u00f6vesd a dokument\u00e1ci\u00f3t.", + "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rem, k\u00f6vesse a dokument\u00e1ci\u00f3t.", "no_url_available": "Nincs el\u00e9rhet\u0151 URL. A hib\u00e1r\u00f3l tov\u00e1bbi inform\u00e1ci\u00f3t [a s\u00fag\u00f3ban]({docs_url}) tal\u00e1lsz." }, "create_entry": { diff --git a/homeassistant/components/home_plus_control/translations/ca.json b/homeassistant/components/home_plus_control/translations/ca.json index 90e23fcd7ab..6e6dc1e0577 100644 --- a/homeassistant/components/home_plus_control/translations/ca.json +++ b/homeassistant/components/home_plus_control/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat", + "already_configured": "El compte ja est\u00e0 configurat", "already_in_progress": "El flux de configuraci\u00f3 ja est\u00e0 en curs", "authorize_url_timeout": "Temps d'espera esgotat durant la generaci\u00f3 de l'URL d'autoritzaci\u00f3.", "missing_configuration": "El component no est\u00e0 configurat. Mira'n la documentaci\u00f3.", diff --git a/homeassistant/components/home_plus_control/translations/hu.json b/homeassistant/components/home_plus_control/translations/hu.json index 7bc04beb057..2dc22c7a729 100644 --- a/homeassistant/components/home_plus_control/translations/hu.json +++ b/homeassistant/components/home_plus_control/translations/hu.json @@ -2,9 +2,9 @@ "config": { "abort": { "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van.", "authorize_url_timeout": "Id\u0151t\u00fall\u00e9p\u00e9s a hiteles\u00edt\u00e9si URL gener\u00e1l\u00e1sa sor\u00e1n.", - "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rlek, k\u00f6vesd a dokument\u00e1ci\u00f3t.", + "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rem, k\u00f6vesse a dokument\u00e1ci\u00f3t.", "no_url_available": "Nincs el\u00e9rhet\u0151 URL. A hib\u00e1r\u00f3l tov\u00e1bbi inform\u00e1ci\u00f3t [a s\u00fag\u00f3ban]({docs_url}) tal\u00e1lsz.", "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges." }, diff --git a/homeassistant/components/homekit/translations/ca.json b/homeassistant/components/homekit/translations/ca.json index a71287d3c0b..63f34999a4d 100644 --- a/homeassistant/components/homekit/translations/ca.json +++ b/homeassistant/components/homekit/translations/ca.json @@ -29,10 +29,11 @@ }, "cameras": { "data": { + "camera_audio": "C\u00e0meres que admeten \u00e0udio", "camera_copy": "C\u00e0meres que admeten fluxos H.264 natius" }, "description": "Comprova les c\u00e0meres que suporten fluxos nadius H.264. Si alguna c\u00e0mera not proporciona una sortida H.264, el sistema transcodificar\u00e0 el v\u00eddeo a H.264 per a HomeKit. La transcodificaci\u00f3 necessita una CPU potent i probablement no funcioni en ordinadors petits (SBC).", - "title": "Selecci\u00f3 del c\u00f2dec de v\u00eddeo de c\u00e0mera" + "title": "Configuraci\u00f3 de c\u00e0mera" }, "include_exclude": { "data": { diff --git a/homeassistant/components/homekit/translations/de.json b/homeassistant/components/homekit/translations/de.json index 06027c4c09e..a0c407c454e 100644 --- a/homeassistant/components/homekit/translations/de.json +++ b/homeassistant/components/homekit/translations/de.json @@ -29,6 +29,7 @@ }, "cameras": { "data": { + "camera_audio": "Kameras, die Audio unterst\u00fctzen", "camera_copy": "Kameras, die native H.264-Streams unterst\u00fctzen" }, "description": "Pr\u00fcfe alle Kameras, die native H.264-Streams unterst\u00fctzen. Wenn die Kamera keinen H.264-Stream ausgibt, transkodiert das System das Video in H.264 f\u00fcr HomeKit. Die Transkodierung erfordert eine leistungsstarke CPU und wird wahrscheinlich nicht auf Einplatinencomputern funktionieren.", diff --git a/homeassistant/components/homekit/translations/el.json b/homeassistant/components/homekit/translations/el.json new file mode 100644 index 00000000000..58d7a62bc59 --- /dev/null +++ b/homeassistant/components/homekit/translations/el.json @@ -0,0 +1,11 @@ +{ + "options": { + "step": { + "advanced": { + "data": { + "devices": "\u03a3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ad\u03c2 (\u0395\u03bd\u03b1\u03cd\u03c3\u03bc\u03b1\u03c4\u03b1)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit/translations/es.json b/homeassistant/components/homekit/translations/es.json index e713391eb9e..6008d399d64 100644 --- a/homeassistant/components/homekit/translations/es.json +++ b/homeassistant/components/homekit/translations/es.json @@ -5,7 +5,7 @@ }, "step": { "pairing": { - "description": "Tan pronto como la pasarela {name} est\u00e9 lista, la vinculaci\u00f3n estar\u00e1 disponible en \"Notificaciones\" como \"configuraci\u00f3n de pasarela Homekit\"", + "description": "Para completar el emparejamiento, sigue las instrucciones en \"Notificaciones\" en \"Emparejamiento HomeKit\".", "title": "Vincular pasarela Homekit" }, "user": { @@ -21,13 +21,15 @@ "step": { "advanced": { "data": { - "auto_start": "Arranque autom\u00e1tico (desactivado si se utiliza Z-Wave u otro sistema de arranque retardado)" + "auto_start": "Arranque autom\u00e1tico (desactivado si se utiliza Z-Wave u otro sistema de arranque retardado)", + "devices": "Dispositivos (disparadores)" }, "description": "Esta configuraci\u00f3n solo necesita ser ajustada si el puente HomeKit no es funcional.", "title": "Configuraci\u00f3n avanzada" }, "cameras": { "data": { + "camera_audio": "C\u00e1maras que admiten audio", "camera_copy": "C\u00e1maras compatibles con transmisiones H.264 nativas" }, "description": "Verifique todas las c\u00e1maras que admiten transmisiones H.264 nativas. Si la c\u00e1mara no emite una transmisi\u00f3n H.264, el sistema transcodificar\u00e1 el video a H.264 para HomeKit. La transcodificaci\u00f3n requiere una CPU de alto rendimiento y es poco probable que funcione en ordenadores de placa \u00fanica.", diff --git a/homeassistant/components/homekit/translations/et.json b/homeassistant/components/homekit/translations/et.json index 4e454178048..cd02425a2b6 100644 --- a/homeassistant/components/homekit/translations/et.json +++ b/homeassistant/components/homekit/translations/et.json @@ -29,10 +29,11 @@ }, "cameras": { "data": { + "camera_audio": "Heliedastusega kaamerad", "camera_copy": "Kaamerad, mis toetavad riistvaralist H.264 voogu" }, "description": "Vali k\u00f5iki kaameraid, mis toetavad kohalikku H.264 voogu. Kui kaamera ei edasta H.264 voogu, kodeerib s\u00fcsteem video HomeKiti jaoks versioonile H.264. \u00dcmberkodeerimine n\u00f5uab j\u00f5udsat protsessorit ja t\u00f5en\u00e4oliselt ei t\u00f6\u00f6ta see \u00fcheplaadilistes arvutites.", - "title": "Vali kaamera videokoodek." + "title": "Kaamera s\u00e4tted" }, "include_exclude": { "data": { diff --git a/homeassistant/components/homekit/translations/he.json b/homeassistant/components/homekit/translations/he.json index ee476b92b8c..320bf203044 100644 --- a/homeassistant/components/homekit/translations/he.json +++ b/homeassistant/components/homekit/translations/he.json @@ -15,6 +15,9 @@ "devices": "\u05d4\u05ea\u05e7\u05e0\u05d9\u05dd (\u05d8\u05e8\u05d9\u05d2\u05e8\u05d9\u05dd)" } }, + "cameras": { + "title": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05de\u05e6\u05dc\u05de\u05d4" + }, "include_exclude": { "data": { "mode": "\u05de\u05e6\u05d1" diff --git a/homeassistant/components/homekit/translations/hu.json b/homeassistant/components/homekit/translations/hu.json index f60db036247..046cf57e9b9 100644 --- a/homeassistant/components/homekit/translations/hu.json +++ b/homeassistant/components/homekit/translations/hu.json @@ -24,15 +24,16 @@ "auto_start": "Automatikus ind\u00edt\u00e1s (tiltsa le, ha manu\u00e1lisan h\u00edvja a homekit.start szolg\u00e1ltat\u00e1st)", "devices": "Eszk\u00f6z\u00f6k (triggerek)" }, - "description": "Ezeket a be\u00e1ll\u00edt\u00e1sokat csak akkor kell m\u00f3dos\u00edtani, ha a HomeKit nem m\u0171k\u00f6dik.", + "description": "Programozhat\u00f3 kapcsol\u00f3k j\u00f6nnek l\u00e9tre minden kiv\u00e1lasztott eszk\u00f6zh\u00f6z. Amikor egy eszk\u00f6z esem\u00e9nyt ind\u00edt el, a HomeKit be\u00e1ll\u00edthat\u00f3 \u00fagy, hogy egy automatizmus vagy egy jelenet induljon el.", "title": "Halad\u00f3 be\u00e1ll\u00edt\u00e1sok" }, "cameras": { "data": { - "camera_copy": "A nat\u00edv H.264 streameket t\u00e1mogat\u00f3 kamer\u00e1k" + "camera_audio": "Hangot t\u00e1mogat\u00f3 kamer\u00e1k", + "camera_copy": "Nat\u00edv H.264 streameket t\u00e1mogat\u00f3 kamer\u00e1k" }, "description": "Ellen\u0151rizze az \u00f6sszes kamer\u00e1t, amely t\u00e1mogatja a nat\u00edv H.264 adatfolyamokat. Ha a f\u00e9nyk\u00e9pez\u0151g\u00e9p nem ad ki H.264 adatfolyamot, a rendszer \u00e1tk\u00f3dolja a vide\u00f3t H.264 form\u00e1tumba a HomeKit sz\u00e1m\u00e1ra. Az \u00e1tk\u00f3dol\u00e1shoz nagy teljes\u00edtm\u00e9ny\u0171 CPU sz\u00fcks\u00e9ges, \u00e9s val\u00f3sz\u00edn\u0171leg nem fog m\u0171k\u00f6dni egylapos sz\u00e1m\u00edt\u00f3g\u00e9peken.", - "title": "V\u00e1laszd ki a kamera vide\u00f3 kodekj\u00e9t." + "title": "V\u00e1lassza ki a kamera vide\u00f3 kodekj\u00e9t." }, "include_exclude": { "data": { @@ -40,7 +41,7 @@ "mode": "M\u00f3d" }, "description": "V\u00e1lassza ki a felvenni k\u00edv\u00e1nt entit\u00e1sokat. Kieg\u00e9sz\u00edt\u0151 m\u00f3dban csak egyetlen entit\u00e1s szerepel. H\u00eddbefogad\u00e1si m\u00f3dban a tartom\u00e1ny \u00f6sszes entit\u00e1sa szerepelni fog, hacsak nincsenek kijel\u00f6lve konkr\u00e9t entit\u00e1sok. H\u00eddkiz\u00e1r\u00e1si m\u00f3dban a domain \u00f6sszes entit\u00e1sa szerepelni fog, kiv\u00e9ve a kiz\u00e1rt entit\u00e1sokat. A legjobb teljes\u00edtm\u00e9ny \u00e9rdek\u00e9ben minden TV m\u00e9dialej\u00e1tsz\u00f3hoz, tev\u00e9kenys\u00e9galap\u00fa t\u00e1vir\u00e1ny\u00edt\u00f3hoz, z\u00e1rhoz \u00e9s f\u00e9nyk\u00e9pez\u0151g\u00e9phez k\u00fcl\u00f6n HomeKit tartoz\u00e9kot hoznak l\u00e9tre.", - "title": "V\u00e1laszd ki a felvenni k\u00edv\u00e1nt entit\u00e1sokat" + "title": "V\u00e1lassza ki a felvenni k\u00edv\u00e1nt entit\u00e1sokat" }, "init": { "data": { @@ -48,7 +49,7 @@ "mode": "M\u00f3d" }, "description": "A HomeKit konfigur\u00e1lhat\u00f3 \u00fagy, hogy egy h\u00edd vagy egyetlen tartoz\u00e9k l\u00e1that\u00f3 legyen. Kieg\u00e9sz\u00edt\u0151 m\u00f3dban csak egyetlen entit\u00e1s haszn\u00e1lhat\u00f3. A tartoz\u00e9k m\u00f3dra van sz\u00fcks\u00e9g ahhoz, hogy a TV -eszk\u00f6zoszt\u00e1ly\u00fa m\u00e9dialej\u00e1tsz\u00f3k megfelel\u0151en m\u0171k\u00f6djenek. A \u201eTartalmazand\u00f3 tartom\u00e1nyok\u201d entit\u00e1sai szerepelni fognak a HomeKitben. A k\u00f6vetkez\u0151 k\u00e9perny\u0151n kiv\u00e1laszthatja, hogy mely entit\u00e1sokat k\u00edv\u00e1nja felvenni vagy kiz\u00e1rni a list\u00e1b\u00f3l.", - "title": "V\u00e1laszd ki a felvenni k\u00edv\u00e1nt domaineket." + "title": "V\u00e1lassza ki a felvenni k\u00edv\u00e1nt domaineket." }, "yaml": { "description": "Ez a bejegyz\u00e9s YAML-en kereszt\u00fcl vez\u00e9relhet\u0151", diff --git a/homeassistant/components/homekit/translations/id.json b/homeassistant/components/homekit/translations/id.json index ecb35196228..64ce23a5224 100644 --- a/homeassistant/components/homekit/translations/id.json +++ b/homeassistant/components/homekit/translations/id.json @@ -12,7 +12,7 @@ "data": { "include_domains": "Domain yang disertakan" }, - "description": "Pilih domain yang akan disertakan. Semua entitas yang didukung di domain akan disertakan. Instans HomeKit terpisah dalam mode aksesori akan dibuat untuk setiap pemutar media TV dan kamera.", + "description": "Pilih domain yang akan disertakan. Semua entitas yang didukung di domain akan disertakan. Instans HomeKit terpisah dalam mode aksesori akan dibuat untuk setiap pemutar media TV, remote berbasis aktivitas, kunci, dan kamera.", "title": "Pilih domain yang akan disertakan" } } @@ -23,7 +23,7 @@ "data": { "auto_start": "Mulai otomatis (nonaktifkan jika Anda memanggil layanan homekit.start secara manual)" }, - "description": "Pengaturan ini hanya perlu disesuaikan jika HomeKit tidak berfungsi.", + "description": "Sakelar yang dapat diprogram dibuat untuk setiap perangkat yang dipilih. Saat pemicu perangkat aktif, HomeKit dapat dikonfigurasi untuk menjalankan otomatisasi atau scene.", "title": "Konfigurasi Tingkat Lanjut" }, "cameras": { @@ -31,14 +31,14 @@ "camera_copy": "Kamera yang mendukung aliran H.264 asli" }, "description": "Periksa semua kamera yang mendukung streaming H.264 asli. Jika kamera tidak mengeluarkan aliran H.264, sistem akan mentranskode video ke H.264 untuk HomeKit. Proses transcoding membutuhkan CPU kinerja tinggi dan tidak mungkin bekerja pada komputer papan tunggal.", - "title": "Pilih codec video kamera." + "title": "Konfigurasi Kamera" }, "include_exclude": { "data": { "entities": "Entitas", "mode": "Mode" }, - "description": "Pilih entitas yang akan disertakan. Dalam mode aksesori, hanya satu entitas yang disertakan. Dalam mode \"bridge include\", semua entitas di domain akan disertakan, kecuali entitas tertentu dipilih. Dalam mode \"bridge exclude\", semua entitas di domain akan disertakan, kecuali untuk entitas tertentu yang dipilih. Untuk kinerja terbaik, aksesori HomeKit terpisah diperlukan untuk masing-masing pemutar media, TV, dan kamera.", + "description": "Pilih entitas yang akan disertakan. Dalam mode aksesori, hanya satu entitas yang disertakan. Dalam mode \"bridge include\", semua entitas di domain akan disertakan, kecuali entitas tertentu dipilih. Dalam mode \"bridge exclude\", semua entitas di domain akan disertakan, kecuali untuk entitas tertentu yang dipilih. Untuk kinerja terbaik, aksesori HomeKit terpisah diperlukan untuk masing-masing pemutar media TV, remote berbasis aktivitas, kunci, dan kamera.", "title": "Pilih entitas untuk disertakan" }, "init": { diff --git a/homeassistant/components/homekit/translations/it.json b/homeassistant/components/homekit/translations/it.json index 74bc0032580..8e7ead91ac1 100644 --- a/homeassistant/components/homekit/translations/it.json +++ b/homeassistant/components/homekit/translations/it.json @@ -29,10 +29,11 @@ }, "cameras": { "data": { + "camera_audio": "Telecamere che supportano l'audio", "camera_copy": "Telecamere che supportano flussi H.264 nativi" }, "description": "Controllare tutte le telecamere che supportano i flussi H.264 nativi. Se la videocamera non emette uno stream H.264, il sistema provveder\u00e0 a transcodificare il video in H.264 per HomeKit. La transcodifica richiede una CPU performante ed \u00e8 improbabile che funzioni su computer a scheda singola.", - "title": "Seleziona il codec video della videocamera." + "title": "Configurazione della telecamera" }, "include_exclude": { "data": { diff --git a/homeassistant/components/homekit/translations/nl.json b/homeassistant/components/homekit/translations/nl.json index e08364e038f..2ab21f66db5 100644 --- a/homeassistant/components/homekit/translations/nl.json +++ b/homeassistant/components/homekit/translations/nl.json @@ -29,10 +29,11 @@ }, "cameras": { "data": { + "camera_audio": "Camera's die audio ondersteunen", "camera_copy": "Camera's die native H.264-streams ondersteunen" }, "description": "Controleer alle camera's die native H.264-streams ondersteunen. Als de camera geen H.264-stream uitvoert, transcodeert het systeem de video naar H.264 voor HomeKit. Transcodering vereist een performante CPU en het is onwaarschijnlijk dat dit werkt op computers met \u00e9\u00e9n bord.", - "title": "Selecteer de videocodec van de camera." + "title": "Cameraconfiguratie" }, "include_exclude": { "data": { diff --git a/homeassistant/components/homekit/translations/no.json b/homeassistant/components/homekit/translations/no.json index 08df5bd72fa..86e5c8d95cb 100644 --- a/homeassistant/components/homekit/translations/no.json +++ b/homeassistant/components/homekit/translations/no.json @@ -29,10 +29,11 @@ }, "cameras": { "data": { + "camera_audio": "Kameraer som st\u00f8tter lyd", "camera_copy": "Kameraer som st\u00f8tter opprinnelige H.264-str\u00f8mmer" }, "description": "Sjekk alle kameraer som st\u00f8tter opprinnelige H.264-str\u00f8mmer. Hvis kameraet ikke sender ut en H.264-str\u00f8m, vil systemet omkode videoen til H.264 for HomeKit. Transkoding krever en potent prosessor og er usannsynlig \u00e5 fungere p\u00e5 enkeltkortdatamaskiner som Raspberry Pi o.l.", - "title": "Velg videokodek for kamera." + "title": "Kamerakonfigurasjon" }, "include_exclude": { "data": { diff --git a/homeassistant/components/homekit/translations/ru.json b/homeassistant/components/homekit/translations/ru.json index 670c5e8002f..f871636df00 100644 --- a/homeassistant/components/homekit/translations/ru.json +++ b/homeassistant/components/homekit/translations/ru.json @@ -29,10 +29,11 @@ }, "cameras": { "data": { - "camera_copy": "\u041a\u0430\u043c\u0435\u0440\u044b, \u043a\u043e\u0442\u043e\u0440\u044b\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u044e\u0442 \u043f\u043e\u0442\u043e\u043a\u0438 H.264" + "camera_audio": "\u041a\u0430\u043c\u0435\u0440\u044b \u0441 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u043a\u043e\u0439 \u0430\u0443\u0434\u0438\u043e", + "camera_copy": "\u041a\u0430\u043c\u0435\u0440\u044b \u0441 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u043a\u043e\u0439 H.264" }, "description": "\u0415\u0441\u043b\u0438 \u043a\u0430\u043c\u0435\u0440\u0430 \u043d\u0435 \u0432\u044b\u0432\u043e\u0434\u0438\u0442 \u043f\u043e\u0442\u043e\u043a H.264, \u0441\u0438\u0441\u0442\u0435\u043c\u0430 \u043f\u0435\u0440\u0435\u043a\u043e\u0434\u0438\u0440\u0443\u0435\u0442 \u0432\u0438\u0434\u0435\u043e \u0432 H.264 \u0434\u043b\u044f HomeKit. \u0422\u0440\u0430\u043d\u0441\u043a\u043e\u0434\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435 \u0442\u0440\u0435\u0431\u0443\u0435\u0442 \u0432\u044b\u0441\u043e\u043a\u043e\u043f\u0440\u043e\u0438\u0437\u0432\u043e\u0434\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u0433\u043e \u043f\u0440\u043e\u0446\u0435\u0441\u0441\u043e\u0440\u0430 \u0438 \u0432\u0440\u044f\u0434 \u043b\u0438 \u0431\u0443\u0434\u0435\u0442 \u0440\u0430\u0431\u043e\u0442\u0430\u0442\u044c \u043d\u0430 \u043e\u0434\u043d\u043e\u043f\u043b\u0430\u0442\u043d\u044b\u0445 \u043a\u043e\u043c\u043f\u044c\u044e\u0442\u0435\u0440\u0430\u0445.", - "title": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0432\u0438\u0434\u0435\u043e\u043a\u043e\u0434\u0435\u043a \u043a\u0430\u043c\u0435\u0440\u044b." + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u043a\u0430\u043c\u0435\u0440\u044b" }, "include_exclude": { "data": { diff --git a/homeassistant/components/homekit/translations/zh-Hant.json b/homeassistant/components/homekit/translations/zh-Hant.json index a4a5ac06b96..ba1cd8adf88 100644 --- a/homeassistant/components/homekit/translations/zh-Hant.json +++ b/homeassistant/components/homekit/translations/zh-Hant.json @@ -29,10 +29,11 @@ }, "cameras": { "data": { + "camera_audio": "\u652f\u63f4\u97f3\u6548\u8f38\u51fa\u651d\u5f71\u6a5f", "camera_copy": "\u652f\u63f4\u539f\u751f H.264 \u4e32\u6d41\u651d\u5f71\u6a5f" }, "description": "\u6aa2\u67e5\u6240\u6709\u652f\u63f4\u539f\u751f H.264 \u4e32\u6d41\u4e4b\u651d\u5f71\u6a5f\u3002\u5047\u5982\u651d\u5f71\u6a5f\u4e0d\u652f\u63f4 H.264 \u4e32\u6d41\u3001\u7cfb\u7d71\u5c07\u6703\u91dd\u5c0d Homekit \u9032\u884c H.264 \u8f49\u78bc\u3002\u8f49\u78bc\u5c07\u9700\u8981\u4f7f\u7528 CPU \u9032\u884c\u904b\u7b97\u3001\u55ae\u6676\u7247\u96fb\u8166\u53ef\u80fd\u6703\u906d\u9047\u6548\u80fd\u554f\u984c\u3002", - "title": "\u9078\u64c7\u651d\u5f71\u6a5f\u7de8\u78bc\u3002" + "title": "\u651d\u5f71\u6a5f\u8a2d\u5b9a" }, "include_exclude": { "data": { diff --git a/homeassistant/components/homekit_controller/translations/hu.json b/homeassistant/components/homekit_controller/translations/hu.json index 1ad63bfb508..aef97c7b3ba 100644 --- a/homeassistant/components/homekit_controller/translations/hu.json +++ b/homeassistant/components/homekit_controller/translations/hu.json @@ -3,22 +3,22 @@ "abort": { "accessory_not_found_error": "Nem adhat\u00f3 hozz\u00e1 p\u00e1ros\u00edt\u00e1s, mert az eszk\u00f6z m\u00e1r nem tal\u00e1lhat\u00f3.", "already_configured": "A tartoz\u00e9k m\u00e1r konfigur\u00e1lva van ezzel a vez\u00e9rl\u0151vel.", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van.", "already_paired": "Ez a tartoz\u00e9k m\u00e1r p\u00e1ros\u00edtva van egy m\u00e1sik eszk\u00f6zzel. \u00c1ll\u00edtsa alaphelyzetbe a tartoz\u00e9kot, majd pr\u00f3b\u00e1lkozzon \u00fajra.", "ignored_model": "A HomeKit t\u00e1mogat\u00e1sa e modelln\u00e9l blokkolva van, mivel a szolg\u00e1ltat\u00e1shoz teljes nat\u00edv integr\u00e1ci\u00f3 \u00e9rhet\u0151 el.", - "invalid_config_entry": "Ez az eszk\u00f6z k\u00e9szen \u00e1ll a p\u00e1ros\u00edt\u00e1sra, de m\u00e1r van egy \u00fctk\u00f6z\u0151 konfigur\u00e1ci\u00f3s bejegyz\u00e9s a Home Assistant-ben, amelyet el\u0151sz\u00f6r el kell t\u00e1vol\u00edtani.", + "invalid_config_entry": "Ez az eszk\u00f6z k\u00e9szen \u00e1ll a p\u00e1ros\u00edt\u00e1sra, de m\u00e1r van egy \u00fctk\u00f6z\u0151 konfigur\u00e1ci\u00f3s bejegyz\u00e9s Home Assistant-ben, amelyet el\u0151sz\u00f6r el kell t\u00e1vol\u00edtani.", "invalid_properties": "Az eszk\u00f6z \u00e1ltal bejelentett \u00e9rv\u00e9nytelen tulajdons\u00e1gok.", "no_devices": "Nem tal\u00e1lhat\u00f3 nem p\u00e1ros\u00edtott eszk\u00f6z" }, "error": { "authentication_error": "Helytelen HomeKit k\u00f3d. K\u00e9rj\u00fck, ellen\u0151rizze, \u00e9s pr\u00f3b\u00e1lja \u00fajra.", "insecure_setup_code": "A k\u00e9rt telep\u00edt\u00e9si k\u00f3d trivi\u00e1lis jellege miatt nem biztons\u00e1gos. Ez a tartoz\u00e9k nem felel meg az alapvet\u0151 biztons\u00e1gi k\u00f6vetelm\u00e9nyeknek.", - "max_peers_error": "Az eszk\u00f6z megtagadta a p\u00e1ros\u00edt\u00e1s hozz\u00e1ad\u00e1s\u00e1t, mivel nincs ingyenes p\u00e1ros\u00edt\u00e1si t\u00e1rhely.", + "max_peers_error": "Az eszk\u00f6z megtagadta a p\u00e1ros\u00edt\u00e1s hozz\u00e1ad\u00e1s\u00e1t, mivel nincs szabad p\u00e1ros\u00edt\u00e1si t\u00e1rhelye.", "pairing_failed": "Nem kezelt hiba t\u00f6rt\u00e9nt az eszk\u00f6zzel val\u00f3 p\u00e1ros\u00edt\u00e1s sor\u00e1n. Lehet, hogy ez \u00e1tmeneti hiba, vagy az eszk\u00f6z jelenleg m\u00e9g nem t\u00e1mogatott.", "unable_to_pair": "Nem siker\u00fclt p\u00e1ros\u00edtani, pr\u00f3b\u00e1ld \u00fajra.", "unknown_error": "Az eszk\u00f6z ismeretlen hib\u00e1t jelentett. A p\u00e1ros\u00edt\u00e1s sikertelen." }, - "flow_title": "HomeKit tartoz\u00e9k: {name}", + "flow_title": "{name}", "step": { "busy_error": { "description": "Sz\u00fcntesse meg a p\u00e1ros\u00edt\u00e1st az \u00f6sszes vez\u00e9rl\u0151n, vagy pr\u00f3b\u00e1lja \u00fajraind\u00edtani az eszk\u00f6zt, majd folytassa a p\u00e1ros\u00edt\u00e1st.", @@ -33,8 +33,8 @@ "allow_insecure_setup_codes": "P\u00e1ros\u00edt\u00e1s enged\u00e9lyez\u00e9se a nem biztons\u00e1gos be\u00e1ll\u00edt\u00e1si k\u00f3dokkal.", "pairing_code": "P\u00e1ros\u00edt\u00e1si k\u00f3d" }, - "description": "\u00cdrja be a HomeKit p\u00e1ros\u00edt\u00e1si k\u00f3dj\u00e1t (XXX-XX-XXX form\u00e1tumban) a kieg\u00e9sz\u00edt\u0151 haszn\u00e1lat\u00e1hoz", - "title": "HomeKit tartoz\u00e9k p\u00e1ros\u00edt\u00e1sa" + "description": "A HomeKit Controller {name} n\u00e9vvel kommunik\u00e1l a helyi h\u00e1l\u00f3zaton kereszt\u00fcl, biztons\u00e1gos titkos\u00edtott kapcsolaton kereszt\u00fcl, k\u00fcl\u00f6n HomeKit vez\u00e9rl\u0151 vagy iCloud n\u00e9lk\u00fcl. A tartoz\u00e9k haszn\u00e1lat\u00e1hoz adja meg HomeKit p\u00e1ros\u00edt\u00e1si k\u00f3dj\u00e1t (XXX-XX-XXX form\u00e1tumban). Ez a k\u00f3d \u00e1ltal\u00e1ban mag\u00e1ban az eszk\u00f6z\u00f6n vagy a csomagol\u00e1sban tal\u00e1lhat\u00f3.", + "title": "P\u00e1ros\u00edt\u00e1s egy eszk\u00f6zzel a HomeKit Accessory Protocol protokollon seg\u00edts\u00e9g\u00e9vel" }, "protocol_error": { "description": "El\u0151fordulhat, hogy a k\u00e9sz\u00fcl\u00e9k nincs p\u00e1ros\u00edt\u00e1si m\u00f3dban, \u00e9s sz\u00fcks\u00e9g lehet fizikai vagy virtu\u00e1lis gombnyom\u00e1sra. Gy\u0151z\u0151dj\u00f6n meg arr\u00f3l, hogy az eszk\u00f6z p\u00e1ros\u00edt\u00e1si m\u00f3dban van, vagy pr\u00f3b\u00e1lja \u00fajraind\u00edtani az eszk\u00f6zt, majd folytassa a p\u00e1ros\u00edt\u00e1st.", @@ -44,7 +44,7 @@ "data": { "device": "Eszk\u00f6z" }, - "description": "V\u00e1lassza ki azt az eszk\u00f6zt, amelyet p\u00e1ros\u00edtani szeretne", + "description": "A HomeKit Controller biztons\u00e1gos titkos\u00edtott kapcsolaton kereszt\u00fcl kommunik\u00e1l a helyi h\u00e1l\u00f3zaton kereszt\u00fcl, k\u00fcl\u00f6n HomeKit vez\u00e9rl\u0151 vagy iCloud n\u00e9lk\u00fcl. V\u00e1lassza ki a p\u00e1ros\u00edtani k\u00edv\u00e1nt eszk\u00f6zt:", "title": "Eszk\u00f6z kiv\u00e1laszt\u00e1sa" } } diff --git a/homeassistant/components/homekit_controller/translations/id.json b/homeassistant/components/homekit_controller/translations/id.json index 49a37d3b3fb..839169fc6a9 100644 --- a/homeassistant/components/homekit_controller/translations/id.json +++ b/homeassistant/components/homekit_controller/translations/id.json @@ -17,7 +17,7 @@ "unable_to_pair": "Gagal memasangkan, coba lagi.", "unknown_error": "Perangkat melaporkan kesalahan yang tidak diketahui. Pemasangan gagal." }, - "flow_title": "{name} lewat HomeKit Accessory Protocol", + "flow_title": "{name}", "step": { "busy_error": { "description": "Batalkan pemasangan di semua pengontrol, atau coba mulai ulang perangkat, lalu lanjutkan untuk melanjutkan pemasangan.", diff --git a/homeassistant/components/homematicip_cloud/translations/fi.json b/homeassistant/components/homematicip_cloud/translations/fi.json index 9fcaacf4ba1..6a46955cddb 100644 --- a/homeassistant/components/homematicip_cloud/translations/fi.json +++ b/homeassistant/components/homematicip_cloud/translations/fi.json @@ -1,11 +1,20 @@ { "config": { "abort": { + "already_configured": "Laite on jo m\u00e4\u00e4ritetty", + "connection_aborted": "Yhdist\u00e4minen ep\u00e4onnistui", "unknown": "Tapahtui tuntematon virhe." }, "error": { "invalid_sgtin_or_pin": "Virheellinen PIN-koodi, yrit\u00e4 uudelleen.", "press_the_button": "Paina sinist\u00e4 painiketta." + }, + "step": { + "init": { + "data": { + "pin": "PIN-koodi" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/translations/hu.json b/homeassistant/components/homematicip_cloud/translations/hu.json index 90fee286a3a..2915d442a37 100644 --- a/homeassistant/components/homematicip_cloud/translations/hu.json +++ b/homeassistant/components/homematicip_cloud/translations/hu.json @@ -18,7 +18,7 @@ "name": "N\u00e9v (opcion\u00e1lis, minden eszk\u00f6z n\u00e9vel\u0151tagjak\u00e9nt haszn\u00e1latos)", "pin": "PIN-k\u00f3d" }, - "title": "V\u00e1lassz HomematicIP hozz\u00e1f\u00e9r\u00e9si pontot" + "title": "V\u00e1lasszon HomematicIP hozz\u00e1f\u00e9r\u00e9si pontot" }, "link": { "description": "A HomematicIP regisztr\u00e1l\u00e1s\u00e1hoz a Home Assistant alkalmaz\u00e1sban nyomja meg a hozz\u00e1f\u00e9r\u00e9si pont k\u00e9k gombj\u00e1t \u00e9s a bek\u00fcld\u00e9s gombot. \n\n ! [A gomb helye a h\u00eddon] (/ static / images / config_flows / config_homematicip_cloud.png)", diff --git a/homeassistant/components/honeywell/translations/es.json b/homeassistant/components/honeywell/translations/es.json index 70549039a04..9f6c562e888 100644 --- a/homeassistant/components/honeywell/translations/es.json +++ b/homeassistant/components/honeywell/translations/es.json @@ -1,9 +1,13 @@ { "config": { + "error": { + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida" + }, "step": { "user": { "data": { - "password": "Contrase\u00f1a" + "password": "Contrase\u00f1a", + "username": "Usuario" }, "description": "Por favor, introduzca las credenciales utilizadas para iniciar sesi\u00f3n en mytotalconnectcomfort.com.", "title": "Honeywell Total Connect Comfort (US)" diff --git a/homeassistant/components/honeywell/translations/id.json b/homeassistant/components/honeywell/translations/id.json new file mode 100644 index 00000000000..ee1540cc787 --- /dev/null +++ b/homeassistant/components/honeywell/translations/id.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "invalid_auth": "Autentikasi tidak valid" + }, + "step": { + "user": { + "data": { + "password": "Kata Sandi", + "username": "Nama Pengguna" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huawei_lte/translations/hu.json b/homeassistant/components/huawei_lte/translations/hu.json index 22bd37c37ba..91f70a17e46 100644 --- a/homeassistant/components/huawei_lte/translations/hu.json +++ b/homeassistant/components/huawei_lte/translations/hu.json @@ -2,11 +2,11 @@ "config": { "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van.", "not_huawei_lte": "Nem Huawei LTE eszk\u00f6z" }, "error": { - "connection_timeout": "Kapcsolat id\u0151t\u00fall\u00e9p\u00e9se", + "connection_timeout": "Kapcsolat id\u0151t\u00fall\u00e9p\u00e9s", "incorrect_password": "Hib\u00e1s jelsz\u00f3", "incorrect_username": "Helytelen felhaszn\u00e1l\u00f3n\u00e9v", "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", diff --git a/homeassistant/components/huawei_lte/translations/id.json b/homeassistant/components/huawei_lte/translations/id.json index 2077b31ccd7..de784fd3e94 100644 --- a/homeassistant/components/huawei_lte/translations/id.json +++ b/homeassistant/components/huawei_lte/translations/id.json @@ -15,7 +15,7 @@ "response_error": "Kesalahan tidak dikenal dari perangkat", "unknown": "Kesalahan yang tidak diharapkan" }, - "flow_title": "Huawei LTE: {name}", + "flow_title": "{name}", "step": { "user": { "data": { @@ -23,7 +23,7 @@ "url": "URL", "username": "Nama Pengguna" }, - "description": "Masukkan detail akses perangkat. Menentukan nama pengguna dan kata sandi bersifat opsional, tetapi memungkinkan dukungan untuk fitur integrasi lainnya. Selain itu, penggunaan koneksi resmi dapat menyebabkan masalah mengakses antarmuka web perangkat dari luar Home Assistant saat integrasi aktif, dan sebaliknya.", + "description": "Masukkan detail akses perangkat.", "title": "Konfigurasikan Huawei LTE" } } diff --git a/homeassistant/components/hue/translations/hu.json b/homeassistant/components/hue/translations/hu.json index 30084ee9940..2f04c53163f 100644 --- a/homeassistant/components/hue/translations/hu.json +++ b/homeassistant/components/hue/translations/hu.json @@ -3,10 +3,10 @@ "abort": { "all_configured": "M\u00e1r minden Philips Hue bridge konfigur\u00e1lt", "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van.", "cannot_connect": "Sikertelen csatlakoz\u00e1s", - "discover_timeout": "Nem tal\u00e1ltam a Hue bridget", - "no_bridges": "Nem tal\u00e1ltam Philips Hue bridget", + "discover_timeout": "Nem tal\u00e1lhat\u00f3 a Hue bridge", + "no_bridges": "Nem tal\u00e1lhat\u00f3 Philips Hue bridget", "not_hue_bridge": "Nem egy Hue Bridge", "unknown": "Ismeretlen hiba t\u00f6rt\u00e9nt" }, @@ -17,9 +17,9 @@ "step": { "init": { "data": { - "host": "Hoszt" + "host": "C\u00edm" }, - "title": "V\u00e1lassz Hue bridge-t" + "title": "V\u00e1lasszon Hue bridge-t" }, "link": { "description": "Nyomja meg a gombot a bridge-en a Philips Hue Home Assistant-ben val\u00f3 regisztr\u00e1l\u00e1s\u00e1hoz.\n\n![Location of button on bridge](/static/images/config_philips_hue.jpg)", @@ -27,7 +27,7 @@ }, "manual": { "data": { - "host": "Hoszt" + "host": "C\u00edm" }, "title": "A Hue bridge manu\u00e1lis konfigur\u00e1l\u00e1sa" } diff --git a/homeassistant/components/hunterdouglas_powerview/translations/hu.json b/homeassistant/components/hunterdouglas_powerview/translations/hu.json index 1fedd8bc126..e6afd8a1dc4 100644 --- a/homeassistant/components/hunterdouglas_powerview/translations/hu.json +++ b/homeassistant/components/hunterdouglas_powerview/translations/hu.json @@ -10,7 +10,7 @@ "flow_title": "{name} ({host})", "step": { "link": { - "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a {name}({host})-t?", + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani: {name} ({host})?", "title": "Csatlakozzon a PowerView Hubhoz" }, "user": { diff --git a/homeassistant/components/hvv_departures/translations/hu.json b/homeassistant/components/hvv_departures/translations/hu.json index dfbdd92f27a..41113527ecb 100644 --- a/homeassistant/components/hvv_departures/translations/hu.json +++ b/homeassistant/components/hvv_departures/translations/hu.json @@ -23,7 +23,7 @@ }, "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" }, diff --git a/homeassistant/components/hyperion/translations/hu.json b/homeassistant/components/hyperion/translations/hu.json index 852c108c0e9..3fa440c41d5 100644 --- a/homeassistant/components/hyperion/translations/hu.json +++ b/homeassistant/components/hyperion/translations/hu.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "A szolg\u00e1ltat\u00e1s m\u00e1r konfigur\u00e1lva van", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van.", "auth_new_token_not_granted_error": "Az \u00fajonnan l\u00e9trehozott tokent nem hagyt\u00e1k j\u00f3v\u00e1 a Hyperion felhaszn\u00e1l\u00f3i fel\u00fclet\u00e9n", "auth_new_token_not_work_error": "Nem siker\u00fclt hiteles\u00edteni az \u00fajonnan l\u00e9trehozott token haszn\u00e1lat\u00e1val", "auth_required_error": "Nem siker\u00fclt meghat\u00e1rozni, hogy sz\u00fcks\u00e9ges-e enged\u00e9ly", @@ -23,11 +23,11 @@ "description": "Konfigur\u00e1lja a jogosults\u00e1got a Hyperion Ambilight kiszolg\u00e1l\u00f3hoz" }, "confirm": { - "description": "Hozz\u00e1 szeretn\u00e9 adni ezt a Hyperion Ambilight-ot az Otthoni asszisztenshez? \n\n ** Host: ** {host}\n ** Port: ** {port}\n ** azonos\u00edt\u00f3 **: {id}", + "description": "Hozz\u00e1 szeretn\u00e9 adni ezt a Hyperion Ambilight-ot az Otthoni asszisztenshez? \n\n ** C\u00edm: ** {host}\n ** Port: ** {port}\n ** Azonos\u00edt\u00f3 **: {id}", "title": "Er\u0151s\u00edtse meg a Hyperion Ambilight szolg\u00e1ltat\u00e1s hozz\u00e1ad\u00e1s\u00e1t" }, "create_token": { - "description": "Az al\u00e1bbiakban v\u00e1lassza a ** K\u00fcld\u00e9s ** lehet\u0151s\u00e9get \u00faj hiteles\u00edt\u00e9si token k\u00e9r\u00e9s\u00e9hez. A k\u00e9relem j\u00f3v\u00e1hagy\u00e1s\u00e1hoz \u00e1tir\u00e1ny\u00edtunk a Hyperion felhaszn\u00e1l\u00f3i fel\u00fcletre. K\u00e9rj\u00fck, ellen\u0151rizze, hogy a megjelen\u00edtett azonos\u00edt\u00f3 \" {auth_id} \"", + "description": "Az al\u00e1bbiakban v\u00e1lassza a **K\u00fcld\u00e9s** lehet\u0151s\u00e9get \u00faj hiteles\u00edt\u00e9si token k\u00e9r\u00e9s\u00e9hez. A k\u00e9relem j\u00f3v\u00e1hagy\u00e1s\u00e1hoz \u00e1tir\u00e1ny\u00edtunk a Hyperion felhaszn\u00e1l\u00f3i fel\u00fcletre. K\u00e9rj\u00fck, ellen\u0151rizze, hogy a megjelen\u00edtett azonos\u00edt\u00f3 \"{auth_id}\"", "title": "\u00daj hiteles\u00edt\u00e9si token automatikus l\u00e9trehoz\u00e1sa" }, "create_token_external": { @@ -35,7 +35,7 @@ }, "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "port": "Port" } } diff --git a/homeassistant/components/ialarm/translations/hu.json b/homeassistant/components/ialarm/translations/hu.json index e69c6e7e7ea..a98836bb7b7 100644 --- a/homeassistant/components/ialarm/translations/hu.json +++ b/homeassistant/components/ialarm/translations/hu.json @@ -10,7 +10,7 @@ "step": { "user": { "data": { - "host": "Gazdag\u00e9p", + "host": "C\u00edm", "port": "Port" } } diff --git a/homeassistant/components/iaqualink/translations/hu.json b/homeassistant/components/iaqualink/translations/hu.json index 1ca85c41190..2b0b9ac3e67 100644 --- a/homeassistant/components/iaqualink/translations/hu.json +++ b/homeassistant/components/iaqualink/translations/hu.json @@ -12,7 +12,7 @@ "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" }, - "description": "K\u00e9rj\u00fck, adja meg iAqualink-fi\u00f3kja felhaszn\u00e1l\u00f3nev\u00e9t \u00e9s jelszav\u00e1t.", + "description": "K\u00e9rj\u00fck, adja meg iAqualink-fi\u00f3kj\u00e1nak felhaszn\u00e1l\u00f3nev\u00e9t \u00e9s jelszav\u00e1t.", "title": "Csatlakoz\u00e1s az iAqualinkhez" } } diff --git a/homeassistant/components/icloud/translations/ca.json b/homeassistant/components/icloud/translations/ca.json index 6e92897161a..0ffdf5bc0c1 100644 --- a/homeassistant/components/icloud/translations/ca.json +++ b/homeassistant/components/icloud/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat", + "already_configured": "El compte ja est\u00e0 configurat", "no_device": "Cap dels teus dispositius t\u00e9 activada la opci\u00f3 \"Troba el meu iPhone\"", "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" }, diff --git a/homeassistant/components/icloud/translations/hu.json b/homeassistant/components/icloud/translations/hu.json index 722b3711e67..e858eedb757 100644 --- a/homeassistant/components/icloud/translations/hu.json +++ b/homeassistant/components/icloud/translations/hu.json @@ -8,7 +8,7 @@ "error": { "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", "send_verification_code": "Nem siker\u00fclt elk\u00fcldeni az ellen\u0151rz\u0151 k\u00f3dot", - "validate_verification_code": "Nem siker\u00fclt ellen\u0151rizni az ellen\u0151rz\u0151 k\u00f3dot, ki kell v\u00e1lasztania egy megb\u00edzhat\u00f3s\u00e1gi eszk\u00f6zt, \u00e9s \u00fajra kell ind\u00edtania az ellen\u0151rz\u00e9st" + "validate_verification_code": "Nem siker\u00fclt hiteles\u00edteni az ellen\u0151rz\u0151 k\u00f3dot, k\u00e9rem, pr\u00f3b\u00e1lja meg \u00fajra" }, "step": { "reauth": { diff --git a/homeassistant/components/ifttt/translations/hu.json b/homeassistant/components/ifttt/translations/hu.json index 9898beb3e92..2f64056e985 100644 --- a/homeassistant/components/ifttt/translations/hu.json +++ b/homeassistant/components/ifttt/translations/hu.json @@ -2,14 +2,14 @@ "config": { "abort": { "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges.", - "webhook_not_internet_accessible": "A Home Assistant rendszerednek el\u00e9rhet\u0151nek kell lennie az internetr\u0151l a webhook \u00fczenetek fogad\u00e1s\u00e1hoz." + "webhook_not_internet_accessible": "A Home Assistant p\u00e9ld\u00e1ny\u00e1nak el\u00e9rhet\u0151nek kell lennie az internet fel\u0151l a webhook \u00fczenetek fogad\u00e1s\u00e1hoz." }, "create_entry": { - "default": "Ha esem\u00e9nyeket szeretne k\u00fcldeni a Home Assistant alkalmaz\u00e1snak, akkor az [IFTTT Webhook applet]({applet_url}) \"Make a web request\" m\u0171velet\u00e9t kell haszn\u00e1lnia. \n\n T\u00f6ltse ki a k\u00f6vetkez\u0151 inform\u00e1ci\u00f3kat: \n\n - URL: `{webhook_url}`\n - M\u00f3dszer: POST\n - Tartalom t\u00edpusa: application/json \n\n L\u00e1sd [a dokument\u00e1ci\u00f3]({docs_url}), hogyan konfigur\u00e1lhatja az automatiz\u00e1l\u00e1sokat a bej\u00f6v\u0151 adatok kezel\u00e9s\u00e9re." + "default": "Ha esem\u00e9nyeket szeretne k\u00fcldeni a Home Assistant alkalmaz\u00e1snak, akkor az [IFTTT Webhook applet]({applet_url}) \"Make a web request\" m\u0171velet\u00e9t kell haszn\u00e1lnia. \n\nT\u00f6ltse ki a k\u00f6vetkez\u0151 inform\u00e1ci\u00f3kat: \n\n - URL: `{webhook_url}`\n - M\u00f3dszer: POST\n - Tartalom t\u00edpusa: application/json \n\nL\u00e1sd [a dokument\u00e1ci\u00f3]({docs_url}), hogyan konfigur\u00e1lhatja az automatizmusokat a bej\u00f6v\u0151 adatok kezel\u00e9s\u00e9re." }, "step": { "user": { - "description": "Biztosan be szeretn\u00e9d \u00e1ll\u00edtani az IFTTT-t?", + "description": "Biztos benne, hogy be szeretn\u00e9 \u00e1ll\u00edtani az IFTTT-t?", "title": "IFTTT Webhook Applet be\u00e1ll\u00edt\u00e1sa" } } diff --git a/homeassistant/components/insteon/translations/ca.json b/homeassistant/components/insteon/translations/ca.json index 63601dd8071..59c711c3dae 100644 --- a/homeassistant/components/insteon/translations/ca.json +++ b/homeassistant/components/insteon/translations/ca.json @@ -29,7 +29,7 @@ }, "plm": { "data": { - "device": "Ruta del port USB del dispositiu" + "device": "Ruta del dispositiu USB" }, "description": "Configura el m\u00f2dem Insteon PowerLink (PLM).", "title": "Insteon PLM" diff --git a/homeassistant/components/insteon/translations/hu.json b/homeassistant/components/insteon/translations/hu.json index 8444aa97655..f34307a67a4 100644 --- a/homeassistant/components/insteon/translations/hu.json +++ b/homeassistant/components/insteon/translations/hu.json @@ -6,7 +6,7 @@ }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", - "select_single": "V\u00e1lassz egy lehet\u0151s\u00e9get" + "select_single": "V\u00e1lasszon egy lehet\u0151s\u00e9get" }, "step": { "hubv1": { @@ -25,7 +25,7 @@ "username": "Felhaszn\u00e1l\u00f3n\u00e9v" }, "description": "Konfigur\u00e1lja az Insteon Hub 2. verzi\u00f3j\u00e1t.", - "title": "Insteon Hub 2. verzi\u00f3" + "title": "Insteon Hub Version 2" }, "plm": { "data": { @@ -38,7 +38,7 @@ "data": { "modem_type": "Modem t\u00edpusa." }, - "description": "V\u00e1laszd ki az Insteon modem t\u00edpus\u00e1t.", + "description": "V\u00e1lassza ki az Insteon modem t\u00edpus\u00e1t.", "title": "Insteon" } } @@ -47,14 +47,14 @@ "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", "input_error": "\u00c9rv\u00e9nytelen bejegyz\u00e9sek, ellen\u0151rizze \u00e9rt\u00e9keket.", - "select_single": "V\u00e1lassz egy lehet\u0151s\u00e9get" + "select_single": "V\u00e1lasszon egy lehet\u0151s\u00e9get" }, "step": { "add_override": { "data": { - "address": "Eszk\u00f6z c\u00edme (azaz 1a2b3c)", - "cat": "Eszk\u00f6zkateg\u00f3ria (azaz 0x10)", - "subcat": "Eszk\u00f6z alkateg\u00f3ria (azaz 0x0a)" + "address": "Eszk\u00f6z c\u00edme (pl. 1a2b3c)", + "cat": "Eszk\u00f6zkateg\u00f3ria (pl. 0x10)", + "subcat": "Eszk\u00f6z alkateg\u00f3ria (pl. 0x0a)" }, "description": "Eszk\u00f6z-fel\u00fclb\u00edr\u00e1l\u00e1s hozz\u00e1ad\u00e1sa.", "title": "Insteon" diff --git a/homeassistant/components/ios/translations/hu.json b/homeassistant/components/ios/translations/hu.json index dda7af8c541..06a80cc8c5e 100644 --- a/homeassistant/components/ios/translations/hu.json +++ b/homeassistant/components/ios/translations/hu.json @@ -5,7 +5,7 @@ }, "step": { "confirm": { - "description": "El szeretn\u00e9d kezdeni a be\u00e1ll\u00edt\u00e1st?" + "description": "El szeretn\u00e9 kezdeni a be\u00e1ll\u00edt\u00e1st?" } } } diff --git a/homeassistant/components/ios/translations/nl.json b/homeassistant/components/ios/translations/nl.json index 78757f9f715..1e660ec2f5d 100644 --- a/homeassistant/components/ios/translations/nl.json +++ b/homeassistant/components/ios/translations/nl.json @@ -5,7 +5,7 @@ }, "step": { "confirm": { - "description": "Wil je beginnen met instellen?" + "description": "Wilt u beginnen met instellen?" } } } diff --git a/homeassistant/components/iotawatt/translations/el.json b/homeassistant/components/iotawatt/translations/el.json index 0030674e3ca..44996764873 100644 --- a/homeassistant/components/iotawatt/translations/el.json +++ b/homeassistant/components/iotawatt/translations/el.json @@ -9,7 +9,8 @@ "data": { "password": "\u039a\u03c9\u03b4\u03b9\u03ba\u03cc\u03c2", "username": "\u038c\u03bd\u03bf\u03bc\u03b1 \u03c7\u03c1\u03ae\u03c3\u03c4\u03b7" - } + }, + "description": "\u0397 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae IoTawatt \u03b1\u03c0\u03b1\u03b9\u03c4\u03b5\u03af \u03ad\u03bb\u03b5\u03b3\u03c7\u03bf \u03c4\u03b1\u03c5\u03c4\u03cc\u03c4\u03b7\u03c4\u03b1\u03c2. \u0395\u03b9\u03c3\u03ac\u03b3\u03b5\u03c4\u03b5 \u03c4\u03bf \u03cc\u03bd\u03bf\u03bc\u03b1 \u03c7\u03c1\u03ae\u03c3\u03c4\u03b7 \u03ba\u03b1\u03b9 \u03c4\u03bf\u03bd \u03ba\u03c9\u03b4\u03b9\u03ba\u03cc \u03c0\u03c1\u03cc\u03c3\u03b2\u03b1\u03c3\u03b7\u03c2 \u03ba\u03b1\u03b9 \u03ba\u03ac\u03bd\u03c4\u03b5 \u03ba\u03bb\u03b9\u03ba \u03c3\u03c4\u03bf \u03ba\u03bf\u03c5\u03bc\u03c0\u03af \u03a5\u03c0\u03bf\u03b2\u03bf\u03bb\u03ae." } } } diff --git a/homeassistant/components/iotawatt/translations/es.json b/homeassistant/components/iotawatt/translations/es.json index 07540d160bb..00c04d7771f 100644 --- a/homeassistant/components/iotawatt/translations/es.json +++ b/homeassistant/components/iotawatt/translations/es.json @@ -10,7 +10,8 @@ "data": { "password": "Contrase\u00f1a", "username": "Nombre de usuario" - } + }, + "description": "El dispositivo IoTawatt requiere autenticaci\u00f3n. Introduce el nombre de usuario y la contrase\u00f1a y haz clic en el bot\u00f3n Enviar." }, "user": { "data": { diff --git a/homeassistant/components/iotawatt/translations/hu.json b/homeassistant/components/iotawatt/translations/hu.json index 1c545b3d3ce..52d46f97a84 100644 --- a/homeassistant/components/iotawatt/translations/hu.json +++ b/homeassistant/components/iotawatt/translations/hu.json @@ -15,7 +15,7 @@ }, "user": { "data": { - "host": "Gazdag\u00e9p" + "host": "C\u00edm" } } } diff --git a/homeassistant/components/iotawatt/translations/id.json b/homeassistant/components/iotawatt/translations/id.json new file mode 100644 index 00000000000..a48af7cd34d --- /dev/null +++ b/homeassistant/components/iotawatt/translations/id.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "Gagal terhubung", + "invalid_auth": "Autentikasi tidak valid", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "auth": { + "data": { + "password": "Kata Sandi", + "username": "Nama Pengguna" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/iotawatt/translations/nl.json b/homeassistant/components/iotawatt/translations/nl.json index 3d1e6d3ef17..617073e91c0 100644 --- a/homeassistant/components/iotawatt/translations/nl.json +++ b/homeassistant/components/iotawatt/translations/nl.json @@ -1,7 +1,16 @@ { "config": { + "error": { + "cannot_connect": "Kon niet verbinden", + "invalid_auth": "Ongeldige authenticatie", + "unknown": "Onverwachte fout" + }, "step": { "auth": { + "data": { + "password": "Wachtwoord", + "username": "Gebruikersnaam" + }, "description": "Het IoTawatt-apparaat vereist authenticatie. Voer de gebruikersnaam en het wachtwoord in en klik op de knop Verzenden." }, "user": { diff --git a/homeassistant/components/ipp/translations/hu.json b/homeassistant/components/ipp/translations/hu.json index a024cfb2e56..18381fde2cf 100644 --- a/homeassistant/components/ipp/translations/hu.json +++ b/homeassistant/components/ipp/translations/hu.json @@ -13,21 +13,21 @@ "cannot_connect": "Sikertelen csatlakoz\u00e1s", "connection_upgrade": "Nem siker\u00fclt csatlakozni a nyomtat\u00f3hoz. K\u00e9rj\u00fck, pr\u00f3b\u00e1lja meg \u00fajra az SSL/TLS opci\u00f3 bejel\u00f6l\u00e9s\u00e9vel." }, - "flow_title": "Nyomtat\u00f3: {name}", + "flow_title": "{name}", "step": { "user": { "data": { "base_path": "Relat\u00edv \u00fatvonal a nyomtat\u00f3hoz", - "host": "Hoszt", + "host": "C\u00edm", "port": "Port", "ssl": "SSL tan\u00fas\u00edtv\u00e1ny haszn\u00e1lata", "verify_ssl": "SSL-tan\u00fas\u00edtv\u00e1ny ellen\u0151rz\u00e9se" }, - "description": "\u00c1ll\u00edtsa be a nyomtat\u00f3t az Internet Printing Protocol (IPP) protokollon kereszt\u00fcl, hogy integr\u00e1lhat\u00f3 legyen a Home Assistant seg\u00edts\u00e9g\u00e9vel.", + "description": "\u00c1ll\u00edtsa be a nyomtat\u00f3t az Internet Printing Protocol (IPP) protokollon kereszt\u00fcl, hogy integr\u00e1lhat\u00f3 legyen Home Assistant seg\u00edts\u00e9g\u00e9vel.", "title": "Kapcsolja \u00f6ssze a nyomtat\u00f3t" }, "zeroconf_confirm": { - "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a {name}-t?", + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani: {name}?", "title": "Felfedezett nyomtat\u00f3" } } diff --git a/homeassistant/components/ipp/translations/id.json b/homeassistant/components/ipp/translations/id.json index c2b95751d4b..f65b853d671 100644 --- a/homeassistant/components/ipp/translations/id.json +++ b/homeassistant/components/ipp/translations/id.json @@ -13,7 +13,7 @@ "cannot_connect": "Gagal terhubung", "connection_upgrade": "Gagal terhubung ke printer. Coba lagi dengan mencentang opsi SSL/TLS." }, - "flow_title": "Printer: {name}", + "flow_title": "{name}", "step": { "user": { "data": { diff --git a/homeassistant/components/isy994/translations/es.json b/homeassistant/components/isy994/translations/es.json index 46dc3260f83..324b94e9938 100644 --- a/homeassistant/components/isy994/translations/es.json +++ b/homeassistant/components/isy994/translations/es.json @@ -9,7 +9,7 @@ "invalid_host": "La entrada del host no estaba en formato URL completo, por ejemplo, http://192.168.10.100:80", "unknown": "Error inesperado" }, - "flow_title": "Dispositivos Universales ISY994 {nombre} ({host})", + "flow_title": "{name} ({host})", "step": { "user": { "data": { diff --git a/homeassistant/components/isy994/translations/hu.json b/homeassistant/components/isy994/translations/hu.json index dab85300e6d..d9cce2fefcb 100644 --- a/homeassistant/components/isy994/translations/hu.json +++ b/homeassistant/components/isy994/translations/hu.json @@ -6,7 +6,7 @@ "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", - "invalid_host": "A gazdag\u00e9p bejegyz\u00e9se nem volt teljes URL-form\u00e1tumban, p\u00e9ld\u00e1ul: http://192.168.10.100:80", + "invalid_host": "A c\u00edm bejegyz\u00e9se nem volt teljes URL-form\u00e1tumban, p\u00e9ld\u00e1ul: http://192.168.10.100:80", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "flow_title": "{name} ({host})", @@ -18,7 +18,7 @@ "tls": "Az ISY vez\u00e9rl\u0151 TLS verzi\u00f3ja.", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" }, - "description": "A gazdag\u00e9p bejegyz\u00e9s\u00e9nek teljes URL form\u00e1tumban kell lennie, pl. Http://192.168.10.100:80", + "description": "A c\u00edm bejegyz\u00e9s\u00e9nek teljes URL form\u00e1tumban kell lennie, pl. Http://192.168.10.100:80", "title": "Csatlakozzon az ISY994-hez" } } @@ -40,7 +40,7 @@ "system_health": { "info": { "device_connected": "ISY csatlakozik", - "host_reachable": "El\u00e9rhet\u0151 gazdag\u00e9p", + "host_reachable": "C\u00edm el\u00e9rhet\u0151", "last_heartbeat": "Utols\u00f3 sz\u00edvver\u00e9s ideje", "websocket_status": "Esem\u00e9nySocket \u00e1llapota" } diff --git a/homeassistant/components/isy994/translations/id.json b/homeassistant/components/isy994/translations/id.json index fec6d1090b0..099e3607d1e 100644 --- a/homeassistant/components/isy994/translations/id.json +++ b/homeassistant/components/isy994/translations/id.json @@ -9,7 +9,7 @@ "invalid_host": "Entri host tidak dalam format URL lengkap, misalnya, http://192.168.10.100:80", "unknown": "Kesalahan yang tidak diharapkan" }, - "flow_title": "Universal Devices ISY994 {name} ({host})", + "flow_title": "{name} ({host})", "step": { "user": { "data": { diff --git a/homeassistant/components/juicenet/translations/ca.json b/homeassistant/components/juicenet/translations/ca.json index f5df6921062..01a3a0bcae4 100644 --- a/homeassistant/components/juicenet/translations/ca.json +++ b/homeassistant/components/juicenet/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat" + "already_configured": "El compte ja est\u00e0 configurat" }, "error": { "cannot_connect": "Ha fallat la connexi\u00f3", diff --git a/homeassistant/components/keenetic_ndms2/translations/ca.json b/homeassistant/components/keenetic_ndms2/translations/ca.json index 0acb0ef0266..748a55885e4 100644 --- a/homeassistant/components/keenetic_ndms2/translations/ca.json +++ b/homeassistant/components/keenetic_ndms2/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat", + "already_configured": "El compte ja est\u00e0 configurat", "no_udn": "La informaci\u00f3 de descobriment SSDP no t\u00e9 UDN", "not_keenetic_ndms2": "El dispositiu descobert no \u00e9s un router Keenetic" }, diff --git a/homeassistant/components/keenetic_ndms2/translations/hu.json b/homeassistant/components/keenetic_ndms2/translations/hu.json index c2327130a11..2575d832863 100644 --- a/homeassistant/components/keenetic_ndms2/translations/hu.json +++ b/homeassistant/components/keenetic_ndms2/translations/hu.json @@ -12,7 +12,7 @@ "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "password": "Jelsz\u00f3", "port": "Port", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" diff --git a/homeassistant/components/keenetic_ndms2/translations/id.json b/homeassistant/components/keenetic_ndms2/translations/id.json index bb30e715579..900745bc29e 100644 --- a/homeassistant/components/keenetic_ndms2/translations/id.json +++ b/homeassistant/components/keenetic_ndms2/translations/id.json @@ -6,6 +6,7 @@ "error": { "cannot_connect": "Gagal terhubung" }, + "flow_title": "{name} ({host})", "step": { "user": { "data": { diff --git a/homeassistant/components/keenetic_ndms2/translations/ru.json b/homeassistant/components/keenetic_ndms2/translations/ru.json index 3c7eed4be01..810c2bfff05 100644 --- a/homeassistant/components/keenetic_ndms2/translations/ru.json +++ b/homeassistant/components/keenetic_ndms2/translations/ru.json @@ -25,7 +25,7 @@ "step": { "user": { "data": { - "consider_home": "\u0412\u0440\u0435\u043c\u044f \u043e\u0442 \u043f\u043e\u0441\u043b\u0435\u0434\u043d\u0435\u0433\u043e \u0441\u0435\u0430\u043d\u0441\u0430 \u0441\u0432\u044f\u0437\u0438 \u0441 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u043c (\u0441\u0435\u043a.), \u043f\u043e \u0438\u0441\u0442\u0435\u0447\u0435\u043d\u0438\u044e \u043a\u043e\u0442\u043e\u0440\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043f\u043e\u043b\u0443\u0447\u0438\u0442 \u0441\u0442\u0430\u0442\u0443\u0441 \"\u041d\u0435 \u0434\u043e\u043c\u0430\"", + "consider_home": "\u0412\u0440\u0435\u043c\u044f, \u0432 \u0442\u0435\u0447\u0435\u043d\u0438\u0438 \u043a\u043e\u0442\u043e\u0440\u043e\u0433\u043e \u0441\u0447\u0438\u0442\u0430\u0442\u044c \u0447\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0434\u043e\u043c\u0430 (\u0432 \u0441\u0435\u043a\u0443\u043d\u0434\u0430\u0445)", "include_arp": "\u0418\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c \u0434\u0430\u043d\u043d\u044b\u0435 ARP (\u0438\u0433\u043d\u043e\u0440\u0438\u0440\u0443\u044e\u0442\u0441\u044f, \u0435\u0441\u043b\u0438 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u044e\u0442\u0441\u044f \u0434\u0430\u043d\u043d\u044b\u0435 \u0442\u043e\u0447\u043a\u0438 \u0434\u043e\u0441\u0442\u0443\u043f\u0430)", "include_associated": "\u0418\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c \u0434\u0430\u043d\u043d\u044b\u0435 \u0442\u043e\u0447\u0435\u043a \u0434\u043e\u0441\u0442\u0443\u043f\u0430 WiFi (\u0438\u0433\u043d\u043e\u0440\u0438\u0440\u0443\u044e\u0442\u0441\u044f, \u0435\u0441\u043b\u0438 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u044e\u0442\u0441\u044f \u0434\u0430\u043d\u043d\u044b\u0435 hotspot)", "interfaces": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u044b \u0434\u043b\u044f \u0441\u043a\u0430\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f", diff --git a/homeassistant/components/kmtronic/translations/hu.json b/homeassistant/components/kmtronic/translations/hu.json index 4fe9a3875e6..3ea79e3bd89 100644 --- a/homeassistant/components/kmtronic/translations/hu.json +++ b/homeassistant/components/kmtronic/translations/hu.json @@ -11,7 +11,7 @@ "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" } diff --git a/homeassistant/components/kodi/translations/hu.json b/homeassistant/components/kodi/translations/hu.json index 9ae1e0741d5..017d33010ac 100644 --- a/homeassistant/components/kodi/translations/hu.json +++ b/homeassistant/components/kodi/translations/hu.json @@ -19,15 +19,15 @@ "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" }, - "description": "Add meg a Kodi felhaszn\u00e1l\u00f3nevet \u00e9s jelsz\u00f3t. Ezek megtal\u00e1lhat\u00f3k a Rendszer/Be\u00e1ll\u00edt\u00e1sok/H\u00e1l\u00f3zat/Szolg\u00e1ltat\u00e1sok r\u00e9szben." + "description": "Adja meg a Kodi felhaszn\u00e1l\u00f3nevet \u00e9s jelsz\u00f3t. Ezek megtal\u00e1lhat\u00f3k a Rendszer/Be\u00e1ll\u00edt\u00e1sok/H\u00e1l\u00f3zat/Szolg\u00e1ltat\u00e1sok r\u00e9szben." }, "discovery_confirm": { - "description": "Szeretn\u00e9d hozz\u00e1adni a Kodi (`{name}`)-t a Home Assistant-hoz?", + "description": "Szeretn\u00e9 hozz\u00e1adni a Kodi (`{name}`)-t Home Assistant-hoz?", "title": "Felfedezett Kodi" }, "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "port": "Port", "ssl": "SSL tan\u00fas\u00edtv\u00e1ny haszn\u00e1lata" }, diff --git a/homeassistant/components/kodi/translations/id.json b/homeassistant/components/kodi/translations/id.json index 1a81ab72fab..16ce1e2c43b 100644 --- a/homeassistant/components/kodi/translations/id.json +++ b/homeassistant/components/kodi/translations/id.json @@ -12,7 +12,7 @@ "invalid_auth": "Autentikasi tidak valid", "unknown": "Kesalahan yang tidak diharapkan" }, - "flow_title": "Kodi: {name}", + "flow_title": "{name}", "step": { "credentials": { "data": { diff --git a/homeassistant/components/konnected/translations/hu.json b/homeassistant/components/konnected/translations/hu.json index 1ad58223b88..f5431480ebb 100644 --- a/homeassistant/components/konnected/translations/hu.json +++ b/homeassistant/components/konnected/translations/hu.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van.", "not_konn_panel": "Nem felismert Konnected.io eszk\u00f6z", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, @@ -11,7 +11,7 @@ }, "step": { "confirm": { - "description": "Modell: {model}\nAzonos\u00edt\u00f3: {id}\nGazdag\u00e9p: {host}\nPort: {port} \n\n Az IO \u00e9s a panel viselked\u00e9s\u00e9t a Konnected Alarm Panel be\u00e1ll\u00edt\u00e1saiban konfigur\u00e1lhatja.", + "description": "Modell: {model}\nAzonos\u00edt\u00f3: {id}\nC\u00edm: {host}\nPort: {port} \n\nAz IO \u00e9s a panel viselked\u00e9s\u00e9t a Konnected Alarm Panel be\u00e1ll\u00edt\u00e1saiban konfigur\u00e1lhatja.", "title": "Konnected eszk\u00f6z k\u00e9sz" }, "import_confirm": { @@ -23,7 +23,7 @@ "host": "IP c\u00edm", "port": "Port" }, - "description": "K\u00e9rj\u00fck, adja meg a Konnected Panel gazdag\u00e9p\u00e9nek adatait." + "description": "K\u00e9rj\u00fck, adja meg a Konnected Panel csatlakoz\u00e1si adatait." } } }, diff --git a/homeassistant/components/konnected/translations/id.json b/homeassistant/components/konnected/translations/id.json index 633e6bba2df..b80b86c25c9 100644 --- a/homeassistant/components/konnected/translations/id.json +++ b/homeassistant/components/konnected/translations/id.json @@ -78,7 +78,7 @@ "alarm2_out2": "OUT2/ALARM2", "out1": "OUT1" }, - "description": "Pilih konfigurasi I/O lainnya di bawah ini. Anda dapat mengonfigurasi detail opsi pada langkah berikutnya.", + "description": "Pilih konfigurasi I/O lainnya di bawah ini. Anda dapat mengonfigurasi detail opsi pada langkah berikutnya.", "title": "Konfigurasikan I/O yang Diperluas" }, "options_misc": { diff --git a/homeassistant/components/kostal_plenticore/translations/hu.json b/homeassistant/components/kostal_plenticore/translations/hu.json index b235578e9c3..3ffe413a82b 100644 --- a/homeassistant/components/kostal_plenticore/translations/hu.json +++ b/homeassistant/components/kostal_plenticore/translations/hu.json @@ -11,7 +11,7 @@ "step": { "user": { "data": { - "host": "Gazdag\u00e9p", + "host": "C\u00edm", "password": "Jelsz\u00f3" } } diff --git a/homeassistant/components/kraken/translations/es.json b/homeassistant/components/kraken/translations/es.json index 1befa14a52b..86df8397c15 100644 --- a/homeassistant/components/kraken/translations/es.json +++ b/homeassistant/components/kraken/translations/es.json @@ -1,11 +1,15 @@ { "config": { + "abort": { + "already_configured": "Ya est\u00e1 configurado. Solo es posible una \u00fanica configuraci\u00f3n." + }, "step": { "user": { "data": { "one": "", "other": "Otros" - } + }, + "description": "\u00bfQuieres iniciar la configuraci\u00f3n?" } } }, diff --git a/homeassistant/components/kraken/translations/hu.json b/homeassistant/components/kraken/translations/hu.json index 793a3433eb8..6ea1c832188 100644 --- a/homeassistant/components/kraken/translations/hu.json +++ b/homeassistant/components/kraken/translations/hu.json @@ -13,7 +13,7 @@ "one": "\u00dcres", "other": "\u00dcres" }, - "description": "El szeretn\u00e9d kezdeni a be\u00e1ll\u00edt\u00e1st?" + "description": "El szeretn\u00e9 kezdeni a be\u00e1ll\u00edt\u00e1st?" } } }, diff --git a/homeassistant/components/kraken/translations/id.json b/homeassistant/components/kraken/translations/id.json new file mode 100644 index 00000000000..a436ac4aee5 --- /dev/null +++ b/homeassistant/components/kraken/translations/id.json @@ -0,0 +1,12 @@ +{ + "config": { + "abort": { + "already_configured": "Sudah dikonfigurasi. Hanya satu konfigurasi yang diizinkan." + }, + "step": { + "user": { + "description": "Ingin memulai penyiapan?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/kraken/translations/nl.json b/homeassistant/components/kraken/translations/nl.json index 25fe63bebd5..09b93b205e3 100644 --- a/homeassistant/components/kraken/translations/nl.json +++ b/homeassistant/components/kraken/translations/nl.json @@ -9,7 +9,7 @@ }, "step": { "user": { - "description": "Wil je beginnen met instellen?" + "description": "Wilt u beginnen met instellen?" } } }, diff --git a/homeassistant/components/kulersky/translations/hu.json b/homeassistant/components/kulersky/translations/hu.json index 6c61530acbe..a56ebbfc906 100644 --- a/homeassistant/components/kulersky/translations/hu.json +++ b/homeassistant/components/kulersky/translations/hu.json @@ -6,7 +6,7 @@ }, "step": { "confirm": { - "description": "El szeretn\u00e9d kezdeni a be\u00e1ll\u00edt\u00e1st?" + "description": "El szeretn\u00e9 kezdeni a be\u00e1ll\u00edt\u00e1st?" } } } diff --git a/homeassistant/components/kulersky/translations/nl.json b/homeassistant/components/kulersky/translations/nl.json index d11896014fd..0671f0b3674 100644 --- a/homeassistant/components/kulersky/translations/nl.json +++ b/homeassistant/components/kulersky/translations/nl.json @@ -6,7 +6,7 @@ }, "step": { "confirm": { - "description": "Wil je beginnen met instellen?" + "description": "Wilt u beginnen met instellen?" } } } diff --git a/homeassistant/components/life360/translations/ca.json b/homeassistant/components/life360/translations/ca.json index cf57e4e1d2f..875692a661a 100644 --- a/homeassistant/components/life360/translations/ca.json +++ b/homeassistant/components/life360/translations/ca.json @@ -8,7 +8,7 @@ "default": "Per configurar les opcions avan\u00e7ades mira la [documentaci\u00f3 de Life360]({docs_url})." }, "error": { - "already_configured": "El compte ja ha estat configurat", + "already_configured": "El compte ja est\u00e0 configurat", "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", "invalid_username": "Nom d'usuari incorrecte", "unknown": "Error inesperat" diff --git a/homeassistant/components/lifx/translations/hu.json b/homeassistant/components/lifx/translations/hu.json index f706dcefa96..3d728f21d07 100644 --- a/homeassistant/components/lifx/translations/hu.json +++ b/homeassistant/components/lifx/translations/hu.json @@ -6,7 +6,7 @@ }, "step": { "confirm": { - "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a LIFX-t?" + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani: LIFX?" } } } diff --git a/homeassistant/components/litterrobot/translations/ca.json b/homeassistant/components/litterrobot/translations/ca.json index b7ca6053fbc..5165473860a 100644 --- a/homeassistant/components/litterrobot/translations/ca.json +++ b/homeassistant/components/litterrobot/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat" + "already_configured": "El compte ja est\u00e0 configurat" }, "error": { "cannot_connect": "Ha fallat la connexi\u00f3", diff --git a/homeassistant/components/litterrobot/translations/hu.json b/homeassistant/components/litterrobot/translations/hu.json index fd8db27da5e..cc0c820facf 100644 --- a/homeassistant/components/litterrobot/translations/hu.json +++ b/homeassistant/components/litterrobot/translations/hu.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van" }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", diff --git a/homeassistant/components/local_ip/translations/hu.json b/homeassistant/components/local_ip/translations/hu.json index e930d58784a..cfb92ddb7b6 100644 --- a/homeassistant/components/local_ip/translations/hu.json +++ b/homeassistant/components/local_ip/translations/hu.json @@ -5,7 +5,7 @@ }, "step": { "user": { - "description": "El szeretn\u00e9d kezdeni a be\u00e1ll\u00edt\u00e1st?", + "description": "El szeretn\u00e9 kezdeni a be\u00e1ll\u00edt\u00e1st?", "title": "Helyi IP c\u00edm" } } diff --git a/homeassistant/components/local_ip/translations/nl.json b/homeassistant/components/local_ip/translations/nl.json index 3ea8140a96e..4b2672d2a3b 100644 --- a/homeassistant/components/local_ip/translations/nl.json +++ b/homeassistant/components/local_ip/translations/nl.json @@ -5,7 +5,7 @@ }, "step": { "user": { - "description": "Wil je beginnen met instellen?", + "description": "Wilt u beginnen met instellen?", "title": "Lokaal IP-adres" } } diff --git a/homeassistant/components/locative/translations/hu.json b/homeassistant/components/locative/translations/hu.json index 8dc03e9c37a..893e22f1471 100644 --- a/homeassistant/components/locative/translations/hu.json +++ b/homeassistant/components/locative/translations/hu.json @@ -2,14 +2,14 @@ "config": { "abort": { "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges.", - "webhook_not_internet_accessible": "A Home Assistant rendszerednek el\u00e9rhet\u0151nek kell lennie az internetr\u0151l a webhook \u00fczenetek fogad\u00e1s\u00e1hoz." + "webhook_not_internet_accessible": "A Home Assistant p\u00e9ld\u00e1ny\u00e1nak el\u00e9rhet\u0151nek kell lennie az internet fel\u0151l a webhook \u00fczenetek fogad\u00e1s\u00e1hoz." }, "create_entry": { "default": "Ha helyeket szeretne k\u00fcldeni a Home Assistant alkalmaz\u00e1snak, be kell \u00e1ll\u00edtania a webhook funkci\u00f3t a Locative alkalmaz\u00e1sban. \n\n T\u00f6ltse ki a k\u00f6vetkez\u0151 inform\u00e1ci\u00f3kat: \n\n - URL: `{webhook_url}`\n - M\u00f3dszer: POST \n\n Tov\u00e1bbi r\u00e9szletek\u00e9rt l\u00e1sd a [dokument\u00e1ci\u00f3t]({docs_url})." }, "step": { "user": { - "description": "El szeretn\u00e9d kezdeni a be\u00e1ll\u00edt\u00e1st?", + "description": "El szeretn\u00e9 kezdeni a be\u00e1ll\u00edt\u00e1st?", "title": "Locative Webhook be\u00e1ll\u00edt\u00e1sa" } } diff --git a/homeassistant/components/locative/translations/nl.json b/homeassistant/components/locative/translations/nl.json index d66a1262b5d..ed39d00430b 100644 --- a/homeassistant/components/locative/translations/nl.json +++ b/homeassistant/components/locative/translations/nl.json @@ -9,7 +9,7 @@ }, "step": { "user": { - "description": "Wil je beginnen met instellen?", + "description": "Wilt u beginnen met instellen?", "title": "Stel de Locative Webhook in" } } diff --git a/homeassistant/components/logi_circle/translations/ca.json b/homeassistant/components/logi_circle/translations/ca.json index 9f46b3f621a..da66dbf55dd 100644 --- a/homeassistant/components/logi_circle/translations/ca.json +++ b/homeassistant/components/logi_circle/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat", + "already_configured": "El compte ja est\u00e0 configurat", "external_error": "S'ha produ\u00eft una excepci\u00f3 d'un altre flux de dades.", "external_setup": "Logi Circle s'ha configurat correctament des d'un altre flux de dades.", "missing_configuration": "El component no est\u00e0 configurat. Mira'n la documentaci\u00f3." diff --git a/homeassistant/components/logi_circle/translations/hu.json b/homeassistant/components/logi_circle/translations/hu.json index 73522a59519..f79ab3944dc 100644 --- a/homeassistant/components/logi_circle/translations/hu.json +++ b/homeassistant/components/logi_circle/translations/hu.json @@ -4,16 +4,16 @@ "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van", "external_error": "Kiv\u00e9tel t\u00f6rt\u00e9nt egy m\u00e1sik folyamatb\u00f3l.", "external_setup": "LogiCircle sikeresen konfigur\u00e1lva egy m\u00e1sik folyamatb\u00f3l.", - "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rlek, k\u00f6vesd a dokument\u00e1ci\u00f3t." + "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rem, k\u00f6vesse a dokument\u00e1ci\u00f3t." }, "error": { "authorize_url_timeout": "Id\u0151t\u00fall\u00e9p\u00e9s a hiteles\u00edt\u00e9si URL gener\u00e1l\u00e1sa sor\u00e1n.", - "follow_link": "K\u00e9rlek, k\u00f6vesd a hivatkoz\u00e1st \u00e9s hiteles\u00edtsd magad miel\u0151tt megnyomod a K\u00fcld\u00e9s gombot", + "follow_link": "K\u00e9rem, k\u00f6vesse a hivatkoz\u00e1st \u00e9s hiteles\u00edtse mag\u00e1t miel\u0151tt megnyomn\u00e1 a K\u00fcld\u00e9s gombot", "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s" }, "step": { "auth": { - "description": "K\u00e9rj\u00fck, k\u00f6vesse az al\u00e1bbi linket, \u00e9s ** Fogadja el ** a LogiCircle -fi\u00f3kj\u00e1hoz val\u00f3 hozz\u00e1f\u00e9r\u00e9st, majd t\u00e9rjen vissza, \u00e9s nyomja meg az al\u00e1bbi ** K\u00fcld\u00e9s ** gombot. \n\n [Link] ({authorization_url})", + "description": "K\u00e9rj\u00fck, k\u00f6vesse az al\u00e1bbi linket, \u00e9s ** Fogadja el ** a LogiCircle -fi\u00f3kj\u00e1hoz val\u00f3 hozz\u00e1f\u00e9r\u00e9st, majd t\u00e9rjen vissza, \u00e9s nyomja meg az al\u00e1bbi ** K\u00fcld\u00e9s ** gombot. \n\n [Link]({authorization_url})", "title": "Hiteles\u00edt\u00e9s a LogiCircle seg\u00edts\u00e9g\u00e9vel" }, "user": { diff --git a/homeassistant/components/lutron_caseta/translations/es.json b/homeassistant/components/lutron_caseta/translations/es.json index 9dbedba1457..098a90377d8 100644 --- a/homeassistant/components/lutron_caseta/translations/es.json +++ b/homeassistant/components/lutron_caseta/translations/es.json @@ -69,8 +69,8 @@ "stop_all": "Detener todo" }, "trigger_type": { - "press": "\"{subtipo}\" presionado", - "release": "\"{subtipo}\" liberado" + "press": "\"{subtype}\" presionado", + "release": "\"{subtype}\" liberado" } } } \ No newline at end of file diff --git a/homeassistant/components/lutron_caseta/translations/hu.json b/homeassistant/components/lutron_caseta/translations/hu.json index 0e8960530e3..f3fca2ff705 100644 --- a/homeassistant/components/lutron_caseta/translations/hu.json +++ b/homeassistant/components/lutron_caseta/translations/hu.json @@ -15,12 +15,12 @@ "title": "Nem siker\u00fclt import\u00e1lni a Cas\u00e9ta h\u00edd konfigur\u00e1ci\u00f3j\u00e1t." }, "link": { - "description": "A(z) {name} {host} p\u00e1ros\u00edt\u00e1s\u00e1hoz az \u0171rlap elk\u00fcld\u00e9se ut\u00e1n nyomja meg a h\u00edd h\u00e1tulj\u00e1n tal\u00e1lhat\u00f3 fekete gombot.", + "description": "A(z) {name} ({host}) p\u00e1ros\u00edt\u00e1s\u00e1hoz az \u0171rlap elk\u00fcld\u00e9se ut\u00e1n nyomja meg a h\u00edd h\u00e1tulj\u00e1n tal\u00e1lhat\u00f3 fekete gombot.", "title": "P\u00e1ros\u00edtsd a h\u00edddal" }, "user": { "data": { - "host": "Hoszt" + "host": "C\u00edm" }, "description": "Add meg az eszk\u00f6z IP-c\u00edm\u00e9t.", "title": "Automatikus csatlakoz\u00e1s a h\u00eddhoz" diff --git a/homeassistant/components/lutron_caseta/translations/id.json b/homeassistant/components/lutron_caseta/translations/id.json index b14e9ad1c23..409cea59060 100644 --- a/homeassistant/components/lutron_caseta/translations/id.json +++ b/homeassistant/components/lutron_caseta/translations/id.json @@ -8,7 +8,7 @@ "error": { "cannot_connect": "Gagal terhubung" }, - "flow_title": "Lutron Cas\u00e9ta {name} ({host})", + "flow_title": "{name} ({host})", "step": { "import_failed": { "description": "Tidak dapat menyiapkan bridge (host: {host} ) yang diimpor dari configuration.yaml.", diff --git a/homeassistant/components/lyric/translations/hu.json b/homeassistant/components/lyric/translations/hu.json index c6174673a90..7586310c8a7 100644 --- a/homeassistant/components/lyric/translations/hu.json +++ b/homeassistant/components/lyric/translations/hu.json @@ -2,7 +2,7 @@ "config": { "abort": { "authorize_url_timeout": "Id\u0151t\u00fall\u00e9p\u00e9s a hiteles\u00edt\u00e9si URL gener\u00e1l\u00e1sa sor\u00e1n.", - "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rlek, k\u00f6vesd a dokument\u00e1ci\u00f3t.", + "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rem, k\u00f6vesse a dokument\u00e1ci\u00f3t.", "reauth_successful": "Az \u00fajhiteles\u00edt\u00e9s sikeres volt" }, "create_entry": { diff --git a/homeassistant/components/lyric/translations/id.json b/homeassistant/components/lyric/translations/id.json index 876fe2f8c39..f1057fc7cb2 100644 --- a/homeassistant/components/lyric/translations/id.json +++ b/homeassistant/components/lyric/translations/id.json @@ -2,7 +2,8 @@ "config": { "abort": { "authorize_url_timeout": "Tenggang waktu pembuatan URL otorisasi habis.", - "missing_configuration": "Komponen tidak dikonfigurasi. Ikuti petunjuk dalam dokumentasi." + "missing_configuration": "Komponen tidak dikonfigurasi. Ikuti petunjuk dalam dokumentasi.", + "reauth_successful": "Autentikasi ulang berhasil" }, "create_entry": { "default": "Berhasil diautentikasi" @@ -10,6 +11,9 @@ "step": { "pick_implementation": { "title": "Pilih Metode Autentikasi" + }, + "reauth_confirm": { + "title": "Autentikasi Ulang Integrasi" } } } diff --git a/homeassistant/components/mailgun/translations/hu.json b/homeassistant/components/mailgun/translations/hu.json index 14c2293734c..b40c4316bba 100644 --- a/homeassistant/components/mailgun/translations/hu.json +++ b/homeassistant/components/mailgun/translations/hu.json @@ -2,14 +2,14 @@ "config": { "abort": { "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges.", - "webhook_not_internet_accessible": "A Home Assistant rendszerednek el\u00e9rhet\u0151nek kell lennie az internetr\u0151l a webhook \u00fczenetek fogad\u00e1s\u00e1hoz." + "webhook_not_internet_accessible": "A Home Assistant p\u00e9ld\u00e1ny\u00e1nak el\u00e9rhet\u0151nek kell lennie az internet fel\u0151l a webhook \u00fczenetek fogad\u00e1s\u00e1hoz." }, "create_entry": { - "default": "Ha esem\u00e9nyeket szeretne k\u00fcldeni a Home Assistant programnak, be kell \u00e1ll\u00edtania a [Webhooks with Mailgun]({mailgun_url}) alkalmaz\u00e1st. \n\n T\u00f6ltse ki a k\u00f6vetkez\u0151 inform\u00e1ci\u00f3kat: \n\n - URL: `{webhook_url}`\n - M\u00f3dszer: POST\n - Tartalom t\u00edpusa: application/json \n\n L\u00e1sd [a dokument\u00e1ci\u00f3]({docs_url}), hogyan konfigur\u00e1lhatja az automatiz\u00e1l\u00e1sokat a bej\u00f6v\u0151 adatok kezel\u00e9s\u00e9re." + "default": "Ha esem\u00e9nyeket szeretne k\u00fcldeni a Home Assistant programnak, be kell \u00e1ll\u00edtania a [Webhooks with Mailgun]({mailgun_url}) alkalmaz\u00e1st. \n\nT\u00f6ltse ki a k\u00f6vetkez\u0151 inform\u00e1ci\u00f3kat: \n\n - URL: `{webhook_url}`\n - M\u00f3dszer: POST\n - Tartalom t\u00edpusa: application/json \n\nL\u00e1sd [a dokument\u00e1ci\u00f3]({docs_url}), hogyan konfigur\u00e1lhatja az automatizmusokat a bej\u00f6v\u0151 adatok kezel\u00e9s\u00e9re." }, "step": { "user": { - "description": "Biztosan be szeretn\u00e9d \u00e1ll\u00edtani a Mailgunt?", + "description": "Biztos benne, hogy be szeretn\u00e9 \u00e1ll\u00edtani a Mailgunt?", "title": "Mailgun Webhook be\u00e1ll\u00edt\u00e1sa" } } diff --git a/homeassistant/components/mazda/translations/ca.json b/homeassistant/components/mazda/translations/ca.json index ef00713216a..17ef370b007 100644 --- a/homeassistant/components/mazda/translations/ca.json +++ b/homeassistant/components/mazda/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat", + "already_configured": "El compte ja est\u00e0 configurat", "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" }, "error": { diff --git a/homeassistant/components/mazda/translations/hu.json b/homeassistant/components/mazda/translations/hu.json index e6b80240184..c3f00040ea3 100644 --- a/homeassistant/components/mazda/translations/hu.json +++ b/homeassistant/components/mazda/translations/hu.json @@ -5,7 +5,7 @@ "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt" }, "error": { - "account_locked": "Fi\u00f3k z\u00e1rolva. K\u00e9rlek, pr\u00f3b\u00e1ld \u00fajra k\u00e9s\u0151bb.", + "account_locked": "Fi\u00f3k z\u00e1rolva. K\u00e9rem, pr\u00f3b\u00e1lja \u00fajra k\u00e9s\u0151bb.", "cannot_connect": "Sikertelen csatlakoz\u00e1s", "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" diff --git a/homeassistant/components/meteo_france/translations/hu.json b/homeassistant/components/meteo_france/translations/hu.json index 112f70b6ea6..8034f6d0586 100644 --- a/homeassistant/components/meteo_france/translations/hu.json +++ b/homeassistant/components/meteo_france/translations/hu.json @@ -12,7 +12,7 @@ "data": { "city": "V\u00e1ros" }, - "description": "V\u00e1laszd ki a v\u00e1rost a list\u00e1b\u00f3l", + "description": "V\u00e1lassza ki a v\u00e1rost a list\u00e1b\u00f3l", "title": "M\u00e9t\u00e9o-France" }, "user": { diff --git a/homeassistant/components/meteoclimatic/translations/es.json b/homeassistant/components/meteoclimatic/translations/es.json index 2cb627d4ae0..ab84e6604e3 100644 --- a/homeassistant/components/meteoclimatic/translations/es.json +++ b/homeassistant/components/meteoclimatic/translations/es.json @@ -1,8 +1,12 @@ { "config": { "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado", "unknown": "Error inesperado" }, + "error": { + "not_found": "No se encontraron dispositivos en la red" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/meteoclimatic/translations/id.json b/homeassistant/components/meteoclimatic/translations/id.json new file mode 100644 index 00000000000..81dddee653f --- /dev/null +++ b/homeassistant/components/meteoclimatic/translations/id.json @@ -0,0 +1,11 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "error": { + "not_found": "Tidak ada perangkat yang ditemukan di jaringan" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mikrotik/translations/hu.json b/homeassistant/components/mikrotik/translations/hu.json index 248884f9687..3e5281fc06a 100644 --- a/homeassistant/components/mikrotik/translations/hu.json +++ b/homeassistant/components/mikrotik/translations/hu.json @@ -11,7 +11,7 @@ "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "name": "N\u00e9v", "password": "Jelsz\u00f3", "port": "Port", diff --git a/homeassistant/components/mikrotik/translations/ru.json b/homeassistant/components/mikrotik/translations/ru.json index 06e9d647545..015d2061c76 100644 --- a/homeassistant/components/mikrotik/translations/ru.json +++ b/homeassistant/components/mikrotik/translations/ru.json @@ -27,7 +27,7 @@ "device_tracker": { "data": { "arp_ping": "\u0412\u043a\u043b\u044e\u0447\u0438\u0442\u044c ARP-\u043f\u0438\u043d\u0433", - "detection_time": "\u0412\u0440\u0435\u043c\u044f \u043e\u0442 \u043f\u043e\u0441\u043b\u0435\u0434\u043d\u0435\u0433\u043e \u0441\u0435\u0430\u043d\u0441\u0430 \u0441\u0432\u044f\u0437\u0438 \u0441 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u043c (\u0441\u0435\u043a.), \u043f\u043e \u0438\u0441\u0442\u0435\u0447\u0435\u043d\u0438\u044e \u043a\u043e\u0442\u043e\u0440\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043f\u043e\u043b\u0443\u0447\u0438\u0442 \u0441\u0442\u0430\u0442\u0443\u0441 \"\u041d\u0435 \u0434\u043e\u043c\u0430\".", + "detection_time": "\u0412\u0440\u0435\u043c\u044f, \u0432 \u0442\u0435\u0447\u0435\u043d\u0438\u0438 \u043a\u043e\u0442\u043e\u0440\u043e\u0433\u043e \u0441\u0447\u0438\u0442\u0430\u0442\u044c \u0447\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0434\u043e\u043c\u0430 (\u0432 \u0441\u0435\u043a\u0443\u043d\u0434\u0430\u0445)", "force_dhcp": "\u041f\u0440\u0438\u043d\u0443\u0434\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u0435 \u0441\u043a\u0430\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435 \u0441 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043c DHCP" } } diff --git a/homeassistant/components/mill/translations/ca.json b/homeassistant/components/mill/translations/ca.json index 13ce41cec91..309e5ccc41c 100644 --- a/homeassistant/components/mill/translations/ca.json +++ b/homeassistant/components/mill/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat" + "already_configured": "El compte ja est\u00e0 configurat" }, "error": { "cannot_connect": "Ha fallat la connexi\u00f3" diff --git a/homeassistant/components/minecraft_server/translations/hu.json b/homeassistant/components/minecraft_server/translations/hu.json index ef3c228d2d5..02c2a06d8ab 100644 --- a/homeassistant/components/minecraft_server/translations/hu.json +++ b/homeassistant/components/minecraft_server/translations/hu.json @@ -4,14 +4,14 @@ "already_configured": "A szolg\u00e1ltat\u00e1s m\u00e1r konfigur\u00e1lva van" }, "error": { - "cannot_connect": "Nem siker\u00fclt csatlakozni a szerverhez. K\u00e9rj\u00fck, ellen\u0151rizze a gazdag\u00e9pet \u00e9s a portot, majd pr\u00f3b\u00e1lkozzon \u00fajra. Gondoskodjon arr\u00f3l, hogy a szerveren legal\u00e1bb a Minecraft 1.7-es verzi\u00f3j\u00e1t futtassa.", + "cannot_connect": "Nem siker\u00fclt csatlakozni a szerverhez. K\u00e9rj\u00fck, ellen\u0151rizze a c\u00edmet \u00e9s a portot, majd pr\u00f3b\u00e1lkozzon \u00fajra. Gondoskodjon arr\u00f3l, hogy a szerveren legal\u00e1bb a Minecraft 1.7-es verzi\u00f3j\u00e1t futtassa.", "invalid_ip": "Az IP -c\u00edm \u00e9rv\u00e9nytelen (a MAC -c\u00edmet nem siker\u00fclt meghat\u00e1rozni). K\u00e9rj\u00fck, jav\u00edtsa ki, \u00e9s pr\u00f3b\u00e1lja \u00fajra.", "invalid_port": "A portnak 1024 \u00e9s 65535 k\u00f6z\u00f6tt kell lennie. K\u00e9rj\u00fck, jav\u00edtsa ki, \u00e9s pr\u00f3b\u00e1lja \u00fajra." }, "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "name": "N\u00e9v" }, "description": "\u00c1ll\u00edtsa be a Minecraft Server p\u00e9ld\u00e1nyt, hogy lehet\u0151v\u00e9 tegye a megfigyel\u00e9st.", diff --git a/homeassistant/components/mobile_app/translations/hu.json b/homeassistant/components/mobile_app/translations/hu.json index 90690e2545b..1dda8ce7223 100644 --- a/homeassistant/components/mobile_app/translations/hu.json +++ b/homeassistant/components/mobile_app/translations/hu.json @@ -1,11 +1,11 @@ { "config": { "abort": { - "install_app": "Nyisd meg a mobil alkalmaz\u00e1st a Home Assistant-tal val\u00f3 integr\u00e1ci\u00f3hoz. A kompatibilis alkalmaz\u00e1sok list\u00e1j\u00e1nak megtekint\u00e9s\u00e9hez ellen\u0151rizd [a le\u00edr\u00e1st]({apps_url})." + "install_app": "Nyissa meg a mobil alkalmaz\u00e1st Home Assistant-tal val\u00f3 integr\u00e1ci\u00f3hoz. A kompatibilis alkalmaz\u00e1sok list\u00e1j\u00e1nak megtekint\u00e9s\u00e9hez ellen\u0151rizze [a le\u00edr\u00e1st]({apps_url})." }, "step": { "confirm": { - "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a mobil alkalmaz\u00e1s komponenst?" + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani a mobil alkalmaz\u00e1s komponenst?" } } }, diff --git a/homeassistant/components/modem_callerid/translations/ca.json b/homeassistant/components/modem_callerid/translations/ca.json new file mode 100644 index 00000000000..d94d4cf392d --- /dev/null +++ b/homeassistant/components/modem_callerid/translations/ca.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat", + "already_in_progress": "El flux de configuraci\u00f3 ja est\u00e0 en curs", + "no_devices_found": "No s'ha trobat cap dispositiu restant" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3" + }, + "step": { + "usb_confirm": { + "description": "Integraci\u00f3 per a trucades fixes amb el m\u00f2dem de veu CX93001. Pot obtenir l'identificador del que truca i pot rebutjar trucades entrants.", + "title": "M\u00f2dem telef\u00f2nic" + }, + "user": { + "data": { + "name": "Nom", + "port": "Port" + }, + "description": "Integraci\u00f3 per a trucades fixes amb el m\u00f2dem de veu CX93001. Pot obtenir l'identificador del que truca i pot rebutjar trucades entrants.", + "title": "M\u00f2dem telef\u00f2nic" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/modem_callerid/translations/cs.json b/homeassistant/components/modem_callerid/translations/cs.json new file mode 100644 index 00000000000..05861d2c427 --- /dev/null +++ b/homeassistant/components/modem_callerid/translations/cs.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno", + "already_in_progress": "Konfigurace ji\u017e prob\u00edh\u00e1" + }, + "error": { + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit" + }, + "step": { + "user": { + "data": { + "name": "Jm\u00e9no", + "port": "Port" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/modem_callerid/translations/de.json b/homeassistant/components/modem_callerid/translations/de.json new file mode 100644 index 00000000000..0bc505be5c8 --- /dev/null +++ b/homeassistant/components/modem_callerid/translations/de.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "already_in_progress": "Der Konfigurationsablauf wird bereits ausgef\u00fchrt", + "no_devices_found": "Keine weiteren Ger\u00e4te gefunden" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen" + }, + "step": { + "usb_confirm": { + "description": "Dies ist eine Integration f\u00fcr Festnetzanrufe mit einem CX93001 Sprachmodem. Damit k\u00f6nnen Anrufer-ID-Informationen mit einer Option zum Abweisen eines eingehenden Anrufs abgerufen werden.", + "title": "Telefonmodem" + }, + "user": { + "data": { + "name": "Name", + "port": "Port" + }, + "description": "Dies ist eine Integration f\u00fcr Festnetzanrufe mit einem CX93001 Sprachmodem. Damit k\u00f6nnen Anrufer-ID-Informationen mit einer Option zum Abweisen eines eingehenden Anrufs abgerufen werden.", + "title": "Telefonmodem" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/modem_callerid/translations/en.json b/homeassistant/components/modem_callerid/translations/en.json index 207f9ab7a17..5450a930ff3 100644 --- a/homeassistant/components/modem_callerid/translations/en.json +++ b/homeassistant/components/modem_callerid/translations/en.json @@ -9,17 +9,17 @@ "cannot_connect": "Failed to connect" }, "step": { + "usb_confirm": { + "description": "This is an integration for landline calls using a CX93001 voice modem. This can retrieve caller ID information with an option to reject an incoming call.", + "title": "Phone Modem" + }, "user": { "data": { "name": "Name", "port": "Port" }, - "title": "Phone Modem", - "description": "This is an integration for landline calls using a CX93001 voice modem. This can retrieve caller id information with an option to reject an incoming call." - }, - "usb_confirm": { - "title": "Phone Modem", - "description": "This is an integration for landline calls using a CX93001 voice modem. This can retrieve caller ID information with an option to reject an incoming call." + "description": "This is an integration for landline calls using a CX93001 voice modem. This can retrieve caller ID information with an option to reject an incoming call.", + "title": "Phone Modem" } } } diff --git a/homeassistant/components/modem_callerid/translations/es.json b/homeassistant/components/modem_callerid/translations/es.json new file mode 100644 index 00000000000..eaf0a9afea1 --- /dev/null +++ b/homeassistant/components/modem_callerid/translations/es.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado", + "already_in_progress": "El flujo de configuraci\u00f3n ya est\u00e1 en proceso" + }, + "step": { + "user": { + "data": { + "name": "Nombre", + "port": "Puerto" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/modem_callerid/translations/et.json b/homeassistant/components/modem_callerid/translations/et.json new file mode 100644 index 00000000000..463d24e8f9f --- /dev/null +++ b/homeassistant/components/modem_callerid/translations/et.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud", + "already_in_progress": "Seadistamine juba k\u00e4ib", + "no_devices_found": "Lisatavaid seadmeid ei leitud" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus" + }, + "step": { + "usb_confirm": { + "description": "See on sidumine fiksv\u00f5rgu telefonile kasutades CX93001 modemit. See v\u00f5ib hankida helistaja ID teabe koos sissetulevast k\u00f5nestloobumise v\u00f5imalusega.", + "title": "Telefoniliini modem" + }, + "user": { + "data": { + "name": "Nimi", + "port": "Port" + }, + "description": "See on sidumine fiksv\u00f5rgu telefonile kasutades CX93001 modemit. See v\u00f5ib hankida helistaja ID teabe koos sissetulevast k\u00f5nestloobumise v\u00f5imalusega.", + "title": "Telefoniliini modem" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/modem_callerid/translations/he.json b/homeassistant/components/modem_callerid/translations/he.json new file mode 100644 index 00000000000..e156f21f826 --- /dev/null +++ b/homeassistant/components/modem_callerid/translations/he.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "already_in_progress": "\u05d6\u05e8\u05d9\u05de\u05ea \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4 \u05db\u05d1\u05e8 \u05de\u05ea\u05d1\u05e6\u05e2\u05ea" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" + }, + "step": { + "user": { + "data": { + "name": "\u05e9\u05dd", + "port": "\u05e4\u05ea\u05d7\u05d4" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/modem_callerid/translations/hu.json b/homeassistant/components/modem_callerid/translations/hu.json new file mode 100644 index 00000000000..cb8433e0028 --- /dev/null +++ b/homeassistant/components/modem_callerid/translations/hu.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van.", + "no_devices_found": "Nem tal\u00e1lhat\u00f3 egy\u00e9b eszk\u00f6z" + }, + "error": { + "cannot_connect": "Sikertelen csatlakoz\u00e1s" + }, + "step": { + "usb_confirm": { + "description": "Ez egy integr\u00e1ci\u00f3 a CX93001 hangmodemmel t\u00f6rt\u00e9n\u0151 vezet\u00e9kes h\u00edv\u00e1sokhoz. Ez k\u00e9pes lek\u00e9rdezni a h\u00edv\u00f3azonos\u00edt\u00f3 inform\u00e1ci\u00f3t a bej\u00f6v\u0151 h\u00edv\u00e1s visszautas\u00edt\u00e1s\u00e1nak lehet\u0151s\u00e9g\u00e9vel.", + "title": "Telefon modem" + }, + "user": { + "data": { + "name": "N\u00e9v", + "port": "Port" + }, + "description": "Ez egy integr\u00e1ci\u00f3 a CX93001 hangmodemmel t\u00f6rt\u00e9n\u0151 vezet\u00e9kes h\u00edv\u00e1sokhoz. Ez k\u00e9pes lek\u00e9rdezni a h\u00edv\u00f3azonos\u00edt\u00f3 inform\u00e1ci\u00f3t a bej\u00f6v\u0151 h\u00edv\u00e1s visszautas\u00edt\u00e1s\u00e1nak lehet\u0151s\u00e9g\u00e9vel.", + "title": "Telefon modem" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/modem_callerid/translations/id.json b/homeassistant/components/modem_callerid/translations/id.json new file mode 100644 index 00000000000..9e8fc6738b9 --- /dev/null +++ b/homeassistant/components/modem_callerid/translations/id.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi", + "already_in_progress": "Alur konfigurasi sedang berlangsung" + }, + "error": { + "cannot_connect": "Gagal terhubung" + }, + "step": { + "user": { + "data": { + "name": "Nama", + "port": "Port" + }, + "title": "Modem Telepon" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/modem_callerid/translations/it.json b/homeassistant/components/modem_callerid/translations/it.json new file mode 100644 index 00000000000..65d1c74f956 --- /dev/null +++ b/homeassistant/components/modem_callerid/translations/it.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", + "already_in_progress": "Il flusso di configurazione \u00e8 gi\u00e0 in corso", + "no_devices_found": "Nessun dispositivo rimanente trovato" + }, + "error": { + "cannot_connect": "Impossibile connettersi" + }, + "step": { + "usb_confirm": { + "description": "Questa \u00e8 un'integrazione per le chiamate su linea fissa che utilizza un modem vocale CX93001. Questo pu\u00f2 recuperare le informazioni sull'ID del chiamante con un'opzione per rifiutare una chiamata in arrivo.", + "title": "Modem del telefono" + }, + "user": { + "data": { + "name": "Nome", + "port": "Porta" + }, + "description": "Questa \u00e8 un'integrazione per le chiamate su linea fissa che utilizza un modem vocale CX93001. Questo pu\u00f2 recuperare le informazioni sull'ID del chiamante con un'opzione per rifiutare una chiamata in arrivo.", + "title": "Modem del telefono" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/modem_callerid/translations/nl.json b/homeassistant/components/modem_callerid/translations/nl.json new file mode 100644 index 00000000000..4077a03105b --- /dev/null +++ b/homeassistant/components/modem_callerid/translations/nl.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd", + "already_in_progress": "De configuratiestroom is al aan de gang", + "no_devices_found": "Geen resterende apparaten gevonden" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken" + }, + "step": { + "usb_confirm": { + "description": "Dit is een integratie voor vaste telefoongesprekken met een CX93001 spraakmodem. Hiermee kan beller-ID informatie worden opgehaald met een optie om een inkomende oproep te weigeren.", + "title": "Telefoonmodem" + }, + "user": { + "data": { + "name": "Naam", + "port": "Poort" + }, + "description": "Dit is een integratie voor vaste telefoongesprekken met een CX93001 spraakmodem. Hiermee kan beller-ID informatie worden opgehaald met een optie om een inkomende oproep te weigeren.", + "title": "Telefoonmodem" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/modem_callerid/translations/no.json b/homeassistant/components/modem_callerid/translations/no.json new file mode 100644 index 00000000000..2e1103b5092 --- /dev/null +++ b/homeassistant/components/modem_callerid/translations/no.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert", + "already_in_progress": "Konfigurasjonsflyten p\u00e5g\u00e5r allerede", + "no_devices_found": "Ingen gjenv\u00e6rende enheter funnet" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes" + }, + "step": { + "usb_confirm": { + "description": "Dette er en integrasjon for fasttelefonsamtaler ved hjelp av et talemodem CX93001. Dette kan hente oppringer -ID -informasjon med et alternativ for \u00e5 avvise et innkommende anrop.", + "title": "Telefonmodem" + }, + "user": { + "data": { + "name": "Navn", + "port": "Port" + }, + "description": "Dette er en integrasjon for fasttelefonsamtaler ved hjelp av et talemodem CX93001. Dette kan hente oppringer -ID -informasjon med et alternativ for \u00e5 avvise et innkommende anrop.", + "title": "Telefonmodem" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/modem_callerid/translations/ru.json b/homeassistant/components/modem_callerid/translations/ru.json new file mode 100644 index 00000000000..f5fa5061a4a --- /dev/null +++ b/homeassistant/components/modem_callerid/translations/ru.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant.", + "already_in_progress": "\u041f\u0440\u043e\u0446\u0435\u0441\u0441 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u044f\u0435\u0442\u0441\u044f.", + "no_devices_found": "\u041f\u043e\u0434\u0445\u043e\u0434\u044f\u0449\u0438\u0445 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432 \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u043e." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f." + }, + "step": { + "usb_confirm": { + "description": "\u0418\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f \u0433\u043e\u043b\u043e\u0441\u043e\u0432\u043e\u0433\u043e \u043c\u043e\u0434\u0435\u043c\u0430 CX93001 \u0434\u043b\u044f \u0437\u0432\u043e\u043d\u043a\u043e\u0432 \u043f\u043e \u0441\u0442\u0430\u0446\u0438\u043e\u043d\u0430\u0440\u043d\u043e\u0439 \u043b\u0438\u043d\u0438\u0438. \u041f\u043e\u0437\u0432\u043e\u043b\u044f\u0435\u0442 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e \u043e\u0431 \u0438\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440\u0435 \u0432\u044b\u0437\u044b\u0432\u0430\u044e\u0449\u0435\u0433\u043e \u0430\u0431\u043e\u043d\u0435\u043d\u0442\u0430 \u0441 \u0432\u043e\u0437\u043c\u043e\u0436\u043d\u043e\u0441\u0442\u044c\u044e \u043e\u0442\u043a\u043b\u043e\u043d\u0435\u043d\u0438\u044f \u0432\u0445\u043e\u0434\u044f\u0449\u0435\u0433\u043e \u0432\u044b\u0437\u043e\u0432\u0430.", + "title": "\u0422\u0435\u043b\u0435\u0444\u043e\u043d\u043d\u044b\u0439 \u043c\u043e\u0434\u0435\u043c" + }, + "user": { + "data": { + "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435", + "port": "\u041f\u043e\u0440\u0442" + }, + "description": "\u0418\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f \u0433\u043e\u043b\u043e\u0441\u043e\u0432\u043e\u0433\u043e \u043c\u043e\u0434\u0435\u043c\u0430 CX93001 \u0434\u043b\u044f \u0437\u0432\u043e\u043d\u043a\u043e\u0432 \u043f\u043e \u0441\u0442\u0430\u0446\u0438\u043e\u043d\u0430\u0440\u043d\u043e\u0439 \u043b\u0438\u043d\u0438\u0438. \u041f\u043e\u0437\u0432\u043e\u043b\u044f\u0435\u0442 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e \u043e\u0431 \u0438\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440\u0435 \u0432\u044b\u0437\u044b\u0432\u0430\u044e\u0449\u0435\u0433\u043e \u0430\u0431\u043e\u043d\u0435\u043d\u0442\u0430 \u0441 \u0432\u043e\u0437\u043c\u043e\u0436\u043d\u043e\u0441\u0442\u044c\u044e \u043e\u0442\u043a\u043b\u043e\u043d\u0435\u043d\u0438\u044f \u0432\u0445\u043e\u0434\u044f\u0449\u0435\u0433\u043e \u0432\u044b\u0437\u043e\u0432\u0430.", + "title": "\u0422\u0435\u043b\u0435\u0444\u043e\u043d\u043d\u044b\u0439 \u043c\u043e\u0434\u0435\u043c" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/modem_callerid/translations/zh-Hant.json b/homeassistant/components/modem_callerid/translations/zh-Hant.json new file mode 100644 index 00000000000..542a12e8c5d --- /dev/null +++ b/homeassistant/components/modem_callerid/translations/zh-Hant.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "already_in_progress": "\u8a2d\u5b9a\u5df2\u7d93\u9032\u884c\u4e2d", + "no_devices_found": "\u627e\u4e0d\u5230\u5269\u9918\u88dd\u7f6e" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557" + }, + "step": { + "usb_confirm": { + "description": "\u6b64\u6574\u5408\u4f7f\u7528 CX93001 \u8a9e\u97f3\u6578\u64da\u6a5f\u9032\u884c\u5e02\u8a71\u901a\u8a71\u3002\u53ef\u7528\u4ee5\u6aa2\u67e5\u4f86\u96fb ID \u8cc7\u8a0a\u3001\u4e26\u9032\u884c\u62d2\u63a5\u4f86\u96fb\u7684\u529f\u80fd\u3002", + "title": "\u624b\u6a5f\u6578\u64da\u6a5f" + }, + "user": { + "data": { + "name": "\u540d\u7a31", + "port": "\u901a\u8a0a\u57e0" + }, + "description": "\u6b64\u6574\u5408\u4f7f\u7528 CX93001 \u8a9e\u97f3\u6578\u64da\u6a5f\u9032\u884c\u5e02\u8a71\u901a\u8a71\u3002\u53ef\u7528\u4ee5\u6aa2\u67e5\u4f86\u96fb ID \u8cc7\u8a0a\u3001\u4e26\u9032\u884c\u62d2\u63a5\u4f86\u96fb\u7684\u529f\u80fd\u3002", + "title": "\u624b\u6a5f\u6578\u64da\u6a5f" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/modern_forms/translations/es.json b/homeassistant/components/modern_forms/translations/es.json index 25a432214fc..f651dca40a5 100644 --- a/homeassistant/components/modern_forms/translations/es.json +++ b/homeassistant/components/modern_forms/translations/es.json @@ -1,7 +1,17 @@ { "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado", + "cannot_connect": "No se pudo conectar" + }, + "error": { + "cannot_connect": "No se pudo conectar" + }, "flow_title": "{name}", "step": { + "confirm": { + "description": "\u00bfQuieres iniciar la configuraci\u00f3n?" + }, "user": { "data": { "host": "Anfitri\u00f3n" diff --git a/homeassistant/components/modern_forms/translations/hu.json b/homeassistant/components/modern_forms/translations/hu.json index fee0216224c..5bea7c3054e 100644 --- a/homeassistant/components/modern_forms/translations/hu.json +++ b/homeassistant/components/modern_forms/translations/hu.json @@ -10,16 +10,16 @@ "flow_title": "{name}", "step": { "confirm": { - "description": "El szeretn\u00e9d kezdeni a be\u00e1ll\u00edt\u00e1st?" + "description": "El szeretn\u00e9 kezdeni a be\u00e1ll\u00edt\u00e1st?" }, "user": { "data": { - "host": "Hoszt" + "host": "C\u00edm" }, - "description": "\u00c1ll\u00edtsa be a Modern Forms-t, hogy integr\u00e1l\u00f3djon a Home Assistant programba." + "description": "\u00c1ll\u00edtsa be Modern Forms-t, hogy integr\u00e1l\u00f3djon Home Assistant-ba." }, "zeroconf_confirm": { - "description": "Hozz\u00e1 szeretn\u00e9 adni a(z) {name} `nev\u0171 Modern Forms rajong\u00f3t a Home Assistanthoz?", + "description": "Hozz\u00e1 szeretn\u00e9 adni `{name}`nev\u0171 Modern Forms rajong\u00f3t Home Assistanthoz?", "title": "Felfedezte a Modern Forms rajong\u00f3i eszk\u00f6zt" } } diff --git a/homeassistant/components/modern_forms/translations/id.json b/homeassistant/components/modern_forms/translations/id.json new file mode 100644 index 00000000000..8b2f9fcfa1d --- /dev/null +++ b/homeassistant/components/modern_forms/translations/id.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi", + "cannot_connect": "Gagal terhubung" + }, + "error": { + "cannot_connect": "Gagal terhubung" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "Ingin memulai penyiapan?" + }, + "user": { + "data": { + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/modern_forms/translations/nl.json b/homeassistant/components/modern_forms/translations/nl.json index 5a3d63e15a7..ccbdf7d5b44 100644 --- a/homeassistant/components/modern_forms/translations/nl.json +++ b/homeassistant/components/modern_forms/translations/nl.json @@ -10,7 +10,7 @@ "flow_title": "{name}", "step": { "confirm": { - "description": "Wil je beginnen met instellen?" + "description": "Wilt u beginnen met instellen?" }, "user": { "data": { diff --git a/homeassistant/components/motion_blinds/translations/hu.json b/homeassistant/components/motion_blinds/translations/hu.json index a2560e5fa79..32ff2dcc58e 100644 --- a/homeassistant/components/motion_blinds/translations/hu.json +++ b/homeassistant/components/motion_blinds/translations/hu.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van.", "connection_error": "Sikertelen csatlakoz\u00e1s" }, "error": { diff --git a/homeassistant/components/motioneye/translations/hu.json b/homeassistant/components/motioneye/translations/hu.json index 5b23c74dc76..c381d3954d4 100644 --- a/homeassistant/components/motioneye/translations/hu.json +++ b/homeassistant/components/motioneye/translations/hu.json @@ -12,7 +12,7 @@ }, "step": { "hassio_confirm": { - "description": "Be szeretn\u00e9 \u00e1ll\u00edtani a Home Assistant, hogy csatlakozzon a(z) {addon} \u00e1ltal biztos\u00edtott motionEye szolg\u00e1ltat\u00e1shoz?", + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani Home Assistant-ot, hogy csatlakozzon {addon} kieg\u00e9sz\u00edt\u0151 \u00e1ltal biztos\u00edtott motionEye szolg\u00e1ltat\u00e1shoz?", "title": "motionEye a Home Assistant kieg\u00e9sz\u00edt\u0151 seg\u00edts\u00e9g\u00e9vel" }, "user": { @@ -30,7 +30,7 @@ "step": { "init": { "data": { - "webhook_set": "\u00c1ll\u00edtsa be a motionEye webhookokat az esem\u00e9nyek jelent\u00e9s\u00e9nek a Home Assistant sz\u00e1m\u00e1ra", + "webhook_set": "\u00c1ll\u00edtsa be a motionEye webhookokat az esem\u00e9nyek jelent\u00e9s\u00e9nek Home Assistant sz\u00e1m\u00e1ra", "webhook_set_overwrite": "Fel\u00fcl\u00edrja a fel nem ismert webhookokat" } } diff --git a/homeassistant/components/motioneye/translations/ko.json b/homeassistant/components/motioneye/translations/ko.json new file mode 100644 index 00000000000..ff2a843677d --- /dev/null +++ b/homeassistant/components/motioneye/translations/ko.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "\uc11c\ube44\uc2a4\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "reauth_successful": "\uc7ac\uc778\uc99d\uc5d0 \uc131\uacf5\ud588\uc2b5\ub2c8\ub2e4" + }, + "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "invalid_url": "\uc798\ubabb\ub41c URL", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "admin_password": "Admin \ube44\ubc00\ubc88\ud638", + "admin_username": "Admin \uc0ac\uc6a9\uc790 \uc774\ub984", + "surveillance_password": "Surveillance \ube44\ubc00\ubc88\ud638", + "surveillance_username": "Surveillance \uc0ac\uc6a9\uc790 \uc774\ub984", + "url": "URL \uc8fc\uc18c" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mqtt/translations/es.json b/homeassistant/components/mqtt/translations/es.json index 2cabe392308..50cac3172ab 100644 --- a/homeassistant/components/mqtt/translations/es.json +++ b/homeassistant/components/mqtt/translations/es.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "El servicio ya est\u00e1 configurado", "single_instance_allowed": "S\u00f3lo se permite una \u00fanica configuraci\u00f3n de MQTT." }, "error": { diff --git a/homeassistant/components/mqtt/translations/fi.json b/homeassistant/components/mqtt/translations/fi.json index 27a956beb33..bc974dfd7d9 100644 --- a/homeassistant/components/mqtt/translations/fi.json +++ b/homeassistant/components/mqtt/translations/fi.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "cannot_connect": "Yhdist\u00e4minen ep\u00e4onnistui" + }, "step": { "broker": { "data": { diff --git a/homeassistant/components/mqtt/translations/he.json b/homeassistant/components/mqtt/translations/he.json index df987bd35a2..36521ce6839 100644 --- a/homeassistant/components/mqtt/translations/he.json +++ b/homeassistant/components/mqtt/translations/he.json @@ -19,18 +19,45 @@ "description": "\u05e0\u05d0 \u05dc\u05d4\u05d6\u05d9\u05df \u05d0\u05ea \u05e4\u05e8\u05d8\u05d9 \u05d4\u05d7\u05d9\u05d1\u05d5\u05e8 \u05e9\u05dc \u05d4\u05d1\u05e8\u05d5\u05e7\u05e8 MQTT \u05e9\u05dc\u05da." }, "hassio_confirm": { + "data": { + "discovery": "\u05d0\u05d9\u05e4\u05e9\u05d5\u05e8 \u05d2\u05d9\u05dc\u05d5\u05d9" + }, "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05d2\u05d3\u05d9\u05e8 \u05d0\u05ea \u05ea\u05e6\u05d5\u05e8\u05ea \u05d4-Home Assistant \u05db\u05da \u05e9\u05ea\u05ea\u05d7\u05d1\u05e8 \u05dc\u05de\u05ea\u05d5\u05d5\u05da MQTT \u05d4\u05de\u05e1\u05d5\u05e4\u05e7 \u05e2\u05dc \u05d9\u05d3\u05d9 \u05d4\u05d4\u05e8\u05d7\u05d1\u05d4 {addon}?", "title": "MQTT \u05d1\u05e8\u05d5\u05e7\u05e8 \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea \u05d4\u05e8\u05d7\u05d1\u05ea Home Assistant" } } }, + "device_automation": { + "trigger_subtype": { + "button_1": "\u05db\u05e4\u05ea\u05d5\u05e8 \u05e8\u05d0\u05e9\u05d5\u05df", + "button_2": "\u05db\u05e4\u05ea\u05d5\u05e8 \u05e9\u05e0\u05d9", + "button_3": "\u05db\u05e4\u05ea\u05d5\u05e8 \u05e9\u05dc\u05d9\u05e9\u05d9", + "button_4": "\u05db\u05e4\u05ea\u05d5\u05e8 \u05e8\u05d1\u05d9\u05e2\u05d9", + "button_5": "\u05db\u05e4\u05ea\u05d5\u05e8 \u05d7\u05de\u05d9\u05e9\u05d9", + "button_6": "\u05db\u05e4\u05ea\u05d5\u05e8 \u05e9\u05d9\u05e9\u05d9", + "turn_off": "\u05db\u05d1\u05d4", + "turn_on": "\u05d4\u05e4\u05e2\u05dc" + }, + "trigger_type": { + "button_double_press": "\"{subtype}\" \u05d4\u05e7\u05e9\u05d4 \u05db\u05e4\u05d5\u05dc\u05d4", + "button_long_press": "\"{subtype}\" \u05dc\u05d7\u05d9\u05e6\u05d4 \u05de\u05de\u05d5\u05e9\u05db\u05ea", + "button_long_release": "\"{subtype}\" \u05e9\u05d5\u05d7\u05e8\u05e8 \u05dc\u05d0\u05d7\u05e8 \u05dc\u05d7\u05d9\u05e6\u05d4 \u05de\u05de\u05d5\u05e9\u05db\u05ea", + "button_quadruple_press": "\"{subtype}\" \u05dc\u05d7\u05d9\u05e6\u05d4 \u05de\u05e8\u05d5\u05d1\u05e2\u05ea", + "button_quintuple_press": "\"{subtype}\" \u05dc\u05d7\u05d9\u05e6\u05d4 \u05de\u05d7\u05d5\u05de\u05e9\u05ea", + "button_short_press": "\"{subtype}\" \u05e0\u05dc\u05d7\u05e5", + "button_short_release": "\"{subtype}\" \u05e9\u05d5\u05d7\u05e8\u05e8", + "button_triple_press": "\"{subtype}\" \u05dc\u05d7\u05d9\u05e6\u05d4 \u05de\u05e9\u05d5\u05dc\u05e9\u05ea" + } + }, "options": { "error": { + "bad_birth": "\u05e0\u05d5\u05e9\u05d0 \u05dc\u05d9\u05d3\u05d4 \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9.", "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" }, "step": { "broker": { "data": { + "broker": "\u05d1\u05e8\u05d5\u05e7\u05e8", "password": "\u05e1\u05d9\u05e1\u05de\u05d4", "port": "\u05e4\u05ea\u05d7\u05d4", "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" @@ -39,6 +66,13 @@ "title": "\u05d0\u05e4\u05e9\u05e8\u05d5\u05d9\u05d5\u05ea \u05de\u05ea\u05d5\u05d5\u05da" }, "options": { + "data": { + "birth_enable": "\u05d0\u05e4\u05e9\u05e8 \u05d4\u05d5\u05d3\u05e2\u05ea \u05dc\u05d9\u05d3\u05d4", + "birth_payload": "\u05de\u05d8\u05e2\u05df \u05d4\u05d5\u05d3\u05e2\u05ea \u05dc\u05d9\u05d3\u05d4", + "birth_retain": "\u05d4\u05d5\u05d3\u05e2\u05ea \u05dc\u05d9\u05d3\u05d4 \u05e0\u05e9\u05de\u05e8\u05ea", + "birth_topic": "\u05e0\u05d5\u05e9\u05d0 \u05d4\u05d5\u05d3\u05e2\u05ea \u05dc\u05d9\u05d3\u05d4", + "discovery": "\u05d0\u05d9\u05e4\u05e9\u05d5\u05e8 \u05d2\u05d9\u05dc\u05d5\u05d9" + }, "description": "\u05d2\u05d9\u05dc\u05d5\u05d9 - \u05d0\u05dd \u05d2\u05d9\u05dc\u05d5\u05d9 \u05de\u05d5\u05e4\u05e2\u05dc (\u05de\u05d5\u05de\u05dc\u05e5), Home Assistant \u05d9\u05d2\u05dc\u05d4 \u05d1\u05d0\u05d5\u05e4\u05df \u05d0\u05d5\u05d8\u05d5\u05de\u05d8\u05d9 \u05de\u05db\u05e9\u05d9\u05e8\u05d9\u05dd \u05d5\u05d9\u05e9\u05d5\u05d9\u05d5\u05ea \u05d4\u05de\u05e4\u05e8\u05e1\u05de\u05d9\u05dd \u05d0\u05ea \u05ea\u05e6\u05d5\u05e8\u05ea\u05dd \u05d1\u05de\u05ea\u05d5\u05d5\u05da MQTT. \u05d0\u05dd \u05d4\u05d2\u05d9\u05dc\u05d5\u05d9 \u05d0\u05d9\u05e0\u05d5 \u05de\u05d5\u05e4\u05e2\u05dc, \u05db\u05dc \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4 \u05d7\u05d9\u05d9\u05d1\u05ea \u05dc\u05d4\u05d9\u05e2\u05e9\u05d5\u05ea \u05d1\u05d0\u05d5\u05e4\u05df \u05d9\u05d3\u05e0\u05d9.\n\u05d4\u05d5\u05d3\u05e2\u05ea \u05dc\u05d9\u05d3\u05d4 - \u05d4\u05d5\u05d3\u05e2\u05ea \u05d4\u05dc\u05d9\u05d3\u05d4 \u05ea\u05d9\u05e9\u05dc\u05d7 \u05d1\u05db\u05dc \u05e4\u05e2\u05dd \u05e9-Home Assistant \u05de\u05ea\u05d7\u05d1\u05e8 (\u05de\u05d7\u05d3\u05e9) \u05dc\u05de\u05ea\u05d5\u05d5\u05da MQTT.\n\u05d4\u05d5\u05d3\u05e2\u05ea \u05e8\u05e6\u05d5\u05df - \u05d4\u05d5\u05d3\u05e2\u05ea \u05d4\u05e8\u05e6\u05d5\u05df \u05ea\u05d9\u05e9\u05dc\u05d7 \u05d1\u05db\u05dc \u05e4\u05e2\u05dd \u05e9-Home Assistant \u05d9\u05d0\u05d1\u05d3 \u05d0\u05ea \u05d4\u05e7\u05e9\u05e8 \u05e9\u05dc\u05d5 \u05dc\u05de\u05ea\u05d5\u05d5\u05da, \u05d2\u05dd \u05d1\u05de\u05e7\u05e8\u05d4 \u05e9\u05dc \u05e0\u05d9\u05ea\u05d5\u05e7 \u05e0\u05e7\u05d9 (\u05dc\u05de\u05e9\u05dc \u05db\u05d9\u05d1\u05d5\u05d9 \u05e9\u05dc Home Assistant) \u05d5\u05d2\u05dd \u05d1\u05de\u05e7\u05e8\u05d4 \u05e9\u05dc \u05e0\u05d9\u05ea\u05d5\u05e7 \u05dc\u05d0 \u05e0\u05e7\u05d9 (\u05dc\u05de\u05e9\u05dc Home Assistant \u05de\u05ea\u05e8\u05e1\u05e7 \u05d0\u05d5 \u05de\u05d0\u05d1\u05d3 \u05d0\u05ea \u05d7\u05d9\u05d1\u05d5\u05e8 \u05d4\u05e8\u05e9\u05ea \u05e9\u05dc\u05d5).", "title": "\u05d0\u05e4\u05e9\u05e8\u05d5\u05d9\u05d5\u05ea MQTT" } diff --git a/homeassistant/components/mqtt/translations/hu.json b/homeassistant/components/mqtt/translations/hu.json index ad371afabc8..471982756eb 100644 --- a/homeassistant/components/mqtt/translations/hu.json +++ b/homeassistant/components/mqtt/translations/hu.json @@ -16,14 +16,14 @@ "port": "Port", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" }, - "description": "K\u00e9rlek, add meg az MQTT br\u00f3ker kapcsol\u00f3d\u00e1si adatait." + "description": "K\u00e9rem, adja meg az MQTT br\u00f3ker kapcsol\u00f3d\u00e1si adatait." }, "hassio_confirm": { "data": { "discovery": "Felfedez\u00e9s enged\u00e9lyez\u00e9se" }, - "description": "Be szeretn\u00e9d konfigru\u00e1lni, hogy a Home Assistant a(z) {addon} Supervisor add-on \u00e1ltal biztos\u00edtott MQTT br\u00f3kerhez csatlakozzon?", - "title": "MQTT Br\u00f3ker a Supervisor b\u0151v\u00edtm\u00e9nnyel" + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani Home Assistant-ot MQTT br\u00f3kerhez val\u00f3 csatlakoz\u00e1shoz, {addon} kieg\u00e9sz\u00edt\u0151 \u00e1ltal?", + "title": "MQTT Br\u00f3ker - Home Assistant kieg\u00e9sz\u00edt\u0151 \u00e1ltal" } } }, @@ -63,7 +63,7 @@ "port": "Port", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" }, - "description": "K\u00e9rlek, add meg az MQTT br\u00f3ker kapcsol\u00f3d\u00e1si adatait.", + "description": "K\u00e9rem, adja meg az MQTT br\u00f3ker kapcsol\u00f3d\u00e1si adatait.", "title": "Br\u00f3ker opci\u00f3k" }, "options": { @@ -80,7 +80,7 @@ "will_retain": "\u00dczenet megtart\u00e1sa", "will_topic": "\u00dczenet t\u00e9m\u00e1ja" }, - "description": "Felfedez\u00e9s - Ha a felfedez\u00e9s enged\u00e9lyezve van (aj\u00e1nlott), a Home Assistant automatikusan felfedezi azokat az eszk\u00f6z\u00f6ket \u00e9s entit\u00e1sokat, amelyek k\u00f6zz\u00e9teszik konfigur\u00e1ci\u00f3jukat az MQTT br\u00f3keren. Ha a felfedez\u00e9s le van tiltva, minden konfigur\u00e1ci\u00f3t manu\u00e1lisan kell elv\u00e9gezni.\nSz\u00fclet\u00e9si \u00fczenet - A sz\u00fclet\u00e9si \u00fczenetet minden alkalommal elk\u00fcldi, amikor a Home Assistant (\u00fajra) csatlakozik az MQTT br\u00f3kerhez.\nAkarat \u00fczenet - Az akarat\u00fczenet minden alkalommal el lesz k\u00fcldve, amikor a Home Assistant elvesz\u00edti a kapcsolatot a k\u00f6zvet\u00edt\u0151vel, mind takar\u00edt\u00e1s eset\u00e9n (pl. A Home Assistant le\u00e1ll\u00edt\u00e1sa), mind tiszt\u00e1talans\u00e1g eset\u00e9n (pl. Home Assistant \u00f6sszeomlik vagy megszakad a h\u00e1l\u00f3zati kapcsolata) bontani.", + "description": "Discovery - Ha a felfedez\u00e9s enged\u00e9lyezve van (aj\u00e1nlott), akkor Home Assistant automatikusan felfedezi azokat az eszk\u00f6z\u00f6ket \u00e9s entit\u00e1sokat, amelyek k\u00f6zz\u00e9teszik konfigur\u00e1ci\u00f3jukat az MQTT br\u00f3keren. Ha a felfedez\u00e9s le van tiltva, minden konfigur\u00e1ci\u00f3t manu\u00e1lisan kell elv\u00e9gezni.\nBirth \u00fczenet - A sz\u00fclet\u00e9si \u00fczenet minden alkalommal el lesz k\u00fcldve, amikor Home Assistant (\u00fajra) csatlakozik az MQTT br\u00f3kerhez.\nWill \u00fczenet - Az akarat\u00fczenet minden alkalommal el lesz k\u00fcldve, amikor Home Assistant elvesz\u00edti a kapcsolatot a k\u00f6zvet\u00edt\u0151vel, mind takar\u00edt\u00e1s eset\u00e9n (pl. Home Assistant le\u00e1ll\u00edt\u00e1sa), mind rendelenes helyzetben (pl. Home Assistant \u00f6sszeomlik vagy megszakad a h\u00e1l\u00f3zati kapcsolata).", "title": "MQTT opci\u00f3k" } } diff --git a/homeassistant/components/mqtt/translations/id.json b/homeassistant/components/mqtt/translations/id.json index 2a3171456c8..14e047c1694 100644 --- a/homeassistant/components/mqtt/translations/id.json +++ b/homeassistant/components/mqtt/translations/id.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "Layanan sudah dikonfigurasi", "single_instance_allowed": "Sudah dikonfigurasi. Hanya satu konfigurasi yang diizinkan." }, "error": { @@ -21,8 +22,8 @@ "data": { "discovery": "Aktifkan penemuan" }, - "description": "Ingin mengonfigurasi Home Assistant untuk terhubung ke broker MQTT yang disediakan oleh add-on Supervisor {addon}?", - "title": "MQTT Broker via add-on Supervisor" + "description": "Ingin mengonfigurasi Home Assistant untuk terhubung ke broker MQTT yang disediakan oleh add-on {addon}?", + "title": "MQTT Broker via add-on Home Assistant" } } }, diff --git a/homeassistant/components/mutesync/translations/hu.json b/homeassistant/components/mutesync/translations/hu.json index 68cb5c18d27..0fd40705765 100644 --- a/homeassistant/components/mutesync/translations/hu.json +++ b/homeassistant/components/mutesync/translations/hu.json @@ -8,7 +8,7 @@ "step": { "user": { "data": { - "host": "Gazdag\u00e9p" + "host": "C\u00edm" } } } diff --git a/homeassistant/components/mutesync/translations/id.json b/homeassistant/components/mutesync/translations/id.json new file mode 100644 index 00000000000..66c930e348b --- /dev/null +++ b/homeassistant/components/mutesync/translations/id.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "Gagal terhubung", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "user": { + "data": { + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/myq/translations/id.json b/homeassistant/components/myq/translations/id.json index 2cc790d15e0..4972803f37d 100644 --- a/homeassistant/components/myq/translations/id.json +++ b/homeassistant/components/myq/translations/id.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Layanan sudah dikonfigurasi" + "already_configured": "Layanan sudah dikonfigurasi", + "reauth_successful": "Autentikasi ulang berhasil" }, "error": { "cannot_connect": "Gagal terhubung", @@ -9,6 +10,11 @@ "unknown": "Kesalahan yang tidak diharapkan" }, "step": { + "reauth_confirm": { + "data": { + "password": "Kata Sandi" + } + }, "user": { "data": { "password": "Kata Sandi", diff --git a/homeassistant/components/nam/translations/hu.json b/homeassistant/components/nam/translations/hu.json index 8776ae92e20..0698b4d3e26 100644 --- a/homeassistant/components/nam/translations/hu.json +++ b/homeassistant/components/nam/translations/hu.json @@ -11,11 +11,11 @@ "flow_title": "{name}", "step": { "confirm_discovery": { - "description": "Be szeretn\u00e9 \u00e1ll\u00edtani a Nettigo Air Monitor-ot a {host} c\u00edmen?" + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani Nettigo Air Monitor-ot a {host} c\u00edmen?" }, "user": { "data": { - "host": "Gazdag\u00e9p" + "host": "C\u00edm" }, "description": "\u00c1ll\u00edtsa be a Nettigo Air Monitor integr\u00e1ci\u00f3j\u00e1t." } diff --git a/homeassistant/components/nanoleaf/translations/el.json b/homeassistant/components/nanoleaf/translations/el.json index be6719d8c2a..5112f61ef9f 100644 --- a/homeassistant/components/nanoleaf/translations/el.json +++ b/homeassistant/components/nanoleaf/translations/el.json @@ -11,6 +11,7 @@ "not_allowing_new_tokens": "\u03a4\u03bf Nanoleaf \u03b4\u03b5\u03bd \u03b5\u03c0\u03b9\u03c4\u03c1\u03ad\u03c0\u03b5\u03b9 \u03bd\u03ad\u03b1 tokens, \u03b1\u03ba\u03bf\u03bb\u03bf\u03c5\u03b8\u03ae\u03c3\u03c4\u03b5 \u03c4\u03b9\u03c2 \u03c0\u03b1\u03c1\u03b1\u03c0\u03ac\u03bd\u03c9 \u03bf\u03b4\u03b7\u03b3\u03af\u03b5\u03c2.", "unknown": "\u0391\u03bd\u03b5\u03c0\u03ac\u03bd\u03c4\u03b5\u03c7\u03bf \u03c3\u03c6\u03ac\u03bb\u03bc\u03b1" }, + "flow_title": "{name}", "step": { "link": { "description": "\u03a0\u03b1\u03c4\u03ae\u03c3\u03c4\u03b5 \u03c0\u03b1\u03c1\u03b1\u03c4\u03b5\u03c4\u03b1\u03bc\u03ad\u03bd\u03b1 \u03c4\u03bf \u03ba\u03bf\u03c5\u03bc\u03c0\u03af \u03bb\u03b5\u03b9\u03c4\u03bf\u03c5\u03c1\u03b3\u03af\u03b1\u03c2 \u03c3\u03c4\u03bf Nanoleaf \u03b3\u03b9\u03b1 5 \u03b4\u03b5\u03c5\u03c4\u03b5\u03c1\u03cc\u03bb\u03b5\u03c0\u03c4\u03b1 \u03bc\u03ad\u03c7\u03c1\u03b9 \u03bd\u03b1 \u03b1\u03c1\u03c7\u03af\u03c3\u03bf\u03c5\u03bd \u03bd\u03b1 \u03b1\u03bd\u03b1\u03b2\u03bf\u03c3\u03b2\u03ae\u03bd\u03bf\u03c5\u03bd \u03bf\u03b9 \u03bb\u03c5\u03c7\u03bd\u03af\u03b5\u03c2 LED \u03c4\u03bf\u03c5 \u03ba\u03bf\u03c5\u03bc\u03c0\u03b9\u03bf\u03cd \u03ba\u03b1\u03b9, \u03c3\u03c4\u03b7 \u03c3\u03c5\u03bd\u03ad\u03c7\u03b5\u03b9\u03b1, \u03ba\u03ac\u03bd\u03c4\u03b5 \u03ba\u03bb\u03b9\u03ba \u03c3\u03c4\u03bf \u03ba\u03bf\u03c5\u03bc\u03c0\u03af **SUBMIT** \u03bc\u03ad\u03c3\u03b1 \u03c3\u03b5 30 \u03b4\u03b5\u03c5\u03c4\u03b5\u03c1\u03cc\u03bb\u03b5\u03c0\u03c4\u03b1.", diff --git a/homeassistant/components/nanoleaf/translations/es.json b/homeassistant/components/nanoleaf/translations/es.json index 16b28203215..2efbfb875f4 100644 --- a/homeassistant/components/nanoleaf/translations/es.json +++ b/homeassistant/components/nanoleaf/translations/es.json @@ -1,13 +1,21 @@ { "config": { "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado", + "cannot_connect": "No se pudo conectar", + "invalid_token": "Token de acceso no v\u00e1lido", + "reauth_successful": "La reautenticaci\u00f3n se realiz\u00f3 correctamente", "unknown": "Error inesperado" }, "error": { + "cannot_connect": "No se pudo conectar", "unknown": "Error inesperado" }, "flow_title": "{name}", "step": { + "link": { + "title": "Link Nanoleaf" + }, "user": { "data": { "host": "Anfitri\u00f3n" diff --git a/homeassistant/components/nanoleaf/translations/hu.json b/homeassistant/components/nanoleaf/translations/hu.json index 7c5854055a4..176d47cc38f 100644 --- a/homeassistant/components/nanoleaf/translations/hu.json +++ b/homeassistant/components/nanoleaf/translations/hu.json @@ -15,12 +15,12 @@ "flow_title": "{name}", "step": { "link": { - "description": "Nyomja meg \u00e9s tartsa lenyomva a Nanoleaf bekapcsol\u00f3gombj\u00e1t 5 m\u00e1sodpercig, am\u00edg a gomb LED-je villogni nem kezd, majd kattintson a **SUBMIT** gombra 30 m\u00e1sodpercen bel\u00fcl.", + "description": "Nyomja meg \u00e9s tartsa lenyomva a Nanoleaf bekapcsol\u00f3gombj\u00e1t 5 m\u00e1sodpercig, am\u00edg a gomb LED-je villogni nem kezd, majd kattintson a **K\u00fcld\u00e9s** gombra 30 m\u00e1sodpercen bel\u00fcl.", "title": "Nanoleaf link" }, "user": { "data": { - "host": "Gazdag\u00e9p" + "host": "C\u00edm" } } } diff --git a/homeassistant/components/nanoleaf/translations/id.json b/homeassistant/components/nanoleaf/translations/id.json new file mode 100644 index 00000000000..b0e3328df0b --- /dev/null +++ b/homeassistant/components/nanoleaf/translations/id.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi", + "cannot_connect": "Gagal terhubung", + "invalid_token": "Token akses tidak valid", + "reauth_successful": "Autentikasi ulang berhasil", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "error": { + "cannot_connect": "Gagal terhubung", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "user": { + "data": { + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/neato/translations/es.json b/homeassistant/components/neato/translations/es.json index f9b6fe54e22..64c58279614 100644 --- a/homeassistant/components/neato/translations/es.json +++ b/homeassistant/components/neato/translations/es.json @@ -8,7 +8,7 @@ "reauth_successful": "La reautenticaci\u00f3n se realiz\u00f3 correctamente" }, "create_entry": { - "default": "Ver [documentaci\u00f3n Neato]({docs_url})." + "default": "Autenticado correctamente" }, "step": { "pick_implementation": { diff --git a/homeassistant/components/neato/translations/hu.json b/homeassistant/components/neato/translations/hu.json index 90fb417e6a6..20bc76ca6c0 100644 --- a/homeassistant/components/neato/translations/hu.json +++ b/homeassistant/components/neato/translations/hu.json @@ -3,7 +3,7 @@ "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", "authorize_url_timeout": "Id\u0151t\u00fall\u00e9p\u00e9s a hiteles\u00edt\u00e9si URL gener\u00e1l\u00e1sa sor\u00e1n.", - "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rlek, k\u00f6vesd a dokument\u00e1ci\u00f3t.", + "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rem, k\u00f6vesse a dokument\u00e1ci\u00f3t.", "no_url_available": "Nincs el\u00e9rhet\u0151 URL. A hib\u00e1r\u00f3l tov\u00e1bbi inform\u00e1ci\u00f3t [a s\u00fag\u00f3ban]({docs_url}) tal\u00e1lsz.", "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt" }, @@ -15,7 +15,7 @@ "title": "V\u00e1lassz hiteles\u00edt\u00e9si m\u00f3dszert" }, "reauth_confirm": { - "title": "El szeretn\u00e9d kezdeni a be\u00e1ll\u00edt\u00e1st?" + "title": "El szeretn\u00e9 kezdeni a be\u00e1ll\u00edt\u00e1st?" } } }, diff --git a/homeassistant/components/neato/translations/nl.json b/homeassistant/components/neato/translations/nl.json index 3d7bbab2e75..2e9ab212fa9 100644 --- a/homeassistant/components/neato/translations/nl.json +++ b/homeassistant/components/neato/translations/nl.json @@ -15,7 +15,7 @@ "title": "Kies een authenticatie methode" }, "reauth_confirm": { - "title": "Wil je beginnen met instellen?" + "title": "Wilt u beginnen met instellen?" } } }, diff --git a/homeassistant/components/nest/translations/fi.json b/homeassistant/components/nest/translations/fi.json index 5365f73b721..e4235ee096e 100644 --- a/homeassistant/components/nest/translations/fi.json +++ b/homeassistant/components/nest/translations/fi.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "unknown": "Odottamaton virhe" + }, "step": { "init": { "data": { diff --git a/homeassistant/components/nest/translations/he.json b/homeassistant/components/nest/translations/he.json index a3b5411b536..6efee1d74bd 100644 --- a/homeassistant/components/nest/translations/he.json +++ b/homeassistant/components/nest/translations/he.json @@ -5,7 +5,8 @@ "missing_configuration": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05e8\u05db\u05d9\u05d1 \u05dc\u05d0 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e0\u05d0 \u05e2\u05e7\u05d5\u05d1 \u05d0\u05d7\u05e8 \u05d4\u05ea\u05d9\u05e2\u05d5\u05d3.", "no_url_available": "\u05d0\u05d9\u05df \u05db\u05ea\u05d5\u05d1\u05ea \u05d0\u05ea\u05e8 \u05d6\u05de\u05d9\u05e0\u05d4. \u05e7\u05d1\u05dc\u05ea \u05de\u05d9\u05d3\u05e2 \u05e2\u05dc \u05e9\u05d2\u05d9\u05d0\u05d4 \u05d6\u05d5, [\u05e2\u05d9\u05d9\u05df \u05d1\u05e1\u05e2\u05d9\u05e3 \u05d4\u05e2\u05d6\u05e8\u05d4] ({docs_url})", "reauth_successful": "\u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7", - "single_instance_allowed": "\u05ea\u05e6\u05d5\u05e8\u05ea\u05d5 \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e8\u05e7 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05d0\u05e4\u05e9\u05e8\u05d9\u05ea." + "single_instance_allowed": "\u05ea\u05e6\u05d5\u05e8\u05ea\u05d5 \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e8\u05e7 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05d0\u05e4\u05e9\u05e8\u05d9\u05ea.", + "unknown_authorize_url_generation": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05dc\u05d0 \u05d9\u05d3\u05d5\u05e2\u05d4 \u05d1\u05d9\u05e6\u05d9\u05e8\u05ea \u05db\u05ea\u05d5\u05d1\u05ea URL \u05e9\u05dc \u05d4\u05e8\u05e9\u05d0\u05d4." }, "create_entry": { "default": "\u05d0\u05d5\u05de\u05ea \u05d1\u05d4\u05e6\u05dc\u05d7\u05d4" @@ -28,7 +29,7 @@ "data": { "code": "\u05e7\u05d5\u05d3 PIN" }, - "description": "\u05db\u05d3\u05d9 \u05dc\u05e7\u05e9\u05e8 \u05d0\u05ea \u05d7\u05e9\u05d1\u05d5\u05df Nest \u05e9\u05dc\u05da, [\u05d0\u05de\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05e9\u05dc\u05da] ({url}). \n\n \u05dc\u05d0\u05d7\u05e8 \u05d4\u05d0\u05d9\u05e9\u05d5\u05e8, \u05d4\u05e2\u05ea\u05e7 \u05d0\u05ea \u05e7\u05d5\u05d3 \u05d4PIN \u05e9\u05e1\u05d5\u05e4\u05e7 \u05d5\u05d4\u05d3\u05d1\u05e7 \u05d0\u05d5\u05ea\u05d5 \u05dc\u05de\u05d8\u05d4.", + "description": "\u05db\u05d3\u05d9 \u05dc\u05e7\u05e9\u05e8 \u05d0\u05ea \u05d7\u05e9\u05d1\u05d5\u05df Nest \u05e9\u05dc\u05da, [\u05d4\u05e8\u05e9\u05d0\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05e9\u05dc\u05da]({url}). \n\n \u05dc\u05d0\u05d7\u05e8 \u05d4\u05d0\u05d9\u05e9\u05d5\u05e8, \u05d9\u05e9 \u05dc\u05d4\u05e2\u05ea\u05d9\u05e7 \u05d0\u05ea \u05e7\u05d5\u05d3 \u05d4PIN \u05e9\u05e1\u05d5\u05e4\u05e7 \u05d5\u05dc\u05d4\u05d3\u05d1\u05d9\u05e7 \u05d0\u05d5\u05ea\u05d5 \u05dc\u05de\u05d8\u05d4.", "title": "\u05e7\u05d9\u05e9\u05d5\u05e8 \u05d7\u05e9\u05d1\u05d5\u05df Nest" }, "pick_implementation": { diff --git a/homeassistant/components/nest/translations/hu.json b/homeassistant/components/nest/translations/hu.json index 5690724c4a0..58f8ea30caf 100644 --- a/homeassistant/components/nest/translations/hu.json +++ b/homeassistant/components/nest/translations/hu.json @@ -2,7 +2,7 @@ "config": { "abort": { "authorize_url_timeout": "Id\u0151t\u00fall\u00e9p\u00e9s az \u00e9rv\u00e9nyes\u00edt\u00e9si url gener\u00e1l\u00e1sa sor\u00e1n.", - "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rlek, k\u00f6vesd a dokument\u00e1ci\u00f3t.", + "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rem, k\u00f6vesse a dokument\u00e1ci\u00f3t.", "no_url_available": "Nincs el\u00e9rhet\u0151 URL. A hib\u00e1r\u00f3l tov\u00e1bbi inform\u00e1ci\u00f3t [a s\u00fag\u00f3ban]({docs_url}) tal\u00e1lsz.", "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt", "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges.", diff --git a/homeassistant/components/netatmo/translations/hu.json b/homeassistant/components/netatmo/translations/hu.json index 48f084f84c2..cb634547efc 100644 --- a/homeassistant/components/netatmo/translations/hu.json +++ b/homeassistant/components/netatmo/translations/hu.json @@ -2,7 +2,7 @@ "config": { "abort": { "authorize_url_timeout": "Id\u0151t\u00fall\u00e9p\u00e9s a hiteles\u00edt\u00e9si URL gener\u00e1l\u00e1sa sor\u00e1n.", - "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rlek, k\u00f6vesd a dokument\u00e1ci\u00f3t.", + "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rem, k\u00f6vesse a dokument\u00e1ci\u00f3t.", "no_url_available": "Nincs el\u00e9rhet\u0151 URL. A hib\u00e1r\u00f3l tov\u00e1bbi inform\u00e1ci\u00f3t [a s\u00fag\u00f3ban]({docs_url}) tal\u00e1lsz.", "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges." }, diff --git a/homeassistant/components/netgear/translations/ca.json b/homeassistant/components/netgear/translations/ca.json new file mode 100644 index 00000000000..48de8c99684 --- /dev/null +++ b/homeassistant/components/netgear/translations/ca.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat" + }, + "error": { + "config": "Error de connexi\u00f3 o d'inici de sessi\u00f3: comprova la configuraci\u00f3" + }, + "step": { + "user": { + "data": { + "host": "Amfitri\u00f3 (opcional)", + "password": "Contrasenya", + "port": "Port (opcional)", + "ssl": "Utilitza un certificat SSL", + "username": "Nom d'usuari (opcional)" + }, + "description": "Amfitri\u00f3 predeterminat: {host}\nPort predeterminat: {port}\nNom d'usuari predeterminat: {username}", + "title": "Netgear" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "Temps per considerar 'a casa' (segons)" + }, + "description": "Especifica les configuracions opcional", + "title": "Netgear" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/netgear/translations/cs.json b/homeassistant/components/netgear/translations/cs.json new file mode 100644 index 00000000000..786cd2229ab --- /dev/null +++ b/homeassistant/components/netgear/translations/cs.json @@ -0,0 +1,15 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "Hostitel (nepovinn\u00fd)", + "password": "Heslo", + "port": "Port (nepovinn\u00fd)", + "ssl": "Pou\u017e\u00edv\u00e1 SSL certifik\u00e1t", + "username": "U\u017eivatelsk\u00e9 jm\u00e9no (nepovinn\u00e9)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/netgear/translations/de.json b/homeassistant/components/netgear/translations/de.json new file mode 100644 index 00000000000..d1ee1310cad --- /dev/null +++ b/homeassistant/components/netgear/translations/de.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert" + }, + "error": { + "config": "Verbindungs- oder Anmeldefehler: Bitte \u00fcberpr\u00fcfe deine Konfiguration" + }, + "step": { + "user": { + "data": { + "host": "Host (Optional)", + "password": "Passwort", + "port": "Port (Optional)", + "ssl": "Verwendet ein SSL-Zertifikat", + "username": "Benutzername (Optional)" + }, + "description": "Standardhost: {host}\nStandardport: {port}\nStandardbenutzername: {username}", + "title": "Netgear" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "Zu Hause Zeit (Sekunden)" + }, + "description": "Optionale Einstellungen angeben", + "title": "Netgear" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/netgear/translations/en.json b/homeassistant/components/netgear/translations/en.json index 64dbeda0d7f..f9c2dbf2c91 100644 --- a/homeassistant/components/netgear/translations/en.json +++ b/homeassistant/components/netgear/translations/en.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Host already configured" + "already_configured": "Device is already configured" }, "error": { "config": "Connection or login error: please check your configuration" @@ -12,21 +12,23 @@ "host": "Host (Optional)", "password": "Password", "port": "Port (Optional)", - "ssl": "Use SSL (Optional)", + "ssl": "Uses an SSL certificate", "username": "Username (Optional)" }, - "description": "Default host: {host}\nDefault port: {port}\nDefault username: {username}" + "description": "Default host: {host}\nDefault port: {port}\nDefault username: {username}", + "title": "Netgear" } } }, "options": { "step": { - "init": { - "description": "Specify optional settings", - "data": { - "consider_home": "Consider home time (seconds)" + "init": { + "data": { + "consider_home": "Consider home time (seconds)" + }, + "description": "Specify optional settings", + "title": "Netgear" } - } } } } \ No newline at end of file diff --git a/homeassistant/components/netgear/translations/es.json b/homeassistant/components/netgear/translations/es.json new file mode 100644 index 00000000000..57054de1c37 --- /dev/null +++ b/homeassistant/components/netgear/translations/es.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado" + }, + "step": { + "user": { + "data": { + "host": "Host (Opcional)", + "password": "Contrase\u00f1a", + "port": "Puerto (Opcional)", + "ssl": "Utiliza un certificado SSL", + "username": "Usuario (Opcional)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/netgear/translations/et.json b/homeassistant/components/netgear/translations/et.json new file mode 100644 index 00000000000..ad100c4b83e --- /dev/null +++ b/homeassistant/components/netgear/translations/et.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud" + }, + "error": { + "config": "\u00dchenduse v\u00f5i sisselogimise viga: kontrolli oma s\u00e4tteid" + }, + "step": { + "user": { + "data": { + "host": "Host (valikuline)", + "password": "Salas\u00f5na", + "port": "Port (valikuline)", + "ssl": "Kasutusel on SSL sert", + "username": "Kasutajanimi (valikuline)" + }, + "description": "Vaikimisi host: {host}\nVaikeport: {port}\nVaikimisi kasutajanimi: {username}", + "title": "Netgear" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "Kohaloleku m\u00e4\u00e4ramise aeg (sekundites)" + }, + "description": "Valikuliste s\u00e4tete m\u00e4\u00e4ramine", + "title": "Netgear" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/netgear/translations/he.json b/homeassistant/components/netgear/translations/he.json new file mode 100644 index 00000000000..f1f42b6c771 --- /dev/null +++ b/homeassistant/components/netgear/translations/he.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "step": { + "user": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7 (\u05d0\u05d5\u05e4\u05e6\u05d9\u05d5\u05e0\u05dc\u05d9)", + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "port": "\u05e4\u05ea\u05d7\u05d4 (\u05d0\u05d5\u05e4\u05e6\u05d9\u05d5\u05e0\u05dc\u05d9)", + "ssl": "\u05e9\u05d9\u05de\u05d5\u05e9 \u05d1\u05d0\u05d9\u05e9\u05d5\u05e8 SSL", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9 (\u05d0\u05d5\u05e4\u05e6\u05d9\u05d5\u05e0\u05dc\u05d9)" + }, + "title": "Netgear" + } + } + }, + "options": { + "step": { + "init": { + "title": "Netgear" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/netgear/translations/hu.json b/homeassistant/components/netgear/translations/hu.json new file mode 100644 index 00000000000..64452c9ef58 --- /dev/null +++ b/homeassistant/components/netgear/translations/hu.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + }, + "error": { + "config": "Csatlakoz\u00e1si vagy bejelentkez\u00e9si hiba: k\u00e9rj\u00fck, ellen\u0151rizze a konfigur\u00e1ci\u00f3t" + }, + "step": { + "user": { + "data": { + "host": "C\u00edm (nem k\u00f6telez\u0151)", + "password": "Jelsz\u00f3", + "port": "Port (nem k\u00f6telez\u0151)", + "ssl": "SSL tan\u00fas\u00edtv\u00e1ny haszn\u00e1lata", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v (nem k\u00f6telez\u0151)" + }, + "description": "Alap\u00e9rtelmezett c\u00edm: {host}\nAlap\u00e9rtelmezett port: {port}\nAlap\u00e9rtelmezett felhaszn\u00e1l\u00f3n\u00e9v: {username}", + "title": "Netgear" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "Otthoni \u00e1llapotnak tekint\u00e9s (m\u00e1sodperc)" + }, + "description": "Opcion\u00e1lis be\u00e1ll\u00edt\u00e1sok megad\u00e1sa", + "title": "Netgear" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/netgear/translations/id.json b/homeassistant/components/netgear/translations/id.json new file mode 100644 index 00000000000..a6a41a5023f --- /dev/null +++ b/homeassistant/components/netgear/translations/id.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi" + }, + "step": { + "user": { + "data": { + "host": "Host (Opsional)", + "password": "Kata Sandi", + "port": "Port (Opsional)", + "ssl": "Menggunakan sertifikat SSL", + "username": "Nama Pengguna (Opsional)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/netgear/translations/it.json b/homeassistant/components/netgear/translations/it.json new file mode 100644 index 00000000000..72feece850a --- /dev/null +++ b/homeassistant/components/netgear/translations/it.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato" + }, + "error": { + "config": "Errore di connessione o di login: controlla la tua configurazione" + }, + "step": { + "user": { + "data": { + "host": "Host (Facoltativo)", + "password": "Password", + "port": "Porta (Facoltativo)", + "ssl": "Utilizza un certificato SSL", + "username": "Nome utente (Facoltativo)" + }, + "description": "Host predefinito: {host}\nPorta predefinita: {port}\nNome utente predefinito: {username}", + "title": "Netgear" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "Considera il tempo in casa (secondi)" + }, + "description": "Specificare le impostazioni opzionali", + "title": "Netgear" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/netgear/translations/nl.json b/homeassistant/components/netgear/translations/nl.json new file mode 100644 index 00000000000..22ac348af4e --- /dev/null +++ b/homeassistant/components/netgear/translations/nl.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al ingesteld" + }, + "error": { + "config": "Verbindings- of inlogfout; controleer uw configuratie" + }, + "step": { + "user": { + "data": { + "host": "Host (optioneel)", + "password": "Wachtwoord", + "port": "Poort (optioneel)", + "ssl": "Gebruikt een SSL certificaat", + "username": "Gebruikersnaam (optioneel)" + }, + "description": "Standaard host: {host}\nStandaard poort: {port}\nStandaard gebruikersnaam: {username}", + "title": "Netgear" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "Overweeg thuis tijd (seconden)" + }, + "description": "Optionele instellingen opgeven", + "title": "Netgear" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/netgear/translations/no.json b/homeassistant/components/netgear/translations/no.json new file mode 100644 index 00000000000..52020ae3824 --- /dev/null +++ b/homeassistant/components/netgear/translations/no.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, + "error": { + "config": "Tilkoblings- eller p\u00e5loggingsfeil: Kontroller konfigurasjonen" + }, + "step": { + "user": { + "data": { + "host": "Vert (valgfritt)", + "password": "Passord", + "port": "Port (valgfritt)", + "ssl": "Bruker et SSL-sertifikat", + "username": "Brukernavn (Valgfritt)" + }, + "description": "Standard vert: {host}\nStandardport: {port}\nStandard brukernavn: {username}", + "title": "Netgear" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "Vurder hjemmetid (sekunder)" + }, + "description": "Spesifiser valgfrie innstillinger", + "title": "Netgear" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/netgear/translations/pt-BR.json b/homeassistant/components/netgear/translations/pt-BR.json new file mode 100644 index 00000000000..ec18c9a65df --- /dev/null +++ b/homeassistant/components/netgear/translations/pt-BR.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Dispositivo j\u00e1 configurado" + }, + "step": { + "user": { + "data": { + "host": "Host (Opcional)", + "password": "Senha", + "port": "Porta (Opcional)", + "ssl": "Utilize um certificado SSL", + "username": "Usu\u00e1rio (Opcional)" + }, + "description": "Host padr\u00e3o: {host}\n Porta padr\u00e3o: {port}\n Usu\u00e1rio padr\u00e3o: {username}", + "title": "Netgear" + } + } + }, + "options": { + "step": { + "init": { + "description": "Especifique configura\u00e7\u00f5es opcionais", + "title": "Netgear" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/netgear/translations/ru.json b/homeassistant/components/netgear/translations/ru.json new file mode 100644 index 00000000000..035492a01fe --- /dev/null +++ b/homeassistant/components/netgear/translations/ru.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant." + }, + "error": { + "config": "\u041e\u0448\u0438\u0431\u043a\u0430 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f \u0438\u043b\u0438 \u0432\u0445\u043e\u0434\u0430 \u0432 \u0441\u0438\u0441\u0442\u0435\u043c\u0443. \u041f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e." + }, + "step": { + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442 (\u043d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e)", + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "port": "\u041f\u043e\u0440\u0442 (\u043d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e)", + "ssl": "\u0418\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442\u0441\u044f \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442 SSL", + "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f (\u043d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e)" + }, + "description": "\u0425\u043e\u0441\u0442 \u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e: {host}\n\u041f\u043e\u0440\u0442 \u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e: {port}\n\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e: {username}", + "title": "Netgear" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "\u0412\u0440\u0435\u043c\u044f, \u0432 \u0442\u0435\u0447\u0435\u043d\u0438\u0438 \u043a\u043e\u0442\u043e\u0440\u043e\u0433\u043e \u0441\u0447\u0438\u0442\u0430\u0442\u044c \u0447\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0434\u043e\u043c\u0430 (\u0432 \u0441\u0435\u043a\u0443\u043d\u0434\u0430\u0445)" + }, + "description": "\u0414\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438", + "title": "Netgear" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/netgear/translations/zh-Hant.json b/homeassistant/components/netgear/translations/zh-Hant.json new file mode 100644 index 00000000000..a4978fbb6bc --- /dev/null +++ b/homeassistant/components/netgear/translations/zh-Hant.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "config": "\u9023\u7dda\u6216\u767b\u5165\u932f\u8aa4\uff1a\u8acb\u6aa2\u67e5\u60a8\u7684\u8a2d\u5b9a" + }, + "step": { + "user": { + "data": { + "host": "\u4e3b\u6a5f\u7aef\uff08\u9078\u9805\uff09", + "password": "\u5bc6\u78bc", + "port": "\u901a\u8a0a\u57e0\uff08\u9078\u9805\uff09", + "ssl": "\u4f7f\u7528 SSL \u8a8d\u8b49", + "username": "\u4f7f\u7528\u8005\u540d\u7a31\uff08\u9078\u9805\uff09" + }, + "description": "\u9810\u8a2d\u4e3b\u6a5f\u7aef\uff1a{host}\n\u9810\u8a2d\u901a\u8a0a\u57e0\uff1a{port}\n\u9810\u8a2d\u4f7f\u7528\u8005\u540d\u7a31\uff1a{username}", + "title": "Netgear" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "\u5224\u5b9a\u5728\u5bb6\u6642\u9593\uff08\u79d2\uff09" + }, + "description": "\u6307\u5b9a\u9078\u9805\u8a2d\u5b9a", + "title": "Netgear" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nfandroidtv/translations/es.json b/homeassistant/components/nfandroidtv/translations/es.json index efb7c6a5c8a..880835cfb1e 100644 --- a/homeassistant/components/nfandroidtv/translations/es.json +++ b/homeassistant/components/nfandroidtv/translations/es.json @@ -1,12 +1,17 @@ { "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado" + }, "error": { + "cannot_connect": "No se pudo conectar", "unknown": "Error inesperado" }, "step": { "user": { "data": { - "host": "Anfitri\u00f3n" + "host": "Host", + "name": "Nombre" }, "description": "Esta integraci\u00f3n requiere la aplicaci\u00f3n de Notificaciones para Android TV.\n\nPara Android TV: https://play.google.com/store/apps/details?id=de.cyberdream.androidtv.notifications.google\nPara Fire TV: https://www.amazon.com/Christian-Fees-Notifications-for-Fire/dp/B00OESCXEK\n\nDebes configurar una reserva DHCP en su router (consulta el manual de usuario de tu router) o una direcci\u00f3n IP est\u00e1tica en el dispositivo. Si no, el dispositivo acabar\u00e1 por no estar disponible.", "title": "Notificaciones para Android TV / Fire TV" diff --git a/homeassistant/components/nfandroidtv/translations/hu.json b/homeassistant/components/nfandroidtv/translations/hu.json index e7dea95e4d0..c0dc8d679d6 100644 --- a/homeassistant/components/nfandroidtv/translations/hu.json +++ b/homeassistant/components/nfandroidtv/translations/hu.json @@ -10,7 +10,7 @@ "step": { "user": { "data": { - "host": "H\u00e1zigazda", + "host": "C\u00edm", "name": "N\u00e9v" }, "description": "Ehhez az integr\u00e1ci\u00f3hoz az \u00c9rtes\u00edt\u00e9sek az Android TV alkalmaz\u00e1shoz sz\u00fcks\u00e9ges. \n\nAndroid TV eset\u00e9n: https://play.google.com/store/apps/details?id=de.cyberdream.androidtv.notifications.google\nA Fire TV eset\u00e9ben: https://www.amazon.com/Christian-Fees-Notifications-for-Fire/dp/B00OESCXEK \n\nBe kell \u00e1ll\u00edtania a DHCP -foglal\u00e1st az \u00fatv\u00e1laszt\u00f3n (l\u00e1sd az \u00fatv\u00e1laszt\u00f3 felhaszn\u00e1l\u00f3i k\u00e9zik\u00f6nyv\u00e9t), vagy egy statikus IP -c\u00edmet az eszk\u00f6z\u00f6n. Ha nem, az eszk\u00f6z v\u00e9g\u00fcl el\u00e9rhetetlenn\u00e9 v\u00e1lik.", diff --git a/homeassistant/components/nfandroidtv/translations/id.json b/homeassistant/components/nfandroidtv/translations/id.json new file mode 100644 index 00000000000..087e25a22ae --- /dev/null +++ b/homeassistant/components/nfandroidtv/translations/id.json @@ -0,0 +1,10 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi" + }, + "error": { + "cannot_connect": "Gagal terhubung" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nightscout/translations/hu.json b/homeassistant/components/nightscout/translations/hu.json index b3e5a36e172..569a4f3aca9 100644 --- a/homeassistant/components/nightscout/translations/hu.json +++ b/homeassistant/components/nightscout/translations/hu.json @@ -15,7 +15,7 @@ "api_key": "API kulcs", "url": "URL" }, - "description": "- URL: a nightcout p\u00e9ld\u00e1ny c\u00edme. Vagyis: https://myhomeassistant.duckdns.org:5423\n - API kulcs (opcion\u00e1lis): Csak akkor haszn\u00e1lja, ha a p\u00e9ld\u00e1nya v\u00e9dett (auth_default_roles! = Olvashat\u00f3).", + "description": "- URL: a nightscout p\u00e9ld\u00e1ny c\u00edme. Pl: https://myhomeassistant.duckdns.org:5423\n - API kulcs (opcion\u00e1lis): Csak akkor haszn\u00e1lja, ha a p\u00e9ld\u00e1nya v\u00e9dett (auth_default_roles != readable).", "title": "Adja meg a Nightscout szerver adatait." } } diff --git a/homeassistant/components/nightscout/translations/id.json b/homeassistant/components/nightscout/translations/id.json index 75496084bc4..147c3131213 100644 --- a/homeassistant/components/nightscout/translations/id.json +++ b/homeassistant/components/nightscout/translations/id.json @@ -15,7 +15,7 @@ "api_key": "Kunci API", "url": "URL" }, - "description": "- URL: alamat instans nightscout Anda, misalnya https://myhomeassistant.duckdns.org:5423\n- Kunci API (Opsional): Hanya gunakan jika instans Anda dilindungi (auth_default_roles != dapat dibaca).", + "description": "- URL: alamat instans nightscout Anda, misalnya https://myhomeassistant.duckdns.org:5423\n- Kunci API (opsional): Hanya gunakan jika instans Anda dilindungi (auth_default_roles != dapat dibaca).", "title": "Masukkan informasi server Nightscout Anda." } } diff --git a/homeassistant/components/nightscout/translations/no.json b/homeassistant/components/nightscout/translations/no.json index d68fe45c684..6a21b13aca7 100644 --- a/homeassistant/components/nightscout/translations/no.json +++ b/homeassistant/components/nightscout/translations/no.json @@ -15,7 +15,7 @@ "api_key": "API-n\u00f8kkel", "url": "URL" }, - "description": "- URL: Adressen til din nattscout-forekomst. F. Eks: https://myhomeassistant.duckdns.org:5423 \n- API-n\u00f8kkel (valgfritt): Bruk bare hvis forekomsten din er beskyttet (auth_default_roles! = readable).", + "description": "- URL: adressen til nightscout -forekomsten din. Dvs: https://myhomeassistant.duckdns.org:5423\n - API -n\u00f8kkel (valgfritt): Bruk bare hvis forekomsten din er beskyttet (auth_default_roles! = Lesbar).", "title": "Skriv inn informasjon om Nightscout-serveren." } } diff --git a/homeassistant/components/nmap_tracker/translations/es.json b/homeassistant/components/nmap_tracker/translations/es.json index d5c3d71321f..212b56a9606 100644 --- a/homeassistant/components/nmap_tracker/translations/es.json +++ b/homeassistant/components/nmap_tracker/translations/es.json @@ -25,6 +25,7 @@ "step": { "init": { "data": { + "consider_home": "Segundos de espera hasta que se marca un dispositivo de seguimiento como no en casa despu\u00e9s de no ser visto.", "exclude": "Direcciones de red (separadas por comas) para excluir del escaneo", "home_interval": "N\u00famero m\u00ednimo de minutos entre los escaneos de los dispositivos activos (preservar la bater\u00eda)", "hosts": "Direcciones de red (separadas por comas) para escanear", diff --git a/homeassistant/components/nmap_tracker/translations/hu.json b/homeassistant/components/nmap_tracker/translations/hu.json index e7443f41a0e..7385f12b3df 100644 --- a/homeassistant/components/nmap_tracker/translations/hu.json +++ b/homeassistant/components/nmap_tracker/translations/hu.json @@ -4,7 +4,7 @@ "already_configured": "A hely m\u00e1r konfigur\u00e1lva van" }, "error": { - "invalid_hosts": "\u00c9rv\u00e9nytelen gazdag\u00e9pek" + "invalid_hosts": "\u00c9rv\u00e9nytelen c\u00edmek" }, "step": { "user": { @@ -14,13 +14,13 @@ "hosts": "H\u00e1l\u00f3zati c\u00edmek (vessz\u0151vel elv\u00e1lasztva) a beolvas\u00e1shoz", "scan_options": "Nyersen konfigur\u00e1lhat\u00f3 szkennel\u00e9si lehet\u0151s\u00e9gek az Nmap sz\u00e1m\u00e1ra" }, - "description": "\u00c1ll\u00edtsa be a gazdag\u00e9peket, hogy az Nmap ellen\u0151rizhesse \u0151ket. A h\u00e1l\u00f3zati c\u00edm IP-c\u00edm (192.168.1.1), IP-h\u00e1l\u00f3zat (192.168.0.0/24) vagy IP-tartom\u00e1ny (192.168.1.0-32) lehet." + "description": "\u00c1ll\u00edtsa be a hogy milyen c\u00edmeket szkenneljen az Nmap. A h\u00e1l\u00f3zati c\u00edm lehet IP-c\u00edm (pl. 192.168.1.1), IP-h\u00e1l\u00f3zat (pl. 192.168.0.0/24) vagy IP-tartom\u00e1ny (pl. 192.168.1.0-32)." } } }, "options": { "error": { - "invalid_hosts": "\u00c9rv\u00e9nytelen gazdag\u00e9p" + "invalid_hosts": "\u00c9rv\u00e9nytelen c\u00edmek" }, "step": { "init": { @@ -33,7 +33,7 @@ "scan_options": "Nyersen konfigur\u00e1lhat\u00f3 beolvas\u00e1si lehet\u0151s\u00e9gek az Nmap sz\u00e1m\u00e1ra", "track_new_devices": "\u00daj eszk\u00f6z\u00f6k nyomon k\u00f6vet\u00e9se" }, - "description": "\u00c1ll\u00edtsa be a gazdag\u00e9peket, amelyeket a Nmap ellen\u0151riz. A h\u00e1l\u00f3zati c\u00edm IP-c\u00edm (192.168.1.1), IP-h\u00e1l\u00f3zat (192.168.0.0/24) vagy IP-tartom\u00e1ny (192.168.1.0-32) lehet." + "description": "\u00c1ll\u00edtsa be a hogy milyen c\u00edmeket szkenneljen az Nmap. A h\u00e1l\u00f3zati c\u00edm lehet IP-c\u00edm (pl. 192.168.1.1), IP-h\u00e1l\u00f3zat (pl. 192.168.0.0/24) vagy IP-tartom\u00e1ny (pl. 192.168.1.0-32)." } } }, diff --git a/homeassistant/components/nmap_tracker/translations/id.json b/homeassistant/components/nmap_tracker/translations/id.json index d36ba84e8ac..6c06e815565 100644 --- a/homeassistant/components/nmap_tracker/translations/id.json +++ b/homeassistant/components/nmap_tracker/translations/id.json @@ -8,6 +8,7 @@ "step": { "init": { "data": { + "interval_seconds": "Interval pindai", "track_new_devices": "Lacak perangkat baru" } } diff --git a/homeassistant/components/nmap_tracker/translations/nl.json b/homeassistant/components/nmap_tracker/translations/nl.json index e9675dfc328..8385aca1ffe 100644 --- a/homeassistant/components/nmap_tracker/translations/nl.json +++ b/homeassistant/components/nmap_tracker/translations/nl.json @@ -25,6 +25,7 @@ "step": { "init": { "data": { + "consider_home": "Aantal seconden wachten tot het markeren van een apparaattracker als niet thuis nadat hij niet is gezien.", "exclude": "Netwerkadressen (door komma's gescheiden) om uit te sluiten van scannen", "home_interval": "Minimum aantal minuten tussen scans van actieve apparaten (batterij sparen)", "hosts": "Netwerkadressen (gescheiden door komma's) om te scannen", diff --git a/homeassistant/components/nmap_tracker/translations/ru.json b/homeassistant/components/nmap_tracker/translations/ru.json index b899d63ce83..ba143e20d01 100644 --- a/homeassistant/components/nmap_tracker/translations/ru.json +++ b/homeassistant/components/nmap_tracker/translations/ru.json @@ -25,7 +25,7 @@ "step": { "init": { "data": { - "consider_home": "\u0412\u0440\u0435\u043c\u044f \u043e\u0436\u0438\u0434\u0430\u043d\u0438\u044f (\u0432 \u0441\u0435\u043a\u0443\u043d\u0434\u0430\u0445), \u043f\u043e \u0438\u0441\u0442\u0435\u0447\u0435\u043d\u0438\u044e \u043a\u043e\u0442\u043e\u0440\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043f\u043e\u043b\u0443\u0447\u0438\u0442 \u0441\u0442\u0430\u0442\u0443\u0441 \"\u041d\u0435 \u0434\u043e\u043c\u0430\"", + "consider_home": "\u0412\u0440\u0435\u043c\u044f \u043e\u0436\u0438\u0434\u0430\u043d\u0438\u044f (\u0432 \u0441\u0435\u043a\u0443\u043d\u0434\u0430\u0445), \u043f\u043e \u0438\u0441\u0442\u0435\u0447\u0435\u043d\u0438\u044e \u043a\u043e\u0442\u043e\u0440\u043e\u0433\u043e \u0441\u0447\u0438\u0442\u0430\u0442\u044c \u0447\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u0434\u043e\u043c\u0430", "exclude": "\u0421\u0435\u0442\u0435\u0432\u044b\u0435 \u0430\u0434\u0440\u0435\u0441\u0430 \u0434\u043b\u044f \u0438\u0441\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f \u0438\u0437 \u0441\u043a\u0430\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f (\u0447\u0435\u0440\u0435\u0437 \u0437\u0430\u043f\u044f\u0442\u0443\u044e)", "home_interval": "\u041c\u0438\u043d\u0438\u043c\u0430\u043b\u044c\u043d\u043e\u0435 \u043a\u043e\u043b\u0438\u0447\u0435\u0441\u0442\u0432\u043e \u043c\u0438\u043d\u0443\u0442 \u043c\u0435\u0436\u0434\u0443 \u0441\u043a\u0430\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f\u043c\u0438 \u0430\u043a\u0442\u0438\u0432\u043d\u044b\u0445 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432 (\u044d\u043a\u043e\u043d\u043e\u043c\u0438\u044f \u0437\u0430\u0440\u044f\u0434\u0430 \u0431\u0430\u0442\u0430\u0440\u0435\u0438)", "hosts": "\u0421\u0435\u0442\u0435\u0432\u044b\u0435 \u0430\u0434\u0440\u0435\u0441\u0430 \u0434\u043b\u044f \u0441\u043a\u0430\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f (\u0447\u0435\u0440\u0435\u0437 \u0437\u0430\u043f\u044f\u0442\u0443\u044e)", diff --git a/homeassistant/components/notion/translations/ca.json b/homeassistant/components/notion/translations/ca.json index 5d89413d36f..51ca461f854 100644 --- a/homeassistant/components/notion/translations/ca.json +++ b/homeassistant/components/notion/translations/ca.json @@ -1,13 +1,22 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat" + "already_configured": "El compte ja est\u00e0 configurat", + "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" }, "error": { "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", - "no_devices": "No s'han trobat dispositius al compte" + "no_devices": "No s'han trobat dispositius al compte", + "unknown": "Error inesperat" }, "step": { + "reauth_confirm": { + "data": { + "password": "Contrasenya" + }, + "description": "Torna a introduir la contrasenya de {username}.", + "title": "Reautenticaci\u00f3 de la integraci\u00f3" + }, "user": { "data": { "password": "Contrasenya", diff --git a/homeassistant/components/notion/translations/de.json b/homeassistant/components/notion/translations/de.json index 0b421911aa7..59ab1fdc1be 100644 --- a/homeassistant/components/notion/translations/de.json +++ b/homeassistant/components/notion/translations/de.json @@ -1,13 +1,22 @@ { "config": { "abort": { - "already_configured": "Konto wurde bereits konfiguriert" + "already_configured": "Konto wurde bereits konfiguriert", + "reauth_successful": "Die erneute Authentifizierung war erfolgreich" }, "error": { "invalid_auth": "Ung\u00fcltige Authentifizierung", - "no_devices": "Keine Ger\u00e4te im Konto gefunden" + "no_devices": "Keine Ger\u00e4te im Konto gefunden", + "unknown": "Unerwarteter Fehler" }, "step": { + "reauth_confirm": { + "data": { + "password": "Passwort" + }, + "description": "Bitte gib das Passwort f\u00fcr {username} erneut ein.", + "title": "Integration erneut authentifizieren" + }, "user": { "data": { "password": "Passwort", diff --git a/homeassistant/components/notion/translations/en.json b/homeassistant/components/notion/translations/en.json index 0eb4689bce6..afd58a4d404 100644 --- a/homeassistant/components/notion/translations/en.json +++ b/homeassistant/components/notion/translations/en.json @@ -6,6 +6,7 @@ }, "error": { "invalid_auth": "Invalid authentication", + "no_devices": "No devices found in account", "unknown": "Unexpected error" }, "step": { diff --git a/homeassistant/components/notion/translations/et.json b/homeassistant/components/notion/translations/et.json index a377f1e69ab..7639901201d 100644 --- a/homeassistant/components/notion/translations/et.json +++ b/homeassistant/components/notion/translations/et.json @@ -1,13 +1,22 @@ { "config": { "abort": { - "already_configured": "Konto on juba seadistatud" + "already_configured": "Konto on juba seadistatud", + "reauth_successful": "Taastuvastamine \u00f5nnestus" }, "error": { "invalid_auth": "Tuvastamise viga", - "no_devices": "Kontolt ei leitud \u00fchtegi seadet" + "no_devices": "Kontolt ei leitud \u00fchtegi seadet", + "unknown": "Ootamatu t\u00f5rge" }, "step": { + "reauth_confirm": { + "data": { + "password": "Salas\u00f5na" + }, + "description": "Sisesta uuesti {username} salas\u00f5na.", + "title": "Taastuvasta sidumine" + }, "user": { "data": { "password": "Salas\u00f5na", diff --git a/homeassistant/components/notion/translations/he.json b/homeassistant/components/notion/translations/he.json index 1a397f894cf..159db09e3b3 100644 --- a/homeassistant/components/notion/translations/he.json +++ b/homeassistant/components/notion/translations/he.json @@ -1,12 +1,20 @@ { "config": { "abort": { - "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "reauth_successful": "\u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7" }, "error": { - "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9" + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" }, "step": { + "reauth_confirm": { + "data": { + "password": "\u05e1\u05d9\u05e1\u05de\u05d4" + }, + "title": "\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05e9\u05dc \u05e9\u05d9\u05dc\u05d5\u05d1" + }, "user": { "data": { "password": "\u05e1\u05d9\u05e1\u05de\u05d4", diff --git a/homeassistant/components/notion/translations/hu.json b/homeassistant/components/notion/translations/hu.json index b4d57f83bb3..43f4f1f914c 100644 --- a/homeassistant/components/notion/translations/hu.json +++ b/homeassistant/components/notion/translations/hu.json @@ -1,13 +1,22 @@ { "config": { "abort": { - "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van" + "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van", + "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt" }, "error": { "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", - "no_devices": "Nem tal\u00e1lhat\u00f3 eszk\u00f6z a fi\u00f3kban" + "no_devices": "Nem tal\u00e1lhat\u00f3 eszk\u00f6z a fi\u00f3kban", + "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "step": { + "reauth_confirm": { + "data": { + "password": "Jelsz\u00f3" + }, + "description": "K\u00e9rem, adja meg ism\u00e9t {username} jelszav\u00e1t", + "title": "Integr\u00e1ci\u00f3 \u00fajrahiteles\u00edt\u00e9se" + }, "user": { "data": { "password": "Jelsz\u00f3", diff --git a/homeassistant/components/notion/translations/it.json b/homeassistant/components/notion/translations/it.json index 3304d2b395a..69d2294394b 100644 --- a/homeassistant/components/notion/translations/it.json +++ b/homeassistant/components/notion/translations/it.json @@ -1,13 +1,22 @@ { "config": { "abort": { - "already_configured": "L'account \u00e8 gi\u00e0 configurato" + "already_configured": "L'account \u00e8 gi\u00e0 configurato", + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente" }, "error": { "invalid_auth": "Autenticazione non valida", - "no_devices": "Nessun dispositivo trovato nell'account" + "no_devices": "Nessun dispositivo trovato nell'account", + "unknown": "Errore imprevisto" }, "step": { + "reauth_confirm": { + "data": { + "password": "Password" + }, + "description": "Inserisci nuovamente la password per {username}.", + "title": "Autenticare nuovamente l'integrazione" + }, "user": { "data": { "password": "Password", diff --git a/homeassistant/components/notion/translations/nl.json b/homeassistant/components/notion/translations/nl.json index acb42046c90..81da85f6240 100644 --- a/homeassistant/components/notion/translations/nl.json +++ b/homeassistant/components/notion/translations/nl.json @@ -1,13 +1,22 @@ { "config": { "abort": { - "already_configured": "Account is al geconfigureerd" + "already_configured": "Account is al geconfigureerd", + "reauth_successful": "Herauthenticatie was succesvol" }, "error": { "invalid_auth": "Ongeldige authenticatie", - "no_devices": "Geen apparaten gevonden in account" + "no_devices": "Geen apparaten gevonden in account", + "unknown": "Onverwachte fout" }, "step": { + "reauth_confirm": { + "data": { + "password": "Wachtwoord" + }, + "description": "Voer het wachtwoord voor {username} opnieuw in.", + "title": "Verifieer de integratie opnieuw" + }, "user": { "data": { "password": "Wachtwoord", diff --git a/homeassistant/components/notion/translations/no.json b/homeassistant/components/notion/translations/no.json index c1d8a1d17b5..0bbbeb9c0dd 100644 --- a/homeassistant/components/notion/translations/no.json +++ b/homeassistant/components/notion/translations/no.json @@ -1,13 +1,22 @@ { "config": { "abort": { - "already_configured": "Kontoen er allerede konfigurert" + "already_configured": "Kontoen er allerede konfigurert", + "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" }, "error": { "invalid_auth": "Ugyldig godkjenning", - "no_devices": "Ingen enheter funnet i kontoen" + "no_devices": "Ingen enheter funnet i kontoen", + "unknown": "Uventet feil" }, "step": { + "reauth_confirm": { + "data": { + "password": "Passord" + }, + "description": "Skriv inn passordet for {username} p\u00e5 nytt.", + "title": "Godkjenne integrering p\u00e5 nytt" + }, "user": { "data": { "password": "Passord", diff --git a/homeassistant/components/notion/translations/ru.json b/homeassistant/components/notion/translations/ru.json index 4b9a45bbf3f..bebd8a66e0e 100644 --- a/homeassistant/components/notion/translations/ru.json +++ b/homeassistant/components/notion/translations/ru.json @@ -1,13 +1,22 @@ { "config": { "abort": { - "already_configured": "\u042d\u0442\u0430 \u0443\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430 \u0432 Home Assistant." + "already_configured": "\u042d\u0442\u0430 \u0443\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430 \u0432 Home Assistant.", + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430 \u0443\u0441\u043f\u0435\u0448\u043d\u043e." }, "error": { "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", - "no_devices": "\u041d\u0435\u0442 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432, \u0441\u0432\u044f\u0437\u0430\u043d\u043d\u044b\u0445 \u0441 \u0443\u0447\u0451\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u044c\u044e." + "no_devices": "\u041d\u0435\u0442 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432, \u0441\u0432\u044f\u0437\u0430\u043d\u043d\u044b\u0445 \u0441 \u0443\u0447\u0451\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u044c\u044e.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." }, "step": { + "reauth_confirm": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u044c" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043f\u0430\u0440\u043e\u043b\u044c \u0434\u043b\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f {username}.", + "title": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f" + }, "user": { "data": { "password": "\u041f\u0430\u0440\u043e\u043b\u044c", diff --git a/homeassistant/components/notion/translations/zh-Hant.json b/homeassistant/components/notion/translations/zh-Hant.json index 865bd1dbd08..951ec07c8ad 100644 --- a/homeassistant/components/notion/translations/zh-Hant.json +++ b/homeassistant/components/notion/translations/zh-Hant.json @@ -1,13 +1,22 @@ { "config": { "abort": { - "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f" }, "error": { "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", - "no_devices": "\u5e33\u865f\u4e2d\u627e\u4e0d\u5230\u4efb\u4f55\u88dd\u7f6e" + "no_devices": "\u5e33\u865f\u4e2d\u627e\u4e0d\u5230\u4efb\u4f55\u88dd\u7f6e", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" }, "step": { + "reauth_confirm": { + "data": { + "password": "\u5bc6\u78bc" + }, + "description": "\u8acb\u8f38\u5165 {username} \u5bc6\u78bc\u3002", + "title": "\u91cd\u65b0\u8a8d\u8b49\u6574\u5408" + }, "user": { "data": { "password": "\u5bc6\u78bc", diff --git a/homeassistant/components/nuheat/translations/de.json b/homeassistant/components/nuheat/translations/de.json index 0ab69dd4557..682867d5404 100644 --- a/homeassistant/components/nuheat/translations/de.json +++ b/homeassistant/components/nuheat/translations/de.json @@ -16,7 +16,7 @@ "serial_number": "Seriennummer des Thermostats.", "username": "Benutzername" }, - "description": "Du musst die numerische Seriennummer oder ID deines Thermostats erhalten, indem du dich bei https://MyNuHeat.com anmeldest und deine Thermostate ausw\u00e4hlst.", + "description": "Du musst die numerische Seriennummer oder ID deines Thermostats erhalten, indem du dich bei https://MyNuHeat.com anmeldest und dein(e) Thermostat(e) ausw\u00e4hlst.", "title": "Stelle eine Verbindung zu NuHeat her" } } diff --git a/homeassistant/components/nuki/translations/hu.json b/homeassistant/components/nuki/translations/hu.json index 7a0b6b6159e..a5da7700b6f 100644 --- a/homeassistant/components/nuki/translations/hu.json +++ b/homeassistant/components/nuki/translations/hu.json @@ -18,7 +18,7 @@ }, "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "port": "Port", "token": "Hozz\u00e1f\u00e9r\u00e9si token" } diff --git a/homeassistant/components/nut/translations/hu.json b/homeassistant/components/nut/translations/hu.json index bfc8e01c11a..aa8f7c37105 100644 --- a/homeassistant/components/nut/translations/hu.json +++ b/homeassistant/components/nut/translations/hu.json @@ -23,7 +23,7 @@ }, "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "password": "Jelsz\u00f3", "port": "Port", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" diff --git a/homeassistant/components/nws/translations/hu.json b/homeassistant/components/nws/translations/hu.json index ec9bf3f4988..4533733e866 100644 --- a/homeassistant/components/nws/translations/hu.json +++ b/homeassistant/components/nws/translations/hu.json @@ -16,7 +16,7 @@ "station": "METAR \u00e1llom\u00e1s k\u00f3dja" }, "description": "Ha a METAR \u00e1llom\u00e1s k\u00f3dja nincs megadva, a sz\u00e9less\u00e9gi \u00e9s hossz\u00fas\u00e1gi fokokat haszn\u00e1lja a legk\u00f6zelebbi \u00e1llom\u00e1s megkeres\u00e9s\u00e9hez. Egyel\u0151re az API-kulcs b\u00e1rmi lehet. Javasoljuk, hogy \u00e9rv\u00e9nyes e -mail c\u00edmet haszn\u00e1ljon.", - "title": "Csatlakozzon az National Weather Service-hez" + "title": "Csatlakoz\u00e1s az National Weather Service-hez" } } } diff --git a/homeassistant/components/nzbget/translations/hu.json b/homeassistant/components/nzbget/translations/hu.json index 829fa03fe8e..6db44f83c28 100644 --- a/homeassistant/components/nzbget/translations/hu.json +++ b/homeassistant/components/nzbget/translations/hu.json @@ -11,7 +11,7 @@ "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "name": "N\u00e9v", "password": "Jelsz\u00f3", "port": "Port", diff --git a/homeassistant/components/nzbget/translations/id.json b/homeassistant/components/nzbget/translations/id.json index af096f4ef5f..585d50dc2f0 100644 --- a/homeassistant/components/nzbget/translations/id.json +++ b/homeassistant/components/nzbget/translations/id.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "Gagal terhubung" }, - "flow_title": "NZBGet: {name}", + "flow_title": "{name}", "step": { "user": { "data": { diff --git a/homeassistant/components/ondilo_ico/translations/hu.json b/homeassistant/components/ondilo_ico/translations/hu.json index cae1f6d20c0..a6979721779 100644 --- a/homeassistant/components/ondilo_ico/translations/hu.json +++ b/homeassistant/components/ondilo_ico/translations/hu.json @@ -2,7 +2,7 @@ "config": { "abort": { "authorize_url_timeout": "Id\u0151t\u00fall\u00e9p\u00e9s a hiteles\u00edt\u00e9si URL gener\u00e1l\u00e1sa sor\u00e1n.", - "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rlek, k\u00f6vesd a dokument\u00e1ci\u00f3t." + "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rem, k\u00f6vesse a dokument\u00e1ci\u00f3t." }, "create_entry": { "default": "Sikeres hiteles\u00edt\u00e9s" diff --git a/homeassistant/components/onewire/translations/hu.json b/homeassistant/components/onewire/translations/hu.json index e2c7ffa8c03..4d53659788d 100644 --- a/homeassistant/components/onewire/translations/hu.json +++ b/homeassistant/components/onewire/translations/hu.json @@ -10,7 +10,7 @@ "step": { "owserver": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "port": "Port" }, "title": "Owserver adatok be\u00e1ll\u00edt\u00e1sa" diff --git a/homeassistant/components/onvif/translations/hu.json b/homeassistant/components/onvif/translations/hu.json index c43df53ae9f..f0df008f145 100644 --- a/homeassistant/components/onvif/translations/hu.json +++ b/homeassistant/components/onvif/translations/hu.json @@ -2,10 +2,10 @@ "config": { "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", - "no_h264": "Nem voltak el\u00e9rhet\u0151 H264 streamek. Ellen\u0151rizd a profil konfigur\u00e1ci\u00f3j\u00e1t a k\u00e9sz\u00fcl\u00e9ken.", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van.", + "no_h264": "Nem voltak el\u00e9rhet\u0151 H264 streamek. Ellen\u0151rizze a profil konfigur\u00e1ci\u00f3j\u00e1t a k\u00e9sz\u00fcl\u00e9ken.", "no_mac": "Nem siker\u00fclt konfigur\u00e1lni az egyedi azonos\u00edt\u00f3t az ONVIF eszk\u00f6zh\u00f6z.", - "onvif_error": "Hiba t\u00f6rt\u00e9nt az ONVIF eszk\u00f6z be\u00e1ll\u00edt\u00e1sakor. Tov\u00e1bbi inform\u00e1ci\u00f3k\u00e9rt ellen\u0151rizd a napl\u00f3kat." + "onvif_error": "Hiba t\u00f6rt\u00e9nt az ONVIF eszk\u00f6z be\u00e1ll\u00edt\u00e1sakor. Tov\u00e1bbi inform\u00e1ci\u00f3k\u00e9rt ellen\u0151rizze a napl\u00f3kat." }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s" @@ -20,7 +20,7 @@ }, "configure": { "data": { - "host": "Gazdag\u00e9p", + "host": "C\u00edm", "name": "N\u00e9v", "password": "Jelsz\u00f3", "port": "Port", @@ -37,13 +37,13 @@ }, "device": { "data": { - "host": "V\u00e1laszd ki a felfedezett ONVIF eszk\u00f6zt" + "host": "V\u00e1lassza ki a felfedezett ONVIF eszk\u00f6zt" }, "title": "ONVIF eszk\u00f6z kiv\u00e1laszt\u00e1sa" }, "manual_input": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "name": "N\u00e9v", "port": "Port" }, @@ -53,7 +53,7 @@ "data": { "auto": "Automatikus keres\u00e9s" }, - "description": "A k\u00fcld\u00e9s gombra kattintva olyan ONVIF-eszk\u00f6z\u00f6ket keres\u00fcnk a h\u00e1l\u00f3zat\u00e1ban, amelyek t\u00e1mogatj\u00e1k az S profilt.\n\nEgyes gy\u00e1rt\u00f3k alap\u00e9rtelmez\u00e9s szerint elkezdt\u00e9k letiltani az ONVIF-et. Ellen\u0151rizze, hogy az ONVIF enged\u00e9lyezve van-e a kamera konfigur\u00e1ci\u00f3j\u00e1ban.", + "description": "A K\u00fcld\u00e9s gombra kattintva olyan ONVIF-eszk\u00f6z\u00f6ket keres\u00fcnk a h\u00e1l\u00f3zat\u00e1ban, amelyek t\u00e1mogatj\u00e1k az S profilt.\n\nEgyes gy\u00e1rt\u00f3k alap\u00e9rtelmez\u00e9s szerint elkezdt\u00e9k letiltani az ONVIF-et. Ellen\u0151rizze, hogy az ONVIF enged\u00e9lyezve van-e a kamera konfigur\u00e1ci\u00f3j\u00e1ban.", "title": "ONVIF eszk\u00f6z be\u00e1ll\u00edt\u00e1sa" } } diff --git a/homeassistant/components/onvif/translations/id.json b/homeassistant/components/onvif/translations/id.json index 3ed50ae63c4..6fcb49dcd99 100644 --- a/homeassistant/components/onvif/translations/id.json +++ b/homeassistant/components/onvif/translations/id.json @@ -18,6 +18,15 @@ }, "title": "Konfigurasikan autentikasi" }, + "configure": { + "data": { + "host": "Host", + "name": "Nama", + "password": "Kata Sandi", + "port": "Port", + "username": "Nama Pengguna" + } + }, "configure_profile": { "data": { "include": "Buat entitas kamera" diff --git a/homeassistant/components/opengarage/translations/ca.json b/homeassistant/components/opengarage/translations/ca.json new file mode 100644 index 00000000000..6a8e611b188 --- /dev/null +++ b/homeassistant/components/opengarage/translations/ca.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "unknown": "Error inesperat" + }, + "step": { + "user": { + "data": { + "device_key": "Clau del dispositiu", + "host": "Amfitri\u00f3", + "port": "Port", + "verify_ssl": "Verifica el certificat SSL" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/opengarage/translations/de.json b/homeassistant/components/opengarage/translations/de.json new file mode 100644 index 00000000000..4e39620a9a9 --- /dev/null +++ b/homeassistant/components/opengarage/translations/de.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "user": { + "data": { + "device_key": "Ger\u00e4teschl\u00fcssel", + "host": "Host", + "port": "Port", + "verify_ssl": "SSL-Zertifikat \u00fcberpr\u00fcfen" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/opengarage/translations/en.json b/homeassistant/components/opengarage/translations/en.json new file mode 100644 index 00000000000..9a103e2a1c0 --- /dev/null +++ b/homeassistant/components/opengarage/translations/en.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "device_key": "Device key", + "host": "Host", + "port": "Port", + "verify_ssl": "Verify SSL certificate" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/opengarage/translations/es.json b/homeassistant/components/opengarage/translations/es.json new file mode 100644 index 00000000000..77ca2a2d001 --- /dev/null +++ b/homeassistant/components/opengarage/translations/es.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "device_key": "Clave del dispositivo" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/opengarage/translations/et.json b/homeassistant/components/opengarage/translations/et.json new file mode 100644 index 00000000000..eb25c27492b --- /dev/null +++ b/homeassistant/components/opengarage/translations/et.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "invalid_auth": "Tuvastamnie nurjus", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "user": { + "data": { + "device_key": "Seadme v\u00f5ti", + "host": "Host", + "port": "Port", + "verify_ssl": "Kontrolli SSL serti" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/opengarage/translations/hu.json b/homeassistant/components/opengarage/translations/hu.json new file mode 100644 index 00000000000..2c7687261c8 --- /dev/null +++ b/homeassistant/components/opengarage/translations/hu.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + }, + "error": { + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" + }, + "step": { + "user": { + "data": { + "device_key": "Eszk\u00f6zkulcs", + "host": "C\u00edm", + "port": "Port", + "verify_ssl": "SSL-tan\u00fas\u00edtv\u00e1ny ellen\u0151rz\u00e9se" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/opengarage/translations/it.json b/homeassistant/components/opengarage/translations/it.json new file mode 100644 index 00000000000..0bd8adf23e0 --- /dev/null +++ b/homeassistant/components/opengarage/translations/it.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato" + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "invalid_auth": "Autenticazione non valida", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "device_key": "Chiave del dispositivo", + "host": "Host", + "port": "Porta", + "verify_ssl": "Verificare il certificato SSL" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/opengarage/translations/nl.json b/homeassistant/components/opengarage/translations/nl.json new file mode 100644 index 00000000000..96190a06817 --- /dev/null +++ b/homeassistant/components/opengarage/translations/nl.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "invalid_auth": "Ongeldige authenticatie", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "device_key": "Apparaatsleutel", + "host": "Host", + "port": "Poort", + "verify_ssl": "SSL-certificaat verifi\u00ebren" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/opengarage/translations/no.json b/homeassistant/components/opengarage/translations/no.json new file mode 100644 index 00000000000..5c5189a9de9 --- /dev/null +++ b/homeassistant/components/opengarage/translations/no.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "device_key": "Enhetsn\u00f8kkel", + "host": "Vert", + "port": "Port", + "verify_ssl": "Verifisere SSL-sertifikat" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/opengarage/translations/ru.json b/homeassistant/components/opengarage/translations/ru.json new file mode 100644 index 00000000000..85f528778bf --- /dev/null +++ b/homeassistant/components/opengarage/translations/ru.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "user": { + "data": { + "device_key": "\u041a\u043b\u044e\u0447 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430", + "host": "\u0425\u043e\u0441\u0442", + "port": "\u041f\u043e\u0440\u0442", + "verify_ssl": "\u041f\u0440\u043e\u0432\u0435\u0440\u044f\u0442\u044c \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442 SSL" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/opengarage/translations/zh-Hant.json b/homeassistant/components/opengarage/translations/zh-Hant.json new file mode 100644 index 00000000000..fffbd19b551 --- /dev/null +++ b/homeassistant/components/opengarage/translations/zh-Hant.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "user": { + "data": { + "device_key": "\u88dd\u7f6e\u5bc6\u9470", + "host": "\u4e3b\u6a5f\u7aef", + "port": "\u901a\u8a0a\u57e0", + "verify_ssl": "\u78ba\u8a8d SSL \u8a8d\u8b49" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/openuv/translations/es.json b/homeassistant/components/openuv/translations/es.json index c912cef6c54..014eba04f52 100644 --- a/homeassistant/components/openuv/translations/es.json +++ b/homeassistant/components/openuv/translations/es.json @@ -21,6 +21,10 @@ "options": { "step": { "init": { + "data": { + "from_window": "\u00cdndice UV inicial para la ventana de protecci\u00f3n", + "to_window": "\u00cdndice UV final para la ventana de protecci\u00f3n" + }, "title": "Configurar OpenUV" } } diff --git a/homeassistant/components/openuv/translations/nl.json b/homeassistant/components/openuv/translations/nl.json index 0129e24e304..bd56a1fa4e0 100644 --- a/homeassistant/components/openuv/translations/nl.json +++ b/homeassistant/components/openuv/translations/nl.json @@ -21,6 +21,10 @@ "options": { "step": { "init": { + "data": { + "from_window": "Uv-index starten voor het beschermingsvenster", + "to_window": "Uv-index voor het beveiligingsvenster be\u00ebindigen" + }, "title": "Configureer OpenUV" } } diff --git a/homeassistant/components/openweathermap/translations/hu.json b/homeassistant/components/openweathermap/translations/hu.json index 2fd2f0acc7a..99932ff5c68 100644 --- a/homeassistant/components/openweathermap/translations/hu.json +++ b/homeassistant/components/openweathermap/translations/hu.json @@ -17,7 +17,7 @@ "mode": "M\u00f3d", "name": "Az integr\u00e1ci\u00f3 neve" }, - "description": "Az OpenWeatherMap integr\u00e1ci\u00f3 be\u00e1ll\u00edt\u00e1sa. Az API kulcs l\u00e9trehoz\u00e1s\u00e1hoz menj az https://openweathermap.org/appid oldalra", + "description": "Az OpenWeatherMap integr\u00e1ci\u00f3 be\u00e1ll\u00edt\u00e1sa. Az API kulcs l\u00e9trehoz\u00e1s\u00e1hoz l\u00e1togasson el a https://openweathermap.org/appid oldalra", "title": "OpenWeatherMap" } } diff --git a/homeassistant/components/ovo_energy/translations/ca.json b/homeassistant/components/ovo_energy/translations/ca.json index f8552caa86b..0d0677ec522 100644 --- a/homeassistant/components/ovo_energy/translations/ca.json +++ b/homeassistant/components/ovo_energy/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "error": { - "already_configured": "El compte ja ha estat configurat", + "already_configured": "El compte ja est\u00e0 configurat", "cannot_connect": "Ha fallat la connexi\u00f3", "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida" }, diff --git a/homeassistant/components/ovo_energy/translations/id.json b/homeassistant/components/ovo_energy/translations/id.json index 05c38f244e7..fa072b59236 100644 --- a/homeassistant/components/ovo_energy/translations/id.json +++ b/homeassistant/components/ovo_energy/translations/id.json @@ -5,7 +5,7 @@ "cannot_connect": "Gagal terhubung", "invalid_auth": "Autentikasi tidak valid" }, - "flow_title": "OVO Energy: {username}", + "flow_title": "{username}", "step": { "reauth": { "data": { diff --git a/homeassistant/components/owntracks/translations/hu.json b/homeassistant/components/owntracks/translations/hu.json index f103fc9bbe1..84a40a1a593 100644 --- a/homeassistant/components/owntracks/translations/hu.json +++ b/homeassistant/components/owntracks/translations/hu.json @@ -4,11 +4,11 @@ "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges." }, "create_entry": { - "default": "\n\nAndroidon, nyisd meg [az OwnTracks appot]({android_url}), menj a preferences -> connectionre. V\u00e1ltoztasd meg a al\u00e1bbi be\u00e1ll\u00edt\u00e1sokat:\n - Mode: Private HTTP\n - Host: {webhook_url}\n - Identification:\n - Username: ``\n - Device ID: ``\n\niOS-en, nyisd meg [az OwnTracks appot]({ios_url}), kattints az (i) ikonra bal oldalon fel\u00fcl -> settings. V\u00e1ltoztasd meg az al\u00e1bbi be\u00e1ll\u00edt\u00e1sokat:\n - Mode: HTTP\n - URL: {webhook_url}\n - Turn on authentication\n - UserID: ``\n\n{secret}\n\nN\u00e9zd meg [a dokument\u00e1ci\u00f3t]({docs_url}) tov\u00e1bbi inform\u00e1ci\u00f3k\u00e9rt." + "default": "\n\nAndroidon, nyissa meg [az OwnTracks appot]({android_url}), majd v\u00e1lassza ki a Preferences -> Connection men\u00fct. V\u00e1ltoztassa meg az al\u00e1bbi be\u00e1ll\u00edt\u00e1sokat:\n - Mode: Private HTTP\n - Host: {webhook_url}\n - Identification:\n - Username: ``\n - Device ID: ``\n\niOS-en, nyissa meg [az OwnTracks appot]({ios_url}), kattintson az (i) ikonra bal oldalon fel\u00fcl -> settings. V\u00e1ltoztassa meg az al\u00e1bbi be\u00e1ll\u00edt\u00e1sokat:\n - Mode: HTTP\n - URL: {webhook_url}\n - Turn on authentication\n - UserID: ``\n\n{secret}\n\nN\u00e9zze meg [a dokument\u00e1ci\u00f3t]({docs_url}) tov\u00e1bbi inform\u00e1ci\u00f3k\u00e9rt." }, "step": { "user": { - "description": "Biztosan be szeretn\u00e9d \u00e1ll\u00edtani az Owntracks-t?", + "description": "Biztos benne, hogy be szeretn\u00e9 \u00e1ll\u00edtani az Owntracks-t?", "title": "Owntracks be\u00e1ll\u00edt\u00e1sa" } } diff --git a/homeassistant/components/ozw/translations/ca.json b/homeassistant/components/ozw/translations/ca.json index 835c16eb449..9c9fc17e58e 100644 --- a/homeassistant/components/ozw/translations/ca.json +++ b/homeassistant/components/ozw/translations/ca.json @@ -32,7 +32,7 @@ "start_addon": { "data": { "network_key": "Clau de xarxa", - "usb_path": "Ruta del port USB del dispositiu" + "usb_path": "Ruta del dispositiu USB" }, "title": "Introdueix la configuraci\u00f3 del complement OpenZWave" } diff --git a/homeassistant/components/ozw/translations/hu.json b/homeassistant/components/ozw/translations/hu.json index a43f234c909..06d921c86d3 100644 --- a/homeassistant/components/ozw/translations/hu.json +++ b/homeassistant/components/ozw/translations/hu.json @@ -5,7 +5,7 @@ "addon_install_failed": "Nem siker\u00fclt telep\u00edteni az OpenZWave b\u0151v\u00edtm\u00e9nyt.", "addon_set_config_failed": "Nem siker\u00fclt be\u00e1ll\u00edtani az OpenZWave konfigur\u00e1ci\u00f3t.", "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van.", "mqtt_required": "Az MQTT integr\u00e1ci\u00f3 nincs be\u00e1ll\u00edtva", "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges." }, @@ -26,8 +26,8 @@ "data": { "use_addon": "Haszn\u00e1ld az OpenZWave Supervisor b\u0151v\u00edtm\u00e9nyt" }, - "description": "Szeretn\u00e9d haszn\u00e1lni az OpenZWave Supervisor b\u0151v\u00edtm\u00e9nyt?", - "title": "V\u00e1laszd ki a csatlakoz\u00e1si m\u00f3dot" + "description": "Szeretn\u00e9 haszn\u00e1lni az OpenZWave Supervisor b\u0151v\u00edtm\u00e9nyt?", + "title": "V\u00e1lassza ki a csatlakoz\u00e1si m\u00f3dot" }, "start_addon": { "data": { diff --git a/homeassistant/components/p1_monitor/translations/es.json b/homeassistant/components/p1_monitor/translations/es.json index 023a2a9f17c..5c8552d224b 100644 --- a/homeassistant/components/p1_monitor/translations/es.json +++ b/homeassistant/components/p1_monitor/translations/es.json @@ -1,12 +1,13 @@ { "config": { "error": { + "cannot_connect": "No se pudo conectar", "unknown": "Error inesperado" }, "step": { "user": { "data": { - "host": "Anfitri\u00f3n", + "host": "Host", "name": "Nombre" } } diff --git a/homeassistant/components/p1_monitor/translations/hu.json b/homeassistant/components/p1_monitor/translations/hu.json index 80d00e51571..f9025022c6d 100644 --- a/homeassistant/components/p1_monitor/translations/hu.json +++ b/homeassistant/components/p1_monitor/translations/hu.json @@ -7,7 +7,7 @@ "step": { "user": { "data": { - "host": "Gazdag\u00e9p", + "host": "C\u00edm", "name": "N\u00e9v" }, "description": "\u00c1ll\u00edtsa be a P1 monitort az Otthoni asszisztenssel val\u00f3 integr\u00e1ci\u00f3hoz." diff --git a/homeassistant/components/p1_monitor/translations/id.json b/homeassistant/components/p1_monitor/translations/id.json new file mode 100644 index 00000000000..8c96f3ee6cb --- /dev/null +++ b/homeassistant/components/p1_monitor/translations/id.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "cannot_connect": "Gagal terhubung", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Nama" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/panasonic_viera/translations/hu.json b/homeassistant/components/panasonic_viera/translations/hu.json index df520bb1ca5..e373a352a45 100644 --- a/homeassistant/components/panasonic_viera/translations/hu.json +++ b/homeassistant/components/panasonic_viera/translations/hu.json @@ -14,7 +14,7 @@ "data": { "pin": "PIN-k\u00f3d" }, - "description": "Add meg a TV-k\u00e9sz\u00fcl\u00e9ken megjelen\u0151 PIN-k\u00f3dot", + "description": "Adja meg a TV-k\u00e9sz\u00fcl\u00e9ken megjelen\u0151 PIN-k\u00f3dot", "title": "P\u00e1ros\u00edt\u00e1s" }, "user": { @@ -22,7 +22,7 @@ "host": "IP c\u00edm", "name": "N\u00e9v" }, - "description": "Add meg a Panasonic Viera TV-hez tartoz\u00f3 IP c\u00edmet", + "description": "Adja meg a Panasonic Viera TV-hez tartoz\u00f3 IP c\u00edmet", "title": "A TV be\u00e1ll\u00edt\u00e1sa" } } diff --git a/homeassistant/components/philips_js/translations/hu.json b/homeassistant/components/philips_js/translations/hu.json index 1fe4811d21e..544b44ee8ee 100644 --- a/homeassistant/components/philips_js/translations/hu.json +++ b/homeassistant/components/philips_js/translations/hu.json @@ -20,7 +20,7 @@ "user": { "data": { "api_version": "API Verzi\u00f3", - "host": "Hoszt" + "host": "C\u00edm" } } } diff --git a/homeassistant/components/pi_hole/translations/hu.json b/homeassistant/components/pi_hole/translations/hu.json index a8f8563da41..71321c4cf85 100644 --- a/homeassistant/components/pi_hole/translations/hu.json +++ b/homeassistant/components/pi_hole/translations/hu.json @@ -15,7 +15,7 @@ "user": { "data": { "api_key": "API kulcs", - "host": "Hoszt", + "host": "C\u00edm", "location": "Elhelyezked\u00e9s", "name": "N\u00e9v", "port": "Port", diff --git a/homeassistant/components/picnic/translations/id.json b/homeassistant/components/picnic/translations/id.json index 0455a5b3b5e..819125c6909 100644 --- a/homeassistant/components/picnic/translations/id.json +++ b/homeassistant/components/picnic/translations/id.json @@ -1,6 +1,11 @@ { "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi" + }, "error": { + "cannot_connect": "Gagal terhubung", + "invalid_auth": "Autentikasi tidak valid", "unknown": "Kesalahan yang tidak diharapkan" }, "step": { diff --git a/homeassistant/components/picnic/translations/ko.json b/homeassistant/components/picnic/translations/ko.json new file mode 100644 index 00000000000..fe58774c459 --- /dev/null +++ b/homeassistant/components/picnic/translations/ko.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "country_code": "\uad6d\uac00 \ucf54\ub4dc", + "password": "\ube44\ubc00\ubc88\ud638", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/plaato/translations/ca.json b/homeassistant/components/plaato/translations/ca.json index c4669b219ab..06aa27e5b37 100644 --- a/homeassistant/components/plaato/translations/ca.json +++ b/homeassistant/components/plaato/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat", + "already_configured": "El compte ja est\u00e0 configurat", "single_instance_allowed": "Ja configurat. Nom\u00e9s \u00e9s possible una sola configuraci\u00f3.", "webhook_not_internet_accessible": "La teva inst\u00e0ncia de Home Assistant ha de ser accessible des d'Internet per poder rebre missatges webhook." }, diff --git a/homeassistant/components/plaato/translations/es.json b/homeassistant/components/plaato/translations/es.json index e0b6c767043..d38bf2a8265 100644 --- a/homeassistant/components/plaato/translations/es.json +++ b/homeassistant/components/plaato/translations/es.json @@ -6,7 +6,7 @@ "webhook_not_internet_accessible": "Tu instancia de Home Assistant debe estar accesible desde Internet para recibir mensajes webhook." }, "create_entry": { - "default": "Para enviar eventos a Home Assistant, necesitar\u00e1s configurar la funci\u00f3n de webhook en Plaato Airlock.\n\nCompleta la siguiente informaci\u00f3n:\n\n- URL: `{webhook_url}`\n- M\u00e9todo: POST\n\nEcha un vistazo a [la documentaci\u00f3n]({docs_url}) para m\u00e1s detalles." + "default": "\u00a1Tu Plaato {device_type} con nombre **{device_name}** se configur\u00f3 correctamente!" }, "error": { "invalid_webhook_device": "Has seleccionado un dispositivo que no admite el env\u00edo de datos a un webhook. Solo est\u00e1 disponible para Airlock", diff --git a/homeassistant/components/plaato/translations/hu.json b/homeassistant/components/plaato/translations/hu.json index 4778b41e8be..a25c0c35672 100644 --- a/homeassistant/components/plaato/translations/hu.json +++ b/homeassistant/components/plaato/translations/hu.json @@ -3,7 +3,7 @@ "abort": { "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van", "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges.", - "webhook_not_internet_accessible": "A Home Assistant rendszerednek el\u00e9rhet\u0151nek kell lennie az internetr\u0151l a webhook \u00fczenetek fogad\u00e1s\u00e1hoz." + "webhook_not_internet_accessible": "A Home Assistant p\u00e9ld\u00e1ny\u00e1nak el\u00e9rhet\u0151nek kell lennie az internet fel\u0151l a webhook \u00fczenetek fogad\u00e1s\u00e1hoz." }, "create_entry": { "default": "A Plaato {device_type} **{device_name}** n\u00e9vvel sikeresen telep\u00edtve lett!" @@ -27,11 +27,11 @@ "device_name": "Eszk\u00f6z neve", "device_type": "A Plaato eszk\u00f6z t\u00edpusa" }, - "description": "El szeretn\u00e9d kezdeni a be\u00e1ll\u00edt\u00e1st?", + "description": "El szeretn\u00e9 kezdeni a be\u00e1ll\u00edt\u00e1st?", "title": "A Plaato eszk\u00f6z\u00f6k be\u00e1ll\u00edt\u00e1sa" }, "webhook": { - "description": "Ha esem\u00e9nyeket szeretne k\u00fcldeni a Home Assistant alkalmaz\u00e1snak, be kell \u00e1ll\u00edtania a webhook funkci\u00f3t a Plaato Airlock-ban. \n\n T\u00f6ltse ki a k\u00f6vetkez\u0151 inform\u00e1ci\u00f3kat: \n\n - URL: \" {webhook_url} \"\n - M\u00f3dszer: POST \n\n Tov\u00e1bbi r\u00e9szletek\u00e9rt l\u00e1sd a [dokument\u00e1ci\u00f3t] ( {docs_url} ).", + "description": "Ha esem\u00e9nyeket szeretne k\u00fcldeni a Home Assistant alkalmaz\u00e1snak, be kell \u00e1ll\u00edtania a webhook funkci\u00f3t a Plaato Airlock-ban. \n\n T\u00f6ltse ki a k\u00f6vetkez\u0151 inform\u00e1ci\u00f3kat: \n\n - URL: \" {webhook_url} \"\n - M\u00f3dszer: POST \n\n Tov\u00e1bbi r\u00e9szletek\u00e9rt l\u00e1ssa a [dokument\u00e1ci\u00f3t]({docs_url}).", "title": "Haszn\u00e1land\u00f3 webhook" } } diff --git a/homeassistant/components/plaato/translations/nl.json b/homeassistant/components/plaato/translations/nl.json index 23fae52b020..7dc3eaf6fb7 100644 --- a/homeassistant/components/plaato/translations/nl.json +++ b/homeassistant/components/plaato/translations/nl.json @@ -27,7 +27,7 @@ "device_name": "Geef uw apparaat een naam", "device_type": "Type Plaato-apparaat" }, - "description": "Wil je beginnen met instellen?", + "description": "Wilt u beginnen met instellen?", "title": "Stel de Plaato-apparaten in" }, "webhook": { diff --git a/homeassistant/components/plant/translations/hu.json b/homeassistant/components/plant/translations/hu.json index 3206ef7064d..ad2061411f5 100644 --- a/homeassistant/components/plant/translations/hu.json +++ b/homeassistant/components/plant/translations/hu.json @@ -5,5 +5,5 @@ "problem": "Probl\u00e9ma" } }, - "title": "N\u00f6v\u00e9ny" + "title": "N\u00f6v\u00e9nyfigyel\u0151" } \ No newline at end of file diff --git a/homeassistant/components/plex/translations/hu.json b/homeassistant/components/plex/translations/hu.json index c0ecbe3e02c..cde11b9c7cc 100644 --- a/homeassistant/components/plex/translations/hu.json +++ b/homeassistant/components/plex/translations/hu.json @@ -3,15 +3,15 @@ "abort": { "all_configured": "Az \u00f6sszes \u00f6sszekapcsolt szerver m\u00e1r konfigur\u00e1lva van", "already_configured": "Ez a Plex szerver m\u00e1r konfigur\u00e1lva van", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van.", "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt", "token_request_timeout": "Token k\u00e9r\u00e9sre sz\u00e1nt id\u0151 lej\u00e1rt", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "error": { - "faulty_credentials": "A hiteles\u00edt\u00e9s sikertelen", - "host_or_token": "Legal\u00e1bb egyet kell megadnia a Gazdag\u00e9p vagy a Token k\u00f6z\u00fcl", - "no_servers": "Nincs szerver csatlakoztatva a fi\u00f3khoz", + "faulty_credentials": "A hiteles\u00edt\u00e9s sikertelen, k\u00e9rem, ellen\u0151rizze a token-t", + "host_or_token": "Legal\u00e1bb egyet kell megadnia a C\u00edm vagy a Token k\u00f6z\u00fcl", + "no_servers": "Nincsenek Plex-fi\u00f3khoz kapcsol\u00f3d\u00f3 kiszolg\u00e1l\u00f3k", "not_found": "A Plex szerver nem tal\u00e1lhat\u00f3", "ssl_error": "SSL tan\u00fas\u00edtv\u00e1ny probl\u00e9ma" }, @@ -19,7 +19,7 @@ "step": { "manual_setup": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "port": "Port", "ssl": "SSL tan\u00fas\u00edtv\u00e1ny haszn\u00e1lata", "token": "Token (opcion\u00e1lis)", diff --git a/homeassistant/components/plugwise/translations/id.json b/homeassistant/components/plugwise/translations/id.json index 9047bf477bd..f3eeb0926ba 100644 --- a/homeassistant/components/plugwise/translations/id.json +++ b/homeassistant/components/plugwise/translations/id.json @@ -8,7 +8,7 @@ "invalid_auth": "Autentikasi tidak valid", "unknown": "Kesalahan yang tidak diharapkan" }, - "flow_title": "Smile: {name}", + "flow_title": "{name}", "step": { "user": { "data": { diff --git a/homeassistant/components/plum_lightpad/translations/ca.json b/homeassistant/components/plum_lightpad/translations/ca.json index 86f649d57d7..c1854b868e6 100644 --- a/homeassistant/components/plum_lightpad/translations/ca.json +++ b/homeassistant/components/plum_lightpad/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat" + "already_configured": "El compte ja est\u00e0 configurat" }, "error": { "cannot_connect": "Ha fallat la connexi\u00f3" diff --git a/homeassistant/components/point/translations/he.json b/homeassistant/components/point/translations/he.json index 24decb09dd8..a226a9e4c6d 100644 --- a/homeassistant/components/point/translations/he.json +++ b/homeassistant/components/point/translations/he.json @@ -3,7 +3,8 @@ "abort": { "already_setup": "\u05ea\u05e6\u05d5\u05e8\u05ea\u05d5 \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e8\u05e7 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05d0\u05e4\u05e9\u05e8\u05d9\u05ea.", "authorize_url_timeout": "\u05e4\u05e1\u05e7 \u05d6\u05de\u05df \u05dc\u05d9\u05e6\u05d9\u05e8\u05ea \u05db\u05ea\u05d5\u05d1\u05ea URL \u05dc\u05d0\u05d9\u05e9\u05d5\u05e8.", - "no_flows": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05e8\u05db\u05d9\u05d1 \u05dc\u05d0 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e0\u05d0 \u05e2\u05e7\u05d5\u05d1 \u05d0\u05d7\u05e8 \u05d4\u05ea\u05d9\u05e2\u05d5\u05d3." + "no_flows": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05e8\u05db\u05d9\u05d1 \u05dc\u05d0 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e0\u05d0 \u05e2\u05e7\u05d5\u05d1 \u05d0\u05d7\u05e8 \u05d4\u05ea\u05d9\u05e2\u05d5\u05d3.", + "unknown_authorize_url_generation": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05dc\u05d0 \u05d9\u05d3\u05d5\u05e2\u05d4 \u05d1\u05d9\u05e6\u05d9\u05e8\u05ea \u05db\u05ea\u05d5\u05d1\u05ea URL \u05e9\u05dc \u05d4\u05e8\u05e9\u05d0\u05d4." }, "create_entry": { "default": "\u05d0\u05d5\u05de\u05ea \u05d1\u05d4\u05e6\u05dc\u05d7\u05d4" diff --git a/homeassistant/components/point/translations/hu.json b/homeassistant/components/point/translations/hu.json index 17dc73a189b..c582bbfc7cd 100644 --- a/homeassistant/components/point/translations/hu.json +++ b/homeassistant/components/point/translations/hu.json @@ -4,26 +4,26 @@ "already_setup": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges.", "authorize_url_timeout": "Id\u0151t\u00fall\u00e9p\u00e9s a hiteles\u00edt\u00e9si URL gener\u00e1l\u00e1sa sor\u00e1n.", "external_setup": "A pont sikeresen konfigur\u00e1lva van egy m\u00e1sik folyamatb\u00f3l.", - "no_flows": "A komponens nincs konfigur\u00e1lva. K\u00e9rlek, k\u00f6vesd a dokument\u00e1ci\u00f3t.", + "no_flows": "A komponens nincs konfigur\u00e1lva. K\u00e9rem, k\u00f6vesse a dokument\u00e1ci\u00f3t.", "unknown_authorize_url_generation": "Ismeretlen hiba t\u00f6rt\u00e9nt a hiteles\u00edt\u00e9si link gener\u00e1l\u00e1sa sor\u00e1n." }, "create_entry": { "default": "Sikeres hiteles\u00edt\u00e9s" }, "error": { - "follow_link": "K\u00e9rlek, k\u00f6vesd a hivatkoz\u00e1st \u00e9s hiteles\u00edtsd magad miel\u0151tt megnyomod a K\u00fcld\u00e9s gombot", + "follow_link": "K\u00e9rem, k\u00f6vesse a hivatkoz\u00e1st \u00e9s hiteles\u00edtse mag\u00e1t miel\u0151tt megnyomn\u00e1 a K\u00fcld\u00e9s gombot", "no_token": "\u00c9rv\u00e9nytelen hozz\u00e1f\u00e9r\u00e9si token" }, "step": { "auth": { - "description": "K\u00e9rlek k\u00f6vesd az al\u00e1bbi linket \u00e9s a **Fogadd el** a hozz\u00e1f\u00e9r\u00e9st a Minut fi\u00f3kj\u00e1hoz, majd t\u00e9rj vissza \u00e9s nyomd meg a **K\u00fcld\u00e9s ** gombot. \n\n [Link]({authorization_url})", + "description": "K\u00e9rem k\u00f6vesse az al\u00e1bbi linket \u00e9s a **Fogadja el** a hozz\u00e1f\u00e9r\u00e9st a Minut fi\u00f3kj\u00e1hoz, majd t\u00e9rjen vissza \u00e9s nyomja meg a **K\u00fcld\u00e9s ** gombot. \n\n [Link]({authorization_url})", "title": "Point hiteles\u00edt\u00e9se" }, "user": { "data": { "flow_impl": "Szolg\u00e1ltat\u00f3" }, - "description": "El szeretn\u00e9d kezdeni a be\u00e1ll\u00edt\u00e1st?", + "description": "El szeretn\u00e9 kezdeni a be\u00e1ll\u00edt\u00e1st?", "title": "V\u00e1lassz hiteles\u00edt\u00e9si m\u00f3dszert" } } diff --git a/homeassistant/components/point/translations/nl.json b/homeassistant/components/point/translations/nl.json index 37dae8481eb..f0ab4d8696e 100644 --- a/homeassistant/components/point/translations/nl.json +++ b/homeassistant/components/point/translations/nl.json @@ -23,7 +23,7 @@ "data": { "flow_impl": "Leverancier" }, - "description": "Wil je beginnen met instellen?", + "description": "Wilt u beginnen met instellen?", "title": "Kies een authenticatie methode" } } diff --git a/homeassistant/components/poolsense/translations/hu.json b/homeassistant/components/poolsense/translations/hu.json index 80562b34e28..39274e14c21 100644 --- a/homeassistant/components/poolsense/translations/hu.json +++ b/homeassistant/components/poolsense/translations/hu.json @@ -12,7 +12,7 @@ "email": "E-mail", "password": "Jelsz\u00f3" }, - "description": "El szeretn\u00e9d kezdeni a be\u00e1ll\u00edt\u00e1st?", + "description": "El szeretn\u00e9 kezdeni a be\u00e1ll\u00edt\u00e1st?", "title": "PoolSense" } } diff --git a/homeassistant/components/poolsense/translations/nl.json b/homeassistant/components/poolsense/translations/nl.json index f88d14e297a..1fd59ebf2ea 100644 --- a/homeassistant/components/poolsense/translations/nl.json +++ b/homeassistant/components/poolsense/translations/nl.json @@ -12,7 +12,7 @@ "email": "E-mail", "password": "Wachtwoord" }, - "description": "Wil je beginnen met instellen?", + "description": "Wilt u beginnen met instellen?", "title": "PoolSense" } } diff --git a/homeassistant/components/powerwall/translations/id.json b/homeassistant/components/powerwall/translations/id.json index a5ae5f5e979..95f8d600901 100644 --- a/homeassistant/components/powerwall/translations/id.json +++ b/homeassistant/components/powerwall/translations/id.json @@ -10,7 +10,7 @@ "unknown": "Kesalahan yang tidak diharapkan", "wrong_version": "Powerwall Anda menggunakan versi perangkat lunak yang tidak didukung. Pertimbangkan untuk memutakhirkan atau melaporkan masalah ini agar dapat diatasi." }, - "flow_title": "Tesla Powerwall ({ip_address})", + "flow_title": "{ip_address}", "step": { "user": { "data": { diff --git a/homeassistant/components/profiler/translations/hu.json b/homeassistant/components/profiler/translations/hu.json index c5d28903888..215cd02307b 100644 --- a/homeassistant/components/profiler/translations/hu.json +++ b/homeassistant/components/profiler/translations/hu.json @@ -5,7 +5,7 @@ }, "step": { "user": { - "description": "El szeretn\u00e9d kezdeni a be\u00e1ll\u00edt\u00e1st?" + "description": "El szeretn\u00e9 kezdeni a be\u00e1ll\u00edt\u00e1st?" } } } diff --git a/homeassistant/components/profiler/translations/nl.json b/homeassistant/components/profiler/translations/nl.json index 8690611b1c9..8b99a128bd3 100644 --- a/homeassistant/components/profiler/translations/nl.json +++ b/homeassistant/components/profiler/translations/nl.json @@ -5,7 +5,7 @@ }, "step": { "user": { - "description": "Wil je beginnen met instellen?" + "description": "Wilt u beginnen met instellen?" } } } diff --git a/homeassistant/components/progettihwsw/translations/he.json b/homeassistant/components/progettihwsw/translations/he.json index 67c80866be0..4095264c76c 100644 --- a/homeassistant/components/progettihwsw/translations/he.json +++ b/homeassistant/components/progettihwsw/translations/he.json @@ -10,20 +10,20 @@ "step": { "relay_modes": { "data": { - "relay_1": "Relay 1", - "relay_10": "Relay 10", - "relay_11": "Relay 11", - "relay_12": "Relay 12", - "relay_13": "Relay 13", - "relay_15": "Relay 15", - "relay_2": "Relay 2", - "relay_3": "Relay 3", - "relay_4": "Relay 4", - "relay_5": "Relay 5", - "relay_6": "Relay 6", - "relay_7": "Relay 7", - "relay_8": "Relay 8", - "relay_9": "Relay 9" + "relay_1": "\u05de\u05de\u05e1\u05e8 1", + "relay_10": "\u05de\u05de\u05e1\u05e8 10", + "relay_11": "\u05de\u05de\u05e1\u05e8 11", + "relay_12": "\u05de\u05de\u05e1\u05e8 12", + "relay_13": "\u05de\u05de\u05e1\u05e8 13", + "relay_15": "\u05de\u05de\u05e1\u05e8 15", + "relay_2": "\u05de\u05de\u05e1\u05e8 2", + "relay_3": "\u05de\u05de\u05e1\u05e8 3", + "relay_4": "\u05de\u05de\u05e1\u05e8 4", + "relay_5": "\u05de\u05de\u05e1\u05e8 5", + "relay_6": "\u05de\u05de\u05e1\u05e8 6", + "relay_7": "\u05de\u05de\u05e1\u05e8 7", + "relay_8": "\u05de\u05de\u05e1\u05e8 8", + "relay_9": "\u05de\u05de\u05e1\u05e8 9" }, "title": "\u05d4\u05d2\u05d3\u05e8\u05ea \u05de\u05de\u05e1\u05e8\u05d9\u05dd" }, diff --git a/homeassistant/components/progettihwsw/translations/hu.json b/homeassistant/components/progettihwsw/translations/hu.json index fea70ec88ac..84258a6a01b 100644 --- a/homeassistant/components/progettihwsw/translations/hu.json +++ b/homeassistant/components/progettihwsw/translations/hu.json @@ -31,7 +31,7 @@ }, "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "port": "Port" }, "title": "\u00c1ll\u00edtsa be" diff --git a/homeassistant/components/prosegur/translations/el.json b/homeassistant/components/prosegur/translations/el.json new file mode 100644 index 00000000000..c5dee661aa2 --- /dev/null +++ b/homeassistant/components/prosegur/translations/el.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "reauth_confirm": { + "data": { + "description": "\u0395\u03ba \u03bd\u03ad\u03bf\u03c5 \u03ad\u03bb\u03b5\u03b3\u03c7\u03bf\u03c2 \u03c4\u03b1\u03c5\u03c4\u03cc\u03c4\u03b7\u03c4\u03b1\u03c2 \u03bc\u03b5 \u03bb\u03bf\u03b3\u03b1\u03c1\u03b9\u03b1\u03c3\u03bc\u03cc Prosegur." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/prosegur/translations/es.json b/homeassistant/components/prosegur/translations/es.json index 272b501da30..af4f61d6fdc 100644 --- a/homeassistant/components/prosegur/translations/es.json +++ b/homeassistant/components/prosegur/translations/es.json @@ -1,27 +1,27 @@ { "config": { "abort": { - "already_configured": "El sistema ya est\u00e1 configurado", + "already_configured": "El dispositivo ya est\u00e1 configurado", "reauth_successful": "La reautenticaci\u00f3n se realiz\u00f3 correctamente" }, "error": { "cannot_connect": "No se pudo conectar", - "invalid_auth": "Autenticaci\u00f3n err\u00f3nea", - "unknown": "Error desconocido" + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "unknown": "Error inesperado" }, "step": { "reauth_confirm": { "data": { "description": "Vuelva a autenticarse con su cuenta Prosegur.", "password": "Contrase\u00f1a", - "username": "Nombre de Usuario" + "username": "Usuario" } }, "user": { "data": { "country": "Pa\u00eds", "password": "Contrase\u00f1a", - "username": "Nombre de Usuario" + "username": "Usuario" } } } diff --git a/homeassistant/components/prosegur/translations/id.json b/homeassistant/components/prosegur/translations/id.json new file mode 100644 index 00000000000..9616471c03a --- /dev/null +++ b/homeassistant/components/prosegur/translations/id.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi", + "reauth_successful": "Autentikasi ulang berhasil" + }, + "error": { + "cannot_connect": "Gagal terhubung", + "invalid_auth": "Autentikasi tidak valid", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "Kata Sandi", + "username": "Nama Pengguna" + } + }, + "user": { + "data": { + "country": "Negara", + "password": "Kata Sandi" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ps4/translations/he.json b/homeassistant/components/ps4/translations/he.json index e9543da8206..421f869d8c5 100644 --- a/homeassistant/components/ps4/translations/he.json +++ b/homeassistant/components/ps4/translations/he.json @@ -19,6 +19,9 @@ "region": "\u05d0\u05d9\u05d6\u05d5\u05e8" }, "title": "\u05e4\u05dc\u05d9\u05d9\u05e1\u05d8\u05d9\u05d9\u05e9\u05df 4" + }, + "mode": { + "title": "\u05e4\u05dc\u05d9\u05d9\u05e1\u05d8\u05d9\u05d9\u05e9\u05df 4" } } } diff --git a/homeassistant/components/ps4/translations/hu.json b/homeassistant/components/ps4/translations/hu.json index 97614bcac57..753ea60b282 100644 --- a/homeassistant/components/ps4/translations/hu.json +++ b/homeassistant/components/ps4/translations/hu.json @@ -9,7 +9,7 @@ }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", - "credential_timeout": "Id\u0151t\u00fall\u00e9p\u00e9s. Az \u00fajraind\u00edt\u00e1shoz nyomja meg a bek\u00fcld\u00e9s gombot.", + "credential_timeout": "Id\u0151t\u00fall\u00e9p\u00e9s. Az \u00fajraind\u00edt\u00e1shoz nyomja meg a K\u00fcld\u00e9s gombot.", "login_failed": "Nem siker\u00fclt p\u00e1ros\u00edtani a PlayStation 4-gyel. Ellen\u0151rizze, hogy a PIN-k\u00f3d helyes-e.", "no_ipaddress": "\u00cdrja be a konfigur\u00e1lni k\u00edv\u00e1nt PlayStation 4 IP c\u00edm\u00e9t" }, @@ -30,7 +30,7 @@ }, "mode": { "data": { - "ip_address": "IP c\u00edm (Hagyd \u00fcresen az Automatikus Felder\u00edt\u00e9s haszn\u00e1lat\u00e1hoz).", + "ip_address": "IP c\u00edm (Hagyja \u00fcresen az Automatikus Felder\u00edt\u00e9s haszn\u00e1lat\u00e1hoz).", "mode": "Konfigur\u00e1ci\u00f3s m\u00f3d" }, "description": "V\u00e1lassza ki a m\u00f3dot a konfigur\u00e1l\u00e1shoz. Az IP c\u00edm mez\u0151 \u00fcresen maradhat, ha az Automatikus felder\u00edt\u00e9s lehet\u0151s\u00e9get v\u00e1lasztja, mivel az eszk\u00f6z\u00f6k automatikusan felfedez\u0151dnek.", diff --git a/homeassistant/components/pvpc_hourly_pricing/translations/hu.json b/homeassistant/components/pvpc_hourly_pricing/translations/hu.json index 1f706862ee1..c654c92969a 100644 --- a/homeassistant/components/pvpc_hourly_pricing/translations/hu.json +++ b/homeassistant/components/pvpc_hourly_pricing/translations/hu.json @@ -24,7 +24,7 @@ "power_p3": "Szerz\u0151d\u00f6tt teljes\u00edtm\u00e9ny P3 v\u00f6lgyid\u0151szakra (kW)", "tariff": "Alkalmazand\u00f3 tarifa f\u00f6ldrajzi z\u00f3n\u00e1k szerint" }, - "description": "Ez az \u00e9rz\u00e9kel\u0151 a hivatalos API-t haszn\u00e1lja a [villamos energia \u00f3r\u00e1nk\u00e9nti \u00e1raz\u00e1s\u00e1nak (PVPC)] (https://www.esios.ree.es/es/pvpc) megszerz\u00e9s\u00e9hez Spanyolorsz\u00e1gban.\n Pontosabb magyar\u00e1zat\u00e9rt keresse fel az [integr\u00e1ci\u00f3s dokumentumok] oldalt (https://www.home-assistant.io/integrations/pvpc_hourly_pricing/).", + "description": "Ez az \u00e9rz\u00e9kel\u0151 a hivatalos API-t haszn\u00e1lja a [villamos energia \u00f3r\u00e1nk\u00e9nti \u00e1raz\u00e1s\u00e1nak (PVPC)] (https://www.esios.ree.es/es/pvpc) megszerz\u00e9s\u00e9hez Spanyolorsz\u00e1gban.\nPontosabb magyar\u00e1zat\u00e9rt keresse fel az [integr\u00e1ci\u00f3s dokumentumok] oldalt (https://www.home-assistant.io/integrations/pvpc_hourly_pricing/).", "title": "\u00c9rz\u00e9kel\u0151 be\u00e1ll\u00edt\u00e1sa" } } diff --git a/homeassistant/components/pvpc_hourly_pricing/translations/id.json b/homeassistant/components/pvpc_hourly_pricing/translations/id.json index 8601c31fda0..9a8a18a7543 100644 --- a/homeassistant/components/pvpc_hourly_pricing/translations/id.json +++ b/homeassistant/components/pvpc_hourly_pricing/translations/id.json @@ -7,10 +7,10 @@ "user": { "data": { "name": "Nama Sensor", - "tariff": "Tarif kontrak (1, 2, atau 3 periode)" + "tariff": "Tarif yang berlaku menurut zona geografis" }, - "description": "Sensor ini menggunakan API resmi untuk mendapatkan [harga listrik per jam (PVPC)](https://www.esios.ree.es/es/pvpc) di Spanyol.\nUntuk penjelasan yang lebih tepat, kunjungi [dokumen integrasi](https://www.home-assistant.io/integrations/pvpc_hourly_pricing/).\n\nPilih tarif kontrak berdasarkan jumlah periode penagihan per hari:\n- 1 periode: normal\n- 2 periode: diskriminasi (tarif per malam)\n- 3 periode: mobil listrik (tarif per malam 3 periode)", - "title": "Pemilihan tarif" + "description": "Sensor ini menggunakan API resmi untuk mendapatkan [harga listrik per jam (PVPC)](https://www.esios.ree.es/es/pvpc) di Spanyol.\nUntuk penjelasan yang lebih tepat, kunjungi [dokumen integrasi](https://www.home-assistant.io/integrations/pvpc_hourly_pricing/).", + "title": "Penyiapan sensor" } } } diff --git a/homeassistant/components/rainforest_eagle/translations/es.json b/homeassistant/components/rainforest_eagle/translations/es.json index 53d9cb6f7c8..08649fda7ec 100644 --- a/homeassistant/components/rainforest_eagle/translations/es.json +++ b/homeassistant/components/rainforest_eagle/translations/es.json @@ -1,12 +1,17 @@ { "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado" + }, "error": { + "cannot_connect": "No se pudo conectar", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", "unknown": "Error inesperado" }, "step": { "user": { "data": { - "host": "Anfitri\u00f3n" + "host": "Host" } } } diff --git a/homeassistant/components/rainforest_eagle/translations/hu.json b/homeassistant/components/rainforest_eagle/translations/hu.json index 10f5a16cd23..c3d489c8eec 100644 --- a/homeassistant/components/rainforest_eagle/translations/hu.json +++ b/homeassistant/components/rainforest_eagle/translations/hu.json @@ -12,7 +12,7 @@ "user": { "data": { "cloud_id": "Cloud ID", - "host": "Gazdag\u00e9p", + "host": "C\u00edm", "install_code": "Telep\u00edt\u00e9si k\u00f3d" } } diff --git a/homeassistant/components/rainforest_eagle/translations/id.json b/homeassistant/components/rainforest_eagle/translations/id.json new file mode 100644 index 00000000000..80db8f3182d --- /dev/null +++ b/homeassistant/components/rainforest_eagle/translations/id.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi" + }, + "error": { + "cannot_connect": "Gagal terhubung", + "invalid_auth": "Autentikasi tidak valid", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "user": { + "data": { + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rainmachine/translations/hu.json b/homeassistant/components/rainmachine/translations/hu.json index 1ff7dc34b9c..c6120797a72 100644 --- a/homeassistant/components/rainmachine/translations/hu.json +++ b/homeassistant/components/rainmachine/translations/hu.json @@ -10,7 +10,7 @@ "step": { "user": { "data": { - "ip_address": "Hosztn\u00e9v vagy IP c\u00edm", + "ip_address": "C\u00edm", "password": "Jelsz\u00f3", "port": "Port" }, diff --git a/homeassistant/components/renault/translations/ca.json b/homeassistant/components/renault/translations/ca.json index 4aacab5cfc8..e16cb333acf 100644 --- a/homeassistant/components/renault/translations/ca.json +++ b/homeassistant/components/renault/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat", + "already_configured": "El compte ja est\u00e0 configurat", "kamereon_no_account": "No s'ha pogut trobar cap compte Kamereon", "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" }, diff --git a/homeassistant/components/renault/translations/cs.json b/homeassistant/components/renault/translations/cs.json index d731b4c2ec0..94f2bbd5773 100644 --- a/homeassistant/components/renault/translations/cs.json +++ b/homeassistant/components/renault/translations/cs.json @@ -1,12 +1,19 @@ { "config": { "abort": { - "already_configured": "\u00da\u010det je ji\u017e nastaven" + "already_configured": "\u00da\u010det je ji\u017e nastaven", + "reauth_successful": "Op\u011btovn\u00e9 ov\u011b\u0159en\u00ed bylo \u00fasp\u011b\u0161n\u00e9" }, "error": { "invalid_credentials": "Neplatn\u00e9 ov\u011b\u0159en\u00ed" }, "step": { + "reauth_confirm": { + "data": { + "password": "Heslo" + }, + "title": "Znovu ov\u011b\u0159it integraci" + }, "user": { "data": { "password": "Heslo", diff --git a/homeassistant/components/renault/translations/el.json b/homeassistant/components/renault/translations/el.json new file mode 100644 index 00000000000..4f29e856865 --- /dev/null +++ b/homeassistant/components/renault/translations/el.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "reauth_confirm": { + "description": "\u03a0\u03b1\u03c1\u03b1\u03ba\u03b1\u03bb\u03bf\u03cd\u03bc\u03b5 \u03b5\u03bd\u03b7\u03bc\u03b5\u03c1\u03ce\u03c3\u03c4\u03b5 \u03c4\u03bf\u03bd \u03ba\u03c9\u03b4\u03b9\u03ba\u03cc \u03c0\u03c1\u03cc\u03c3\u03b2\u03b1\u03c3\u03ae\u03c2 \u03c3\u03b1\u03c2 \u03b3\u03b9\u03b1 \u03c4\u03bf {username}" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/renault/translations/es.json b/homeassistant/components/renault/translations/es.json index 987377770dd..cf0f88983e0 100644 --- a/homeassistant/components/renault/translations/es.json +++ b/homeassistant/components/renault/translations/es.json @@ -1,11 +1,12 @@ { "config": { "abort": { - "already_configured": "La cuenta ya est\u00e1 configurada", - "kamereon_no_account": "No se pudo encontrar la cuenta de Kamereon." + "already_configured": "La cuenta ya ha sido configurada", + "kamereon_no_account": "No se pudo encontrar la cuenta de Kamereon.", + "reauth_successful": "La reautenticaci\u00f3n se realiz\u00f3 correctamente" }, "error": { - "invalid_credentials": "Autenticaci\u00f3n err\u00f3nea" + "invalid_credentials": "Autenticaci\u00f3n no v\u00e1lida" }, "step": { "kamereon": { @@ -14,11 +15,18 @@ }, "title": "Selecciona el id de la cuenta de Kamereon" }, + "reauth_confirm": { + "data": { + "password": "Contrase\u00f1a" + }, + "description": "Por favor, actualiza tu contrase\u00f1a para {username}", + "title": "Volver a autenticar la integraci\u00f3n" + }, "user": { "data": { "locale": "Configuraci\u00f3n regional", "password": "Contrase\u00f1a", - "username": "Correo-e" + "username": "Correo electr\u00f3nico" }, "title": "Establecer las credenciales de Renault" } diff --git a/homeassistant/components/renault/translations/hu.json b/homeassistant/components/renault/translations/hu.json index 9e63117a7c4..d74d8cdf9e4 100644 --- a/homeassistant/components/renault/translations/hu.json +++ b/homeassistant/components/renault/translations/hu.json @@ -19,7 +19,7 @@ "data": { "password": "Jelsz\u00f3" }, - "description": "K\u00e9rj\u00fck, friss\u00edtse a (z) {username} jelszav\u00e1t", + "description": "K\u00e9rj\u00fck, friss\u00edtse {username} jelszav\u00e1t", "title": "Integr\u00e1ci\u00f3 \u00fajb\u00f3li hiteles\u00edt\u00e9se" }, "user": { diff --git a/homeassistant/components/renault/translations/id.json b/homeassistant/components/renault/translations/id.json new file mode 100644 index 00000000000..e1b1f3fc893 --- /dev/null +++ b/homeassistant/components/renault/translations/id.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Akun sudah dikonfigurasi", + "reauth_successful": "Autentikasi ulang berhasil" + }, + "error": { + "invalid_credentials": "Autentikasi tidak valid" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "Kata Sandi" + }, + "description": "Perbarui kata sandi Anda untuk {username}", + "title": "Autentikasi Ulang Integrasi" + }, + "user": { + "data": { + "password": "Kata Sandi", + "username": "Email" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/renault/translations/nl.json b/homeassistant/components/renault/translations/nl.json index 1ca9e0ae32b..c2e02b03166 100644 --- a/homeassistant/components/renault/translations/nl.json +++ b/homeassistant/components/renault/translations/nl.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "Account is al geconfigureerd", - "kamereon_no_account": "Kan Kamereon-account niet vinden." + "kamereon_no_account": "Kan Kamereon-account niet vinden.", + "reauth_successful": "Opnieuw verifi\u00ebren is gelukt" }, "error": { "invalid_credentials": "Ongeldige authenticatie" @@ -15,7 +16,11 @@ "title": "Selecteer Kamereon-account-ID" }, "reauth_confirm": { - "description": "Werk uw wachtwoord voor {gebruikersnaam} bij" + "data": { + "password": "Wachtwoord" + }, + "description": "Werk uw wachtwoord voor {gebruikersnaam} bij", + "title": "Integratie opnieuw verifi\u00ebren" }, "user": { "data": { diff --git a/homeassistant/components/rfxtrx/translations/ca.json b/homeassistant/components/rfxtrx/translations/ca.json index d7db4107e3b..477bfa14608 100644 --- a/homeassistant/components/rfxtrx/translations/ca.json +++ b/homeassistant/components/rfxtrx/translations/ca.json @@ -23,7 +23,7 @@ }, "setup_serial_manual_path": { "data": { - "device": "Ruta del port USB del dispositiu" + "device": "Ruta del dispositiu USB" }, "title": "Ruta" }, @@ -35,6 +35,16 @@ } } }, + "device_automation": { + "action_type": { + "send_command": "Envia comanda: {subtype}", + "send_status": "Envia comanda d'estat: {subtype}" + }, + "trigger_type": { + "command": "Comanda rebuda: {subtype}", + "status": "Estat rebut: {subtype}" + } + }, "options": { "error": { "already_configured_device": "El dispositiu ja est\u00e0 configurat", diff --git a/homeassistant/components/rfxtrx/translations/de.json b/homeassistant/components/rfxtrx/translations/de.json index 7b006782d96..ee65e371330 100644 --- a/homeassistant/components/rfxtrx/translations/de.json +++ b/homeassistant/components/rfxtrx/translations/de.json @@ -35,6 +35,16 @@ } } }, + "device_automation": { + "action_type": { + "send_command": "Befehl senden: {subtype}", + "send_status": "Statusaktualisierung senden: {subtype}" + }, + "trigger_type": { + "command": "Empfangener Befehl: {subtype}", + "status": "Erhaltener Status: {subtype}" + } + }, "options": { "error": { "already_configured_device": "Ger\u00e4t ist bereits konfiguriert", diff --git a/homeassistant/components/rfxtrx/translations/en.json b/homeassistant/components/rfxtrx/translations/en.json index 69be3726865..2728c189010 100644 --- a/homeassistant/components/rfxtrx/translations/en.json +++ b/homeassistant/components/rfxtrx/translations/en.json @@ -35,6 +35,16 @@ } } }, + "device_automation": { + "action_type": { + "send_command": "Send command: {subtype}", + "send_status": "Send status update: {subtype}" + }, + "trigger_type": { + "command": "Received command: {subtype}", + "status": "Received status: {subtype}" + } + }, "options": { "error": { "already_configured_device": "Device is already configured", @@ -70,16 +80,5 @@ "title": "Configure device options" } } - }, - "device_automation": { - "action_type": { - "send_status": "Send status update: {subtype}", - "send_command": "Send command: {subtype}" - }, - "trigger_type": { - "status": "Received status: {subtype}", - "command": "Received command: {subtype}" - } - }, - "title": "Rfxtrx" -} + } +} \ No newline at end of file diff --git a/homeassistant/components/rfxtrx/translations/es.json b/homeassistant/components/rfxtrx/translations/es.json index c1c4d72735c..fa45fe8a777 100644 --- a/homeassistant/components/rfxtrx/translations/es.json +++ b/homeassistant/components/rfxtrx/translations/es.json @@ -35,6 +35,16 @@ } } }, + "device_automation": { + "action_type": { + "send_command": "Enviar comando: {subtype}", + "send_status": "Enviar actualizaci\u00f3n de estado: {subtype}" + }, + "trigger_type": { + "command": "Comando recibido: {subtype}", + "status": "Estado recibido: {subtype}" + } + }, "options": { "error": { "already_configured_device": "El dispositivo ya est\u00e1 configurado", diff --git a/homeassistant/components/rfxtrx/translations/et.json b/homeassistant/components/rfxtrx/translations/et.json index 662664b4454..1b414db656c 100644 --- a/homeassistant/components/rfxtrx/translations/et.json +++ b/homeassistant/components/rfxtrx/translations/et.json @@ -35,6 +35,16 @@ } } }, + "device_automation": { + "action_type": { + "send_command": "Saada k\u00e4sk: {subtype}", + "send_status": "Saada olekuv\u00e4rskendus: {subtype}" + }, + "trigger_type": { + "command": "Saabunud k\u00e4sk: {subtype}", + "status": "Saabunud olek: {subtype}" + } + }, "options": { "error": { "already_configured_device": "Seade on juba h\u00e4\u00e4lestatud", diff --git a/homeassistant/components/rfxtrx/translations/hu.json b/homeassistant/components/rfxtrx/translations/hu.json index 5b953c1260e..86242a4e973 100644 --- a/homeassistant/components/rfxtrx/translations/hu.json +++ b/homeassistant/components/rfxtrx/translations/hu.json @@ -14,7 +14,7 @@ "other": "\u00dcres", "setup_network": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "port": "Port" }, "title": "V\u00e1lassza ki a csatlakoz\u00e1si c\u00edmet" @@ -39,6 +39,16 @@ } } }, + "device_automation": { + "action_type": { + "send_command": "Parancs k\u00fcld\u00e9se: {subtype}", + "send_status": "\u00c1llapotfriss\u00edt\u00e9s k\u00fcld\u00e9se: {subtype}" + }, + "trigger_type": { + "command": "Be\u00e9rkezett parancs: {alt\u00edpus}", + "status": "Be\u00e9rkezett st\u00e1tusz: {subtype}" + } + }, "one": "\u00dcres", "options": { "error": { diff --git a/homeassistant/components/rfxtrx/translations/it.json b/homeassistant/components/rfxtrx/translations/it.json index 4d2ae4710e7..d5bb516b26b 100644 --- a/homeassistant/components/rfxtrx/translations/it.json +++ b/homeassistant/components/rfxtrx/translations/it.json @@ -39,6 +39,16 @@ } } }, + "device_automation": { + "action_type": { + "send_command": "Invia comando: {subtype}", + "send_status": "Invia aggiornamento di stato: {subtype}" + }, + "trigger_type": { + "command": "Comando ricevuto: {subtype}", + "status": "Stato ricevuto: {subtype}" + } + }, "one": "Pi\u00f9", "options": { "error": { diff --git a/homeassistant/components/rfxtrx/translations/nl.json b/homeassistant/components/rfxtrx/translations/nl.json index 1d22751ceed..92154861f15 100644 --- a/homeassistant/components/rfxtrx/translations/nl.json +++ b/homeassistant/components/rfxtrx/translations/nl.json @@ -35,6 +35,16 @@ } } }, + "device_automation": { + "action_type": { + "send_command": "Stuur commando: {subtype}", + "send_status": "Stuur status update: {subtype}" + }, + "trigger_type": { + "command": "Ontvangen commando: {subtype}", + "status": "Ontvangen status: {subtype}" + } + }, "options": { "error": { "already_configured_device": "Apparaat is al geconfigureerd", diff --git a/homeassistant/components/rfxtrx/translations/no.json b/homeassistant/components/rfxtrx/translations/no.json index 3eb9c9b83df..2f867554442 100644 --- a/homeassistant/components/rfxtrx/translations/no.json +++ b/homeassistant/components/rfxtrx/translations/no.json @@ -35,6 +35,16 @@ } } }, + "device_automation": { + "action_type": { + "send_command": "Send kommando: {subtype}", + "send_status": "Send statusoppdatering: {subtype}" + }, + "trigger_type": { + "command": "Mottatt kommando: {subtype}", + "status": "Mottatt status: {subtype}" + } + }, "options": { "error": { "already_configured_device": "Enheten er allerede konfigurert", diff --git a/homeassistant/components/rfxtrx/translations/ru.json b/homeassistant/components/rfxtrx/translations/ru.json index 5a635766d3f..4a56f37687a 100644 --- a/homeassistant/components/rfxtrx/translations/ru.json +++ b/homeassistant/components/rfxtrx/translations/ru.json @@ -35,6 +35,16 @@ } } }, + "device_automation": { + "action_type": { + "send_command": "\u041e\u0442\u043f\u0440\u0430\u0432\u0438\u0442\u044c \u043a\u043e\u043c\u0430\u043d\u0434\u0443: {subtype}", + "send_status": "\u041e\u0442\u043f\u0440\u0430\u0432\u0438\u0442\u044c \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435 \u0441\u0442\u0430\u0442\u0443\u0441\u0430: {subtype}" + }, + "trigger_type": { + "command": "\u041f\u043e\u043b\u0443\u0447\u0435\u043d\u043d\u0430\u044f \u043a\u043e\u043c\u0430\u043d\u0434\u0430: {subtype}", + "status": "\u041f\u043e\u043b\u0443\u0447\u0435\u043d\u043d\u044b\u0439 \u0441\u0442\u0430\u0442\u0443\u0441: {subtype}" + } + }, "options": { "error": { "already_configured_device": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant.", diff --git a/homeassistant/components/rfxtrx/translations/zh-Hant.json b/homeassistant/components/rfxtrx/translations/zh-Hant.json index fbbfeb5d6a0..ec763ece1de 100644 --- a/homeassistant/components/rfxtrx/translations/zh-Hant.json +++ b/homeassistant/components/rfxtrx/translations/zh-Hant.json @@ -35,6 +35,16 @@ } } }, + "device_automation": { + "action_type": { + "send_command": "\u50b3\u9001\u547d\u4ee4\uff1a{subtype}", + "send_status": "\u50b3\u9001\u72c0\u614b\u66f4\u65b0\uff1a{subtype}" + }, + "trigger_type": { + "command": "\u63a5\u6536\u547d\u4ee4\uff1a{subtype}", + "status": "\u63a5\u6536\u72c0\u614b\uff1a{subtype}" + } + }, "options": { "error": { "already_configured_device": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", diff --git a/homeassistant/components/risco/translations/hu.json b/homeassistant/components/risco/translations/hu.json index aaa7974cd4a..198c30d2b02 100644 --- a/homeassistant/components/risco/translations/hu.json +++ b/homeassistant/components/risco/translations/hu.json @@ -27,8 +27,8 @@ "armed_home": "\u00c9les\u00edtve (otthon)", "armed_night": "\u00c9les\u00edtve (\u00e9jszakai)" }, - "description": "V\u00e1lassza ki, hogy milyen \u00e1llapotba \u00e1ll\u00edtsa a Risco riaszt\u00e1st a Home Assistant riaszt\u00e1s \u00e9les\u00edt\u00e9sekor", - "title": "A Home Assistant \u00e1llapotok megjelen\u00edt\u00e9se Risco \u00e1llapotokba" + "description": "V\u00e1lassza ki, hogy milyen \u00e1llapotba \u00e1ll\u00edtsa a Risco riaszt\u00e1st Home Assistant riaszt\u00e1s \u00e9les\u00edt\u00e9sekor", + "title": "Home Assistant \u00e1llapotok megjelen\u00edt\u00e9se Risco \u00e1llapotokba" }, "init": { "data": { diff --git a/homeassistant/components/roku/translations/hu.json b/homeassistant/components/roku/translations/hu.json index b7aa12bfb4d..101931e0d21 100644 --- a/homeassistant/components/roku/translations/hu.json +++ b/homeassistant/components/roku/translations/hu.json @@ -2,20 +2,20 @@ "config": { "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van.", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s" }, - "flow_title": "Roku: {name}", + "flow_title": "{name}", "step": { "discovery_confirm": { "data": { "one": "Egy", "other": "Egy\u00e9b" }, - "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a(z) {name}-t?", + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani: {name}?", "title": "Roku" }, "ssdp_confirm": { @@ -23,12 +23,12 @@ "one": "\u00dcres", "other": "\u00dcres" }, - "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a {name}-t?", + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani: {name}?", "title": "Roku" }, "user": { "data": { - "host": "Hoszt" + "host": "C\u00edm" }, "description": "Adja meg Roku adatait." } diff --git a/homeassistant/components/roku/translations/id.json b/homeassistant/components/roku/translations/id.json index 0e60de9b61f..3a227e80eaf 100644 --- a/homeassistant/components/roku/translations/id.json +++ b/homeassistant/components/roku/translations/id.json @@ -8,7 +8,7 @@ "error": { "cannot_connect": "Gagal terhubung" }, - "flow_title": "Roku: {name}", + "flow_title": "{name}", "step": { "discovery_confirm": { "description": "Ingin menyiapkan {name}?", diff --git a/homeassistant/components/roomba/translations/es.json b/homeassistant/components/roomba/translations/es.json index c78b66bbb87..315c8bda096 100644 --- a/homeassistant/components/roomba/translations/es.json +++ b/homeassistant/components/roomba/translations/es.json @@ -34,7 +34,7 @@ "blid": "BLID", "host": "Host" }, - "description": "No se ha descubierto ning\u00fan dispositivo Roomba ni Braava en tu red. El BLID es la parte del nombre de host del dispositivo despu\u00e9s de 'iRobot-'. Por favor, sigue los pasos descritos en la documentaci\u00f3n en: {auth_help_url}", + "description": "No se ha descubierto ning\u00fan dispositivo Roomba ni Braava en tu red.", "title": "Conectar manualmente con el dispositivo" }, "user": { diff --git a/homeassistant/components/roomba/translations/hu.json b/homeassistant/components/roomba/translations/hu.json index 0d76ce920b2..34e36f55150 100644 --- a/homeassistant/components/roomba/translations/hu.json +++ b/homeassistant/components/roomba/translations/hu.json @@ -13,7 +13,7 @@ "step": { "init": { "data": { - "host": "Hoszt" + "host": "C\u00edm" }, "description": "V\u00e1lasszon egy Roomba vagy Braava k\u00e9sz\u00fcl\u00e9ket.", "title": "Automatikus csatlakoz\u00e1s az eszk\u00f6zh\u00f6z" @@ -32,9 +32,9 @@ "manual": { "data": { "blid": "BLID", - "host": "Hoszt" + "host": "C\u00edm" }, - "description": "A h\u00e1l\u00f3zaton egyetlen Roomba vagy Braava sem ker\u00fclt el\u0151. A BLID az eszk\u00f6z hostnev\u00e9nek az `iRobot-` vagy `Roomba -` ut\u00e1ni r\u00e9sze. K\u00e9rj\u00fck, k\u00f6vesse a dokument\u00e1ci\u00f3ban ismertetett l\u00e9p\u00e9seket: {auth_help_url}", + "description": "A h\u00e1l\u00f3zaton egyetlen Roomba vagy Braava sem ker\u00fclt el\u0151.", "title": "Manu\u00e1lis csatlakoz\u00e1s az eszk\u00f6zh\u00f6z" }, "user": { @@ -42,11 +42,11 @@ "blid": "BLID", "continuous": "Folyamatos", "delay": "K\u00e9sleltet\u00e9s", - "host": "Hoszt", + "host": "C\u00edm", "password": "Jelsz\u00f3" }, "description": "V\u00e1lasszon Roomba-t vagy Braava-t.", - "title": "Csatlakoz\u00e1s az eszk\u00f6zh\u00f6z" + "title": "Automatikus csatlakoz\u00e1s az eszk\u00f6zh\u00f6z" } } }, diff --git a/homeassistant/components/roomba/translations/id.json b/homeassistant/components/roomba/translations/id.json index aaffac267aa..1ade232fd70 100644 --- a/homeassistant/components/roomba/translations/id.json +++ b/homeassistant/components/roomba/translations/id.json @@ -9,7 +9,7 @@ "error": { "cannot_connect": "Gagal terhubung" }, - "flow_title": "iRobot {name} ({host})", + "flow_title": "{name} ({host})", "step": { "init": { "data": { @@ -19,7 +19,7 @@ "title": "Sambungkan secara otomatis ke perangkat" }, "link": { - "description": "Tekan dan tahan tombol Home pada {name} hingga perangkat mengeluarkan suara (sekitar dua detik).", + "description": "Tekan dan tahan tombol Home pada {name} hingga perangkat mengeluarkan suara (sekitar dua detik), lalu kirim dalam waktu 30 detik.", "title": "Ambil Kata Sandi" }, "link_manual": { @@ -34,7 +34,7 @@ "blid": "BLID", "host": "Host" }, - "description": "Tidak ada Roomba atau Braava yang ditemukan di jaringan Anda. BLID adalah bagian dari nama host perangkat setelah `iRobot-`. Ikuti langkah-langkah yang diuraikan dalam dokumentasi di: {auth_help_url}", + "description": "Tidak ada Roomba atau Braava yang ditemukan di jaringan Anda.", "title": "Hubungkan ke perangkat secara manual" }, "user": { @@ -45,8 +45,8 @@ "host": "Host", "password": "Kata Sandi" }, - "description": "Saat ini proses mengambil BLID dan kata sandi merupakan proses manual. Iikuti langkah-langkah yang diuraikan dalam dokumentasi di: https://www.home-assistant.io/integrations/roomba/#retrieving-your-credentials", - "title": "Hubungkan ke perangkat" + "description": "Pilih Roomba atau Braava.", + "title": "Sambungkan secara otomatis ke perangkat" } } }, diff --git a/homeassistant/components/roon/translations/hu.json b/homeassistant/components/roon/translations/hu.json index 56a8ade165c..09bad262c45 100644 --- a/homeassistant/components/roon/translations/hu.json +++ b/homeassistant/components/roon/translations/hu.json @@ -9,14 +9,14 @@ }, "step": { "link": { - "description": "Enged\u00e9lyeznie kell az HomeAssistantot a Roonban. Miut\u00e1n r\u00e1kattintott a K\u00fcld\u00e9s gombra, nyissa meg a Roon Core alkalmaz\u00e1st, nyissa meg a Be\u00e1ll\u00edt\u00e1sokat, \u00e9s enged\u00e9lyezze a HomeAssistant funkci\u00f3t a B\u0151v\u00edtm\u00e9nyek lapon.", - "title": "Enged\u00e9lyezze a HomeAssistant alkalmaz\u00e1st Roon-ban" + "description": "Enged\u00e9lyeznie kell az Home Assistant-ot a Roonban. Miut\u00e1n r\u00e1kattintott a K\u00fcld\u00e9s gombra, nyissa meg a Roon Core alkalmaz\u00e1st, nyissa meg a Be\u00e1ll\u00edt\u00e1sokat, \u00e9s enged\u00e9lyezze a Home Assistant funkci\u00f3t a B\u0151v\u00edtm\u00e9nyek lapon.", + "title": "Enged\u00e9lyezze a Home Assistant alkalmaz\u00e1st Roon-ban" }, "user": { "data": { - "host": "Hoszt" + "host": "C\u00edm" }, - "description": "Nem tal\u00e1lta a Roon szervert, adja meg a gazdag\u00e9p nev\u00e9t vagy IP-c\u00edm\u00e9t." + "description": "A Roon szerver nem tal\u00e1lhat\u00f3, adja meg a hosztnev\u00e9t vagy c\u00edm\u00e9t" } } } diff --git a/homeassistant/components/rpi_power/translations/hu.json b/homeassistant/components/rpi_power/translations/hu.json index feb1687037f..840ce725b8b 100644 --- a/homeassistant/components/rpi_power/translations/hu.json +++ b/homeassistant/components/rpi_power/translations/hu.json @@ -6,9 +6,9 @@ }, "step": { "confirm": { - "description": "El szeretn\u00e9d kezdeni a be\u00e1ll\u00edt\u00e1st?" + "description": "El szeretn\u00e9 kezdeni a be\u00e1ll\u00edt\u00e1st?" } } }, - "title": "Raspberry Pi Power Supply Checker" + "title": "Raspberry Pi t\u00e1pegys\u00e9g ellen\u0151rz\u0151" } \ No newline at end of file diff --git a/homeassistant/components/rpi_power/translations/nl.json b/homeassistant/components/rpi_power/translations/nl.json index 5529aa39f20..d9e42ef11f3 100644 --- a/homeassistant/components/rpi_power/translations/nl.json +++ b/homeassistant/components/rpi_power/translations/nl.json @@ -6,7 +6,7 @@ }, "step": { "confirm": { - "description": "Wil je beginnen met instellen?" + "description": "Wilt u beginnen met instellen?" } } }, diff --git a/homeassistant/components/ruckus_unleashed/translations/hu.json b/homeassistant/components/ruckus_unleashed/translations/hu.json index 0abcc301f0c..9590d3c12be 100644 --- a/homeassistant/components/ruckus_unleashed/translations/hu.json +++ b/homeassistant/components/ruckus_unleashed/translations/hu.json @@ -11,7 +11,7 @@ "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" } diff --git a/homeassistant/components/samsungtv/translations/bg.json b/homeassistant/components/samsungtv/translations/bg.json new file mode 100644 index 00000000000..c30e629d8ad --- /dev/null +++ b/homeassistant/components/samsungtv/translations/bg.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/samsungtv/translations/ca.json b/homeassistant/components/samsungtv/translations/ca.json index 64e2298d141..e701bdb1d92 100644 --- a/homeassistant/components/samsungtv/translations/ca.json +++ b/homeassistant/components/samsungtv/translations/ca.json @@ -6,6 +6,7 @@ "auth_missing": "Home Assistant no est\u00e0 autenticat per connectar-se amb aquest televisor Samsung. V\u00e9s a la configuraci\u00f3 de dispositius externs del televisor per autoritzar Home Assistant.", "cannot_connect": "Ha fallat la connexi\u00f3", "id_missing": "El dispositiu Samsung no t\u00e9 cap n\u00famero de s\u00e8rie.", + "missing_config_entry": "Aquest dispositiu Samsung no t\u00e9 cap entrada de configuraci\u00f3.", "not_supported": "Actualment aquest dispositiu Samsung no \u00e9s compatible.", "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament", "unknown": "Error inesperat" diff --git a/homeassistant/components/samsungtv/translations/de.json b/homeassistant/components/samsungtv/translations/de.json index f59004a5dab..ec5b791626a 100644 --- a/homeassistant/components/samsungtv/translations/de.json +++ b/homeassistant/components/samsungtv/translations/de.json @@ -6,6 +6,7 @@ "auth_missing": "Home Assistant ist nicht berechtigt, eine Verbindung zu diesem Samsung TV herzustellen. \u00dcberpr\u00fcfe den Ger\u00e4teverbindungsmanager in den Einstellungen deines Fernsehger\u00e4ts, um Home Assistant zu autorisieren.", "cannot_connect": "Verbindung fehlgeschlagen", "id_missing": "Dieses Samsung-Ger\u00e4t hat keine Seriennummer.", + "missing_config_entry": "Dieses Samsung-Ger\u00e4t hat keinen Konfigurationseintrag.", "not_supported": "Dieses Samsung TV-Ger\u00e4t wird derzeit nicht unterst\u00fctzt.", "reauth_successful": "Die erneute Authentifizierung war erfolgreich", "unknown": "Unerwarteter Fehler" diff --git a/homeassistant/components/samsungtv/translations/en.json b/homeassistant/components/samsungtv/translations/en.json index 8b48de950ee..4648f930e9b 100644 --- a/homeassistant/components/samsungtv/translations/en.json +++ b/homeassistant/components/samsungtv/translations/en.json @@ -6,6 +6,7 @@ "auth_missing": "Home Assistant is not authorized to connect to this Samsung TV. Check your TV's External Device Manager settings to authorize Home Assistant.", "cannot_connect": "Failed to connect", "id_missing": "This Samsung device doesn't have a SerialNumber.", + "missing_config_entry": "This Samsung device doesn't have a configuration entry.", "not_supported": "This Samsung device is currently not supported.", "reauth_successful": "Re-authentication was successful", "unknown": "Unexpected error" @@ -16,7 +17,8 @@ "flow_title": "{device}", "step": { "confirm": { - "description": "Do you want to set up {device}? If you never connected Home Assistant before you should see a popup on your TV asking for authorization." + "description": "Do you want to set up {device}? If you never connected Home Assistant before you should see a popup on your TV asking for authorization.", + "title": "Samsung TV" }, "reauth_confirm": { "description": "After submitting, accept the the popup on {device} requesting authorization within 30 seconds." diff --git a/homeassistant/components/samsungtv/translations/es.json b/homeassistant/components/samsungtv/translations/es.json index 0228ca3101f..42b0f794e7a 100644 --- a/homeassistant/components/samsungtv/translations/es.json +++ b/homeassistant/components/samsungtv/translations/es.json @@ -6,12 +6,18 @@ "auth_missing": "Home Assistant no est\u00e1 autorizado para conectarse a este televisor Samsung. Revisa la configuraci\u00f3n de tu televisor para autorizar a Home Assistant.", "cannot_connect": "No se pudo conectar", "id_missing": "Este dispositivo Samsung no tiene un n\u00famero de serie.", - "not_supported": "Esta televisi\u00f3n Samsung actualmente no es compatible." + "missing_config_entry": "Este dispositivo de Samsung no est\u00e1 configurado.", + "not_supported": "Esta televisi\u00f3n Samsung actualmente no es compatible.", + "reauth_successful": "La reautenticaci\u00f3n se realiz\u00f3 correctamente", + "unknown": "Error inesperado" }, - "flow_title": "Televisor Samsung: {model}", + "error": { + "auth_missing": "Home Assistant no est\u00e1 autorizado para conectarse a este televisor Samsung. Revisa la configuraci\u00f3n de tu televisor para autorizar a Home Assistant." + }, + "flow_title": "{device}", "step": { "confirm": { - "description": "\u00bfQuieres configurar la televisi\u00f3n Samsung {model}? Si nunca la has conectado a Home Assistant antes deber\u00edas ver una ventana en tu TV pidiendo autorizaci\u00f3n. Cualquier configuraci\u00f3n manual de esta TV se sobreescribir\u00e1.", + "description": "\u00bfQuieres configurar {device}? Si nunca la has conectado a Home Assistant antes deber\u00edas ver una ventana en tu TV pidiendo autorizaci\u00f3n.", "title": "Samsung TV" }, "reauth_confirm": { diff --git a/homeassistant/components/samsungtv/translations/et.json b/homeassistant/components/samsungtv/translations/et.json index 0cc9bf8ebcc..47360f4ed06 100644 --- a/homeassistant/components/samsungtv/translations/et.json +++ b/homeassistant/components/samsungtv/translations/et.json @@ -6,6 +6,7 @@ "auth_missing": "Home Assistantil pole selle Samsungi teleriga \u00fchenduse loomiseks luba. Home Assistanti autoriseerimiseks kontrolli oma teleri seadeid.", "cannot_connect": "\u00dchendamine nurjus", "id_missing": "Sellel Samsungi seadmel puudub seerianumber.", + "missing_config_entry": "Sellel Samsungi seadmel puudub seadekirje.", "not_supported": "Seda Samsungi seadet praegu ei toetata.", "reauth_successful": "Taastuvastamine \u00f5nnestus", "unknown": "Tundmatu t\u00f5rge" diff --git a/homeassistant/components/samsungtv/translations/hu.json b/homeassistant/components/samsungtv/translations/hu.json index f0aa85433a1..93c0b2bee6d 100644 --- a/homeassistant/components/samsungtv/translations/hu.json +++ b/homeassistant/components/samsungtv/translations/hu.json @@ -2,21 +2,22 @@ "config": { "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", - "auth_missing": "A Home Assistant nem jogosult csatlakozni ehhez a Samsung TV-hez. Ellen\u0151rizd a TV be\u00e1ll\u00edt\u00e1sait a Home Assistant enged\u00e9lyez\u00e9s\u00e9hez.", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van.", + "auth_missing": "Home Assistant nem jogosult csatlakozni ehhez a Samsung TV-hez. Ellen\u0151rizze a TV be\u00e1ll\u00edt\u00e1sait Home Assistant enged\u00e9lyez\u00e9s\u00e9hez.", "cannot_connect": "Sikertelen csatlakoz\u00e1s", "id_missing": "Ennek a Samsung eszk\u00f6znek nincs sorsz\u00e1ma.", + "missing_config_entry": "Ez a Samsung eszk\u00f6z nem rendelkezik konfigur\u00e1ci\u00f3s bejegyz\u00e9ssel.", "not_supported": "Ez a Samsung k\u00e9sz\u00fcl\u00e9k jelenleg nem t\u00e1mogatott.", "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "error": { - "auth_missing": "A Home Assistant nem jogosult csatlakozni ehhez a Samsung TV-hez. Ellen\u0151rizd a TV be\u00e1ll\u00edt\u00e1sait a Home Assistant enged\u00e9lyez\u00e9s\u00e9hez." + "auth_missing": "Home Assistant nem jogosult csatlakozni ehhez a Samsung TV-hez. Ellen\u0151rizze a TV be\u00e1ll\u00edt\u00e1sait Home Assistant enged\u00e9lyez\u00e9s\u00e9hez." }, "flow_title": "{device}", "step": { "confirm": { - "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a(z) {device} k\u00e9sz\u00fcl\u00e9ket? Ha kor\u00e1bban m\u00e9g csatlakoztattad a Home Assistantet, akkor meg kell jelennie egy felugr\u00f3 ablaknak a TV k\u00e9perny\u0151j\u00e9n, ami j\u00f3v\u00e1hagy\u00e1sra v\u00e1r.", + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani {device} k\u00e9sz\u00fcl\u00e9k\u00e9t? Ha kor\u00e1bban m\u00e9g sosem csatlakoztatta Home Assistant-hoz, akkor meg kell jelennie egy felugr\u00f3 ablaknak a TV k\u00e9perny\u0151j\u00e9n, ami j\u00f3v\u00e1hagy\u00e1sra v\u00e1r.", "title": "Samsung TV" }, "reauth_confirm": { @@ -24,7 +25,7 @@ }, "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "name": "N\u00e9v" }, "description": "\u00cdrd be a Samsung TV adatait. Ha m\u00e9g soha nem csatlakozott Home Assistant-hez, akkor meg kell jelennie egy felugr\u00f3 ablaknak a TV k\u00e9perny\u0151j\u00e9n, ahol hiteles\u00edt\u00e9st k\u00e9r." diff --git a/homeassistant/components/samsungtv/translations/id.json b/homeassistant/components/samsungtv/translations/id.json index 0b8bbe60150..0714af37146 100644 --- a/homeassistant/components/samsungtv/translations/id.json +++ b/homeassistant/components/samsungtv/translations/id.json @@ -3,21 +3,26 @@ "abort": { "already_configured": "Perangkat sudah dikonfigurasi", "already_in_progress": "Alur konfigurasi sedang berlangsung", - "auth_missing": "Home Assistant tidak diizinkan untuk tersambung ke TV Samsung ini. Periksa setelan TV Anda untuk mengotorisasi Home Assistant.", + "auth_missing": "Home Assistant tidak diizinkan untuk tersambung ke TV Samsung ini. Periksa Manajer Perangkat Eksternal TV Anda untuk mengotorisasi Home Assistant.", "cannot_connect": "Gagal terhubung", + "id_missing": "Perangkat Samsung ini tidak memiliki SerialNumber.", + "missing_config_entry": "Perangkat Samsung ini tidak memiliki entri konfigurasi.", "not_supported": "Perangkat TV Samsung ini saat ini tidak didukung.", "reauth_successful": "Autentikasi ulang berhasil", "unknown": "Kesalahan yang tidak diharapkan" }, "error": { - "auth_missing": "Home Assistant tidak diizinkan untuk tersambung ke TV Samsung ini. Periksa setelan TV Anda untuk mengotorisasi Home Assistant." + "auth_missing": "Home Assistant tidak diizinkan untuk tersambung ke TV Samsung ini. Periksa Manajer Perangkat Eksternal TV Anda untuk mengotorisasi Home Assistant." }, - "flow_title": "TV Samsung: {model}", + "flow_title": "{device}", "step": { "confirm": { - "description": "Apakah Anda ingin menyiapkan TV Samsung {model}? Jika Anda belum pernah menyambungkan Home Assistant sebelumnya, Anda akan melihat dialog di TV yang meminta otorisasi. Konfigurasi manual untuk TV ini akan ditimpa.", + "description": "Apakah Anda ingin menyiapkan {device}? Jika Anda belum pernah menyambungkan Home Assistant sebelumnya, Anda akan melihat dialog di TV yang meminta otorisasi.", "title": "TV Samsung" }, + "reauth_confirm": { + "description": "Setelah mengirimkan, setujui pada popup di {device} yang meminta otorisasi dalam waktu 30 detik." + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/samsungtv/translations/it.json b/homeassistant/components/samsungtv/translations/it.json index ee1219305d7..51f9b4e2ef9 100644 --- a/homeassistant/components/samsungtv/translations/it.json +++ b/homeassistant/components/samsungtv/translations/it.json @@ -6,6 +6,7 @@ "auth_missing": "Home Assistant non \u00e8 autorizzato a connettersi a questo televisore Samsung. Controlla le impostazioni di Gestione dispositivi esterni della tua TV per autorizzare Home Assistant.", "cannot_connect": "Impossibile connettersi", "id_missing": "Questo dispositivo Samsung non ha un SerialNumber.", + "missing_config_entry": "Questo dispositivo Samsung non ha una voce di configurazione.", "not_supported": "Questo dispositivo Samsung non \u00e8 attualmente supportato.", "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente", "unknown": "Errore imprevisto" diff --git a/homeassistant/components/samsungtv/translations/nl.json b/homeassistant/components/samsungtv/translations/nl.json index b4478994e1c..692c70642d6 100644 --- a/homeassistant/components/samsungtv/translations/nl.json +++ b/homeassistant/components/samsungtv/translations/nl.json @@ -6,6 +6,7 @@ "auth_missing": "Home Assistant is niet gemachtigd om verbinding te maken met deze Samsung TV. Controleer de instellingen van Extern apparaatbeheer van uw tv om Home Assistant te machtigen.", "cannot_connect": "Kan geen verbinding maken", "id_missing": "Dit Samsung-apparaat heeft geen serienummer.", + "missing_config_entry": "Dit Samsung-apparaat heeft geen configuratie-invoer.", "not_supported": "Deze Samsung TV wordt momenteel niet ondersteund.", "reauth_successful": "Herauthenticatie was succesvol", "unknown": "Onverwachte fout" diff --git a/homeassistant/components/samsungtv/translations/no.json b/homeassistant/components/samsungtv/translations/no.json index 6da9787d3f6..7b7108cbf77 100644 --- a/homeassistant/components/samsungtv/translations/no.json +++ b/homeassistant/components/samsungtv/translations/no.json @@ -6,6 +6,7 @@ "auth_missing": "Home Assistant er ikke autorisert til \u00e5 koble til denne Samsung TV-en. Sjekk TV-ens innstillinger for ekstern enhetsbehandling for \u00e5 autorisere Home Assistant.", "cannot_connect": "Tilkobling mislyktes", "id_missing": "Denne Samsung-enheten har ikke serienummer.", + "missing_config_entry": "Denne Samsung -enheten har ingen konfigurasjonsoppf\u00f8ring.", "not_supported": "Denne Samsung-enheten st\u00f8ttes forel\u00f8pig ikke.", "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket", "unknown": "Uventet feil" diff --git a/homeassistant/components/samsungtv/translations/ru.json b/homeassistant/components/samsungtv/translations/ru.json index 7d4c24aba45..111b30c5488 100644 --- a/homeassistant/components/samsungtv/translations/ru.json +++ b/homeassistant/components/samsungtv/translations/ru.json @@ -6,6 +6,7 @@ "auth_missing": "Home Assistant \u043d\u0435 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u043e\u0432\u0430\u043d \u0434\u043b\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f \u043a \u044d\u0442\u043e\u043c\u0443 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443 Samsung TV. \u041f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 External Device Manager \u0412\u0430\u0448\u0435\u0433\u043e \u0442\u0435\u043b\u0435\u0432\u0438\u0437\u043e\u0440\u0430.", "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", "id_missing": "\u0423 \u044d\u0442\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 Samsung \u043d\u0435\u0442 \u0441\u0435\u0440\u0438\u0439\u043d\u043e\u0433\u043e \u043d\u043e\u043c\u0435\u0440\u0430.", + "missing_config_entry": "\u041e\u0442\u0441\u0443\u0442\u0441\u0442\u0432\u0443\u0435\u0442 \u0437\u0430\u043f\u0438\u0441\u044c \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 \u0434\u043b\u044f \u044d\u0442\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 Samsung.", "not_supported": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e Samsung \u0432 \u043d\u0430\u0441\u0442\u043e\u044f\u0449\u0435\u0435 \u0432\u0440\u0435\u043c\u044f \u043d\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442\u0441\u044f.", "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430 \u0443\u0441\u043f\u0435\u0448\u043d\u043e.", "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." diff --git a/homeassistant/components/samsungtv/translations/zh-Hant.json b/homeassistant/components/samsungtv/translations/zh-Hant.json index 950a460965b..ba828665cea 100644 --- a/homeassistant/components/samsungtv/translations/zh-Hant.json +++ b/homeassistant/components/samsungtv/translations/zh-Hant.json @@ -6,6 +6,7 @@ "auth_missing": "Home Assistant \u672a\u7372\u5f97\u9a57\u8b49\u4ee5\u9023\u7dda\u81f3\u6b64\u4e09\u661f\u96fb\u8996\u3002\u8acb\u6aa2\u67e5\u60a8\u7684\u96fb\u8996\u5916\u90e8\u88dd\u7f6e\u7ba1\u7406\u54e1\u8a2d\u5b9a\u4ee5\u9032\u884c\u9a57\u8b49\u3002", "cannot_connect": "\u9023\u7dda\u5931\u6557", "id_missing": "\u4e09\u661f\u88dd\u7f6e\u4e26\u672a\u5305\u542b\u5e8f\u865f\u3002", + "missing_config_entry": "\u6b64\u4e09\u661f\u88dd\u7f6e\u4e26\u672a\u5305\u542b\u8a2d\u5b9a\u3002", "not_supported": "\u4e0d\u652f\u63f4\u6b64\u6b3e\u4e09\u661f\u88dd\u7f6e\u3002", "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f", "unknown": "\u672a\u9810\u671f\u932f\u8aa4" diff --git a/homeassistant/components/screenlogic/translations/hu.json b/homeassistant/components/screenlogic/translations/hu.json index 3efb8ec6e60..6781f477ab6 100644 --- a/homeassistant/components/screenlogic/translations/hu.json +++ b/homeassistant/components/screenlogic/translations/hu.json @@ -13,7 +13,7 @@ "ip_address": "IP c\u00edm", "port": "Port" }, - "description": "Add meg a ScreenLogic Gateway adatait.", + "description": "Adja meg a ScreenLogic Gateway adatait.", "title": "ScreenLogic" }, "gateway_select": { diff --git a/homeassistant/components/screenlogic/translations/id.json b/homeassistant/components/screenlogic/translations/id.json index 5af1cfbe5ef..b0052f6f0f7 100644 --- a/homeassistant/components/screenlogic/translations/id.json +++ b/homeassistant/components/screenlogic/translations/id.json @@ -6,7 +6,7 @@ "error": { "cannot_connect": "Gagal terhubung" }, - "flow_title": "ScreenLogic {name}", + "flow_title": "{name}", "step": { "gateway_entry": { "data": { diff --git a/homeassistant/components/sensor/translations/el.json b/homeassistant/components/sensor/translations/el.json index ed9e92cd4b1..ff8750f52dc 100644 --- a/homeassistant/components/sensor/translations/el.json +++ b/homeassistant/components/sensor/translations/el.json @@ -3,7 +3,8 @@ "condition_type": { "is_gas": "\u03a4\u03c1\u03ad\u03c7\u03bf\u03bd \u03b1\u03ad\u03c1\u03b9\u03bf {entity_name}", "is_pm25": "\u03a4\u03c1\u03ad\u03c7\u03bf\u03bd \u03b5\u03c0\u03af\u03c0\u03b5\u03b4\u03bf \u03c3\u03c5\u03b3\u03ba\u03ad\u03bd\u03c4\u03c1\u03c9\u03c3\u03b7\u03c2 {entity_name} PM2.5", - "is_sulphur_dioxide": "\u03a4\u03c1\u03ad\u03c7\u03bf\u03bd \u03b5\u03c0\u03af\u03c0\u03b5\u03b4\u03bf \u03c3\u03c5\u03b3\u03ba\u03ad\u03bd\u03c4\u03c1\u03c9\u03c3\u03b7\u03c2 \u03b4\u03b9\u03bf\u03be\u03b5\u03b9\u03b4\u03af\u03bf\u03c5 \u03c4\u03bf\u03c5 \u03b8\u03b5\u03af\u03bf\u03c5 {entity_name}" + "is_sulphur_dioxide": "\u03a4\u03c1\u03ad\u03c7\u03bf\u03bd \u03b5\u03c0\u03af\u03c0\u03b5\u03b4\u03bf \u03c3\u03c5\u03b3\u03ba\u03ad\u03bd\u03c4\u03c1\u03c9\u03c3\u03b7\u03c2 \u03b4\u03b9\u03bf\u03be\u03b5\u03b9\u03b4\u03af\u03bf\u03c5 \u03c4\u03bf\u03c5 \u03b8\u03b5\u03af\u03bf\u03c5 {entity_name}", + "is_volatile_organic_compounds": "\u03a4\u03c1\u03ad\u03c7\u03bf\u03bd \u03b5\u03c0\u03af\u03c0\u03b5\u03b4\u03bf \u03c3\u03c5\u03b3\u03ba\u03ad\u03bd\u03c4\u03c1\u03c9\u03c3\u03b7\u03c2 \u03c0\u03c4\u03b7\u03c4\u03b9\u03ba\u03ce\u03bd \u03bf\u03c1\u03b3\u03b1\u03bd\u03b9\u03ba\u03ce\u03bd \u03b5\u03bd\u03ce\u03c3\u03b5\u03c9\u03bd {entity_name}" }, "trigger_type": { "gas": "{entity_name} \u03bc\u03b5\u03c4\u03b1\u03b2\u03bf\u03bb\u03ad\u03c2 \u03b1\u03b5\u03c1\u03af\u03bf\u03c5", @@ -14,7 +15,8 @@ "pm1": "{entity_name} \u03bc\u03b5\u03c4\u03b1\u03b2\u03bf\u03bb\u03ad\u03c2 \u03c3\u03c5\u03b3\u03ba\u03ad\u03bd\u03c4\u03c1\u03c9\u03c3\u03b7\u03c2 PM1", "pm10": "{entity_name} \u03bc\u03b5\u03c4\u03b1\u03b2\u03bf\u03bb\u03ad\u03c2 \u03c3\u03c5\u03b3\u03ba\u03ad\u03bd\u03c4\u03c1\u03c9\u03c3\u03b7\u03c2 PM10", "pm25": "{entity_name} \u03bc\u03b5\u03c4\u03b1\u03b2\u03bf\u03bb\u03ad\u03c2 \u03c3\u03c5\u03b3\u03ba\u03ad\u03bd\u03c4\u03c1\u03c9\u03c3\u03b7\u03c2 PM2.5", - "sulphur_dioxide": "{entity_name} \u03bc\u03b5\u03c4\u03b1\u03b2\u03bf\u03bb\u03ad\u03c2 \u03c3\u03c4\u03b7 \u03c3\u03c5\u03b3\u03ba\u03ad\u03bd\u03c4\u03c1\u03c9\u03c3\u03b7\u03c2 \u03b4\u03b9\u03bf\u03be\u03b5\u03b9\u03b4\u03af\u03bf\u03c5 \u03c4\u03bf\u03c5 \u03b8\u03b5\u03af\u03bf\u03c5" + "sulphur_dioxide": "{entity_name} \u03bc\u03b5\u03c4\u03b1\u03b2\u03bf\u03bb\u03ad\u03c2 \u03c3\u03c4\u03b7 \u03c3\u03c5\u03b3\u03ba\u03ad\u03bd\u03c4\u03c1\u03c9\u03c3\u03b7\u03c2 \u03b4\u03b9\u03bf\u03be\u03b5\u03b9\u03b4\u03af\u03bf\u03c5 \u03c4\u03bf\u03c5 \u03b8\u03b5\u03af\u03bf\u03c5", + "volatile_organic_compounds": "\u0391\u03bb\u03bb\u03b1\u03b3\u03ad\u03c2 \u03c3\u03c4\u03b7 \u03c3\u03c5\u03b3\u03ba\u03ad\u03bd\u03c4\u03c1\u03c9\u03c3\u03b7 \u03c4\u03c9\u03bd \u03c0\u03c4\u03b7\u03c4\u03b9\u03ba\u03ce\u03bd \u03bf\u03c1\u03b3\u03b1\u03bd\u03b9\u03ba\u03ce\u03bd \u03b5\u03bd\u03ce\u03c3\u03b5\u03c9\u03bd {entity_name}" } }, "state": { diff --git a/homeassistant/components/sensor/translations/id.json b/homeassistant/components/sensor/translations/id.json index 9af162d1357..cea3f890430 100644 --- a/homeassistant/components/sensor/translations/id.json +++ b/homeassistant/components/sensor/translations/id.json @@ -6,14 +6,24 @@ "is_carbon_monoxide": "Level konsentasi karbonmonoksida {entity_name} saat ini", "is_current": "Arus {entity_name} saat ini", "is_energy": "Energi {entity_name} saat ini", + "is_gas": "Gas {entity_name} saat ini", "is_humidity": "Kelembaban {entity_name} saat ini", "is_illuminance": "Pencahayaan {entity_name} saat ini", + "is_nitrogen_dioxide": "Tingkat konsentrasi nitrogen dioksida {entity_name} saat ini", + "is_nitrogen_monoxide": "Tingkat konsentrasi nitrogen monoksida {entity_name} saat ini", + "is_nitrous_oxide": "Tingkat konsentrasi nitrit oksida {entity_name} saat ini", + "is_ozone": "Tingkat konsentrasi ozon {entity_name} saat ini", + "is_pm1": "Tingkat konsentrasi PM1 {entity_name} saat ini", + "is_pm10": "Tingkat konsentrasi PM10 {entity_name} saat ini", + "is_pm25": "Tingkat konsentrasi PM2.5 {entity_name} saat ini", "is_power": "Daya {entity_name} saat ini", "is_power_factor": "Faktor daya {entity_name} saat ini", "is_pressure": "Tekanan {entity_name} saat ini", "is_signal_strength": "Kekuatan sinyal {entity_name} saat ini", + "is_sulphur_dioxide": "Tingkat konsentrasi sulfur dioksida {entity_name} saat ini", "is_temperature": "Suhu {entity_name} saat ini", "is_value": "Nilai {entity_name} saat ini", + "is_volatile_organic_compounds": "Tingkat konsentrasi senyawa organik volatil {entity_name} saat ini", "is_voltage": "Tegangan {entity_name} saat ini" }, "trigger_type": { @@ -22,14 +32,24 @@ "carbon_monoxide": "Perubahan konsentrasi karbonmonoksida {entity_name}", "current": "Perubahan arus {entity_name}", "energy": "Perubahan energi {entity_name}", + "gas": "Perubahan gas {entity_name}", "humidity": "Perubahan kelembaban {entity_name}", "illuminance": "Perubahan pencahayaan {entity_name}", + "nitrogen_dioxide": "Perubahan konsentrasi nitrogen dioksida {entity_name}", + "nitrogen_monoxide": "Perubahan konsentrasi nitrogen monoksida {entity_name}", + "nitrous_oxide": "Perubahan konsentrasi nitro oksida {entity_name}", + "ozone": "Perubahan konsentrasi ozon {entity_name}", + "pm1": "Perubahan konsentrasi PM1 {entity_name}", + "pm10": "Perubahan konsentrasi PM10 {entity_name}", + "pm25": "Perubahan konsentrasi PM2.5 {entity_name}", "power": "Perubahan daya {entity_name}", "power_factor": "Perubahan faktor daya {entity_name}", "pressure": "Perubahan tekanan {entity_name}", "signal_strength": "Perubahan kekuatan sinyal {entity_name}", + "sulphur_dioxide": "Perubahan konsentrasi sulfur dioksida {entity_name}", "temperature": "Perubahan suhu {entity_name}", "value": "Perubahan nilai {entity_name}", + "volatile_organic_compounds": "Perubahan konsentrasi senyawa organik volatil {entity_name}", "voltage": "Perubahan tegangan {entity_name}" } }, diff --git a/homeassistant/components/sentry/translations/hu.json b/homeassistant/components/sentry/translations/hu.json index df07c41449e..9c28d57eb5d 100644 --- a/homeassistant/components/sentry/translations/hu.json +++ b/homeassistant/components/sentry/translations/hu.json @@ -12,7 +12,7 @@ "data": { "dsn": "DSN" }, - "description": "Add meg a Sentry DSN-t", + "description": "Adja meg a Sentry DSN-t", "title": "Sentry" } } diff --git a/homeassistant/components/sharkiq/translations/ca.json b/homeassistant/components/sharkiq/translations/ca.json index 9ae6a703835..70402446062 100644 --- a/homeassistant/components/sharkiq/translations/ca.json +++ b/homeassistant/components/sharkiq/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat", + "already_configured": "El compte ja est\u00e0 configurat", "cannot_connect": "Ha fallat la connexi\u00f3", "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament", "unknown": "Error inesperat" diff --git a/homeassistant/components/shelly/translations/ca.json b/homeassistant/components/shelly/translations/ca.json index 13cc79ac3d8..c485d955ff2 100644 --- a/homeassistant/components/shelly/translations/ca.json +++ b/homeassistant/components/shelly/translations/ca.json @@ -33,14 +33,20 @@ "button": "Bot\u00f3", "button1": "Primer bot\u00f3", "button2": "Segon bot\u00f3", - "button3": "Tercer bot\u00f3" + "button3": "Tercer bot\u00f3", + "button4": "Quart bot\u00f3" }, "trigger_type": { + "btn_down": "Bot\u00f3 {subtype} avall", + "btn_up": "Bot\u00f3 {subtype} amunt", "double": "{subtype} clicat dues vegades", + "double_push": "{subtype} clicat dues vegades", "long": "{subtype} clicat durant una estona", + "long_push": "{subtype} clicat durant una estona", "long_single": "{subtype} clicat durant una estona i despr\u00e9s r\u00e0pid", "single": "{subtype} clicat una vegada", "single_long": "{subtype} clicat r\u00e0pid i, despr\u00e9s, durant una estona", + "single_push": "{subtype} clicat una vegada", "triple": "{subtype} clicat tres vegades" } } diff --git a/homeassistant/components/shelly/translations/cs.json b/homeassistant/components/shelly/translations/cs.json index afdfe7c8f56..e3f1215d6f2 100644 --- a/homeassistant/components/shelly/translations/cs.json +++ b/homeassistant/components/shelly/translations/cs.json @@ -33,7 +33,8 @@ "button": "Tla\u010d\u00edtko", "button1": "Prvn\u00ed tla\u010d\u00edtko", "button2": "Druh\u00e9 tla\u010d\u00edtko", - "button3": "T\u0159et\u00ed tla\u010d\u00edtko" + "button3": "T\u0159et\u00ed tla\u010d\u00edtko", + "button4": "\u010ctvrt\u00e9 tla\u010d\u00edtko" }, "trigger_type": { "double": "\"{subtype}\" stisknuto dvakr\u00e1t", diff --git a/homeassistant/components/shelly/translations/de.json b/homeassistant/components/shelly/translations/de.json index 513ff66dff1..3a943507284 100644 --- a/homeassistant/components/shelly/translations/de.json +++ b/homeassistant/components/shelly/translations/de.json @@ -33,14 +33,20 @@ "button": "Taste", "button1": "Erste Taste", "button2": "Zweite Taste", - "button3": "Dritte Taste" + "button3": "Dritte Taste", + "button4": "Vierte Taste" }, "trigger_type": { + "btn_down": "{subtype} Taste nach unten", + "btn_up": "{subtype} Taste nach oben", "double": "{subtype} zweifach bet\u00e4tigt", + "double_push": "{subtype} Doppelter Push", "long": "{subtype} gehalten", + "long_push": "{subtype} langer Push", "long_single": "{subtype} gehalten und dann einfach bet\u00e4tigt", "single": "{subtype} einfach bet\u00e4tigt", "single_long": "{subtype} einfach bet\u00e4tigt und dann gehalten", + "single_push": "{subtype} einzelner Push", "triple": "{subtype} dreifach bet\u00e4tigt" } } diff --git a/homeassistant/components/shelly/translations/en.json b/homeassistant/components/shelly/translations/en.json index 2ed09356363..b48eb630024 100644 --- a/homeassistant/components/shelly/translations/en.json +++ b/homeassistant/components/shelly/translations/en.json @@ -37,17 +37,17 @@ "button4": "Fourth button" }, "trigger_type": { + "btn_down": "{subtype} button down", + "btn_up": "{subtype} button up", "double": "{subtype} double clicked", + "double_push": "{subtype} double push", "long": " {subtype} long clicked", + "long_push": " {subtype} long push", "long_single": "{subtype} long clicked and then single clicked", "single": "{subtype} single clicked", "single_long": "{subtype} single clicked and then long clicked", - "triple": "{subtype} triple clicked", - "btn_down": "{subtype} button down", - "btn_up": "{subtype} button up", "single_push": "{subtype} single push", - "double_push": "{subtype} double push", - "long_push": " {subtype} long push" + "triple": "{subtype} triple clicked" } } } \ No newline at end of file diff --git a/homeassistant/components/shelly/translations/es.json b/homeassistant/components/shelly/translations/es.json index 09cc3f51378..6f5c86417d4 100644 --- a/homeassistant/components/shelly/translations/es.json +++ b/homeassistant/components/shelly/translations/es.json @@ -33,14 +33,20 @@ "button": "Bot\u00f3n", "button1": "Primer bot\u00f3n", "button2": "Segundo bot\u00f3n", - "button3": "Tercer bot\u00f3n" + "button3": "Tercer bot\u00f3n", + "button4": "Cuarto bot\u00f3n" }, "trigger_type": { + "btn_down": "Bot\u00f3n {subtype} pulsado", + "btn_up": "Bot\u00f3n {subtype} soltado", "double": "Pulsaci\u00f3n doble de {subtype}", + "double_push": "Pulsaci\u00f3n doble de {subtype}", "long": "Pulsaci\u00f3n larga de {subtype}", + "long_push": "Pulsaci\u00f3n larga de {subtype}", "long_single": "Pulsaci\u00f3n larga de {subtype} seguida de una pulsaci\u00f3n simple", "single": "Pulsaci\u00f3n simple de {subtype}", "single_long": "Pulsaci\u00f3n simple de {subtype} seguida de una pulsaci\u00f3n larga", + "single_push": "Pulsaci\u00f3n simple de {subtype}", "triple": "Pulsaci\u00f3n triple de {subtype}" } } diff --git a/homeassistant/components/shelly/translations/et.json b/homeassistant/components/shelly/translations/et.json index 7059ce6b3d3..7db0eaad4ac 100644 --- a/homeassistant/components/shelly/translations/et.json +++ b/homeassistant/components/shelly/translations/et.json @@ -33,14 +33,20 @@ "button": "Nupp", "button1": "Esimene nupp", "button2": "Teine nupp", - "button3": "Kolmas nupp" + "button3": "Kolmas nupp", + "button4": "Neljas nupp" }, "trigger_type": { + "btn_down": "{subtype} nupp vajutatud", + "btn_up": "{subtype} nupp vabastatud", "double": "Nuppu {subtype} topeltkl\u00f5psati", + "double_push": "{subtype} topeltkl\u00f5ps", "long": "Nuppu \"{subtype}\" hoiti all", + "long_push": "{subtype} pikk vajutus", "long_single": "Nuppu {subtype} hoiti all ja seej\u00e4rel kl\u00f5psati", "single": "Nuppu {subtype} kl\u00f5psati", "single_long": "Nuppu {subtype} kl\u00f5psati \u00fcks kord ja seej\u00e4rel hoiti all", + "single_push": "{subtype} l\u00fchike vajutus", "triple": "Nuppu {subtype} kl\u00f5psati kolm korda" } } diff --git a/homeassistant/components/shelly/translations/he.json b/homeassistant/components/shelly/translations/he.json index a27b19c08e7..5eb76e4b55e 100644 --- a/homeassistant/components/shelly/translations/he.json +++ b/homeassistant/components/shelly/translations/he.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "unsupported_firmware": "\u05d4\u05d4\u05ea\u05e7\u05df \u05de\u05e9\u05ea\u05de\u05e9 \u05d1\u05d2\u05d9\u05e8\u05e1\u05ea \u05e7\u05d5\u05e9\u05d7\u05d4 \u05e9\u05d0\u05d9\u05e0\u05d4 \u05e0\u05ea\u05de\u05db\u05ea." }, "error": { "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", @@ -22,8 +23,31 @@ "user": { "data": { "host": "\u05de\u05d0\u05e8\u05d7" - } + }, + "description": "\u05dc\u05e4\u05e0\u05d9 \u05d4\u05d4\u05ea\u05e7\u05e0\u05d4, \u05d9\u05e9 \u05dc\u05d4\u05e2\u05d9\u05e8 \u05d4\u05ea\u05e7\u05e0\u05d9\u05dd \u05d4\u05de\u05d5\u05e4\u05e2\u05dc\u05d9\u05dd \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea \u05e1\u05d5\u05dc\u05dc\u05d4, \u05db\u05e2\u05ea \u05d1\u05d0\u05e4\u05e9\u05e8\u05d5\u05ea\u05da \u05dc\u05d4\u05e2\u05d9\u05e8 \u05d0\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea \u05dc\u05d7\u05d9\u05e6\u05d4 \u05e2\u05dc\u05d9\u05d5." } } + }, + "device_automation": { + "trigger_subtype": { + "button": "\u05dc\u05d7\u05e6\u05df", + "button1": "\u05dc\u05d7\u05e6\u05df \u05e8\u05d0\u05e9\u05d5\u05df", + "button2": "\u05dc\u05d7\u05e6\u05df \u05e9\u05e0\u05d9", + "button3": "\u05dc\u05d7\u05e6\u05df \u05e9\u05dc\u05d9\u05e9\u05d9", + "button4": "\u05dc\u05d7\u05e6\u05df \u05e8\u05d1\u05d9\u05e2\u05d9" + }, + "trigger_type": { + "btn_down": "{subtype} \u05dc\u05d7\u05e6\u05df \u05de\u05d8\u05d4", + "btn_up": "{subtype} \u05dc\u05d7\u05e6\u05df\u05df \u05de\u05e2\u05dc\u05d4", + "double": "{subtype} \u05dc\u05d7\u05d9\u05e6\u05d4 \u05db\u05e4\u05d5\u05dc\u05d4", + "double_push": "{subtype} \u05dc\u05d7\u05d9\u05e6\u05d4 \u05db\u05e4\u05d5\u05dc\u05d4", + "long": "{subtype} \u05dc\u05d7\u05d9\u05e6\u05d4 \u05de\u05de\u05d5\u05e9\u05db\u05ea", + "long_push": "{subtype} \u05dc\u05d7\u05d9\u05e6\u05d4 \u05de\u05de\u05d5\u05e9\u05db\u05ea", + "long_single": "{subtype} \u05dc\u05d7\u05d9\u05e6\u05d4 \u05de\u05de\u05d5\u05e9\u05db\u05ea \u05d5\u05dc\u05d0\u05d7\u05e8 \u05de\u05db\u05df \u05dc\u05d7\u05d9\u05e6\u05d4 \u05d1\u05d5\u05d3\u05d3\u05ea", + "single": "{subtype} \u05dc\u05d7\u05d9\u05e6\u05d4 \u05d1\u05d5\u05d3\u05d3\u05ea", + "single_long": "{subtype} \u05dc\u05d7\u05d9\u05e6\u05d4 \u05d1\u05d5\u05d3\u05d3\u05ea \u05d5\u05dc\u05d0\u05d7\u05e8 \u05de\u05db\u05df \u05dc\u05d7\u05d9\u05e6\u05d4 \u05de\u05de\u05d5\u05e9\u05db\u05ea", + "single_push": "{subtype} \u05dc\u05d7\u05d9\u05e6\u05d4 \u05d1\u05d5\u05d3\u05d3\u05ea", + "triple": "{subtype} \u05dc\u05d7\u05d9\u05e6\u05d4 \u05de\u05e9\u05d5\u05dc\u05e9\u05ea" + } } } \ No newline at end of file diff --git a/homeassistant/components/shelly/translations/hu.json b/homeassistant/components/shelly/translations/hu.json index 9388e26515a..bfaf591d7c2 100644 --- a/homeassistant/components/shelly/translations/hu.json +++ b/homeassistant/components/shelly/translations/hu.json @@ -12,7 +12,7 @@ "flow_title": "{name}", "step": { "confirm_discovery": { - "description": "Szeretn\u00e9 be\u00e1ll\u00edtani a(z) {model} a(z) {host} c\u00edmen? \n\n A jelsz\u00f3val v\u00e9dett akkumul\u00e1toros eszk\u00f6z\u00f6ket fel kell \u00e9breszteni, miel\u0151tt folytatn\u00e1 a be\u00e1ll\u00edt\u00e1st.\n Az elemmel m\u0171k\u00f6d\u0151, jelsz\u00f3val nem v\u00e9dett eszk\u00f6z\u00f6k hozz\u00e1ad\u00e1sra ker\u00fclnek, amikor az eszk\u00f6z fel\u00e9bred, most manu\u00e1lisan \u00e9bresztheti fel az eszk\u00f6zt egy rajta l\u00e9v\u0151 gombbal, vagy v\u00e1rhat a k\u00f6vetkez\u0151 adatfriss\u00edt\u00e9sre." + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani {model}-t {host} c\u00edmen? \n\nA jelsz\u00f3val v\u00e9dett akkumul\u00e1toros eszk\u00f6z\u00f6ket fel kell \u00e9breszteni, miel\u0151tt folytatn\u00e1 a be\u00e1ll\u00edt\u00e1st.\nAz elemmel m\u0171k\u00f6d\u0151, jelsz\u00f3val nem v\u00e9dett eszk\u00f6z\u00f6k hozz\u00e1ad\u00e1sra ker\u00fclnek, amikor az eszk\u00f6z fel\u00e9bred, most manu\u00e1lisan \u00e9bresztheti fel az eszk\u00f6zt egy rajta l\u00e9v\u0151 gombbal, vagy v\u00e1rhat a k\u00f6vetkez\u0151 adatfriss\u00edt\u00e9sre." }, "credentials": { "data": { @@ -22,7 +22,7 @@ }, "user": { "data": { - "host": "Hoszt" + "host": "C\u00edm" }, "description": "A be\u00e1ll\u00edt\u00e1s el\u0151tt az akkumul\u00e1toros eszk\u00f6z\u00f6ket fel kell \u00e9breszteni, most egy rajta l\u00e9v\u0151 gombbal fel\u00e9bresztheted az eszk\u00f6zt." } @@ -33,14 +33,20 @@ "button": "Gomb", "button1": "Els\u0151 gomb", "button2": "M\u00e1sodik gomb", - "button3": "Harmadik gomb" + "button3": "Harmadik gomb", + "button4": "Negyedik gomb" }, "trigger_type": { + "btn_down": "{subtype} gomb lenyomva", + "btn_up": "{subtype} gomb elengedve", "double": "{subtype} dupla kattint\u00e1s", + "double_push": "{subtype} dupla lenyom\u00e1s", "long": "{subtype} hosszan nyomva", + "long_push": "{subtype} hosszan lenyomva", "long_single": "{subtype} hosszan nyomva, majd egy kattint\u00e1s", "single": "{subtype} egy kattint\u00e1s", "single_long": "{subtype} egy kattint\u00e1s, majd hosszan nyomva", + "single_push": "{subtype} egy lenyom\u00e1s", "triple": "{subtype} tripla kattint\u00e1s" } } diff --git a/homeassistant/components/shelly/translations/id.json b/homeassistant/components/shelly/translations/id.json index 606ee473805..2f385796fd1 100644 --- a/homeassistant/components/shelly/translations/id.json +++ b/homeassistant/components/shelly/translations/id.json @@ -38,9 +38,11 @@ "trigger_type": { "double": "{subtype} diklik dua kali", "long": "{subtype} diklik lama", + "long_push": "Push lama {subtype}", "long_single": "{subtype} diklik lama kemudian diklik sekali", "single": "{subtype} diklik sekali", "single_long": "{subtype} diklik sekali kemudian diklik lama", + "single_push": "Push tunggal {subtype}", "triple": "{subtype} diklik tiga kali" } } diff --git a/homeassistant/components/shelly/translations/it.json b/homeassistant/components/shelly/translations/it.json index 051cf88dc38..c004141cac4 100644 --- a/homeassistant/components/shelly/translations/it.json +++ b/homeassistant/components/shelly/translations/it.json @@ -33,14 +33,20 @@ "button": "Pulsante", "button1": "Primo pulsante", "button2": "Secondo pulsante", - "button3": "Terzo pulsante" + "button3": "Terzo pulsante", + "button4": "Quarto pulsante" }, "trigger_type": { + "btn_down": "{subtype} pulsante in gi\u00f9", + "btn_up": "{subtype} pulsante in su", "double": "{subtype} premuto due volte", + "double_push": "{subtype} doppia pressione", "long": "{subtype} premuto a lungo", + "long_push": "{subtype} pressione prolungata", "long_single": "{subtype} premuto a lungo e poi singolarmente", "single": "{subtype} premuto singolarmente", "single_long": "{subtype} premuto singolarmente e poi a lungo", + "single_push": "{subtype} singola pressione", "triple": "{subtype} premuto tre volte" } } diff --git a/homeassistant/components/shelly/translations/nl.json b/homeassistant/components/shelly/translations/nl.json index 4a58fa31d85..0251e2e7267 100644 --- a/homeassistant/components/shelly/translations/nl.json +++ b/homeassistant/components/shelly/translations/nl.json @@ -33,14 +33,20 @@ "button": "Knop", "button1": "Eerste knop", "button2": "Tweede knop", - "button3": "Derde knop" + "button3": "Derde knop", + "button4": "Vierde knop" }, "trigger_type": { + "btn_down": "{subtype} knop omlaag", + "btn_up": "{subtype} knop omhoog", "double": "{subtype} dubbel geklikt", + "double_push": "{subtype} dubbele druk", "long": "{subtype} lang geklikt", + "long_push": " {subtype} lange druk", "long_single": "{subtype} lang geklikt en daarna \u00e9\u00e9n keer geklikt", "single": "{subtype} enkel geklikt", "single_long": "{subtype} een keer geklikt en daarna lang geklikt", + "single_push": "{subtype} een druk", "triple": "{subtype} driemaal geklikt" } } diff --git a/homeassistant/components/shelly/translations/no.json b/homeassistant/components/shelly/translations/no.json index 90cfe3ca906..dd587e56a6b 100644 --- a/homeassistant/components/shelly/translations/no.json +++ b/homeassistant/components/shelly/translations/no.json @@ -33,14 +33,20 @@ "button": "Knapp", "button1": "F\u00f8rste knapp", "button2": "Andre knapp", - "button3": "Tredje knapp" + "button3": "Tredje knapp", + "button4": "Fjerde knapp" }, "trigger_type": { + "btn_down": "{subtype}-knappen ned", + "btn_up": "{subtype} -knappen opp", "double": "{subtype} dobbeltklikket", + "double_push": "{subtype} dobbelt trykk", "long": "{subtype} lenge klikket", + "long_push": "{subtype} langt trykk", "long_single": "{subtype} lengre klikk og deretter et enkeltklikk", "single": "{subtype} enkeltklikket", "single_long": "{subtype} enkeltklikket og deretter et lengre klikk", + "single_push": "{subtype} enkelt trykk", "triple": "{subtype} trippelklikket" } } diff --git a/homeassistant/components/shelly/translations/ru.json b/homeassistant/components/shelly/translations/ru.json index 9996e347e96..d3f38aa9eeb 100644 --- a/homeassistant/components/shelly/translations/ru.json +++ b/homeassistant/components/shelly/translations/ru.json @@ -24,7 +24,7 @@ "data": { "host": "\u0425\u043e\u0441\u0442" }, - "description": "\u041f\u0435\u0440\u0435\u0434 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u043e\u0439 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430, \u0440\u0430\u0431\u043e\u0442\u0430\u044e\u0449\u0438\u0435 \u043e\u0442 \u0431\u0430\u0442\u0430\u0440\u0435\u0438, \u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u0432\u044b\u0432\u0435\u0441\u0442\u0438 \u0438\u0437 \u0441\u043f\u044f\u0449\u0435\u0433\u043e \u0440\u0435\u0436\u0438\u043c\u0430, \u043d\u0430\u0436\u0430\u0432 \u043a\u043d\u043e\u043f\u043a\u0443 \u043d\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0435." + "description": "\u0420\u0430\u0431\u043e\u0442\u0430\u044e\u0449\u0438\u0435 \u043e\u0442 \u0431\u0430\u0442\u0430\u0440\u0435\u0438 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u043f\u0435\u0440\u0435\u0434 \u043d\u0430\u0447\u0430\u043b\u043e\u043c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0432\u044b\u0432\u0435\u0441\u0442\u0438 \u0438\u0437 \u0441\u043f\u044f\u0449\u0435\u0433\u043e \u0440\u0435\u0436\u0438\u043c\u0430. \u042d\u0442\u043e \u043c\u043e\u0436\u043d\u043e \u0441\u0434\u0435\u043b\u0430\u0442\u044c \u0441 \u043f\u043e\u043c\u043e\u0449\u044c\u044e \u043a\u043d\u043e\u043f\u043a\u0438, \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u043d\u043e\u0439 \u043d\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0435." } } }, @@ -33,14 +33,20 @@ "button": "\u041a\u043d\u043e\u043f\u043a\u0430", "button1": "\u041f\u0435\u0440\u0432\u0430\u044f \u043a\u043d\u043e\u043f\u043a\u0430", "button2": "\u0412\u0442\u043e\u0440\u0430\u044f \u043a\u043d\u043e\u043f\u043a\u0430", - "button3": "\u0422\u0440\u0435\u0442\u044c\u044f \u043a\u043d\u043e\u043f\u043a\u0430" + "button3": "\u0422\u0440\u0435\u0442\u044c\u044f \u043a\u043d\u043e\u043f\u043a\u0430", + "button4": "\u0427\u0435\u0442\u0432\u0435\u0440\u0442\u0430\u044f \u043a\u043d\u043e\u043f\u043a\u0430" }, "trigger_type": { + "btn_down": "{subtype} \u0432 \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0438 '\u0432\u043d\u0438\u0437'", + "btn_up": "{subtype} \u0432 \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0438 '\u0432\u0432\u0435\u0440\u0445'", "double": "{subtype} \u043d\u0430\u0436\u0430\u0442\u0430 \u0434\u0432\u0430 \u0440\u0430\u0437\u0430", + "double_push": "{subtype} \u043d\u0430\u0436\u0430\u0442\u0430 \u0434\u0432\u0430 \u0440\u0430\u0437\u0430", "long": "{subtype} \u0434\u043e\u043b\u0433\u043e \u043d\u0430\u0436\u0430\u0442\u0430", + "long_push": "{subtype} \u0434\u043e\u043b\u0433\u043e \u043d\u0430\u0436\u0430\u0442\u0430", "long_single": "{subtype} \u0434\u043e\u043b\u0433\u043e \u043d\u0430\u0436\u0430\u0442\u0430 \u0438 \u0437\u0430\u0442\u0435\u043c \u043d\u0430\u0436\u0430\u0442\u0430 \u043e\u0434\u0438\u043d \u0440\u0430\u0437", "single": "{subtype} \u043d\u0430\u0436\u0430\u0442\u0430 \u043e\u0434\u0438\u043d \u0440\u0430\u0437", "single_long": "{subtype} \u043d\u0430\u0436\u0430\u0442\u0430 \u043e\u0434\u0438\u043d \u0440\u0430\u0437 \u0438 \u0437\u0430\u0442\u0435\u043c \u0434\u043e\u043b\u0433\u043e \u043d\u0430\u0436\u0430\u0442\u0430", + "single_push": "{subtype} \u043d\u0430\u0436\u0430\u0442\u0430 \u043e\u0434\u0438\u043d \u0440\u0430\u0437", "triple": "{subtype} \u043d\u0430\u0436\u0430\u0442\u0430 \u0442\u0440\u0438 \u0440\u0430\u0437\u0430" } } diff --git a/homeassistant/components/shelly/translations/zh-Hant.json b/homeassistant/components/shelly/translations/zh-Hant.json index d0e255560be..bc746ccac2a 100644 --- a/homeassistant/components/shelly/translations/zh-Hant.json +++ b/homeassistant/components/shelly/translations/zh-Hant.json @@ -33,14 +33,20 @@ "button": "\u6309\u9215", "button1": "\u7b2c\u4e00\u500b\u6309\u9215", "button2": "\u7b2c\u4e8c\u500b\u6309\u9215", - "button3": "\u7b2c\u4e09\u500b\u6309\u9215" + "button3": "\u7b2c\u4e09\u500b\u6309\u9215", + "button4": "\u7b2c\u56db\u500b\u6309\u9215" }, "trigger_type": { + "btn_down": "\"{subtype}\" \u6309\u9215\u6309\u4e0b", + "btn_up": "\"{subtype}\" \u6309\u9215\u91cb\u653e", "double": "{subtype} \u96d9\u64ca", + "double_push": "{subtype} \u96d9\u6309", "long": "{subtype} \u9577\u6309", + "long_push": "{subtype} \u9577\u6309", "long_single": "{subtype} \u9577\u6309\u5f8c\u55ae\u64ca", "single": "{subtype} \u55ae\u64ca", "single_long": "{subtype} \u55ae\u64ca\u5f8c\u9577\u6309", + "single_push": "{subtype} \u55ae\u6309", "triple": "{subtype} \u4e09\u9023\u64ca" } } diff --git a/homeassistant/components/shopping_list/translations/hu.json b/homeassistant/components/shopping_list/translations/hu.json index 5f092963da3..27c984ce1ae 100644 --- a/homeassistant/components/shopping_list/translations/hu.json +++ b/homeassistant/components/shopping_list/translations/hu.json @@ -5,7 +5,7 @@ }, "step": { "user": { - "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a bev\u00e1s\u00e1rl\u00f3list\u00e1t?", + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani a bev\u00e1s\u00e1rl\u00f3list\u00e1t?", "title": "Bev\u00e1s\u00e1rl\u00f3lista" } } diff --git a/homeassistant/components/sia/translations/es.json b/homeassistant/components/sia/translations/es.json index 34ff6847421..8e1bb05978d 100644 --- a/homeassistant/components/sia/translations/es.json +++ b/homeassistant/components/sia/translations/es.json @@ -6,10 +6,18 @@ "invalid_key_format": "La clave no es un valor hexadecimal, por favor utilice s\u00f3lo 0-9 y A-F.", "invalid_key_length": "La clave no tiene la longitud correcta, tiene que ser de 16, 24 o 32 caracteres hexadecimales.", "invalid_ping": "El intervalo de ping debe estar entre 1 y 1440 minutos.", - "invalid_zones": "Tiene que haber al menos 1 zona." + "invalid_zones": "Tiene que haber al menos 1 zona.", + "unknown": "Error inesperado" }, "step": { "additional_account": { + "data": { + "account": "ID de la cuenta", + "additional_account": "Cuentas adicionales", + "encryption_key": "Clave de encriptaci\u00f3n", + "ping_interval": "Intervalo de ping (min)", + "zones": "N\u00famero de zonas de la cuenta" + }, "title": "Agrega otra cuenta al puerto actual." }, "user": { @@ -30,7 +38,8 @@ "step": { "options": { "data": { - "ignore_timestamps": "Ignore la verificaci\u00f3n de la marca de tiempo de los eventos SIA" + "ignore_timestamps": "Ignore la verificaci\u00f3n de la marca de tiempo de los eventos SIA", + "zones": "N\u00famero de zonas de la cuenta" }, "description": "Configure las opciones para la cuenta: {account}", "title": "Opciones para la configuraci\u00f3n de SIA." diff --git a/homeassistant/components/sia/translations/id.json b/homeassistant/components/sia/translations/id.json new file mode 100644 index 00000000000..e7ab7918fb3 --- /dev/null +++ b/homeassistant/components/sia/translations/id.json @@ -0,0 +1,50 @@ +{ + "config": { + "error": { + "invalid_account_format": "Format akun ini tidak dalam nilai heksadesimal, gunakan hanya karakter 0-9 dan A-F.", + "invalid_account_length": "Panjang format akun tidak tepat, harus antara 3 dan 16 karakter.", + "invalid_key_format": "Format kunci ini tidak dalam nilai heksadesimal, gunakan hanya karakter 0-9 dan A-F.", + "invalid_key_length": "Panjang format kunci tidak tepat, harus antara 16, 25, atau 32 karakter heksadesimal.", + "invalid_ping": "Interval ping harus antara 1 dan 1440 menit.", + "invalid_zones": "Setidaknya harus ada 1 zona.", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "additional_account": { + "data": { + "account": "ID Akun", + "additional_account": "Akun lainnya", + "encryption_key": "Kunci Enkripsi", + "ping_interval": "Interval Ping (menit)", + "zones": "Jumlah zona untuk akun" + }, + "title": "Tambahkan akun lain ke port saat ini." + }, + "user": { + "data": { + "account": "ID Akun", + "additional_account": "Akun lainnya", + "encryption_key": "Kunci Enkripsi", + "ping_interval": "Interval Ping (menit)", + "port": "Port", + "protocol": "Protokol", + "zones": "Jumlah zona untuk akun" + }, + "title": "Buat koneksi untuk sistem alarm berbasis SIA." + } + } + }, + "options": { + "step": { + "options": { + "data": { + "ignore_timestamps": "Abaikan pemeriksaan stempel waktu peristiwa SIA", + "zones": "Jumlah zona untuk akun" + }, + "description": "Setel opsi untuk akun: {account}", + "title": "Opsi untuk Pengaturan SIA." + } + } + }, + "title": "Sistem Alarm SIA" +} \ No newline at end of file diff --git a/homeassistant/components/simplisafe/translations/hu.json b/homeassistant/components/simplisafe/translations/hu.json index f7c1b5afd9d..ed0eb0b2212 100644 --- a/homeassistant/components/simplisafe/translations/hu.json +++ b/homeassistant/components/simplisafe/translations/hu.json @@ -28,7 +28,7 @@ "password": "Jelsz\u00f3", "username": "E-mail" }, - "title": "T\u00f6ltsd ki az adataid" + "title": "T\u00f6ltse ki az adatait" } } }, diff --git a/homeassistant/components/simplisafe/translations/id.json b/homeassistant/components/simplisafe/translations/id.json index 512d6a38405..c9ff0f96bb9 100644 --- a/homeassistant/components/simplisafe/translations/id.json +++ b/homeassistant/components/simplisafe/translations/id.json @@ -19,7 +19,7 @@ "data": { "password": "Kata Sandi" }, - "description": "Token akses Anda telah kedaluwarsa atau dicabut. Masukkan kata sandi Anda untuk menautkan kembali akun Anda.", + "description": "Akses Anda telah kedaluwarsa atau dicabut. Masukkan kata sandi Anda untuk menautkan kembali akun Anda.", "title": "Autentikasi Ulang Integrasi" }, "user": { diff --git a/homeassistant/components/sma/translations/hu.json b/homeassistant/components/sma/translations/hu.json index cab063cd077..f1958dbcc1f 100644 --- a/homeassistant/components/sma/translations/hu.json +++ b/homeassistant/components/sma/translations/hu.json @@ -14,7 +14,7 @@ "user": { "data": { "group": "Csoport", - "host": "Gazdag\u00e9p", + "host": "C\u00edm", "password": "Jelsz\u00f3", "ssl": "SSL tan\u00fas\u00edtv\u00e1nyt haszn\u00e1l", "verify_ssl": "Ellen\u0151rizze az SSL tan\u00fas\u00edtv\u00e1nyt" diff --git a/homeassistant/components/smappee/translations/hu.json b/homeassistant/components/smappee/translations/hu.json index 5b00dffde9c..5b4a83a74b0 100644 --- a/homeassistant/components/smappee/translations/hu.json +++ b/homeassistant/components/smappee/translations/hu.json @@ -6,7 +6,7 @@ "authorize_url_timeout": "Id\u0151t\u00fall\u00e9p\u00e9s a hiteles\u00edt\u00e9si URL gener\u00e1l\u00e1sa sor\u00e1n.", "cannot_connect": "Sikertelen csatlakoz\u00e1s", "invalid_mdns": "Nem t\u00e1mogatott eszk\u00f6z a Smappee integr\u00e1ci\u00f3hoz.", - "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rlek, k\u00f6vesd a dokument\u00e1ci\u00f3t.", + "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rem, k\u00f6vesse a dokument\u00e1ci\u00f3t.", "no_url_available": "Nincs el\u00e9rhet\u0151 URL. A hib\u00e1r\u00f3l tov\u00e1bbi inform\u00e1ci\u00f3t [a s\u00fag\u00f3ban]({docs_url}) tal\u00e1lsz." }, "flow_title": "{name}", @@ -19,9 +19,9 @@ }, "local": { "data": { - "host": "Hoszt" + "host": "C\u00edm" }, - "description": "Adja meg a gazdag\u00e9pet a Smappee helyi integr\u00e1ci\u00f3j\u00e1nak elind\u00edt\u00e1s\u00e1hoz" + "description": "Adja meg a c\u00edmet a Smappee helyi integr\u00e1ci\u00f3j\u00e1nak elind\u00edt\u00e1s\u00e1hoz" }, "pick_implementation": { "title": "V\u00e1lassz hiteles\u00edt\u00e9si m\u00f3dszert" diff --git a/homeassistant/components/smappee/translations/id.json b/homeassistant/components/smappee/translations/id.json index b72200c34ca..66efc23dcee 100644 --- a/homeassistant/components/smappee/translations/id.json +++ b/homeassistant/components/smappee/translations/id.json @@ -9,7 +9,7 @@ "missing_configuration": "Komponen tidak dikonfigurasi. Ikuti petunjuk dalam dokumentasi.", "no_url_available": "Tidak ada URL yang tersedia. Untuk informasi tentang kesalahan ini, [lihat bagian bantuan]({docs_url})" }, - "flow_title": "Smappee: {name}", + "flow_title": "{name}", "step": { "environment": { "data": { diff --git a/homeassistant/components/smartthings/translations/hu.json b/homeassistant/components/smartthings/translations/hu.json index 05e99bef2ea..90ea748ae33 100644 --- a/homeassistant/components/smartthings/translations/hu.json +++ b/homeassistant/components/smartthings/translations/hu.json @@ -1,15 +1,15 @@ { "config": { "abort": { - "invalid_webhook_url": "A Home Assistant nincs megfelel\u0151en konfigur\u00e1lva a SmartThings friss\u00edt\u00e9seinek fogad\u00e1s\u00e1ra. A webhook URL \u00e9rv\u00e9nytelen:\n > {webhook_url} \n\n K\u00e9rj\u00fck, friss\u00edtse konfigur\u00e1ci\u00f3j\u00e1t az [utas\u00edt\u00e1sok] szerint ({component_url}), ind\u00edtsa \u00fajra a Home Assistant alkalmaz\u00e1st, \u00e9s pr\u00f3b\u00e1lja \u00fajra.", + "invalid_webhook_url": "Home Assistant nincs megfelel\u0151en konfigur\u00e1lva a SmartThings friss\u00edt\u00e9seinek fogad\u00e1s\u00e1ra. A webhook URL \u00e9rv\u00e9nytelen:\n > {webhook_url} \n\nK\u00e9rj\u00fck, friss\u00edtse konfigur\u00e1ci\u00f3j\u00e1t az [utas\u00edt\u00e1sok]({component_url}) szerint, ind\u00edtsa \u00fajra a Home Assistant alkalmaz\u00e1st, \u00e9s pr\u00f3b\u00e1lja \u00fajra.", "no_available_locations": "Nincsenek be\u00e1ll\u00edthat\u00f3 SmartThings helyek a Home Assistant alkalmaz\u00e1sban." }, "error": { - "app_setup_error": "A SmartApp be\u00e1ll\u00edt\u00e1sa nem siker\u00fclt. K\u00e9rlek pr\u00f3b\u00e1ld \u00fajra.", + "app_setup_error": "A SmartApp be\u00e1ll\u00edt\u00e1sa nem siker\u00fclt. K\u00e9rem, pr\u00f3b\u00e1lja \u00fajra.", "token_forbidden": "A token nem rendelkezik a sz\u00fcks\u00e9ges OAuth-tartom\u00e1nyokkal.", "token_invalid_format": "A tokennek UID / GUID form\u00e1tumban kell lennie", "token_unauthorized": "A token \u00e9rv\u00e9nytelen vagy m\u00e1r nem enged\u00e9lyezett.", - "webhook_error": "A SmartThings nem tudta \u00e9rv\u00e9nyes\u00edteni a `base_url`-ben konfigur\u00e1lt v\u00e9gpontot. K\u00e9rlek, tekintsd \u00e1t az \u00f6sszetev\u0151 k\u00f6vetelm\u00e9nyeit." + "webhook_error": "SmartThings nem tudta \u00e9rv\u00e9nyes\u00edteni a webhook URL-t. K\u00e9rj\u00fck, ellen\u0151rizze, hogy a webhook URL el\u00e9rhet\u0151-e az internet fel\u0151l, \u00e9s pr\u00f3b\u00e1lja meg \u00fajra." }, "step": { "authorize": { @@ -30,7 +30,7 @@ "title": "Hely kiv\u00e1laszt\u00e1sa" }, "user": { - "description": "K\u00e9rlek add meg a SmartThings [Personal Access Tokent]({token_url}), amit az [instrukci\u00f3k]({component_url}) alapj\u00e1n hozt\u00e1l l\u00e9tre.", + "description": "K\u00e9rem adja meg a SmartThings [Personal Access Tokent]({token_url}), amit az [instrukci\u00f3k]({component_url}) alapj\u00e1n hozott l\u00e9tre.", "title": "Callback URL meger\u0151s\u00edt\u00e9se" } } diff --git a/homeassistant/components/smarttub/translations/ca.json b/homeassistant/components/smarttub/translations/ca.json index d00c4d26c98..4f0e9a2c502 100644 --- a/homeassistant/components/smarttub/translations/ca.json +++ b/homeassistant/components/smarttub/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat", + "already_configured": "El compte ja est\u00e0 configurat", "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" }, "error": { diff --git a/homeassistant/components/smarttub/translations/hu.json b/homeassistant/components/smarttub/translations/hu.json index e9a45d3773f..3764b27abd2 100644 --- a/homeassistant/components/smarttub/translations/hu.json +++ b/homeassistant/components/smarttub/translations/hu.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van", "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt" }, "error": { @@ -17,7 +17,7 @@ "email": "E-mail", "password": "Jelsz\u00f3" }, - "description": "Add meg SmartTub e-mail c\u00edmet \u00e9s jelsz\u00f3t a bejelentkez\u00e9shez", + "description": "Adja meg SmartTub e-mail c\u00edmet \u00e9s jelsz\u00f3t a bejelentkez\u00e9shez", "title": "Bejelentkez\u00e9s" } } diff --git a/homeassistant/components/smarttub/translations/id.json b/homeassistant/components/smarttub/translations/id.json index bf32b29d1e7..9b021f65771 100644 --- a/homeassistant/components/smarttub/translations/id.json +++ b/homeassistant/components/smarttub/translations/id.json @@ -9,6 +9,7 @@ }, "step": { "reauth_confirm": { + "description": "Integrasi SmartTub perlu mengautentikasi ulang akun Anda", "title": "Autentikasi Ulang Integrasi" }, "user": { diff --git a/homeassistant/components/smarttub/translations/ko.json b/homeassistant/components/smarttub/translations/ko.json index b68ff871d4d..ff50dadc63e 100644 --- a/homeassistant/components/smarttub/translations/ko.json +++ b/homeassistant/components/smarttub/translations/ko.json @@ -8,6 +8,9 @@ "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "step": { + "reauth_confirm": { + "title": "\ud1b5\ud569 \uad6c\uc131\uc694\uc18c \uc7ac\uc778\uc99d\ud558\uae30" + }, "user": { "data": { "email": "\uc774\uba54\uc77c", diff --git a/homeassistant/components/solarlog/translations/hu.json b/homeassistant/components/solarlog/translations/hu.json index 23baa393942..ada2ab95751 100644 --- a/homeassistant/components/solarlog/translations/hu.json +++ b/homeassistant/components/solarlog/translations/hu.json @@ -10,7 +10,7 @@ "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "name": "A Solar-Log szenzorokhoz haszn\u00e1land\u00f3 el\u0151tag" }, "title": "Hat\u00e1rozza meg a Solar-Log kapcsolatot" diff --git a/homeassistant/components/soma/translations/hu.json b/homeassistant/components/soma/translations/hu.json index c3e572ebe0a..e7ac9d8d71c 100644 --- a/homeassistant/components/soma/translations/hu.json +++ b/homeassistant/components/soma/translations/hu.json @@ -13,7 +13,7 @@ "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "port": "Port" }, "description": "K\u00e9rj\u00fck, adja meg a SOMA Connect csatlakoz\u00e1si be\u00e1ll\u00edt\u00e1sait.", diff --git a/homeassistant/components/somfy/translations/hu.json b/homeassistant/components/somfy/translations/hu.json index ce4e94b3399..06b0894faf1 100644 --- a/homeassistant/components/somfy/translations/hu.json +++ b/homeassistant/components/somfy/translations/hu.json @@ -2,7 +2,7 @@ "config": { "abort": { "authorize_url_timeout": "Id\u0151t\u00fall\u00e9p\u00e9s a hiteles\u00edt\u00e9si URL gener\u00e1l\u00e1sa sor\u00e1n.", - "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rlek, k\u00f6vesd a dokument\u00e1ci\u00f3t.", + "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rem, k\u00f6vesse a dokument\u00e1ci\u00f3t.", "no_url_available": "Nincs el\u00e9rhet\u0151 URL. A hib\u00e1r\u00f3l tov\u00e1bbi inform\u00e1ci\u00f3t [a s\u00fag\u00f3ban]({docs_url}) tal\u00e1lsz.", "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges." }, diff --git a/homeassistant/components/somfy_mylink/translations/hu.json b/homeassistant/components/somfy_mylink/translations/hu.json index 3610a930022..fa6620859f5 100644 --- a/homeassistant/components/somfy_mylink/translations/hu.json +++ b/homeassistant/components/somfy_mylink/translations/hu.json @@ -12,7 +12,7 @@ "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "port": "Port", "system_id": "Rendszerazonos\u00edt\u00f3" }, diff --git a/homeassistant/components/somfy_mylink/translations/id.json b/homeassistant/components/somfy_mylink/translations/id.json index 0203ae421e2..c4b2269ef2c 100644 --- a/homeassistant/components/somfy_mylink/translations/id.json +++ b/homeassistant/components/somfy_mylink/translations/id.json @@ -8,7 +8,7 @@ "invalid_auth": "Autentikasi tidak valid", "unknown": "Kesalahan yang tidak diharapkan" }, - "flow_title": "Somfy MyLink {mac} ({ip})", + "flow_title": "{mac} ({ip})", "step": { "user": { "data": { diff --git a/homeassistant/components/sonarr/translations/hu.json b/homeassistant/components/sonarr/translations/hu.json index 6ebdb22404c..7ac0b621b13 100644 --- a/homeassistant/components/sonarr/translations/hu.json +++ b/homeassistant/components/sonarr/translations/hu.json @@ -19,7 +19,7 @@ "data": { "api_key": "API kulcs", "base_path": "El\u00e9r\u00e9si \u00fat az API-hoz", - "host": "Hoszt", + "host": "C\u00edm", "port": "Port", "ssl": "SSL tan\u00fas\u00edtv\u00e1ny haszn\u00e1lata", "verify_ssl": "SSL-tan\u00fas\u00edtv\u00e1ny ellen\u0151rz\u00e9se" diff --git a/homeassistant/components/sonarr/translations/id.json b/homeassistant/components/sonarr/translations/id.json index ffaf1d22604..9d906a07f91 100644 --- a/homeassistant/components/sonarr/translations/id.json +++ b/homeassistant/components/sonarr/translations/id.json @@ -9,7 +9,7 @@ "cannot_connect": "Gagal terhubung", "invalid_auth": "Autentikasi tidak valid" }, - "flow_title": "Sonarr: {name}", + "flow_title": "{name}", "step": { "reauth_confirm": { "description": "Integrasi Sonarr perlu diautentikasi ulang secara manual dengan API Sonarr yang dihosting di: {host}", diff --git a/homeassistant/components/songpal/translations/hu.json b/homeassistant/components/songpal/translations/hu.json index 2bce32d0cb8..a02844a50a7 100644 --- a/homeassistant/components/songpal/translations/hu.json +++ b/homeassistant/components/songpal/translations/hu.json @@ -10,7 +10,7 @@ "flow_title": "{name} ({host})", "step": { "init": { - "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a {name}({host})-t?" + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani: {name} ({host})?" }, "user": { "data": { diff --git a/homeassistant/components/songpal/translations/id.json b/homeassistant/components/songpal/translations/id.json index 2b8149661bc..9e619e5bf76 100644 --- a/homeassistant/components/songpal/translations/id.json +++ b/homeassistant/components/songpal/translations/id.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "Gagal terhubung" }, - "flow_title": "Sony Songpal {name} ({host})", + "flow_title": "{name} ({host})", "step": { "init": { "description": "Ingin menyiapkan {name} ({host})?" diff --git a/homeassistant/components/sonos/translations/he.json b/homeassistant/components/sonos/translations/he.json index 878c14a5119..64824b942ec 100644 --- a/homeassistant/components/sonos/translations/he.json +++ b/homeassistant/components/sonos/translations/he.json @@ -2,6 +2,7 @@ "config": { "abort": { "no_devices_found": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05de\u05db\u05e9\u05d9\u05e8\u05d9\u05dd \u05d1\u05e8\u05e9\u05ea", + "not_sonos_device": "\u05d4\u05ea\u05e7\u05df \u05e9\u05d4\u05ea\u05d2\u05dc\u05d4 \u05d0\u05d9\u05e0\u05d5 \u05d4\u05ea\u05e7\u05df Sonos", "single_instance_allowed": "\u05ea\u05e6\u05d5\u05e8\u05ea\u05d5 \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e8\u05e7 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05d0\u05e4\u05e9\u05e8\u05d9\u05ea." }, "step": { diff --git a/homeassistant/components/sonos/translations/hu.json b/homeassistant/components/sonos/translations/hu.json index a928f97b3d6..a521c1e9d75 100644 --- a/homeassistant/components/sonos/translations/hu.json +++ b/homeassistant/components/sonos/translations/hu.json @@ -7,7 +7,7 @@ }, "step": { "confirm": { - "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a Sonos-t?" + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani a Sonos-t?" } } } diff --git a/homeassistant/components/sonos/translations/id.json b/homeassistant/components/sonos/translations/id.json index 145e2775e4a..d64dccf3af3 100644 --- a/homeassistant/components/sonos/translations/id.json +++ b/homeassistant/components/sonos/translations/id.json @@ -2,6 +2,7 @@ "config": { "abort": { "no_devices_found": "Tidak ada perangkat yang ditemukan di jaringan", + "not_sonos_device": "Perangkat yang ditemukan bukan perangkat Sonos", "single_instance_allowed": "Sudah dikonfigurasi. Hanya satu konfigurasi yang diizinkan." }, "step": { diff --git a/homeassistant/components/speedtestdotnet/translations/hu.json b/homeassistant/components/speedtestdotnet/translations/hu.json index cd08c3bd2d6..9e602652e5b 100644 --- a/homeassistant/components/speedtestdotnet/translations/hu.json +++ b/homeassistant/components/speedtestdotnet/translations/hu.json @@ -6,7 +6,7 @@ }, "step": { "user": { - "description": "El szeretn\u00e9d kezdeni a be\u00e1ll\u00edt\u00e1st?" + "description": "El szeretn\u00e9 kezdeni a be\u00e1ll\u00edt\u00e1st?" } } }, @@ -16,7 +16,7 @@ "data": { "manual": "Automatikus friss\u00edt\u00e9s letilt\u00e1sa", "scan_interval": "Friss\u00edt\u00e9si gyakoris\u00e1g (perc)", - "server_name": "V\u00e1laszd ki a teszt szervert" + "server_name": "V\u00e1lassza ki a teszt szervert" } } } diff --git a/homeassistant/components/speedtestdotnet/translations/nl.json b/homeassistant/components/speedtestdotnet/translations/nl.json index 5de8460fd77..3a112d48e9d 100644 --- a/homeassistant/components/speedtestdotnet/translations/nl.json +++ b/homeassistant/components/speedtestdotnet/translations/nl.json @@ -6,7 +6,7 @@ }, "step": { "user": { - "description": "Wil je beginnen met instellen?" + "description": "Wilt u beginnen met instellen?" } } }, diff --git a/homeassistant/components/spotify/translations/hu.json b/homeassistant/components/spotify/translations/hu.json index 8ffeadaf842..136e6185b46 100644 --- a/homeassistant/components/spotify/translations/hu.json +++ b/homeassistant/components/spotify/translations/hu.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "authorize_url_timeout": "Id\u0151t\u00fall\u00e9p\u00e9s az \u00e9rv\u00e9nyes\u00edt\u00e9si url gener\u00e1l\u00e1sa sor\u00e1n.", + "authorize_url_timeout": "Id\u0151t\u00fall\u00e9p\u00e9s az \u00e9rv\u00e9nyes\u00edt\u00e9si URL gener\u00e1l\u00e1sa sor\u00e1n.", "missing_configuration": "A Spotify integr\u00e1ci\u00f3 nincs konfigur\u00e1lva. K\u00e9rj\u00fck, k\u00f6vesse a dokument\u00e1ci\u00f3t.", "no_url_available": "Nincs el\u00e9rhet\u0151 URL. A hib\u00e1r\u00f3l tov\u00e1bbi inform\u00e1ci\u00f3t [a s\u00fag\u00f3ban]({docs_url}) tal\u00e1lsz.", "reauth_account_mismatch": "A Spotify-fi\u00f3kkal hiteles\u00edtett fi\u00f3k nem egyezik meg az \u00faj hiteles\u00edt\u00e9shez sz\u00fcks\u00e9ges fi\u00f3kkal." diff --git a/homeassistant/components/squeezebox/translations/hu.json b/homeassistant/components/squeezebox/translations/hu.json index a047dbca45f..5c2a3d37e85 100644 --- a/homeassistant/components/squeezebox/translations/hu.json +++ b/homeassistant/components/squeezebox/translations/hu.json @@ -14,7 +14,7 @@ "step": { "edit": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "password": "Jelsz\u00f3", "port": "Port", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" @@ -23,7 +23,7 @@ }, "user": { "data": { - "host": "Hoszt" + "host": "C\u00edm" } } } diff --git a/homeassistant/components/squeezebox/translations/id.json b/homeassistant/components/squeezebox/translations/id.json index 764c356ba84..02d82e872d8 100644 --- a/homeassistant/components/squeezebox/translations/id.json +++ b/homeassistant/components/squeezebox/translations/id.json @@ -10,7 +10,7 @@ "no_server_found": "Tidak dapat menemukan server secara otomatis.", "unknown": "Kesalahan yang tidak diharapkan" }, - "flow_title": "Logitech Squeezebox: {host}", + "flow_title": "{host}", "step": { "edit": { "data": { diff --git a/homeassistant/components/subaru/translations/ca.json b/homeassistant/components/subaru/translations/ca.json index 310747f613e..6b83af06bb8 100644 --- a/homeassistant/components/subaru/translations/ca.json +++ b/homeassistant/components/subaru/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat", + "already_configured": "El compte ja est\u00e0 configurat", "cannot_connect": "Ha fallat la connexi\u00f3" }, "error": { diff --git a/homeassistant/components/surepetcare/translations/ca.json b/homeassistant/components/surepetcare/translations/ca.json new file mode 100644 index 00000000000..5165473860a --- /dev/null +++ b/homeassistant/components/surepetcare/translations/ca.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "El compte ja est\u00e0 configurat" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "unknown": "Error inesperat" + }, + "step": { + "user": { + "data": { + "password": "Contrasenya", + "username": "Nom d'usuari" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/surepetcare/translations/cs.json b/homeassistant/components/surepetcare/translations/cs.json new file mode 100644 index 00000000000..b6c00c05389 --- /dev/null +++ b/homeassistant/components/surepetcare/translations/cs.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u00da\u010det je ji\u017e nastaven" + }, + "error": { + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", + "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed", + "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" + }, + "step": { + "user": { + "data": { + "password": "Heslo", + "username": "U\u017eivatelsk\u00e9 jm\u00e9no" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/surepetcare/translations/de.json b/homeassistant/components/surepetcare/translations/de.json new file mode 100644 index 00000000000..14f319fb4d3 --- /dev/null +++ b/homeassistant/components/surepetcare/translations/de.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Konto wurde bereits konfiguriert" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "user": { + "data": { + "password": "Passwort", + "username": "Benutzername" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/surepetcare/translations/en.json b/homeassistant/components/surepetcare/translations/en.json new file mode 100644 index 00000000000..a6c0889765f --- /dev/null +++ b/homeassistant/components/surepetcare/translations/en.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Account is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "password": "Password", + "username": "Username" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/surepetcare/translations/es.json b/homeassistant/components/surepetcare/translations/es.json new file mode 100644 index 00000000000..3d3945748cb --- /dev/null +++ b/homeassistant/components/surepetcare/translations/es.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "La cuenta ya ha sido configurada" + }, + "error": { + "cannot_connect": "No se pudo conectar", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "unknown": "Error inesperado" + }, + "step": { + "user": { + "data": { + "password": "Contrase\u00f1a", + "username": "Usuario" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/surepetcare/translations/et.json b/homeassistant/components/surepetcare/translations/et.json new file mode 100644 index 00000000000..74f668d14dc --- /dev/null +++ b/homeassistant/components/surepetcare/translations/et.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Konto on juba h\u00e4\u00e4lestatud" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "invalid_auth": "Tuvastamine nurjus", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "user": { + "data": { + "password": "Salas\u00f5na", + "username": "Kasutajanimi" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/surepetcare/translations/he.json b/homeassistant/components/surepetcare/translations/he.json new file mode 100644 index 00000000000..454b7e1ae51 --- /dev/null +++ b/homeassistant/components/surepetcare/translations/he.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "user": { + "data": { + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/surepetcare/translations/hu.json b/homeassistant/components/surepetcare/translations/hu.json new file mode 100644 index 00000000000..cc0c820facf --- /dev/null +++ b/homeassistant/components/surepetcare/translations/hu.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van" + }, + "error": { + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" + }, + "step": { + "user": { + "data": { + "password": "Jelsz\u00f3", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/surepetcare/translations/id.json b/homeassistant/components/surepetcare/translations/id.json new file mode 100644 index 00000000000..a346fab8e56 --- /dev/null +++ b/homeassistant/components/surepetcare/translations/id.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Akun sudah dikonfigurasi" + }, + "error": { + "cannot_connect": "Gagal terhubung", + "invalid_auth": "Autentikasi tidak valid", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "user": { + "data": { + "password": "Kata Sandi", + "username": "Nama Pengguna" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/surepetcare/translations/it.json b/homeassistant/components/surepetcare/translations/it.json new file mode 100644 index 00000000000..aee18749ab0 --- /dev/null +++ b/homeassistant/components/surepetcare/translations/it.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "L'account \u00e8 gi\u00e0 configurato" + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "invalid_auth": "Autenticazione non valida", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "password": "Password", + "username": "Nome utente" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/surepetcare/translations/nl.json b/homeassistant/components/surepetcare/translations/nl.json new file mode 100644 index 00000000000..1dd597d28b4 --- /dev/null +++ b/homeassistant/components/surepetcare/translations/nl.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Account is al geconfigureerd" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "invalid_auth": "Ongeldige authenticatie", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "password": "Wachtwoord", + "username": "Gebruikersnaam" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/surepetcare/translations/no.json b/homeassistant/components/surepetcare/translations/no.json new file mode 100644 index 00000000000..f34edbd641d --- /dev/null +++ b/homeassistant/components/surepetcare/translations/no.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Kontoen er allerede konfigurert" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "password": "Passord", + "username": "Brukernavn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/surepetcare/translations/pt-BR.json b/homeassistant/components/surepetcare/translations/pt-BR.json new file mode 100644 index 00000000000..c41610abb32 --- /dev/null +++ b/homeassistant/components/surepetcare/translations/pt-BR.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Dispositivo j\u00e1 configurado" + }, + "error": { + "cannot_connect": "Falha ao conectar", + "invalid_auth": "Autentica\u00e7\u00e3o inv\u00e1lida", + "unknown": "Erro inesperado" + }, + "step": { + "user": { + "data": { + "password": "Senha", + "username": "Usu\u00e1rio" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/surepetcare/translations/ru.json b/homeassistant/components/surepetcare/translations/ru.json new file mode 100644 index 00000000000..c31f79d1d04 --- /dev/null +++ b/homeassistant/components/surepetcare/translations/ru.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u0430 \u0443\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430 \u0432 Home Assistant." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "user": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/surepetcare/translations/zh-Hant.json b/homeassistant/components/surepetcare/translations/zh-Hant.json new file mode 100644 index 00000000000..ad4530cb30f --- /dev/null +++ b/homeassistant/components/surepetcare/translations/zh-Hant.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "user": { + "data": { + "password": "\u5bc6\u78bc", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switch/translations/he.json b/homeassistant/components/switch/translations/he.json index 0b70a69350b..6d41c202beb 100644 --- a/homeassistant/components/switch/translations/he.json +++ b/homeassistant/components/switch/translations/he.json @@ -1,4 +1,19 @@ { + "device_automation": { + "action_type": { + "toggle": "\u05d4\u05d7\u05dc\u05e4\u05ea \u05de\u05e6\u05d1 {entity_name}", + "turn_off": "\u05db\u05d9\u05d1\u05d5\u05d9 {entity_name}", + "turn_on": "\u05d4\u05e4\u05e2\u05dc\u05ea {entity_name}" + }, + "condition_type": { + "is_off": "{entity_name} \u05db\u05d1\u05d5\u05d9", + "is_on": "{entity_name} \u05e4\u05d5\u05e2\u05dc" + }, + "trigger_type": { + "turned_off": "{entity_name} \u05db\u05d5\u05d1\u05d4", + "turned_on": "{entity_name} \u05d4\u05d5\u05e4\u05e2\u05dc" + } + }, "state": { "_": { "off": "\u05db\u05d1\u05d5\u05d9", diff --git a/homeassistant/components/switchbot/translations/ca.json b/homeassistant/components/switchbot/translations/ca.json new file mode 100644 index 00000000000..6409efcbab7 --- /dev/null +++ b/homeassistant/components/switchbot/translations/ca.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured_device": "El dispositiu ja est\u00e0 configurat", + "cannot_connect": "Ha fallat la connexi\u00f3", + "no_unconfigured_devices": "No s'han trobat dispositius no configurats.", + "switchbot_unsupported_type": "Tipus de Switchbot no compatible.", + "unknown": "Error inesperat" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3" + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "mac": "Adre\u00e7a MAC del dispositiu", + "name": "Nom", + "password": "Contrasenya" + }, + "title": "Configuraci\u00f3 de dispositiu Switchbot" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "retry_count": "Nombre de reintents", + "retry_timeout": "Temps d'espera entre reintents", + "scan_timeout": "Quant de temps s'ha d'escanejar en busca de dades d'alerta", + "update_time": "Temps entre actualitzacions (segons)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switchbot/translations/cs.json b/homeassistant/components/switchbot/translations/cs.json new file mode 100644 index 00000000000..7a44ab78d3b --- /dev/null +++ b/homeassistant/components/switchbot/translations/cs.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "already_configured_device": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno" + }, + "step": { + "user": { + "data": { + "password": "Heslo" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switchbot/translations/de.json b/homeassistant/components/switchbot/translations/de.json new file mode 100644 index 00000000000..f499712718e --- /dev/null +++ b/homeassistant/components/switchbot/translations/de.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured_device": "Ger\u00e4t ist bereits konfiguriert", + "cannot_connect": "Verbindung fehlgeschlagen", + "no_unconfigured_devices": "Keine unkonfigurierten Ger\u00e4te gefunden.", + "switchbot_unsupported_type": "Nicht unterst\u00fctzter Switchbot-Typ.", + "unknown": "Unerwarteter Fehler" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen" + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "mac": "MAC-Adresse des Ger\u00e4ts", + "name": "Name", + "password": "Passwort" + }, + "title": "Switchbot-Ger\u00e4t einrichten" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "retry_count": "Anzahl der Wiederholungen", + "retry_timeout": "Zeit\u00fcberschreitung zwischen Wiederholungsversuchen", + "scan_timeout": "Wie lange nach Anzeigendaten suchen", + "update_time": "Zeit zwischen Aktualisierungen (Sekunden)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switchbot/translations/en.json b/homeassistant/components/switchbot/translations/en.json index 5f2c49e74f5..4ea3d21de65 100644 --- a/homeassistant/components/switchbot/translations/en.json +++ b/homeassistant/components/switchbot/translations/en.json @@ -2,18 +2,19 @@ "config": { "abort": { "already_configured_device": "Device is already configured", - "no_unconfigured_devices": "No unconfigured devices found.", - "unknown": "Unexpected error", "cannot_connect": "Failed to connect", - "switchbot_unsupported_type": "Unsupported Switchbot Type." + "no_unconfigured_devices": "No unconfigured devices found.", + "switchbot_unsupported_type": "Unsupported Switchbot Type.", + "unknown": "Unexpected error" + }, + "error": { + "cannot_connect": "Failed to connect" }, - "error": {}, "flow_title": "{name}", "step": { - "user": { "data": { - "mac": "Mac", + "mac": "Device MAC address", "name": "Name", "password": "Password" }, @@ -25,10 +26,10 @@ "step": { "init": { "data": { - "update_time": "Time between updates (seconds)", "retry_count": "Retry count", "retry_timeout": "Timeout between retries", - "scan_timeout": "How long to scan for advertisement data" + "scan_timeout": "How long to scan for advertisement data", + "update_time": "Time between updates (seconds)" } } } diff --git a/homeassistant/components/switchbot/translations/es.json b/homeassistant/components/switchbot/translations/es.json new file mode 100644 index 00000000000..fe22d91e7f1 --- /dev/null +++ b/homeassistant/components/switchbot/translations/es.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured_device": "El dispositivo ya est\u00e1 configurado", + "switchbot_unsupported_type": "Tipo de Switchbot no compatible.", + "unknown": "Error inesperado" + }, + "error": { + "cannot_connect": "Fall\u00f3 al conectar" + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "mac": "Direcci\u00f3n MAC del dispositivo", + "name": "Nombre", + "password": "Contrase\u00f1a" + }, + "title": "Configurar el dispositivo Switchbot" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "retry_count": "Recuento de reintentos", + "retry_timeout": "Tiempo de espera entre reintentos", + "scan_timeout": "Cu\u00e1nto tiempo se debe buscar datos de anuncio", + "update_time": "Tiempo entre actualizaciones (segundos)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switchbot/translations/et.json b/homeassistant/components/switchbot/translations/et.json new file mode 100644 index 00000000000..cc746796195 --- /dev/null +++ b/homeassistant/components/switchbot/translations/et.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured_device": "Seade on juba h\u00e4\u00e4lestatud", + "cannot_connect": "\u00dchendamine nurjus", + "no_unconfigured_devices": "H\u00e4\u00e4lestamata seadmeid ei leitud.", + "switchbot_unsupported_type": "Toetamata Switchboti t\u00fc\u00fcp.", + "unknown": "Ootamatu t\u00f5rge" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus" + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "mac": "Seadme MAC-aadress", + "name": "Nimi", + "password": "Salas\u00f5na" + }, + "title": "Switchbot seadme seadistamine" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "retry_count": "Korduskatsete arv", + "retry_timeout": "Korduskatsete vaheline aeg", + "scan_timeout": "Kui kaua andmeid otsida", + "update_time": "V\u00e4rskenduste vaheline aeg (sekundites)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switchbot/translations/he.json b/homeassistant/components/switchbot/translations/he.json new file mode 100644 index 00000000000..9e4d8129169 --- /dev/null +++ b/homeassistant/components/switchbot/translations/he.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured_device": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "name": "\u05e9\u05dd", + "password": "\u05e1\u05d9\u05e1\u05de\u05d4" + }, + "title": "\u05d4\u05ea\u05e7\u05e0\u05ea \u05d1\u05d5\u05e8\u05e8 \u05db\u05d9\u05d5\u05d5\u05e0\u05d5\u05df" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "retry_count": "\u05e1\u05e4\u05d9\u05e8\u05ea \u05e0\u05e1\u05d9\u05d5\u05e0\u05d5\u05ea \u05d7\u05d5\u05d6\u05e8\u05d9\u05dd", + "retry_timeout": "\u05e4\u05e1\u05e7 \u05d6\u05de\u05df \u05d1\u05d9\u05df \u05e0\u05d9\u05e1\u05d9\u05d5\u05e0\u05d5\u05ea \u05d7\u05d5\u05d6\u05e8\u05d9\u05dd", + "scan_timeout": "\u05db\u05de\u05d4 \u05d6\u05de\u05df \u05dc\u05e1\u05e8\u05d5\u05e7 \u05e0\u05ea\u05d5\u05e0\u05d9 \u05e4\u05e8\u05e1\u05d5\u05de\u05ea", + "update_time": "\u05d6\u05de\u05df \u05d1\u05d9\u05df \u05e2\u05d3\u05db\u05d5\u05e0\u05d9\u05dd (\u05e9\u05e0\u05d9\u05d5\u05ea)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switchbot/translations/hu.json b/homeassistant/components/switchbot/translations/hu.json new file mode 100644 index 00000000000..5af1acb5d35 --- /dev/null +++ b/homeassistant/components/switchbot/translations/hu.json @@ -0,0 +1,39 @@ +{ + "config": { + "abort": { + "already_configured_device": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "cannot_connect": "A csatlakoz\u00e1s sikertelen", + "no_unconfigured_devices": "Nem tal\u00e1lhat\u00f3 konfigur\u00e1latlan eszk\u00f6z.", + "switchbot_unsupported_type": "Nem t\u00e1mogatott Switchbot t\u00edpus.", + "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" + }, + "error": { + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "one": "\u00dcres", + "other": "\u00dcres" + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "mac": "Eszk\u00f6z MAC-c\u00edme", + "name": "N\u00e9v", + "password": "Jelsz\u00f3" + }, + "title": "Switchbot eszk\u00f6z be\u00e1ll\u00edt\u00e1sa" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "retry_count": "\u00dajrapr\u00f3b\u00e1lkoz\u00e1sok sz\u00e1ma", + "retry_timeout": "\u00dajrapr\u00f3b\u00e1lkoz\u00e1sok k\u00f6z\u00f6tti id\u0151korl\u00e1t", + "scan_timeout": "Mennyi ideig keresse a hirdet\u00e9si adatokat", + "update_time": "Friss\u00edt\u00e9sek k\u00f6z\u00f6tti id\u0151 (m\u00e1sodperc)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switchbot/translations/id.json b/homeassistant/components/switchbot/translations/id.json new file mode 100644 index 00000000000..af61966afa5 --- /dev/null +++ b/homeassistant/components/switchbot/translations/id.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured_device": "Perangkat sudah dikonfigurasi", + "switchbot_unsupported_type": "Jenis Switchbot yang tidak didukung.", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "error": { + "cannot_connect": "Gagal terhubung" + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "mac": "Alamat MAC perangkat", + "name": "Nama", + "password": "Kata Sandi" + }, + "title": "Siapkan perangkat Switchbot" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "retry_count": "Jumlah percobaan", + "retry_timeout": "Tenggang waktu antara percobaan ulang", + "scan_timeout": "Berapa lama untuk memindai data iklan", + "update_time": "Waktu antara pembaruan (detik)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switchbot/translations/it.json b/homeassistant/components/switchbot/translations/it.json new file mode 100644 index 00000000000..fc8296f6442 --- /dev/null +++ b/homeassistant/components/switchbot/translations/it.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured_device": "Il dispositivo \u00e8 gi\u00e0 configurato", + "cannot_connect": "Impossibile connettersi", + "no_unconfigured_devices": "Nessun dispositivo non configurato trovato.", + "switchbot_unsupported_type": "Tipo di Switchbot non supportato.", + "unknown": "Errore imprevisto" + }, + "error": { + "cannot_connect": "Impossibile connettersi" + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "mac": "Indirizzo MAC del dispositivo", + "name": "Nome", + "password": "Password" + }, + "title": "Impostare il dispositivo Switchbot" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "retry_count": "Conteggio dei tentativi di ripetizione", + "retry_timeout": "Tempo scaduto tra i tentativi", + "scan_timeout": "Per quanto tempo eseguire la scansione dei dati pubblicitari", + "update_time": "Tempo tra gli aggiornamenti (secondi)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switchbot/translations/nl.json b/homeassistant/components/switchbot/translations/nl.json new file mode 100644 index 00000000000..fb1e55f6b9d --- /dev/null +++ b/homeassistant/components/switchbot/translations/nl.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured_device": "Apparaat is al geconfigureerd", + "cannot_connect": "Kan geen verbinding maken", + "no_unconfigured_devices": "Geen niet-geconfigureerde apparaten gevonden.", + "switchbot_unsupported_type": "Niet-ondersteund Switchbot-type.", + "unknown": "Onverwachte fout" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken" + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "mac": "MAC-adres apparaat", + "name": "Naam", + "password": "Wachtwoord" + }, + "title": "Switchbot-apparaat instellen" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "retry_count": "Aantal herhalingen", + "retry_timeout": "Time-out tussen nieuwe pogingen", + "scan_timeout": "Hoe lang te scannen voor advertentiegegevens", + "update_time": "Tijd tussen updates (seconden)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switchbot/translations/no.json b/homeassistant/components/switchbot/translations/no.json new file mode 100644 index 00000000000..4d8cb95061a --- /dev/null +++ b/homeassistant/components/switchbot/translations/no.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured_device": "Enheten er allerede konfigurert", + "cannot_connect": "Tilkobling mislyktes", + "no_unconfigured_devices": "Ingen ukonfigurerte enheter ble funnet.", + "switchbot_unsupported_type": "Switchbot-type st\u00f8ttes ikke.", + "unknown": "Uventet feil" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes" + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "mac": "Enhetens MAC -adresse", + "name": "Navn", + "password": "Passord" + }, + "title": "Sett opp Switchbot-enhet" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "retry_count": "Antall nye fors\u00f8k", + "retry_timeout": "Tidsavbrudd mellom fors\u00f8k", + "scan_timeout": "Hvor lenge skal jeg s\u00f8ke etter annonsedata", + "update_time": "Tid mellom oppdateringer (sekunder)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switchbot/translations/ro.json b/homeassistant/components/switchbot/translations/ro.json new file mode 100644 index 00000000000..7668dd5e8e2 --- /dev/null +++ b/homeassistant/components/switchbot/translations/ro.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "switchbot_unsupported_type": "Tipul Switchbot neacceptat." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switchbot/translations/ru.json b/homeassistant/components/switchbot/translations/ru.json new file mode 100644 index 00000000000..5eaa1cdbc4f --- /dev/null +++ b/homeassistant/components/switchbot/translations/ru.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured_device": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant.", + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "no_unconfigured_devices": "\u041d\u0435\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u043d\u044b\u0445 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432 \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u043e.", + "switchbot_unsupported_type": "\u041d\u0435\u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u043c\u044b\u0439 \u0442\u0438\u043f Switchbot.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f." + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "mac": "MAC-\u0430\u0434\u0440\u0435\u0441 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430", + "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435", + "password": "\u041f\u0430\u0440\u043e\u043b\u044c" + }, + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 Switchbot" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "retry_count": "\u041a\u043e\u043b\u0438\u0447\u0435\u0441\u0442\u0432\u043e \u043f\u043e\u0432\u0442\u043e\u0440\u043d\u044b\u0445 \u043f\u043e\u043f\u044b\u0442\u043e\u043a", + "retry_timeout": "\u0422\u0430\u0439\u043c-\u0430\u0443\u0442 \u043c\u0435\u0436\u0434\u0443 \u043f\u043e\u0432\u0442\u043e\u0440\u043d\u044b\u043c\u0438 \u043f\u043e\u043f\u044b\u0442\u043a\u0430\u043c\u0438", + "scan_timeout": "\u041a\u0430\u043a \u0434\u043e\u043b\u0433\u043e \u0441\u043a\u0430\u043d\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u0440\u0435\u043a\u043b\u0430\u043c\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435", + "update_time": "\u0412\u0440\u0435\u043c\u044f \u043c\u0435\u0436\u0434\u0443 \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u044f\u043c\u0438 (\u0432 \u0441\u0435\u043a\u0443\u043d\u0434\u0430\u0445)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switchbot/translations/zh-Hant.json b/homeassistant/components/switchbot/translations/zh-Hant.json new file mode 100644 index 00000000000..44fe1fe5c54 --- /dev/null +++ b/homeassistant/components/switchbot/translations/zh-Hant.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured_device": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "no_unconfigured_devices": "\u627e\u4e0d\u5230\u4efb\u4f55\u672a\u8a2d\u5b9a\u88dd\u7f6e\u3002", + "switchbot_unsupported_type": "\u4e0d\u652f\u6301\u7684 Switchbot \u985e\u578b\u3002", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557" + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "mac": "\u88dd\u7f6e MAC \u4f4d\u5740", + "name": "\u540d\u7a31", + "password": "\u5bc6\u78bc" + }, + "title": "\u8a2d\u5b9a Switchbot \u88dd\u7f6e" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "retry_count": "\u91cd\u8a66\u6b21\u6578", + "retry_timeout": "\u903e\u6642", + "scan_timeout": "\u6383\u63cf\u5ee3\u544a\u6578\u64da\u7684\u6642\u9593", + "update_time": "\u66f4\u65b0\u9593\u9694\u6642\u9593\uff08\u79d2\uff09" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switcher_kis/translations/es.json b/homeassistant/components/switcher_kis/translations/es.json new file mode 100644 index 00000000000..520df7ee4cd --- /dev/null +++ b/homeassistant/components/switcher_kis/translations/es.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "No se encontraron dispositivos en la red", + "single_instance_allowed": "Ya est\u00e1 configurado. Solo es posible una \u00fanica configuraci\u00f3n." + }, + "step": { + "confirm": { + "description": "\u00bfQuieres iniciar la configuraci\u00f3n?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switcher_kis/translations/hu.json b/homeassistant/components/switcher_kis/translations/hu.json index c3be866fb85..20237758f11 100644 --- a/homeassistant/components/switcher_kis/translations/hu.json +++ b/homeassistant/components/switcher_kis/translations/hu.json @@ -6,7 +6,7 @@ }, "step": { "confirm": { - "description": "El akarja kezdeni a be\u00e1ll\u00edt\u00e1sokat?" + "description": "El szeretn\u00e9 kezdeni a be\u00e1ll\u00edt\u00e1sokat?" } } } diff --git a/homeassistant/components/switcher_kis/translations/id.json b/homeassistant/components/switcher_kis/translations/id.json new file mode 100644 index 00000000000..223836a8b40 --- /dev/null +++ b/homeassistant/components/switcher_kis/translations/id.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "Tidak ada perangkat yang ditemukan di jaringan", + "single_instance_allowed": "Sudah dikonfigurasi. Hanya satu konfigurasi yang diizinkan." + }, + "step": { + "confirm": { + "description": "Ingin memulai penyiapan?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switcher_kis/translations/nl.json b/homeassistant/components/switcher_kis/translations/nl.json index d11896014fd..0671f0b3674 100644 --- a/homeassistant/components/switcher_kis/translations/nl.json +++ b/homeassistant/components/switcher_kis/translations/nl.json @@ -6,7 +6,7 @@ }, "step": { "confirm": { - "description": "Wil je beginnen met instellen?" + "description": "Wilt u beginnen met instellen?" } } } diff --git a/homeassistant/components/syncthru/translations/id.json b/homeassistant/components/syncthru/translations/id.json index 54d5e6f5c96..5a79e74a771 100644 --- a/homeassistant/components/syncthru/translations/id.json +++ b/homeassistant/components/syncthru/translations/id.json @@ -8,7 +8,7 @@ "syncthru_not_supported": "Perangkat tidak mendukung SyncThru", "unknown_state": "Status printer tidak diketahui, verifikasi URL dan konektivitas jaringan" }, - "flow_title": "Printer Samsung SyncThru: {name}", + "flow_title": "{name}", "step": { "confirm": { "data": { diff --git a/homeassistant/components/synology_dsm/translations/es.json b/homeassistant/components/synology_dsm/translations/es.json index 571dced9bc7..e143a636fb0 100644 --- a/homeassistant/components/synology_dsm/translations/es.json +++ b/homeassistant/components/synology_dsm/translations/es.json @@ -1,7 +1,9 @@ { "config": { "abort": { - "already_configured": "El host ya est\u00e1 configurado." + "already_configured": "El host ya est\u00e1 configurado.", + "reauth_successful": "La reautenticaci\u00f3n se realiz\u00f3 correctamente", + "reconfigure_successful": "La reconfiguraci\u00f3n se realiz\u00f3 correctamente" }, "error": { "cannot_connect": "No se pudo conectar", @@ -31,10 +33,18 @@ }, "reauth": { "data": { - "password": "Contrase\u00f1a" + "password": "Contrase\u00f1a", + "username": "Usuario" }, "description": "Raz\u00f3n: {details}", - "title": "Synology DSM Volver a autenticar la integraci\u00f3n" + "title": "Volver a autenticar la integraci\u00f3n Synology DSM" + }, + "reauth_confirm": { + "data": { + "password": "Contrase\u00f1a", + "username": "Usuario" + }, + "title": "Volver a autenticar la integraci\u00f3n Synology DSM" }, "user": { "data": { diff --git a/homeassistant/components/synology_dsm/translations/hu.json b/homeassistant/components/synology_dsm/translations/hu.json index 56a5ebb994f..f3f3d4ead4c 100644 --- a/homeassistant/components/synology_dsm/translations/hu.json +++ b/homeassistant/components/synology_dsm/translations/hu.json @@ -28,7 +28,7 @@ "username": "Felhaszn\u00e1l\u00f3n\u00e9v", "verify_ssl": "SSL-tan\u00fas\u00edtv\u00e1ny ellen\u0151rz\u00e9se" }, - "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a {name}({host})-t?", + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani: {name} ({host})?", "title": "Synology DSM" }, "reauth": { @@ -39,9 +39,16 @@ "description": "Indokl\u00e1s: {details}", "title": "Synology DSM Integr\u00e1ci\u00f3 \u00fajrahiteles\u00edt\u00e9se" }, + "reauth_confirm": { + "data": { + "password": "Jelsz\u00f3", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + }, + "title": "Synology DSM Integr\u00e1ci\u00f3 \u00fajrahiteles\u00edt\u00e9se" + }, "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "password": "Jelsz\u00f3", "port": "Port", "ssl": "SSL tan\u00fas\u00edtv\u00e1ny haszn\u00e1lata", diff --git a/homeassistant/components/synology_dsm/translations/id.json b/homeassistant/components/synology_dsm/translations/id.json index e614c2578d4..ca322fc518e 100644 --- a/homeassistant/components/synology_dsm/translations/id.json +++ b/homeassistant/components/synology_dsm/translations/id.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Perangkat sudah dikonfigurasi" + "already_configured": "Perangkat sudah dikonfigurasi", + "reauth_successful": "Autentikasi ulang berhasil" }, "error": { "cannot_connect": "Gagal terhubung", @@ -10,7 +11,7 @@ "otp_failed": "Autentikasi dua langkah gagal, coba lagi dengan kode sandi baru", "unknown": "Kesalahan yang tidak diharapkan" }, - "flow_title": "Synology DSM {name} ({host})", + "flow_title": "{name} ({host})", "step": { "2sa": { "data": { @@ -29,6 +30,18 @@ "description": "Ingin menyiapkan {name} ({host})?", "title": "Synology DSM" }, + "reauth": { + "data": { + "username": "Nama Pengguna" + }, + "title": "Autentikasi Ulang Integrasi Synology DSM" + }, + "reauth_confirm": { + "data": { + "password": "Kata Sandi", + "username": "Nama Pengguna" + } + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/synology_dsm/translations/it.json b/homeassistant/components/synology_dsm/translations/it.json index a9ea23bf08a..41c535f714f 100644 --- a/homeassistant/components/synology_dsm/translations/it.json +++ b/homeassistant/components/synology_dsm/translations/it.json @@ -39,6 +39,13 @@ "description": "Motivo: {details}", "title": "Synology DSM Autenticare nuovamente l'integrazione" }, + "reauth_confirm": { + "data": { + "password": "Password", + "username": "Nome utente" + }, + "title": "Autenticare nuovamente l'integrazione Synology DSM " + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/synology_dsm/translations/nl.json b/homeassistant/components/synology_dsm/translations/nl.json index d33ed48ce10..8740308faf0 100644 --- a/homeassistant/components/synology_dsm/translations/nl.json +++ b/homeassistant/components/synology_dsm/translations/nl.json @@ -39,6 +39,13 @@ "description": "Reden: {details}", "title": "Synology DSM Verifieer de integratie opnieuw" }, + "reauth_confirm": { + "data": { + "password": "Wachtwoord", + "username": "Gebruikersnaam" + }, + "title": "Synology DSM Integratie opnieuw verifi\u00ebren" + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/system_bridge/translations/hu.json b/homeassistant/components/system_bridge/translations/hu.json index 50643ca5e95..31082202419 100644 --- a/homeassistant/components/system_bridge/translations/hu.json +++ b/homeassistant/components/system_bridge/translations/hu.json @@ -21,7 +21,7 @@ "user": { "data": { "api_key": "API kulcs", - "host": "Gazdag\u00e9p", + "host": "C\u00edm", "port": "Port" }, "description": "K\u00e9rj\u00fck, adja meg kapcsolati adatait." diff --git a/homeassistant/components/tasmota/translations/hu.json b/homeassistant/components/tasmota/translations/hu.json index 72a26925bc9..3a6c32dbb42 100644 --- a/homeassistant/components/tasmota/translations/hu.json +++ b/homeassistant/components/tasmota/translations/hu.json @@ -11,11 +11,11 @@ "data": { "discovery_prefix": "Felder\u00edt\u00e9si t\u00e9ma el\u0151tagja" }, - "description": "Add meg a Tasmota konfigur\u00e1ci\u00f3t.", + "description": "Adja meg a Tasmota konfigur\u00e1ci\u00f3t.", "title": "Tasmota" }, "confirm": { - "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a Tasmota-t?" + "description": "Szeretn\u00e9 b\u00e1ll\u00edtani a Tasmota-t?" } } } diff --git a/homeassistant/components/tellduslive/translations/he.json b/homeassistant/components/tellduslive/translations/he.json index d19fa6d3d31..6a1c8a23c65 100644 --- a/homeassistant/components/tellduslive/translations/he.json +++ b/homeassistant/components/tellduslive/translations/he.json @@ -3,7 +3,8 @@ "abort": { "already_configured": "\u05e9\u05d9\u05e8\u05d5\u05ea \u05d6\u05d4 \u05db\u05d1\u05e8 \u05de\u05d5\u05d2\u05d3\u05e8", "authorize_url_timeout": "\u05e4\u05e1\u05e7 \u05d6\u05de\u05df \u05dc\u05d9\u05e6\u05d9\u05e8\u05ea \u05db\u05ea\u05d5\u05d1\u05ea URL \u05dc\u05d0\u05d9\u05e9\u05d5\u05e8.", - "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4", + "unknown_authorize_url_generation": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05dc\u05d0 \u05d9\u05d3\u05d5\u05e2\u05d4 \u05d1\u05d9\u05e6\u05d9\u05e8\u05ea \u05db\u05ea\u05d5\u05d1\u05ea URL \u05e9\u05dc \u05d4\u05e8\u05e9\u05d0\u05d4." }, "error": { "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9" diff --git a/homeassistant/components/tellduslive/translations/hu.json b/homeassistant/components/tellduslive/translations/hu.json index 207e9ada090..a07259b67f9 100644 --- a/homeassistant/components/tellduslive/translations/hu.json +++ b/homeassistant/components/tellduslive/translations/hu.json @@ -11,15 +11,15 @@ }, "step": { "auth": { - "description": "A TelldusLive-fi\u00f3k \u00f6sszekapcsol\u00e1sa:\n 1. Kattintson az al\u00e1bbi linkre\n 2. Jelentkezzen be a Telldus Live szolg\u00e1ltat\u00e1sba\n 3. Enged\u00e9lyezze ** {app_name} ** (kattintson a ** Yes ** gombra).\n 4. J\u00f6jj\u00f6n vissza ide, \u00e9s kattintson a ** SUBMIT ** gombra. \n\n [Link TelldusLive-fi\u00f3k] ( {auth_url} )", + "description": "A TelldusLive-fi\u00f3k \u00f6sszekapcsol\u00e1sa:\n 1. Kattintson az al\u00e1bbi linkre\n 2. Jelentkezzen be a Telldus Live szolg\u00e1ltat\u00e1sba\n 3. Enged\u00e9lyezzeie kell **{app_name}** (kattintson a ** Yes ** gombra).\n 4. J\u00f6jj\u00f6n vissza ide, \u00e9s kattintson a ** K\u00fcld\u00e9s ** gombra. \n\n [Link TelldusLive-fi\u00f3k]({auth_url})", "title": "Hiteles\u00edtsen a TelldusLive-on" }, "user": { "data": { - "host": "Hoszt" + "host": "C\u00edm" }, "description": "\u00dcres", - "title": "V\u00e1lassz v\u00e9gpontot." + "title": "V\u00e1lasszon v\u00e9gpontot." } } } diff --git a/homeassistant/components/tibber/translations/hu.json b/homeassistant/components/tibber/translations/hu.json index 6ad59022845..1ff558b7280 100644 --- a/homeassistant/components/tibber/translations/hu.json +++ b/homeassistant/components/tibber/translations/hu.json @@ -13,7 +13,7 @@ "data": { "access_token": "Hozz\u00e1f\u00e9r\u00e9si token" }, - "description": "Add meg a hozz\u00e1f\u00e9r\u00e9si tokent a https://developer.tibber.com/settings/accesstoken c\u00edmr\u0151l", + "description": "Adja meg a hozz\u00e1f\u00e9r\u00e9si tokent a https://developer.tibber.com/settings/accesstoken c\u00edmr\u0151l", "title": "Tibber" } } diff --git a/homeassistant/components/tile/translations/ca.json b/homeassistant/components/tile/translations/ca.json index 60c31b8dce6..1d70a94f7af 100644 --- a/homeassistant/components/tile/translations/ca.json +++ b/homeassistant/components/tile/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat" + "already_configured": "El compte ja est\u00e0 configurat" }, "error": { "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida" diff --git a/homeassistant/components/toon/translations/he.json b/homeassistant/components/toon/translations/he.json index 431a7b32509..c4269a46b1c 100644 --- a/homeassistant/components/toon/translations/he.json +++ b/homeassistant/components/toon/translations/he.json @@ -3,7 +3,8 @@ "abort": { "authorize_url_timeout": "\u05e4\u05e1\u05e7 \u05d6\u05de\u05df \u05dc\u05d9\u05e6\u05d9\u05e8\u05ea \u05db\u05ea\u05d5\u05d1\u05ea URL \u05dc\u05d0\u05d9\u05e9\u05d5\u05e8.", "missing_configuration": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05e8\u05db\u05d9\u05d1 \u05dc\u05d0 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e0\u05d0 \u05e2\u05e7\u05d5\u05d1 \u05d0\u05d7\u05e8 \u05d4\u05ea\u05d9\u05e2\u05d5\u05d3.", - "no_url_available": "\u05d0\u05d9\u05df \u05db\u05ea\u05d5\u05d1\u05ea \u05d0\u05ea\u05e8 \u05d6\u05de\u05d9\u05e0\u05d4. \u05e7\u05d1\u05dc\u05ea \u05de\u05d9\u05d3\u05e2 \u05e2\u05dc \u05e9\u05d2\u05d9\u05d0\u05d4 \u05d6\u05d5, [\u05e2\u05d9\u05d9\u05df \u05d1\u05e1\u05e2\u05d9\u05e3 \u05d4\u05e2\u05d6\u05e8\u05d4] ({docs_url})" + "no_url_available": "\u05d0\u05d9\u05df \u05db\u05ea\u05d5\u05d1\u05ea \u05d0\u05ea\u05e8 \u05d6\u05de\u05d9\u05e0\u05d4. \u05e7\u05d1\u05dc\u05ea \u05de\u05d9\u05d3\u05e2 \u05e2\u05dc \u05e9\u05d2\u05d9\u05d0\u05d4 \u05d6\u05d5, [\u05e2\u05d9\u05d9\u05df \u05d1\u05e1\u05e2\u05d9\u05e3 \u05d4\u05e2\u05d6\u05e8\u05d4] ({docs_url})", + "unknown_authorize_url_generation": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05dc\u05d0 \u05d9\u05d3\u05d5\u05e2\u05d4 \u05d1\u05d9\u05e6\u05d9\u05e8\u05ea \u05db\u05ea\u05d5\u05d1\u05ea URL \u05e9\u05dc \u05d4\u05e8\u05e9\u05d0\u05d4." } } } \ No newline at end of file diff --git a/homeassistant/components/toon/translations/hu.json b/homeassistant/components/toon/translations/hu.json index 18f333dccdf..b1a69144dd5 100644 --- a/homeassistant/components/toon/translations/hu.json +++ b/homeassistant/components/toon/translations/hu.json @@ -3,7 +3,7 @@ "abort": { "already_configured": "A kiv\u00e1lasztott meg\u00e1llapod\u00e1s m\u00e1r konfigur\u00e1lva van.", "authorize_url_timeout": "Id\u0151t\u00fall\u00e9p\u00e9s a hiteles\u00edt\u00e9si URL gener\u00e1l\u00e1sa sor\u00e1n.", - "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rlek, k\u00f6vesd a dokument\u00e1ci\u00f3t.", + "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rem, k\u00f6vesse a dokument\u00e1ci\u00f3t.", "no_agreements": "Ennek a fi\u00f3knak nincsenek Toon kijelz\u0151i.", "no_url_available": "Nincs el\u00e9rhet\u0151 URL. A hib\u00e1r\u00f3l tov\u00e1bbi inform\u00e1ci\u00f3t [a s\u00fag\u00f3ban]({docs_url}) tal\u00e1lsz.", "unknown_authorize_url_generation": "Ismeretlen hiba t\u00f6rt\u00e9nt a hiteles\u00edt\u00e9si link gener\u00e1l\u00e1sa sor\u00e1n." @@ -17,7 +17,7 @@ "title": "V\u00e1lassza ki a meg\u00e1llapod\u00e1st" }, "pick_implementation": { - "title": "V\u00e1lassza ki a hiteles\u00edt\u00e9shez" + "title": "V\u00e1lassza ki a b\u00e9rl\u0151t a hiteles\u00edt\u00e9shez" } } } diff --git a/homeassistant/components/totalconnect/translations/ca.json b/homeassistant/components/totalconnect/translations/ca.json index cbe1d4e449c..fa42c81e1be 100644 --- a/homeassistant/components/totalconnect/translations/ca.json +++ b/homeassistant/components/totalconnect/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat", + "already_configured": "El compte ja est\u00e0 configurat", "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" }, "error": { diff --git a/homeassistant/components/totalconnect/translations/es.json b/homeassistant/components/totalconnect/translations/es.json index c4923884c43..63d61445ef5 100644 --- a/homeassistant/components/totalconnect/translations/es.json +++ b/homeassistant/components/totalconnect/translations/es.json @@ -14,7 +14,7 @@ "location": "Localizaci\u00f3n", "usercode": "Codigo de usuario" }, - "description": "Ingrese el c\u00f3digo de usuario para este usuario en esta ubicaci\u00f3n", + "description": "Introduce el c\u00f3digo de usuario para este usuario en la ubicaci\u00f3n {location_id}", "title": "C\u00f3digos de usuario de ubicaci\u00f3n" }, "reauth_confirm": { diff --git a/homeassistant/components/totalconnect/translations/hu.json b/homeassistant/components/totalconnect/translations/hu.json index 319611fd2b1..9b55278e9a8 100644 --- a/homeassistant/components/totalconnect/translations/hu.json +++ b/homeassistant/components/totalconnect/translations/hu.json @@ -14,7 +14,7 @@ "location": "Elhelyezked\u00e9s", "usercode": "Felhaszn\u00e1l\u00f3i k\u00f3d" }, - "description": "Adja meg ennek a felhaszn\u00e1l\u00f3nak a felhaszn\u00e1l\u00f3i k\u00f3dj\u00e1t a k\u00f6vetkez\u0151 helyen: {location_id}", + "description": "Adja meg ennek a felhaszn\u00e1l\u00f3i k\u00f3dj\u00e1t a k\u00f6vetkez\u0151 helyen: {location_id}", "title": "Helyhaszn\u00e1lati k\u00f3dok" }, "reauth_confirm": { diff --git a/homeassistant/components/totalconnect/translations/id.json b/homeassistant/components/totalconnect/translations/id.json index c1bdf664994..b1bc5573021 100644 --- a/homeassistant/components/totalconnect/translations/id.json +++ b/homeassistant/components/totalconnect/translations/id.json @@ -13,7 +13,7 @@ "data": { "location": "Lokasi" }, - "description": "Masukkan kode pengguna untuk pengguna ini di lokasi ini", + "description": "Masukkan kode pengguna untuk pengguna ini di lokasi {location_id}", "title": "Lokasi Kode Pengguna" }, "reauth_confirm": { diff --git a/homeassistant/components/tplink/translations/ca.json b/homeassistant/components/tplink/translations/ca.json index 69dfc1b4b9d..4dfb749a9d7 100644 --- a/homeassistant/components/tplink/translations/ca.json +++ b/homeassistant/components/tplink/translations/ca.json @@ -1,12 +1,31 @@ { "config": { "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat", "no_devices_found": "No s'han trobat dispositius a la xarxa", "single_instance_allowed": "Ja configurat. Nom\u00e9s \u00e9s possible una sola configuraci\u00f3." }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3" + }, + "flow_title": "{name} {model} ({host})", "step": { "confirm": { "description": "Vols configurar dispositius intel\u00b7ligents TP-Link?" + }, + "discovery_confirm": { + "description": "Vols configurar {name} {model} ({host})?" + }, + "pick_device": { + "data": { + "device": "Dispositiu" + } + }, + "user": { + "data": { + "host": "Amfitri\u00f3" + }, + "description": "Si deixes l'amfitri\u00f3 buit, s'utilitzar\u00e0 el descobriment per cercar dispositius." } } } diff --git a/homeassistant/components/tplink/translations/de.json b/homeassistant/components/tplink/translations/de.json index 6f804a6eeef..4d6a07b881e 100644 --- a/homeassistant/components/tplink/translations/de.json +++ b/homeassistant/components/tplink/translations/de.json @@ -1,12 +1,31 @@ { "config": { "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert", "no_devices_found": "Keine Ger\u00e4te im Netzwerk gefunden", "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich." }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen" + }, + "flow_title": "{name} {model} ({host})", "step": { "confirm": { "description": "M\u00f6chtest du TP-Link Smart Devices einrichten?" + }, + "discovery_confirm": { + "description": "M\u00f6chtest du {name} {model} ({host}) einrichten?" + }, + "pick_device": { + "data": { + "device": "Ger\u00e4t" + } + }, + "user": { + "data": { + "host": "Host" + }, + "description": "Wenn du den Host leer l\u00e4sst, wird die Erkennung verwendet, um Ger\u00e4te zu finden." } } } diff --git a/homeassistant/components/tplink/translations/en.json b/homeassistant/components/tplink/translations/en.json index 0697974e708..da4681145d8 100644 --- a/homeassistant/components/tplink/translations/en.json +++ b/homeassistant/components/tplink/translations/en.json @@ -2,13 +2,17 @@ "config": { "abort": { "already_configured": "Device is already configured", - "no_devices_found": "No devices found on the network" + "no_devices_found": "No devices found on the network", + "single_instance_allowed": "Already configured. Only a single configuration possible." }, "error": { "cannot_connect": "Failed to connect" }, "flow_title": "{name} {model} ({host})", "step": { + "confirm": { + "description": "Do you want to setup TP-Link smart devices?" + }, "discovery_confirm": { "description": "Do you want to setup {name} {model} ({host})?" }, diff --git a/homeassistant/components/tplink/translations/et.json b/homeassistant/components/tplink/translations/et.json index 972e581fc61..12c4f3d6f84 100644 --- a/homeassistant/components/tplink/translations/et.json +++ b/homeassistant/components/tplink/translations/et.json @@ -1,12 +1,31 @@ { "config": { "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud", "no_devices_found": "V\u00f5rgust ei leitud seadmeid", "single_instance_allowed": "Juba seadistatud. V\u00f5imalik on ainult \u00fcks seadistamine." }, + "error": { + "cannot_connect": "\u00dchendamine nurjus" + }, + "flow_title": "{name} {model} ( {host} )", "step": { "confirm": { "description": "Kas soovid seadistada TP-Linki nutiseadmeid?" + }, + "discovery_confirm": { + "description": "Kas seadistada {name}{model} ({host})?" + }, + "pick_device": { + "data": { + "device": "Seade" + } + }, + "user": { + "data": { + "host": "Host" + }, + "description": "Kui j\u00e4tad hosti t\u00fchjaks kasutatakse seadmete leidmiseks avastamist." } } } diff --git a/homeassistant/components/tplink/translations/hu.json b/homeassistant/components/tplink/translations/hu.json index bcfb467538d..c00744d0dcf 100644 --- a/homeassistant/components/tplink/translations/hu.json +++ b/homeassistant/components/tplink/translations/hu.json @@ -1,12 +1,31 @@ { "config": { "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r be van konfigur\u00e1lva", "no_devices_found": "Nem tal\u00e1lhat\u00f3 eszk\u00f6z a h\u00e1l\u00f3zaton", "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges." }, + "error": { + "cannot_connect": "A csatlakoz\u00e1s sikertelen" + }, + "flow_title": "{name} {model} ({host})", "step": { "confirm": { - "description": "Be szeretn\u00e9 \u00e1ll\u00edtani a TP-Link intelligens eszk\u00f6z\u00f6ket?" + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani a TP-Link intelligens eszk\u00f6zeit?" + }, + "discovery_confirm": { + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani: {name} {model} ({host})?" + }, + "pick_device": { + "data": { + "device": "Eszk\u00f6z" + } + }, + "user": { + "data": { + "host": "C\u00edm" + }, + "description": "Ha nem ad meg c\u00edmet, akkor az eszk\u00f6z\u00f6k keres\u00e9se a felder\u00edt\u00e9ssel t\u00f6rt\u00e9nik." } } } diff --git a/homeassistant/components/tplink/translations/it.json b/homeassistant/components/tplink/translations/it.json index 8940b1c8ee6..3fd30d8d12c 100644 --- a/homeassistant/components/tplink/translations/it.json +++ b/homeassistant/components/tplink/translations/it.json @@ -1,12 +1,31 @@ { "config": { "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", "no_devices_found": "Nessun dispositivo trovato sulla rete", "single_instance_allowed": "Gi\u00e0 configurato. \u00c8 possibile una sola configurazione." }, + "error": { + "cannot_connect": "Impossibile connettersi" + }, + "flow_title": "{name} {model} ({host})", "step": { "confirm": { "description": "Vuoi configurare i dispositivi intelligenti TP-Link?" + }, + "discovery_confirm": { + "description": "Vuoi configurare {name} {model} ({host})?" + }, + "pick_device": { + "data": { + "device": "Dispositivo" + } + }, + "user": { + "data": { + "host": "Host" + }, + "description": "Se si lascia vuoto l'host, l'individuazione verr\u00e0 utilizzata per trovare i dispositivi." } } } diff --git a/homeassistant/components/tplink/translations/nl.json b/homeassistant/components/tplink/translations/nl.json index 362645d9f19..f6cf6a21e72 100644 --- a/homeassistant/components/tplink/translations/nl.json +++ b/homeassistant/components/tplink/translations/nl.json @@ -1,12 +1,31 @@ { "config": { "abort": { + "already_configured": "Apparaat is al geconfigureerd", "no_devices_found": "Geen apparaten gevonden op het netwerk", "single_instance_allowed": "Al geconfigureerd. Slechts een enkele configuratie mogelijk." }, + "error": { + "cannot_connect": "Kon geen verbinding maken" + }, + "flow_title": "{name} {model} ({host})", "step": { "confirm": { "description": "Wil je TP-Link slimme apparaten instellen?" + }, + "discovery_confirm": { + "description": "Wilt u {name} {model} ({host}) instellen?" + }, + "pick_device": { + "data": { + "device": "Apparaat" + } + }, + "user": { + "data": { + "host": "Host" + }, + "description": "Als u de host leeg laat, wordt detectie gebruikt om apparaten te vinden." } } } diff --git a/homeassistant/components/tplink/translations/no.json b/homeassistant/components/tplink/translations/no.json index 1d1d624ab40..6c7bd7dcbf4 100644 --- a/homeassistant/components/tplink/translations/no.json +++ b/homeassistant/components/tplink/translations/no.json @@ -1,12 +1,31 @@ { "config": { "abort": { + "already_configured": "Enheten er allerede konfigurert", "no_devices_found": "Ingen enheter funnet p\u00e5 nettverket", "single_instance_allowed": "Allerede konfigurert. Bare \u00e9n enkelt konfigurasjon er mulig." }, + "error": { + "cannot_connect": "Tilkobling mislyktes" + }, + "flow_title": "{name} {model} ( {host} )", "step": { "confirm": { "description": "Vil du konfigurere TP-Link smart enheter?" + }, + "discovery_confirm": { + "description": "Vil du konfigurere {name} {model} ( {host} )?" + }, + "pick_device": { + "data": { + "device": "Enhet" + } + }, + "user": { + "data": { + "host": "Vert" + }, + "description": "Hvis du lar verten st\u00e5 tom, brukes automatisk oppdagelse til \u00e5 finne enheter" } } } diff --git a/homeassistant/components/tplink/translations/ru.json b/homeassistant/components/tplink/translations/ru.json index 4df755bee4f..47f1459e572 100644 --- a/homeassistant/components/tplink/translations/ru.json +++ b/homeassistant/components/tplink/translations/ru.json @@ -1,12 +1,31 @@ { "config": { "abort": { + "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant.", "no_devices_found": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b \u0432 \u0441\u0435\u0442\u0438.", "single_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430. \u0412\u043e\u0437\u043c\u043e\u0436\u043d\u043e \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0442\u043e\u043b\u044c\u043a\u043e \u043e\u0434\u043d\u0443 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e." }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f." + }, + "flow_title": "{name} {model} ({host})", "step": { "confirm": { "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c TP-Link Smart Home?" + }, + "discovery_confirm": { + "description": "\u0425\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c {name} {model} ({host})?" + }, + "pick_device": { + "data": { + "device": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e" + } + }, + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442" + }, + "description": "\u0415\u0441\u043b\u0438 \u043d\u0435 \u0443\u043a\u0430\u0437\u044b\u0432\u0430\u0442\u044c \u0430\u0434\u0440\u0435\u0441 \u0445\u043e\u0441\u0442\u0430, \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0431\u0443\u0434\u0443\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u044b \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u0438." } } } diff --git a/homeassistant/components/tplink/translations/zh-Hant.json b/homeassistant/components/tplink/translations/zh-Hant.json index 2fac2ac142d..153783b1b90 100644 --- a/homeassistant/components/tplink/translations/zh-Hant.json +++ b/homeassistant/components/tplink/translations/zh-Hant.json @@ -1,12 +1,31 @@ { "config": { "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", "no_devices_found": "\u7db2\u8def\u4e0a\u627e\u4e0d\u5230\u88dd\u7f6e", "single_instance_allowed": "\u50c5\u80fd\u8a2d\u5b9a\u4e00\u7d44\u88dd\u7f6e\u3002" }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557" + }, + "flow_title": "{name} {model} ({host})", "step": { "confirm": { "description": "\u662f\u5426\u8981\u8a2d\u5b9a TP-Link \u667a\u80fd\u88dd\u7f6e\uff1f" + }, + "discovery_confirm": { + "description": "\u662f\u5426\u8981\u8a2d\u5b9a {name} {model} ({host})\uff1f" + }, + "pick_device": { + "data": { + "device": "\u88dd\u7f6e" + } + }, + "user": { + "data": { + "host": "\u4e3b\u6a5f\u7aef" + }, + "description": "\u5047\u5982\u4e3b\u6a5f\u7aef\u4f4d\u5740\u6b04\u4f4d\u70ba\u7a7a\u767d\uff0c\u5c07\u6703\u63a2\u7d22\u6240\u6709\u53ef\u7528\u88dd\u7f6e\u3002" } } } diff --git a/homeassistant/components/traccar/translations/hu.json b/homeassistant/components/traccar/translations/hu.json index 94fc9198921..902b4ea5231 100644 --- a/homeassistant/components/traccar/translations/hu.json +++ b/homeassistant/components/traccar/translations/hu.json @@ -2,10 +2,10 @@ "config": { "abort": { "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges.", - "webhook_not_internet_accessible": "A Home Assistant rendszerednek el\u00e9rhet\u0151nek kell lennie az internetr\u0151l a webhook \u00fczenetek fogad\u00e1s\u00e1hoz." + "webhook_not_internet_accessible": "A Home Assistant p\u00e9ld\u00e1ny\u00e1nak el\u00e9rhet\u0151nek kell lennie az internet fel\u0151l a webhook \u00fczenetek fogad\u00e1s\u00e1hoz." }, "create_entry": { - "default": "Ha esem\u00e9nyeket szeretne k\u00fcldeni a Home Assistant alkalmaz\u00e1snak, be kell \u00e1ll\u00edtania a webhook funkci\u00f3t a Traccar-ban. \n\n Haszn\u00e1lja a k\u00f6vetkez\u0151 URL-t: `{webhook_url}`\n\n Tov\u00e1bbi r\u00e9szletek\u00e9rt l\u00e1sd a [dokument\u00e1ci\u00f3t]({docs_url})." + "default": "Ha esem\u00e9nyeket szeretne k\u00fcldeni a Home Assistant alkalmaz\u00e1snak, be kell \u00e1ll\u00edtania a webhook funkci\u00f3t a Traccar-ban. \n\nHaszn\u00e1lja a k\u00f6vetkez\u0151 URL-t: `{webhook_url}`\n\nTov\u00e1bbi r\u00e9szletek\u00e9rt l\u00e1ssa a [dokument\u00e1ci\u00f3t]({docs_url})." }, "step": { "user": { diff --git a/homeassistant/components/tractive/translations/es.json b/homeassistant/components/tractive/translations/es.json index 11aa4f1aa9c..9b252a0b2f0 100644 --- a/homeassistant/components/tractive/translations/es.json +++ b/homeassistant/components/tractive/translations/es.json @@ -1,17 +1,18 @@ { "config": { "abort": { - "already_configured": "El sistema ya est\u00e1 configurado" + "already_configured": "El dispositivo ya est\u00e1 configurado", + "reauth_successful": "La reautenticaci\u00f3n se realiz\u00f3 correctamente" }, "error": { - "invalid_auth": "Autenticaci\u00f3n err\u00f3nea", - "unknown": "Error desconocido" + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "unknown": "Error inesperado" }, "step": { "user": { "data": { - "email": "Correo-e", - "password": "Clave" + "email": "Correo electr\u00f3nico", + "password": "Contrase\u00f1a" } } } diff --git a/homeassistant/components/tradfri/translations/fi.json b/homeassistant/components/tradfri/translations/fi.json index 31984784ee6..4946d88778f 100644 --- a/homeassistant/components/tradfri/translations/fi.json +++ b/homeassistant/components/tradfri/translations/fi.json @@ -3,6 +3,9 @@ "abort": { "already_configured": "Silta on jo m\u00e4\u00e4ritetty" }, + "error": { + "cannot_connect": "Yhdist\u00e4minen ep\u00e4onnistui" + }, "step": { "auth": { "data": { diff --git a/homeassistant/components/tradfri/translations/hu.json b/homeassistant/components/tradfri/translations/hu.json index 3bc4ec90e77..e5f749a83df 100644 --- a/homeassistant/components/tradfri/translations/hu.json +++ b/homeassistant/components/tradfri/translations/hu.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van." + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van." }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", @@ -12,11 +12,11 @@ "step": { "auth": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "security_code": "Biztons\u00e1gi K\u00f3d" }, "description": "A biztons\u00e1gi k\u00f3dot a Gatewayed h\u00e1toldal\u00e1n tal\u00e1lod.", - "title": "Add meg a biztons\u00e1gi k\u00f3dot" + "title": "Adja meg a biztons\u00e1gi k\u00f3dot" } } } diff --git a/homeassistant/components/transmission/translations/hu.json b/homeassistant/components/transmission/translations/hu.json index 5c968b21ed7..5e3dcfd2b6c 100644 --- a/homeassistant/components/transmission/translations/hu.json +++ b/homeassistant/components/transmission/translations/hu.json @@ -6,12 +6,12 @@ "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", - "name_exists": "A n\u00e9v m\u00e1r l\u00e9tezik" + "name_exists": "A n\u00e9v m\u00e1r foglalt" }, "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "name": "N\u00e9v", "password": "Jelsz\u00f3", "port": "Port", diff --git a/homeassistant/components/tuya/translations/af.json b/homeassistant/components/tuya/translations/af.json new file mode 100644 index 00000000000..71ac741b6b8 --- /dev/null +++ b/homeassistant/components/tuya/translations/af.json @@ -0,0 +1,8 @@ +{ + "options": { + "error": { + "dev_not_config": "Ger\u00e4tetyp nicht konfigurierbar", + "dev_not_found": "Ger\u00e4t nicht gefunden" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/ca.json b/homeassistant/components/tuya/translations/ca.json new file mode 100644 index 00000000000..6759d322484 --- /dev/null +++ b/homeassistant/components/tuya/translations/ca.json @@ -0,0 +1,79 @@ +{ + "config": { + "abort": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "single_instance_allowed": "Ja configurat. Nom\u00e9s \u00e9s possible una sola configuraci\u00f3." + }, + "error": { + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida" + }, + "flow_title": "Configuraci\u00f3 de Tuya", + "step": { + "login": { + "data": { + "access_id": "ID d'acc\u00e9s", + "access_secret": "Secret d'acc\u00e9s", + "country_code": "Codi de pa\u00eds", + "endpoint": "Zona de disponibilitat", + "password": "Contrasenya", + "tuya_app_type": "Aplicaci\u00f3 per a m\u00f2bil", + "username": "Compte" + }, + "description": "Introdueix la credencial de Tuya", + "title": "Tuya" + }, + "user": { + "data": { + "country_code": "El teu codi de pa\u00eds (per exemple, 1 per l'EUA o 86 per la Xina)", + "password": "Contrasenya", + "platform": "L'aplicaci\u00f3 on es registra el teu compte", + "tuya_project_type": "Tipus de projecte al n\u00favol de Tuya", + "username": "Nom d'usuari" + }, + "description": "Introdueix les teves credencial de Tuya.", + "title": "Integraci\u00f3 Tuya" + } + } + }, + "options": { + "abort": { + "cannot_connect": "Ha fallat la connexi\u00f3" + }, + "error": { + "dev_multi_type": "Per configurar una selecci\u00f3 de m\u00faltiples dispositius, aquests han de ser del mateix tipus", + "dev_not_config": "El tipus d'aquest dispositiu no \u00e9s configurable", + "dev_not_found": "No s'ha trobat el dispositiu." + }, + "step": { + "device": { + "data": { + "brightness_range_mode": "Rang de brillantor utilitzat pel dispositiu", + "curr_temp_divider": "Divisor del valor de temperatura actual (0 = predeterminat)", + "max_kelvin": "Temperatura del color m\u00e0xima suportada, en Kelvin", + "max_temp": "Temperatura desitjada m\u00e0xima (utilitza min i max = 0 per defecte)", + "min_kelvin": "Temperatura del color m\u00ednima suportada, en Kelvin", + "min_temp": "Temperatura desitjada m\u00ednima (utilitza min i max = 0 per defecte)", + "set_temp_divided": "Utilitza el valor de temperatura dividit per a ordres de configuraci\u00f3 de temperatura", + "support_color": "For\u00e7a el suport de color", + "temp_divider": "Divisor del valor de temperatura (0 = predeterminat)", + "temp_step_override": "Pas de temperatura objectiu", + "tuya_max_coltemp": "Temperatura de color m\u00e0xima enviada pel dispositiu", + "unit_of_measurement": "Unitat de temperatura utilitzada pel dispositiu" + }, + "description": "Configura les opcions per ajustar la informaci\u00f3 mostrada pel dispositiu {device_type} `{device_name}`", + "title": "Configuraci\u00f3 de dispositiu Tuya" + }, + "init": { + "data": { + "discovery_interval": "Interval de sondeig del dispositiu de descoberta, en segons", + "list_devices": "Selecciona els dispositius a configurar o deixa-ho buit per desar la configuraci\u00f3", + "query_device": "Selecciona el dispositiu que utilitzar\u00e0 m\u00e8tode de consulta, per actualitzacions d'estat m\u00e9s freq\u00fcents", + "query_interval": "Interval de sondeig de consultes del dispositiu, en segons" + }, + "description": "No estableixis valors d'interval de sondeig massa baixos ja que les crides fallaran i generaran missatges d'error al registre", + "title": "Configuraci\u00f3 d'opcions de Tuya" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/cs.json b/homeassistant/components/tuya/translations/cs.json new file mode 100644 index 00000000000..1dda4ea6df7 --- /dev/null +++ b/homeassistant/components/tuya/translations/cs.json @@ -0,0 +1,60 @@ +{ + "config": { + "abort": { + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", + "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed", + "single_instance_allowed": "Ji\u017e nastaveno. Je mo\u017en\u00e1 pouze jedin\u00e1 konfigurace." + }, + "error": { + "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed" + }, + "flow_title": "Konfigurace Tuya", + "step": { + "user": { + "data": { + "country_code": "K\u00f3d zem\u011b va\u0161eho \u00fa\u010dtu (nap\u0159. 1 pro USA nebo 86 pro \u010c\u00ednu)", + "password": "Heslo", + "platform": "Aplikace, ve kter\u00e9 m\u00e1te zaregistrovan\u00fd \u00fa\u010det", + "username": "U\u017eivatelsk\u00e9 jm\u00e9no" + }, + "description": "Zadejte sv\u00e9 p\u0159ihla\u0161ovac\u00ed \u00fadaje k Tuya.", + "title": "Tuya" + } + } + }, + "options": { + "abort": { + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit" + }, + "error": { + "dev_multi_type": "V\u00edce vybran\u00fdch za\u0159\u00edzen\u00ed k nastaven\u00ed mus\u00ed b\u00fdt stejn\u00e9ho typu", + "dev_not_config": "Typ za\u0159\u00edzen\u00ed nelze nastavit", + "dev_not_found": "Za\u0159\u00edzen\u00ed nenalezeno" + }, + "step": { + "device": { + "data": { + "brightness_range_mode": "Rozsah jasu pou\u017e\u00edvan\u00fd za\u0159\u00edzen\u00edm", + "max_kelvin": "Maxim\u00e1ln\u00ed podporovan\u00e1 teplota barev v kelvinech", + "max_temp": "Maxim\u00e1ln\u00ed c\u00edlov\u00e1 teplota (pou\u017eijte min a max = 0 jako v\u00fdchoz\u00ed)", + "min_kelvin": "Maxim\u00e1ln\u00ed podporovan\u00e1 teplota barev v kelvinech", + "min_temp": "Minim\u00e1ln\u00ed c\u00edlov\u00e1 teplota (pou\u017eijte min a max = 0 jako v\u00fdchoz\u00ed)", + "support_color": "Vynutit podporu barev", + "tuya_max_coltemp": "Maxim\u00e1ln\u00ed teplota barev nahl\u00e1\u0161en\u00e1 za\u0159\u00edzen\u00edm", + "unit_of_measurement": "Jednotka teploty pou\u017e\u00edvan\u00e1 za\u0159\u00edzen\u00edm" + }, + "title": "Nastavte za\u0159\u00edzen\u00ed Tuya" + }, + "init": { + "data": { + "discovery_interval": "Interval objevov\u00e1n\u00ed za\u0159\u00edzen\u00ed v sekund\u00e1ch", + "list_devices": "Vyberte za\u0159\u00edzen\u00ed, kter\u00e1 chcete nastavit, nebo ponechte pr\u00e1zdn\u00e9, abyste konfiguraci ulo\u017eili", + "query_device": "Vyberte za\u0159\u00edzen\u00ed, kter\u00e9 bude pou\u017e\u00edvat metodu dotaz\u016f pro rychlej\u0161\u00ed aktualizaci stavu", + "query_interval": "Interval dotazov\u00e1n\u00ed za\u0159\u00edzen\u00ed v sekund\u00e1ch" + }, + "description": "Nenastavujte intervalu dotazov\u00e1n\u00ed p\u0159\u00edli\u0161 n\u00edzk\u00e9 hodnoty, jinak se dotazov\u00e1n\u00ed nezda\u0159\u00ed a bude generovat chybov\u00e9 zpr\u00e1vy do logu", + "title": "Nastavte mo\u017enosti Tuya" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/de.json b/homeassistant/components/tuya/translations/de.json new file mode 100644 index 00000000000..57439e1fa76 --- /dev/null +++ b/homeassistant/components/tuya/translations/de.json @@ -0,0 +1,79 @@ +{ + "config": { + "abort": { + "cannot_connect": "Verbindung fehlgeschlagen", + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich." + }, + "error": { + "invalid_auth": "Ung\u00fcltige Authentifizierung" + }, + "flow_title": "Tuya Konfiguration", + "step": { + "login": { + "data": { + "access_id": "Zugangs-ID", + "access_secret": "Zugangsgeheimnis", + "country_code": "L\u00e4ndercode", + "endpoint": "Verf\u00fcgbarkeitszone", + "password": "Passwort", + "tuya_app_type": "Mobile App", + "username": "Konto" + }, + "description": "Gib deine Tuya-Anmeldedaten ein", + "title": "Tuya" + }, + "user": { + "data": { + "country_code": "L\u00e4ndercode deines Kontos (z. B. 1 f\u00fcr USA oder 86 f\u00fcr China)", + "password": "Passwort", + "platform": "Die App, in der dein Konto registriert ist", + "tuya_project_type": "Tuya Cloud Projekttyp", + "username": "Benutzername" + }, + "description": "Gib deine Tuya-Anmeldeinformationen ein.", + "title": "Tuya-Integration" + } + } + }, + "options": { + "abort": { + "cannot_connect": "Verbindung fehlgeschlagen" + }, + "error": { + "dev_multi_type": "Mehrere ausgew\u00e4hlte Ger\u00e4te zur Konfiguration m\u00fcssen vom gleichen Typ sein", + "dev_not_config": "Ger\u00e4tetyp nicht konfigurierbar", + "dev_not_found": "Ger\u00e4t nicht gefunden" + }, + "step": { + "device": { + "data": { + "brightness_range_mode": "Vom Ger\u00e4t genutzter Helligkeitsbereich", + "curr_temp_divider": "Aktueller Temperaturwert-Teiler (0 = Standard verwenden)", + "max_kelvin": "Maximal unterst\u00fctzte Farbtemperatur in Kelvin", + "max_temp": "Maximale Solltemperatur (f\u00fcr Voreinstellung min und max = 0 verwenden)", + "min_kelvin": "Minimale unterst\u00fctzte Farbtemperatur in Kelvin", + "min_temp": "Minimal Solltemperatur (f\u00fcr Voreinstellung min und max = 0 verwenden)", + "set_temp_divided": "Geteilten Temperaturwert f\u00fcr Solltemperaturbefehl verwenden", + "support_color": "Farbunterst\u00fctzung erzwingen", + "temp_divider": "Teiler f\u00fcr Temperaturwerte (0 = Standard verwenden)", + "temp_step_override": "Zieltemperaturschritt", + "tuya_max_coltemp": "Vom Ger\u00e4t gemeldete maximale Farbtemperatur", + "unit_of_measurement": "Vom Ger\u00e4t verwendete Temperatureinheit" + }, + "description": "Optionen zur Anpassung der angezeigten Informationen f\u00fcr das Ger\u00e4t `{device_name}` vom Typ: {device_type}konfigurieren", + "title": "Tuya-Ger\u00e4t konfigurieren" + }, + "init": { + "data": { + "discovery_interval": "Abfrageintervall f\u00fcr Ger\u00e4teabruf in Sekunden", + "list_devices": "W\u00e4hle die zu konfigurierenden Ger\u00e4te aus oder lasse sie leer, um die Konfiguration zu speichern", + "query_device": "W\u00e4hle ein Ger\u00e4t aus, das die Abfragemethode f\u00fcr eine schnellere Statusaktualisierung verwendet.", + "query_interval": "Ger\u00e4teabrufintervall in Sekunden" + }, + "description": "Stelle das Abfrageintervall nicht zu niedrig ein, sonst schlagen die Aufrufe fehl und erzeugen eine Fehlermeldung im Protokoll", + "title": "Tuya-Optionen konfigurieren" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/en.json b/homeassistant/components/tuya/translations/en.json index 631f0b7172f..c7aaee977ee 100644 --- a/homeassistant/components/tuya/translations/en.json +++ b/homeassistant/components/tuya/translations/en.json @@ -1,29 +1,79 @@ { "config": { + "abort": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "single_instance_allowed": "Already configured. Only a single configuration possible." + }, "error": { "invalid_auth": "Invalid authentication" }, "flow_title": "Tuya configuration", "step": { - "user":{ - "title":"Tuya Integration", - "data":{ - "tuya_project_type": "Tuya cloud project type" - } - }, "login": { "data": { - "endpoint": "Availability Zone", "access_id": "Access ID", "access_secret": "Access Secret", - "tuya_app_type": "Mobile App", "country_code": "Country Code", - "username": "Account", - "password": "Password" + "endpoint": "Availability Zone", + "password": "Password", + "tuya_app_type": "Mobile App", + "username": "Account" }, - "description": "Enter your Tuya credential.", + "description": "Enter your Tuya credential", "title": "Tuya" + }, + "user": { + "data": { + "country_code": "Your account country code (e.g., 1 for USA or 86 for China)", + "password": "Password", + "platform": "The app where your account is registered", + "tuya_project_type": "Tuya cloud project type", + "username": "Username" + }, + "description": "Enter your Tuya credentials.", + "title": "Tuya Integration" + } + } + }, + "options": { + "abort": { + "cannot_connect": "Failed to connect" + }, + "error": { + "dev_multi_type": "Multiple selected devices to configure must be of the same type", + "dev_not_config": "Device type not configurable", + "dev_not_found": "Device not found" + }, + "step": { + "device": { + "data": { + "brightness_range_mode": "Brightness range used by device", + "curr_temp_divider": "Current Temperature value divider (0 = use default)", + "max_kelvin": "Max color temperature supported in kelvin", + "max_temp": "Max target temperature (use min and max = 0 for default)", + "min_kelvin": "Min color temperature supported in kelvin", + "min_temp": "Min target temperature (use min and max = 0 for default)", + "set_temp_divided": "Use divided Temperature value for set temperature command", + "support_color": "Force color support", + "temp_divider": "Temperature values divider (0 = use default)", + "temp_step_override": "Target Temperature step", + "tuya_max_coltemp": "Max color temperature reported by device", + "unit_of_measurement": "Temperature unit used by device" + }, + "description": "Configure options to adjust displayed information for {device_type} device `{device_name}`", + "title": "Configure Tuya Device" + }, + "init": { + "data": { + "discovery_interval": "Discovery device polling interval in seconds", + "list_devices": "Select the devices to configure or leave empty to save configuration", + "query_device": "Select device that will use query method for faster status update", + "query_interval": "Query device polling interval in seconds" + }, + "description": "Do not set pollings interval values too low or the calls will fail generating error message in the log", + "title": "Configure Tuya Options" } } } -} +} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/es.json b/homeassistant/components/tuya/translations/es.json new file mode 100644 index 00000000000..74649379a7c --- /dev/null +++ b/homeassistant/components/tuya/translations/es.json @@ -0,0 +1,79 @@ +{ + "config": { + "abort": { + "cannot_connect": "No se pudo conectar", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "single_instance_allowed": "Ya est\u00e1 configurado. Solo es posible una \u00fanica configuraci\u00f3n." + }, + "error": { + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida" + }, + "flow_title": "Configuraci\u00f3n Tuya", + "step": { + "login": { + "data": { + "access_id": "ID de acceso", + "access_secret": "Acceso secreto", + "country_code": "C\u00f3digo de pa\u00eds", + "endpoint": "Zona de disponibilidad", + "password": "Contrase\u00f1a", + "tuya_app_type": "Aplicaci\u00f3n m\u00f3vil", + "username": "Cuenta" + }, + "description": "Ingrese su credencial Tuya", + "title": "Tuya" + }, + "user": { + "data": { + "country_code": "C\u00f3digo de pais de tu cuenta (por ejemplo, 1 para USA o 86 para China)", + "password": "Contrase\u00f1a", + "platform": "La aplicaci\u00f3n en la cual registraste tu cuenta", + "tuya_project_type": "Tipo de proyecto en la nube de Tuya", + "username": "Usuario" + }, + "description": "Introduce tu credencial Tuya.", + "title": "Tuya" + } + } + }, + "options": { + "abort": { + "cannot_connect": "No se pudo conectar" + }, + "error": { + "dev_multi_type": "Los m\u00faltiples dispositivos seleccionados para configurar deben ser del mismo tipo", + "dev_not_config": "Tipo de dispositivo no configurable", + "dev_not_found": "Dispositivo no encontrado" + }, + "step": { + "device": { + "data": { + "brightness_range_mode": "Rango de brillo utilizado por el dispositivo", + "curr_temp_divider": "Divisor del valor de la temperatura actual (0 = usar valor por defecto)", + "max_kelvin": "Temperatura de color m\u00e1xima admitida en kelvin", + "max_temp": "Temperatura objetivo m\u00e1xima (usa m\u00edn. y m\u00e1x. = 0 por defecto)", + "min_kelvin": "Temperatura de color m\u00ednima soportada en kelvin", + "min_temp": "Temperatura objetivo m\u00ednima (usa m\u00edn. y m\u00e1x. = 0 por defecto)", + "set_temp_divided": "Use el valor de temperatura dividido para el comando de temperatura establecida", + "support_color": "Forzar soporte de color", + "temp_divider": "Divisor de los valores de temperatura (0 = usar valor por defecto)", + "temp_step_override": "Temperatura deseada", + "tuya_max_coltemp": "Temperatura de color m\u00e1xima notificada por dispositivo", + "unit_of_measurement": "Unidad de temperatura utilizada por el dispositivo" + }, + "description": "Configura las opciones para ajustar la informaci\u00f3n mostrada para {device_type} dispositivo `{device_name}`", + "title": "Configurar dispositivo Tuya" + }, + "init": { + "data": { + "discovery_interval": "Intervalo de sondeo del descubrimiento al dispositivo en segundos", + "list_devices": "Selecciona los dispositivos a configurar o d\u00e9jalos en blanco para guardar la configuraci\u00f3n", + "query_device": "Selecciona el dispositivo que utilizar\u00e1 el m\u00e9todo de consulta para una actualizaci\u00f3n de estado m\u00e1s r\u00e1pida", + "query_interval": "Intervalo de sondeo de la consulta al dispositivo en segundos" + }, + "description": "No establezcas valores de intervalo de sondeo demasiado bajos o las llamadas fallar\u00e1n generando un mensaje de error en el registro", + "title": "Configurar opciones de Tuya" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/et.json b/homeassistant/components/tuya/translations/et.json new file mode 100644 index 00000000000..45b4e4d2639 --- /dev/null +++ b/homeassistant/components/tuya/translations/et.json @@ -0,0 +1,79 @@ +{ + "config": { + "abort": { + "cannot_connect": "\u00dchendamine nurjus", + "invalid_auth": "Tuvastamise viga", + "single_instance_allowed": "Juba seadistatud. V\u00f5imalik on ainult \u00fcks sidumine." + }, + "error": { + "invalid_auth": "Tuvastamise viga" + }, + "flow_title": "Tuya seaded", + "step": { + "login": { + "data": { + "access_id": "Juurdep\u00e4\u00e4su ID", + "access_secret": "API salas\u00f5na", + "country_code": "Riigi kood", + "endpoint": "Seadmete regioon", + "password": "Salas\u00f5na", + "tuya_app_type": "Mobiilirakendus", + "username": "Konto" + }, + "description": "Sisesta oma Tuya mandaat", + "title": "Tuya" + }, + "user": { + "data": { + "country_code": "Konto riigikood (nt 1 USA v\u00f5i 372 Eesti)", + "password": "Salas\u00f5na", + "platform": "\u00c4pp kus konto registreeriti", + "tuya_project_type": "Tuya pilveprojekti t\u00fc\u00fcp", + "username": "Kasutajanimi" + }, + "description": "Sisesta oma Tuya konto andmed.", + "title": "Tuya sidumine" + } + } + }, + "options": { + "abort": { + "cannot_connect": "\u00dchendamine nurjus" + }, + "error": { + "dev_multi_type": "Mitu h\u00e4\u00e4lestatavat seadet peavad olema sama t\u00fc\u00fcpi", + "dev_not_config": "Seda t\u00fc\u00fcpi seade pole seadistatav", + "dev_not_found": "Seadet ei leitud" + }, + "step": { + "device": { + "data": { + "brightness_range_mode": "Seadme kasutatav heledusvahemik", + "curr_temp_divider": "Praeguse temperatuuri v\u00e4\u00e4rtuse eraldaja (0 = kasuta vaikev\u00e4\u00e4rtust)", + "max_kelvin": "Maksimaalne v\u00f5imalik v\u00e4rvitemperatuur (Kelvinites)", + "max_temp": "Maksimaalne sihttemperatuur (vaikimisi kasuta min ja max = 0)", + "min_kelvin": "Minimaalne v\u00f5imalik v\u00e4rvitemperatuur (Kelvinites)", + "min_temp": "Minimaalne sihttemperatuur (vaikimisi kasuta min ja max = 0)", + "set_temp_divided": "M\u00e4\u00e4ratud temperatuuri k\u00e4su jaoks kasuta jagatud temperatuuri v\u00e4\u00e4rtust", + "support_color": "Luba v\u00e4rvuse juhtimine", + "temp_divider": "Temperatuuri v\u00e4\u00e4rtuse eraldaja (0 = kasuta vaikev\u00e4\u00e4rtust)", + "temp_step_override": "Sihttemperatuuri samm", + "tuya_max_coltemp": "Seadme teatatud maksimaalne v\u00e4rvitemperatuur", + "unit_of_measurement": "Seadme temperatuuri\u00fchik" + }, + "description": "Suvandid \u00fcksuse {device_type} {device_name} kuvatava teabe muutmiseks", + "title": "H\u00e4\u00e4lesta Tuya seade" + }, + "init": { + "data": { + "discovery_interval": "Seadme leidmisp\u00e4ringute intervall (sekundites)", + "list_devices": "Vali seadistatavad seadmed v\u00f5i j\u00e4ta s\u00e4tete salvestamiseks t\u00fchjaks", + "query_device": "Vali seade, mis kasutab oleku kiiremaks v\u00e4rskendamiseks p\u00e4ringumeetodit", + "query_interval": "P\u00e4ringute intervall (sekundites)" + }, + "description": "\u00c4ra m\u00e4\u00e4ra k\u00fcsitlusintervalli v\u00e4\u00e4rtusi liiga madalaks, vastasel korral v\u00f5ivad p\u00e4ringud logis t\u00f5rketeate genereerida", + "title": "Tuya suvandite seadistamine" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/fi.json b/homeassistant/components/tuya/translations/fi.json new file mode 100644 index 00000000000..3c74a9b8eeb --- /dev/null +++ b/homeassistant/components/tuya/translations/fi.json @@ -0,0 +1,17 @@ +{ + "config": { + "flow_title": "Tuya-asetukset", + "step": { + "user": { + "data": { + "country_code": "Tilisi maakoodi (esim. 1 Yhdysvalloissa, 358 Suomessa)", + "password": "Salasana", + "platform": "Sovellus, johon tili rekister\u00f6id\u00e4\u00e4n", + "username": "K\u00e4ytt\u00e4j\u00e4tunnus" + }, + "description": "Anna Tuya-tunnistetietosi.", + "title": "Tuya" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/fr.json b/homeassistant/components/tuya/translations/fr.json new file mode 100644 index 00000000000..b741d3f9377 --- /dev/null +++ b/homeassistant/components/tuya/translations/fr.json @@ -0,0 +1,65 @@ +{ + "config": { + "abort": { + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide", + "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." + }, + "error": { + "invalid_auth": "Authentification invalide" + }, + "flow_title": "Configuration Tuya", + "step": { + "user": { + "data": { + "country_code": "Le code de pays de votre compte (par exemple, 1 pour les \u00c9tats-Unis ou 86 pour la Chine)", + "password": "Mot de passe", + "platform": "L'application dans laquelle votre compte est enregistr\u00e9", + "username": "Nom d'utilisateur" + }, + "description": "Saisissez vos informations d'identification Tuya.", + "title": "Tuya" + } + } + }, + "options": { + "abort": { + "cannot_connect": "\u00c9chec de connexion" + }, + "error": { + "dev_multi_type": "Plusieurs p\u00e9riph\u00e9riques s\u00e9lectionn\u00e9s \u00e0 configurer doivent \u00eatre du m\u00eame type", + "dev_not_config": "Type d'appareil non configurable", + "dev_not_found": "Appareil non trouv\u00e9" + }, + "step": { + "device": { + "data": { + "brightness_range_mode": "Plage de luminosit\u00e9 utilis\u00e9e par l'appareil", + "curr_temp_divider": "Diviseur de valeur de temp\u00e9rature actuelle (0 = utiliser la valeur par d\u00e9faut)", + "max_kelvin": "Temp\u00e9rature de couleur maximale prise en charge en Kelvin", + "max_temp": "Temp\u00e9rature cible maximale (utilisez min et max = 0 par d\u00e9faut)", + "min_kelvin": "Temp\u00e9rature de couleur minimale prise en charge en kelvin", + "min_temp": "Temp\u00e9rature cible minimale (utilisez min et max = 0 par d\u00e9faut)", + "set_temp_divided": "Utilisez la valeur de temp\u00e9rature divis\u00e9e pour la commande de temp\u00e9rature d\u00e9finie", + "support_color": "Forcer la prise en charge des couleurs", + "temp_divider": "Diviseur de valeurs de temp\u00e9rature (0 = utiliser la valeur par d\u00e9faut)", + "temp_step_override": "Pas de temp\u00e9rature cible", + "tuya_max_coltemp": "Temp\u00e9rature de couleur maximale rapport\u00e9e par l'appareil", + "unit_of_measurement": "Unit\u00e9 de temp\u00e9rature utilis\u00e9e par l'appareil" + }, + "description": "Configurer les options pour ajuster les informations affich\u00e9es pour l'appareil {device_type} ` {device_name} `", + "title": "Configurer l'appareil Tuya" + }, + "init": { + "data": { + "discovery_interval": "Intervalle de d\u00e9couverte de l'appareil en secondes", + "list_devices": "S\u00e9lectionnez les appareils \u00e0 configurer ou laissez vide pour enregistrer la configuration", + "query_device": "S\u00e9lectionnez l'appareil qui utilisera la m\u00e9thode de requ\u00eate pour une mise \u00e0 jour plus rapide de l'\u00e9tat", + "query_interval": "Intervalle d'interrogation de l'appareil en secondes" + }, + "description": "Ne d\u00e9finissez pas des valeurs d'intervalle d'interrogation trop faibles ou les appels \u00e9choueront \u00e0 g\u00e9n\u00e9rer un message d'erreur dans le journal", + "title": "Configurer les options de Tuya" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/he.json b/homeassistant/components/tuya/translations/he.json new file mode 100644 index 00000000000..44a7699e511 --- /dev/null +++ b/homeassistant/components/tuya/translations/he.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "single_instance_allowed": "\u05ea\u05e6\u05d5\u05e8\u05ea\u05d5 \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e8\u05e7 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05d0\u05e4\u05e9\u05e8\u05d9\u05ea." + }, + "error": { + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9" + }, + "flow_title": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d8\u05d5\u05d9\u05d4", + "step": { + "user": { + "data": { + "country_code": "\u05e7\u05d5\u05d3 \u05de\u05d3\u05d9\u05e0\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05e9\u05dc\u05da (\u05dc\u05de\u05e9\u05dc, 1 \u05dc\u05d0\u05e8\u05d4\"\u05d1 \u05d0\u05d5 972 \u05dc\u05d9\u05e9\u05e8\u05d0\u05dc)", + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "platform": "\u05d4\u05d9\u05d9\u05e9\u05d5\u05dd \u05d1\u05d5 \u05e8\u05e9\u05d5\u05dd \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05e9\u05dc\u05da", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + }, + "description": "\u05d4\u05d6\u05df \u05d0\u05ea \u05d0\u05d9\u05e9\u05d5\u05e8\u05d9 \u05d8\u05d5\u05d9\u05d4 \u05e9\u05dc\u05da.", + "title": "Tuya" + } + } + }, + "options": { + "abort": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/hu.json b/homeassistant/components/tuya/translations/hu.json new file mode 100644 index 00000000000..b90d2a2ff81 --- /dev/null +++ b/homeassistant/components/tuya/translations/hu.json @@ -0,0 +1,65 @@ +{ + "config": { + "abort": { + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges." + }, + "error": { + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s" + }, + "flow_title": "Tuya konfigur\u00e1ci\u00f3", + "step": { + "user": { + "data": { + "country_code": "A fi\u00f3k orsz\u00e1gk\u00f3dja (pl. 1 USA, 36 Magyarorsz\u00e1g, vagy 86 K\u00edna)", + "password": "Jelsz\u00f3", + "platform": "Az alkalmaz\u00e1s, ahol a fi\u00f3k regisztr\u00e1lt", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + }, + "description": "Adja meg Tuya hiteles\u00edt\u0151 adatait.", + "title": "Tuya" + } + } + }, + "options": { + "abort": { + "cannot_connect": "A kapcsol\u00f3d\u00e1s nem siker\u00fclt" + }, + "error": { + "dev_multi_type": "T\u00f6bb kiv\u00e1lasztott konfigur\u00e1land\u00f3 eszk\u00f6z eset\u00e9n, azonos t\u00edpus\u00fanak kell lennie", + "dev_not_config": "Ez az eszk\u00f6zt\u00edpus nem konfigur\u00e1lhat\u00f3", + "dev_not_found": "Eszk\u00f6z nem tal\u00e1lhat\u00f3" + }, + "step": { + "device": { + "data": { + "brightness_range_mode": "Az eszk\u00f6z \u00e1ltal haszn\u00e1lt f\u00e9nyer\u0151 tartom\u00e1ny", + "curr_temp_divider": "Aktu\u00e1lis h\u0151m\u00e9rs\u00e9klet-\u00e9rt\u00e9k oszt\u00f3 (0 = alap\u00e9rtelmezetten)", + "max_kelvin": "Maxim\u00e1lis t\u00e1mogatott sz\u00ednh\u0151m\u00e9rs\u00e9klet kelvinben", + "max_temp": "Maxim\u00e1lis k\u00edv\u00e1nt h\u0151m\u00e9rs\u00e9klet (alap\u00e9rtelmezettnek min \u00e9s max 0)", + "min_kelvin": "Minimum t\u00e1mogatott sz\u00ednh\u0151m\u00e9rs\u00e9klet kelvinben", + "min_temp": "Minim\u00e1lis k\u00edv\u00e1nt h\u0151m\u00e9rs\u00e9klet (alap\u00e9rtelmezettnek min \u00e9s max 0)", + "set_temp_divided": "A h\u0151m\u00e9rs\u00e9klet be\u00e1ll\u00edt\u00e1s\u00e1hoz osztott h\u0151m\u00e9rs\u00e9kleti \u00e9rt\u00e9ket haszn\u00e1ljon", + "support_color": "Sz\u00ednt\u00e1mogat\u00e1s k\u00e9nyszer\u00edt\u00e9se", + "temp_divider": "Sz\u00ednh\u0151m\u00e9rs\u00e9klet-\u00e9rt\u00e9kek oszt\u00f3ja (0 = alap\u00e9rtelmezett)", + "temp_step_override": "C\u00e9lh\u0151m\u00e9rs\u00e9klet l\u00e9pcs\u0151", + "tuya_max_coltemp": "Az eszk\u00f6z \u00e1ltal megadott maxim\u00e1lis sz\u00ednh\u0151m\u00e9rs\u00e9klet", + "unit_of_measurement": "Az eszk\u00f6z \u00e1ltal haszn\u00e1lt h\u0151m\u00e9rs\u00e9kleti egys\u00e9g" + }, + "description": "Konfigur\u00e1l\u00e1si lehet\u0151s\u00e9gek a(z) {device_type} t\u00edpus\u00fa `{device_name}` eszk\u00f6z megjelen\u00edtett inform\u00e1ci\u00f3inak be\u00e1ll\u00edt\u00e1s\u00e1hoz", + "title": "Tuya eszk\u00f6z konfigur\u00e1l\u00e1sa" + }, + "init": { + "data": { + "discovery_interval": "Felfedez\u0151 eszk\u00f6z lek\u00e9rdez\u00e9si intervalluma m\u00e1sodpercben", + "list_devices": "V\u00e1lassza ki a konfigur\u00e1lni k\u00edv\u00e1nt eszk\u00f6z\u00f6ket, vagy hagyja \u00fcresen a konfigur\u00e1ci\u00f3 ment\u00e9s\u00e9hez", + "query_device": "V\u00e1lassza ki azt az eszk\u00f6zt, amely a lek\u00e9rdez\u00e9si m\u00f3dszert haszn\u00e1lja a gyorsabb \u00e1llapotfriss\u00edt\u00e9shez", + "query_interval": "Eszk\u00f6z lek\u00e9rdez\u00e9si id\u0151k\u00f6ze m\u00e1sodpercben" + }, + "description": "Ne \u00e1ll\u00edtsd t\u00fal alacsonyra a lek\u00e9rdez\u00e9si intervallum \u00e9rt\u00e9keit, k\u00fcl\u00f6nben a h\u00edv\u00e1sok nem fognak hiba\u00fczenetet gener\u00e1lni a napl\u00f3ban", + "title": "Tuya be\u00e1ll\u00edt\u00e1sok konfigur\u00e1l\u00e1sa" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/id.json b/homeassistant/components/tuya/translations/id.json new file mode 100644 index 00000000000..8b7f196b5a2 --- /dev/null +++ b/homeassistant/components/tuya/translations/id.json @@ -0,0 +1,65 @@ +{ + "config": { + "abort": { + "cannot_connect": "Gagal terhubung", + "invalid_auth": "Autentikasi tidak valid", + "single_instance_allowed": "Sudah dikonfigurasi. Hanya satu konfigurasi yang diizinkan." + }, + "error": { + "invalid_auth": "Autentikasi tidak valid" + }, + "flow_title": "Konfigurasi Tuya", + "step": { + "user": { + "data": { + "country_code": "Kode negara akun Anda (mis., 1 untuk AS atau 86 untuk China)", + "password": "Kata Sandi", + "platform": "Aplikasi tempat akun Anda terdaftar", + "username": "Nama Pengguna" + }, + "description": "Masukkan kredensial Tuya Anda.", + "title": "Tuya" + } + } + }, + "options": { + "abort": { + "cannot_connect": "Gagal terhubung" + }, + "error": { + "dev_multi_type": "Untuk konfigurasi sekaligus, beberapa perangkat yang dipilih harus berjenis sama", + "dev_not_config": "Jenis perangkat tidak dapat dikonfigurasi", + "dev_not_found": "Perangkat tidak ditemukan" + }, + "step": { + "device": { + "data": { + "brightness_range_mode": "Rentang kecerahan yang digunakan oleh perangkat", + "curr_temp_divider": "Pembagi nilai suhu saat ini (0 = gunakan bawaan)", + "max_kelvin": "Suhu warna maksimal yang didukung dalam Kelvin", + "max_temp": "Suhu target maksimal (gunakan min dan maks = 0 untuk bawaan)", + "min_kelvin": "Suhu warna minimal yang didukung dalam Kelvin", + "min_temp": "Suhu target minimal (gunakan min dan maks = 0 untuk bawaan)", + "set_temp_divided": "Gunakan nilai suhu terbagi untuk mengirimkan perintah mengatur suhu", + "support_color": "Paksa dukungan warna", + "temp_divider": "Pembagi nilai suhu (0 = gunakan bawaan)", + "temp_step_override": "Langkah Suhu Target", + "tuya_max_coltemp": "Suhu warna maksimal yang dilaporkan oleh perangkat", + "unit_of_measurement": "Satuan suhu yang digunakan oleh perangkat" + }, + "description": "Konfigurasikan opsi untuk menyesuaikan informasi yang ditampilkan untuk perangkat {device_type} `{device_name}`", + "title": "Konfigurasi Perangkat Tuya" + }, + "init": { + "data": { + "discovery_interval": "Interval polling penemuan perangkat dalam detik", + "list_devices": "Pilih perangkat yang akan dikonfigurasi atau biarkan kosong untuk menyimpan konfigurasi", + "query_device": "Pilih perangkat yang akan menggunakan metode kueri untuk pembaruan status lebih cepat", + "query_interval": "Interval polling perangkat kueri dalam detik" + }, + "description": "Jangan atur nilai interval polling terlalu rendah karena panggilan akan gagal menghasilkan pesan kesalahan dalam log", + "title": "Konfigurasikan Opsi Tuya" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/it.json b/homeassistant/components/tuya/translations/it.json new file mode 100644 index 00000000000..3baed47661c --- /dev/null +++ b/homeassistant/components/tuya/translations/it.json @@ -0,0 +1,79 @@ +{ + "config": { + "abort": { + "cannot_connect": "Impossibile connettersi", + "invalid_auth": "Autenticazione non valida", + "single_instance_allowed": "Gi\u00e0 configurato. \u00c8 possibile una sola configurazione." + }, + "error": { + "invalid_auth": "Autenticazione non valida" + }, + "flow_title": "Configurazione di Tuya", + "step": { + "login": { + "data": { + "access_id": "ID di accesso", + "access_secret": "Accesso segreto", + "country_code": "Prefisso internazionale", + "endpoint": "Zona di disponibilit\u00e0", + "password": "Password", + "tuya_app_type": "App per dispositivi mobili", + "username": "Account" + }, + "description": "Inserisci le tue credenziali Tuya", + "title": "Tuya" + }, + "user": { + "data": { + "country_code": "Prefisso internazionale del tuo account (ad es. 1 per gli Stati Uniti o 86 per la Cina)", + "password": "Password", + "platform": "L'app in cui \u00e8 registrato il tuo account", + "tuya_project_type": "Tipo di progetto Tuya cloud", + "username": "Nome utente" + }, + "description": "Inserisci le tue credenziali Tuya.", + "title": "Integrazione Tuya" + } + } + }, + "options": { + "abort": { + "cannot_connect": "Impossibile connettersi" + }, + "error": { + "dev_multi_type": "Pi\u00f9 dispositivi selezionati da configurare devono essere dello stesso tipo", + "dev_not_config": "Tipo di dispositivo non configurabile", + "dev_not_found": "Dispositivo non trovato" + }, + "step": { + "device": { + "data": { + "brightness_range_mode": "Intervallo di luminosit\u00e0 utilizzato dal dispositivo", + "curr_temp_divider": "Divisore del valore della temperatura corrente (0 = usa il valore predefinito)", + "max_kelvin": "Temperatura colore massima supportata in kelvin", + "max_temp": "Temperatura di destinazione massima (utilizzare min e max = 0 per impostazione predefinita)", + "min_kelvin": "Temperatura colore minima supportata in kelvin", + "min_temp": "Temperatura di destinazione minima (utilizzare min e max = 0 per impostazione predefinita)", + "set_temp_divided": "Utilizzare il valore temperatura diviso per impostare il comando temperatura", + "support_color": "Forza il supporto del colore", + "temp_divider": "Divisore dei valori di temperatura (0 = utilizzare il valore predefinito)", + "temp_step_override": "Passo della temperatura da raggiungere", + "tuya_max_coltemp": "Temperatura di colore massima riportata dal dispositivo", + "unit_of_measurement": "Unit\u00e0 di temperatura utilizzata dal dispositivo" + }, + "description": "Configura le opzioni per regolare le informazioni visualizzate per il dispositivo {device_type} `{device_name}`", + "title": "Configura il dispositivo Tuya" + }, + "init": { + "data": { + "discovery_interval": "Intervallo di scansione di rilevamento dispositivo in secondi", + "list_devices": "Selezionare i dispositivi da configurare o lasciare vuoto per salvare la configurazione", + "query_device": "Selezionare il dispositivo che utilizzer\u00e0 il metodo di interrogazione per un pi\u00f9 rapido aggiornamento dello stato", + "query_interval": "Intervallo di scansione di interrogazione dispositivo in secondi" + }, + "description": "Non impostare valori dell'intervallo di scansione troppo bassi o le chiamate non riusciranno a generare un messaggio di errore nel registro", + "title": "Configura le opzioni Tuya" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/ka.json b/homeassistant/components/tuya/translations/ka.json new file mode 100644 index 00000000000..7c80ef1ffba --- /dev/null +++ b/homeassistant/components/tuya/translations/ka.json @@ -0,0 +1,37 @@ +{ + "options": { + "error": { + "dev_multi_type": "\u10d9\u10dd\u10dc\u10e4\u10d8\u10d2\u10e3\u10e0\u10d0\u10ea\u10d8\u10d8\u10e1\u10d7\u10d5\u10d8\u10e1 \u10e8\u10d4\u10e0\u10e9\u10d4\u10e3\u10da\u10d8 \u10db\u10e0\u10d0\u10d5\u10da\u10dd\u10d1\u10d8\u10d7\u10d8 \u10db\u10dd\u10ec\u10e7\u10dd\u10d1\u10d8\u10da\u10dd\u10d1\u10d0 \u10e3\u10dc\u10d3\u10d0 \u10d8\u10e7\u10dd\u10e1 \u10d4\u10e0\u10d7\u10dc\u10d0\u10d8\u10e0\u10d8 \u10e2\u10d8\u10de\u10d8\u10e1", + "dev_not_config": "\u10db\u10dd\u10ec\u10e7\u10dd\u10d1\u10d8\u10da\u10dd\u10d1\u10d8\u10e1 \u10e2\u10d8\u10de\u10d8 \u10d0\u10e0 \u10d0\u10e0\u10d8\u10e1 \u10d9\u10dd\u10dc\u10e4\u10d8\u10d2\u10e3\u10e0\u10d8\u10e0\u10d4\u10d1\u10d0\u10d3\u10d8", + "dev_not_found": "\u10db\u10dd\u10ec\u10e7\u10dd\u10d1\u10d8\u10da\u10dd\u10d1\u10d0 \u10d5\u10d4\u10e0 \u10db\u10dd\u10d8\u10eb\u10d4\u10d1\u10dc\u10d0" + }, + "step": { + "device": { + "data": { + "brightness_range_mode": "\u10db\u10dd\u10ec\u10e7\u10dd\u10d1\u10d8\u10da\u10dd\u10d1\u10d8\u10e1 \u10db\u10d8\u10d4\u10e0 \u10d2\u10d0\u10db\u10dd\u10e7\u10d4\u10dc\u10d4\u10d1\u10e3\u10da\u10d8 \u10e1\u10d8\u10d9\u10d0\u10e8\u10d9\u10d0\u10e8\u10d8\u10e1 \u10d3\u10d8\u10d0\u10de\u10d0\u10d6\u10dd\u10dc\u10d8", + "curr_temp_divider": "\u10db\u10d8\u10db\u10d3\u10d8\u10dc\u10d0\u10e0\u10d4 \u10e2\u10d4\u10db\u10d4\u10de\u10e0\u10d0\u10e2\u10e3\u10e0\u10d8\u10e1 \u10db\u10dc\u10d8\u10e8\u10d5\u10dc\u10d4\u10da\u10d8\u10e1 \u10d2\u10d0\u10db\u10e7\u10dd\u10e4\u10d8 (0 - \u10dc\u10d0\u10d2\u10e3\u10da\u10d8\u10e1\u10ee\u10db\u10d4\u10d5\u10d8)", + "max_kelvin": "\u10db\u10ee\u10d0\u10e0\u10d3\u10d0\u10ed\u10d4\u10e0\u10d8\u10da\u10d8 \u10db\u10d0\u10e5\u10e1\u10d8\u10db\u10d0\u10da\u10e3\u10e0\u10d8 \u10e2\u10d4\u10db\u10de\u10d4\u10e0\u10d0\u10e2\u10e3\u10e0\u10d8\u10e1 \u10e4\u10d4\u10e0\u10d8 \u10d9\u10d4\u10da\u10d5\u10d8\u10dc\u10d4\u10d1\u10e8\u10d8", + "max_temp": "\u10db\u10d0\u10e5\u10e1\u10d8\u10db\u10d0\u10da\u10e3\u10e0\u10d8 \u10db\u10d8\u10d6\u10dc\u10dd\u10d1\u10e0\u10d8\u10d5\u10d8 \u10e2\u10d4\u10db\u10de\u10d4\u10e0\u10d0\u10e2\u10e3\u10e0\u10d0 (\u10db\u10d8\u10dc\u10d8\u10db\u10d0\u10da\u10e3\u10e0\u10d8 \u10d3\u10d0 \u10db\u10d0\u10e5\u10e1\u10d8\u10db\u10d0\u10da\u10e3\u10e0\u10d8\u10e1\u10d0\u10d7\u10d5\u10d8\u10e1 \u10dc\u10d0\u10d2\u10e3\u10da\u10d8\u10e1\u10ee\u10db\u10d4\u10d5\u10d8\u10d0 0)", + "min_kelvin": "\u10db\u10ee\u10d0\u10e0\u10d3\u10d0\u10ed\u10d4\u10e0\u10d8\u10da\u10d8 \u10db\u10d8\u10dc\u10d8\u10db\u10d0\u10da\u10e3\u10e0\u10d8 \u10e2\u10d4\u10db\u10de\u10d4\u10e0\u10d0\u10e2\u10e3\u10e0\u10d8\u10e1 \u10e4\u10d4\u10e0\u10d8 \u10d9\u10d4\u10da\u10d5\u10d8\u10dc\u10d4\u10d1\u10e8\u10d8", + "min_temp": "\u10db\u10d8\u10dc\u10d8\u10db\u10d0\u10da\u10e3\u10e0\u10d8 \u10db\u10d8\u10d6\u10dc\u10dd\u10d1\u10e0\u10d8\u10d5\u10d8 \u10e2\u10d4\u10db\u10de\u10d4\u10e0\u10d0\u10e2\u10e3\u10e0\u10d0 (\u10db\u10d8\u10dc\u10d8\u10db\u10d0\u10da\u10e3\u10e0\u10d8 \u10d3\u10d0 \u10db\u10d0\u10e5\u10e1\u10d8\u10db\u10d0\u10da\u10e3\u10e0\u10d8\u10e1\u10d0\u10d7\u10d5\u10d8\u10e1 \u10dc\u10d0\u10d2\u10e3\u10da\u10d8\u10e1\u10ee\u10db\u10d4\u10d5\u10d8\u10d0 0)", + "support_color": "\u10e4\u10d4\u10e0\u10d8\u10e1 \u10db\u10ee\u10d0\u10e0\u10d3\u10d0\u10ed\u10d4\u10e0\u10d0 \u10d8\u10eb\u10e3\u10da\u10d4\u10d1\u10d8\u10d7", + "temp_divider": "\u10e2\u10d4\u10db\u10de\u10d4\u10e0\u10d0\u10e2\u10e3\u10e0\u10e3\u10da\u10d8 \u10db\u10dc\u10d8\u10e8\u10d5\u10dc\u10d4\u10da\u10d8\u10e1 \u10d2\u10d0\u10db\u10e7\u10dd\u10e4\u10d8 (0 = \u10dc\u10d0\u10d2\u10e3\u10da\u10d8\u10e1\u10ee\u10db\u10d4\u10d5\u10d8)", + "tuya_max_coltemp": "\u10db\u10dd\u10ec\u10e7\u10dd\u10d1\u10d8\u10da\u10dd\u10d1\u10d8\u10e1 \u10db\u10d8\u10d4\u10e0 \u10db\u10dd\u10ec\u10dd\u10d3\u10d4\u10d1\u10e3\u10da\u10d8 \u10e4\u10d4\u10e0\u10d8\u10e1 \u10db\u10d0\u10e5\u10e1\u10d8\u10db\u10d0\u10da\u10e3\u10e0\u10d8 \u10e2\u10d4\u10db\u10de\u10d4\u10e0\u10d0\u10e2\u10e3\u10e0\u10d0", + "unit_of_measurement": "\u10db\u10dd\u10ec\u10e7\u10dd\u10d1\u10d8\u10da\u10dd\u10d1\u10d8\u10e1 \u10db\u10d8\u10d4\u10e0 \u10d2\u10d0\u10db\u10dd\u10e7\u10d4\u10dc\u10d4\u10d1\u10e3\u10da\u10d8 \u10e2\u10d4\u10db\u10de\u10d4\u10e0\u10d0\u10e2\u10e3\u10e0\u10e3\u10da\u10d8 \u10d4\u10e0\u10d7\u10d4\u10e3\u10da\u10d8" + }, + "description": "\u10d3\u10d0\u10d0\u10d9\u10dd\u10dc\u10e4\u10d8\u10d2\u10e3\u10e0\u10d8\u10e0\u10d4\u10d3 {device_type} `{device_name}` \u10de\u10d0\u10e0\u10d0\u10db\u10d4\u10e2\u10d4\u10e0\u10d1\u10d8 \u10d8\u10dc\u10e4\u10dd\u10e0\u10db\u10d0\u10ea\u10d8\u10d8\u10e1 \u10e9\u10d5\u10d4\u10dc\u10d4\u10d1\u10d8\u10e1 \u10db\u10dd\u10e1\u10d0\u10e0\u10d2\u10d4\u10d1\u10d0\u10d3", + "title": "Tuya-\u10e1 \u10db\u10dd\u10ec\u10e7\u10dd\u10d1\u10d8\u10da\u10dd\u10d1\u10d8\u10e1 \u10d9\u10dd\u10dc\u10e4\u10d8\u10d2\u10e3\u10e0\u10d8\u10e0\u10d4\u10d1\u10d0" + }, + "init": { + "data": { + "discovery_interval": "\u10db\u10dd\u10ec\u10e7\u10dd\u10d1\u10d8\u10da\u10dd\u10d1\u10d8\u10e1 \u10d0\u10e6\u10db\u10dd\u10e9\u10d4\u10dc\u10d8\u10e1 \u10d2\u10d0\u10db\u10dd\u10d9\u10d8\u10d7\u10ee\u10d5\u10d8\u10e1 \u10d8\u10dc\u10e2\u10d4\u10e0\u10d5\u10d0\u10da\u10d8 \u10ec\u10d0\u10db\u10d4\u10d1\u10e8\u10d8", + "list_devices": "\u10d0\u10d8\u10e0\u10e9\u10d8\u10d4\u10d7 \u10db\u10dd\u10ec\u10e7\u10dd\u10d1\u10d8\u10da\u10dd\u10d1\u10d0 \u10d9\u10dd\u10dc\u10e4\u10d8\u10d2\u10e3\u10e0\u10d0\u10ea\u10d8\u10d8\u10e1\u10d7\u10d5\u10d8\u10e1 \u10d0\u10dc \u10d3\u10d0\u10e2\u10dd\u10d5\u10d4\u10d7 \u10ea\u10d0\u10e0\u10d8\u10d4\u10da\u10d8 \u10d9\u10dd\u10dc\u10e4\u10d8\u10d2\u10e3\u10e0\u10d0\u10ea\u10d8\u10d8\u10e1 \u10e8\u10d4\u10e1\u10d0\u10dc\u10d0\u10ee\u10d0\u10d3", + "query_device": "\u10d0\u10d8\u10e0\u10e9\u10d8\u10d4\u10d7 \u10db\u10dd\u10ec\u10e7\u10dd\u10d1\u10d8\u10da\u10dd\u10d1\u10d0, \u10e0\u10dd\u10db\u10d4\u10da\u10d8\u10ea \u10d2\u10d0\u10db\u10dd\u10d8\u10e7\u10d4\u10dc\u10d4\u10d1\u10e1 \u10db\u10dd\u10d7\u10ee\u10dd\u10d5\u10dc\u10d8\u10e1 \u10db\u10d4\u10d7\u10dd\u10d3\u10e1 \u10e1\u10e2\u10d0\u10e2\u10e3\u10e1\u10d8\u10e1 \u10e1\u10ec\u10e0\u10d0\u10e4\u10d8 \u10d2\u10d0\u10dc\u10d0\u10ee\u10da\u10d4\u10d1\u10d8\u10e1\u10d7\u10d5\u10d8\u10e1", + "query_interval": "\u10db\u10dd\u10ec\u10e7\u10dd\u10d1\u10d8\u10da\u10dd\u10d1\u10d8\u10e1 \u10d2\u10d0\u10db\u10dd\u10d9\u10d8\u10d7\u10ee\u10d5\u10d8\u10e1 \u10db\u10dd\u10d7\u10ee\u10dd\u10d5\u10dc\u10d8\u10e1 \u10d8\u10dc\u10e2\u10d4\u10e0\u10d5\u10d0\u10da\u10d8 \u10ec\u10d0\u10db\u10d4\u10d1\u10e8\u10d8" + }, + "description": "\u10d0\u10e0 \u10d3\u10d0\u10d0\u10e7\u10d4\u10dc\u10dd\u10d7 \u10d2\u10d0\u10db\u10dd\u10d9\u10d8\u10d7\u10ee\u10d5\u10d8\u10e1 \u10d8\u10dc\u10e2\u10d4\u10e0\u10d5\u10d0\u10da\u10d8\u10e1 \u10db\u10dc\u10d8\u10e8\u10d5\u10dc\u10d4\u10da\u10dd\u10d1\u10d4\u10d1\u10d8 \u10eb\u10d0\u10da\u10d8\u10d0\u10dc \u10db\u10ea\u10d8\u10e0\u10d4 \u10d7\u10dd\u10e0\u10d4\u10d1 \u10d2\u10d0\u10db\u10dd\u10eb\u10d0\u10ee\u10d4\u10d1\u10d4\u10d1\u10d8 \u10d3\u10d0\u10d0\u10d2\u10d4\u10dc\u10d4\u10e0\u10d8\u10e0\u10d4\u10d1\u10d4\u10dc \u10e8\u10d4\u10ea\u10d3\u10dd\u10db\u10d4\u10d1\u10e1 \u10da\u10dd\u10d2\u10e8\u10d8", + "title": "Tuya-\u10e1 \u10de\u10d0\u10e0\u10d0\u10db\u10d4\u10e2\u10e0\u10d4\u10d1\u10d8\u10e1 \u10d9\u10dd\u10dc\u10e4\u10d8\u10d2\u10e3\u10e0\u10d8\u10e0\u10d4\u10d1\u10d0" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/ko.json b/homeassistant/components/tuya/translations/ko.json new file mode 100644 index 00000000000..afa2541e7b9 --- /dev/null +++ b/homeassistant/components/tuya/translations/ko.json @@ -0,0 +1,65 @@ +{ + "config": { + "abort": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uc778\uc2a4\ud134\uc2a4\ub9cc \uad6c\uc131\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4." + }, + "error": { + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "flow_title": "Tuya \uad6c\uc131\ud558\uae30", + "step": { + "user": { + "data": { + "country_code": "\uacc4\uc815 \uad6d\uac00 \ucf54\ub4dc (\uc608 : \ubbf8\uad6d\uc758 \uacbd\uc6b0 1, \uc911\uad6d\uc758 \uacbd\uc6b0 86)", + "password": "\ube44\ubc00\ubc88\ud638", + "platform": "\uacc4\uc815\uc774 \ub4f1\ub85d\ub41c \uc571", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" + }, + "description": "Tuya \uc790\uaca9 \uc99d\uba85\uc744 \uc785\ub825\ud574\uc8fc\uc138\uc694.", + "title": "Tuya" + } + } + }, + "options": { + "abort": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4" + }, + "error": { + "dev_multi_type": "\uc120\ud0dd\ud55c \uc5ec\ub7ec \uae30\uae30\ub97c \uad6c\uc131\ud558\ub824\uba74 \uc720\ud615\uc774 \ub3d9\uc77c\ud574\uc57c \ud569\ub2c8\ub2e4", + "dev_not_config": "\uae30\uae30 \uc720\ud615\uc744 \uad6c\uc131\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4", + "dev_not_found": "\uae30\uae30\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4" + }, + "step": { + "device": { + "data": { + "brightness_range_mode": "\uae30\uae30\uc5d0\uc11c \uc0ac\uc6a9\ud558\ub294 \ubc1d\uae30 \ubc94\uc704", + "curr_temp_divider": "\ud604\uc7ac \uc628\ub3c4 \uac12 \ubd84\ud560 (0 = \uae30\ubcf8\uac12 \uc0ac\uc6a9)", + "max_kelvin": "\uce98\ube48 \ub2e8\uc704\uc758 \ucd5c\ub300 \uc0c9\uc628\ub3c4", + "max_temp": "\ucd5c\ub300 \ubaa9\ud45c \uc628\ub3c4 (\uae30\ubcf8\uac12\uc758 \uacbd\uc6b0 \ucd5c\uc19f\uac12 \ubc0f \ucd5c\ub313\uac12 = 0)", + "min_kelvin": "\uce98\ube48 \ub2e8\uc704\uc758 \ucd5c\uc18c \uc0c9\uc628\ub3c4", + "min_temp": "\ucd5c\uc18c \ubaa9\ud45c \uc628\ub3c4 (\uae30\ubcf8\uac12\uc758 \uacbd\uc6b0 \ucd5c\uc19f\uac12 \ubc0f \ucd5c\ub313\uac12 = 0)", + "set_temp_divided": "\uc124\uc815 \uc628\ub3c4 \uba85\ub839\uc5d0 \ubd84\ud560\ub41c \uc628\ub3c4 \uac12 \uc0ac\uc6a9\ud558\uae30", + "support_color": "\uc0c9\uc0c1 \uc9c0\uc6d0 \uac15\uc81c \uc801\uc6a9\ud558\uae30", + "temp_divider": "\uc628\ub3c4 \uac12 \ubd84\ud560 (0 = \uae30\ubcf8\uac12 \uc0ac\uc6a9)", + "temp_step_override": "\ud76c\ub9dd \uc628\ub3c4 \ub2e8\uacc4", + "tuya_max_coltemp": "\uae30\uae30\uc5d0\uc11c \ubcf4\uace0\ud55c \ucd5c\ub300 \uc0c9\uc628\ub3c4", + "unit_of_measurement": "\uae30\uae30\uc5d0\uc11c \uc0ac\uc6a9\ud558\ub294 \uc628\ub3c4 \ub2e8\uc704" + }, + "description": "{device_type} `{device_name}` \uae30\uae30\uc5d0 \ub300\ud574 \ud45c\uc2dc\ub418\ub294 \uc815\ubcf4\ub97c \uc870\uc815\ud558\ub294 \uc635\uc158 \uad6c\uc131\ud558\uae30", + "title": "Tuya \uae30\uae30 \uad6c\uc131\ud558\uae30" + }, + "init": { + "data": { + "discovery_interval": "\uae30\uae30 \uac80\uc0c9 \ud3f4\ub9c1 \uac04\uaca9 (\ucd08)", + "list_devices": "\uad6c\uc131\uc744 \uc800\uc7a5\ud558\ub824\uba74 \uad6c\uc131\ud560 \uae30\uae30\ub97c \uc120\ud0dd\ud558\uac70\ub098 \ube44\uc6cc \ub450\uc138\uc694", + "query_device": "\ube60\ub978 \uc0c1\ud0dc \uc5c5\ub370\uc774\ud2b8\ub97c \uc704\ud574 \ucffc\ub9ac \ubc29\ubc95\uc744 \uc0ac\uc6a9\ud560 \uae30\uae30\ub97c \uc120\ud0dd\ud574\uc8fc\uc138\uc694", + "query_interval": "\uae30\uae30 \ucffc\ub9ac \ud3f4\ub9c1 \uac04\uaca9 (\ucd08)" + }, + "description": "\ud3f4\ub9c1 \uac04\uaca9 \uac12\uc744 \ub108\ubb34 \ub0ae\uac8c \uc124\uc815\ud558\uc9c0 \ub9d0\uc544 \uc8fc\uc138\uc694. \uadf8\ub807\uc9c0 \uc54a\uc73c\uba74 \ud638\ucd9c\uc5d0 \uc2e4\ud328\ud558\uace0 \ub85c\uadf8\uc5d0 \uc624\ub958 \uba54\uc2dc\uc9c0\uac00 \uc0dd\uc131\ub429\ub2c8\ub2e4.", + "title": "Tuya \uc635\uc158 \uad6c\uc131\ud558\uae30" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/lb.json b/homeassistant/components/tuya/translations/lb.json new file mode 100644 index 00000000000..0000f9ef6e6 --- /dev/null +++ b/homeassistant/components/tuya/translations/lb.json @@ -0,0 +1,59 @@ +{ + "config": { + "abort": { + "cannot_connect": "Feeler beim verbannen", + "invalid_auth": "Ong\u00eblteg Authentifikatioun", + "single_instance_allowed": "Scho konfigur\u00e9iert. N\u00ebmmen eng eenzeg Konfiguratioun ass m\u00e9iglech." + }, + "error": { + "invalid_auth": "Ong\u00eblteg Authentifikatioun" + }, + "flow_title": "Tuya Konfiguratioun", + "step": { + "user": { + "data": { + "country_code": "De L\u00e4nner Code fir d\u00e4i Kont (beispill 1 fir USA oder 86 fir China)", + "password": "Passwuert", + "platform": "d'App wou den Kont registr\u00e9iert ass", + "username": "Benotzernumm" + }, + "description": "F\u00ebll deng Tuya Umeldungs Informatiounen aus.", + "title": "Tuya" + } + } + }, + "options": { + "abort": { + "cannot_connect": "Feeler beim verbannen" + }, + "error": { + "dev_multi_type": "Multiple ausgewielte Ger\u00e4ter fir ze konfigur\u00e9ieren musse vum selwechten Typ sinn", + "dev_not_config": "Typ vun Apparat net konfigur\u00e9ierbar", + "dev_not_found": "Apparat net fonnt" + }, + "step": { + "device": { + "data": { + "brightness_range_mode": "Hellegkeetsber\u00e4ich vum Apparat", + "curr_temp_divider": "Aktuell Temperatur W\u00e4erter Deeler (0= benotz Standard)", + "max_kelvin": "Maximal Faarftemperatur \u00ebnnerst\u00ebtzt a Kelvin", + "max_temp": "Maximal Zil Temperatur (benotz min a max = 0 fir standard)", + "min_kelvin": "Minimal Faarftemperatur \u00ebnnerst\u00ebtzt a Kelvin", + "min_temp": "Minimal Zil Temperatur (benotz min a max = 0 fir standard)", + "support_color": "Forc\u00e9ier Faarf \u00cbnnerst\u00ebtzung", + "temp_divider": "Temperatur W\u00e4erter Deeler (0= benotz Standard)", + "tuya_max_coltemp": "Max Faarftemperatur vum Apparat gemellt", + "unit_of_measurement": "Temperatur Eenheet vum Apparat" + }, + "description": "Konfigur\u00e9ier Optioune fir ugewisen Informatioune fir {device_type} Apparat `{device_name}` unzepassen", + "title": "Tuya Apparat ariichten" + }, + "init": { + "data": { + "list_devices": "Wiel d'Apparater fir ze konfigur\u00e9ieren aus oder loss se eidel fir d'Konfiguratioun ze sp\u00e4icheren" + }, + "title": "Tuya Optioune konfigur\u00e9ieren" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/nl.json b/homeassistant/components/tuya/translations/nl.json new file mode 100644 index 00000000000..be405db1a08 --- /dev/null +++ b/homeassistant/components/tuya/translations/nl.json @@ -0,0 +1,78 @@ +{ + "config": { + "abort": { + "cannot_connect": "Kan geen verbinding maken", + "invalid_auth": "Ongeldige authenticatie", + "single_instance_allowed": "Al geconfigureerd. Er is maar een configuratie mogelijk." + }, + "error": { + "invalid_auth": "Ongeldige authenticatie" + }, + "flow_title": "Tuya-configuratie", + "step": { + "login": { + "data": { + "access_id": "Toegangs-ID", + "country_code": "Landcode", + "endpoint": "Beschikbaarheidszone", + "password": "Wachtwoord", + "tuya_app_type": "Mobiele app", + "username": "Account" + }, + "description": "Voer uw Tuya-inloggegevens in", + "title": "Tuya" + }, + "user": { + "data": { + "country_code": "De landcode van uw account (bijvoorbeeld 1 voor de VS of 86 voor China)", + "password": "Wachtwoord", + "platform": "De app waar uw account is geregistreerd", + "tuya_project_type": "Tuya cloud project type", + "username": "Gebruikersnaam" + }, + "description": "Voer uw Tuya-inloggegevens in.", + "title": "Tuya" + } + } + }, + "options": { + "abort": { + "cannot_connect": "Kan geen verbinding maken" + }, + "error": { + "dev_multi_type": "Meerdere geselecteerde apparaten om te configureren moeten van hetzelfde type zijn", + "dev_not_config": "Apparaattype kan niet worden geconfigureerd", + "dev_not_found": "Apparaat niet gevonden" + }, + "step": { + "device": { + "data": { + "brightness_range_mode": "Helderheidsbereik gebruikt door apparaat", + "curr_temp_divider": "Huidige temperatuurwaarde deler (0 = standaardwaarde)", + "max_kelvin": "Max kleurtemperatuur in kelvin", + "max_temp": "Maximale doeltemperatuur (gebruik min en max = 0 voor standaardwaarde)", + "min_kelvin": "Minimaal ondersteunde kleurtemperatuur in kelvin", + "min_temp": "Min. gewenste temperatuur (gebruik min en max = 0 voor standaard)", + "set_temp_divided": "Gedeelde temperatuurwaarde gebruiken voor ingestelde temperatuuropdracht", + "support_color": "Forceer kleurenondersteuning", + "temp_divider": "Temperatuurwaarde deler (0 = standaardwaarde)", + "temp_step_override": "Doeltemperatuur stap", + "tuya_max_coltemp": "Max. kleurtemperatuur gerapporteerd door apparaat", + "unit_of_measurement": "Temperatuureenheid gebruikt door apparaat" + }, + "description": "Configureer opties om weergegeven informatie aan te passen voor {device_type} apparaat `{device_name}`", + "title": "Configureer Tuya Apparaat" + }, + "init": { + "data": { + "discovery_interval": "Polling-interval van nieuwe apparaten in seconden", + "list_devices": "Selecteer de te configureren apparaten of laat leeg om de configuratie op te slaan", + "query_device": "Selecteer apparaat dat query-methode zal gebruiken voor snellere statusupdate", + "query_interval": "Peilinginterval van het apparaat in seconden" + }, + "description": "Stel de waarden voor het pollinginterval niet te laag in, anders zullen de oproepen geen foutmelding in het logboek genereren", + "title": "Configureer Tuya opties" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/no.json b/homeassistant/components/tuya/translations/no.json new file mode 100644 index 00000000000..eedf24be696 --- /dev/null +++ b/homeassistant/components/tuya/translations/no.json @@ -0,0 +1,65 @@ +{ + "config": { + "abort": { + "cannot_connect": "Tilkobling mislyktes", + "invalid_auth": "Ugyldig godkjenning", + "single_instance_allowed": "Allerede konfigurert. Bare \u00e9n enkelt konfigurasjon er mulig." + }, + "error": { + "invalid_auth": "Ugyldig godkjenning" + }, + "flow_title": "Tuya konfigurasjon", + "step": { + "user": { + "data": { + "country_code": "Din landskode for kontoen din (f.eks. 1 for USA eller 86 for Kina)", + "password": "Passord", + "platform": "Appen der kontoen din er registrert", + "username": "Brukernavn" + }, + "description": "Angi Tuya-legitimasjonen din.", + "title": "" + } + } + }, + "options": { + "abort": { + "cannot_connect": "Tilkobling mislyktes" + }, + "error": { + "dev_multi_type": "Flere valgte enheter som skal konfigureres, m\u00e5 v\u00e6re av samme type", + "dev_not_config": "Enhetstype kan ikke konfigureres", + "dev_not_found": "Finner ikke enheten" + }, + "step": { + "device": { + "data": { + "brightness_range_mode": "Lysstyrkeomr\u00e5de som brukes av enheten", + "curr_temp_divider": "N\u00e5v\u00e6rende temperaturverdi (0 = bruk standard)", + "max_kelvin": "Maks fargetemperatur st\u00f8ttet i kelvin", + "max_temp": "Maks m\u00e5ltemperatur (bruk min og maks = 0 for standard)", + "min_kelvin": "Min fargetemperatur st\u00f8ttet i kelvin", + "min_temp": "Min m\u00e5ltemperatur (bruk min og maks = 0 for standard)", + "set_temp_divided": "Bruk delt temperaturverdi for innstilt temperaturkommando", + "support_color": "Tving fargest\u00f8tte", + "temp_divider": "Deler temperaturverdier (0 = bruk standard)", + "temp_step_override": "Trinn for m\u00e5ltemperatur", + "tuya_max_coltemp": "Maks fargetemperatur rapportert av enheten", + "unit_of_measurement": "Temperaturenhet som brukes av enheten" + }, + "description": "Konfigurer alternativer for \u00e5 justere vist informasjon for {device_type} device ` {device_name} `", + "title": "Konfigurere Tuya-enhet" + }, + "init": { + "data": { + "discovery_interval": "Avsp\u00f8rringsintervall for discovery-enheten i l\u00f8pet av sekunder", + "list_devices": "Velg enhetene du vil konfigurere, eller la de v\u00e6re tomme for \u00e5 lagre konfigurasjonen", + "query_device": "Velg enhet som skal bruke sp\u00f8rringsmetode for raskere statusoppdatering", + "query_interval": "Sp\u00f8rringsintervall for intervall i sekunder" + }, + "description": "Ikke angi pollingsintervallverdiene for lave, ellers vil ikke anropene generere feilmelding i loggen", + "title": "Konfigurer Tuya-alternativer" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/pl.json b/homeassistant/components/tuya/translations/pl.json new file mode 100644 index 00000000000..92ced00e733 --- /dev/null +++ b/homeassistant/components/tuya/translations/pl.json @@ -0,0 +1,65 @@ +{ + "config": { + "abort": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "invalid_auth": "Niepoprawne uwierzytelnienie", + "single_instance_allowed": "Ju\u017c skonfigurowano. Mo\u017cliwa jest tylko jedna konfiguracja." + }, + "error": { + "invalid_auth": "Niepoprawne uwierzytelnienie" + }, + "flow_title": "Konfiguracja integracji Tuya", + "step": { + "user": { + "data": { + "country_code": "Kod kraju twojego konta (np. 1 dla USA lub 86 dla Chin)", + "password": "Has\u0142o", + "platform": "Aplikacja, w kt\u00f3rej zarejestrowane jest Twoje konto", + "username": "Nazwa u\u017cytkownika" + }, + "description": "Wprowad\u017a dane uwierzytelniaj\u0105ce", + "title": "Tuya" + } + } + }, + "options": { + "abort": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia" + }, + "error": { + "dev_multi_type": "Wybrane urz\u0105dzenia do skonfigurowania musz\u0105 by\u0107 tego samego typu", + "dev_not_config": "Typ urz\u0105dzenia nie jest konfigurowalny", + "dev_not_found": "Nie znaleziono urz\u0105dzenia" + }, + "step": { + "device": { + "data": { + "brightness_range_mode": "Zakres jasno\u015bci u\u017cywany przez urz\u0105dzenie", + "curr_temp_divider": "Dzielnik aktualnej warto\u015bci temperatury (0 = u\u017cyj warto\u015bci domy\u015blnej)", + "max_kelvin": "Maksymalna obs\u0142ugiwana temperatura barwy w kelwinach", + "max_temp": "Maksymalna temperatura docelowa (u\u017cyj min i max = 0 dla warto\u015bci domy\u015blnej)", + "min_kelvin": "Minimalna obs\u0142ugiwana temperatura barwy w kelwinach", + "min_temp": "Minimalna temperatura docelowa (u\u017cyj min i max = 0 dla warto\u015bci domy\u015blnej)", + "set_temp_divided": "U\u017cyj podzielonej warto\u015bci temperatury dla polecenia ustawienia temperatury", + "support_color": "Wymu\u015b obs\u0142ug\u0119 kolor\u00f3w", + "temp_divider": "Dzielnik warto\u015bci temperatury (0 = u\u017cyj warto\u015bci domy\u015blnej)", + "temp_step_override": "Krok docelowej temperatury", + "tuya_max_coltemp": "Maksymalna temperatura barwy raportowana przez urz\u0105dzenie", + "unit_of_measurement": "Jednostka temperatury u\u017cywana przez urz\u0105dzenie" + }, + "description": "Skonfiguruj opcje, aby dostosowa\u0107 wy\u015bwietlane informacje dla urz\u0105dzenia {device_type} `{device_name}'", + "title": "Konfiguracja urz\u0105dzenia Tuya" + }, + "init": { + "data": { + "discovery_interval": "Cz\u0119stotliwo\u015b\u0107 skanowania nowych urz\u0105dze\u0144 (w sekundach)", + "list_devices": "Wybierz urz\u0105dzenia do skonfigurowania lub pozostaw puste, aby zapisa\u0107 konfiguracj\u0119", + "query_device": "Wybierz urz\u0105dzenie, kt\u00f3re b\u0119dzie u\u017cywa\u0107 metody odpytywania w celu szybszej aktualizacji statusu", + "query_interval": "Cz\u0119stotliwo\u015b\u0107 skanowania odpytywanego urz\u0105dzenia w sekundach" + }, + "description": "Nie ustawiaj zbyt niskich warto\u015bci skanowania, bo zako\u0144cz\u0105 si\u0119 niepowodzeniem, generuj\u0105c komunikat o b\u0142\u0119dzie w logu", + "title": "Konfiguracja opcji Tuya" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/pt-BR.json b/homeassistant/components/tuya/translations/pt-BR.json new file mode 100644 index 00000000000..8dc537e7549 --- /dev/null +++ b/homeassistant/components/tuya/translations/pt-BR.json @@ -0,0 +1,17 @@ +{ + "config": { + "flow_title": "Configura\u00e7\u00e3o Tuya", + "step": { + "user": { + "data": { + "country_code": "O c\u00f3digo do pa\u00eds da sua conta (por exemplo, 1 para os EUA ou 86 para a China)", + "password": "Senha", + "platform": "O aplicativo onde sua conta \u00e9 registrada", + "username": "Nome de usu\u00e1rio" + }, + "description": "Digite sua credencial Tuya.", + "title": "Tuya" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/pt.json b/homeassistant/components/tuya/translations/pt.json new file mode 100644 index 00000000000..566746538c0 --- /dev/null +++ b/homeassistant/components/tuya/translations/pt.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "cannot_connect": "Falha na liga\u00e7\u00e3o", + "invalid_auth": "Autentica\u00e7\u00e3o inv\u00e1lida", + "single_instance_allowed": "J\u00e1 configurado. Apenas uma \u00fanica configura\u00e7\u00e3o \u00e9 poss\u00edvel." + }, + "error": { + "invalid_auth": "Autentica\u00e7\u00e3o inv\u00e1lida" + }, + "step": { + "user": { + "data": { + "password": "Palavra-passe", + "username": "Nome de Utilizador" + } + } + } + }, + "options": { + "abort": { + "cannot_connect": "Falha na liga\u00e7\u00e3o" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/ru.json b/homeassistant/components/tuya/translations/ru.json new file mode 100644 index 00000000000..8e00eee568c --- /dev/null +++ b/homeassistant/components/tuya/translations/ru.json @@ -0,0 +1,79 @@ +{ + "config": { + "abort": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", + "single_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430. \u0412\u043e\u0437\u043c\u043e\u0436\u043d\u043e \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0442\u043e\u043b\u044c\u043a\u043e \u043e\u0434\u043d\u0443 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e." + }, + "error": { + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438." + }, + "flow_title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Tuya", + "step": { + "login": { + "data": { + "access_id": "\u0418\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440 \u0434\u043e\u0441\u0442\u0443\u043f\u0430", + "access_secret": "\u0421\u0435\u043a\u0440\u0435\u0442 \u0434\u043e\u0441\u0442\u0443\u043f\u0430", + "country_code": "\u041a\u043e\u0434 \u0441\u0442\u0440\u0430\u043d\u044b", + "endpoint": "\u0417\u043e\u043d\u0430 \u0434\u043e\u0441\u0442\u0443\u043f\u043d\u043e\u0441\u0442\u0438", + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "tuya_app_type": "\u041c\u043e\u0431\u0438\u043b\u044c\u043d\u043e\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435", + "username": "\u0423\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c" + }, + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 Tuya.", + "title": "Tuya" + }, + "user": { + "data": { + "country_code": "\u041a\u043e\u0434 \u0441\u0442\u0440\u0430\u043d\u044b \u0430\u043a\u043a\u0430\u0443\u043d\u0442\u0430 (1 \u0434\u043b\u044f \u0421\u0428\u0410 \u0438\u043b\u0438 86 \u0434\u043b\u044f \u041a\u0438\u0442\u0430\u044f)", + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "platform": "\u041f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435, \u0432 \u043a\u043e\u0442\u043e\u0440\u043e\u043c \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u043d\u0430 \u0443\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c", + "tuya_project_type": "\u0422\u0438\u043f \u043e\u0431\u043b\u0430\u0447\u043d\u043e\u0433\u043e \u043f\u0440\u043e\u0435\u043a\u0442\u0430 Tuya", + "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" + }, + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 Tuya.", + "title": "Tuya" + } + } + }, + "options": { + "abort": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f." + }, + "error": { + "dev_multi_type": "\u041d\u0435\u0441\u043a\u043e\u043b\u044c\u043a\u043e \u0432\u044b\u0431\u0440\u0430\u043d\u043d\u044b\u0445 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432 \u0434\u043b\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0434\u043e\u043b\u0436\u043d\u044b \u0431\u044b\u0442\u044c \u043e\u0434\u043d\u043e\u0433\u043e \u0442\u0438\u043f\u0430.", + "dev_not_config": "\u0422\u0438\u043f \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u043d\u0435 \u043d\u0430\u0441\u0442\u0440\u0430\u0438\u0432\u0430\u0435\u0442\u0441\u044f.", + "dev_not_found": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u043e." + }, + "step": { + "device": { + "data": { + "brightness_range_mode": "\u0414\u0438\u0430\u043f\u0430\u0437\u043e\u043d \u044f\u0440\u043a\u043e\u0441\u0442\u0438, \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u043c\u044b\u0439 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u043c", + "curr_temp_divider": "\u0414\u0435\u043b\u0438\u0442\u0435\u043b\u044c \u0442\u0435\u043a\u0443\u0449\u0435\u0433\u043e \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f \u0442\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u044b (0 = \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c \u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e)", + "max_kelvin": "\u041c\u0430\u043a\u0441\u0438\u043c\u0430\u043b\u044c\u043d\u0430\u044f \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u043c\u0430\u044f \u0446\u0432\u0435\u0442\u043e\u0432\u0430\u044f \u0442\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u0430 (\u0432 \u043a\u0435\u043b\u044c\u0432\u0438\u043d\u0430\u0445)", + "max_temp": "\u041c\u0430\u043a\u0441\u0438\u043c\u0430\u043b\u044c\u043d\u0430\u044f \u0446\u0435\u043b\u0435\u0432\u0430\u044f \u0442\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u0430 (\u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0439\u0442\u0435 min \u0438 max = 0)", + "min_kelvin": "\u041c\u0438\u043d\u0438\u043c\u0430\u043b\u044c\u043d\u0430\u044f \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u043c\u0430\u044f \u0446\u0432\u0435\u0442\u043e\u0432\u0430\u044f \u0442\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u0430 (\u0432 \u043a\u0435\u043b\u044c\u0432\u0438\u043d\u0430\u0445)", + "min_temp": "\u041c\u0438\u043d\u0438\u043c\u0430\u043b\u044c\u043d\u0430\u044f \u0446\u0435\u043b\u0435\u0432\u0430\u044f \u0442\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u0430 (\u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0439\u0442\u0435 min \u0438 max = 0)", + "set_temp_divided": "\u0418\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c \u0440\u0430\u0437\u0434\u0435\u043b\u0435\u043d\u043d\u043e\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0442\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u044b \u0434\u043b\u044f \u043a\u043e\u043c\u0430\u043d\u0434\u044b \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0438 \u0442\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u044b", + "support_color": "\u041f\u0440\u0438\u043d\u0443\u0434\u0438\u0442\u0435\u043b\u044c\u043d\u0430\u044f \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u043a\u0430 \u0446\u0432\u0435\u0442\u0430", + "temp_divider": "\u0414\u0435\u043b\u0438\u0442\u0435\u043b\u044c \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439 \u0442\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u044b (0 = \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c \u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e)", + "temp_step_override": "\u0428\u0430\u0433 \u0446\u0435\u043b\u0435\u0432\u043e\u0439 \u0442\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u044b", + "tuya_max_coltemp": "\u041c\u0430\u043a\u0441\u0438\u043c\u0430\u043b\u044c\u043d\u0430\u044f \u0446\u0432\u0435\u0442\u043e\u0432\u0430\u044f \u0442\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u0430, \u0441\u043e\u043e\u0431\u0449\u0430\u0435\u043c\u0430\u044f \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u043c", + "unit_of_measurement": "\u0415\u0434\u0438\u043d\u0438\u0446\u0430 \u0438\u0437\u043c\u0435\u0440\u0435\u043d\u0438\u044f \u0442\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u044b, \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u043c\u0430\u044f \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u043c" + }, + "description": "\u041e\u043f\u0446\u0438\u0438 \u0434\u043b\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0430\u0435\u043c\u043e\u0439 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u0438 \u0434\u043b\u044f {device_type} \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 `{device_name}`", + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 Tuya" + }, + "init": { + "data": { + "discovery_interval": "\u0418\u043d\u0442\u0435\u0440\u0432\u0430\u043b \u043e\u043f\u0440\u043e\u0441\u0430 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u0438\u044f \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 (\u0432 \u0441\u0435\u043a\u0443\u043d\u0434\u0430\u0445)", + "list_devices": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0434\u043b\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0438\u043b\u0438 \u043e\u0441\u0442\u0430\u0432\u044c\u0442\u0435 \u043f\u0443\u0441\u0442\u044b\u043c \u0434\u043b\u044f \u0441\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u0438\u044f \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438", + "query_device": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e, \u043a\u043e\u0442\u043e\u0440\u043e\u0435 \u0431\u0443\u0434\u0435\u0442 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c \u043c\u0435\u0442\u043e\u0434 \u0437\u0430\u043f\u0440\u043e\u0441\u0430 \u0434\u043b\u044f \u0431\u043e\u043b\u0435\u0435 \u0431\u044b\u0441\u0442\u0440\u043e\u0433\u043e \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u044f \u0441\u0442\u0430\u0442\u0443\u0441\u0430", + "query_interval": "\u0418\u043d\u0442\u0435\u0440\u0432\u0430\u043b \u043e\u043f\u0440\u043e\u0441\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 (\u0432 \u0441\u0435\u043a\u0443\u043d\u0434\u0430\u0445)" + }, + "description": "\u041d\u0435 \u0443\u0441\u0442\u0430\u043d\u0430\u0432\u043b\u0438\u0432\u0430\u0439\u0442\u0435 \u0441\u043b\u0438\u0448\u043a\u043e\u043c \u043d\u0438\u0437\u043a\u0438\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f \u0438\u043d\u0442\u0435\u0440\u0432\u0430\u043b\u0430 \u043e\u043f\u0440\u043e\u0441\u0430, \u0438\u043d\u0430\u0447\u0435 \u0432\u044b\u0437\u043e\u0432\u044b \u043d\u0435 \u0431\u0443\u0434\u0443\u0442 \u0433\u0435\u043d\u0435\u0440\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435 \u043e\u0431 \u043e\u0448\u0438\u0431\u043a\u0435 \u0432 \u0436\u0443\u0440\u043d\u0430\u043b\u0435.", + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 Tuya" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/sl.json b/homeassistant/components/tuya/translations/sl.json new file mode 100644 index 00000000000..b07ad70adac --- /dev/null +++ b/homeassistant/components/tuya/translations/sl.json @@ -0,0 +1,11 @@ +{ + "options": { + "abort": { + "cannot_connect": "Povezovanje ni uspelo." + }, + "error": { + "dev_not_config": "Vrsta naprave ni nastavljiva", + "dev_not_found": "Naprave ni mogo\u010de najti" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/sv.json b/homeassistant/components/tuya/translations/sv.json new file mode 100644 index 00000000000..85cc9c57fd3 --- /dev/null +++ b/homeassistant/components/tuya/translations/sv.json @@ -0,0 +1,17 @@ +{ + "config": { + "flow_title": "Tuya-konfiguration", + "step": { + "user": { + "data": { + "country_code": "Landskod f\u00f6r ditt konto (t.ex. 1 f\u00f6r USA eller 86 f\u00f6r Kina)", + "password": "L\u00f6senord", + "platform": "Appen d\u00e4r ditt konto registreras", + "username": "Anv\u00e4ndarnamn" + }, + "description": "Ange dina Tuya anv\u00e4ndaruppgifter.", + "title": "Tuya" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/tr.json b/homeassistant/components/tuya/translations/tr.json new file mode 100644 index 00000000000..2edf3276b6c --- /dev/null +++ b/homeassistant/components/tuya/translations/tr.json @@ -0,0 +1,60 @@ +{ + "config": { + "abort": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", + "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr." + }, + "error": { + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama" + }, + "flow_title": "Tuya yap\u0131land\u0131rmas\u0131", + "step": { + "user": { + "data": { + "country_code": "Hesap \u00fclke kodunuz (\u00f6r. ABD i\u00e7in 1 veya \u00c7in i\u00e7in 86)", + "password": "Parola", + "platform": "Hesab\u0131n\u0131z\u0131n kay\u0131tl\u0131 oldu\u011fu uygulama", + "username": "Kullan\u0131c\u0131 Ad\u0131" + }, + "description": "Tuya kimlik bilgilerinizi girin.", + "title": "Tuya" + } + } + }, + "options": { + "abort": { + "cannot_connect": "Ba\u011flanma hatas\u0131" + }, + "error": { + "dev_not_config": "Cihaz t\u00fcr\u00fc yap\u0131land\u0131r\u0131lamaz", + "dev_not_found": "Cihaz bulunamad\u0131" + }, + "step": { + "device": { + "data": { + "brightness_range_mode": "Cihaz\u0131n kulland\u0131\u011f\u0131 parlakl\u0131k aral\u0131\u011f\u0131", + "max_temp": "Maksimum hedef s\u0131cakl\u0131k (varsay\u0131lan olarak min ve maks = 0 kullan\u0131n)", + "min_kelvin": "Kelvin destekli min renk s\u0131cakl\u0131\u011f\u0131", + "min_temp": "Minimum hedef s\u0131cakl\u0131k (varsay\u0131lan i\u00e7in min ve maks = 0 kullan\u0131n)", + "support_color": "Vurgu rengi", + "temp_divider": "S\u0131cakl\u0131k de\u011ferleri ay\u0131r\u0131c\u0131 (0 = varsay\u0131lan\u0131 kullan)", + "tuya_max_coltemp": "Cihaz taraf\u0131ndan bildirilen maksimum renk s\u0131cakl\u0131\u011f\u0131", + "unit_of_measurement": "Cihaz\u0131n kulland\u0131\u011f\u0131 s\u0131cakl\u0131k birimi" + }, + "description": "{device_type} ayg\u0131t\u0131 '{device_name}' i\u00e7in g\u00f6r\u00fcnt\u00fclenen bilgileri ayarlamak i\u00e7in se\u00e7enekleri yap\u0131land\u0131r\u0131n", + "title": "Tuya Cihaz\u0131n\u0131 Yap\u0131land\u0131r\u0131n" + }, + "init": { + "data": { + "discovery_interval": "Cihaz\u0131 yoklama aral\u0131\u011f\u0131 saniye cinsinden", + "list_devices": "Yap\u0131land\u0131rmay\u0131 kaydetmek i\u00e7in yap\u0131land\u0131r\u0131lacak veya bo\u015f b\u0131rak\u0131lacak cihazlar\u0131 se\u00e7in", + "query_device": "Daha h\u0131zl\u0131 durum g\u00fcncellemesi i\u00e7in sorgu y\u00f6ntemini kullanacak cihaz\u0131 se\u00e7in", + "query_interval": "Ayg\u0131t yoklama aral\u0131\u011f\u0131 saniye cinsinden" + }, + "description": "Yoklama aral\u0131\u011f\u0131 de\u011ferlerini \u00e7ok d\u00fc\u015f\u00fck ayarlamay\u0131n, aksi takdirde \u00e7a\u011fr\u0131lar g\u00fcnl\u00fckte hata mesaj\u0131 olu\u015fturarak ba\u015far\u0131s\u0131z olur", + "title": "Tuya Se\u00e7eneklerini Konfig\u00fcre Et" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/uk.json b/homeassistant/components/tuya/translations/uk.json new file mode 100644 index 00000000000..1d2709d260a --- /dev/null +++ b/homeassistant/components/tuya/translations/uk.json @@ -0,0 +1,63 @@ +{ + "config": { + "abort": { + "cannot_connect": "\u041d\u0435 \u0432\u0434\u0430\u043b\u043e\u0441\u044f \u043f\u0456\u0434'\u0454\u0434\u043d\u0430\u0442\u0438\u0441\u044f", + "invalid_auth": "\u041d\u0435\u0432\u0456\u0440\u043d\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0456\u043a\u0430\u0446\u0456\u044f.", + "single_instance_allowed": "\u041d\u0430\u043b\u0430\u0448\u0442\u0443\u0432\u0430\u043d\u043d\u044f \u0432\u0436\u0435 \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u043e. \u041c\u043e\u0436\u043d\u0430 \u0434\u043e\u0434\u0430\u0442\u0438 \u043b\u0438\u0448\u0435 \u043e\u0434\u043d\u0443 \u043a\u043e\u043d\u0444\u0456\u0433\u0443\u0440\u0430\u0446\u0456\u044e." + }, + "error": { + "invalid_auth": "\u041d\u0435\u0432\u0456\u0440\u043d\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0456\u043a\u0430\u0446\u0456\u044f." + }, + "flow_title": "\u041d\u0430\u043b\u0430\u0448\u0442\u0443\u0432\u0430\u043d\u043d\u044f Tuya", + "step": { + "user": { + "data": { + "country_code": "\u041a\u043e\u0434 \u043a\u0440\u0430\u0457\u043d\u0438 \u043e\u0431\u043b\u0456\u043a\u043e\u0432\u043e\u0433\u043e \u0437\u0430\u043f\u0438\u0441\u0443 (1 \u0434\u043b\u044f \u0421\u0428\u0410 \u0430\u0431\u043e 86 \u0434\u043b\u044f \u041a\u0438\u0442\u0430\u044e)", + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "platform": "\u0414\u043e\u0434\u0430\u0442\u043e\u043a, \u0432 \u044f\u043a\u043e\u043c\u0443 \u0437\u0430\u0440\u0435\u0454\u0441\u0442\u0440\u043e\u0432\u0430\u043d\u043e \u043e\u0431\u043b\u0456\u043a\u043e\u0432\u0438\u0439 \u0437\u0430\u043f\u0438\u0441", + "username": "\u0406\u043c'\u044f \u043a\u043e\u0440\u0438\u0441\u0442\u0443\u0432\u0430\u0447\u0430" + }, + "description": "\u041d\u0430\u043b\u0430\u0448\u0442\u0443\u0439\u0442\u0435 Home Assistant \u0434\u043b\u044f \u0456\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0456\u0457 \u0437 Tuya.", + "title": "Tuya" + } + } + }, + "options": { + "abort": { + "cannot_connect": "\u041d\u0435 \u0432\u0434\u0430\u043b\u043e\u0441\u044f \u043f\u0456\u0434'\u0454\u0434\u043d\u0430\u0442\u0438\u0441\u044f" + }, + "error": { + "dev_multi_type": "\u041a\u0456\u043b\u044c\u043a\u0430 \u043e\u0431\u0440\u0430\u043d\u0438\u0445 \u043f\u0440\u0438\u0441\u0442\u0440\u043e\u0457\u0432 \u0434\u043b\u044f \u043d\u0430\u043b\u0430\u0448\u0442\u0443\u0432\u0430\u043d\u043d\u044f \u043f\u043e\u0432\u0438\u043d\u043d\u0456 \u0431\u0443\u0442\u0438 \u043e\u0434\u043d\u043e\u0433\u043e \u0442\u0438\u043f\u0443.", + "dev_not_config": "\u0422\u0438\u043f \u043f\u0440\u0438\u0441\u0442\u0440\u043e\u044e \u043d\u0435 \u043d\u0430\u043b\u0430\u0448\u0442\u043e\u0432\u0443\u0454\u0442\u044c\u0441\u044f.", + "dev_not_found": "\u041f\u0440\u0438\u0441\u0442\u0440\u0456\u0439 \u043d\u0435 \u0437\u043d\u0430\u0439\u0434\u0435\u043d\u043e." + }, + "step": { + "device": { + "data": { + "brightness_range_mode": "\u0414\u0456\u0430\u043f\u0430\u0437\u043e\u043d \u044f\u0441\u043a\u0440\u0430\u0432\u043e\u0441\u0442\u0456, \u044f\u043a\u0438\u0439 \u0432\u0438\u043a\u043e\u0440\u0438\u0441\u0442\u043e\u0432\u0443\u0454\u0442\u044c\u0441\u044f \u043f\u0440\u0438\u0441\u0442\u0440\u043e\u0454\u043c", + "curr_temp_divider": "\u0414\u0456\u043b\u044c\u043d\u0438\u043a \u043f\u043e\u0442\u043e\u0447\u043d\u043e\u0433\u043e \u0437\u043d\u0430\u0447\u0435\u043d\u043d\u044f \u0442\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u0438 (0 = \u0432\u0438\u043a\u043e\u0440\u0438\u0441\u0442\u043e\u0432\u0443\u0432\u0430\u0442\u0438 \u0437\u0430 \u0437\u0430\u043c\u043e\u0432\u0447\u0443\u0432\u0430\u043d\u043d\u044f\u043c)", + "max_kelvin": "\u041c\u0430\u043a\u0441\u0438\u043c\u0430\u043b\u044c\u043d\u0430 \u043f\u0456\u0434\u0442\u0440\u0438\u043c\u0443\u0432\u0430\u043d\u0430 \u043a\u043e\u043b\u0456\u0440\u043d\u0430 \u0442\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u0430 (\u0432 \u043a\u0435\u043b\u044c\u0432\u0456\u043d\u0430\u0445)", + "max_temp": "\u041c\u0430\u043a\u0441\u0438\u043c\u0430\u043b\u044c\u043d\u0430 \u0446\u0456\u043b\u044c\u043e\u0432\u0430 \u0442\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u0430 (\u0437\u0430 \u0437\u0430\u043c\u043e\u0432\u0447\u0443\u0432\u0430\u043d\u043d\u044f\u043c \u0432\u0438\u043a\u043e\u0440\u0438\u0441\u0442\u043e\u0432\u0443\u0439\u0442\u0435 min \u0456 max = 0)", + "min_kelvin": "\u041c\u0456\u043d\u0456\u043c\u0430\u043b\u044c\u043d\u0430 \u043f\u0456\u0434\u0442\u0440\u0438\u043c\u0443\u0432\u0430\u043d\u0430 \u043a\u043e\u043b\u0456\u0440\u043d\u0430 \u0442\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u0430 (\u0432 \u043a\u0435\u043b\u044c\u0432\u0456\u043d\u0430\u0445)", + "min_temp": "\u041c\u0456\u043d\u0456\u043c\u0430\u043b\u044c\u043d\u0430 \u0446\u0456\u043b\u044c\u043e\u0432\u0430 \u0442\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u0430 (\u0437\u0430 \u0437\u0430\u043c\u043e\u0432\u0447\u0443\u0432\u0430\u043d\u043d\u044f\u043c \u0432\u0438\u043a\u043e\u0440\u0438\u0441\u0442\u043e\u0432\u0443\u0439\u0442\u0435 min \u0456 max = 0)", + "support_color": "\u041f\u0440\u0438\u043c\u0443\u0441\u043e\u0432\u0430 \u043f\u0456\u0434\u0442\u0440\u0438\u043c\u043a\u0430 \u043a\u043e\u043b\u044c\u043e\u0440\u0443", + "temp_divider": "\u0414\u0456\u043b\u044c\u043d\u0438\u043a \u0437\u043d\u0430\u0447\u0435\u043d\u044c \u0442\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u0438 (0 = \u0432\u0438\u043a\u043e\u0440\u0438\u0441\u0442\u043e\u0432\u0443\u0432\u0430\u0442\u0438 \u0437\u0430 \u0437\u0430\u043c\u043e\u0432\u0447\u0443\u0432\u0430\u043d\u043d\u044f\u043c)", + "tuya_max_coltemp": "\u041c\u0430\u043a\u0441\u0438\u043c\u0430\u043b\u044c\u043d\u0430 \u043a\u043e\u043b\u0456\u0440\u043d\u0430 \u0442\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u0430, \u044f\u043a\u0430 \u043f\u043e\u0432\u0456\u0434\u043e\u043c\u043b\u044f\u0454\u0442\u044c\u0441\u044f \u043f\u0440\u0438\u0441\u0442\u0440\u043e\u0454\u043c", + "unit_of_measurement": "\u041e\u0434\u0438\u043d\u0438\u0446\u044f \u0432\u0438\u043c\u0456\u0440\u0443 \u0442\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u0438, \u044f\u043a\u0430 \u0432\u0438\u043a\u043e\u0440\u0438\u0441\u0442\u043e\u0432\u0443\u0454\u0442\u044c\u0441\u044f \u043f\u0440\u0438\u0441\u0442\u0440\u043e\u0454\u043c" + }, + "description": "\u041f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0438 \u0434\u043b\u044f \u043d\u0430\u043b\u0430\u0448\u0442\u0443\u0432\u0430\u043d\u043d\u044f \u0432\u0438\u0434\u0438\u043c\u043e\u0457 \u0456\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0456\u0457 \u0434\u043b\u044f {device_type} \u043f\u0440\u0438\u0441\u0442\u0440\u043e\u044e '{device_name}'", + "title": "\u041d\u0430\u043b\u0430\u0448\u0442\u0443\u0432\u0430\u043d\u043d\u044f \u043f\u0440\u0438\u0441\u0442\u0440\u043e\u044e Tuya" + }, + "init": { + "data": { + "discovery_interval": "\u0406\u043d\u0442\u0435\u0440\u0432\u0430\u043b \u043e\u043f\u0438\u0442\u0443\u0432\u0430\u043d\u043d\u044f \u0432\u0438\u044f\u0432\u043b\u0435\u043d\u043d\u044f \u043f\u0440\u0438\u0441\u0442\u0440\u043e\u044e (\u0432 \u0441\u0435\u043a\u0443\u043d\u0434\u0430\u0445)", + "list_devices": "\u0412\u0438\u0431\u0435\u0440\u0456\u0442\u044c \u043f\u0440\u0438\u0441\u0442\u0440\u043e\u0457 \u0434\u043b\u044f \u043d\u0430\u043b\u0430\u0448\u0442\u0443\u0432\u0430\u043d\u043d\u044f \u0430\u0431\u043e \u0437\u0430\u043b\u0438\u0448\u0442\u0435 \u043f\u043e\u0440\u043e\u0436\u043d\u0456\u043c \u0434\u043b\u044f \u0437\u0431\u0435\u0440\u0435\u0436\u0435\u043d\u043d\u044f \u043a\u043e\u043d\u0444\u0456\u0433\u0443\u0440\u0430\u0446\u0456\u0457", + "query_device": "\u0412\u0438\u0431\u0435\u0440\u0456\u0442\u044c \u043f\u0440\u0438\u0441\u0442\u0440\u0456\u0439, \u044f\u043a\u0438\u0439 \u0431\u0443\u0434\u0435 \u0432\u0438\u043a\u043e\u0440\u0438\u0441\u0442\u043e\u0432\u0443\u0432\u0430\u0442\u0438 \u043c\u0435\u0442\u043e\u0434 \u0437\u0430\u043f\u0438\u0442\u0443 \u0434\u043b\u044f \u0431\u0456\u043b\u044c\u0448 \u0448\u0432\u0438\u0434\u043a\u043e\u0433\u043e \u043e\u043d\u043e\u0432\u043b\u0435\u043d\u043d\u044f \u0441\u0442\u0430\u0442\u0443\u0441\u0443", + "query_interval": "\u0406\u043d\u0442\u0435\u0440\u0432\u0430\u043b \u043e\u043f\u0438\u0442\u0443\u0432\u0430\u043d\u043d\u044f \u043f\u0440\u0438\u0441\u0442\u0440\u043e\u044e (\u0432 \u0441\u0435\u043a\u0443\u043d\u0434\u0430\u0445)" + }, + "description": "\u041d\u0435 \u0432\u0441\u0442\u0430\u043d\u043e\u0432\u043b\u044e\u0439\u0442\u0435 \u0437\u0430\u043d\u0430\u0434\u0442\u043e \u043d\u0438\u0437\u044c\u043a\u0456 \u0437\u043d\u0430\u0447\u0435\u043d\u043d\u044f \u0456\u043d\u0442\u0435\u0440\u0432\u0430\u043b\u0443 \u043e\u043f\u0438\u0442\u0443\u0432\u0430\u043d\u043d\u044f, \u0456\u043d\u0430\u043a\u0448\u0435 \u0432\u0438\u043a\u043b\u0438\u043a\u0438 \u043d\u0435 \u0431\u0443\u0434\u0443\u0442\u044c \u0433\u0435\u043d\u0435\u0440\u0443\u0432\u0430\u0442\u0438 \u043f\u043e\u0432\u0456\u0434\u043e\u043c\u043b\u0435\u043d\u043d\u044f \u043f\u0440\u043e \u043f\u043e\u043c\u0438\u043b\u043a\u0443 \u0432 \u0436\u0443\u0440\u043d\u0430\u043b\u0456.", + "title": "\u041d\u0430\u043b\u0430\u0448\u0442\u0443\u0432\u0430\u043d\u043d\u044f Tuya" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/zh-Hans.json b/homeassistant/components/tuya/translations/zh-Hans.json index e1acb5453aa..ff3887c840d 100644 --- a/homeassistant/components/tuya/translations/zh-Hans.json +++ b/homeassistant/components/tuya/translations/zh-Hans.json @@ -1,29 +1,60 @@ { "config": { - "error": { - "invalid_auth": "身份认证无效" + "abort": { + "cannot_connect": "\u8fde\u63a5\u5931\u8d25", + "invalid_auth": "\u8eab\u4efd\u8ba4\u8bc1\u65e0\u6548", + "single_instance_allowed": "\u5df2\u7ecf\u914d\u7f6e\u8fc7\u4e86\uff0c\u4e14\u53ea\u80fd\u914d\u7f6e\u4e00\u6b21\u3002" }, - "flow_title": "涂鸦配置", + "error": { + "invalid_auth": "\u8eab\u4efd\u8ba4\u8bc1\u65e0\u6548" + }, + "flow_title": "\u6d82\u9e26\u914d\u7f6e", "step": { - "user":{ - "title":"Tuya插件", - "data":{ - "tuya_project_type": "涂鸦云项目类型" - } - }, - "login": { + "user": { "data": { - "endpoint": "可用区域", - "access_id": "Access ID", - "access_secret": "Access Secret", - "tuya_app_type": "移动应用", - "country_code": "国家码", - "username": "账号", - "password": "密码" + "country_code": "\u60a8\u7684\u5e10\u6237\u56fd\u5bb6(\u5730\u533a)\u4ee3\u7801\uff08\u4f8b\u5982\u4e2d\u56fd\u4e3a 86\uff0c\u7f8e\u56fd\u4e3a 1\uff09", + "password": "\u5bc6\u7801", + "platform": "\u60a8\u6ce8\u518c\u5e10\u6237\u7684\u5e94\u7528", + "username": "\u7528\u6237\u540d" }, - "description": "请输入涂鸦账户信息。", - "title": "涂鸦" + "description": "\u8bf7\u8f93\u5165\u6d82\u9e26\u8d26\u6237\u4fe1\u606f\u3002", + "title": "\u6d82\u9e26" + } + } + }, + "options": { + "abort": { + "cannot_connect": "\u8fde\u63a5\u5931\u8d25" + }, + "error": { + "dev_multi_type": "\u591a\u4e2a\u8981\u914d\u7f6e\u7684\u8bbe\u5907\u5fc5\u987b\u5177\u6709\u76f8\u540c\u7684\u7c7b\u578b", + "dev_not_config": "\u8bbe\u5907\u7c7b\u578b\u4e0d\u53ef\u914d\u7f6e", + "dev_not_found": "\u672a\u627e\u5230\u8bbe\u5907" + }, + "step": { + "device": { + "data": { + "brightness_range_mode": "\u8bbe\u5907\u4f7f\u7528\u7684\u4eae\u5ea6\u8303\u56f4", + "max_kelvin": "\u6700\u9ad8\u652f\u6301\u8272\u6e29\uff08\u5f00\u6c0f\uff09", + "max_temp": "\u6700\u9ad8\u76ee\u6807\u6e29\u5ea6\uff08min \u548c max \u4e3a 0 \u65f6\u4f7f\u7528\u9ed8\u8ba4\uff09", + "min_kelvin": "\u6700\u4f4e\u652f\u6301\u8272\u6e29\uff08\u5f00\u6c0f\uff09", + "min_temp": "\u6700\u4f4e\u76ee\u6807\u6e29\u5ea6\uff08min \u548c max \u4e3a 0 \u65f6\u4f7f\u7528\u9ed8\u8ba4\uff09", + "support_color": "\u5f3a\u5236\u652f\u6301\u8c03\u8272", + "tuya_max_coltemp": "\u8bbe\u5907\u62a5\u544a\u7684\u6700\u9ad8\u8272\u6e29", + "unit_of_measurement": "\u8bbe\u5907\u4f7f\u7528\u7684\u6e29\u5ea6\u5355\u4f4d" + }, + "title": "\u914d\u7f6e\u6d82\u9e26\u8bbe\u5907" + }, + "init": { + "data": { + "discovery_interval": "\u53d1\u73b0\u8bbe\u5907\u8f6e\u8be2\u95f4\u9694\uff08\u4ee5\u79d2\u4e3a\u5355\u4f4d\uff09", + "list_devices": "\u8bf7\u9009\u62e9\u8981\u914d\u7f6e\u7684\u8bbe\u5907\uff0c\u6216\u7559\u7a7a\u4ee5\u4fdd\u5b58\u914d\u7f6e", + "query_device": "\u8bf7\u9009\u62e9\u4f7f\u7528\u67e5\u8be2\u65b9\u6cd5\u7684\u8bbe\u5907\uff0c\u4ee5\u4fbf\u66f4\u5feb\u5730\u66f4\u65b0\u72b6\u6001", + "query_interval": "\u67e5\u8be2\u8bbe\u5907\u8f6e\u8be2\u95f4\u9694\uff08\u4ee5\u79d2\u4e3a\u5355\u4f4d\uff09" + }, + "description": "\u8bf7\u4e0d\u8981\u5c06\u8f6e\u8be2\u95f4\u9694\u8bbe\u7f6e\u5f97\u592a\u4f4e\uff0c\u5426\u5219\u5c06\u8c03\u7528\u5931\u8d25\u5e76\u5728\u65e5\u5fd7\u751f\u6210\u9519\u8bef\u6d88\u606f", + "title": "\u914d\u7f6e\u6d82\u9e26\u9009\u9879" } } } -} +} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/zh-Hant.json b/homeassistant/components/tuya/translations/zh-Hant.json new file mode 100644 index 00000000000..e747e50d2c7 --- /dev/null +++ b/homeassistant/components/tuya/translations/zh-Hant.json @@ -0,0 +1,79 @@ +{ + "config": { + "abort": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "single_instance_allowed": "\u50c5\u80fd\u8a2d\u5b9a\u4e00\u7d44\u88dd\u7f6e\u3002" + }, + "error": { + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548" + }, + "flow_title": "Tuya \u8a2d\u5b9a", + "step": { + "login": { + "data": { + "access_id": "Access ID", + "access_secret": "Access Secret", + "country_code": "\u570b\u78bc", + "endpoint": "\u53ef\u7528\u5340\u57df", + "password": "\u5bc6\u78bc", + "tuya_app_type": "\u624b\u6a5f App", + "username": "\u5e33\u865f" + }, + "description": "\u8f38\u5165 Tuya \u6191\u8b49", + "title": "Tuya" + }, + "user": { + "data": { + "country_code": "\u5e33\u865f\u570b\u5bb6\u4ee3\u78bc\uff08\u4f8b\u5982\uff1a\u7f8e\u570b 1 \u6216\u4e2d\u570b 86\uff09", + "password": "\u5bc6\u78bc", + "platform": "\u5e33\u6236\u8a3b\u518a\u6240\u5728\u4f4d\u7f6e", + "tuya_project_type": "Tuya \u96f2\u5c08\u6848\u985e\u578b", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + }, + "description": "\u8f38\u5165 Tuya \u6191\u8b49\u3002", + "title": "Tuya \u6574\u5408" + } + } + }, + "options": { + "abort": { + "cannot_connect": "\u9023\u7dda\u5931\u6557" + }, + "error": { + "dev_multi_type": "\u591a\u91cd\u9078\u64c7\u8a2d\u88dd\u7f6e\u4ee5\u8a2d\u5b9a\u4f7f\u7528\u76f8\u540c\u985e\u578b", + "dev_not_config": "\u88dd\u7f6e\u985e\u578b\u7121\u6cd5\u8a2d\u5b9a", + "dev_not_found": "\u627e\u4e0d\u5230\u88dd\u7f6e" + }, + "step": { + "device": { + "data": { + "brightness_range_mode": "\u88dd\u7f6e\u6240\u4f7f\u7528\u4e4b\u4eae\u5ea6\u7bc4\u570d", + "curr_temp_divider": "\u76ee\u524d\u8272\u6eab\u503c\u5206\u914d\u5668\uff080 = \u4f7f\u7528\u9810\u8a2d\uff09", + "max_kelvin": "Kelvin \u652f\u63f4\u6700\u9ad8\u8272\u6eab", + "max_temp": "\u6700\u9ad8\u76ee\u6a19\u8272\u6eab\uff08\u4f7f\u7528\u6700\u4f4e\u8207\u6700\u9ad8 = 0 \u4f7f\u7528\u9810\u8a2d\uff09", + "min_kelvin": "Kelvin \u652f\u63f4\u6700\u4f4e\u8272\u6eab", + "min_temp": "\u6700\u4f4e\u76ee\u6a19\u8272\u6eab\uff08\u4f7f\u7528\u6700\u4f4e\u8207\u6700\u9ad8 = 0 \u4f7f\u7528\u9810\u8a2d\uff09", + "set_temp_divided": "\u4f7f\u7528\u5206\u9694\u865f\u6eab\u5ea6\u503c\u4ee5\u57f7\u884c\u8a2d\u5b9a\u6eab\u5ea6\u6307\u4ee4", + "support_color": "\u5f37\u5236\u8272\u6eab\u652f\u63f4", + "temp_divider": "\u8272\u6eab\u503c\u5206\u914d\u5668\uff080 = \u4f7f\u7528\u9810\u8a2d\uff09", + "temp_step_override": "\u76ee\u6a19\u6eab\u5ea6\u8a2d\u5b9a", + "tuya_max_coltemp": "\u88dd\u7f6e\u56de\u5831\u6700\u9ad8\u8272\u6eab", + "unit_of_measurement": "\u88dd\u7f6e\u6240\u4f7f\u7528\u4e4b\u6eab\u5ea6\u55ae\u4f4d" + }, + "description": "\u8a2d\u5b9a\u9078\u9805\u4ee5\u8abf\u6574 {device_type} \u88dd\u7f6e `{device_name}` \u986f\u793a\u8cc7\u8a0a", + "title": "\u8a2d\u5b9a Tuya \u88dd\u7f6e" + }, + "init": { + "data": { + "discovery_interval": "\u63a2\u7d22\u88dd\u7f6e\u66f4\u65b0\u79d2\u9593\u8ddd", + "list_devices": "\u9078\u64c7\u88dd\u7f6e\u4ee5\u8a2d\u5b9a\u3001\u6216\u4fdd\u6301\u7a7a\u767d\u4ee5\u5132\u5b58\u8a2d\u5b9a", + "query_device": "\u9078\u64c7\u88dd\u7f6e\u5c07\u4f7f\u7528\u67e5\u8a62\u65b9\u5f0f\u4ee5\u7372\u5f97\u66f4\u5feb\u7684\u72c0\u614b\u66f4\u65b0", + "query_interval": "\u67e5\u8a62\u88dd\u7f6e\u66f4\u65b0\u79d2\u9593\u8ddd" + }, + "description": "\u66f4\u65b0\u9593\u8ddd\u4e0d\u8981\u8a2d\u5b9a\u7684\u904e\u4f4e\u3001\u53ef\u80fd\u6703\u5c0e\u81f4\u65bc\u65e5\u8a8c\u4e2d\u7522\u751f\u932f\u8aa4\u8a0a\u606f", + "title": "\u8a2d\u5b9a Tuya \u9078\u9805" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/twilio/translations/hu.json b/homeassistant/components/twilio/translations/hu.json index cd60890dab3..512296463e4 100644 --- a/homeassistant/components/twilio/translations/hu.json +++ b/homeassistant/components/twilio/translations/hu.json @@ -2,14 +2,14 @@ "config": { "abort": { "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges.", - "webhook_not_internet_accessible": "A Home Assistant rendszerednek el\u00e9rhet\u0151nek kell lennie az internetr\u0151l a webhook \u00fczenetek fogad\u00e1s\u00e1hoz." + "webhook_not_internet_accessible": "A Home Assistant p\u00e9ld\u00e1ny\u00e1nak el\u00e9rhet\u0151nek kell lennie az internet fel\u0151l a webhook \u00fczenetek fogad\u00e1s\u00e1hoz." }, "create_entry": { - "default": "Ha esem\u00e9nyeket szeretne k\u00fcldeni a Home Assistant alkalmaz\u00e1snak, be kell \u00e1ll\u00edtania a [Webhooks Twilio-val]({twilio_url}) alkalmaz\u00e1st. \n\n T\u00f6ltse ki a k\u00f6vetkez\u0151 inform\u00e1ci\u00f3kat: \n\n - URL: `{webhook_url}`\n - M\u00f3dszer: POST\n - Tartalom t\u00edpusa: application/x-www-form-urlencoded \n\n L\u00e1sd [a dokument\u00e1ci\u00f3]({docs_url}), hogyan konfigur\u00e1lhatja az automatiz\u00e1l\u00e1sokat a bej\u00f6v\u0151 adatok kezel\u00e9s\u00e9re." + "default": "Ha esem\u00e9nyeket szeretne k\u00fcldeni a Home Assistant alkalmaz\u00e1snak, be kell \u00e1ll\u00edtania a [Webhooks Twilio-val]({twilio_url}) alkalmaz\u00e1st. \n\n T\u00f6ltse ki a k\u00f6vetkez\u0151 inform\u00e1ci\u00f3kat: \n\n - URL: `{webhook_url}`\n - M\u00f3dszer: POST\n - Tartalom t\u00edpusa: application/x-www-form-urlencoded \n\n L\u00e1sd [a dokument\u00e1ci\u00f3]({docs_url}), hogyan konfigur\u00e1lhatja az automatizmusokat a bej\u00f6v\u0151 adatok kezel\u00e9s\u00e9re." }, "step": { "user": { - "description": "El szeretn\u00e9d kezdeni a be\u00e1ll\u00edt\u00e1st?", + "description": "El szeretn\u00e9 kezdeni a be\u00e1ll\u00edt\u00e1st?", "title": "A Twilio Webhook be\u00e1ll\u00edt\u00e1sa" } } diff --git a/homeassistant/components/twilio/translations/nl.json b/homeassistant/components/twilio/translations/nl.json index 0d5d33a727e..3d31175d2de 100644 --- a/homeassistant/components/twilio/translations/nl.json +++ b/homeassistant/components/twilio/translations/nl.json @@ -9,7 +9,7 @@ }, "step": { "user": { - "description": "Wil je beginnen met instellen?", + "description": "Wilt u beginnen met instellen?", "title": "Stel de Twilio Webhook in" } } diff --git a/homeassistant/components/twinkly/translations/hu.json b/homeassistant/components/twinkly/translations/hu.json index d5cd872bbd0..9c5137c30a4 100644 --- a/homeassistant/components/twinkly/translations/hu.json +++ b/homeassistant/components/twinkly/translations/hu.json @@ -9,7 +9,7 @@ "step": { "user": { "data": { - "host": "A Twinkly eszk\u00f6z gazdag\u00e9pe (vagy IP-c\u00edme)" + "host": "A Twinkly eszk\u00f6z c\u00edme" }, "description": "\u00c1ll\u00edtsa be a Twinkly led-karakterl\u00e1nc\u00e1t", "title": "Twinkly" diff --git a/homeassistant/components/unifi/translations/hu.json b/homeassistant/components/unifi/translations/hu.json index 22904c8ec7b..9564b211043 100644 --- a/homeassistant/components/unifi/translations/hu.json +++ b/homeassistant/components/unifi/translations/hu.json @@ -14,10 +14,10 @@ "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "password": "Jelsz\u00f3", "port": "Port", - "site": "Site azonos\u00edt\u00f3", + "site": "Hely azonos\u00edt\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v", "verify_ssl": "SSL-tan\u00fas\u00edtv\u00e1ny ellen\u0151rz\u00e9se" }, @@ -31,10 +31,10 @@ "data": { "block_client": "H\u00e1l\u00f3zathozz\u00e1f\u00e9r\u00e9s vez\u00e9relt \u00fcgyfelek", "dpi_restrictions": "Enged\u00e9lyezze a DPI restrikci\u00f3s csoportok vez\u00e9rl\u00e9s\u00e9t", - "poe_clients": "Enged\u00e9lyezze az \u00fcgyfelek POE-vez\u00e9rl\u00e9s\u00e9t" + "poe_clients": "Enged\u00e9lyezze a POE-vez\u00e9rl\u00e9s\u00e9t" }, "description": "Konfigur\u00e1lja a klienseket\n\n Hozzon l\u00e9tre kapcsol\u00f3kat azokhoz a sorsz\u00e1mokhoz, amelyeknek vez\u00e9relni k\u00edv\u00e1nja a h\u00e1l\u00f3zati hozz\u00e1f\u00e9r\u00e9st.", - "title": "UniFi lehet\u0151s\u00e9gek 2/3" + "title": "UniFi be\u00e1ll\u00edt\u00e1sok 2/3" }, "device_tracker": { "data": { @@ -46,7 +46,7 @@ "track_wired_clients": "Vegyen fel vezet\u00e9kes h\u00e1l\u00f3zati \u00fcgyfeleket" }, "description": "Eszk\u00f6zk\u00f6vet\u00e9s konfigur\u00e1l\u00e1sa", - "title": "UniFi lehet\u0151s\u00e9gek 1/3" + "title": "UniFi be\u00e1ll\u00edt\u00e1sok 1/3" }, "init": { "data": { @@ -64,11 +64,11 @@ }, "statistics_sensors": { "data": { - "allow_bandwidth_sensors": "S\u00e1vsz\u00e9less\u00e9g-haszn\u00e1lati \u00e9rz\u00e9kel\u0151k l\u00e9trehoz\u00e1sa a h\u00e1l\u00f3zati \u00fcgyfelek sz\u00e1m\u00e1ra", + "allow_bandwidth_sensors": "Kliensenk\u00e9nti s\u00e1vsz\u00e9less\u00e9g-haszn\u00e1lati \u00e9rz\u00e9kel\u0151k l\u00e9trehoz\u00e1sa", "allow_uptime_sensors": "\u00dczemid\u0151-\u00e9rz\u00e9kel\u0151k h\u00e1l\u00f3zati \u00fcgyfelek sz\u00e1m\u00e1ra" }, "description": "Statisztikai \u00e9rz\u00e9kel\u0151k konfigur\u00e1l\u00e1sa", - "title": "UniFi lehet\u0151s\u00e9gek 3/3" + "title": "UniFi be\u00e1ll\u00edt\u00e1sok 3/3" } } } diff --git a/homeassistant/components/unifi/translations/id.json b/homeassistant/components/unifi/translations/id.json index 7a707b28aa0..ec023fa7363 100644 --- a/homeassistant/components/unifi/translations/id.json +++ b/homeassistant/components/unifi/translations/id.json @@ -10,7 +10,7 @@ "service_unavailable": "Gagal terhubung", "unknown_client_mac": "Tidak ada klien yang tersedia di alamat MAC tersebut" }, - "flow_title": "UniFi Network {site} ({host})", + "flow_title": "{site} ({host})", "step": { "user": { "data": { diff --git a/homeassistant/components/unifi/translations/ru.json b/homeassistant/components/unifi/translations/ru.json index 8a34f9fc17e..a2c9bfe8061 100644 --- a/homeassistant/components/unifi/translations/ru.json +++ b/homeassistant/components/unifi/translations/ru.json @@ -38,7 +38,7 @@ }, "device_tracker": { "data": { - "detection_time": "\u0412\u0440\u0435\u043c\u044f \u043e\u0442 \u043f\u043e\u0441\u043b\u0435\u0434\u043d\u0435\u0433\u043e \u0441\u0435\u0430\u043d\u0441\u0430 \u0441\u0432\u044f\u0437\u0438 \u0441 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u043c (\u0441\u0435\u043a.), \u043f\u043e \u0438\u0441\u0442\u0435\u0447\u0435\u043d\u0438\u044e \u043a\u043e\u0442\u043e\u0440\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043f\u043e\u043b\u0443\u0447\u0438\u0442 \u0441\u0442\u0430\u0442\u0443\u0441 \"\u041d\u0435 \u0434\u043e\u043c\u0430\".", + "detection_time": "\u0412\u0440\u0435\u043c\u044f \u043e\u0436\u0438\u0434\u0430\u043d\u0438\u044f (\u0432 \u0441\u0435\u043a\u0443\u043d\u0434\u0430\u0445), \u043f\u043e \u0438\u0441\u0442\u0435\u0447\u0435\u043d\u0438\u044e \u043a\u043e\u0442\u043e\u0440\u043e\u0433\u043e \u0441\u0447\u0438\u0442\u0430\u0442\u044c \u0447\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u0434\u043e\u043c\u0430", "ignore_wired_bug": "\u041e\u0442\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u043b\u043e\u0433\u0438\u043a\u0443 \u043e\u0448\u0438\u0431\u043a\u0438 \u0434\u043b\u044f \u043d\u0435 \u0431\u0435\u0441\u043f\u0440\u043e\u0432\u043e\u0434\u043d\u044b\u0445 \u043a\u043b\u0438\u0435\u043d\u0442\u043e\u0432 UniFi", "ssid_filter": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 SSID \u0434\u043b\u044f \u043e\u0442\u0441\u043b\u0435\u0436\u0438\u0432\u0430\u043d\u0438\u044f \u0431\u0435\u0441\u043f\u0440\u043e\u0432\u043e\u0434\u043d\u044b\u0445 \u043a\u043b\u0438\u0435\u043d\u0442\u043e\u0432", "track_clients": "\u041e\u0442\u0441\u043b\u0435\u0436\u0438\u0432\u0430\u0442\u044c \u043a\u043b\u0438\u0435\u043d\u0442\u043e\u0432 \u0441\u0435\u0442\u0438", diff --git a/homeassistant/components/updater/translations/hu.json b/homeassistant/components/updater/translations/hu.json index 52b2c972559..e862dcb360c 100644 --- a/homeassistant/components/updater/translations/hu.json +++ b/homeassistant/components/updater/translations/hu.json @@ -1,3 +1,3 @@ { - "title": "Friss\u00edt\u00e9sek" + "title": "Friss\u00edt\u0151" } \ No newline at end of file diff --git a/homeassistant/components/upnp/translations/fi.json b/homeassistant/components/upnp/translations/fi.json index dcd927ffd24..aaf44e6c730 100644 --- a/homeassistant/components/upnp/translations/fi.json +++ b/homeassistant/components/upnp/translations/fi.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Laite on jo m\u00e4\u00e4ritetty" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/upnp/translations/hu.json b/homeassistant/components/upnp/translations/hu.json index 8ef3ff8dcc0..46c6bd2de1f 100644 --- a/homeassistant/components/upnp/translations/hu.json +++ b/homeassistant/components/upnp/translations/hu.json @@ -13,7 +13,7 @@ "step": { "init": { "one": "\u00dcres", - "other": "" + "other": "\u00dcres" }, "ssdp_confirm": { "description": "Szeretn\u00e9 be\u00e1ll\u00edtani ezt az UPnP/IGD eszk\u00f6zt?" diff --git a/homeassistant/components/upnp/translations/id.json b/homeassistant/components/upnp/translations/id.json index 3a953ba62a9..f70fca145e8 100644 --- a/homeassistant/components/upnp/translations/id.json +++ b/homeassistant/components/upnp/translations/id.json @@ -5,7 +5,7 @@ "incomplete_discovery": "Proses penemuan tidak selesai", "no_devices_found": "Tidak ada perangkat yang ditemukan di jaringan" }, - "flow_title": "UPnP/IGD: {name}", + "flow_title": "{name}", "step": { "ssdp_confirm": { "description": "Ingin menyiapkan perangkat UPnP/IGD ini?" diff --git a/homeassistant/components/uptimerobot/translations/ca.json b/homeassistant/components/uptimerobot/translations/ca.json index a3bccb98295..b845e271666 100644 --- a/homeassistant/components/uptimerobot/translations/ca.json +++ b/homeassistant/components/uptimerobot/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat", + "already_configured": "El compte ja est\u00e0 configurat", "reauth_failed_existing": "No s'ha pogut actualitzar l'entrada de configuraci\u00f3, elimina la integraci\u00f3 i torna-la a instal\u00b7lar.", "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament", "unknown": "Error inesperat" diff --git a/homeassistant/components/uptimerobot/translations/es.json b/homeassistant/components/uptimerobot/translations/es.json index 1f04109611f..455c96cd644 100644 --- a/homeassistant/components/uptimerobot/translations/es.json +++ b/homeassistant/components/uptimerobot/translations/es.json @@ -1,16 +1,16 @@ { "config": { "abort": { - "already_configured": "La cuenta ya est\u00e1 configurada", + "already_configured": "La cuenta ya ha sido configurada", "reauth_failed_existing": "No se pudo actualizar la entrada de configuraci\u00f3n, elimine la integraci\u00f3n y config\u00farela nuevamente.", - "reauth_successful": "La reautenticaci\u00f3n fue exitosa", - "unknown": "Error desconocido" + "reauth_successful": "La reautenticaci\u00f3n se realiz\u00f3 correctamente", + "unknown": "Error inesperado" }, "error": { "cannot_connect": "No se pudo conectar", - "invalid_api_key": "Clave de la API err\u00f3nea", + "invalid_api_key": "Clave API no v\u00e1lida", "reauth_failed_matching_account": "La clave de API que has proporcionado no coincide con el ID de cuenta para la configuraci\u00f3n existente.", - "unknown": "Error desconocido" + "unknown": "Error inesperado" }, "step": { "reauth_confirm": { @@ -22,7 +22,7 @@ }, "user": { "data": { - "api_key": "Clave de la API" + "api_key": "Clave API" }, "description": "Debes proporcionar una clave API de solo lectura de robot de tiempo de actividad/funcionamiento" } diff --git a/homeassistant/components/uptimerobot/translations/id.json b/homeassistant/components/uptimerobot/translations/id.json new file mode 100644 index 00000000000..e107b1fcac6 --- /dev/null +++ b/homeassistant/components/uptimerobot/translations/id.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "reauth_successful": "Autentikasi ulang berhasil", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "error": { + "cannot_connect": "Gagal terhubung", + "invalid_api_key": "Kunci API tidak valid", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "Kunci API" + }, + "title": "Autentikasi Ulang Integrasi" + }, + "user": { + "data": { + "api_key": "Kunci API" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/vera/translations/hu.json b/homeassistant/components/vera/translations/hu.json index 1f1e22b9ed8..4e9639b0258 100644 --- a/homeassistant/components/vera/translations/hu.json +++ b/homeassistant/components/vera/translations/hu.json @@ -1,16 +1,16 @@ { "config": { "abort": { - "cannot_connect": "Nem siker\u00fclt csatlakozni a {base_url}" + "cannot_connect": "Nem siker\u00fclt csatlakozni: {base_url}" }, "step": { "user": { "data": { - "exclude": "Vera eszk\u00f6zazonos\u00edt\u00f3k kiz\u00e1r\u00e1sa a HomeAssistantb\u00f3l.", - "lights": "A Vera kapcsol\u00f3eszk\u00f6z-azonos\u00edt\u00f3k f\u00e9nyk\u00e9nt kezelhet\u0151k a HomeAssistant alkalmaz\u00e1sban.", + "exclude": "Vera eszk\u00f6zazonos\u00edt\u00f3k kiz\u00e1r\u00e1sa Home Assistant-b\u00f3l.", + "lights": "A Vera kapcsol\u00f3eszk\u00f6z-azonos\u00edt\u00f3k f\u00e9nyk\u00e9nt kezelhet\u0151k a Home Assistant alkalmaz\u00e1sban.", "vera_controller_url": "Vez\u00e9rl\u0151 URL" }, - "description": "Adja meg a Vera vez\u00e9rl\u0151 URL-j\u00e9t al\u00e1bb. Ennek \u00edgy kell kin\u00e9znie: http://192.168.1.161:3480.", + "description": "Adja meg a Vera vez\u00e9rl\u0151 URL-j\u00e9t al\u00e1bb. Hasonl\u00f3k\u00e9ppen kell kin\u00e9znie: http://192.168.1.161:3480.", "title": "Vera vez\u00e9rl\u0151 be\u00e1ll\u00edt\u00e1sa" } } @@ -19,8 +19,8 @@ "step": { "init": { "data": { - "exclude": "Vera eszk\u00f6zazonos\u00edt\u00f3k kiz\u00e1r\u00e1sa a HomeAssistantb\u00f3l.", - "lights": "A Vera kapcsol\u00f3eszk\u00f6z-azonos\u00edt\u00f3k f\u00e9nyk\u00e9nt kezelhet\u0151k a HomeAssistant alkalmaz\u00e1sban." + "exclude": "Vera eszk\u00f6zazonos\u00edt\u00f3k kiz\u00e1r\u00e1sa Home Assistant-b\u00f3l.", + "lights": "A Vera kapcsol\u00f3eszk\u00f6z-azonos\u00edt\u00f3k f\u00e9nyk\u00e9nt kezelhet\u0151k a Home Assistant alkalmaz\u00e1sban." }, "description": "Az opcion\u00e1lis param\u00e9terekr\u0151l a vera dokument\u00e1ci\u00f3j\u00e1ban olvashat: https://www.home-assistant.io/integrations/vera/. Megjegyz\u00e9s: Az itt v\u00e9grehajtott v\u00e1ltoztat\u00e1sokhoz \u00fajra kell ind\u00edtani a h\u00e1zi asszisztens szervert. Az \u00e9rt\u00e9kek t\u00f6rl\u00e9s\u00e9hez adjon meg egy sz\u00f3k\u00f6zt.", "title": "Vera vez\u00e9rl\u0151 opci\u00f3k" diff --git a/homeassistant/components/verisure/translations/ca.json b/homeassistant/components/verisure/translations/ca.json index 0ddcf9513f4..c27943b35f3 100644 --- a/homeassistant/components/verisure/translations/ca.json +++ b/homeassistant/components/verisure/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat", + "already_configured": "El compte ja est\u00e0 configurat", "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" }, "error": { diff --git a/homeassistant/components/verisure/translations/hu.json b/homeassistant/components/verisure/translations/hu.json index f071872b81c..89ff19bd1fe 100644 --- a/homeassistant/components/verisure/translations/hu.json +++ b/homeassistant/components/verisure/translations/hu.json @@ -13,7 +13,7 @@ "data": { "giid": "Telep\u00edt\u00e9s" }, - "description": "A Home Assistant t\u00f6bb Verisure telep\u00edt\u00e9st tal\u00e1lt a Saj\u00e1t oldalak fi\u00f3kodban. K\u00e9rj\u00fck, v\u00e1lassza ki azt a telep\u00edt\u00e9st, amelyet hozz\u00e1 k\u00edv\u00e1n adni a Home Assistant programhoz." + "description": "Home Assistant t\u00f6bb Verisure telep\u00edt\u00e9st tal\u00e1lt a Saj\u00e1t oldalak fi\u00f3kj\u00e1ban. K\u00e9rj\u00fck, v\u00e1lassza ki azt a telep\u00edt\u00e9st, amelyet hozz\u00e1 k\u00edv\u00e1nja adni a Home Assistant p\u00e9ld\u00e1ny\u00e1hoz." }, "reauth_confirm": { "data": { diff --git a/homeassistant/components/vilfo/translations/hu.json b/homeassistant/components/vilfo/translations/hu.json index 4e2ab47a476..157a6cdbabd 100644 --- a/homeassistant/components/vilfo/translations/hu.json +++ b/homeassistant/components/vilfo/translations/hu.json @@ -12,7 +12,7 @@ "user": { "data": { "access_token": "Hozz\u00e1f\u00e9r\u00e9si token", - "host": "Hoszt" + "host": "C\u00edm" }, "description": "\u00c1ll\u00edtsa be a Vilfo Router integr\u00e1ci\u00f3t. Sz\u00fcks\u00e9ge van a Vilfo Router gazdag\u00e9pnev\u00e9re/IP -c\u00edm\u00e9re \u00e9s egy API hozz\u00e1f\u00e9r\u00e9si jogkivonatra. Ha tov\u00e1bbi inform\u00e1ci\u00f3ra van sz\u00fcks\u00e9ge az integr\u00e1ci\u00f3r\u00f3l \u00e9s a r\u00e9szletekr\u0151l, l\u00e1togasson el a k\u00f6vetkez\u0151 webhelyre: https://www.home-assistant.io/integrations/vilfo", "title": "Csatlakoz\u00e1s a Vilfo routerhez" diff --git a/homeassistant/components/vizio/translations/hu.json b/homeassistant/components/vizio/translations/hu.json index edc91cdb31c..3708bfbc379 100644 --- a/homeassistant/components/vizio/translations/hu.json +++ b/homeassistant/components/vizio/translations/hu.json @@ -19,18 +19,18 @@ "title": "V\u00e9gezze el a p\u00e1ros\u00edt\u00e1si folyamatot" }, "pairing_complete": { - "description": "A VIZIO SmartCast Eszk\u00f6z mostant\u00f3l csatlakozik a Home Assistant-hoz.", + "description": "A VIZIO SmartCast Eszk\u00f6z mostant\u00f3l csatlakozik Home Assistant-hoz.", "title": "P\u00e1ros\u00edt\u00e1s k\u00e9sz" }, "pairing_complete_import": { - "description": "A VIZIO SmartCast Eszk\u00f6z mostant\u00f3l csatlakozik a Home Assistant szolg\u00e1ltat\u00e1shoz. \n\n A Hozz\u00e1f\u00e9r\u00e9si token a \u201e** {access_token} **\u201d.", + "description": "A VIZIO SmartCast Eszk\u00f6z mostant\u00f3l csatlakozik a Home Assistant szolg\u00e1ltat\u00e1shoz. \n\nA Hozz\u00e1f\u00e9r\u00e9si token a `**{access_token}**`.", "title": "P\u00e1ros\u00edt\u00e1s k\u00e9sz" }, "user": { "data": { "access_token": "Hozz\u00e1f\u00e9r\u00e9si token", "device_class": "Eszk\u00f6zt\u00edpus", - "host": "Hoszt", + "host": "C\u00edm", "name": "N\u00e9v" }, "description": "A Hozz\u00e1f\u00e9r\u00e9si token csak t\u00e9v\u00e9khez sz\u00fcks\u00e9ges. Ha TV -t konfigur\u00e1l, \u00e9s m\u00e9g nincs Hozz\u00e1f\u00e9r\u00e9si token , hagyja \u00fcresen a p\u00e1ros\u00edt\u00e1si folyamathoz.", diff --git a/homeassistant/components/volumio/translations/hu.json b/homeassistant/components/volumio/translations/hu.json index e58f0666039..209a892af3d 100644 --- a/homeassistant/components/volumio/translations/hu.json +++ b/homeassistant/components/volumio/translations/hu.json @@ -10,12 +10,12 @@ }, "step": { "discovery_confirm": { - "description": "Szeretn\u00e9d hozz\u00e1adni a Volumio (`{name}`)-t a Home Assistant-hoz?", + "description": "Szeretn\u00e9 hozz\u00e1adni a Volumio (`{name}`)-t a Home Assistant-hoz?", "title": "Felfedezett Volumio" }, "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "port": "Port" } } diff --git a/homeassistant/components/wallbox/translations/es.json b/homeassistant/components/wallbox/translations/es.json index 2b22ece4860..1252e5eaca1 100644 --- a/homeassistant/components/wallbox/translations/es.json +++ b/homeassistant/components/wallbox/translations/es.json @@ -1,13 +1,19 @@ { "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado" + }, "error": { + "cannot_connect": "No se pudo conectar", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", "unknown": "Error inesperado" }, "step": { "user": { "data": { "password": "Contrase\u00f1a", - "station": "N\u00famero de serie de la estaci\u00f3n" + "station": "N\u00famero de serie de la estaci\u00f3n", + "username": "Usuario" } } } diff --git a/homeassistant/components/wallbox/translations/id.json b/homeassistant/components/wallbox/translations/id.json index 8fa55e63051..becbcbe817f 100644 --- a/homeassistant/components/wallbox/translations/id.json +++ b/homeassistant/components/wallbox/translations/id.json @@ -11,9 +11,11 @@ "step": { "user": { "data": { - "password": "Kata Sandi" + "password": "Kata Sandi", + "username": "Nama Pengguna" } } } - } + }, + "title": "Wallbox" } \ No newline at end of file diff --git a/homeassistant/components/watttime/translations/ca.json b/homeassistant/components/watttime/translations/ca.json new file mode 100644 index 00000000000..09a0360fab3 --- /dev/null +++ b/homeassistant/components/watttime/translations/ca.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat" + }, + "error": { + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "unknown": "Error inesperat", + "unknown_coordinates": "No hi ha dades de latitud/longitud" + }, + "step": { + "coordinates": { + "data": { + "latitude": "Latitud", + "longitude": "Longitud" + }, + "description": "Introdueix la latitud i la longitud a monitoritzar:" + }, + "location": { + "data": { + "location_type": "Ubicaci\u00f3" + }, + "description": "Tria una ubicaci\u00f3 a monitoritzar:" + }, + "user": { + "data": { + "password": "Contrasenya", + "username": "Nom d'usuari" + }, + "description": "Introdueix el nom d'usuari i contrasenya:" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/watttime/translations/cs.json b/homeassistant/components/watttime/translations/cs.json new file mode 100644 index 00000000000..b4df21bd3a1 --- /dev/null +++ b/homeassistant/components/watttime/translations/cs.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno" + }, + "error": { + "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed", + "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" + }, + "step": { + "coordinates": { + "data": { + "latitude": "Zem\u011bpisn\u00e1 \u0161\u00ed\u0159ka", + "longitude": "Zem\u011bpisn\u00e1 d\u00e9lka" + } + }, + "location": { + "data": { + "location_type": "Um\u00edst\u011bn\u00ed" + } + }, + "user": { + "data": { + "password": "Heslo", + "username": "U\u017eivatelsk\u00e9 jm\u00e9no" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/watttime/translations/de.json b/homeassistant/components/watttime/translations/de.json new file mode 100644 index 00000000000..c6bf9641c13 --- /dev/null +++ b/homeassistant/components/watttime/translations/de.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert" + }, + "error": { + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "unknown": "Unerwarteter Fehler", + "unknown_coordinates": "Keine Daten f\u00fcr Breitengrad/L\u00e4ngengrad" + }, + "step": { + "coordinates": { + "data": { + "latitude": "Breitengrad", + "longitude": "L\u00e4ngengrad" + }, + "description": "Gib den zu \u00fcberwachenden Breitengrad und L\u00e4ngengrad ein:" + }, + "location": { + "data": { + "location_type": "Standort" + }, + "description": "W\u00e4hle einen Standort f\u00fcr die \u00dcberwachung:" + }, + "user": { + "data": { + "password": "Passwort", + "username": "Benutzername" + }, + "description": "Gib deinen Benutzernamen und dein Passwort ein:" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/watttime/translations/es.json b/homeassistant/components/watttime/translations/es.json new file mode 100644 index 00000000000..922aed60d97 --- /dev/null +++ b/homeassistant/components/watttime/translations/es.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositivo ya se ha configurado" + }, + "error": { + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "unknown": "Error inesperado", + "unknown_coordinates": "No hay datos para esa latitud/longitud" + }, + "step": { + "coordinates": { + "data": { + "latitude": "Latitud", + "longitude": "Longitud" + }, + "description": "Introduzca la latitud y longitud a monitorizar:" + }, + "location": { + "data": { + "location_type": "Ubicaci\u00f3n" + }, + "description": "Escoja una ubicaci\u00f3n para monitorizar:" + }, + "user": { + "data": { + "password": "Contrase\u00f1a", + "username": "Nombre de usuario" + }, + "description": "Introduzca su nombre de usuario y contrase\u00f1a:" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/watttime/translations/et.json b/homeassistant/components/watttime/translations/et.json new file mode 100644 index 00000000000..c9f47756021 --- /dev/null +++ b/homeassistant/components/watttime/translations/et.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud" + }, + "error": { + "invalid_auth": "Tuvastamine nurjus", + "unknown": "Ootamatu t\u00f5rge", + "unknown_coordinates": "Laius- ja/v\u00f5i pikkuskraadi andmed puuduvad" + }, + "step": { + "coordinates": { + "data": { + "latitude": "Laiuskraad", + "longitude": "Pikkuskraad" + }, + "description": "Sisesta j\u00e4lgitav laius- ja pikkuskraad:" + }, + "location": { + "data": { + "location_type": "Asukoht" + }, + "description": "Vali j\u00e4lgiv asukoht:" + }, + "user": { + "data": { + "password": "Salas\u00f5na", + "username": "Kasutajanimi" + }, + "description": "Sisesta oma kasutajanimi ja salas\u00f5na:" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/watttime/translations/he.json b/homeassistant/components/watttime/translations/he.json new file mode 100644 index 00000000000..bbc82f5fa86 --- /dev/null +++ b/homeassistant/components/watttime/translations/he.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "coordinates": { + "data": { + "latitude": "\u05e7\u05d5 \u05e8\u05d5\u05d7\u05d1", + "longitude": "\u05e7\u05d5 \u05d0\u05d5\u05e8\u05da" + } + }, + "location": { + "data": { + "location_type": "\u05de\u05d9\u05e7\u05d5\u05dd" + } + }, + "user": { + "data": { + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/watttime/translations/hu.json b/homeassistant/components/watttime/translations/hu.json new file mode 100644 index 00000000000..a106416f4b9 --- /dev/null +++ b/homeassistant/components/watttime/translations/hu.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + }, + "error": { + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt", + "unknown_coordinates": "Nincs adat a megadott sz\u00e9less\u00e9g/hossz\u00fas\u00e1g vonatkoz\u00e1s\u00e1ban" + }, + "step": { + "coordinates": { + "data": { + "latitude": "Sz\u00e9less\u00e9g", + "longitude": "Hossz\u00fas\u00e1g" + }, + "description": "Adja meg a sz\u00e9less\u00e9gi \u00e9s a hossz\u00fas\u00e1gi fokot a monitoroz\u00e1shoz:" + }, + "location": { + "data": { + "location_type": "Elhelyezked\u00e9s" + }, + "description": "V\u00e1lasszon egy helyet a monitoroz\u00e1shoz:" + }, + "user": { + "data": { + "password": "Jelsz\u00f3", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + }, + "description": "Adja meg felhaszn\u00e1l\u00f3nev\u00e9t \u00e9s jelszav\u00e1t:" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/watttime/translations/id.json b/homeassistant/components/watttime/translations/id.json new file mode 100644 index 00000000000..2549bd6f4ff --- /dev/null +++ b/homeassistant/components/watttime/translations/id.json @@ -0,0 +1,26 @@ +{ + "config": { + "step": { + "coordinates": { + "data": { + "latitude": "Lintang", + "longitude": "Bujur" + }, + "description": "Masukkan lintang dan bujur untuk dipantau:" + }, + "location": { + "data": { + "location_type": "Lokasi" + }, + "description": "Pilih lokasi untuk dipantau:" + }, + "user": { + "data": { + "password": "Kata Sandi", + "username": "Nama Pengguna" + }, + "description": "Masukkan nama pengguna dan kata sandi Anda:" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/watttime/translations/it.json b/homeassistant/components/watttime/translations/it.json new file mode 100644 index 00000000000..40f41c1d046 --- /dev/null +++ b/homeassistant/components/watttime/translations/it.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato" + }, + "error": { + "invalid_auth": "Autenticazione non valida", + "unknown": "Errore imprevisto", + "unknown_coordinates": "Nessun dato per latitudine/longitudine" + }, + "step": { + "coordinates": { + "data": { + "latitude": "Latitudine", + "longitude": "Logitudine" + }, + "description": "Immettere la latitudine e la longitudine da monitorare:" + }, + "location": { + "data": { + "location_type": "Posizione" + }, + "description": "Scegli una posizione da monitorare:" + }, + "user": { + "data": { + "password": "Password", + "username": "Nome utente" + }, + "description": "Inserisci il tuo nome utente e password:" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/watttime/translations/nl.json b/homeassistant/components/watttime/translations/nl.json new file mode 100644 index 00000000000..f6776744cbb --- /dev/null +++ b/homeassistant/components/watttime/translations/nl.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd" + }, + "error": { + "invalid_auth": "Ongeldige authenticatie", + "unknown": "Onverwachte fout", + "unknown_coordinates": "Geen gegevens voor lengte-/breedtegraad" + }, + "step": { + "coordinates": { + "data": { + "latitude": "Breedtegraad", + "longitude": "Lengtegraad" + }, + "description": "Voer de breedtegraad en de lengtegraad in die u wilt monitoren:" + }, + "location": { + "data": { + "location_type": "Locatie" + }, + "description": "Kies een locatie om te monitoren:" + }, + "user": { + "data": { + "password": "Wachtwoord", + "username": "Gebruikersnaam" + }, + "description": "Voer uw gebruikersnaam en wachtwoord in:" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/watttime/translations/no.json b/homeassistant/components/watttime/translations/no.json new file mode 100644 index 00000000000..a58a29ff052 --- /dev/null +++ b/homeassistant/components/watttime/translations/no.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, + "error": { + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil", + "unknown_coordinates": "Ingen data for breddegrad/lengdegrad" + }, + "step": { + "coordinates": { + "data": { + "latitude": "Breddegrad", + "longitude": "Lengdegrad" + }, + "description": "Skriv inn breddegrad og lengdegrad som skal overv\u00e5kes:" + }, + "location": { + "data": { + "location_type": "Plassering" + }, + "description": "Velg et sted \u00e5 overv\u00e5ke:" + }, + "user": { + "data": { + "password": "Passord", + "username": "Brukernavn" + }, + "description": "Skriv inn brukernavn og passord:" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/watttime/translations/ru.json b/homeassistant/components/watttime/translations/ru.json new file mode 100644 index 00000000000..d7e67187d9d --- /dev/null +++ b/homeassistant/components/watttime/translations/ru.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant." + }, + "error": { + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430.", + "unknown_coordinates": "\u041d\u0435\u0442 \u0434\u0430\u043d\u043d\u044b\u0445 \u043f\u043e \u0448\u0438\u0440\u043e\u0442\u0435 \u0438 \u0434\u043e\u043b\u0433\u043e\u0442\u0435." + }, + "step": { + "coordinates": { + "data": { + "latitude": "\u0428\u0438\u0440\u043e\u0442\u0430", + "longitude": "\u0414\u043e\u043b\u0433\u043e\u0442\u0430" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0448\u0438\u0440\u043e\u0442\u0443 \u0438 \u0434\u043e\u043b\u0433\u043e\u0442\u0443 \u0434\u043b\u044f \u043c\u043e\u043d\u0438\u0442\u043e\u0440\u0438\u043d\u0433\u0430:" + }, + "location": { + "data": { + "location_type": "\u041c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435" + }, + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u043c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u0434\u043b\u044f \u043c\u043e\u043d\u0438\u0442\u043e\u0440\u0438\u043d\u0433\u0430:" + }, + "user": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0438\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u0438 \u043f\u0430\u0440\u043e\u043b\u044c:" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/watttime/translations/zh-Hant.json b/homeassistant/components/watttime/translations/zh-Hant.json new file mode 100644 index 00000000000..898dfc05dd7 --- /dev/null +++ b/homeassistant/components/watttime/translations/zh-Hant.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4", + "unknown_coordinates": "\u6c92\u6709\u7d93\u5ea6/\u7def\u5ea6\u8cc7\u6599" + }, + "step": { + "coordinates": { + "data": { + "latitude": "\u7def\u5ea6", + "longitude": "\u7d93\u5ea6" + }, + "description": "\u8f38\u5165\u6240\u8981\u76e3\u63a7\u7684\u7d93\u5ea6\u8207\u7def\u5ea6\uff1a" + }, + "location": { + "data": { + "location_type": "\u5ea7\u6a19" + }, + "description": "\u9078\u64c7\u6240\u8981\u76e3\u63a7\u7684\u4f4d\u7f6e\uff1a" + }, + "user": { + "data": { + "password": "\u5bc6\u78bc", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + }, + "description": "\u8f38\u5165\u4f7f\u7528\u8005\u540d\u7a31\u8207\u5bc6\u78bc\uff1a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/waze_travel_time/translations/id.json b/homeassistant/components/waze_travel_time/translations/id.json index 587e959fe7e..3f3cd02aaf6 100644 --- a/homeassistant/components/waze_travel_time/translations/id.json +++ b/homeassistant/components/waze_travel_time/translations/id.json @@ -10,6 +10,7 @@ "user": { "data": { "destination": "Tujuan", + "name": "Nama", "origin": "Asal", "region": "Wilayah" }, diff --git a/homeassistant/components/wemo/translations/hu.json b/homeassistant/components/wemo/translations/hu.json index ff9f4dc5f75..f27d566ceb9 100644 --- a/homeassistant/components/wemo/translations/hu.json +++ b/homeassistant/components/wemo/translations/hu.json @@ -6,7 +6,7 @@ }, "step": { "confirm": { - "description": "Be szeretn\u00e9 \u00e1ll\u00edtani a Wemo-t?" + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani Wemo-t?" } } }, diff --git a/homeassistant/components/wemo/translations/id.json b/homeassistant/components/wemo/translations/id.json index af0b3128cb9..831b0822f64 100644 --- a/homeassistant/components/wemo/translations/id.json +++ b/homeassistant/components/wemo/translations/id.json @@ -9,5 +9,10 @@ "description": "Ingin menyiapkan Wemo?" } } + }, + "device_automation": { + "trigger_type": { + "long_press": "Tombol Wemo ditekan selama 2 detik" + } } } \ No newline at end of file diff --git a/homeassistant/components/whirlpool/translations/ca.json b/homeassistant/components/whirlpool/translations/ca.json new file mode 100644 index 00000000000..f844476e4c6 --- /dev/null +++ b/homeassistant/components/whirlpool/translations/ca.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "unknown": "Error inesperat" + }, + "step": { + "user": { + "data": { + "password": "Contrasenya", + "username": "Nom d'usuari" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/whirlpool/translations/cs.json b/homeassistant/components/whirlpool/translations/cs.json new file mode 100644 index 00000000000..c0841233cb7 --- /dev/null +++ b/homeassistant/components/whirlpool/translations/cs.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", + "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed", + "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" + }, + "step": { + "user": { + "data": { + "password": "Heslo", + "username": "U\u017eivatelsk\u00e9 jm\u00e9no" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/whirlpool/translations/de.json b/homeassistant/components/whirlpool/translations/de.json new file mode 100644 index 00000000000..57f62e0da32 --- /dev/null +++ b/homeassistant/components/whirlpool/translations/de.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "user": { + "data": { + "password": "Passwort", + "username": "Benutzername" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/whirlpool/translations/en.json b/homeassistant/components/whirlpool/translations/en.json index 407d41d6736..74817db9ba7 100644 --- a/homeassistant/components/whirlpool/translations/en.json +++ b/homeassistant/components/whirlpool/translations/en.json @@ -1,8 +1,5 @@ { "config": { - "abort": { - "already_configured": "Device is already configured" - }, "error": { "cannot_connect": "Failed to connect", "invalid_auth": "Invalid authentication", @@ -11,12 +8,10 @@ "step": { "user": { "data": { - "host": "Host", "password": "Password", "username": "Username" } } } - }, - "title": "Whirlpool Sixth Sense" + } } \ No newline at end of file diff --git a/homeassistant/components/whirlpool/translations/es.json b/homeassistant/components/whirlpool/translations/es.json new file mode 100644 index 00000000000..d26c25c3548 --- /dev/null +++ b/homeassistant/components/whirlpool/translations/es.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "No se pudo conectar", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "unknown": "Error inesperado" + }, + "step": { + "user": { + "data": { + "password": "Contrase\u00f1a", + "username": "Usuario" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/whirlpool/translations/et.json b/homeassistant/components/whirlpool/translations/et.json new file mode 100644 index 00000000000..983f599c870 --- /dev/null +++ b/homeassistant/components/whirlpool/translations/et.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "invalid_auth": "Tuvastamine nurjus", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "user": { + "data": { + "password": "Salas\u00f5na", + "username": "Kasutajanimi" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/whirlpool/translations/he.json b/homeassistant/components/whirlpool/translations/he.json new file mode 100644 index 00000000000..96803e13b33 --- /dev/null +++ b/homeassistant/components/whirlpool/translations/he.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "user": { + "data": { + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/whirlpool/translations/hu.json b/homeassistant/components/whirlpool/translations/hu.json new file mode 100644 index 00000000000..e1cc19c9c30 --- /dev/null +++ b/homeassistant/components/whirlpool/translations/hu.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" + }, + "step": { + "user": { + "data": { + "password": "Jelsz\u00f3", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/whirlpool/translations/id.json b/homeassistant/components/whirlpool/translations/id.json new file mode 100644 index 00000000000..7244ccf8912 --- /dev/null +++ b/homeassistant/components/whirlpool/translations/id.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "Gagal terhubung", + "invalid_auth": "Autentikasi tidak valid", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "user": { + "data": { + "password": "Kata Sandi", + "username": "Nama Pengguna" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/whirlpool/translations/it.json b/homeassistant/components/whirlpool/translations/it.json new file mode 100644 index 00000000000..eb5545ca85a --- /dev/null +++ b/homeassistant/components/whirlpool/translations/it.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "Impossibile connettersi", + "invalid_auth": "Autenticazione non valida", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "password": "Password", + "username": "Nome utente" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/whirlpool/translations/nl.json b/homeassistant/components/whirlpool/translations/nl.json new file mode 100644 index 00000000000..a4954b83866 --- /dev/null +++ b/homeassistant/components/whirlpool/translations/nl.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "Kan geen verbinding maken", + "invalid_auth": "Ongeldige authenticatie", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "password": "Wachtwoord", + "username": "Gebruikersnaam" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/whirlpool/translations/no.json b/homeassistant/components/whirlpool/translations/no.json new file mode 100644 index 00000000000..4bcac3aada8 --- /dev/null +++ b/homeassistant/components/whirlpool/translations/no.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "Tilkobling mislyktes", + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "password": "Passord", + "username": "Brukernavn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/whirlpool/translations/pt-BR.json b/homeassistant/components/whirlpool/translations/pt-BR.json new file mode 100644 index 00000000000..efdc82ab438 --- /dev/null +++ b/homeassistant/components/whirlpool/translations/pt-BR.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "Falha ao conectar", + "invalid_auth": "Autentica\u00e7\u00e3o inv\u00e1lida", + "unknown": "Erro inesperado" + }, + "step": { + "user": { + "data": { + "password": "Senha", + "username": "Usu\u00e1rio" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/whirlpool/translations/ru.json b/homeassistant/components/whirlpool/translations/ru.json new file mode 100644 index 00000000000..994a287efd7 --- /dev/null +++ b/homeassistant/components/whirlpool/translations/ru.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "user": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/whirlpool/translations/zh-Hant.json b/homeassistant/components/whirlpool/translations/zh-Hant.json new file mode 100644 index 00000000000..a3784595b65 --- /dev/null +++ b/homeassistant/components/whirlpool/translations/zh-Hant.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "user": { + "data": { + "password": "\u5bc6\u78bc", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wilight/translations/hu.json b/homeassistant/components/wilight/translations/hu.json index 3b769a88b8f..9ef669f1ed3 100644 --- a/homeassistant/components/wilight/translations/hu.json +++ b/homeassistant/components/wilight/translations/hu.json @@ -8,7 +8,7 @@ "flow_title": "{name}", "step": { "confirm": { - "description": "Be szeretn\u00e9 \u00e1ll\u00edtani a WiLight {name} ? \n\n T\u00e1mogatja: {components}", + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani a WiLight {name}-t ? \n\nT\u00e1mogatja: {components}", "title": "WiLight" } } diff --git a/homeassistant/components/wilight/translations/id.json b/homeassistant/components/wilight/translations/id.json index dae7b0bd16a..06616b29e35 100644 --- a/homeassistant/components/wilight/translations/id.json +++ b/homeassistant/components/wilight/translations/id.json @@ -5,7 +5,7 @@ "not_supported_device": "Perangkat WiLight ini saat ini tidak didukung.", "not_wilight_device": "Perangkat ini bukan perangkat WiLight" }, - "flow_title": "WiLight: {name}", + "flow_title": "{name}", "step": { "confirm": { "description": "Apakah Anda ingin menyiapkan WiLight {name}?\n\nIni mendukung: {components}", diff --git a/homeassistant/components/withings/translations/ca.json b/homeassistant/components/withings/translations/ca.json index b548735d426..c75e08e0234 100644 --- a/homeassistant/components/withings/translations/ca.json +++ b/homeassistant/components/withings/translations/ca.json @@ -10,7 +10,7 @@ "default": "Autenticaci\u00f3 exitosa amb Withings." }, "error": { - "already_configured": "El compte ja ha estat configurat" + "already_configured": "El compte ja est\u00e0 configurat" }, "flow_title": "{profile}", "step": { diff --git a/homeassistant/components/withings/translations/hu.json b/homeassistant/components/withings/translations/hu.json index e26cff027fc..1a64a95de5e 100644 --- a/homeassistant/components/withings/translations/hu.json +++ b/homeassistant/components/withings/translations/hu.json @@ -3,7 +3,7 @@ "abort": { "already_configured": "A profil konfigur\u00e1ci\u00f3ja friss\u00edtve.", "authorize_url_timeout": "Id\u0151t\u00fall\u00e9p\u00e9s a hiteles\u00edt\u00e9si URL gener\u00e1l\u00e1sa sor\u00e1n.", - "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rlek, k\u00f6vesd a dokument\u00e1ci\u00f3t.", + "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rem, k\u00f6vesse a dokument\u00e1ci\u00f3t.", "no_url_available": "Nincs el\u00e9rhet\u0151 URL. A hib\u00e1r\u00f3l tov\u00e1bbi inform\u00e1ci\u00f3t [a s\u00fag\u00f3ban]({docs_url}) tal\u00e1lsz." }, "create_entry": { @@ -19,9 +19,9 @@ }, "profile": { "data": { - "profile": "Profil" + "profile": "Profil n\u00e9v" }, - "description": "Melyik profilt v\u00e1lasztottad ki a Withings weboldalon? Fontos, hogy a profilok egyeznek, k\u00fcl\u00f6nben az adatok helytelen c\u00edmk\u00e9vel lesznek ell\u00e1tva.", + "description": "K\u00e9rem, adjon meg egy egyedi profilnevet. Ez \u00e1ltal\u00e1ban az el\u0151z\u0151 l\u00e9p\u00e9sben kiv\u00e1lasztott profil neve.", "title": "Felhaszn\u00e1l\u00f3i profil." }, "reauth": { diff --git a/homeassistant/components/withings/translations/id.json b/homeassistant/components/withings/translations/id.json index e254e61d91e..eb21a0d3352 100644 --- a/homeassistant/components/withings/translations/id.json +++ b/homeassistant/components/withings/translations/id.json @@ -12,7 +12,7 @@ "error": { "already_configured": "Akun sudah dikonfigurasi" }, - "flow_title": "Withings: {profile}", + "flow_title": "{profile}", "step": { "pick_implementation": { "title": "Pilih Metode Autentikasi" diff --git a/homeassistant/components/wled/translations/hu.json b/homeassistant/components/wled/translations/hu.json index 769573bfc89..2e0ac08d3cb 100644 --- a/homeassistant/components/wled/translations/hu.json +++ b/homeassistant/components/wled/translations/hu.json @@ -7,16 +7,16 @@ "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s" }, - "flow_title": "WLED: {name}", + "flow_title": "{name}", "step": { "user": { "data": { - "host": "Hoszt" + "host": "C\u00edm" }, - "description": "\u00c1ll\u00edtsd be a WLED-et a Home Assistant-ba val\u00f3 integr\u00e1l\u00e1shoz." + "description": "\u00c1ll\u00edtsa be a WLED-et Home Assistant-ba val\u00f3 integr\u00e1l\u00e1shoz." }, "zeroconf_confirm": { - "description": "Szeretn\u00e9d hozz\u00e1adni a(z) `{name}` WLED-et a Home Assistant-hoz?", + "description": "Szeretn\u00e9 hozz\u00e1adni a(z) `{name}` WLED-et Home Assistant-hoz?", "title": "Felfedezett WLED eszk\u00f6z" } } diff --git a/homeassistant/components/wled/translations/id.json b/homeassistant/components/wled/translations/id.json index 6437dfaf83e..122cfd9da0b 100644 --- a/homeassistant/components/wled/translations/id.json +++ b/homeassistant/components/wled/translations/id.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "Gagal terhubung" }, - "flow_title": "WLED: {name}", + "flow_title": "{name}", "step": { "user": { "data": { diff --git a/homeassistant/components/xbox/translations/hu.json b/homeassistant/components/xbox/translations/hu.json index b35b1b8e2fc..24c46bb8ab0 100644 --- a/homeassistant/components/xbox/translations/hu.json +++ b/homeassistant/components/xbox/translations/hu.json @@ -2,7 +2,7 @@ "config": { "abort": { "authorize_url_timeout": "Id\u0151t\u00fall\u00e9p\u00e9s a hiteles\u00edt\u00e9si URL gener\u00e1l\u00e1sa sor\u00e1n.", - "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rlek, k\u00f6vesd a dokument\u00e1ci\u00f3t.", + "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rem, k\u00f6vesse a dokument\u00e1ci\u00f3t.", "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges." }, "create_entry": { diff --git a/homeassistant/components/xiaomi_aqara/translations/hu.json b/homeassistant/components/xiaomi_aqara/translations/hu.json index 675ef24af3b..a6139c8851b 100644 --- a/homeassistant/components/xiaomi_aqara/translations/hu.json +++ b/homeassistant/components/xiaomi_aqara/translations/hu.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van.", "not_xiaomi_aqara": "Nem egy Xiaomi Aqara Gateway, a felfedezett eszk\u00f6z nem egyezett az ismert \u00e1tj\u00e1r\u00f3kkal" }, "error": { diff --git a/homeassistant/components/xiaomi_aqara/translations/id.json b/homeassistant/components/xiaomi_aqara/translations/id.json index 5a2acfa330a..eeab548f681 100644 --- a/homeassistant/components/xiaomi_aqara/translations/id.json +++ b/homeassistant/components/xiaomi_aqara/translations/id.json @@ -12,7 +12,7 @@ "invalid_key": "Kunci gateway tidak valid", "invalid_mac": "Alamat MAC Tidak Valid" }, - "flow_title": "Xiaomi Aqara Gateway: {name}", + "flow_title": "{name}", "step": { "select": { "data": { diff --git a/homeassistant/components/xiaomi_miio/translations/es.json b/homeassistant/components/xiaomi_miio/translations/es.json index 5403e98f25f..d70445d2aa5 100644 --- a/homeassistant/components/xiaomi_miio/translations/es.json +++ b/homeassistant/components/xiaomi_miio/translations/es.json @@ -4,7 +4,8 @@ "already_configured": "El dispositivo ya est\u00e1 configurado", "already_in_progress": "El flujo de configuraci\u00f3n para este dispositivo Xiaomi Miio ya est\u00e1 en marcha.", "incomplete_info": "Informaci\u00f3n incompleta para configurar el dispositivo, no se ha suministrado ning\u00fan host o token.", - "not_xiaomi_miio": "El dispositivo no es (todav\u00eda) compatible con Xiaomi Miio." + "not_xiaomi_miio": "El dispositivo no es (todav\u00eda) compatible con Xiaomi Miio.", + "reauth_successful": "La reautenticaci\u00f3n se realiz\u00f3 correctamente" }, "error": { "cannot_connect": "No se pudo conectar", @@ -53,6 +54,10 @@ "title": "Conectar con un Xiaomi Gateway" }, "manual": { + "data": { + "host": "Direcci\u00f3n IP", + "token": "Token API" + }, "description": "Necesitar\u00e1s la clave de 32 caracteres Token API, consulta https://www.home-assistant.io/integrations/xiaomi_miio#retrieving-the-access-token para obtener instrucciones. Ten en cuenta que esta Token API es diferente de la clave utilizada por la integraci\u00f3n de Xiaomi Aqara.", "title": "Con\u00e9ctate a un dispositivo Xiaomi Miio o una puerta de enlace Xiaomi" }, diff --git a/homeassistant/components/xiaomi_miio/translations/hu.json b/homeassistant/components/xiaomi_miio/translations/hu.json index 1747b51c61a..6d53aad6f56 100644 --- a/homeassistant/components/xiaomi_miio/translations/hu.json +++ b/homeassistant/components/xiaomi_miio/translations/hu.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van.", "incomplete_info": "Az eszk\u00f6z be\u00e1ll\u00edt\u00e1s\u00e1hoz sz\u00fcks\u00e9ges inform\u00e1ci\u00f3k hi\u00e1nyosak, nincs megadva \u00e1llom\u00e1s vagy token.", "not_xiaomi_miio": "Az eszk\u00f6zt (m\u00e9g) nem t\u00e1mogatja a Xiaomi Miio integr\u00e1ci\u00f3.", "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt" @@ -41,7 +41,7 @@ "name": "Eszk\u00f6z neve", "token": "API Token" }, - "description": "Sz\u00fcks\u00e9ged lesz a 32 karakteres API Tokenre, k\u00f6vesd a https://www.home-assistant.io/integrations/xiaomi_miio#retrieving-the-access-token oldal instrukci\u00f3it. Vedd figyelembe, hogy ez az API Token k\u00fcl\u00f6nb\u00f6zik a Xiaomi Aqara integr\u00e1ci\u00f3 \u00e1ltal haszn\u00e1lt kulcst\u00f3l.", + "description": "Sz\u00fcks\u00e9ge lesz a 32 karakteres API Tokenre, k\u00f6vesse a https://www.home-assistant.io/integrations/xiaomi_miio#retrieving-the-access-token oldal instrukci\u00f3it. Vegye figyelembe, hogy ez az API Token k\u00fcl\u00f6nb\u00f6zik a Xiaomi Aqara integr\u00e1ci\u00f3 \u00e1ltal haszn\u00e1lt kulcst\u00f3l.", "title": "Csatlakoz\u00e1s Xiaomi Miio eszk\u00f6zh\u00f6z vagy Xiaomi Gateway-hez" }, "gateway": { diff --git a/homeassistant/components/xiaomi_miio/translations/id.json b/homeassistant/components/xiaomi_miio/translations/id.json index f893f7b06aa..a6217b52eb1 100644 --- a/homeassistant/components/xiaomi_miio/translations/id.json +++ b/homeassistant/components/xiaomi_miio/translations/id.json @@ -2,14 +2,15 @@ "config": { "abort": { "already_configured": "Perangkat sudah dikonfigurasi", - "already_in_progress": "Alur konfigurasi sedang berlangsung" + "already_in_progress": "Alur konfigurasi sedang berlangsung", + "reauth_successful": "Autentikasi ulang berhasil" }, "error": { "cannot_connect": "Gagal terhubung", "no_device_selected": "Tidak ada perangkat yang dipilih, pilih satu perangkat.", "unknown_device": "Model perangkat tidak diketahui, tidak dapat menyiapkan perangkat menggunakan alur konfigurasi." }, - "flow_title": "Xiaomi Miio: {name}", + "flow_title": "{name}", "step": { "cloud": { "data": { diff --git a/homeassistant/components/xiaomi_miio/translations/select.id.json b/homeassistant/components/xiaomi_miio/translations/select.id.json new file mode 100644 index 00000000000..178bc06301c --- /dev/null +++ b/homeassistant/components/xiaomi_miio/translations/select.id.json @@ -0,0 +1,9 @@ +{ + "state": { + "xiaomi_miio__led_brightness": { + "bright": "Terang", + "dim": "Redup", + "off": "Mati" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yale_smart_alarm/translations/ca.json b/homeassistant/components/yale_smart_alarm/translations/ca.json index ab77170999b..04e894afe1b 100644 --- a/homeassistant/components/yale_smart_alarm/translations/ca.json +++ b/homeassistant/components/yale_smart_alarm/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat" + "already_configured": "El compte ja est\u00e0 configurat" }, "error": { "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida" diff --git a/homeassistant/components/yale_smart_alarm/translations/el.json b/homeassistant/components/yale_smart_alarm/translations/el.json new file mode 100644 index 00000000000..676d0889008 --- /dev/null +++ b/homeassistant/components/yale_smart_alarm/translations/el.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "area_id": "\u0391\u03bd\u03b1\u03b3\u03bd\u03c9\u03c1\u03b9\u03c3\u03c4\u03b9\u03ba\u03cc \u03c0\u03b5\u03c1\u03b9\u03bf\u03c7\u03ae\u03c2" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yale_smart_alarm/translations/es.json b/homeassistant/components/yale_smart_alarm/translations/es.json index 367454a7d21..178b8209af7 100644 --- a/homeassistant/components/yale_smart_alarm/translations/es.json +++ b/homeassistant/components/yale_smart_alarm/translations/es.json @@ -1,18 +1,18 @@ { "config": { "abort": { - "already_configured": "La cuenta ya est\u00e1 configurada" + "already_configured": "La cuenta ya ha sido configurada" }, "error": { - "invalid_auth": "Autenticaci\u00f3n err\u00f3nea" + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida" }, "step": { "reauth_confirm": { "data": { - "area_id": "ID de \u00c1rea", + "area_id": "ID de \u00e1rea", "name": "Nombre", - "password": "Clave", - "username": "Nombre de usuario" + "password": "Contrase\u00f1a", + "username": "Usuario" } }, "user": { @@ -20,7 +20,7 @@ "area_id": "ID de \u00e1rea", "name": "Nombre", "password": "Contrase\u00f1a", - "username": "Nombre de usuario" + "username": "Usuario" } } } diff --git a/homeassistant/components/yale_smart_alarm/translations/id.json b/homeassistant/components/yale_smart_alarm/translations/id.json new file mode 100644 index 00000000000..ee24f03a33c --- /dev/null +++ b/homeassistant/components/yale_smart_alarm/translations/id.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Akun sudah dikonfigurasi" + }, + "error": { + "invalid_auth": "Autentikasi tidak valid" + }, + "step": { + "reauth_confirm": { + "data": { + "area_id": "ID Area", + "name": "Nama", + "password": "Kata Sandi", + "username": "Nama Pengguna" + } + }, + "user": { + "data": { + "area_id": "ID Area", + "name": "Nama", + "password": "Kata Sandi", + "username": "Nama Pengguna" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yamaha_musiccast/translations/es.json b/homeassistant/components/yamaha_musiccast/translations/es.json index 185176e7b39..46f8a02f33d 100644 --- a/homeassistant/components/yamaha_musiccast/translations/es.json +++ b/homeassistant/components/yamaha_musiccast/translations/es.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado", "yxc_control_url_missing": "La URL de control no se proporciona en la descripci\u00f3n del ssdp." }, "error": { @@ -8,6 +9,9 @@ }, "flow_title": "MusicCast: {name}", "step": { + "confirm": { + "description": "\u00bfQuieres iniciar la configuraci\u00f3n?" + }, "user": { "data": { "host": "Anfitri\u00f3n" diff --git a/homeassistant/components/yamaha_musiccast/translations/hu.json b/homeassistant/components/yamaha_musiccast/translations/hu.json index 9ddf75ca732..fc2672f5839 100644 --- a/homeassistant/components/yamaha_musiccast/translations/hu.json +++ b/homeassistant/components/yamaha_musiccast/translations/hu.json @@ -10,11 +10,11 @@ "flow_title": "MusicCast: {name}", "step": { "confirm": { - "description": "El szeretn\u00e9d kezdeni a be\u00e1ll\u00edt\u00e1st?" + "description": "El szeretn\u00e9 kezdeni a be\u00e1ll\u00edt\u00e1st?" }, "user": { "data": { - "host": "Hoszt" + "host": "C\u00edm" }, "description": "\u00c1ll\u00edtsa be a MusicCast-ot a Homeassistanttal val\u00f3 integr\u00e1ci\u00f3hoz." } diff --git a/homeassistant/components/yamaha_musiccast/translations/nl.json b/homeassistant/components/yamaha_musiccast/translations/nl.json index e1e31149c06..8cb8265a1f0 100644 --- a/homeassistant/components/yamaha_musiccast/translations/nl.json +++ b/homeassistant/components/yamaha_musiccast/translations/nl.json @@ -10,7 +10,7 @@ "flow_title": "MusicCast: {name}", "step": { "confirm": { - "description": "Wil je beginnen met instellen?" + "description": "Wilt u beginnen met instellen?" }, "user": { "data": { diff --git a/homeassistant/components/yeelight/translations/es.json b/homeassistant/components/yeelight/translations/es.json index 044a10c695d..c58d863fa13 100644 --- a/homeassistant/components/yeelight/translations/es.json +++ b/homeassistant/components/yeelight/translations/es.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "No se pudo conectar" }, - "flow_title": "{model} {host}", + "flow_title": "{model} {id} ({host})", "step": { "discovery_confirm": { "description": "\u00bfQuieres configurar {model} ({host})?" diff --git a/homeassistant/components/yeelight/translations/hu.json b/homeassistant/components/yeelight/translations/hu.json index 26dc6cb5ba1..6cf10422c28 100644 --- a/homeassistant/components/yeelight/translations/hu.json +++ b/homeassistant/components/yeelight/translations/hu.json @@ -7,10 +7,10 @@ "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s" }, - "flow_title": "{model} {host}", + "flow_title": "{model} {id} ({host})", "step": { "discovery_confirm": { - "description": "Szeretn\u00e9 be\u00e1ll\u00edtani a {model} ( {host} ) szolg\u00e1ltat\u00e1st?" + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani a {model} ({host}) szolg\u00e1ltat\u00e1st?" }, "pick_device": { "data": { @@ -19,9 +19,9 @@ }, "user": { "data": { - "host": "Hoszt" + "host": "C\u00edm" }, - "description": "Ha a gazdag\u00e9pet \u00fcresen hagyja, felder\u00edt\u00e9sre ker\u00fcl automatikusan." + "description": "Ha nem ad meg c\u00edmet, akkor az eszk\u00f6z\u00f6k keres\u00e9se a felder\u00edt\u00e9ssel t\u00f6rt\u00e9nik." } } }, diff --git a/homeassistant/components/yeelight/translations/id.json b/homeassistant/components/yeelight/translations/id.json index 3b2f0273ae3..d9795662689 100644 --- a/homeassistant/components/yeelight/translations/id.json +++ b/homeassistant/components/yeelight/translations/id.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "Gagal terhubung" }, - "flow_title": "{model} {host}", + "flow_title": "{model} {id} ({host})", "step": { "discovery_confirm": { "description": "Ingin menyiapkan {model} ({host})?" @@ -29,7 +29,7 @@ "step": { "init": { "data": { - "model": "Model (Opsional)", + "model": "Model (opsional)", "nightlight_switch": "Gunakan Sakelar Lampu Malam", "save_on_change": "Simpan Status Saat Berubah", "transition": "Waktu Transisi (milidetik)", diff --git a/homeassistant/components/youless/translations/es.json b/homeassistant/components/youless/translations/es.json index 72a56cc5608..77837bb25ce 100644 --- a/homeassistant/components/youless/translations/es.json +++ b/homeassistant/components/youless/translations/es.json @@ -6,7 +6,7 @@ "step": { "user": { "data": { - "host": "Anfitri\u00f3n", + "host": "Host", "name": "Nombre" } } diff --git a/homeassistant/components/youless/translations/hu.json b/homeassistant/components/youless/translations/hu.json index 21c7a7ebe4b..31913b7fa6f 100644 --- a/homeassistant/components/youless/translations/hu.json +++ b/homeassistant/components/youless/translations/hu.json @@ -6,7 +6,7 @@ "step": { "user": { "data": { - "host": "H\u00e1zigazda", + "host": "C\u00edm", "name": "N\u00e9v" } } diff --git a/homeassistant/components/youless/translations/id.json b/homeassistant/components/youless/translations/id.json new file mode 100644 index 00000000000..fd6c2bc2491 --- /dev/null +++ b/homeassistant/components/youless/translations/id.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "Gagal terhubung" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Nama" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/zerproc/translations/hu.json b/homeassistant/components/zerproc/translations/hu.json index 6c61530acbe..a56ebbfc906 100644 --- a/homeassistant/components/zerproc/translations/hu.json +++ b/homeassistant/components/zerproc/translations/hu.json @@ -6,7 +6,7 @@ }, "step": { "confirm": { - "description": "El szeretn\u00e9d kezdeni a be\u00e1ll\u00edt\u00e1st?" + "description": "El szeretn\u00e9 kezdeni a be\u00e1ll\u00edt\u00e1st?" } } } diff --git a/homeassistant/components/zerproc/translations/nl.json b/homeassistant/components/zerproc/translations/nl.json index d11896014fd..0671f0b3674 100644 --- a/homeassistant/components/zerproc/translations/nl.json +++ b/homeassistant/components/zerproc/translations/nl.json @@ -6,7 +6,7 @@ }, "step": { "confirm": { - "description": "Wil je beginnen met instellen?" + "description": "Wilt u beginnen met instellen?" } } } diff --git a/homeassistant/components/zha/translations/es.json b/homeassistant/components/zha/translations/es.json index 4753834a493..f04614f1f72 100644 --- a/homeassistant/components/zha/translations/es.json +++ b/homeassistant/components/zha/translations/es.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "single_instance_allowed": "Ya est\u00e1 configurado. Solo es posible una \u00fanica configuraci\u00f3n." + "single_instance_allowed": "Ya est\u00e1 configurado. Solo es posible una \u00fanica configuraci\u00f3n.", + "usb_probe_failed": "No se ha podido sondear el dispositivo usb" }, "error": { "cannot_connect": "No se pudo conectar" diff --git a/homeassistant/components/zha/translations/hu.json b/homeassistant/components/zha/translations/hu.json index c65eaea4325..cc480bb413e 100644 --- a/homeassistant/components/zha/translations/hu.json +++ b/homeassistant/components/zha/translations/hu.json @@ -11,7 +11,7 @@ "flow_title": "{name}", "step": { "confirm": { - "description": "Be szeretn\u00e9 \u00e1ll\u00edtani a {name}-t?" + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani: {name}?" }, "pick_radio": { "data": { diff --git a/homeassistant/components/zha/translations/id.json b/homeassistant/components/zha/translations/id.json index 4198352aae8..5a10e3d01af 100644 --- a/homeassistant/components/zha/translations/id.json +++ b/homeassistant/components/zha/translations/id.json @@ -1,13 +1,18 @@ { "config": { "abort": { - "single_instance_allowed": "Sudah dikonfigurasi. Hanya satu konfigurasi yang diizinkan." + "not_zha_device": "Perangkat ini bukan perangkat zha", + "single_instance_allowed": "Sudah dikonfigurasi. Hanya satu konfigurasi yang diizinkan.", + "usb_probe_failed": "Gagal mendeteksi perangkat usb" }, "error": { "cannot_connect": "Gagal terhubung" }, - "flow_title": "ZHA: {name}", + "flow_title": "{name}", "step": { + "confirm": { + "description": "Ingin menyiapkan {name}?" + }, "pick_radio": { "data": { "radio_type": "Jenis Radio" @@ -43,6 +48,7 @@ "zha_options": { "consider_unavailable_battery": "Anggap perangkat bertenaga baterai sebagai tidak tersedia setelah (detik)", "consider_unavailable_mains": "Anggap perangkat bertenaga listrik sebagai tidak tersedia setelah (detik)", + "default_light_transition": "Waktu transisi lampu default (detik)", "enable_identify_on_join": "Aktifkan efek identifikasi saat perangkat bergabung dengan jaringan", "title": "Opsi Global" } diff --git a/homeassistant/components/zoneminder/translations/hu.json b/homeassistant/components/zoneminder/translations/hu.json index a449464e27f..e01f032925d 100644 --- a/homeassistant/components/zoneminder/translations/hu.json +++ b/homeassistant/components/zoneminder/translations/hu.json @@ -19,7 +19,7 @@ "step": { "user": { "data": { - "host": "Host \u00e9s Port (pl. 10.10.0.4:8010)", + "host": "C\u00edm \u00e9s Port (pl. 10.10.0.4:8010)", "password": "Jelsz\u00f3", "path": "ZM \u00fatvonal", "path_zms": "ZMS el\u00e9r\u00e9si \u00fat", diff --git a/homeassistant/components/zwave/translations/ca.json b/homeassistant/components/zwave/translations/ca.json index 9a5ef2b5010..4b9e8953b8a 100644 --- a/homeassistant/components/zwave/translations/ca.json +++ b/homeassistant/components/zwave/translations/ca.json @@ -11,7 +11,7 @@ "user": { "data": { "network_key": "Clau de xarxa (deixa-ho en blanc per generar-la autom\u00e0ticament)", - "usb_path": "Ruta del port USB del dispositiu" + "usb_path": "Ruta del dispositiu USB" }, "description": "Aquesta integraci\u00f3 ja no s'actualitzar\u00e0. Utilitza Z-Wave JS per a instal\u00b7lacions noves.\n\nConsulta https://www.home-assistant.io/docs/z-wave/installation/ per a m\u00e9s informaci\u00f3 sobre les variables de configuraci\u00f3" } diff --git a/homeassistant/components/zwave/translations/hu.json b/homeassistant/components/zwave/translations/hu.json index 7269ee32daf..4d0c6adff59 100644 --- a/homeassistant/components/zwave/translations/hu.json +++ b/homeassistant/components/zwave/translations/hu.json @@ -13,19 +13,19 @@ "network_key": "H\u00e1l\u00f3zati kulcs (hagyja \u00fcresen az automatikus gener\u00e1l\u00e1shoz)", "usb_path": "USB eszk\u00f6z el\u00e9r\u00e9si \u00fat" }, - "description": "A konfigur\u00e1ci\u00f3s v\u00e1ltoz\u00f3kr\u00f3l az inform\u00e1ci\u00f3kat l\u00e1sd a https://www.home-assistant.io/docs/z-wave/installation/ oldalon." + "description": "Ezt az integr\u00e1ci\u00f3t m\u00e1r nem tartj\u00e1k fenn. \u00daj telep\u00edt\u00e9sek eset\u00e9n haszn\u00e1lja helyette a Z-Wave JS-t.\n\nA konfigur\u00e1ci\u00f3s v\u00e1ltoz\u00f3kkal kapcsolatos inform\u00e1ci\u00f3k\u00e9rt l\u00e1sd https://www.home-assistant.io/docs/z-wave/installation/." } } }, "state": { "_": { - "dead": "Halott", + "dead": "Nem ad \u00e9letjelet", "initializing": "Inicializ\u00e1l\u00e1s", "ready": "K\u00e9sz", "sleeping": "Alv\u00e1s" }, "query_stage": { - "dead": "Halott", + "dead": "Nem ad \u00e9letjelet", "initializing": "Inicializ\u00e1l\u00e1s" } } diff --git a/homeassistant/components/zwave_js/translations/ca.json b/homeassistant/components/zwave_js/translations/ca.json index ac18c44b489..d0861d44f89 100644 --- a/homeassistant/components/zwave_js/translations/ca.json +++ b/homeassistant/components/zwave_js/translations/ca.json @@ -27,7 +27,11 @@ "configure_addon": { "data": { "network_key": "Clau de xarxa", - "usb_path": "Ruta del port USB del dispositiu" + "s0_legacy_key": "Clau d'S0 (est\u00e0ndard)", + "s2_access_control_key": "Clau de control d'acc\u00e9s d'S2", + "s2_authenticated_key": "Clau d'S2 autenticat", + "s2_unauthenticated_key": "Clau d'S2 no autenticat", + "usb_path": "Ruta del dispositiu USB" }, "title": "Introdueix la configuraci\u00f3 del complement Z-Wave JS" }, @@ -58,6 +62,15 @@ } }, "device_automation": { + "action_type": { + "clear_lock_usercode": "Esborra codi d'usuari de {entity_name}", + "ping": "Sondeja dispositiu", + "refresh_value": "Actualitza el/s valor/s de {entity_name}", + "reset_meter": "Reinicialitza comptadors de {subtype}", + "set_config_parameter": "Estableix el valor del par\u00e0metre de configuraci\u00f3 {subtype}", + "set_lock_usercode": "Estableix codi d'usuari a {entity_name}", + "set_value": "Estableix el valor d'un valor Z-Wave" + }, "condition_type": { "config_parameter": "Configura el valor del par\u00e0metre {subtype}", "node_status": "Estat del node", @@ -100,7 +113,11 @@ "emulate_hardware": "Emula maquinari", "log_level": "Nivell dels registres", "network_key": "Clau de xarxa", - "usb_path": "Ruta del port USB del dispositiu" + "s0_legacy_key": "Clau d'S0 (est\u00e0ndard)", + "s2_access_control_key": "Clau de control d'acc\u00e9s d'S2", + "s2_authenticated_key": "Clau d'S2 autenticat", + "s2_unauthenticated_key": "Clau d'S2 no autenticat", + "usb_path": "Ruta del dispositiu USB" }, "title": "Introdueix la configuraci\u00f3 del complement Z-Wave JS" }, diff --git a/homeassistant/components/zwave_js/translations/de.json b/homeassistant/components/zwave_js/translations/de.json index 8d9634c3f46..8bdf7a78237 100644 --- a/homeassistant/components/zwave_js/translations/de.json +++ b/homeassistant/components/zwave_js/translations/de.json @@ -27,6 +27,10 @@ "configure_addon": { "data": { "network_key": "Netzwerk-Schl\u00fcssel", + "s0_legacy_key": "S0 Schl\u00fcssel (Legacy)", + "s2_access_control_key": "S2 Zugangskontrollschl\u00fcssel", + "s2_authenticated_key": "S2 Authentifizierter Schl\u00fcssel", + "s2_unauthenticated_key": "S2 Nicht authentifizierter Schl\u00fcssel", "usb_path": "USB-Ger\u00e4te-Pfad" }, "title": "Gib die Konfiguration des Z-Wave JS Add-ons ein" @@ -58,6 +62,15 @@ } }, "device_automation": { + "action_type": { + "clear_lock_usercode": "Nutzercode f\u00fcr {entity_name} l\u00f6schen", + "ping": "Ger\u00e4t anpingen", + "refresh_value": "Aktualisieren der Wert(e) f\u00fcr {entity_name}", + "reset_meter": "Z\u00e4hler von {subtype} zur\u00fccksetzen", + "set_config_parameter": "Wert des Konfigurationsparameters {subtype} festlegen", + "set_lock_usercode": "Einen Nutzercode f\u00fcr {entity_name} festlegen", + "set_value": "Wert eines Z-Wave-Werts einstellen" + }, "condition_type": { "config_parameter": "Wert des Konfigurationsparameters {subtype}", "node_status": "Status des Knotens", @@ -100,6 +113,10 @@ "emulate_hardware": "Hardware emulieren", "log_level": "Protokollstufe", "network_key": "Netzwerkschl\u00fcssel", + "s0_legacy_key": "S0 Schl\u00fcssel (Legacy)", + "s2_access_control_key": "S2 Zugangskontrollschl\u00fcssel", + "s2_authenticated_key": "S2 Authentifizierter Schl\u00fcssel", + "s2_unauthenticated_key": "S2 Nicht authentifizierter Schl\u00fcssel", "usb_path": "USB-Ger\u00e4te-Pfad" }, "title": "Gib die Konfiguration des Z-Wave JS-Add-ons ein" diff --git a/homeassistant/components/zwave_js/translations/el.json b/homeassistant/components/zwave_js/translations/el.json index 00149f5a0d0..21ba61a6af1 100644 --- a/homeassistant/components/zwave_js/translations/el.json +++ b/homeassistant/components/zwave_js/translations/el.json @@ -4,6 +4,7 @@ "discovery_requires_supervisor": "\u0397 \u03b1\u03bd\u03b1\u03ba\u03ac\u03bb\u03c5\u03c8\u03b7 \u03b1\u03c0\u03b1\u03b9\u03c4\u03b5\u03af \u03c4\u03bf\u03bd \u03b5\u03c0\u03cc\u03c0\u03c4\u03b7.", "not_zwave_device": "\u0397 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae \u03c0\u03bf\u03c5 \u03b1\u03bd\u03b1\u03ba\u03b1\u03bb\u03cd\u03c6\u03b8\u03b7\u03ba\u03b5 \u03b4\u03b5\u03bd \u03b5\u03af\u03bd\u03b1\u03b9 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae Z-Wave." }, + "flow_title": "{name}", "step": { "usb_confirm": { "description": "\u0398\u03ad\u03bb\u03b5\u03c4\u03b5 \u03bd\u03b1 \u03c1\u03c5\u03b8\u03bc\u03af\u03c3\u03b5\u03c4\u03b5 \u03c4\u03bf {name} \u03bc\u03b5 \u03c4\u03bf \u03c0\u03c1\u03cc\u03c3\u03b8\u03b5\u03c4\u03bf Z-Wave JS;" diff --git a/homeassistant/components/zwave_js/translations/en.json b/homeassistant/components/zwave_js/translations/en.json index b24d4f31b06..46650ca5439 100644 --- a/homeassistant/components/zwave_js/translations/en.json +++ b/homeassistant/components/zwave_js/translations/en.json @@ -26,6 +26,7 @@ "step": { "configure_addon": { "data": { + "network_key": "Network Key", "s0_legacy_key": "S0 Key (Legacy)", "s2_access_control_key": "S2 Access Control Key", "s2_authenticated_key": "S2 Authenticated Key", @@ -111,6 +112,7 @@ "data": { "emulate_hardware": "Emulate Hardware", "log_level": "Log level", + "network_key": "Network Key", "s0_legacy_key": "S0 Key (Legacy)", "s2_access_control_key": "S2 Access Control Key", "s2_authenticated_key": "S2 Authenticated Key", @@ -138,5 +140,6 @@ "title": "The Z-Wave JS add-on is starting." } } - } + }, + "title": "Z-Wave JS" } \ No newline at end of file diff --git a/homeassistant/components/zwave_js/translations/es.json b/homeassistant/components/zwave_js/translations/es.json index caebf4f4ecb..e1a9cd081ba 100644 --- a/homeassistant/components/zwave_js/translations/es.json +++ b/homeassistant/components/zwave_js/translations/es.json @@ -16,6 +16,7 @@ "invalid_ws_url": "URL de websocket no v\u00e1lida", "unknown": "Error inesperado" }, + "flow_title": "{name}", "progress": { "install_addon": "Espera mientras termina la instalaci\u00f3n del complemento Z-Wave JS. Puede tardar varios minutos.", "start_addon": "Espere mientras se completa el inicio del complemento Z-Wave JS. Esto puede tardar unos segundos." @@ -99,6 +100,11 @@ "install_addon": { "title": "La instalaci\u00f3n del complemento Z-Wave JS ha comenzado" }, + "manual": { + "data": { + "url": "URL" + } + }, "on_supervisor": { "title": "Selecciona el m\u00e9todo de conexi\u00f3n" } diff --git a/homeassistant/components/zwave_js/translations/et.json b/homeassistant/components/zwave_js/translations/et.json index efed557fe73..10a813aad85 100644 --- a/homeassistant/components/zwave_js/translations/et.json +++ b/homeassistant/components/zwave_js/translations/et.json @@ -27,6 +27,10 @@ "configure_addon": { "data": { "network_key": "V\u00f5rgu v\u00f5ti", + "s0_legacy_key": "S0 vana t\u00fc\u00fcpi v\u00f5ti", + "s2_access_control_key": "S2 juurdep\u00e4\u00e4suv\u00f5ti", + "s2_authenticated_key": "Autenditud S2 v\u00f5ti", + "s2_unauthenticated_key": "Autentimata S2 v\u00f5ti", "usb_path": "USB-seadme asukoha rada" }, "title": "Sisesta Z-Wave JS lisandmooduli seaded" @@ -58,6 +62,15 @@ } }, "device_automation": { + "action_type": { + "clear_lock_usercode": "Kustutaolemi {entity_name} kasutajakood", + "ping": "K\u00fcsitle seadet", + "refresh_value": "Olemi {entity_name} v\u00e4\u00e4rtuste v\u00e4rskendamine", + "reset_meter": "L\u00e4htesta arvesti {subtype}", + "set_config_parameter": "Seadeparameetri {subtype} v\u00e4\u00e4rtuse omistamine", + "set_lock_usercode": "Olemi {entity_name} kasutaja koodi m\u00e4\u00e4ramine", + "set_value": "Z-Wave v\u00e4\u00e4rtuse m\u00e4\u00e4ramine" + }, "condition_type": { "config_parameter": "Seadeparameeteri {subtype} v\u00e4\u00e4rtus", "node_status": "S\u00f5lme olek", @@ -100,6 +113,10 @@ "emulate_hardware": "Riistvara emuleerimine", "log_level": "Logimise tase", "network_key": "V\u00f5rgu v\u00f5ti", + "s0_legacy_key": "S0 vana t\u00fc\u00fcpi v\u00f5ti", + "s2_access_control_key": "S2 juurdep\u00e4\u00e4suv\u00f5ti", + "s2_authenticated_key": "Autenditud S2 v\u00f5ti", + "s2_unauthenticated_key": "Autentimata S2 v\u00f5ti", "usb_path": "USB-seadme asukoha rada" }, "title": "Sisesta Z-Wave JS lisandmooduli seaded" diff --git a/homeassistant/components/zwave_js/translations/hu.json b/homeassistant/components/zwave_js/translations/hu.json index cf5521f3e04..bf541fce26a 100644 --- a/homeassistant/components/zwave_js/translations/hu.json +++ b/homeassistant/components/zwave_js/translations/hu.json @@ -7,7 +7,7 @@ "addon_set_config_failed": "Nem siker\u00fclt be\u00e1ll\u00edtani a Z-Wave JS konfigur\u00e1ci\u00f3t.", "addon_start_failed": "Nem siker\u00fclt elind\u00edtani a Z-Wave JS b\u0151v\u00edtm\u00e9nyt.", "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van.", "cannot_connect": "Sikertelen csatlakoz\u00e1s", "discovery_requires_supervisor": "A felfedez\u00e9shez a fel\u00fcgyel\u0151re van sz\u00fcks\u00e9g.", "not_zwave_device": "A felfedezett eszk\u00f6z nem Z-Wave eszk\u00f6z." @@ -46,8 +46,8 @@ "data": { "use_addon": "Haszn\u00e1ld a Z-Wave JS Supervisor b\u0151v\u00edtm\u00e9nyt" }, - "description": "Szeretn\u00e9d haszn\u00e1lni az Z-Wave JS Supervisor b\u0151v\u00edtm\u00e9nyt?", - "title": "V\u00e1laszd ki a csatlakoz\u00e1si m\u00f3dot" + "description": "Szeretn\u00e9 haszn\u00e1lni az Z-Wave JS Supervisor b\u0151v\u00edtm\u00e9nyt?", + "title": "V\u00e1lassza ki a csatlakoz\u00e1si m\u00f3dot" }, "start_addon": { "title": "Indul a Z-Wave JS b\u0151v\u00edtm\u00e9ny." @@ -58,6 +58,15 @@ } }, "device_automation": { + "action_type": { + "clear_lock_usercode": "{entity_name} felhaszn\u00e1l\u00f3i k\u00f3dj\u00e1nak t\u00f6rl\u00e9se", + "ping": "Eszk\u00f6z pinget\u00e9se", + "refresh_value": "{entity_name} \u00e9rt\u00e9keinek friss\u00edt\u00e9se", + "reset_meter": "{subtype} m\u00e9r\u00e9sek alaphelyzetbe \u00e1ll\u00edt\u00e1sa", + "set_config_parameter": "{subtype} konfigur\u00e1ci\u00f3s param\u00e9ter \u00e9rt\u00e9k\u00e9nek be\u00e1ll\u00edt\u00e1sa", + "set_lock_usercode": "{entity_name} felhaszn\u00e1l\u00f3i k\u00f3dj\u00e1nak be\u00e1ll\u00edt\u00e1sa", + "set_value": "Z-Wave \u00e9rt\u00e9k be\u00e1ll\u00edt\u00e1sa" + }, "condition_type": { "config_parameter": "Konfigur\u00e1lja a(z) {subtype} param\u00e9ter \u00e9rt\u00e9k\u00e9t", "node_status": "Csom\u00f3pont \u00e1llapota", @@ -117,7 +126,7 @@ "use_addon": "Haszn\u00e1lja a Z-Wave JS Supervisor b\u0151v\u00edtm\u00e9nyt" }, "description": "Szeretn\u00e9 haszn\u00e1lni a Z-Wave JS Supervisor b\u0151v\u00edtm\u00e9nyt?", - "title": "V\u00e1laszd ki a csatlakoz\u00e1si m\u00f3dot" + "title": "V\u00e1lassza ki a csatlakoz\u00e1si m\u00f3dot" }, "start_addon": { "title": "Indul a Z-Wave JS b\u0151v\u00edtm\u00e9ny." diff --git a/homeassistant/components/zwave_js/translations/id.json b/homeassistant/components/zwave_js/translations/id.json index 61ea6762c7d..2004be7238f 100644 --- a/homeassistant/components/zwave_js/translations/id.json +++ b/homeassistant/components/zwave_js/translations/id.json @@ -8,7 +8,9 @@ "addon_start_failed": "Gagal memulai add-on Z-Wave JS.", "already_configured": "Perangkat sudah dikonfigurasi", "already_in_progress": "Alur konfigurasi sedang berlangsung", - "cannot_connect": "Gagal terhubung" + "cannot_connect": "Gagal terhubung", + "discovery_requires_supervisor": "Fitur penemuan membutuhkan supervisor.", + "not_zwave_device": "Perangkat yang ditemukan bukanperangkat Z-Wave." }, "error": { "addon_start_failed": "Gagal memulai add-on Z-Wave JS. Periksa konfigurasi.", @@ -16,6 +18,7 @@ "invalid_ws_url": "URL websocket tidak valid", "unknown": "Kesalahan yang tidak diharapkan" }, + "flow_title": "{name}", "progress": { "install_addon": "Harap tunggu hingga penginstalan add-on Z-Wave JS selesai. Ini bisa memakan waktu beberapa saat.", "start_addon": "Harap tunggu hingga add-on Z-Wave JS selesai. Ini mungkin perlu waktu beberapa saat." @@ -51,6 +54,16 @@ } } }, + "device_automation": { + "condition_type": { + "config_parameter": "Nilai parameter konfigurasi {subtype}", + "node_status": "Status node", + "value": "Nilai saat ini dari Nilai Z-Wave" + }, + "trigger_type": { + "state.node_status": "Status node berubah" + } + }, "options": { "abort": { "addon_get_discovery_info_failed": "Gagal mendapatkan info penemuan add-on Z-Wave JS.", diff --git a/homeassistant/components/zwave_js/translations/it.json b/homeassistant/components/zwave_js/translations/it.json index 165849e9387..af3416ed9a9 100644 --- a/homeassistant/components/zwave_js/translations/it.json +++ b/homeassistant/components/zwave_js/translations/it.json @@ -27,6 +27,10 @@ "configure_addon": { "data": { "network_key": "Chiave di rete", + "s0_legacy_key": "Chiave S0 (Obsoleta)", + "s2_access_control_key": "Chiave di controllo di accesso S2", + "s2_authenticated_key": "Chiave S2 autenticata", + "s2_unauthenticated_key": "Chiave S2 non autenticata", "usb_path": "Percorso del dispositivo USB" }, "title": "Accedi alla configurazione del componente aggiuntivo Z-Wave JS" @@ -58,6 +62,15 @@ } }, "device_automation": { + "action_type": { + "clear_lock_usercode": "Cancella codice utente su {entity_name}", + "ping": "Dispositivo ping", + "refresh_value": "Aggiorna il/i valore/i per {entity_name}", + "reset_meter": "Azzerare i contatori su {subtype}", + "set_config_parameter": "Imposta il valore del parametro di configurazione {subtype}", + "set_lock_usercode": "Imposta un codice utente su {entity_name}", + "set_value": "Imposta un valore Z-Wave" + }, "condition_type": { "config_parameter": "Valore del parametro di configurazione {subtype}", "node_status": "Stato del nodo", @@ -100,6 +113,10 @@ "emulate_hardware": "Emulare l'hardware", "log_level": "Livello di registro", "network_key": "Chiave di rete", + "s0_legacy_key": "Chiave S0 (Obsoleta)", + "s2_access_control_key": "Chiave di controllo di accesso S2", + "s2_authenticated_key": "Chiave S2 autenticata", + "s2_unauthenticated_key": "Chiave S2 non autenticata", "usb_path": "Percorso del dispositivo USB" }, "title": "Entra nella configurazione del componente aggiuntivo Z-Wave JS" diff --git a/homeassistant/components/zwave_js/translations/nl.json b/homeassistant/components/zwave_js/translations/nl.json index 23d185f1ded..b7a3a68fe6b 100644 --- a/homeassistant/components/zwave_js/translations/nl.json +++ b/homeassistant/components/zwave_js/translations/nl.json @@ -53,11 +53,20 @@ "title": "De add-on Z-Wave JS wordt gestart." }, "usb_confirm": { - "description": "Wilt u {naam} instellen met de Z-Wave JS add-on?" + "description": "Wilt u {name} instellen met de Z-Wave JS add-on?" } } }, "device_automation": { + "action_type": { + "clear_lock_usercode": "Wis gebruikerscode van {entity_name}", + "ping": "Ping apparaat", + "refresh_value": "Ververs de waarde(s) voor {entity_name}", + "reset_meter": "Reset meters op {subtype}", + "set_config_parameter": "Stel waarde in voor configuratieparameter {subtype}", + "set_lock_usercode": "Stel gebruikerscode in voor {entity_name}", + "set_value": "Waarde van een Z-Wave waarde instellen" + }, "condition_type": { "config_parameter": "Config parameter {subtype} waarde", "node_status": "Knooppuntstatus", diff --git a/homeassistant/components/zwave_js/translations/no.json b/homeassistant/components/zwave_js/translations/no.json index b69b1cb4f7a..9ddf12a3b85 100644 --- a/homeassistant/components/zwave_js/translations/no.json +++ b/homeassistant/components/zwave_js/translations/no.json @@ -58,6 +58,15 @@ } }, "device_automation": { + "action_type": { + "clear_lock_usercode": "Fjern brukerkoden p\u00e5 {entity_name}", + "ping": "Ping -enhet", + "refresh_value": "Oppdater verdien (e) for {entity_name}", + "reset_meter": "Tilbakestill m\u00e5lere p\u00e5 {subtype}", + "set_config_parameter": "Angi verdien til konfigurasjonsparameteren {subtype}", + "set_lock_usercode": "Angi en brukerkode p\u00e5 {entity_name}", + "set_value": "Angi verdien for en Z-Wave-verdi" + }, "condition_type": { "config_parameter": "Konfigurer parameter {subtype} verdi", "node_status": "Nodestatus", diff --git a/homeassistant/components/zwave_js/translations/ru.json b/homeassistant/components/zwave_js/translations/ru.json index 994bfb54cfc..9ae79edb32d 100644 --- a/homeassistant/components/zwave_js/translations/ru.json +++ b/homeassistant/components/zwave_js/translations/ru.json @@ -27,6 +27,10 @@ "configure_addon": { "data": { "network_key": "\u041a\u043b\u044e\u0447 \u0441\u0435\u0442\u0438", + "s0_legacy_key": "\u041a\u043b\u044e\u0447 S0 (\u0443\u0441\u0442\u0430\u0440\u0435\u0432\u0448\u0438\u0439)", + "s2_access_control_key": "\u041a\u043b\u044e\u0447 \u043a\u043e\u043d\u0442\u0440\u043e\u043b\u044f \u0434\u043e\u0441\u0442\u0443\u043f\u0430 S2", + "s2_authenticated_key": "\u041a\u043b\u044e\u0447 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438 S2", + "s2_unauthenticated_key": "\u041a\u043b\u044e\u0447 \u0431\u0435\u0437 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438 S2", "usb_path": "\u041f\u0443\u0442\u044c \u043a USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443" }, "title": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e \u0434\u043e\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f Z-Wave JS" @@ -58,6 +62,15 @@ } }, "device_automation": { + "action_type": { + "clear_lock_usercode": "\u041e\u0447\u0438\u0441\u0442\u0438\u0442\u044c \u043a\u043e\u0434 \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u043d\u0430 {entity_name}", + "ping": "\u041f\u0440\u043e\u0432\u0435\u0440\u0438\u0442\u044c \u0441\u0432\u044f\u0437\u044c \u0441 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u043c", + "refresh_value": "\u041e\u0431\u043d\u043e\u0432\u0438\u0442\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f \u0434\u043b\u044f {entity_name}", + "reset_meter": "\u0421\u0431\u0440\u043e\u0441\u0438\u0442\u044c \u0441\u0447\u0435\u0442\u0447\u0438\u043a\u0438 \u043d\u0430 {subtype}", + "set_config_parameter": "\u0423\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 {subtype}", + "set_lock_usercode": "\u0423\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c \u043a\u043e\u0434 \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u043d\u0430 {entity_name}", + "set_value": "\u0423\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 Z-Wave Value" + }, "condition_type": { "config_parameter": "\u0417\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 {subtype}", "node_status": "\u0421\u0442\u0430\u0442\u0443\u0441 \u0443\u0437\u043b\u0430", @@ -100,6 +113,10 @@ "emulate_hardware": "\u042d\u043c\u0443\u043b\u044f\u0446\u0438\u044f \u043e\u0431\u043e\u0440\u0443\u0434\u043e\u0432\u0430\u043d\u0438\u044f", "log_level": "\u0423\u0440\u043e\u0432\u0435\u043d\u044c \u0436\u0443\u0440\u043d\u0430\u043b\u0430", "network_key": "\u041a\u043b\u044e\u0447 \u0441\u0435\u0442\u0438", + "s0_legacy_key": "\u041a\u043b\u044e\u0447 S0 (\u0443\u0441\u0442\u0430\u0440\u0435\u0432\u0448\u0438\u0439)", + "s2_access_control_key": "\u041a\u043b\u044e\u0447 \u043a\u043e\u043d\u0442\u0440\u043e\u043b\u044f \u0434\u043e\u0441\u0442\u0443\u043f\u0430 S2", + "s2_authenticated_key": "\u041a\u043b\u044e\u0447 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438 S2", + "s2_unauthenticated_key": "\u041a\u043b\u044e\u0447 \u0431\u0435\u0437 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438 S2", "usb_path": "\u041f\u0443\u0442\u044c \u043a USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443" }, "title": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e \u0434\u043e\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f Z-Wave JS" diff --git a/homeassistant/components/zwave_js/translations/zh-Hant.json b/homeassistant/components/zwave_js/translations/zh-Hant.json index e9038ed9a00..7b495ed0ca0 100644 --- a/homeassistant/components/zwave_js/translations/zh-Hant.json +++ b/homeassistant/components/zwave_js/translations/zh-Hant.json @@ -27,6 +27,10 @@ "configure_addon": { "data": { "network_key": "\u7db2\u8def\u5bc6\u9470", + "s0_legacy_key": "S0 \u5bc6\u9470\uff08\u820a\u7248\uff09", + "s2_access_control_key": "S2 \u5b58\u53d6\u63a7\u5236\u5bc6\u9470", + "s2_authenticated_key": "S2 \u9a57\u8b49\u5bc6\u9470", + "s2_unauthenticated_key": "S2 \u672a\u9a57\u8b49\u5bc6\u9470", "usb_path": "USB \u88dd\u7f6e\u8def\u5f91" }, "title": "\u8f38\u5165 Z-Wave JS \u9644\u52a0\u5143\u4ef6\u8a2d\u5b9a" @@ -58,6 +62,15 @@ } }, "device_automation": { + "action_type": { + "clear_lock_usercode": "\u6e05\u9664 {entity_name} usercode", + "ping": "Ping \u88dd\u7f6e", + "refresh_value": "\u66f4\u65b0 {entity_name} \u6578\u503c", + "reset_meter": "\u91cd\u7f6e {subtype} \u8a08\u91cf", + "set_config_parameter": "\u8a2d\u5b9a {subtype} \u8a2d\u5b9a\u8b8a\u6578", + "set_lock_usercode": "\u8a2d\u5b9a {entity_name} usercode", + "set_value": "\u8a2d\u5b9a Z-Wave \u6578\u503c" + }, "condition_type": { "config_parameter": "\u8a2d\u5b9a\u53c3\u6578 {subtype} \u6578\u503c", "node_status": "\u7bc0\u9ede\u72c0\u614b", @@ -100,6 +113,10 @@ "emulate_hardware": "\u6a21\u64ec\u786c\u9ad4", "log_level": "\u65e5\u8a8c\u8a18\u9304\u7b49\u7d1a", "network_key": "\u7db2\u8def\u5bc6\u9470", + "s0_legacy_key": "S0 \u5bc6\u9470\uff08\u820a\u7248\uff09", + "s2_access_control_key": "S2 \u5b58\u53d6\u63a7\u5236\u5bc6\u9470", + "s2_authenticated_key": "S2 \u9a57\u8b49\u5bc6\u9470", + "s2_unauthenticated_key": "S2 \u672a\u9a57\u8b49\u5bc6\u9470", "usb_path": "USB \u88dd\u7f6e\u8def\u5f91" }, "title": "\u8f38\u5165 Z-Wave JS \u9644\u52a0\u5143\u4ef6\u8a2d\u5b9a" From 75b6526cdc0a996fcc09c8114777ac1f7661329a Mon Sep 17 00:00:00 2001 From: Martin Date: Fri, 1 Oct 2021 21:46:44 +0200 Subject: [PATCH 767/843] Fix vicare binary sensor (#56912) --- homeassistant/components/vicare/binary_sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/vicare/binary_sensor.py b/homeassistant/components/vicare/binary_sensor.py index 9897b38ccf5..88d6e3ac06a 100644 --- a/homeassistant/components/vicare/binary_sensor.py +++ b/homeassistant/components/vicare/binary_sensor.py @@ -124,6 +124,7 @@ class ViCareBinarySensor(BinarySensorEntity): def __init__(self, name, api, description: DescriptionT): """Initialize the sensor.""" + self.entity_description = description self._attr_name = f"{name} {description.name}" self._api = api self._state = None From 3579d638a8b20152399fb2e222708165559e0511 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 2 Oct 2021 22:52:28 +0200 Subject: [PATCH 768/843] Set unique id while SSDP discovery of Synology DSM (#56914) --- homeassistant/components/synology_dsm/config_flow.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/synology_dsm/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py index ae24adc7960..ed0ee8e9125 100644 --- a/homeassistant/components/synology_dsm/config_flow.py +++ b/homeassistant/components/synology_dsm/config_flow.py @@ -239,8 +239,12 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): # Synology NAS can broadcast on multiple IP addresses, since they can be connected to multiple ethernets. # The serial of the NAS is actually its MAC address. + await self.async_set_unique_id(discovered_mac) existing_entry = self._async_get_existing_entry(discovered_mac) + if not existing_entry: + self._abort_if_unique_id_configured() + if existing_entry and existing_entry.data[CONF_HOST] != parsed_url.hostname: _LOGGER.debug( "Update host from '%s' to '%s' for NAS '%s' via SSDP discovery", @@ -253,6 +257,7 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): data={**existing_entry.data, CONF_HOST: parsed_url.hostname}, ) return self.async_abort(reason="reconfigure_successful") + if existing_entry: return self.async_abort(reason="already_configured") From 5f681921099d947662206c4c152fc6803e56aeba Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Sat, 2 Oct 2021 04:20:17 -0600 Subject: [PATCH 769/843] Address beta review comments for WattTime (#56919) --- homeassistant/components/watttime/__init__.py | 11 +++--- .../components/watttime/config_flow.py | 16 ++++----- .../components/watttime/manifest.json | 4 --- homeassistant/components/watttime/sensor.py | 35 +++++-------------- 4 files changed, 19 insertions(+), 47 deletions(-) diff --git a/homeassistant/components/watttime/__init__.py b/homeassistant/components/watttime/__init__.py index d376dd40db6..6d23182c011 100644 --- a/homeassistant/components/watttime/__init__.py +++ b/homeassistant/components/watttime/__init__.py @@ -27,16 +27,13 @@ PLATFORMS: list[str] = ["sensor"] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up WattTime from a config entry.""" - hass.data.setdefault(DOMAIN, {DATA_COORDINATOR: {}}) + hass.data.setdefault(DOMAIN, {entry.entry_id: {DATA_COORDINATOR: {}}}) session = aiohttp_client.async_get_clientsession(hass) try: client = await Client.async_login( - entry.data[CONF_USERNAME], - entry.data[CONF_PASSWORD], - session=session, - logger=LOGGER, + entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], session=session ) except WattTimeError as err: LOGGER.error("Error while authenticating with WattTime: %s", err) @@ -62,7 +59,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id] = coordinator + hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] = coordinator hass.config_entries.async_setup_platforms(entry, PLATFORMS) @@ -73,6 +70,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: - hass.data[DOMAIN][DATA_COORDINATOR].pop(entry.entry_id) + hass.data[DOMAIN].pop(entry.entry_id) return unload_ok diff --git a/homeassistant/components/watttime/config_flow.py b/homeassistant/components/watttime/config_flow.py index a6c5dd422c2..6c523f64331 100644 --- a/homeassistant/components/watttime/config_flow.py +++ b/homeassistant/components/watttime/config_flow.py @@ -118,16 +118,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="location", data_schema=STEP_LOCATION_DATA_SCHEMA ) - if user_input[CONF_LOCATION_TYPE] == LOCATION_TYPE_COORDINATES: - return self.async_show_form( - step_id="coordinates", data_schema=STEP_COORDINATES_DATA_SCHEMA + if user_input[CONF_LOCATION_TYPE] == LOCATION_TYPE_HOME: + return await self.async_step_coordinates( + { + CONF_LATITUDE: self.hass.config.latitude, + CONF_LONGITUDE: self.hass.config.longitude, + } ) - return await self.async_step_coordinates( - { - CONF_LATITUDE: self.hass.config.latitude, - CONF_LONGITUDE: self.hass.config.longitude, - } - ) + return await self.async_step_coordinates() async def async_step_user( self, user_input: dict[str, Any] | None = None diff --git a/homeassistant/components/watttime/manifest.json b/homeassistant/components/watttime/manifest.json index d4000b6f6b1..85a32bce331 100644 --- a/homeassistant/components/watttime/manifest.json +++ b/homeassistant/components/watttime/manifest.json @@ -6,10 +6,6 @@ "requirements": [ "aiowatttime==0.1.1" ], - "ssdp": [], - "zeroconf": [], - "homekit": {}, - "dependencies": [], "codeowners": [ "@bachya" ], diff --git a/homeassistant/components/watttime/sensor.py b/homeassistant/components/watttime/sensor.py index 4453044e0d2..6a6d05701c4 100644 --- a/homeassistant/components/watttime/sensor.py +++ b/homeassistant/components/watttime/sensor.py @@ -1,7 +1,6 @@ """Support for WattTime sensors.""" from __future__ import annotations -from dataclasses import dataclass from typing import TYPE_CHECKING from homeassistant.components.sensor import ( @@ -36,40 +35,24 @@ ATTR_BALANCING_AUTHORITY = "balancing_authority" DEFAULT_ATTRIBUTION = "Pickup data provided by WattTime" -SENSOR_TYPE_REALTIME_EMISSIONS_MOER = "realtime_emissions_moer" -SENSOR_TYPE_REALTIME_EMISSIONS_PERCENT = "realtime_emissions_percent" - - -@dataclass -class RealtimeEmissionsSensorDescriptionMixin: - """Define an entity description mixin for realtime emissions sensors.""" - - data_key: str - - -@dataclass -class RealtimeEmissionsSensorEntityDescription( - SensorEntityDescription, RealtimeEmissionsSensorDescriptionMixin -): - """Describe a realtime emissions sensor.""" +SENSOR_TYPE_REALTIME_EMISSIONS_MOER = "moer" +SENSOR_TYPE_REALTIME_EMISSIONS_PERCENT = "percent" REALTIME_EMISSIONS_SENSOR_DESCRIPTIONS = ( - RealtimeEmissionsSensorEntityDescription( + SensorEntityDescription( key=SENSOR_TYPE_REALTIME_EMISSIONS_MOER, name="Marginal Operating Emissions Rate", icon="mdi:blur", native_unit_of_measurement=f"{MASS_POUNDS} CO2/MWh", state_class=STATE_CLASS_MEASUREMENT, - data_key="moer", ), - RealtimeEmissionsSensorEntityDescription( + SensorEntityDescription( key=SENSOR_TYPE_REALTIME_EMISSIONS_PERCENT, name="Relative Marginal Emissions Intensity", icon="mdi:blur", native_unit_of_measurement=PERCENTAGE, state_class=STATE_CLASS_MEASUREMENT, - data_key="percent", ), ) @@ -78,12 +61,12 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up WattTime sensors based on a config entry.""" - coordinator = hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id] + coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] async_add_entities( [ RealtimeEmissionsSensor(coordinator, description) for description in REALTIME_EMISSIONS_SENSOR_DESCRIPTIONS - if description.data_key in coordinator.data + if description.key in coordinator.data ] ) @@ -91,12 +74,10 @@ async def async_setup_entry( class RealtimeEmissionsSensor(CoordinatorEntity, SensorEntity): """Define a realtime emissions sensor.""" - entity_description: RealtimeEmissionsSensorEntityDescription - def __init__( self, coordinator: DataUpdateCoordinator, - description: RealtimeEmissionsSensorEntityDescription, + description: SensorEntityDescription, ) -> None: """Initialize the sensor.""" super().__init__(coordinator) @@ -119,4 +100,4 @@ class RealtimeEmissionsSensor(CoordinatorEntity, SensorEntity): @property def native_value(self) -> StateType: """Return the value reported by the sensor.""" - return self.coordinator.data[self.entity_description.data_key] + return self.coordinator.data[self.entity_description.key] From baae7089ed13c0bd0ae1ecee9a88504af22bccfd Mon Sep 17 00:00:00 2001 From: MatthewFlamm <39341281+MatthewFlamm@users.noreply.github.com> Date: Sat, 2 Oct 2021 03:11:31 -0400 Subject: [PATCH 770/843] Bump pynws: fix unit code bug (#56923) --- homeassistant/components/nws/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nws/manifest.json b/homeassistant/components/nws/manifest.json index d1e7158ab20..30b00fde15a 100644 --- a/homeassistant/components/nws/manifest.json +++ b/homeassistant/components/nws/manifest.json @@ -3,7 +3,7 @@ "name": "National Weather Service (NWS)", "documentation": "https://www.home-assistant.io/integrations/nws", "codeowners": ["@MatthewFlamm"], - "requirements": ["pynws==1.3.0"], + "requirements": ["pynws==1.3.1"], "quality_scale": "platinum", "config_flow": true, "iot_class": "cloud_polling" diff --git a/requirements_all.txt b/requirements_all.txt index dd2447b9bda..990b28276b0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1670,7 +1670,7 @@ pynuki==1.4.1 pynut2==2.1.2 # homeassistant.components.nws -pynws==1.3.0 +pynws==1.3.1 # homeassistant.components.nx584 pynx584==0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8ba707f471c..7bf3e73c6b5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -986,7 +986,7 @@ pynuki==1.4.1 pynut2==2.1.2 # homeassistant.components.nws -pynws==1.3.0 +pynws==1.3.1 # homeassistant.components.nx584 pynx584==0.5 From b1b23ef67df2aceed3d1b837f4e1f449b862a707 Mon Sep 17 00:00:00 2001 From: RenierM26 <66512715+RenierM26@users.noreply.github.com> Date: Sat, 2 Oct 2021 22:51:53 +0200 Subject: [PATCH 771/843] Fix Switchbot unsupported SB types (#56928) --- homeassistant/components/switchbot/config_flow.py | 2 +- tests/components/switchbot/conftest.py | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/switchbot/config_flow.py b/homeassistant/components/switchbot/config_flow.py index eba40d46058..2d4e61bada5 100644 --- a/homeassistant/components/switchbot/config_flow.py +++ b/homeassistant/components/switchbot/config_flow.py @@ -112,7 +112,7 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN): unconfigured_devices = { device["mac_address"]: f"{device['mac_address']} {device['modelName']}" for device in self._discovered_devices.values() - if device["modelName"] in SUPPORTED_MODEL_TYPES + if device.get("modelName") in SUPPORTED_MODEL_TYPES and device["mac_address"] not in configured_devices } diff --git a/tests/components/switchbot/conftest.py b/tests/components/switchbot/conftest.py index 8e90547a18f..52e5fd4fa15 100644 --- a/tests/components/switchbot/conftest.py +++ b/tests/components/switchbot/conftest.py @@ -41,6 +41,12 @@ class MocGetSwitchbotDevices: "model": "c", "modelName": "WoCurtain", }, + "ffffff19ffff": { + "mac_address": "ff:ff:ff:19:ff:ff", + "Flags": "06", + "Manufacturer": "5900ffffff19ffff", + "Complete 128b Services": "cba20d00-224d-11e6-9fb8-0002a5d5c51b", + }, } self._curtain_all_services_data = { "mac_address": "e7:89:43:90:90:90", From 7a75506b0275e1481f9bbe49fa98f2acd6773afc Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sat, 2 Oct 2021 09:05:49 +0200 Subject: [PATCH 772/843] Fix `Unable to serialize to JSON` error in Xiaomi Miio (#56929) --- homeassistant/components/xiaomi_miio/fan.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index 1b275ea2d6e..04cdc4573db 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -1,6 +1,7 @@ """Support for Xiaomi Mi Air Purifier and Xiaomi Mi Air Humidifier.""" from abc import abstractmethod import asyncio +from enum import Enum import logging import math @@ -363,13 +364,21 @@ class XiaomiGenericAirPurifier(XiaomiGenericDevice): return None + @staticmethod + def _extract_value_from_attribute(state, attribute): + value = getattr(state, attribute) + if isinstance(value, Enum): + return value.value + + return value + @callback def _handle_coordinator_update(self): """Fetch state from the device.""" self._state = self.coordinator.data.is_on self._state_attrs.update( { - key: getattr(self.coordinator.data, value) + key: self._extract_value_from_attribute(self.coordinator.data, value) for key, value in self._available_attributes.items() } ) @@ -434,7 +443,7 @@ class XiaomiAirPurifier(XiaomiGenericAirPurifier): self._state = self.coordinator.data.is_on self._state_attrs.update( { - key: getattr(self.coordinator.data, value) + key: self._extract_value_from_attribute(self.coordinator.data, value) for key, value in self._available_attributes.items() } ) From 8ecf03569d422356fe511b33d6cf688d0e758318 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 Oct 2021 03:19:11 -0500 Subject: [PATCH 773/843] Add DHCP support for TPLink KL430, KP115 (#56932) --- homeassistant/components/tplink/manifest.json | 8 ++++++++ homeassistant/generated/dhcp.py | 10 ++++++++++ 2 files changed, 18 insertions(+) diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json index cfc9fce5213..6712da00d0e 100644 --- a/homeassistant/components/tplink/manifest.json +++ b/homeassistant/components/tplink/manifest.json @@ -13,6 +13,14 @@ "hostname": "ep*", "macaddress": "E848B8*" }, + { + "hostname": "k[lp]*", + "macaddress": "E848B8*" + }, + { + "hostname": "k[lp]*", + "macaddress": "909A4A*" + }, { "hostname": "hs*", "macaddress": "1C3BF3*" diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 370a87e2575..34b0a468fc1 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -269,6 +269,16 @@ DHCP = [ "hostname": "ep*", "macaddress": "E848B8*" }, + { + "domain": "tplink", + "hostname": "k[lp]*", + "macaddress": "E848B8*" + }, + { + "domain": "tplink", + "hostname": "k[lp]*", + "macaddress": "909A4A*" + }, { "domain": "tplink", "hostname": "hs*", From 4438fc55e0de34cea625281581a86c6ab98849b5 Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Sat, 2 Oct 2021 22:53:19 +0200 Subject: [PATCH 774/843] Update pypoint to use v5 of backend API (#56934) --- homeassistant/components/point/manifest.json | 2 +- homeassistant/components/point/sensor.py | 8 -------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 3 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/point/manifest.json b/homeassistant/components/point/manifest.json index fffb1b07f25..13a1ac5ce23 100644 --- a/homeassistant/components/point/manifest.json +++ b/homeassistant/components/point/manifest.json @@ -3,7 +3,7 @@ "name": "Minut Point", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/point", - "requirements": ["pypoint==2.1.0"], + "requirements": ["pypoint==2.2.0"], "dependencies": ["webhook", "http"], "codeowners": ["@fredrike"], "quality_scale": "gold", diff --git a/homeassistant/components/point/sensor.py b/homeassistant/components/point/sensor.py index 8d4ee69fca2..bb98ccb53d9 100644 --- a/homeassistant/components/point/sensor.py +++ b/homeassistant/components/point/sensor.py @@ -11,10 +11,8 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ( DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_PRESSURE, DEVICE_CLASS_TEMPERATURE, PERCENTAGE, - PRESSURE_HPA, SOUND_PRESSURE_WEIGHTED_DBA, TEMP_CELSIUS, ) @@ -50,12 +48,6 @@ SENSOR_TYPES: tuple[MinutPointSensorEntityDescription, ...] = ( device_class=DEVICE_CLASS_TEMPERATURE, native_unit_of_measurement=TEMP_CELSIUS, ), - MinutPointSensorEntityDescription( - key="pressure", - precision=0, - device_class=DEVICE_CLASS_PRESSURE, - native_unit_of_measurement=PRESSURE_HPA, - ), MinutPointSensorEntityDescription( key="humidity", precision=1, diff --git a/requirements_all.txt b/requirements_all.txt index 990b28276b0..db90da7d71f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1723,7 +1723,7 @@ pypjlink2==1.2.1 pyplaato==0.0.15 # homeassistant.components.point -pypoint==2.1.0 +pypoint==2.2.0 # homeassistant.components.profiler pyprof2calltree==1.4.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7bf3e73c6b5..b1d34061845 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1021,7 +1021,7 @@ pypck==0.7.10 pyplaato==0.0.15 # homeassistant.components.point -pypoint==2.1.0 +pypoint==2.2.0 # homeassistant.components.profiler pyprof2calltree==1.4.5 From 427200e258e679bc423edc849583b9469fe67123 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 Oct 2021 05:47:28 -0500 Subject: [PATCH 775/843] Bump PyFlume to 0.6.5 to fix compat with new JWT (#56936) Changelog: https://github.com/ChrisMandich/PyFlume/compare/5476fd67cfc8be768c0ea810d248f39399e038d1...v0.6.5 --- homeassistant/components/flume/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/flume/manifest.json b/homeassistant/components/flume/manifest.json index d689f5fb17f..cdad0dd3f0c 100644 --- a/homeassistant/components/flume/manifest.json +++ b/homeassistant/components/flume/manifest.json @@ -2,7 +2,7 @@ "domain": "flume", "name": "Flume", "documentation": "https://www.home-assistant.io/integrations/flume/", - "requirements": ["pyflume==0.5.5"], + "requirements": ["pyflume==0.6.5"], "codeowners": ["@ChrisMandich", "@bdraco"], "config_flow": true, "dhcp": [ diff --git a/requirements_all.txt b/requirements_all.txt index db90da7d71f..a8dd4b89005 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1480,7 +1480,7 @@ pyfireservicerota==0.0.43 pyflic==2.0.3 # homeassistant.components.flume -pyflume==0.5.5 +pyflume==0.6.5 # homeassistant.components.flunearyou pyflunearyou==2.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b1d34061845..1c7299da62b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -856,7 +856,7 @@ pyfido==2.1.1 pyfireservicerota==0.0.43 # homeassistant.components.flume -pyflume==0.5.5 +pyflume==0.6.5 # homeassistant.components.flunearyou pyflunearyou==2.0.2 From 14cddc75c2e8a6c5376cfb7c936015617236139d Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sat, 2 Oct 2021 16:31:23 +0200 Subject: [PATCH 776/843] Add sleep_period to log for easier debugging (#56949) --- homeassistant/components/shelly/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index ad0ad5f4387..b0df4d4cb7f 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -320,9 +320,11 @@ class BlockDeviceWrapper(update_coordinator.DataUpdateCoordinator): async def _async_update_data(self) -> None: """Fetch data.""" - if self.entry.data.get("sleep_period"): + if sleep_period := self.entry.data.get("sleep_period"): # Sleeping device, no point polling it, just mark it unavailable - raise update_coordinator.UpdateFailed("Sleeping device did not update") + raise update_coordinator.UpdateFailed( + f"Sleeping device did not update within {sleep_period} seconds interval" + ) _LOGGER.debug("Polling Shelly Block Device - %s", self.name) try: From e4d295c80a23108b7447ff468626e8f3b4f83544 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 Oct 2021 15:53:37 -0500 Subject: [PATCH 777/843] Add dhcp discovery for TPLink EP10 (#56955) --- homeassistant/components/tplink/manifest.json | 4 ++++ homeassistant/generated/dhcp.py | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json index 6712da00d0e..a24d95bbc75 100644 --- a/homeassistant/components/tplink/manifest.json +++ b/homeassistant/components/tplink/manifest.json @@ -45,6 +45,10 @@ "hostname": "hs*", "macaddress": "C006C3*" }, + { + "hostname": "ep*", + "macaddress": "003192*" + }, { "hostname": "k[lp]*", "macaddress": "003192*" diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 34b0a468fc1..73343bdf157 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -309,6 +309,11 @@ DHCP = [ "hostname": "hs*", "macaddress": "C006C3*" }, + { + "domain": "tplink", + "hostname": "ep*", + "macaddress": "003192*" + }, { "domain": "tplink", "hostname": "k[lp]*", From 180316026ba1af4eaab51d5fc2d942664025c2d3 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 2 Oct 2021 13:54:13 -0700 Subject: [PATCH 778/843] Bumped version to 2021.10.0b3 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 6c6e56f5927..058eeb13668 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -5,7 +5,7 @@ from typing import Final MAJOR_VERSION: Final = 2021 MINOR_VERSION: Final = 10 -PATCH_VERSION: Final = "0b2" +PATCH_VERSION: Final = "0b3" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0) From fc373563bd75dcd19c7af69dc7e80696902f2d83 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Sat, 2 Oct 2021 23:16:29 +0200 Subject: [PATCH 779/843] Update frontend to 20211002.0 (#56963) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index cf1f8f052af..d6d38faab27 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", "requirements": [ - "home-assistant-frontend==20210930.0" + "home-assistant-frontend==20211002.0" ], "dependencies": [ "api", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f8c047e81f0..e7f0cb6a5c3 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -15,7 +15,7 @@ ciso8601==2.2.0 cryptography==3.4.8 emoji==1.5.0 hass-nabucasa==0.50.0 -home-assistant-frontend==20210930.0 +home-assistant-frontend==20211002.0 httpx==0.19.0 ifaddr==0.1.7 jinja2==3.0.1 diff --git a/requirements_all.txt b/requirements_all.txt index a8dd4b89005..506f88df9db 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -810,7 +810,7 @@ hole==0.5.1 holidays==0.11.3.1 # homeassistant.components.frontend -home-assistant-frontend==20210930.0 +home-assistant-frontend==20211002.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1c7299da62b..15df8a9f94e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -485,7 +485,7 @@ hole==0.5.1 holidays==0.11.3.1 # homeassistant.components.frontend -home-assistant-frontend==20210930.0 +home-assistant-frontend==20211002.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 From 2dac92df0c23f7f2342ca707281d13bcf700e5d7 Mon Sep 17 00:00:00 2001 From: Michael Chisholm Date: Mon, 4 Oct 2021 02:09:30 +1100 Subject: [PATCH 780/843] Disable discovery for dlna_dmr until it is more selective (#56950) --- .../components/dlna_dmr/manifest.json | 20 ------------------- homeassistant/generated/ssdp.py | 20 ------------------- 2 files changed, 40 deletions(-) diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index 002228e28b3..8ea4ab48e27 100644 --- a/homeassistant/components/dlna_dmr/manifest.json +++ b/homeassistant/components/dlna_dmr/manifest.json @@ -5,26 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/dlna_dmr", "requirements": ["async-upnp-client==0.22.4"], "dependencies": ["network", "ssdp"], - "ssdp": [ - { - "st": "urn:schemas-upnp-org:device:MediaRenderer:1" - }, - { - "st": "urn:schemas-upnp-org:device:MediaRenderer:2" - }, - { - "st": "urn:schemas-upnp-org:device:MediaRenderer:3" - }, - { - "nt": "urn:schemas-upnp-org:device:MediaRenderer:1" - }, - { - "nt": "urn:schemas-upnp-org:device:MediaRenderer:2" - }, - { - "nt": "urn:schemas-upnp-org:device:MediaRenderer:3" - } - ], "codeowners": ["@StevenLooman", "@chishm"], "iot_class": "local_push" } diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index b058f972229..e5e823b404a 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -83,26 +83,6 @@ SSDP = { "manufacturer": "DIRECTV" } ], - "dlna_dmr": [ - { - "st": "urn:schemas-upnp-org:device:MediaRenderer:1" - }, - { - "st": "urn:schemas-upnp-org:device:MediaRenderer:2" - }, - { - "st": "urn:schemas-upnp-org:device:MediaRenderer:3" - }, - { - "nt": "urn:schemas-upnp-org:device:MediaRenderer:1" - }, - { - "nt": "urn:schemas-upnp-org:device:MediaRenderer:2" - }, - { - "nt": "urn:schemas-upnp-org:device:MediaRenderer:3" - } - ], "fritz": [ { "st": "urn:schemas-upnp-org:device:fritzbox:1" From e454c6628a1a6602aed287b848a2f048b149b06e Mon Sep 17 00:00:00 2001 From: Oliver Ou Date: Sun, 3 Oct 2021 08:41:31 +0800 Subject: [PATCH 781/843] Fix Tuya v2 fan percentage (#56954) * fix:Some fans do not have a fan_speed_percent key * fix comment format issue Co-authored-by: erchuan --- homeassistant/components/tuya/fan.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tuya/fan.py b/homeassistant/components/tuya/fan.py index dcfde0ded0f..15a8e553a10 100644 --- a/homeassistant/components/tuya/fan.py +++ b/homeassistant/components/tuya/fan.py @@ -211,7 +211,7 @@ class TuyaHaFan(TuyaHaEntity, FanEntity): return self.tuya_device.status[DPCODE_MODE] @property - def percentage(self) -> int: + def percentage(self) -> int | None: """Return the current speed.""" if not self.is_on: return 0 @@ -228,7 +228,8 @@ class TuyaHaFan(TuyaHaEntity, FanEntity): self.tuya_device.status[DPCODE_AP_FAN_SPEED_ENUM], ) - return self.tuya_device.status[DPCODE_FAN_SPEED] + # some type may not have the fan_speed_percent key + return self.tuya_device.status.get(DPCODE_FAN_SPEED) @property def speed_count(self) -> int: From 201be1a59d8d6dc0c5138bb0de042f10bca21da3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 Oct 2021 21:08:31 -1000 Subject: [PATCH 782/843] Fix yeelight state when controlled outside of Home Assistant (#56964) --- homeassistant/components/yeelight/__init__.py | 24 +- homeassistant/components/yeelight/light.py | 69 +++-- tests/components/yeelight/test_init.py | 5 +- tests/components/yeelight/test_light.py | 259 ++++++++++-------- 4 files changed, 201 insertions(+), 156 deletions(-) diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py index a4ff947191e..e7f7b06f58f 100644 --- a/homeassistant/components/yeelight/__init__.py +++ b/homeassistant/components/yeelight/__init__.py @@ -36,6 +36,9 @@ from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) +STATE_CHANGE_TIME = 0.25 # seconds + + DOMAIN = "yeelight" DATA_YEELIGHT = DOMAIN DATA_UPDATED = "yeelight_{}_data_updated" @@ -546,6 +549,17 @@ class YeelightScanner: self._async_stop_scan() +def update_needs_bg_power_workaround(data): + """Check if a push update needs the bg_power workaround. + + Some devices will push the incorrect state for bg_power. + + To work around this any time we are pushed an update + with bg_power, we force poll state which will be correct. + """ + return "bg_power" in data + + class YeelightDevice: """Represents single Yeelight device.""" @@ -692,12 +706,18 @@ class YeelightDevice: await self._async_update_properties() async_dispatcher_send(self._hass, DATA_UPDATED.format(self._host)) + async def _async_forced_update(self, _now): + """Call a forced update.""" + await self.async_update(True) + @callback def async_update_callback(self, data): """Update push from device.""" was_available = self._available self._available = data.get(KEY_CONNECTED, True) - if self._did_first_update and not was_available and self._available: + if update_needs_bg_power_workaround(data) or ( + self._did_first_update and not was_available and self._available + ): # On reconnect the properties may be out of sync # # We need to make sure the DEVICE_INITIALIZED dispatcher is setup @@ -708,7 +728,7 @@ class YeelightDevice: # to be called when async_setup_entry reaches the end of the # function # - asyncio.create_task(self.async_update(True)) + async_call_later(self._hass, STATE_CHANGE_TIME, self._async_forced_update) async_dispatcher_send(self._hass, DATA_UPDATED.format(self._host)) diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py index 3f5bb29bab7..69dde0e75b6 100644 --- a/homeassistant/components/yeelight/light.py +++ b/homeassistant/components/yeelight/light.py @@ -1,7 +1,6 @@ """Light platform support for yeelight.""" from __future__ import annotations -import asyncio import logging import math @@ -210,9 +209,6 @@ SERVICE_SCHEMA_SET_AUTO_DELAY_OFF_SCENE = { } -STATE_CHANGE_TIME = 0.25 # seconds - - @callback def _transitions_config_parser(transitions): """Parse transitions config into initialized objects.""" @@ -252,13 +248,15 @@ def _async_cmd(func): # A network error happened, the bulb is likely offline now self.device.async_mark_unavailable() self.async_write_ha_state() + exc_message = str(ex) or type(ex) raise HomeAssistantError( - f"Error when calling {func.__name__} for bulb {self.device.name} at {self.device.host}: {ex}" + f"Error when calling {func.__name__} for bulb {self.device.name} at {self.device.host}: {exc_message}" ) from ex except BULB_EXCEPTIONS as ex: # The bulb likely responded but had an error + exc_message = str(ex) or type(ex) raise HomeAssistantError( - f"Error when calling {func.__name__} for bulb {self.device.name} at {self.device.host}: {ex}" + f"Error when calling {func.__name__} for bulb {self.device.name} at {self.device.host}: {exc_message}" ) from ex return _async_wrap @@ -762,11 +760,6 @@ class YeelightGenericLight(YeelightEntity, LightEntity): if self.config[CONF_SAVE_ON_CHANGE] and (brightness or colortemp or rgb): await self.async_set_default() - # Some devices (mainly nightlights) will not send back the on state so we need to force a refresh - await asyncio.sleep(STATE_CHANGE_TIME) - if not self.is_on: - await self.device.async_update(True) - @_async_cmd async def _async_turn_off(self, duration) -> None: """Turn off with a given transition duration wrapped with _async_cmd.""" @@ -782,10 +775,6 @@ class YeelightGenericLight(YeelightEntity, LightEntity): duration = int(kwargs.get(ATTR_TRANSITION) * 1000) # kwarg in s await self._async_turn_off(duration) - # Some devices will not send back the off state so we need to force a refresh - await asyncio.sleep(STATE_CHANGE_TIME) - if self.is_on: - await self.device.async_update(True) @_async_cmd async def async_set_mode(self, mode: str): @@ -850,10 +839,8 @@ class YeelightNightLightSupport: return PowerMode.NORMAL -class YeelightColorLightWithoutNightlightSwitch( - YeelightColorLightSupport, YeelightGenericLight -): - """Representation of a Color Yeelight light.""" +class YeelightWithoutNightlightSwitchMixIn: + """A mix-in for yeelights without a nightlight switch.""" @property def _brightness_property(self): @@ -861,9 +848,25 @@ class YeelightColorLightWithoutNightlightSwitch( # want to "current_brightness" since it will check # "bg_power" and main light could still be on if self.device.is_nightlight_enabled: - return "current_brightness" + return "nl_br" return super()._brightness_property + @property + def color_temp(self) -> int: + """Return the color temperature.""" + if self.device.is_nightlight_enabled: + # Enabling the nightlight locks the colortemp to max + return self._max_mireds + return super().color_temp + + +class YeelightColorLightWithoutNightlightSwitch( + YeelightColorLightSupport, + YeelightWithoutNightlightSwitchMixIn, + YeelightGenericLight, +): + """Representation of a Color Yeelight light.""" + class YeelightColorLightWithNightlightSwitch( YeelightNightLightSupport, YeelightColorLightSupport, YeelightGenericLight @@ -880,19 +883,12 @@ class YeelightColorLightWithNightlightSwitch( class YeelightWhiteTempWithoutNightlightSwitch( - YeelightWhiteTempLightSupport, YeelightGenericLight + YeelightWhiteTempLightSupport, + YeelightWithoutNightlightSwitchMixIn, + YeelightGenericLight, ): """White temp light, when nightlight switch is not set to light.""" - @property - def _brightness_property(self): - # If the nightlight is not active, we do not - # want to "current_brightness" since it will check - # "bg_power" and main light could still be on - if self.device.is_nightlight_enabled: - return "current_brightness" - return super()._brightness_property - class YeelightWithNightLight( YeelightNightLightSupport, YeelightWhiteTempLightSupport, YeelightGenericLight @@ -911,6 +907,9 @@ class YeelightWithNightLight( class YeelightNightLightMode(YeelightGenericLight): """Representation of a Yeelight when in nightlight mode.""" + _attr_color_mode = COLOR_MODE_BRIGHTNESS + _attr_supported_color_modes = {COLOR_MODE_BRIGHTNESS} + @property def unique_id(self) -> str: """Return a unique ID.""" @@ -941,8 +940,9 @@ class YeelightNightLightMode(YeelightGenericLight): return PowerMode.MOONLIGHT @property - def _predefined_effects(self): - return YEELIGHT_TEMP_ONLY_EFFECT_LIST + def supported_features(self): + """Flag no supported features.""" + return 0 class YeelightNightLightModeWithAmbientSupport(YeelightNightLightMode): @@ -962,11 +962,6 @@ class YeelightNightLightModeWithoutBrightnessControl(YeelightNightLightMode): _attr_color_mode = COLOR_MODE_ONOFF _attr_supported_color_modes = {COLOR_MODE_ONOFF} - @property - def supported_features(self): - """Flag no supported features.""" - return 0 - class YeelightWithAmbientWithoutNightlight(YeelightWhiteTempWithoutNightlightSwitch): """Representation of a Yeelight which has ambilight support. diff --git a/tests/components/yeelight/test_init.py b/tests/components/yeelight/test_init.py index cee798308c4..aed2025ab5d 100644 --- a/tests/components/yeelight/test_init.py +++ b/tests/components/yeelight/test_init.py @@ -13,6 +13,7 @@ from homeassistant.components.yeelight import ( DATA_DEVICE, DOMAIN, NIGHTLIGHT_SWITCH_TYPE_LIGHT, + STATE_CHANGE_TIME, ) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( @@ -458,6 +459,8 @@ async def test_connection_dropped_resyncs_properties(hass: HomeAssistant): await hass.async_block_till_done() assert len(mocked_bulb.async_get_properties.mock_calls) == 1 mocked_bulb._async_callback({KEY_CONNECTED: True}) - await hass.async_block_till_done() + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=STATE_CHANGE_TIME) + ) await hass.async_block_till_done() assert len(mocked_bulb.async_get_properties.mock_calls) == 2 diff --git a/tests/components/yeelight/test_light.py b/tests/components/yeelight/test_light.py index f4cae17a30c..fd6e12f2635 100644 --- a/tests/components/yeelight/test_light.py +++ b/tests/components/yeelight/test_light.py @@ -545,25 +545,27 @@ async def test_update_errors(hass: HomeAssistant, caplog): # Timeout usually means the bulb is overloaded with commands # but will still respond eventually. - mocked_bulb.async_get_properties = AsyncMock(side_effect=asyncio.TimeoutError) - await hass.services.async_call( - "light", - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: ENTITY_LIGHT}, - blocking=True, - ) + mocked_bulb.async_turn_off = AsyncMock(side_effect=asyncio.TimeoutError) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "light", + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_LIGHT}, + blocking=True, + ) assert hass.states.get(ENTITY_LIGHT).state == STATE_ON # socket.error usually means the bulb dropped the connection # or lost wifi, then came back online and forced the existing # connection closed with a TCP RST - mocked_bulb.async_get_properties = AsyncMock(side_effect=socket.error) - await hass.services.async_call( - "light", - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: ENTITY_LIGHT}, - blocking=True, - ) + mocked_bulb.async_turn_off = AsyncMock(side_effect=socket.error) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "light", + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_LIGHT}, + blocking=True, + ) assert hass.states.get(ENTITY_LIGHT).state == STATE_UNAVAILABLE @@ -572,6 +574,7 @@ async def test_state_already_set_avoid_ratelimit(hass: HomeAssistant): mocked_bulb = _mocked_bulb() properties = {**PROPERTIES} properties.pop("active_mode") + properties.pop("nl_br") properties["color_mode"] = "3" # HSV mocked_bulb.last_properties = properties mocked_bulb.bulb_type = BulbType.Color @@ -579,7 +582,9 @@ async def test_state_already_set_avoid_ratelimit(hass: HomeAssistant): domain=DOMAIN, data={**CONFIG_ENTRY_DATA, CONF_NIGHTLIGHT_SWITCH: False} ) config_entry.add_to_hass(hass) - with patch(f"{MODULE}.AsyncBulb", return_value=mocked_bulb): + with _patch_discovery(), _patch_discovery_interval(), patch( + f"{MODULE}.AsyncBulb", return_value=mocked_bulb + ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() # We use asyncio.create_task now to avoid @@ -623,7 +628,7 @@ async def test_state_already_set_avoid_ratelimit(hass: HomeAssistant): SERVICE_TURN_ON, { ATTR_ENTITY_ID: ENTITY_LIGHT, - ATTR_BRIGHTNESS_PCT: PROPERTIES["current_brightness"], + ATTR_BRIGHTNESS_PCT: PROPERTIES["bright"], }, blocking=True, ) @@ -696,9 +701,10 @@ async def test_device_types(hass: HomeAssistant, caplog): bulb_type, model, target_properties, - nightlight_properties=None, + nightlight_entity_properties=None, name=UNIQUE_FRIENDLY_NAME, entity_id=ENTITY_LIGHT, + nightlight_mode_properties=None, ): config_entry = MockConfigEntry( domain=DOMAIN, data={**CONFIG_ENTRY_DATA, CONF_NIGHTLIGHT_SWITCH: False} @@ -708,6 +714,9 @@ async def test_device_types(hass: HomeAssistant, caplog): mocked_bulb.bulb_type = bulb_type model_specs = _MODEL_SPECS.get(model) type(mocked_bulb).get_model_specs = MagicMock(return_value=model_specs) + original_nightlight_brightness = mocked_bulb.last_properties["nl_br"] + + mocked_bulb.last_properties["nl_br"] = "0" await _async_setup(config_entry) state = hass.states.get(entity_id) @@ -715,41 +724,58 @@ async def test_device_types(hass: HomeAssistant, caplog): assert state.state == "on" target_properties["friendly_name"] = name target_properties["flowing"] = False - target_properties["night_light"] = True + target_properties["night_light"] = False target_properties["music_mode"] = False assert dict(state.attributes) == target_properties - await hass.config_entries.async_unload(config_entry.entry_id) await config_entry.async_remove(hass) registry = er.async_get(hass) registry.async_clear_config_entry(config_entry.entry_id) + mocked_bulb.last_properties["nl_br"] = original_nightlight_brightness - # nightlight - if nightlight_properties is None: - return - config_entry = MockConfigEntry( - domain=DOMAIN, data={**CONFIG_ENTRY_DATA, CONF_NIGHTLIGHT_SWITCH: True} - ) - config_entry.add_to_hass(hass) - await _async_setup(config_entry) + # nightlight as a setting of the main entity + if nightlight_mode_properties is not None: + mocked_bulb.last_properties["active_mode"] = True + config_entry.add_to_hass(hass) + await _async_setup(config_entry) + state = hass.states.get(entity_id) + assert state.state == "on" + nightlight_mode_properties["friendly_name"] = name + nightlight_mode_properties["flowing"] = False + nightlight_mode_properties["night_light"] = True + nightlight_mode_properties["music_mode"] = False + assert dict(state.attributes) == nightlight_mode_properties - assert hass.states.get(entity_id).state == "off" - state = hass.states.get(f"{entity_id}_nightlight") - assert state.state == "on" - nightlight_properties["friendly_name"] = f"{name} Nightlight" - nightlight_properties["icon"] = "mdi:weather-night" - nightlight_properties["flowing"] = False - nightlight_properties["night_light"] = True - nightlight_properties["music_mode"] = False - assert dict(state.attributes) == nightlight_properties + await hass.config_entries.async_unload(config_entry.entry_id) + await config_entry.async_remove(hass) + registry.async_clear_config_entry(config_entry.entry_id) + await hass.async_block_till_done() + mocked_bulb.last_properties.pop("active_mode") - await hass.config_entries.async_unload(config_entry.entry_id) - await config_entry.async_remove(hass) - registry.async_clear_config_entry(config_entry.entry_id) - await hass.async_block_till_done() + # nightlight as a separate entity + if nightlight_entity_properties is not None: + config_entry = MockConfigEntry( + domain=DOMAIN, data={**CONFIG_ENTRY_DATA, CONF_NIGHTLIGHT_SWITCH: True} + ) + config_entry.add_to_hass(hass) + await _async_setup(config_entry) + + assert hass.states.get(entity_id).state == "off" + state = hass.states.get(f"{entity_id}_nightlight") + assert state.state == "on" + nightlight_entity_properties["friendly_name"] = f"{name} Nightlight" + nightlight_entity_properties["icon"] = "mdi:weather-night" + nightlight_entity_properties["flowing"] = False + nightlight_entity_properties["night_light"] = True + nightlight_entity_properties["music_mode"] = False + assert dict(state.attributes) == nightlight_entity_properties + + await hass.config_entries.async_unload(config_entry.entry_id) + await config_entry.async_remove(hass) + registry.async_clear_config_entry(config_entry.entry_id) + await hass.async_block_till_done() bright = round(255 * int(PROPERTIES["bright"]) / 100) - current_brightness = round(255 * int(PROPERTIES["current_brightness"]) / 100) ct = color_temperature_kelvin_to_mired(int(PROPERTIES["ct"])) hue = int(PROPERTIES["hue"]) sat = int(PROPERTIES["sat"]) @@ -806,7 +832,7 @@ async def test_device_types(hass: HomeAssistant, caplog): "max_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["min"] ), - "brightness": current_brightness, + "brightness": bright, "color_temp": ct, "color_mode": "color_temp", "supported_color_modes": ["color_temp", "hs", "rgb"], @@ -814,11 +840,30 @@ async def test_device_types(hass: HomeAssistant, caplog): "rgb_color": (255, 205, 166), "xy_color": (0.421, 0.364), }, - { + nightlight_entity_properties={ "supported_features": 0, "color_mode": "onoff", "supported_color_modes": ["onoff"], }, + nightlight_mode_properties={ + "effect_list": YEELIGHT_COLOR_EFFECT_LIST, + "supported_features": SUPPORT_YEELIGHT, + "hs_color": (28.401, 100.0), + "rgb_color": (255, 120, 0), + "xy_color": (0.621, 0.367), + "min_mireds": color_temperature_kelvin_to_mired( + model_specs["color_temp"]["max"] + ), + "max_mireds": color_temperature_kelvin_to_mired( + model_specs["color_temp"]["min"] + ), + "brightness": nl_br, + "color_mode": "color_temp", + "supported_color_modes": ["color_temp", "hs", "rgb"], + "color_temp": color_temperature_kelvin_to_mired( + model_specs["color_temp"]["min"] + ), + }, ) # Color - color mode HS @@ -836,14 +881,14 @@ async def test_device_types(hass: HomeAssistant, caplog): "max_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["min"] ), - "brightness": current_brightness, + "brightness": bright, "hs_color": hs_color, "rgb_color": color_hs_to_RGB(*hs_color), "xy_color": color_hs_to_xy(*hs_color), "color_mode": "hs", "supported_color_modes": ["color_temp", "hs", "rgb"], }, - { + nightlight_entity_properties={ "supported_features": 0, "color_mode": "onoff", "supported_color_modes": ["onoff"], @@ -865,14 +910,14 @@ async def test_device_types(hass: HomeAssistant, caplog): "max_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["min"] ), - "brightness": current_brightness, + "brightness": bright, "hs_color": color_RGB_to_hs(*rgb_color), "rgb_color": rgb_color, "xy_color": color_RGB_to_xy(*rgb_color), "color_mode": "rgb", "supported_color_modes": ["color_temp", "hs", "rgb"], }, - { + nightlight_entity_properties={ "supported_features": 0, "color_mode": "onoff", "supported_color_modes": ["onoff"], @@ -895,11 +940,11 @@ async def test_device_types(hass: HomeAssistant, caplog): "max_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["min"] ), - "brightness": current_brightness, + "brightness": bright, "color_mode": "hs", "supported_color_modes": ["color_temp", "hs", "rgb"], }, - { + nightlight_entity_properties={ "supported_features": 0, "color_mode": "onoff", "supported_color_modes": ["onoff"], @@ -922,11 +967,11 @@ async def test_device_types(hass: HomeAssistant, caplog): "max_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["min"] ), - "brightness": current_brightness, + "brightness": bright, "color_mode": "rgb", "supported_color_modes": ["color_temp", "hs", "rgb"], }, - { + nightlight_entity_properties={ "supported_features": 0, "color_mode": "onoff", "supported_color_modes": ["onoff"], @@ -973,7 +1018,7 @@ async def test_device_types(hass: HomeAssistant, caplog): "max_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["min"] ), - "brightness": current_brightness, + "brightness": bright, "color_temp": ct, "color_mode": "color_temp", "supported_color_modes": ["color_temp"], @@ -981,13 +1026,31 @@ async def test_device_types(hass: HomeAssistant, caplog): "rgb_color": (255, 205, 166), "xy_color": (0.421, 0.364), }, - { - "effect_list": YEELIGHT_TEMP_ONLY_EFFECT_LIST, - "supported_features": SUPPORT_YEELIGHT, + nightlight_entity_properties={ + "supported_features": 0, "brightness": nl_br, "color_mode": "brightness", "supported_color_modes": ["brightness"], }, + nightlight_mode_properties={ + "effect_list": YEELIGHT_TEMP_ONLY_EFFECT_LIST, + "supported_features": SUPPORT_YEELIGHT, + "min_mireds": color_temperature_kelvin_to_mired( + model_specs["color_temp"]["max"] + ), + "max_mireds": color_temperature_kelvin_to_mired( + model_specs["color_temp"]["min"] + ), + "brightness": nl_br, + "color_temp": color_temperature_kelvin_to_mired( + model_specs["color_temp"]["min"] + ), + "color_mode": "color_temp", + "supported_color_modes": ["color_temp"], + "hs_color": (28.391, 65.659), + "rgb_color": (255, 166, 87), + "xy_color": (0.526, 0.387), + }, ) # WhiteTempMood @@ -1009,7 +1072,7 @@ async def test_device_types(hass: HomeAssistant, caplog): "max_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["min"] ), - "brightness": current_brightness, + "brightness": bright, "color_temp": ct, "color_mode": "color_temp", "supported_color_modes": ["color_temp"], @@ -1017,13 +1080,34 @@ async def test_device_types(hass: HomeAssistant, caplog): "rgb_color": (255, 205, 166), "xy_color": (0.421, 0.364), }, - { - "effect_list": YEELIGHT_TEMP_ONLY_EFFECT_LIST, - "supported_features": SUPPORT_YEELIGHT, + nightlight_entity_properties={ + "supported_features": 0, "brightness": nl_br, "color_mode": "brightness", "supported_color_modes": ["brightness"], }, + nightlight_mode_properties={ + "friendly_name": NAME, + "effect_list": YEELIGHT_TEMP_ONLY_EFFECT_LIST, + "flowing": False, + "night_light": True, + "supported_features": SUPPORT_YEELIGHT, + "min_mireds": color_temperature_kelvin_to_mired( + model_specs["color_temp"]["max"] + ), + "max_mireds": color_temperature_kelvin_to_mired( + model_specs["color_temp"]["min"] + ), + "brightness": nl_br, + "color_temp": color_temperature_kelvin_to_mired( + model_specs["color_temp"]["min"] + ), + "color_mode": "color_temp", + "supported_color_modes": ["color_temp"], + "hs_color": (28.391, 65.659), + "rgb_color": (255, 166, 87), + "xy_color": (0.526, 0.387), + }, ) # Background light - color mode CT mocked_bulb.last_properties["bg_lmode"] = "2" # CT @@ -1261,62 +1345,6 @@ async def test_effects(hass: HomeAssistant): await _async_test_effect("not_existed", called=False) -async def test_state_fails_to_update_triggers_update(hass: HomeAssistant): - """Ensure we call async_get_properties if the turn on/off fails to update the state.""" - mocked_bulb = _mocked_bulb() - properties = {**PROPERTIES} - properties.pop("active_mode") - properties["color_mode"] = "3" # HSV - mocked_bulb.last_properties = properties - mocked_bulb.bulb_type = BulbType.Color - config_entry = MockConfigEntry( - domain=DOMAIN, data={**CONFIG_ENTRY_DATA, CONF_NIGHTLIGHT_SWITCH: False} - ) - config_entry.add_to_hass(hass) - with patch(f"{MODULE}.AsyncBulb", return_value=mocked_bulb): - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - # We use asyncio.create_task now to avoid - # blocking starting so we need to block again - await hass.async_block_till_done() - - mocked_bulb.last_properties["power"] = "off" - await hass.services.async_call( - "light", - SERVICE_TURN_ON, - { - ATTR_ENTITY_ID: ENTITY_LIGHT, - }, - blocking=True, - ) - assert len(mocked_bulb.async_turn_on.mock_calls) == 1 - assert len(mocked_bulb.async_get_properties.mock_calls) == 2 - - mocked_bulb.last_properties["power"] = "on" - await hass.services.async_call( - "light", - SERVICE_TURN_OFF, - { - ATTR_ENTITY_ID: ENTITY_LIGHT, - }, - blocking=True, - ) - assert len(mocked_bulb.async_turn_off.mock_calls) == 1 - assert len(mocked_bulb.async_get_properties.mock_calls) == 3 - - # But if the state is correct no calls - await hass.services.async_call( - "light", - SERVICE_TURN_ON, - { - ATTR_ENTITY_ID: ENTITY_LIGHT, - }, - blocking=True, - ) - assert len(mocked_bulb.async_turn_on.mock_calls) == 1 - assert len(mocked_bulb.async_get_properties.mock_calls) == 3 - - async def test_ambilight_with_nightlight_disabled(hass: HomeAssistant): """Test that main light on ambilights with the nightlight disabled shows the correct brightness.""" mocked_bulb = _mocked_bulb() @@ -1325,7 +1353,6 @@ async def test_ambilight_with_nightlight_disabled(hass: HomeAssistant): capabilities["model"] = "ceiling10" properties["color_mode"] = "3" # HSV properties["bg_power"] = "off" - properties["current_brightness"] = 0 properties["bg_lmode"] = "2" # CT mocked_bulb.last_properties = properties mocked_bulb.bulb_type = BulbType.WhiteTempMood From a527c451c38747e5d561b5ade69af3954603019a Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Sat, 2 Oct 2021 18:58:10 -0600 Subject: [PATCH 783/843] Fix incorrect handling of hass.data in WattTime setup (#56971) --- homeassistant/components/watttime/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/watttime/__init__.py b/homeassistant/components/watttime/__init__.py index 6d23182c011..8b3a83aa8d1 100644 --- a/homeassistant/components/watttime/__init__.py +++ b/homeassistant/components/watttime/__init__.py @@ -27,7 +27,8 @@ PLATFORMS: list[str] = ["sensor"] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up WattTime from a config entry.""" - hass.data.setdefault(DOMAIN, {entry.entry_id: {DATA_COORDINATOR: {}}}) + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = {} session = aiohttp_client.async_get_clientsession(hass) From 7203f58b69b75043e0dcb1211ee935d181e3eb85 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Sun, 3 Oct 2021 13:07:17 +0300 Subject: [PATCH 784/843] Bump aioshelly to 1.0.2 (#56980) --- homeassistant/components/shelly/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index ca092295473..09a046ee78d 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -3,7 +3,7 @@ "name": "Shelly", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/shelly", - "requirements": ["aioshelly==1.0.1"], + "requirements": ["aioshelly==1.0.2"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 506f88df9db..7172a8f7e1d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -243,7 +243,7 @@ aiopylgtv==0.4.0 aiorecollect==1.0.8 # homeassistant.components.shelly -aioshelly==1.0.1 +aioshelly==1.0.2 # homeassistant.components.switcher_kis aioswitcher==2.0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 15df8a9f94e..e197992d626 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -167,7 +167,7 @@ aiopylgtv==0.4.0 aiorecollect==1.0.8 # homeassistant.components.shelly -aioshelly==1.0.1 +aioshelly==1.0.2 # homeassistant.components.switcher_kis aioswitcher==2.0.6 From 757c5b92010511b26b2919dd5d9d49b45ce7764b Mon Sep 17 00:00:00 2001 From: Steven Looman Date: Sun, 3 Oct 2021 19:29:01 +0200 Subject: [PATCH 785/843] Fix upnp invalid key in ssdp discovery_info (#56986) --- homeassistant/components/upnp/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/upnp/config_flow.py b/homeassistant/components/upnp/config_flow.py index 9352ae0a5ff..d1c2c4b3c0f 100644 --- a/homeassistant/components/upnp/config_flow.py +++ b/homeassistant/components/upnp/config_flow.py @@ -273,7 +273,7 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): title = _friendly_name_from_discovery(discovery) data = { - CONFIG_ENTRY_UDN: discovery["_udn"], + CONFIG_ENTRY_UDN: discovery[ssdp.ATTR_UPNP_UDN], CONFIG_ENTRY_ST: discovery[ssdp.ATTR_SSDP_ST], CONFIG_ENTRY_HOSTNAME: discovery["_host"], } From 6f396325837e282a91cd5682de061a903edc3e25 Mon Sep 17 00:00:00 2001 From: Steven Looman Date: Sun, 3 Oct 2021 19:28:41 +0200 Subject: [PATCH 786/843] Bump async-upnp-client to 0.22.5 (#56989) --- homeassistant/components/dlna_dmr/manifest.json | 2 +- homeassistant/components/ssdp/manifest.json | 2 +- homeassistant/components/upnp/manifest.json | 2 +- homeassistant/components/yeelight/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index 8ea4ab48e27..53bee3d8519 100644 --- a/homeassistant/components/dlna_dmr/manifest.json +++ b/homeassistant/components/dlna_dmr/manifest.json @@ -3,7 +3,7 @@ "name": "DLNA Digital Media Renderer", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/dlna_dmr", - "requirements": ["async-upnp-client==0.22.4"], + "requirements": ["async-upnp-client==0.22.5"], "dependencies": ["network", "ssdp"], "codeowners": ["@StevenLooman", "@chishm"], "iot_class": "local_push" diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index 3a6531fcacb..3e99a77e8bb 100644 --- a/homeassistant/components/ssdp/manifest.json +++ b/homeassistant/components/ssdp/manifest.json @@ -2,7 +2,7 @@ "domain": "ssdp", "name": "Simple Service Discovery Protocol (SSDP)", "documentation": "https://www.home-assistant.io/integrations/ssdp", - "requirements": ["async-upnp-client==0.22.4"], + "requirements": ["async-upnp-client==0.22.5"], "dependencies": ["network"], "after_dependencies": ["zeroconf"], "codeowners": [], diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index 6ab3896cfdb..9a1875777a6 100644 --- a/homeassistant/components/upnp/manifest.json +++ b/homeassistant/components/upnp/manifest.json @@ -3,7 +3,7 @@ "name": "UPnP/IGD", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/upnp", - "requirements": ["async-upnp-client==0.22.4"], + "requirements": ["async-upnp-client==0.22.5"], "dependencies": ["network", "ssdp"], "codeowners": ["@StevenLooman","@ehendrix23"], "ssdp": [ diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index ca6fe09fe53..cc40f07ce46 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -2,7 +2,7 @@ "domain": "yeelight", "name": "Yeelight", "documentation": "https://www.home-assistant.io/integrations/yeelight", - "requirements": ["yeelight==0.7.5", "async-upnp-client==0.22.4"], + "requirements": ["yeelight==0.7.5", "async-upnp-client==0.22.5"], "codeowners": ["@rytilahti", "@zewelor", "@shenxn", "@starkillerOG"], "config_flow": true, "dependencies": ["network"], diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e7f0cb6a5c3..d69ae6d5e43 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -4,7 +4,7 @@ aiodiscover==1.4.4 aiohttp==3.7.4.post0 aiohttp_cors==0.7.0 astral==2.2 -async-upnp-client==0.22.4 +async-upnp-client==0.22.5 async_timeout==3.0.1 attrs==21.2.0 awesomeversion==21.8.1 diff --git a/requirements_all.txt b/requirements_all.txt index 7172a8f7e1d..f3b49ef91b1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -330,7 +330,7 @@ asterisk_mbox==0.5.0 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.22.4 +async-upnp-client==0.22.5 # homeassistant.components.supla asyncpysupla==0.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e197992d626..6c899f21f3e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -224,7 +224,7 @@ arcam-fmj==0.7.0 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.22.4 +async-upnp-client==0.22.5 # homeassistant.components.aurora auroranoaa==0.0.2 From 45b7922a6a97af8a8bdc08ea9b56338818cc83d7 Mon Sep 17 00:00:00 2001 From: Phil Cole Date: Mon, 4 Oct 2021 05:14:45 +0100 Subject: [PATCH 787/843] Use pycarwings2.12 for Nissan Leaf integration (#56996) --- homeassistant/components/nissan_leaf/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/nissan_leaf/manifest.json b/homeassistant/components/nissan_leaf/manifest.json index 55cd28d59fa..89e55cb69d9 100644 --- a/homeassistant/components/nissan_leaf/manifest.json +++ b/homeassistant/components/nissan_leaf/manifest.json @@ -2,7 +2,7 @@ "domain": "nissan_leaf", "name": "Nissan Leaf", "documentation": "https://www.home-assistant.io/integrations/nissan_leaf", - "requirements": ["pycarwings2==2.11"], + "requirements": ["pycarwings2==2.12"], "codeowners": ["@filcole"], "iot_class": "cloud_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index f3b49ef91b1..f2cf40752f1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1381,7 +1381,7 @@ pyblackbird==0.5 pybotvac==0.0.22 # homeassistant.components.nissan_leaf -pycarwings2==2.11 +pycarwings2==2.12 # homeassistant.components.cloudflare pycfdns==1.2.1 From 5237817109283b328c6a3277e5eaa3f77497b6d4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Oct 2021 09:28:39 -1000 Subject: [PATCH 788/843] Round tplink energy sensors to prevent insignificant updates (#56999) - These sensors wobble quite a bit and the precision did not have sensible limits which generated a massive amount of data in the database which was not very useful --- homeassistant/components/tplink/sensor.py | 10 ++++++++-- tests/components/tplink/test_sensor.py | 20 ++++++++++---------- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/tplink/sensor.py b/homeassistant/components/tplink/sensor.py index 0afcf96dba5..9bd4a056d33 100644 --- a/homeassistant/components/tplink/sensor.py +++ b/homeassistant/components/tplink/sensor.py @@ -44,6 +44,7 @@ class TPLinkSensorEntityDescription(SensorEntityDescription): """Describes TPLink sensor entity.""" emeter_attr: str | None = None + precision: int | None = None ENERGY_SENSORS: tuple[TPLinkSensorEntityDescription, ...] = ( @@ -54,6 +55,7 @@ ENERGY_SENSORS: tuple[TPLinkSensorEntityDescription, ...] = ( state_class=STATE_CLASS_MEASUREMENT, name="Current Consumption", emeter_attr="power", + precision=1, ), TPLinkSensorEntityDescription( key=ATTR_TOTAL_ENERGY_KWH, @@ -62,6 +64,7 @@ ENERGY_SENSORS: tuple[TPLinkSensorEntityDescription, ...] = ( state_class=STATE_CLASS_TOTAL_INCREASING, name="Total Consumption", emeter_attr="total", + precision=3, ), TPLinkSensorEntityDescription( key=ATTR_TODAY_ENERGY_KWH, @@ -69,6 +72,7 @@ ENERGY_SENSORS: tuple[TPLinkSensorEntityDescription, ...] = ( device_class=DEVICE_CLASS_ENERGY, state_class=STATE_CLASS_TOTAL_INCREASING, name="Today's Consumption", + precision=3, ), TPLinkSensorEntityDescription( key=ATTR_VOLTAGE, @@ -77,6 +81,7 @@ ENERGY_SENSORS: tuple[TPLinkSensorEntityDescription, ...] = ( state_class=STATE_CLASS_MEASUREMENT, name="Voltage", emeter_attr="voltage", + precision=1, ), TPLinkSensorEntityDescription( key=ATTR_CURRENT_A, @@ -85,6 +90,7 @@ ENERGY_SENSORS: tuple[TPLinkSensorEntityDescription, ...] = ( state_class=STATE_CLASS_MEASUREMENT, name="Current", emeter_attr="current", + precision=2, ), ) @@ -97,11 +103,11 @@ def async_emeter_from_device( val = getattr(device.emeter_realtime, attr) if val is None: return None - return cast(float, val) + return round(cast(float, val), description.precision) # ATTR_TODAY_ENERGY_KWH if (emeter_today := device.emeter_today) is not None: - return cast(float, emeter_today) + return round(cast(float, emeter_today), description.precision) # today's consumption not available, when device was off all the day # bulb's do not report this information, so filter it out return None if device.is_bulb else 0.0 diff --git a/tests/components/tplink/test_sensor.py b/tests/components/tplink/test_sensor.py index 839588d2756..5413e036d96 100644 --- a/tests/components/tplink/test_sensor.py +++ b/tests/components/tplink/test_sensor.py @@ -34,14 +34,14 @@ async def test_color_light_with_an_emeter(hass: HomeAssistant) -> None: voltage=None, current=5, ) - bulb.emeter_today = 5000 + bulb.emeter_today = 5000.0036 with _patch_discovery(device=bulb), _patch_single_discovery(device=bulb): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() await hass.async_block_till_done() expected = { - "sensor.my_bulb_today_s_consumption": 5000, + "sensor.my_bulb_today_s_consumption": 5000.004, "sensor.my_bulb_current": 5, } entity_id = "light.my_bulb" @@ -69,10 +69,10 @@ async def test_plug_with_an_emeter(hass: HomeAssistant) -> None: plug.color_temp = None plug.has_emeter = True plug.emeter_realtime = Mock( - power=100, - total=30, - voltage=121, - current=5, + power=100.06, + total=30.0049, + voltage=121.19, + current=5.035, ) plug.emeter_today = None with _patch_discovery(device=plug), _patch_single_discovery(device=plug): @@ -81,11 +81,11 @@ async def test_plug_with_an_emeter(hass: HomeAssistant) -> None: await hass.async_block_till_done() expected = { - "sensor.my_plug_current_consumption": 100, - "sensor.my_plug_total_consumption": 30, + "sensor.my_plug_current_consumption": 100.1, + "sensor.my_plug_total_consumption": 30.005, "sensor.my_plug_today_s_consumption": 0.0, - "sensor.my_plug_voltage": 121, - "sensor.my_plug_current": 5, + "sensor.my_plug_voltage": 121.2, + "sensor.my_plug_current": 5.04, } entity_id = "switch.my_plug" state = hass.states.get(entity_id) From 645cb53284cdedc463e878d6a65c2c7c99fc3802 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Oct 2021 13:07:25 -1000 Subject: [PATCH 789/843] Bump yeelight to 0.7.6 (#57009) - Fixes compat with Lamp15 model - May improvment Monob model drops seen in #56646 Changes: https://gitlab.com/stavros/python-yeelight/-/commit/0b94e5214e3375f20defa386067ef6cb058c872c --- homeassistant/components/yeelight/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index cc40f07ce46..561606f5509 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -2,7 +2,7 @@ "domain": "yeelight", "name": "Yeelight", "documentation": "https://www.home-assistant.io/integrations/yeelight", - "requirements": ["yeelight==0.7.5", "async-upnp-client==0.22.5"], + "requirements": ["yeelight==0.7.6", "async-upnp-client==0.22.5"], "codeowners": ["@rytilahti", "@zewelor", "@shenxn", "@starkillerOG"], "config_flow": true, "dependencies": ["network"], diff --git a/requirements_all.txt b/requirements_all.txt index f2cf40752f1..3b7b04dfc9b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2459,7 +2459,7 @@ yalesmartalarmclient==0.3.4 yalexs==1.1.13 # homeassistant.components.yeelight -yeelight==0.7.5 +yeelight==0.7.6 # homeassistant.components.yeelightsunflower yeelightsunflower==0.0.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6c899f21f3e..608b38434a6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1403,7 +1403,7 @@ yalesmartalarmclient==0.3.4 yalexs==1.1.13 # homeassistant.components.yeelight -yeelight==0.7.5 +yeelight==0.7.6 # homeassistant.components.youless youless-api==0.13 From c22ec32726e50fab0f8dcf43c1b4263c87011086 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Mon, 4 Oct 2021 05:59:36 +0100 Subject: [PATCH 790/843] Ignore utility_meter restore state if state is invalid (#57010) Co-authored-by: Paulus Schoutsen --- .../components/utility_meter/sensor.py | 37 ++++++++++++------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index 96bf12fdd4d..50185461030 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -1,5 +1,6 @@ """Utility meter from sensors providing raw data.""" from datetime import date, datetime, timedelta +import decimal from decimal import Decimal, DecimalException import logging @@ -323,19 +324,29 @@ class UtilityMeterSensor(RestoreEntity, SensorEntity): state = await self.async_get_last_state() if state: - self._state = Decimal(state.state) - self._unit_of_measurement = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - self._last_period = ( - float(state.attributes.get(ATTR_LAST_PERIOD)) - if state.attributes.get(ATTR_LAST_PERIOD) - else 0 - ) - self._last_reset = dt_util.as_utc( - dt_util.parse_datetime(state.attributes.get(ATTR_LAST_RESET)) - ) - if state.attributes.get(ATTR_STATUS) == COLLECTING: - # Fake cancellation function to init the meter in similar state - self._collecting = lambda: None + try: + self._state = Decimal(state.state) + except decimal.InvalidOperation: + _LOGGER.error( + "Could not restore state <%s>. Resetting utility_meter.%s", + state.state, + self.name, + ) + else: + self._unit_of_measurement = state.attributes.get( + ATTR_UNIT_OF_MEASUREMENT + ) + self._last_period = ( + float(state.attributes.get(ATTR_LAST_PERIOD)) + if state.attributes.get(ATTR_LAST_PERIOD) + else 0 + ) + self._last_reset = dt_util.as_utc( + dt_util.parse_datetime(state.attributes.get(ATTR_LAST_RESET)) + ) + if state.attributes.get(ATTR_STATUS) == COLLECTING: + # Fake cancellation function to init the meter in similar state + self._collecting = lambda: None @callback def async_source_tracking(event): From ea2113d5d2f5a5dca8482ce6d264fbb01b3a7eff Mon Sep 17 00:00:00 2001 From: Tobias Sauerwein Date: Mon, 4 Oct 2021 00:09:58 +0200 Subject: [PATCH 791/843] Bump pyatmo to v6.1.0 (#57014) --- homeassistant/components/netatmo/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/netatmo/manifest.json b/homeassistant/components/netatmo/manifest.json index f51f1a22f48..f162abbaad5 100644 --- a/homeassistant/components/netatmo/manifest.json +++ b/homeassistant/components/netatmo/manifest.json @@ -3,7 +3,7 @@ "name": "Netatmo", "documentation": "https://www.home-assistant.io/integrations/netatmo", "requirements": [ - "pyatmo==6.0.0" + "pyatmo==6.1.0" ], "after_dependencies": [ "cloud", diff --git a/requirements_all.txt b/requirements_all.txt index 3b7b04dfc9b..e5c58f55dd2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1360,7 +1360,7 @@ pyarlo==0.2.4 pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==6.0.0 +pyatmo==6.1.0 # homeassistant.components.atome pyatome==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 608b38434a6..3601b26a5f3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -799,7 +799,7 @@ pyarlo==0.2.4 pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==6.0.0 +pyatmo==6.1.0 # homeassistant.components.apple_tv pyatv==0.8.2 From 0084db3ad20f0660821309115c936469f76b8959 Mon Sep 17 00:00:00 2001 From: Oncleben31 Date: Mon, 4 Oct 2021 06:15:41 +0200 Subject: [PATCH 792/843] Meteofrance fix #56975 (#57016) --- .../components/meteo_france/sensor.py | 29 +++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/meteo_france/sensor.py b/homeassistant/components/meteo_france/sensor.py index 9f24cf02a2c..1a5b3c4a33a 100644 --- a/homeassistant/components/meteo_france/sensor.py +++ b/homeassistant/components/meteo_france/sensor.py @@ -44,18 +44,23 @@ async def async_setup_entry( MeteoFranceSensor(coordinator_forecast, description) for description in SENSOR_TYPES ] - entities.extend( - [ - MeteoFranceRainSensor(coordinator_rain, description) - for description in SENSOR_TYPES_RAIN - ] - ) - entities.extend( - [ - MeteoFranceAlertSensor(coordinator_alert, description) - for description in SENSOR_TYPES_ALERT - ] - ) + # Add rain forecast entity only if location support this feature + if coordinator_rain: + entities.extend( + [ + MeteoFranceRainSensor(coordinator_rain, description) + for description in SENSOR_TYPES_RAIN + ] + ) + # Add weather alert entity only if location support this feature + if coordinator_alert: + entities.extend( + [ + MeteoFranceAlertSensor(coordinator_alert, description) + for description in SENSOR_TYPES_ALERT + ] + ) + # Add weather probability entities only if location support this feature if coordinator_forecast.data.probability_forecast: entities.extend( [ From 48fecc916ac075420973d164390d6732174c3138 Mon Sep 17 00:00:00 2001 From: Tobias Sauerwein Date: Mon, 4 Oct 2021 01:55:07 +0200 Subject: [PATCH 793/843] Fix camera tests (#57020) --- tests/components/netatmo/common.py | 11 +++++++++++ tests/components/netatmo/conftest.py | 3 ++- tests/components/netatmo/test_camera.py | 1 + 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/tests/components/netatmo/common.py b/tests/components/netatmo/common.py index 5ba989e2504..f2c03ac7de1 100644 --- a/tests/components/netatmo/common.py +++ b/tests/components/netatmo/common.py @@ -71,6 +71,17 @@ async def fake_post_request(*args, **kwargs): ) +async def fake_get_image(*args, **kwargs): + """Return fake data.""" + if "url" not in kwargs: + return "{}" + + endpoint = kwargs["url"].split("/")[-1] + + if endpoint in "snapshot_720.jpg": + return b"test stream image bytes" + + async def fake_post_request_no_data(*args, **kwargs): """Fake error during requesting backend data.""" return "{}" diff --git a/tests/components/netatmo/conftest.py b/tests/components/netatmo/conftest.py index d443802a41d..4d6bbb752f3 100644 --- a/tests/components/netatmo/conftest.py +++ b/tests/components/netatmo/conftest.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, patch import pytest -from .common import ALL_SCOPES, fake_post_request +from .common import ALL_SCOPES, fake_get_image, fake_post_request from tests.common import MockConfigEntry @@ -60,6 +60,7 @@ def netatmo_auth(): "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" ) as mock_auth: mock_auth.return_value.async_post_request.side_effect = fake_post_request + mock_auth.return_value.async_get_image.side_effect = fake_get_image mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock() yield diff --git a/tests/components/netatmo/test_camera.py b/tests/components/netatmo/test_camera.py index c8132331bf3..45c8dc48b22 100644 --- a/tests/components/netatmo/test_camera.py +++ b/tests/components/netatmo/test_camera.py @@ -478,6 +478,7 @@ async def test_camera_image_raises_exception(hass, config_entry, requests_mock): "homeassistant.components.webhook.async_generate_url" ): mock_auth.return_value.async_post_request.side_effect = fake_post + mock_auth.return_value.async_get_image.side_effect = fake_post mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock() From 779ae6c8015234347ac3fd5b6c1e6b0c6c68209b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Oct 2021 14:40:27 -1000 Subject: [PATCH 794/843] Add DHCP support for TPLink KP400 (#57023) --- homeassistant/components/tplink/manifest.json | 4 ++++ homeassistant/generated/dhcp.py | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json index a24d95bbc75..0c45ca84ac6 100644 --- a/homeassistant/components/tplink/manifest.json +++ b/homeassistant/components/tplink/manifest.json @@ -9,6 +9,10 @@ "quality_scale": "platinum", "iot_class": "local_polling", "dhcp": [ + { + "hostname": "k[lp]*", + "macaddress": "403F8C*" + }, { "hostname": "ep*", "macaddress": "E848B8*" diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 73343bdf157..6dcb3251e4e 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -264,6 +264,11 @@ DHCP = [ "hostname": "eneco-*", "macaddress": "74C63B*" }, + { + "domain": "tplink", + "hostname": "k[lp]*", + "macaddress": "403F8C*" + }, { "domain": "tplink", "hostname": "ep*", From 2d8684283f167f2f831f9d2bc2d49ff37dcd2d7e Mon Sep 17 00:00:00 2001 From: jjlawren Date: Sun, 3 Oct 2021 23:13:08 -0500 Subject: [PATCH 795/843] Shorten album titles when browsing artist (#57027) --- homeassistant/components/plex/helpers.py | 5 ++- tests/components/plex/test_browse_media.py | 45 +++++++++++++++++++--- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/plex/helpers.py b/homeassistant/components/plex/helpers.py index be873614ba6..c534eca1f27 100644 --- a/homeassistant/components/plex/helpers.py +++ b/homeassistant/components/plex/helpers.py @@ -5,7 +5,10 @@ def pretty_title(media, short_name=False): """Return a formatted title for the given media item.""" year = None if media.type == "album": - title = f"{media.parentTitle} - {media.title}" + if short_name: + title = media.title + else: + title = f"{media.parentTitle} - {media.title}" elif media.type == "episode": title = f"{media.seasonEpisode.upper()} - {media.title}" if not short_name: diff --git a/tests/components/plex/test_browse_media.py b/tests/components/plex/test_browse_media.py index be4869839d2..d4ea73f6a97 100644 --- a/tests/components/plex/test_browse_media.py +++ b/tests/components/plex/test_browse_media.py @@ -46,6 +46,18 @@ class MockPlexEpisode: type = "episode" +class MockPlexArtist: + """Mock a plexapi Artist instance.""" + + ratingKey = 300 + title = "Artist" + type = "artist" + + def __iter__(self): + """Iterate over albums.""" + yield MockPlexAlbum() + + class MockPlexAlbum: """Mock a plexapi Album instance.""" @@ -53,7 +65,7 @@ class MockPlexAlbum: parentTitle = "Artist" title = "Album" type = "album" - year = 2001 + year = 2019 def __iter__(self): """Iterate over tracks.""" @@ -290,11 +302,13 @@ async def test_browse_media( assert result[ATTR_MEDIA_CONTENT_TYPE] == "library" assert result["title"] == "Music" - # Browse into a Plex album + # Browse into a Plex artist msg_id += 1 - mock_album = MockPlexAlbum() + mock_artist = MockPlexArtist() + mock_album = next(iter(MockPlexArtist())) + mock_track = next(iter(MockPlexAlbum())) with patch.object( - mock_plex_server, "fetch_item", return_value=mock_album + mock_plex_server, "fetch_item", return_value=mock_artist ) as mock_fetch: await websocket_client.send_json( { @@ -312,14 +326,35 @@ async def test_browse_media( msg = await websocket_client.receive_json() assert mock_fetch.called + assert msg["success"] + result = msg["result"] + result_id = int(result[ATTR_MEDIA_CONTENT_ID]) + assert result[ATTR_MEDIA_CONTENT_TYPE] == "artist" + assert result["title"] == mock_artist.title + assert result["children"][0]["title"] == f"{mock_album.title} ({mock_album.year})" + + # Browse into a Plex album + msg_id += 1 + await websocket_client.send_json( + { + "id": msg_id, + "type": "media_player/browse_media", + "entity_id": media_players[0], + ATTR_MEDIA_CONTENT_TYPE: result["children"][-1][ATTR_MEDIA_CONTENT_TYPE], + ATTR_MEDIA_CONTENT_ID: str(result["children"][-1][ATTR_MEDIA_CONTENT_ID]), + } + ) + msg = await websocket_client.receive_json() + assert msg["success"] result = msg["result"] result_id = int(result[ATTR_MEDIA_CONTENT_ID]) assert result[ATTR_MEDIA_CONTENT_TYPE] == "album" assert ( result["title"] - == f"{mock_album.parentTitle} - {mock_album.title} ({mock_album.year})" + == f"{mock_artist.title} - {mock_album.title} ({mock_album.year})" ) + assert result["children"][0]["title"] == f"{mock_track.index}. {mock_track.title}" # Browse into a non-existent TV season unknown_key = 99999999999999 From 6aad7510565c23f9342001f8b844604c1a0bfdac Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 3 Oct 2021 22:01:21 -0700 Subject: [PATCH 796/843] Bumped version to 2021.10.0b4 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 058eeb13668..d803b673e41 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -5,7 +5,7 @@ from typing import Final MAJOR_VERSION: Final = 2021 MINOR_VERSION: Final = 10 -PATCH_VERSION: Final = "0b3" +PATCH_VERSION: Final = "0b4" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0) From 8ee8aade8689517d98d92529d44563061b11fad7 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 4 Oct 2021 17:21:40 +0200 Subject: [PATCH 797/843] Evict purged states from recorder's old_state cache (#56877) Co-authored-by: J. Nick Koston --- homeassistant/components/recorder/purge.py | 51 ++++++++++---- tests/components/recorder/test_purge.py | 80 ++++++++++++---------- 2 files changed, 81 insertions(+), 50 deletions(-) diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index bc91f7ce67e..2b84a439871 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -38,7 +38,8 @@ def purge_old_data( event_ids = _select_event_ids_to_purge(session, purge_before) state_ids = _select_state_ids_to_purge(session, purge_before, event_ids) if state_ids: - _purge_state_ids(session, state_ids) + _purge_state_ids(instance, session, state_ids) + if event_ids: _purge_event_ids(session, event_ids) # If states or events purging isn't processing the purge_before yet, @@ -68,10 +69,10 @@ def _select_event_ids_to_purge(session: Session, purge_before: datetime) -> list def _select_state_ids_to_purge( session: Session, purge_before: datetime, event_ids: list[int] -) -> list[int]: +) -> set[int]: """Return a list of state ids to purge.""" if not event_ids: - return [] + return set() states = ( session.query(States.state_id) .filter(States.last_updated < purge_before) @@ -79,10 +80,10 @@ def _select_state_ids_to_purge( .all() ) _LOGGER.debug("Selected %s state ids to remove", len(states)) - return [state.state_id for state in states] + return {state.state_id for state in states} -def _purge_state_ids(session: Session, state_ids: list[int]) -> None: +def _purge_state_ids(instance: Recorder, session: Session, state_ids: set[int]) -> None: """Disconnect states and delete by state id.""" # Update old_state_id to NULL before deleting to ensure @@ -103,6 +104,26 @@ def _purge_state_ids(session: Session, state_ids: list[int]) -> None: ) _LOGGER.debug("Deleted %s states", deleted_rows) + # Evict eny entries in the old_states cache referring to a purged state + _evict_purged_states_from_old_states_cache(instance, state_ids) + + +def _evict_purged_states_from_old_states_cache( + instance: Recorder, purged_state_ids: set[int] +) -> None: + """Evict purged states from the old states cache.""" + # Make a map from old_state_id to entity_id + old_states = instance._old_states # pylint: disable=protected-access + old_state_reversed = { + old_state.state_id: entity_id + for entity_id, old_state in old_states.items() + if old_state.state_id + } + + # Evict any purged state from the old states cache + for purged_state_id in purged_state_ids.intersection(old_state_reversed): + old_states.pop(old_state_reversed[purged_state_id], None) + def _purge_event_ids(session: Session, event_ids: list[int]) -> None: """Delete by event id.""" @@ -139,7 +160,7 @@ def _purge_filtered_data(instance: Recorder, session: Session) -> bool: if not instance.entity_filter(entity_id) ] if len(excluded_entity_ids) > 0: - _purge_filtered_states(session, excluded_entity_ids) + _purge_filtered_states(instance, session, excluded_entity_ids) return False # Check if excluded event_types are in database @@ -149,13 +170,15 @@ def _purge_filtered_data(instance: Recorder, session: Session) -> bool: if event_type in instance.exclude_t ] if len(excluded_event_types) > 0: - _purge_filtered_events(session, excluded_event_types) + _purge_filtered_events(instance, session, excluded_event_types) return False return True -def _purge_filtered_states(session: Session, excluded_entity_ids: list[str]) -> None: +def _purge_filtered_states( + instance: Recorder, session: Session, excluded_entity_ids: list[str] +) -> None: """Remove filtered states and linked events.""" state_ids: list[int] event_ids: list[int | None] @@ -171,11 +194,13 @@ def _purge_filtered_states(session: Session, excluded_entity_ids: list[str]) -> _LOGGER.debug( "Selected %s state_ids to remove that should be filtered", len(state_ids) ) - _purge_state_ids(session, state_ids) + _purge_state_ids(instance, session, set(state_ids)) _purge_event_ids(session, event_ids) # type: ignore # type of event_ids already narrowed to 'list[int]' -def _purge_filtered_events(session: Session, excluded_event_types: list[str]) -> None: +def _purge_filtered_events( + instance: Recorder, session: Session, excluded_event_types: list[str] +) -> None: """Remove filtered events and linked states.""" events: list[Events] = ( session.query(Events.event_id) @@ -190,8 +215,8 @@ def _purge_filtered_events(session: Session, excluded_event_types: list[str]) -> states: list[States] = ( session.query(States.state_id).filter(States.event_id.in_(event_ids)).all() ) - state_ids: list[int] = [state.state_id for state in states] - _purge_state_ids(session, state_ids) + state_ids: set[int] = {state.state_id for state in states} + _purge_state_ids(instance, session, state_ids) _purge_event_ids(session, event_ids) @@ -207,7 +232,7 @@ def purge_entity_data(instance: Recorder, entity_filter: Callable[[str], bool]) _LOGGER.debug("Purging entity data for %s", selected_entity_ids) if len(selected_entity_ids) > 0: # Purge a max of MAX_ROWS_TO_PURGE, based on the oldest states or events record - _purge_filtered_states(session, selected_entity_ids) + _purge_filtered_states(instance, session, selected_entity_ids) _LOGGER.debug("Purging entity data hasn't fully completed yet") return False diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py index 40ad71096c1..0e66beecd87 100644 --- a/tests/components/recorder/test_purge.py +++ b/tests/components/recorder/test_purge.py @@ -44,6 +44,7 @@ async def test_purge_old_states( events = session.query(Events).filter(Events.event_type == "state_changed") assert events.count() == 6 + assert "test.recorder2" in instance._old_states purge_before = dt_util.utcnow() - timedelta(days=4) @@ -51,6 +52,7 @@ async def test_purge_old_states( finished = purge_old_data(instance, purge_before, repack=False) assert not finished assert states.count() == 2 + assert "test.recorder2" in instance._old_states states_after_purge = session.query(States) assert states_after_purge[1].old_state_id == states_after_purge[0].state_id @@ -59,6 +61,28 @@ async def test_purge_old_states( finished = purge_old_data(instance, purge_before, repack=False) assert finished assert states.count() == 2 + assert "test.recorder2" in instance._old_states + + # run purge_old_data again + purge_before = dt_util.utcnow() + finished = purge_old_data(instance, purge_before, repack=False) + assert not finished + assert states.count() == 0 + assert "test.recorder2" not in instance._old_states + + # Add some more states + await _add_test_states(hass, instance) + + # make sure we start with 6 states + with session_scope(hass=hass) as session: + states = session.query(States) + assert states.count() == 6 + assert states[0].old_state_id is None + assert states[-1].old_state_id == states[-2].state_id + + events = session.query(Events).filter(Events.event_type == "state_changed") + assert events.count() == 6 + assert "test.recorder2" in instance._old_states async def test_purge_old_states_encouters_database_corruption( @@ -872,45 +896,27 @@ async def _add_test_states(hass: HomeAssistant, instance: recorder.Recorder): eleven_days_ago = utcnow - timedelta(days=11) attributes = {"test_attr": 5, "test_attr_10": "nice"} - await hass.async_block_till_done() - await async_wait_recording_done(hass, instance) + async def set_state(entity_id, state, **kwargs): + """Set the state.""" + hass.states.async_set(entity_id, state, **kwargs) + await hass.async_block_till_done() + await async_wait_recording_done(hass, instance) - with recorder.session_scope(hass=hass) as session: - old_state_id = None - for event_id in range(6): - if event_id < 2: - timestamp = eleven_days_ago - state = "autopurgeme" - elif event_id < 4: - timestamp = five_days_ago - state = "purgeme" - else: - timestamp = utcnow - state = "dontpurgeme" + for event_id in range(6): + if event_id < 2: + timestamp = eleven_days_ago + state = f"autopurgeme_{event_id}" + elif event_id < 4: + timestamp = five_days_ago + state = f"purgeme_{event_id}" + else: + timestamp = utcnow + state = f"dontpurgeme_{event_id}" - event = Events( - event_type="state_changed", - event_data="{}", - origin="LOCAL", - created=timestamp, - time_fired=timestamp, - ) - session.add(event) - session.flush() - state = States( - entity_id="test.recorder2", - domain="sensor", - state=state, - attributes=json.dumps(attributes), - last_changed=timestamp, - last_updated=timestamp, - created=timestamp, - event_id=event.event_id, - old_state_id=old_state_id, - ) - session.add(state) - session.flush() - old_state_id = state.state_id + with patch( + "homeassistant.components.recorder.dt_util.utcnow", return_value=timestamp + ): + await set_state("test.recorder2", state, attributes=attributes) async def _add_test_events(hass: HomeAssistant, instance: recorder.Recorder): From d1f36027321582e02762eeea9bc9127543fa75f0 Mon Sep 17 00:00:00 2001 From: Oliver Ou Date: Mon, 4 Oct 2021 16:45:37 +0800 Subject: [PATCH 798/843] Fix Tuya v2 login issue (#56973) * fix login issue * fix:login error * update COUNTRY_CODE_CHINA line location * added one blank line * feat:added line #L88 was not covered by tests * ci build errors Co-authored-by: erchuan --- homeassistant/components/tuya/config_flow.py | 8 ++- tests/components/tuya/test_config_flow.py | 76 +++++++++++++++----- 2 files changed, 66 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/tuya/config_flow.py b/homeassistant/components/tuya/config_flow.py index 9761b1b6c96..357910b5388 100644 --- a/homeassistant/components/tuya/config_flow.py +++ b/homeassistant/components/tuya/config_flow.py @@ -26,6 +26,8 @@ from .const import ( RESULT_SINGLE_INSTANCE = "single_instance_allowed" RESULT_AUTH_FAILED = "invalid_auth" TUYA_ENDPOINT_BASE = "https://openapi.tuyacn.com" +TUYA_ENDPOINT_OTHER = "https://openapi.tuyaus.com" +COUNTRY_CODE_CHINA = ["86", "+86", "China"] _LOGGER = logging.getLogger(__name__) @@ -82,7 +84,11 @@ class TuyaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if project_type == ProjectType.INDUSTY_SOLUTIONS: response = api.login(user_input[CONF_USERNAME], user_input[CONF_PASSWORD]) else: - api.endpoint = TUYA_ENDPOINT_BASE + if user_input[CONF_COUNTRY_CODE] in COUNTRY_CODE_CHINA: + api.endpoint = TUYA_ENDPOINT_BASE + else: + api.endpoint = TUYA_ENDPOINT_OTHER + response = api.login( user_input[CONF_USERNAME], user_input[CONF_PASSWORD], diff --git a/tests/components/tuya/test_config_flow.py b/tests/components/tuya/test_config_flow.py index a15cfcc0fdf..b01745ee8db 100644 --- a/tests/components/tuya/test_config_flow.py +++ b/tests/components/tuya/test_config_flow.py @@ -24,7 +24,8 @@ MOCK_ACCESS_ID = "myAccessId" MOCK_ACCESS_SECRET = "myAccessSecret" MOCK_USERNAME = "myUsername" MOCK_PASSWORD = "myPassword" -MOCK_COUNTRY_CODE = "1" +MOCK_COUNTRY_CODE_BASE = "86" +MOCK_COUNTRY_CODE_OTHER = "1" MOCK_APP_TYPE = "smartlife" MOCK_ENDPOINT = "https://openapi-ueaz.tuyaus.com" @@ -35,15 +36,6 @@ TUYA_INDUSTRY_PROJECT_DATA = { CONF_PROJECT_TYPE: MOCK_INDUSTRY_PROJECT_TYPE, } -TUYA_INPUT_SMART_HOME_DATA = { - CONF_ACCESS_ID: MOCK_ACCESS_ID, - CONF_ACCESS_SECRET: MOCK_ACCESS_SECRET, - CONF_USERNAME: MOCK_USERNAME, - CONF_PASSWORD: MOCK_PASSWORD, - CONF_COUNTRY_CODE: MOCK_COUNTRY_CODE, - CONF_APP_TYPE: MOCK_APP_TYPE, -} - TUYA_INPUT_INDUSTRY_DATA = { CONF_ENDPOINT: MOCK_ENDPOINT, CONF_ACCESS_ID: MOCK_ACCESS_ID, @@ -52,15 +44,23 @@ TUYA_INPUT_INDUSTRY_DATA = { CONF_PASSWORD: MOCK_PASSWORD, } -TUYA_IMPORT_SMART_HOME_DATA = { +TUYA_IMPORT_SMART_HOME_DATA_BASE = { CONF_ACCESS_ID: MOCK_ACCESS_ID, CONF_ACCESS_SECRET: MOCK_ACCESS_SECRET, CONF_USERNAME: MOCK_USERNAME, CONF_PASSWORD: MOCK_PASSWORD, - CONF_COUNTRY_CODE: MOCK_COUNTRY_CODE, + CONF_COUNTRY_CODE: MOCK_COUNTRY_CODE_BASE, CONF_APP_TYPE: MOCK_APP_TYPE, } +TUYA_IMPORT_SMART_HOME_DATA_OTHER = { + CONF_ACCESS_ID: MOCK_ACCESS_ID, + CONF_ACCESS_SECRET: MOCK_ACCESS_SECRET, + CONF_USERNAME: MOCK_USERNAME, + CONF_PASSWORD: MOCK_PASSWORD, + CONF_COUNTRY_CODE: MOCK_COUNTRY_CODE_OTHER, + CONF_APP_TYPE: MOCK_APP_TYPE, +} TUYA_IMPORT_INDUSTRY_DATA = { CONF_PROJECT_TYPE: MOCK_SMART_HOME_PROJECT_TYPE, @@ -118,8 +118,8 @@ async def test_industry_user(hass, tuya): assert not result["result"].unique_id -async def test_smart_home_user(hass, tuya): - """Test smart home user config.""" +async def test_smart_home_user_base(hass, tuya): + """Test smart home user config base.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -137,7 +137,7 @@ async def test_smart_home_user(hass, tuya): tuya().login = MagicMock(return_value={"success": False, "errorCode": 1024}) result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=TUYA_IMPORT_SMART_HOME_DATA + result["flow_id"], user_input=TUYA_IMPORT_SMART_HOME_DATA_BASE ) await hass.async_block_till_done() @@ -145,7 +145,7 @@ async def test_smart_home_user(hass, tuya): tuya().login = MagicMock(return_value={"success": True, "errorCode": 1024}) result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=TUYA_IMPORT_SMART_HOME_DATA + result["flow_id"], user_input=TUYA_IMPORT_SMART_HOME_DATA_BASE ) await hass.async_block_till_done() @@ -155,7 +155,49 @@ async def test_smart_home_user(hass, tuya): assert result["data"][CONF_ACCESS_SECRET] == MOCK_ACCESS_SECRET assert result["data"][CONF_USERNAME] == MOCK_USERNAME assert result["data"][CONF_PASSWORD] == MOCK_PASSWORD - assert result["data"][CONF_COUNTRY_CODE] == MOCK_COUNTRY_CODE + assert result["data"][CONF_COUNTRY_CODE] == MOCK_COUNTRY_CODE_BASE + assert result["data"][CONF_APP_TYPE] == MOCK_APP_TYPE + assert not result["result"].unique_id + + +async def test_smart_home_user_other(hass, tuya): + """Test smart home user config other.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=TUYA_SMART_HOME_PROJECT_DATA + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "login" + + tuya().login = MagicMock(return_value={"success": False, "errorCode": 1024}) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=TUYA_IMPORT_SMART_HOME_DATA_OTHER + ) + await hass.async_block_till_done() + + assert result["errors"]["base"] == RESULT_AUTH_FAILED + + tuya().login = MagicMock(return_value={"success": True, "errorCode": 1024}) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=TUYA_IMPORT_SMART_HOME_DATA_OTHER + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == MOCK_USERNAME + assert result["data"][CONF_ACCESS_ID] == MOCK_ACCESS_ID + assert result["data"][CONF_ACCESS_SECRET] == MOCK_ACCESS_SECRET + assert result["data"][CONF_USERNAME] == MOCK_USERNAME + assert result["data"][CONF_PASSWORD] == MOCK_PASSWORD + assert result["data"][CONF_COUNTRY_CODE] == MOCK_COUNTRY_CODE_OTHER assert result["data"][CONF_APP_TYPE] == MOCK_APP_TYPE assert not result["result"].unique_id From cdaa7b7db7b50608db0d06fc08d7d9778ef0066a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 3 Oct 2021 22:02:30 -0700 Subject: [PATCH 799/843] Mark auth voluptuous schema fields as required (#57003) --- homeassistant/auth/mfa_modules/insecure_example.py | 4 ++-- homeassistant/auth/mfa_modules/notify.py | 2 +- homeassistant/auth/mfa_modules/totp.py | 2 +- homeassistant/auth/providers/command_line.py | 14 ++++++++------ homeassistant/auth/providers/homeassistant.py | 14 ++++++++------ homeassistant/auth/providers/insecure_example.py | 14 ++++++++------ .../auth/providers/legacy_api_password.py | 4 +++- homeassistant/auth/providers/trusted_networks.py | 4 +++- tests/components/auth/test_mfa_setup_flow.py | 2 +- 9 files changed, 35 insertions(+), 25 deletions(-) diff --git a/homeassistant/auth/mfa_modules/insecure_example.py b/homeassistant/auth/mfa_modules/insecure_example.py index 1d40339417b..a50b762b121 100644 --- a/homeassistant/auth/mfa_modules/insecure_example.py +++ b/homeassistant/auth/mfa_modules/insecure_example.py @@ -38,12 +38,12 @@ class InsecureExampleModule(MultiFactorAuthModule): @property def input_schema(self) -> vol.Schema: """Validate login flow input data.""" - return vol.Schema({"pin": str}) + return vol.Schema({vol.Required("pin"): str}) @property def setup_schema(self) -> vol.Schema: """Validate async_setup_user input data.""" - return vol.Schema({"pin": str}) + return vol.Schema({vol.Required("pin"): str}) async def async_setup_flow(self, user_id: str) -> SetupFlow: """Return a data entry flow handler for setup module. diff --git a/homeassistant/auth/mfa_modules/notify.py b/homeassistant/auth/mfa_modules/notify.py index 7d5cf0b0641..ec5d5b7cd03 100644 --- a/homeassistant/auth/mfa_modules/notify.py +++ b/homeassistant/auth/mfa_modules/notify.py @@ -110,7 +110,7 @@ class NotifyAuthModule(MultiFactorAuthModule): @property def input_schema(self) -> vol.Schema: """Validate login flow input data.""" - return vol.Schema({INPUT_FIELD_CODE: str}) + return vol.Schema({vol.Required(INPUT_FIELD_CODE): str}) async def _async_load(self) -> None: """Load stored data.""" diff --git a/homeassistant/auth/mfa_modules/totp.py b/homeassistant/auth/mfa_modules/totp.py index 5ff2c01c755..0ff7e1147b1 100644 --- a/homeassistant/auth/mfa_modules/totp.py +++ b/homeassistant/auth/mfa_modules/totp.py @@ -84,7 +84,7 @@ class TotpAuthModule(MultiFactorAuthModule): @property def input_schema(self) -> vol.Schema: """Validate login flow input data.""" - return vol.Schema({INPUT_FIELD_CODE: str}) + return vol.Schema({vol.Required(INPUT_FIELD_CODE): str}) async def _async_load(self) -> None: """Load stored data.""" diff --git a/homeassistant/auth/providers/command_line.py b/homeassistant/auth/providers/command_line.py index 6d1a1627fd5..81a6b6d78e5 100644 --- a/homeassistant/auth/providers/command_line.py +++ b/homeassistant/auth/providers/command_line.py @@ -2,7 +2,6 @@ from __future__ import annotations import asyncio -import collections from collections.abc import Mapping import logging import os @@ -148,10 +147,13 @@ class CommandLineLoginFlow(LoginFlow): user_input.pop("password") return await self.async_finish(user_input) - schema: dict[str, type] = collections.OrderedDict() - schema["username"] = str - schema["password"] = str - return self.async_show_form( - step_id="init", data_schema=vol.Schema(schema), errors=errors + step_id="init", + data_schema=vol.Schema( + { + vol.Required("username"): str, + vol.Required("password"): str, + } + ), + errors=errors, ) diff --git a/homeassistant/auth/providers/homeassistant.py b/homeassistant/auth/providers/homeassistant.py index 6ac9fac03e5..1ffed6f87fd 100644 --- a/homeassistant/auth/providers/homeassistant.py +++ b/homeassistant/auth/providers/homeassistant.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio import base64 -from collections import OrderedDict from collections.abc import Mapping import logging from typing import Any, cast @@ -335,10 +334,13 @@ class HassLoginFlow(LoginFlow): user_input.pop("password") return await self.async_finish(user_input) - schema: dict[str, type] = OrderedDict() - schema["username"] = str - schema["password"] = str - return self.async_show_form( - step_id="init", data_schema=vol.Schema(schema), errors=errors + step_id="init", + data_schema=vol.Schema( + { + vol.Required("username"): str, + vol.Required("password"): str, + } + ), + errors=errors, ) diff --git a/homeassistant/auth/providers/insecure_example.py b/homeassistant/auth/providers/insecure_example.py index fb390b65b0d..9ad6da27ce3 100644 --- a/homeassistant/auth/providers/insecure_example.py +++ b/homeassistant/auth/providers/insecure_example.py @@ -1,7 +1,6 @@ """Example auth provider.""" from __future__ import annotations -from collections import OrderedDict from collections.abc import Mapping import hmac from typing import Any, cast @@ -117,10 +116,13 @@ class ExampleLoginFlow(LoginFlow): user_input.pop("password") return await self.async_finish(user_input) - schema: dict[str, type] = OrderedDict() - schema["username"] = str - schema["password"] = str - return self.async_show_form( - step_id="init", data_schema=vol.Schema(schema), errors=errors + step_id="init", + data_schema=vol.Schema( + { + vol.Required("username"): str, + vol.Required("password"): str, + } + ), + errors=errors, ) diff --git a/homeassistant/auth/providers/legacy_api_password.py b/homeassistant/auth/providers/legacy_api_password.py index af24506210b..2cb113b8b8c 100644 --- a/homeassistant/auth/providers/legacy_api_password.py +++ b/homeassistant/auth/providers/legacy_api_password.py @@ -102,5 +102,7 @@ class LegacyLoginFlow(LoginFlow): return await self.async_finish({}) return self.async_show_form( - step_id="init", data_schema=vol.Schema({"password": str}), errors=errors + step_id="init", + data_schema=vol.Schema({vol.Required("password"): str}), + errors=errors, ) diff --git a/homeassistant/auth/providers/trusted_networks.py b/homeassistant/auth/providers/trusted_networks.py index a9ee6a48335..0f2b287a227 100644 --- a/homeassistant/auth/providers/trusted_networks.py +++ b/homeassistant/auth/providers/trusted_networks.py @@ -244,5 +244,7 @@ class TrustedNetworksLoginFlow(LoginFlow): return self.async_show_form( step_id="init", - data_schema=vol.Schema({"user": vol.In(self._available_users)}), + data_schema=vol.Schema( + {vol.Required("user"): vol.In(self._available_users)} + ), ) diff --git a/tests/components/auth/test_mfa_setup_flow.py b/tests/components/auth/test_mfa_setup_flow.py index 3569d7d5233..edf45742dbd 100644 --- a/tests/components/auth/test_mfa_setup_flow.py +++ b/tests/components/auth/test_mfa_setup_flow.py @@ -67,7 +67,7 @@ async def test_ws_setup_depose_mfa(hass, hass_ws_client): assert flow["type"] == data_entry_flow.RESULT_TYPE_FORM assert flow["handler"] == "example_module" assert flow["step_id"] == "init" - assert flow["data_schema"][0] == {"type": "string", "name": "pin"} + assert flow["data_schema"][0] == {"type": "string", "name": "pin", "required": True} await client.send_json( { From 4ca0c0d3a9da2d7168721108b37dc25ed682edff Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sun, 3 Oct 2021 18:24:23 -0400 Subject: [PATCH 800/843] Bump zwave-js-server-python to 0.31.2 (#57007) --- homeassistant/components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 1a9091005e2..e80549d815d 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -3,7 +3,7 @@ "name": "Z-Wave JS", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zwave_js", - "requirements": ["zwave-js-server-python==0.31.1"], + "requirements": ["zwave-js-server-python==0.31.2"], "codeowners": ["@home-assistant/z-wave"], "dependencies": ["usb", "http", "websocket_api"], "iot_class": "local_push", diff --git a/requirements_all.txt b/requirements_all.txt index e5c58f55dd2..891d6236635 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2504,4 +2504,4 @@ zigpy==0.38.0 zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.31.1 +zwave-js-server-python==0.31.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3601b26a5f3..9048888ef38 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1430,4 +1430,4 @@ zigpy-znp==0.5.4 zigpy==0.38.0 # homeassistant.components.zwave_js -zwave-js-server-python==0.31.1 +zwave-js-server-python==0.31.2 From 36bac936d14d879b556e94e14d6b7aa254bcc7ad Mon Sep 17 00:00:00 2001 From: Chris Browet Date: Mon, 4 Oct 2021 17:10:41 +0200 Subject: [PATCH 801/843] Universal media player: consider unknown as inactive child state (#57029) --- homeassistant/components/universal/media_player.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/universal/media_player.py b/homeassistant/components/universal/media_player.py index 2e3e6892c1c..d658a44a117 100644 --- a/homeassistant/components/universal/media_player.py +++ b/homeassistant/components/universal/media_player.py @@ -87,6 +87,7 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, STATE_UNAVAILABLE, + STATE_UNKNOWN, ) from homeassistant.core import EVENT_HOMEASSISTANT_START, callback from homeassistant.exceptions import TemplateError @@ -101,7 +102,7 @@ CONF_ATTRS = "attributes" CONF_CHILDREN = "children" CONF_COMMANDS = "commands" -OFF_STATES = [STATE_IDLE, STATE_OFF, STATE_UNAVAILABLE] +OFF_STATES = [STATE_IDLE, STATE_OFF, STATE_UNAVAILABLE, STATE_UNKNOWN] ATTRS_SCHEMA = cv.schema_with_slug_keys(cv.string) CMD_SCHEMA = cv.schema_with_slug_keys(cv.SERVICE_SCHEMA) From b106dab916c5f5ed1e53d4761709e3372abc28b1 Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Mon, 4 Oct 2021 13:17:42 +0200 Subject: [PATCH 802/843] ESPHome fix zeroconf add_listener issue (#57031) --- homeassistant/components/esphome/__init__.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index ed23aa7ec75..ee258317357 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -503,16 +503,14 @@ class ReconnectLogic(RecordUpdateListener): """ async with self._zc_lock: if not self._zc_listening: - await self._hass.async_add_executor_job( - self._zc.add_listener, self, None - ) + self._zc.async_add_listener(self, None) self._zc_listening = True async def _stop_zc_listen(self) -> None: """Stop listening for zeroconf updates.""" async with self._zc_lock: if self._zc_listening: - await self._hass.async_add_executor_job(self._zc.remove_listener, self) + self._zc.async_remove_listener(self) self._zc_listening = False @callback From 1fe4e08003e17951f24cf6dae0225d54fd0b4dc4 Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Mon, 4 Oct 2021 13:23:11 +0200 Subject: [PATCH 803/843] Bump aioesphomeapi from 9.1.2 to 9.1.4 (#57036) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 28371e89d8e..33801431994 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -3,7 +3,7 @@ "name": "ESPHome", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/esphome", - "requirements": ["aioesphomeapi==9.1.2"], + "requirements": ["aioesphomeapi==9.1.4"], "zeroconf": ["_esphomelib._tcp.local."], "codeowners": ["@OttoWinter", "@jesserockz"], "after_dependencies": ["zeroconf", "tag"], diff --git a/requirements_all.txt b/requirements_all.txt index 891d6236635..894b0b9b3de 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -164,7 +164,7 @@ aioeagle==1.1.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==9.1.2 +aioesphomeapi==9.1.4 # homeassistant.components.flo aioflo==0.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9048888ef38..319509de017 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -109,7 +109,7 @@ aioeagle==1.1.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==9.1.2 +aioesphomeapi==9.1.4 # homeassistant.components.flo aioflo==0.4.1 From 7ddb399178fd85b7543eff9312c4190450269296 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 4 Oct 2021 15:09:42 +0200 Subject: [PATCH 804/843] Prevent opening of sockets in watttime tests (#57040) --- tests/components/watttime/test_config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/watttime/test_config_flow.py b/tests/components/watttime/test_config_flow.py index a3d2867eb2d..efee2429d59 100644 --- a/tests/components/watttime/test_config_flow.py +++ b/tests/components/watttime/test_config_flow.py @@ -81,7 +81,7 @@ async def test_duplicate_error(hass: HomeAssistant, client_login): assert result["reason"] == "already_configured" -async def test_show_form_coordinates(hass: HomeAssistant) -> None: +async def test_show_form_coordinates(hass: HomeAssistant, client_login) -> None: """Test showing the form to input custom latitude/longitude.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} From 62390b9531c224bd2ca8ce21f7c93dd3b9403d6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Mon, 4 Oct 2021 17:27:24 +0200 Subject: [PATCH 805/843] Rewrite tuya config flow (#57043) Co-authored-by: Martin Hjelmare Co-authored-by: Franck Nijhof --- homeassistant/components/tuya/config_flow.py | 186 ++++++++--------- homeassistant/components/tuya/const.py | 28 ++- homeassistant/components/tuya/strings.json | 25 +-- .../components/tuya/translations/en.json | 73 +------ tests/components/tuya/test_config_flow.py | 195 +++++------------- 5 files changed, 168 insertions(+), 339 deletions(-) diff --git a/homeassistant/components/tuya/config_flow.py b/homeassistant/components/tuya/config_flow.py index 357910b5388..1b439d49007 100644 --- a/homeassistant/components/tuya/config_flow.py +++ b/homeassistant/components/tuya/config_flow.py @@ -1,10 +1,12 @@ -#!/usr/bin/env python3 """Config flow for Tuya.""" +from __future__ import annotations import logging +from typing import Any from tuya_iot import ProjectType, TuyaOpenAPI import voluptuous as vol +from voluptuous.schema_builder import UNDEFINED from homeassistant import config_entries @@ -16,131 +18,123 @@ from .const import ( CONF_ENDPOINT, CONF_PASSWORD, CONF_PROJECT_TYPE, + CONF_REGION, CONF_USERNAME, DOMAIN, - TUYA_APP_TYPE, - TUYA_ENDPOINT, - TUYA_PROJECT_TYPE, + SMARTLIFE_APP, + TUYA_REGIONS, + TUYA_RESPONSE_CODE, + TUYA_RESPONSE_MSG, + TUYA_RESPONSE_PLATFROM_URL, + TUYA_RESPONSE_RESULT, + TUYA_RESPONSE_SUCCESS, + TUYA_SMART_APP, ) -RESULT_SINGLE_INSTANCE = "single_instance_allowed" -RESULT_AUTH_FAILED = "invalid_auth" -TUYA_ENDPOINT_BASE = "https://openapi.tuyacn.com" -TUYA_ENDPOINT_OTHER = "https://openapi.tuyaus.com" -COUNTRY_CODE_CHINA = ["86", "+86", "China"] - _LOGGER = logging.getLogger(__name__) -# Project Type -DATA_SCHEMA_PROJECT_TYPE = vol.Schema( - {vol.Required(CONF_PROJECT_TYPE, default=0): vol.In(TUYA_PROJECT_TYPE)} -) - -# INDUSTY_SOLUTIONS Schema -DATA_SCHEMA_INDUSTRY_SOLUTIONS = vol.Schema( - { - vol.Required(CONF_ENDPOINT): vol.In(TUYA_ENDPOINT), - vol.Required(CONF_ACCESS_ID): str, - vol.Required(CONF_ACCESS_SECRET): str, - vol.Required(CONF_USERNAME): str, - vol.Required(CONF_PASSWORD): str, - } -) - -# SMART_HOME Schema -DATA_SCHEMA_SMART_HOME = vol.Schema( - { - vol.Required(CONF_ACCESS_ID): str, - vol.Required(CONF_ACCESS_SECRET): str, - vol.Required(CONF_APP_TYPE): vol.In(TUYA_APP_TYPE), - vol.Required(CONF_COUNTRY_CODE): str, - vol.Required(CONF_USERNAME): str, - vol.Required(CONF_PASSWORD): str, - } -) - class TuyaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Tuya Config Flow.""" - def __init__(self) -> None: - """Init tuya config flow.""" - super().__init__() - self.conf_project_type = None - @staticmethod - def _try_login(user_input): - project_type = ProjectType(user_input[CONF_PROJECT_TYPE]) - api = TuyaOpenAPI( - user_input[CONF_ENDPOINT] - if project_type == ProjectType.INDUSTY_SOLUTIONS - else "", - user_input[CONF_ACCESS_ID], - user_input[CONF_ACCESS_SECRET], - project_type, - ) - api.set_dev_channel("hass") + def _try_login(user_input: dict[str, Any]) -> tuple[dict[Any, Any], dict[str, Any]]: + """Try login.""" + response = {} - if project_type == ProjectType.INDUSTY_SOLUTIONS: - response = api.login(user_input[CONF_USERNAME], user_input[CONF_PASSWORD]) - else: - if user_input[CONF_COUNTRY_CODE] in COUNTRY_CODE_CHINA: - api.endpoint = TUYA_ENDPOINT_BASE + data = { + CONF_ENDPOINT: TUYA_REGIONS[user_input[CONF_REGION]], + CONF_PROJECT_TYPE: ProjectType.INDUSTY_SOLUTIONS, + CONF_ACCESS_ID: user_input[CONF_ACCESS_ID], + CONF_ACCESS_SECRET: user_input[CONF_ACCESS_SECRET], + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + CONF_COUNTRY_CODE: user_input[CONF_REGION], + } + + for app_type in ("", TUYA_SMART_APP, SMARTLIFE_APP): + data[CONF_APP_TYPE] = app_type + if data[CONF_APP_TYPE] == "": + data[CONF_PROJECT_TYPE] = ProjectType.INDUSTY_SOLUTIONS else: - api.endpoint = TUYA_ENDPOINT_OTHER + data[CONF_PROJECT_TYPE] = ProjectType.SMART_HOME + + api = TuyaOpenAPI( + endpoint=data[CONF_ENDPOINT], + access_id=data[CONF_ACCESS_ID], + access_secret=data[CONF_ACCESS_SECRET], + project_type=data[CONF_PROJECT_TYPE], + ) + api.set_dev_channel("hass") response = api.login( - user_input[CONF_USERNAME], - user_input[CONF_PASSWORD], - user_input[CONF_COUNTRY_CODE], - user_input[CONF_APP_TYPE], + username=data[CONF_USERNAME], + password=data[CONF_PASSWORD], + country_code=data[CONF_COUNTRY_CODE], + schema=data[CONF_APP_TYPE], ) - if response.get("success", False) and isinstance( - api.token_info.platform_url, str - ): - api.endpoint = api.token_info.platform_url - user_input[CONF_ENDPOINT] = api.token_info.platform_url - _LOGGER.debug("TuyaConfigFlow._try_login finish, response:, %s", response) - return response + _LOGGER.debug("Response %s", response) + + if response.get(TUYA_RESPONSE_SUCCESS, False): + break + + return response, data async def async_step_user(self, user_input=None): """Step user.""" - if user_input is None: - return self.async_show_form( - step_id="user", data_schema=DATA_SCHEMA_PROJECT_TYPE - ) - - self.conf_project_type = user_input[CONF_PROJECT_TYPE] - - return await self.async_step_login() - - async def async_step_login(self, user_input=None): - """Step login.""" errors = {} - if user_input is not None: - assert self.conf_project_type is not None - user_input[CONF_PROJECT_TYPE] = self.conf_project_type + placeholders = {} - response = await self.hass.async_add_executor_job( + if user_input is not None: + response, data = await self.hass.async_add_executor_job( self._try_login, user_input ) - if response.get("success", False): - _LOGGER.debug("TuyaConfigFlow.async_step_user login success") + if response.get(TUYA_RESPONSE_SUCCESS, False): + if endpoint := response.get(TUYA_RESPONSE_RESULT, {}).get( + TUYA_RESPONSE_PLATFROM_URL + ): + data[CONF_ENDPOINT] = endpoint + + data[CONF_PROJECT_TYPE] = data[CONF_PROJECT_TYPE].value + return self.async_create_entry( title=user_input[CONF_USERNAME], - data=user_input, + data=data, ) - errors["base"] = RESULT_AUTH_FAILED + errors["base"] = "login_error" + placeholders = { + TUYA_RESPONSE_CODE: response.get(TUYA_RESPONSE_CODE), + TUYA_RESPONSE_MSG: response.get(TUYA_RESPONSE_MSG), + } - if ProjectType(self.conf_project_type) == ProjectType.SMART_HOME: - return self.async_show_form( - step_id="login", data_schema=DATA_SCHEMA_SMART_HOME, errors=errors - ) + def _schema_default(key: str) -> str | UNDEFINED: + if not user_input: + return UNDEFINED + return user_input[key] return self.async_show_form( - step_id="login", - data_schema=DATA_SCHEMA_INDUSTRY_SOLUTIONS, + step_id="user", + data_schema=vol.Schema( + { + vol.Required( + CONF_REGION, default=_schema_default(CONF_REGION) + ): vol.In(TUYA_REGIONS.keys()), + vol.Required( + CONF_ACCESS_ID, default=_schema_default(CONF_ACCESS_ID) + ): str, + vol.Required( + CONF_ACCESS_SECRET, default=_schema_default(CONF_ACCESS_SECRET) + ): str, + vol.Required( + CONF_USERNAME, default=_schema_default(CONF_USERNAME) + ): str, + vol.Required( + CONF_PASSWORD, default=_schema_default(CONF_PASSWORD) + ): str, + } + ), errors=errors, + description_placeholders=placeholders, ) diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index e259dd9190b..f86180226ee 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -9,6 +9,7 @@ CONF_ACCESS_ID = "access_id" CONF_ACCESS_SECRET = "access_secret" CONF_USERNAME = "username" CONF_PASSWORD = "password" +CONF_REGION = "region" CONF_COUNTRY_CODE = "country_code" CONF_APP_TYPE = "tuya_app_type" @@ -19,19 +20,24 @@ TUYA_MQTT_LISTENER = "tuya_mqtt_listener" TUYA_HA_TUYA_MAP = "tuya_ha_tuya_map" TUYA_HA_DEVICES = "tuya_ha_devices" +TUYA_RESPONSE_CODE = "code" +TUYA_RESPONSE_RESULT = "result" +TUYA_RESPONSE_MSG = "msg" +TUYA_RESPONSE_SUCCESS = "success" +TUYA_RESPONSE_PLATFROM_URL = "platform_url" + TUYA_HA_SIGNAL_UPDATE_ENTITY = "tuya_entry_update" -TUYA_ENDPOINT = { - "https://openapi.tuyaus.com": "America", - "https://openapi.tuyacn.com": "China", - "https://openapi.tuyaeu.com": "Europe", - "https://openapi.tuyain.com": "India", - "https://openapi-ueaz.tuyaus.com": "EasternAmerica", - "https://openapi-weaz.tuyaeu.com": "WesternEurope", +TUYA_SMART_APP = "tuyaSmart" +SMARTLIFE_APP = "smartlife" + +TUYA_REGIONS = { + "America": "https://openapi.tuyaus.com", + "China": "https://openapi.tuyacn.com", + "Eastern America": "https://openapi-ueaz.tuyaus.com", + "Europe": "https://openapi.tuyaeu.com", + "India": "https://openapi.tuyain.com", + "Western Europe": "https://openapi-weaz.tuyaeu.com", } -TUYA_PROJECT_TYPE = {1: "Custom Development", 0: "Smart Home PaaS"} - -TUYA_APP_TYPE = {"tuyaSmart": "TuyaSmart", "smartlife": "Smart Life"} - PLATFORMS = ["climate", "fan", "light", "scene", "switch"] diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index 91ca045e1f5..044c068ac9c 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -1,29 +1,20 @@ { "config": { - "flow_title": "Tuya configuration", "step": { - "user":{ - "title":"Tuya Integration", - "data":{ - "tuya_project_type": "Tuya cloud project type" - } - }, - "login": { - "title": "Tuya", - "description": "Enter your Tuya credential", + "user": { + "description": "Enter your Tuya credentials", "data": { - "endpoint": "Availability Zone", - "access_id": "Access ID", - "access_secret": "Access Secret", - "tuya_app_type": "Mobile App", - "country_code": "Country Code", + "region": "Region", + "access_id": "Tuya IoT Access ID", + "access_secret": "Tuya IoT Access Secret", "username": "Account", - "password": "Password" + "password": "[%key:common::config_flow::data::password%]" } } }, "error": { - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "login_error": "Login error ({code}): {msg}" } } } \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/en.json b/homeassistant/components/tuya/translations/en.json index c7aaee977ee..a3630e66406 100644 --- a/homeassistant/components/tuya/translations/en.json +++ b/homeassistant/components/tuya/translations/en.json @@ -1,78 +1,19 @@ { "config": { - "abort": { - "cannot_connect": "Failed to connect", - "invalid_auth": "Invalid authentication", - "single_instance_allowed": "Already configured. Only a single configuration possible." - }, "error": { - "invalid_auth": "Invalid authentication" + "invalid_auth": "Invalid authentication", + "login_error": "Login error ({code}): {msg}" }, - "flow_title": "Tuya configuration", "step": { - "login": { - "data": { - "access_id": "Access ID", - "access_secret": "Access Secret", - "country_code": "Country Code", - "endpoint": "Availability Zone", - "password": "Password", - "tuya_app_type": "Mobile App", - "username": "Account" - }, - "description": "Enter your Tuya credential", - "title": "Tuya" - }, "user": { "data": { - "country_code": "Your account country code (e.g., 1 for USA or 86 for China)", + "access_id": "Tuya IoT Access ID", + "access_secret": "Tuya IoT Access Secret", "password": "Password", - "platform": "The app where your account is registered", - "tuya_project_type": "Tuya cloud project type", - "username": "Username" + "region": "Region", + "username": "Account" }, - "description": "Enter your Tuya credentials.", - "title": "Tuya Integration" - } - } - }, - "options": { - "abort": { - "cannot_connect": "Failed to connect" - }, - "error": { - "dev_multi_type": "Multiple selected devices to configure must be of the same type", - "dev_not_config": "Device type not configurable", - "dev_not_found": "Device not found" - }, - "step": { - "device": { - "data": { - "brightness_range_mode": "Brightness range used by device", - "curr_temp_divider": "Current Temperature value divider (0 = use default)", - "max_kelvin": "Max color temperature supported in kelvin", - "max_temp": "Max target temperature (use min and max = 0 for default)", - "min_kelvin": "Min color temperature supported in kelvin", - "min_temp": "Min target temperature (use min and max = 0 for default)", - "set_temp_divided": "Use divided Temperature value for set temperature command", - "support_color": "Force color support", - "temp_divider": "Temperature values divider (0 = use default)", - "temp_step_override": "Target Temperature step", - "tuya_max_coltemp": "Max color temperature reported by device", - "unit_of_measurement": "Temperature unit used by device" - }, - "description": "Configure options to adjust displayed information for {device_type} device `{device_name}`", - "title": "Configure Tuya Device" - }, - "init": { - "data": { - "discovery_interval": "Discovery device polling interval in seconds", - "list_devices": "Select the devices to configure or leave empty to save configuration", - "query_device": "Select device that will use query method for faster status update", - "query_interval": "Query device polling interval in seconds" - }, - "description": "Do not set pollings interval values too low or the calls will fail generating error message in the log", - "title": "Configure Tuya Options" + "description": "Enter your Tuya credentials" } } } diff --git a/tests/components/tuya/test_config_flow.py b/tests/components/tuya/test_config_flow.py index b01745ee8db..04fb8ebe009 100644 --- a/tests/components/tuya/test_config_flow.py +++ b/tests/components/tuya/test_config_flow.py @@ -1,93 +1,84 @@ """Tests for the Tuya config flow.""" -from unittest.mock import MagicMock, Mock, patch +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock, patch import pytest from homeassistant import config_entries, data_entry_flow -from homeassistant.components.tuya.config_flow import RESULT_AUTH_FAILED from homeassistant.components.tuya.const import ( CONF_ACCESS_ID, CONF_ACCESS_SECRET, CONF_APP_TYPE, - CONF_COUNTRY_CODE, CONF_ENDPOINT, CONF_PASSWORD, CONF_PROJECT_TYPE, + CONF_REGION, CONF_USERNAME, DOMAIN, + SMARTLIFE_APP, + TUYA_REGIONS, + TUYA_SMART_APP, ) +from homeassistant.core import HomeAssistant MOCK_SMART_HOME_PROJECT_TYPE = 0 MOCK_INDUSTRY_PROJECT_TYPE = 1 +MOCK_REGION = "Europe" MOCK_ACCESS_ID = "myAccessId" MOCK_ACCESS_SECRET = "myAccessSecret" MOCK_USERNAME = "myUsername" MOCK_PASSWORD = "myPassword" -MOCK_COUNTRY_CODE_BASE = "86" -MOCK_COUNTRY_CODE_OTHER = "1" -MOCK_APP_TYPE = "smartlife" MOCK_ENDPOINT = "https://openapi-ueaz.tuyaus.com" -TUYA_SMART_HOME_PROJECT_DATA = { - CONF_PROJECT_TYPE: MOCK_SMART_HOME_PROJECT_TYPE, -} -TUYA_INDUSTRY_PROJECT_DATA = { - CONF_PROJECT_TYPE: MOCK_INDUSTRY_PROJECT_TYPE, -} - -TUYA_INPUT_INDUSTRY_DATA = { - CONF_ENDPOINT: MOCK_ENDPOINT, +TUYA_INPUT_DATA = { + CONF_REGION: MOCK_REGION, CONF_ACCESS_ID: MOCK_ACCESS_ID, CONF_ACCESS_SECRET: MOCK_ACCESS_SECRET, CONF_USERNAME: MOCK_USERNAME, CONF_PASSWORD: MOCK_PASSWORD, } -TUYA_IMPORT_SMART_HOME_DATA_BASE = { - CONF_ACCESS_ID: MOCK_ACCESS_ID, - CONF_ACCESS_SECRET: MOCK_ACCESS_SECRET, - CONF_USERNAME: MOCK_USERNAME, - CONF_PASSWORD: MOCK_PASSWORD, - CONF_COUNTRY_CODE: MOCK_COUNTRY_CODE_BASE, - CONF_APP_TYPE: MOCK_APP_TYPE, -} - -TUYA_IMPORT_SMART_HOME_DATA_OTHER = { - CONF_ACCESS_ID: MOCK_ACCESS_ID, - CONF_ACCESS_SECRET: MOCK_ACCESS_SECRET, - CONF_USERNAME: MOCK_USERNAME, - CONF_PASSWORD: MOCK_PASSWORD, - CONF_COUNTRY_CODE: MOCK_COUNTRY_CODE_OTHER, - CONF_APP_TYPE: MOCK_APP_TYPE, -} - -TUYA_IMPORT_INDUSTRY_DATA = { - CONF_PROJECT_TYPE: MOCK_SMART_HOME_PROJECT_TYPE, - CONF_ENDPOINT: MOCK_ENDPOINT, - CONF_ACCESS_ID: MOCK_ACCESS_ID, - CONF_ACCESS_SECRET: MOCK_ACCESS_SECRET, - CONF_USERNAME: MOCK_USERNAME, - CONF_PASSWORD: MOCK_PASSWORD, +RESPONSE_SUCCESS = { + "success": True, + "code": 1024, + "result": {"platform_url": MOCK_ENDPOINT}, } +RESPONSE_ERROR = {"success": False, "code": 123, "msg": "Error"} @pytest.fixture(name="tuya") -def tuya_fixture() -> Mock: +def tuya_fixture() -> MagicMock: """Patch libraries.""" with patch("homeassistant.components.tuya.config_flow.TuyaOpenAPI") as tuya: yield tuya @pytest.fixture(name="tuya_setup", autouse=True) -def tuya_setup_fixture(): +def tuya_setup_fixture() -> None: """Mock tuya entry setup.""" with patch("homeassistant.components.tuya.async_setup_entry", return_value=True): yield -async def test_industry_user(hass, tuya): - """Test industry user config.""" +@pytest.mark.parametrize( + "app_type,side_effects, project_type", + [ + ("", [RESPONSE_SUCCESS], 1), + (TUYA_SMART_APP, [RESPONSE_ERROR, RESPONSE_SUCCESS], 0), + (SMARTLIFE_APP, [RESPONSE_ERROR, RESPONSE_ERROR, RESPONSE_SUCCESS], 0), + ], +) +async def test_user_flow( + hass: HomeAssistant, + tuya: MagicMock, + app_type: str, + side_effects: list[dict[str, Any]], + project_type: int, +): + """Test user flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -95,17 +86,9 @@ async def test_industry_user(hass, tuya): assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" + tuya().login = MagicMock(side_effect=side_effects) result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=TUYA_INDUSTRY_PROJECT_DATA - ) - await hass.async_block_till_done() - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "login" - - tuya().login = MagicMock(return_value={"success": True, "errorCode": 1024}) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=TUYA_INPUT_INDUSTRY_DATA + result["flow_id"], user_input=TUYA_INPUT_DATA ) await hass.async_block_till_done() @@ -115,90 +98,10 @@ async def test_industry_user(hass, tuya): assert result["data"][CONF_ACCESS_SECRET] == MOCK_ACCESS_SECRET assert result["data"][CONF_USERNAME] == MOCK_USERNAME assert result["data"][CONF_PASSWORD] == MOCK_PASSWORD - assert not result["result"].unique_id - - -async def test_smart_home_user_base(hass, tuya): - """Test smart home user config base.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "user" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=TUYA_SMART_HOME_PROJECT_DATA - ) - await hass.async_block_till_done() - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "login" - - tuya().login = MagicMock(return_value={"success": False, "errorCode": 1024}) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=TUYA_IMPORT_SMART_HOME_DATA_BASE - ) - await hass.async_block_till_done() - - assert result["errors"]["base"] == RESULT_AUTH_FAILED - - tuya().login = MagicMock(return_value={"success": True, "errorCode": 1024}) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=TUYA_IMPORT_SMART_HOME_DATA_BASE - ) - await hass.async_block_till_done() - - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == MOCK_USERNAME - assert result["data"][CONF_ACCESS_ID] == MOCK_ACCESS_ID - assert result["data"][CONF_ACCESS_SECRET] == MOCK_ACCESS_SECRET - assert result["data"][CONF_USERNAME] == MOCK_USERNAME - assert result["data"][CONF_PASSWORD] == MOCK_PASSWORD - assert result["data"][CONF_COUNTRY_CODE] == MOCK_COUNTRY_CODE_BASE - assert result["data"][CONF_APP_TYPE] == MOCK_APP_TYPE - assert not result["result"].unique_id - - -async def test_smart_home_user_other(hass, tuya): - """Test smart home user config other.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "user" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=TUYA_SMART_HOME_PROJECT_DATA - ) - await hass.async_block_till_done() - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "login" - - tuya().login = MagicMock(return_value={"success": False, "errorCode": 1024}) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=TUYA_IMPORT_SMART_HOME_DATA_OTHER - ) - await hass.async_block_till_done() - - assert result["errors"]["base"] == RESULT_AUTH_FAILED - - tuya().login = MagicMock(return_value={"success": True, "errorCode": 1024}) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=TUYA_IMPORT_SMART_HOME_DATA_OTHER - ) - await hass.async_block_till_done() - - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == MOCK_USERNAME - assert result["data"][CONF_ACCESS_ID] == MOCK_ACCESS_ID - assert result["data"][CONF_ACCESS_SECRET] == MOCK_ACCESS_SECRET - assert result["data"][CONF_USERNAME] == MOCK_USERNAME - assert result["data"][CONF_PASSWORD] == MOCK_PASSWORD - assert result["data"][CONF_COUNTRY_CODE] == MOCK_COUNTRY_CODE_OTHER - assert result["data"][CONF_APP_TYPE] == MOCK_APP_TYPE + assert result["data"][CONF_ENDPOINT] == MOCK_ENDPOINT + assert result["data"][CONF_ENDPOINT] != TUYA_REGIONS[TUYA_INPUT_DATA[CONF_REGION]] + assert result["data"][CONF_APP_TYPE] == app_type + assert result["data"][CONF_PROJECT_TYPE] == project_type assert not result["result"].unique_id @@ -212,18 +115,12 @@ async def test_error_on_invalid_credentials(hass, tuya): assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" + tuya().login = MagicMock(return_value=RESPONSE_ERROR) result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=TUYA_INDUSTRY_PROJECT_DATA + result["flow_id"], user_input=TUYA_INPUT_DATA ) await hass.async_block_till_done() - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "login" - - tuya().login = MagicMock(return_value={"success": False, "errorCode": 1024}) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=TUYA_INPUT_INDUSTRY_DATA - ) - await hass.async_block_till_done() - - assert result["errors"]["base"] == RESULT_AUTH_FAILED + assert result["errors"]["base"] == "login_error" + assert result["description_placeholders"]["code"] == RESPONSE_ERROR["code"] + assert result["description_placeholders"]["msg"] == RESPONSE_ERROR["msg"] From 688884ccfe7b4f4c1c53d4d79430d0baf7b37ce9 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sun, 3 Oct 2021 00:13:50 +0000 Subject: [PATCH 806/843] [ci skip] Translation update --- .../apple_tv/translations/zh-Hans.json | 32 +++++++++++++++-- .../coolmaster/translations/he.json | 1 + .../components/dlna_dmr/translations/he.json | 18 ++++++++++ .../dlna_dmr/translations/zh-Hans.json | 12 +++++++ .../esphome/translations/zh-Hans.json | 18 ++++++++-- .../components/fan/translations/he.json | 8 +++++ .../components/flux_led/translations/de.json | 36 +++++++++++++++++++ .../components/flux_led/translations/en.json | 36 +++++++++++++++++++ .../huawei_lte/translations/zh-Hans.json | 36 ++++++++++++++++++- .../nmap_tracker/translations/zh-Hans.json | 5 ++- .../opengarage/translations/he.json | 22 ++++++++++++ .../opengarage/translations/zh-Hans.json | 12 +++++++ .../components/switchbot/translations/he.json | 1 + .../components/tplink/translations/he.json | 14 ++++++++ .../components/tuya/translations/he.json | 36 +++++++++++++++++-- .../xiaomi_aqara/translations/he.json | 29 ++++++++++++--- .../components/zwave_js/translations/he.json | 3 +- 17 files changed, 304 insertions(+), 15 deletions(-) create mode 100644 homeassistant/components/dlna_dmr/translations/he.json create mode 100644 homeassistant/components/dlna_dmr/translations/zh-Hans.json create mode 100644 homeassistant/components/flux_led/translations/de.json create mode 100644 homeassistant/components/flux_led/translations/en.json create mode 100644 homeassistant/components/opengarage/translations/he.json create mode 100644 homeassistant/components/opengarage/translations/zh-Hans.json diff --git a/homeassistant/components/apple_tv/translations/zh-Hans.json b/homeassistant/components/apple_tv/translations/zh-Hans.json index 54095a0a633..4b178c75fce 100644 --- a/homeassistant/components/apple_tv/translations/zh-Hans.json +++ b/homeassistant/components/apple_tv/translations/zh-Hans.json @@ -1,18 +1,46 @@ { "config": { + "abort": { + "already_configured_device": "\u8bbe\u5907\u5df2\u88ab\u914d\u7f6e", + "already_in_progress": "\u914d\u7f6e\u6d41\u7a0b\u5df2\u5728\u8fdb\u884c\u4e2d", + "backoff": "\u8bbe\u5907\u76ee\u524d\u6682\u4e0d\u63a5\u53d7\u914d\u5bf9\u8bf7\u6c42\uff08\u53ef\u80fd\u591a\u6b21\u8f93\u5165\u65e0\u6548 PIN \u7801\uff09\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002", + "invalid_config": "\u6b64\u8bbe\u5907\u7684\u914d\u7f6e\u4fe1\u606f\u4e0d\u5b8c\u6574\u3002\u8bf7\u5c1d\u8bd5\u91cd\u65b0\u6dfb\u52a0\u3002", + "no_devices_found": "\u672a\u5728\u6b64\u7f51\u7edc\u53d1\u73b0\u76f8\u5173\u8bbe\u5907", + "unknown": "\u672a\u77e5\u9519\u8bef" + }, + "error": { + "already_configured": "\u8bbe\u5907\u5df2\u88ab\u914d\u7f6e", + "invalid_auth": "\u51ed\u636e\u65e0\u6548", + "no_devices_found": "\u672a\u5728\u6b64\u7f51\u7edc\u53d1\u73b0\u76f8\u5173\u8bbe\u5907", + "no_usable_service": "\u5df2\u76f8\u5173\u627e\u5230\u8bbe\u5907\uff0c\u4f46\u65e0\u6cd5\u8bc6\u522b\u5e76\u4e0e\u5176\u5efa\u7acb\u8fde\u63a5\u3002\u82e5\u60a8\u4e00\u76f4\u6536\u5230\u6b64\u8b66\u544a\u6d88\u606f\uff0c\u8bf7\u5c1d\u8bd5\u4e3a\u5176\u6307\u5b9a\u56fa\u5b9a IP \u5730\u5740\u6216\u91cd\u65b0\u542f\u52a8\u60a8\u7684 Apple TV\u3002", + "unknown": "\u672a\u77e5\u9519\u8bef" + }, "step": { "confirm": { - "description": "\u60a8\u5373\u5c06\u6dfb\u52a0 Apple TV (\u540d\u79f0\u4e3a\u201c{name}\u201d)\u5230 Home Assistant\u3002 \n\n **\u8981\u5b8c\u6210\u6b64\u8fc7\u7a0b\uff0c\u53ef\u80fd\u9700\u8981\u8f93\u5165\u591a\u4e2a PIN \u7801\u3002** \n\n\u8bf7\u6ce8\u610f\uff0c\u6b64\u96c6\u6210*\u4e0d\u80fd*\u5173\u95ed Apple TV \u7684\u7535\u6e90\uff0c\u53ea\u4f1a\u5173\u95ed Home Assistant \u4e2d\u7684\u5a92\u4f53\u64ad\u653e\u5668\uff01" + "description": "\u60a8\u5373\u5c06\u6dfb\u52a0 Apple TV (\u540d\u79f0\u4e3a\u201c{name}\u201d)\u5230 Home Assistant\u3002 \n\n **\u8981\u5b8c\u6210\u6b64\u8fc7\u7a0b\uff0c\u53ef\u80fd\u9700\u8981\u8f93\u5165\u591a\u4e2a PIN \u7801\u3002** \n\n\u8bf7\u6ce8\u610f\uff0c\u6b64\u96c6\u6210*\u4e0d\u80fd*\u5173\u95ed Apple TV \u7684\u7535\u6e90\uff0c\u53ea\u4f1a\u5173\u95ed Home Assistant \u4e2d\u7684\u5a92\u4f53\u64ad\u653e\u5668\uff01", + "title": "\u786e\u8ba4\u6dfb\u52a0 Apple TV" }, "pair_no_pin": { + "description": "`{protocol}` \u670d\u52a1\u9700\u8981\u914d\u5bf9\u3002\u8bf7\u5728\u60a8\u7684 Apple TV \u4e0a\u8f93\u5165 PIN {pin}", "title": "\u914d\u5bf9\u4e2d" }, "pair_with_pin": { "data": { "pin": "PIN\u7801" - } + }, + "title": "\u914d\u5bf9\u4e2d" + }, + "reconfigure": { + "description": "\u8be5 Apple TV \u9047\u5230\u4e00\u4e9b\u8fde\u63a5\u95ee\u9898\uff0c\u987b\u91cd\u65b0\u914d\u7f6e\u3002", + "title": "\u8bbe\u5907\u91cd\u65b0\u914d\u7f6e" + }, + "service_problem": { + "title": "\u6dfb\u52a0\u670d\u52a1\u5931\u8d25" }, "user": { + "data": { + "device_input": "\u8bbe\u5907\u5730\u5740" + }, "description": "\u8981\u5f00\u59cb\uff0c\u8bf7\u8f93\u5165\u8981\u6dfb\u52a0\u7684 Apple TV \u7684\u8bbe\u5907\u540d\u79f0\u6216 IP \u5730\u5740\u3002\u5728\u7f51\u7edc\u4e0a\u81ea\u52a8\u53d1\u73b0\u7684\u8bbe\u5907\u4f1a\u663e\u793a\u5728\u4e0b\u65b9\u3002 \n\n\u5982\u679c\u6ca1\u6709\u53d1\u73b0\u8bbe\u5907\u6216\u9047\u5230\u4efb\u4f55\u95ee\u9898\uff0c\u8bf7\u5c1d\u8bd5\u6307\u5b9a\u8bbe\u5907 IP \u5730\u5740\u3002 \n\n {devices}", "title": "\u8bbe\u7f6e\u65b0\u7684 Apple TV" } diff --git a/homeassistant/components/coolmaster/translations/he.json b/homeassistant/components/coolmaster/translations/he.json index 5903faf3c72..1fd17b10134 100644 --- a/homeassistant/components/coolmaster/translations/he.json +++ b/homeassistant/components/coolmaster/translations/he.json @@ -7,6 +7,7 @@ "step": { "user": { "data": { + "fan_only": "\u05ea\u05de\u05d9\u05db\u05d4 \u05d1\u05de\u05e6\u05d1 \u05de\u05d0\u05d5\u05d5\u05e8\u05e8 \u05d1\u05dc\u05d1\u05d3", "host": "\u05de\u05d0\u05e8\u05d7" } } diff --git a/homeassistant/components/dlna_dmr/translations/he.json b/homeassistant/components/dlna_dmr/translations/he.json new file mode 100644 index 00000000000..fbdaa0403f4 --- /dev/null +++ b/homeassistant/components/dlna_dmr/translations/he.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05ea\u05d7\u05d9\u05dc \u05d1\u05d4\u05d2\u05d3\u05e8\u05d4?" + }, + "user": { + "data": { + "url": "\u05db\u05ea\u05d5\u05d1\u05ea \u05d0\u05ea\u05e8" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dlna_dmr/translations/zh-Hans.json b/homeassistant/components/dlna_dmr/translations/zh-Hans.json new file mode 100644 index 00000000000..909a38b4b74 --- /dev/null +++ b/homeassistant/components/dlna_dmr/translations/zh-Hans.json @@ -0,0 +1,12 @@ +{ + "config": { + "error": { + "could_not_connect": "\u65e0\u6cd5\u8fde\u63a5\u5230 DLNA \u8bbe\u5907" + } + }, + "options": { + "error": { + "invalid_url": "\u65e0\u6548\u7f51\u5740" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/translations/zh-Hans.json b/homeassistant/components/esphome/translations/zh-Hans.json index b1911b90fde..d0c54f6afb1 100644 --- a/homeassistant/components/esphome/translations/zh-Hans.json +++ b/homeassistant/components/esphome/translations/zh-Hans.json @@ -2,11 +2,13 @@ "config": { "abort": { "already_configured": "\u8bbe\u5907\u5df2\u7ecf\u914d\u7f6e\u8fc7\u4e86", - "already_in_progress": "\u914d\u7f6e\u6d41\u7a0b\u5df2\u5728\u8fdb\u884c\u4e2d" + "already_in_progress": "\u914d\u7f6e\u6d41\u7a0b\u5df2\u5728\u8fdb\u884c\u4e2d", + "reauth_successful": "\u91cd\u9a8c\u8bc1\u6210\u529f" }, "error": { "connection_error": "\u65e0\u6cd5\u8fde\u63a5\u5230 ESP\u3002\u8bf7\u786e\u8ba4\u60a8\u7684 YAML \u6587\u4ef6\u4e2d\u5305\u542b 'api:' \u884c\u3002", "invalid_auth": "\u8eab\u4efd\u8ba4\u8bc1\u65e0\u6548", + "invalid_psk": "\u4f20\u8f93\u52a0\u5bc6\u5bc6\u94a5\u65e0\u6548\u3002\u8bf7\u786e\u4fdd\u5b83\u4e0e\u60a8\u7684\u914d\u7f6e\u4e00\u81f4\u3002", "resolve_error": "\u65e0\u6cd5\u89e3\u6790 ESP \u7684\u5730\u5740\u3002\u5982\u679c\u6b64\u9519\u8bef\u6301\u7eed\u5b58\u5728\uff0c\u8bf7\u8bbe\u7f6e\u9759\u6001IP\u5730\u5740\uff1ahttps://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, "flow_title": "ESPHome: {name}", @@ -21,9 +23,21 @@ "description": "\u662f\u5426\u8981\u5c06 ESPHome \u8282\u70b9 `{name}` \u6dfb\u52a0\u5230 Home Assistant\uff1f", "title": "\u53d1\u73b0\u4e86 ESPHome \u8282\u70b9" }, + "encryption_key": { + "data": { + "noise_psk": "\u52a0\u5bc6\u5bc6\u94a5" + }, + "description": "\u8bf7\u8f93\u5165\u60a8\u5728\u914d\u7f6e\u4e2d\u8bbe\u5907 {name} \u6240\u8bbe\u7f6e\u7684\u52a0\u5bc6\u5bc6\u94a5\u3002" + }, + "reauth_confirm": { + "data": { + "noise_psk": "\u52a0\u5bc6\u5bc6\u94a5" + }, + "description": "ESPHome \u8bbe\u5907 {name} \u5df2\u542f\u7528\u6216\u66f4\u6539\u4f20\u8f93\u52a0\u5bc6\u5bc6\u94a5\u3002\u8bf7\u8f93\u5165\u66f4\u65b0\u540e\u7684\u5bc6\u94a5\u4fe1\u606f\u3002" + }, "user": { "data": { - "host": "\u4e3b\u673a", + "host": "\u4e3b\u673a\u5730\u5740", "port": "\u7aef\u53e3" }, "description": "\u8bf7\u8f93\u5165\u60a8\u7684 [ESPHome](https://esphomelib.com/) \u8282\u70b9\u7684\u8fde\u63a5\u8bbe\u7f6e\u3002" diff --git a/homeassistant/components/fan/translations/he.json b/homeassistant/components/fan/translations/he.json index db876480dfc..92e38a79918 100644 --- a/homeassistant/components/fan/translations/he.json +++ b/homeassistant/components/fan/translations/he.json @@ -1,8 +1,16 @@ { "device_automation": { + "action_type": { + "turn_off": "\u05db\u05d9\u05d1\u05d5\u05d9 {entity_name}", + "turn_on": "\u05d4\u05e4\u05e2\u05dc\u05ea {entity_name}" + }, "condition_type": { "is_off": "{entity_name} \u05db\u05d1\u05d5\u05d9", "is_on": "{entity_name} \u05e4\u05d5\u05e2\u05dc" + }, + "trigger_type": { + "turned_off": "{entity_name} \u05db\u05d5\u05d1\u05d4", + "turned_on": "{entity_name} \u05d4\u05d5\u05e4\u05e2\u05dc" } }, "state": { diff --git a/homeassistant/components/flux_led/translations/de.json b/homeassistant/components/flux_led/translations/de.json new file mode 100644 index 00000000000..f036e7bd913 --- /dev/null +++ b/homeassistant/components/flux_led/translations/de.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "already_in_progress": "Der Konfigurationsablauf wird bereits ausgef\u00fchrt", + "no_devices_found": "Keine Ger\u00e4te im Netzwerk gefunden" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen" + }, + "flow_title": "{model} {id} ({ipaddr})", + "step": { + "discovery_confirm": { + "description": "M\u00f6chtest du {model} {id} ({ipaddr})?" + }, + "user": { + "data": { + "host": "Host" + }, + "description": "Wenn du den Host leer l\u00e4sst, wird die Erkennung verwendet, um Ger\u00e4te zu finden." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "custom_effect_colors": "Benutzerdefinierter Effekt: Liste mit 1 bis 16 [R,G,B]-Farben. Beispiel: [255,0,255],[60,128,0]", + "custom_effect_speed_pct": "Benutzerdefinierter Effekt: Geschwindigkeit in Prozent f\u00fcr den Effekt, der die Farbe wechselt.", + "custom_effect_transition": "Benutzerdefinierter Effekt: Art des \u00dcbergangs zwischen den Farben.", + "mode": "Der gew\u00e4hlte Helligkeitsmodus." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flux_led/translations/en.json b/homeassistant/components/flux_led/translations/en.json new file mode 100644 index 00000000000..9a988408c30 --- /dev/null +++ b/homeassistant/components/flux_led/translations/en.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "already_in_progress": "Configuration flow is already in progress", + "no_devices_found": "No devices found on the network" + }, + "error": { + "cannot_connect": "Failed to connect" + }, + "flow_title": "{model} {id} ({ipaddr})", + "step": { + "discovery_confirm": { + "description": "Do you want to setup {model} {id} ({ipaddr})?" + }, + "user": { + "data": { + "host": "Host" + }, + "description": "If you leave the host empty, discovery will be used to find devices." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "custom_effect_colors": "Custom Effect: List of 1 to 16 [R,G,B] colors. Example: [255,0,255],[60,128,0]", + "custom_effect_speed_pct": "Custom Effect: Speed in percents for the effect that switch colors.", + "custom_effect_transition": "Custom Effect: Type of transition between the colors.", + "mode": "The chosen brightness mode." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huawei_lte/translations/zh-Hans.json b/homeassistant/components/huawei_lte/translations/zh-Hans.json index 4fb447403d6..a63ff964b62 100644 --- a/homeassistant/components/huawei_lte/translations/zh-Hans.json +++ b/homeassistant/components/huawei_lte/translations/zh-Hans.json @@ -1,8 +1,42 @@ { "config": { + "abort": { + "already_configured": "\u8bbe\u5907\u5df2\u88ab\u914d\u7f6e", + "already_in_progress": "\u914d\u7f6e\u6d41\u7a0b\u5df2\u5728\u8fdb\u884c", + "not_huawei_lte": "\u8be5\u8bbe\u5907\u4e0d\u662f\u534e\u4e3a LTE \u8bbe\u5907" + }, "error": { + "connection_timeout": "\u8fde\u63a5\u8d85\u65f6", + "incorrect_password": "\u5bc6\u7801\u9519\u8bef", "incorrect_username": "\u7528\u6237\u540d\u9519\u8bef", - "login_attempts_exceeded": "\u5df2\u8d85\u8fc7\u6700\u5927\u767b\u5f55\u6b21\u6570\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5" + "invalid_auth": "\u51ed\u8bc1\u65e0\u6548", + "invalid_url": "\u65e0\u6548\u7f51\u5740", + "login_attempts_exceeded": "\u5df2\u8d85\u8fc7\u6700\u5927\u767b\u5f55\u6b21\u6570\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5", + "response_error": "\u8bbe\u5907\u51fa\u73b0\u672a\u77e5\u9519\u8bef", + "unknown": "\u672a\u77e5\u9519\u8bef" + }, + "step": { + "user": { + "data": { + "url": "\u4e3b\u673a\u5730\u5740", + "username": "\u7528\u6237\u540d" + }, + "description": "\u8f93\u5165\u8bbe\u5907\u76f8\u5173\u4fe1\u606f\u4ee5\u4fbf\u8fde\u63a5\u81f3\u8be5\u8bbe\u5907", + "title": "\u914d\u7f6e\u534e\u4e3aLTE" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "name": "\u63a8\u9001\u670d\u52a1\u540d\u79f0\uff08\u66f4\u6539\u540e\u9700\u8981\u91cd\u8f7d\uff09", + "recipient": "\u77ed\u4fe1\u901a\u77e5\u6536\u4ef6\u4eba", + "track_new_devices": "\u8ddf\u8e2a\u65b0\u8bbe\u5907", + "track_wired_clients": "\u8ddf\u8e2a\u6709\u7ebf\u7f51\u7edc\u5ba2\u6237\u7aef", + "unauthenticated_mode": "\u672a\u7ecf\u8eab\u4efd\u9a8c\u8bc1\u7684\u6a21\u5f0f\uff08\u66f4\u6539\u540e\u9700\u8981\u91cd\u8f7d\uff09" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/nmap_tracker/translations/zh-Hans.json b/homeassistant/components/nmap_tracker/translations/zh-Hans.json index e0ca0563b7a..5b1be2f497d 100644 --- a/homeassistant/components/nmap_tracker/translations/zh-Hans.json +++ b/homeassistant/components/nmap_tracker/translations/zh-Hans.json @@ -22,10 +22,13 @@ "step": { "init": { "data": { + "consider_home": "\u7b49\u5f85\u591a\u5c11\u79d2\u540e\u5219\u5224\u5b9a\u8bbe\u5907\u79bb\u5f00", "exclude": "\u4ece\u626b\u63cf\u4e2d\u6392\u9664\u7684\u7f51\u7edc\u5730\u5740\uff08\u4ee5\u9017\u53f7\u5206\u9694\uff09", "home_interval": "\u626b\u63cf\u8bbe\u5907\u7684\u6700\u5c0f\u95f4\u9694\u5206\u949f\u6570\uff08\u7528\u4e8e\u8282\u7701\u7535\u91cf\uff09", "hosts": "\u8981\u626b\u63cf\u7684\u7f51\u7edc\u5730\u5740\uff08\u4ee5\u9017\u53f7\u5206\u9694\uff09", - "scan_options": "Nmap \u7684\u539f\u59cb\u53ef\u914d\u7f6e\u626b\u63cf\u9009\u9879" + "interval_seconds": "\u626b\u63cf\u95f4\u9694\uff08\u79d2\uff09", + "scan_options": "Nmap \u7684\u539f\u59cb\u53ef\u914d\u7f6e\u626b\u63cf\u9009\u9879", + "track_new_devices": "\u8ddf\u8e2a\u65b0\u8bbe\u5907" }, "description": "\u914d\u7f6e\u901a\u8fc7 Nmap \u626b\u63cf\u7684\u4e3b\u673a\u3002\u7f51\u7edc\u5730\u5740\u548c\u6392\u9664\u9879\u53ef\u4ee5\u662f IP \u5730\u5740 (192.168.1.1)\u3001IP \u5730\u5740\u5757 (192.168.0.0/24) \u6216 IP \u8303\u56f4 (192.168.1.0-32)\u3002" } diff --git a/homeassistant/components/opengarage/translations/he.json b/homeassistant/components/opengarage/translations/he.json new file mode 100644 index 00000000000..7f9a4197d54 --- /dev/null +++ b/homeassistant/components/opengarage/translations/he.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "user": { + "data": { + "device_key": "\u05de\u05e4\u05ea\u05d7 \u05d4\u05ea\u05e7\u05df", + "host": "\u05de\u05d0\u05e8\u05d7", + "port": "\u05e4\u05ea\u05d7\u05d4", + "verify_ssl": "\u05d0\u05d9\u05de\u05d5\u05ea \u05d0\u05d9\u05e9\u05d5\u05e8 SSL" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/opengarage/translations/zh-Hans.json b/homeassistant/components/opengarage/translations/zh-Hans.json new file mode 100644 index 00000000000..4f99ec0f978 --- /dev/null +++ b/homeassistant/components/opengarage/translations/zh-Hans.json @@ -0,0 +1,12 @@ +{ + "config": { + "abort": { + "already_configured": "\u8bbe\u5907\u5df2\u88ab\u914d\u7f6e" + }, + "error": { + "cannot_connect": "\u8fde\u63a5\u5931\u8d25", + "invalid_auth": "\u51ed\u8bc1\u65e0\u6548", + "unknown": "\u672a\u77e5\u9519\u8bef" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switchbot/translations/he.json b/homeassistant/components/switchbot/translations/he.json index 9e4d8129169..09f62069706 100644 --- a/homeassistant/components/switchbot/translations/he.json +++ b/homeassistant/components/switchbot/translations/he.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured_device": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" }, "error": { diff --git a/homeassistant/components/tplink/translations/he.json b/homeassistant/components/tplink/translations/he.json index 053fc43039a..621ee6bebc9 100644 --- a/homeassistant/components/tplink/translations/he.json +++ b/homeassistant/components/tplink/translations/he.json @@ -1,12 +1,26 @@ { "config": { "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", "no_devices_found": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05de\u05db\u05e9\u05d9\u05e8\u05d9\u05dd \u05d1\u05e8\u05e9\u05ea", "single_instance_allowed": "\u05ea\u05e6\u05d5\u05e8\u05ea\u05d5 \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e8\u05e7 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05d0\u05e4\u05e9\u05e8\u05d9\u05ea." }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" + }, "step": { "confirm": { "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05d2\u05d3\u05d9\u05e8 \u05d4\u05ea\u05e7\u05e0\u05d9\u05dd \u05d7\u05db\u05de\u05d9\u05dd \u05e9\u05dc TP-Link ?" + }, + "pick_device": { + "data": { + "device": "\u05d4\u05ea\u05e7\u05df" + } + }, + "user": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7" + } } } } diff --git a/homeassistant/components/tuya/translations/he.json b/homeassistant/components/tuya/translations/he.json index 44a7699e511..548e623533a 100644 --- a/homeassistant/components/tuya/translations/he.json +++ b/homeassistant/components/tuya/translations/he.json @@ -8,23 +8,53 @@ "error": { "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9" }, - "flow_title": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d8\u05d5\u05d9\u05d4", + "flow_title": "\u05ea\u05e6\u05d5\u05e8\u05ea Tuya", "step": { + "login": { + "data": { + "access_id": "\u05de\u05d6\u05d4\u05d4 \u05d2\u05d9\u05e9\u05d4", + "access_secret": "\u05e1\u05d5\u05d3 \u05d2\u05d9\u05e9\u05d4", + "country_code": "\u05e7\u05d5\u05d3 \u05de\u05d3\u05d9\u05e0\u05d4", + "endpoint": "\u05d0\u05d6\u05d5\u05e8 \u05d6\u05de\u05d9\u05e0\u05d5\u05ea", + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "tuya_app_type": "\u05d9\u05d9\u05e9\u05d5\u05dd \u05dc\u05e0\u05d9\u05d9\u05d3", + "username": "\u05d7\u05e9\u05d1\u05d5\u05df" + }, + "description": "\u05d4\u05d6\u05e0\u05ea \u05d0\u05d9\u05e9\u05d5\u05e8 \u05d4-Tuya \u05e9\u05dc\u05da", + "title": "Tuya" + }, "user": { "data": { "country_code": "\u05e7\u05d5\u05d3 \u05de\u05d3\u05d9\u05e0\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05e9\u05dc\u05da (\u05dc\u05de\u05e9\u05dc, 1 \u05dc\u05d0\u05e8\u05d4\"\u05d1 \u05d0\u05d5 972 \u05dc\u05d9\u05e9\u05e8\u05d0\u05dc)", "password": "\u05e1\u05d9\u05e1\u05de\u05d4", "platform": "\u05d4\u05d9\u05d9\u05e9\u05d5\u05dd \u05d1\u05d5 \u05e8\u05e9\u05d5\u05dd \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05e9\u05dc\u05da", + "tuya_project_type": "\u05e1\u05d5\u05d2 \u05e4\u05e8\u05d5\u05d9\u05d9\u05e7\u05d8 \u05d4\u05e2\u05e0\u05df \u05e9\u05dc Tuya", "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" }, - "description": "\u05d4\u05d6\u05df \u05d0\u05ea \u05d0\u05d9\u05e9\u05d5\u05e8\u05d9 \u05d8\u05d5\u05d9\u05d4 \u05e9\u05dc\u05da.", - "title": "Tuya" + "description": "\u05d4\u05d6\u05e0\u05ea \u05d0\u05d9\u05e9\u05d5\u05e8\u05d9 \u05d4-Tuya \u05e9\u05dc\u05da.", + "title": "\u05e9\u05d9\u05dc\u05d5\u05d1 Tuya" } } }, "options": { "abort": { "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" + }, + "error": { + "dev_multi_type": "\u05d4\u05ea\u05e7\u05e0\u05d9\u05dd \u05e0\u05d1\u05d7\u05e8\u05d9\u05dd \u05de\u05e8\u05d5\u05d1\u05d9\u05dd \u05dc\u05e7\u05d1\u05d9\u05e2\u05ea \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4 \u05d7\u05d9\u05d9\u05d1\u05d9\u05dd \u05dc\u05d4\u05d9\u05d5\u05ea \u05de\u05d0\u05d5\u05ea\u05d5 \u05e1\u05d5\u05d2", + "dev_not_config": "\u05e1\u05d5\u05d2 \u05d4\u05d4\u05ea\u05e7\u05df \u05d0\u05d9\u05e0\u05d5 \u05e0\u05d9\u05ea\u05df \u05dc\u05d4\u05d2\u05d3\u05e8\u05d4", + "dev_not_found": "\u05d4\u05d4\u05ea\u05e7\u05df \u05dc\u05d0 \u05e0\u05de\u05e6\u05d0" + }, + "step": { + "device": { + "data": { + "brightness_range_mode": "\u05d8\u05d5\u05d5\u05d7 \u05d1\u05d4\u05d9\u05e8\u05d5\u05ea \u05d4\u05de\u05e9\u05de\u05e9 \u05d0\u05ea \u05d4\u05d4\u05ea\u05e7\u05df", + "max_kelvin": "\u05d8\u05de\u05e4\u05e8\u05d8\u05d5\u05e8\u05ea \u05e6\u05d1\u05e2 \u05de\u05e8\u05d1\u05d9\u05ea \u05d4\u05e0\u05ea\u05de\u05db\u05ea \u05d1\u05e7\u05dc\u05d5\u05d5\u05d9\u05df", + "min_kelvin": "\u05d8\u05de\u05e4\u05e8\u05d8\u05d5\u05e8\u05ea \u05e6\u05d1\u05e2 \u05de\u05d9\u05e0\u05d9\u05de\u05dc\u05d9\u05ea \u05d4\u05e0\u05ea\u05de\u05db\u05ea \u05d1\u05e7\u05dc\u05d5\u05d5\u05d9\u05df", + "support_color": "\u05db\u05e4\u05d4 \u05ea\u05de\u05d9\u05db\u05d4 \u05d1\u05e6\u05d1\u05e2", + "unit_of_measurement": "\u05d9\u05d7\u05d9\u05d3\u05ea \u05d8\u05de\u05e4\u05e8\u05d8\u05d5\u05e8\u05d4 \u05d4\u05de\u05e9\u05de\u05e9\u05ea \u05d0\u05ea \u05d4\u05d4\u05ea\u05e7\u05df" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/xiaomi_aqara/translations/he.json b/homeassistant/components/xiaomi_aqara/translations/he.json index 5a12ddc3b9e..7450bbd463c 100644 --- a/homeassistant/components/xiaomi_aqara/translations/he.json +++ b/homeassistant/components/xiaomi_aqara/translations/he.json @@ -2,22 +2,41 @@ "config": { "abort": { "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", - "already_in_progress": "\u05d6\u05e8\u05d9\u05de\u05ea \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4 \u05db\u05d1\u05e8 \u05de\u05ea\u05d1\u05e6\u05e2\u05ea" + "already_in_progress": "\u05d6\u05e8\u05d9\u05de\u05ea \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4 \u05db\u05d1\u05e8 \u05de\u05ea\u05d1\u05e6\u05e2\u05ea", + "not_xiaomi_aqara": "\u05dc\u05d0 \u05e9\u05e2\u05e8 \u05e9\u05d9\u05d0\u05d5\u05de\u05d9 \u05d0\u05e7\u05d0\u05e8\u05d4, \u05d4\u05ea\u05e7\u05df \u05e9\u05d4\u05ea\u05d2\u05dc\u05d4 \u05dc\u05d0 \u05ea\u05d0\u05dd \u05dc\u05e9\u05e2\u05e8\u05d9\u05dd \u05d9\u05d3\u05d5\u05e2\u05d9\u05dd" + }, + "error": { + "discovery_error": "\u05d2\u05d9\u05dc\u05d5\u05d9 \u05e9\u05e2\u05e8 \u05e9\u05d9\u05d5\u05d0\u05de\u05d9 \u05d0\u05e7\u05d0\u05e8\u05d4 \u05e0\u05db\u05e9\u05dc, \u05e0\u05e1\u05d4 \u05dc\u05d4\u05e9\u05ea\u05de\u05e9 \u05d1-IP \u05e9\u05dc \u05d4\u05d4\u05ea\u05e7\u05df \u05d1\u05d5 \u05e4\u05d5\u05e2\u05dc HomeAssistant \u05db\u05de\u05de\u05e9\u05e7", + "invalid_host": "\u05e9\u05dd \u05de\u05d0\u05e8\u05d7 \u05d0\u05d5 \u05db\u05ea\u05d5\u05d1\u05ea IP \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9\u05d9\u05dd, \u05e8\u05d0\u05d4 https://www.home-assistant.io/integrations/xiaomi_aqara/#connection-problem", + "invalid_interface": "\u05de\u05de\u05e9\u05e7 \u05e8\u05e9\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "invalid_key": "\u05de\u05e4\u05ea\u05d7 \u05e9\u05e2\u05e8 \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "invalid_mac": "\u05db\u05ea\u05d5\u05d1\u05ea Mac \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9\u05ea" }, "flow_title": "{name}", "step": { "select": { "data": { "select_ip": "\u05db\u05ea\u05d5\u05d1\u05ea IP" - } + }, + "description": "\u05d9\u05e9 \u05dc\u05d4\u05e4\u05e2\u05d9\u05dc \u05d0\u05ea \u05d4\u05d4\u05ea\u05e7\u05e0\u05d4 \u05e9\u05d5\u05d1 \u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d7\u05d1\u05e8 \u05e9\u05e2\u05e8\u05d9\u05dd \u05e0\u05d5\u05e1\u05e4\u05d9\u05dd", + "title": "\u05d1\u05d7\u05d9\u05e8\u05ea \u05e9\u05e2\u05e8 \u05e9\u05d9\u05d0\u05d5\u05de\u05d9 \u05d0\u05e7\u05d0\u05e8\u05d4 \u05e9\u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d7\u05d1\u05e8" }, "settings": { - "description": "\u05e0\u05d9\u05ea\u05df \u05dc\u05d0\u05d7\u05d6\u05e8 \u05d0\u05ea \u05d4\u05de\u05e4\u05ea\u05d7 (\u05e1\u05d9\u05e1\u05de\u05d4) \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea \u05d4\u05d3\u05e8\u05db\u05d4 \u05d6\u05d5: https://www.domoticz.com/wiki/Xiaomi_Gateway_(Aqara)#Adding_the_Xiaomi_Gateway_to_Domoticz. \u05d0\u05dd \u05d4\u05de\u05e4\u05ea\u05d7 \u05d0\u05d9\u05e0\u05d5 \u05de\u05e1\u05d5\u05e4\u05e7, \u05e8\u05e7 \u05d7\u05d9\u05d9\u05e9\u05e0\u05d9\u05dd \u05d9\u05d4\u05d9\u05d5 \u05e0\u05d2\u05d9\u05e9\u05d9\u05dd" + "data": { + "key": "\u05de\u05e4\u05ea\u05d7 \u05d4\u05e9\u05e2\u05e8 \u05e9\u05dc\u05da", + "name": "\u05e9\u05dd \u05d4\u05e9\u05e2\u05e8" + }, + "description": "\u05e0\u05d9\u05ea\u05df \u05dc\u05d0\u05d7\u05d6\u05e8 \u05d0\u05ea \u05d4\u05de\u05e4\u05ea\u05d7 (\u05e1\u05d9\u05e1\u05de\u05d4) \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea \u05d4\u05d3\u05e8\u05db\u05d4 \u05d6\u05d5: https://www.domoticz.com/wiki/Xiaomi_Gateway_(Aqara)#Adding_the_Xiaomi_Gateway_to_Domoticz. \u05d0\u05dd \u05d4\u05de\u05e4\u05ea\u05d7 \u05d0\u05d9\u05e0\u05d5 \u05de\u05e1\u05d5\u05e4\u05e7, \u05e8\u05e7 \u05d7\u05d9\u05d9\u05e9\u05e0\u05d9\u05dd \u05d9\u05d4\u05d9\u05d5 \u05e0\u05d2\u05d9\u05e9\u05d9\u05dd", + "title": "\u05e9\u05e2\u05e8 \u05e9\u05d9\u05d0\u05d5\u05de\u05d9 \u05d0\u05e7\u05d0\u05e8\u05d4, \u05d4\u05d2\u05d3\u05e8\u05d5\u05ea \u05d0\u05d5\u05e4\u05e6\u05d9\u05d5\u05e0\u05dc\u05d9\u05d5\u05ea" }, "user": { "data": { - "host": "\u05db\u05ea\u05d5\u05d1\u05ea IP (\u05d0\u05d5\u05e4\u05e6\u05d9\u05d5\u05e0\u05dc\u05d9)" - } + "host": "\u05db\u05ea\u05d5\u05d1\u05ea IP (\u05d0\u05d5\u05e4\u05e6\u05d9\u05d5\u05e0\u05dc\u05d9)", + "interface": "\u05de\u05de\u05e9\u05e7 \u05d4\u05e8\u05e9\u05ea \u05d1\u05d5 \u05d9\u05e9 \u05dc\u05d4\u05e9\u05ea\u05de\u05e9", + "mac": "\u05db\u05ea\u05d5\u05d1\u05ea Mac (\u05d0\u05d5\u05e4\u05e6\u05d9\u05d5\u05e0\u05dc\u05d9)" + }, + "description": "\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05dc\u05e9\u05e2\u05e8 \u05e9\u05d9\u05d5\u05d0\u05de\u05d9 \u05d0\u05e7\u05d0\u05e8\u05d4 \u05e9\u05dc\u05da, \u05d0\u05dd \u05db\u05ea\u05d5\u05d1\u05d5\u05ea \u05d4-IP \u05d5\u05d4-MAC \u05d9\u05d5\u05d5\u05ea\u05e8\u05d5 \u05e8\u05d9\u05e7\u05d5\u05ea, \u05e0\u05e2\u05e9\u05d4 \u05e9\u05d9\u05de\u05d5\u05e9 \u05d1\u05d2\u05d9\u05dc\u05d5\u05d9 \u05d0\u05d5\u05d8\u05d5\u05de\u05d8\u05d9", + "title": "\u05e9\u05e2\u05e8 \u05e9\u05d9\u05d0\u05d5\u05de\u05d9 \u05d0\u05e7\u05d0\u05e8\u05d4" } } } diff --git a/homeassistant/components/zwave_js/translations/he.json b/homeassistant/components/zwave_js/translations/he.json index fae03188b81..041e1cafec6 100644 --- a/homeassistant/components/zwave_js/translations/he.json +++ b/homeassistant/components/zwave_js/translations/he.json @@ -64,5 +64,6 @@ "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05e9\u05ea\u05de\u05e9 \u05d1\u05d4\u05e8\u05d7\u05d1\u05d4 \u05de\u05e4\u05e7\u05d7 Z-Wave JS?" } } - } + }, + "title": "Z-Wave JS" } \ No newline at end of file From da6169656632c9bcc1500d9b40e84c29f16a74ec Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Mon, 4 Oct 2021 00:11:57 +0000 Subject: [PATCH 807/843] [ci skip] Translation update --- .../components/flux_led/translations/ca.json | 36 +++++++++++++++++++ .../components/flux_led/translations/et.json | 36 +++++++++++++++++++ .../components/flux_led/translations/hu.json | 36 +++++++++++++++++++ .../components/flux_led/translations/it.json | 36 +++++++++++++++++++ .../components/flux_led/translations/ru.json | 36 +++++++++++++++++++ .../components/tuya/translations/hu.json | 14 ++++++++ .../components/zwave_js/translations/hu.json | 8 +++++ 7 files changed, 202 insertions(+) create mode 100644 homeassistant/components/flux_led/translations/ca.json create mode 100644 homeassistant/components/flux_led/translations/et.json create mode 100644 homeassistant/components/flux_led/translations/hu.json create mode 100644 homeassistant/components/flux_led/translations/it.json create mode 100644 homeassistant/components/flux_led/translations/ru.json diff --git a/homeassistant/components/flux_led/translations/ca.json b/homeassistant/components/flux_led/translations/ca.json new file mode 100644 index 00000000000..25314edc1b8 --- /dev/null +++ b/homeassistant/components/flux_led/translations/ca.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat", + "already_in_progress": "El flux de configuraci\u00f3 ja est\u00e0 en curs", + "no_devices_found": "No s'han trobat dispositius a la xarxa" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3" + }, + "flow_title": "{model} {id} ({ipaddr})", + "step": { + "discovery_confirm": { + "description": "Vols configurar {model} {id} ({ipaddr})?" + }, + "user": { + "data": { + "host": "Amfitri\u00f3" + }, + "description": "Si deixes l'amfitri\u00f3 buit, s'utilitzar\u00e0 el descobriment per cercar dispositius." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "custom_effect_colors": "Efecte personalitzat: llista d'1 a 16 colors [R,G,B]. Exemple: [255,0,255],[60,128,0]", + "custom_effect_speed_pct": "Efecte personalitzat: velocitat en percentatges de l'efecte de canvi de color.", + "custom_effect_transition": "Efecte personalitzat: tipus de transici\u00f3 entre colors.", + "mode": "Mode de brillantor escollit." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flux_led/translations/et.json b/homeassistant/components/flux_led/translations/et.json new file mode 100644 index 00000000000..0c2e1f444cb --- /dev/null +++ b/homeassistant/components/flux_led/translations/et.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud", + "already_in_progress": "Seadistamine on juba k\u00e4imas", + "no_devices_found": "V\u00f5rgust seadmeid ei leitud" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus" + }, + "flow_title": "{model} {id} ({ipaddr})", + "step": { + "discovery_confirm": { + "description": "Kas seadistada {model} {id} ({ipaddr})?" + }, + "user": { + "data": { + "host": "Host" + }, + "description": "Kui j\u00e4tad hosti t\u00fchjaks kasutatakse seadmete leidmiseks avastamist." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "custom_effect_colors": "Kohandatud efekt: Loetelu 1 kuni 16 [R,G,B] v\u00e4rvist. N\u00e4ide: [255,0,255],[60,128,0]", + "custom_effect_speed_pct": "Kohandatud efekt: v\u00e4rvide vahetamise efekti kiirus protsentides.", + "custom_effect_transition": "Kohandatud efekt: v\u00e4rvide vahelise \u00fclemineku t\u00fc\u00fcp.", + "mode": "Valitud heleduse re\u017eiim." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flux_led/translations/hu.json b/homeassistant/components/flux_led/translations/hu.json new file mode 100644 index 00000000000..3cfef2c9eb6 --- /dev/null +++ b/homeassistant/components/flux_led/translations/hu.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van.", + "no_devices_found": "Nem tal\u00e1lhat\u00f3 eszk\u00f6z a h\u00e1l\u00f3zaton" + }, + "error": { + "cannot_connect": "Sikertelen csatlakoz\u00e1s" + }, + "flow_title": "{model} {id} ({ipaddr})", + "step": { + "discovery_confirm": { + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani a(z) {model} {id} ({ipaddr}) webhelyet?" + }, + "user": { + "data": { + "host": "C\u00edm" + }, + "description": "Ha nem ad meg c\u00edmet, akkor az eszk\u00f6z\u00f6k keres\u00e9se a felder\u00edt\u00e9ssel t\u00f6rt\u00e9nik." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "custom_effect_colors": "Egy\u00e9ni effektus: 1-16 [R,G,B] sz\u00edn list\u00e1ja. P\u00e9lda: [255,0,255], [60,128,0]", + "custom_effect_speed_pct": "Egy\u00e9ni effektus: A sz\u00edneket v\u00e1lt\u00f3 hat\u00e1s sz\u00e1zal\u00e9kos ar\u00e1nya.", + "custom_effect_transition": "Egy\u00e9ni hat\u00e1s: A sz\u00ednek k\u00f6z\u00f6tti \u00e1tmenet t\u00edpusa.", + "mode": "A v\u00e1lasztott f\u00e9nyer\u0151 m\u00f3d." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flux_led/translations/it.json b/homeassistant/components/flux_led/translations/it.json new file mode 100644 index 00000000000..91654fb3542 --- /dev/null +++ b/homeassistant/components/flux_led/translations/it.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", + "already_in_progress": "Il flusso di configurazione \u00e8 gi\u00e0 in corso", + "no_devices_found": "Nessun dispositivo trovato sulla rete" + }, + "error": { + "cannot_connect": "Impossibile connettersi" + }, + "flow_title": "{model} {id} ({ipaddr})", + "step": { + "discovery_confirm": { + "description": "Vuoi configurare {model} {id} ({ipaddr})?" + }, + "user": { + "data": { + "host": "Host" + }, + "description": "Se lasci vuoto l'host, il rilevamento verr\u00e0 utilizzato per trovare i dispositivi." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "custom_effect_colors": "Effetto personalizzato: Lista da 1 a 16 colori [R,G,B]. Esempio: [255,0,255],[60,128,0]", + "custom_effect_speed_pct": "Effetto personalizzato: Velocit\u00e0 in percentuale per l'effetto che cambia colore.", + "custom_effect_transition": "Effetto personalizzato: Tipo di transizione tra i colori.", + "mode": "La modalit\u00e0 di luminosit\u00e0 scelta." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flux_led/translations/ru.json b/homeassistant/components/flux_led/translations/ru.json new file mode 100644 index 00000000000..e0f7a73baab --- /dev/null +++ b/homeassistant/components/flux_led/translations/ru.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant.", + "already_in_progress": "\u041f\u0440\u043e\u0446\u0435\u0441\u0441 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u044f\u0435\u0442\u0441\u044f.", + "no_devices_found": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b \u0432 \u0441\u0435\u0442\u0438." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f." + }, + "flow_title": "{model} {id} ({ipaddr})", + "step": { + "discovery_confirm": { + "description": "\u0425\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0447\u0430\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0443 {model} {id} ({ipaddr})?" + }, + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442" + }, + "description": "\u0415\u0441\u043b\u0438 \u043d\u0435 \u0443\u043a\u0430\u0437\u044b\u0432\u0430\u0442\u044c \u0430\u0434\u0440\u0435\u0441 \u0445\u043e\u0441\u0442\u0430, \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0431\u0443\u0434\u0443\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u044b \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u0438." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "custom_effect_colors": "\u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u0441\u043a\u0438\u0439 \u044d\u0444\u0444\u0435\u043a\u0442: \u0441\u043f\u0438\u0441\u043e\u043a \u043e\u0442 1 \u0434\u043e 16 [R,G,B] \u0446\u0432\u0435\u0442\u043e\u0432. \u041d\u0430\u043f\u0440\u0438\u043c\u0435\u0440: [255,0,255],[60,128,0]", + "custom_effect_speed_pct": "\u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u0441\u043a\u0438\u0439 \u044d\u0444\u0444\u0435\u043a\u0442: \u0441\u043a\u043e\u0440\u043e\u0441\u0442\u044c \u043f\u0435\u0440\u0435\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f \u0446\u0432\u0435\u0442\u043e\u0432 (\u0432 \u043f\u0440\u043e\u0446\u0435\u043d\u0442\u0430\u0445).", + "custom_effect_transition": "\u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u0441\u043a\u0438\u0439 \u044d\u0444\u0444\u0435\u043a\u0442: \u0442\u0438\u043f \u043f\u0435\u0440\u0435\u0445\u043e\u0434\u0430 \u043c\u0435\u0436\u0434\u0443 \u0446\u0432\u0435\u0442\u0430\u043c\u0438.", + "mode": "\u0412\u044b\u0431\u0440\u0430\u043d\u043d\u044b\u0439 \u0440\u0435\u0436\u0438\u043c \u044f\u0440\u043a\u043e\u0441\u0442\u0438." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/hu.json b/homeassistant/components/tuya/translations/hu.json index b90d2a2ff81..d721a8cd133 100644 --- a/homeassistant/components/tuya/translations/hu.json +++ b/homeassistant/components/tuya/translations/hu.json @@ -10,11 +10,25 @@ }, "flow_title": "Tuya konfigur\u00e1ci\u00f3", "step": { + "login": { + "data": { + "access_id": "Hozz\u00e1f\u00e9r\u00e9si azonos\u00edt\u00f3", + "access_secret": "Hozz\u00e1f\u00e9r\u00e9si token", + "country_code": "Orsz\u00e1g k\u00f3d", + "endpoint": "El\u00e9rhet\u0151s\u00e9gi z\u00f3na", + "password": "Jelsz\u00f3", + "tuya_app_type": "Mobil app", + "username": "Fi\u00f3k" + }, + "description": "Adja meg Tuya hiteles\u00edt\u0151 adatait.", + "title": "Tuya" + }, "user": { "data": { "country_code": "A fi\u00f3k orsz\u00e1gk\u00f3dja (pl. 1 USA, 36 Magyarorsz\u00e1g, vagy 86 K\u00edna)", "password": "Jelsz\u00f3", "platform": "Az alkalmaz\u00e1s, ahol a fi\u00f3k regisztr\u00e1lt", + "tuya_project_type": "Tuya felh\u0151 projekt t\u00edpusa", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" }, "description": "Adja meg Tuya hiteles\u00edt\u0151 adatait.", diff --git a/homeassistant/components/zwave_js/translations/hu.json b/homeassistant/components/zwave_js/translations/hu.json index bf541fce26a..715881fb329 100644 --- a/homeassistant/components/zwave_js/translations/hu.json +++ b/homeassistant/components/zwave_js/translations/hu.json @@ -27,6 +27,10 @@ "configure_addon": { "data": { "network_key": "H\u00e1l\u00f3zati kulcs", + "s0_legacy_key": "S0 kulcs (r\u00e9gi)", + "s2_access_control_key": "S2 Hozz\u00e1f\u00e9r\u00e9s kulcs", + "s2_authenticated_key": "S2 hiteles\u00edtett kulcs", + "s2_unauthenticated_key": "S2 nem hiteles\u00edtett kulcs", "usb_path": "USB eszk\u00f6z el\u00e9r\u00e9si \u00fat" }, "title": "Adja meg a Z-Wave JS kieg\u00e9sz\u00edt\u0151 konfigur\u00e1ci\u00f3j\u00e1t" @@ -109,6 +113,10 @@ "emulate_hardware": "Hardver emul\u00e1ci\u00f3", "log_level": "Napl\u00f3szint", "network_key": "H\u00e1l\u00f3zati kulcs", + "s0_legacy_key": "S0 kulcs (r\u00e9gi)", + "s2_access_control_key": "S2 hozz\u00e1f\u00e9r\u00e9si ", + "s2_authenticated_key": "S2 hiteles\u00edtett kulcs", + "s2_unauthenticated_key": "S2 nem hiteles\u00edtett kulcs", "usb_path": "USB eszk\u00f6z \u00fatvonala" }, "title": "Adja meg a Z-Wave JS kieg\u00e9sz\u00edt\u0151 konfigur\u00e1ci\u00f3j\u00e1t" From 491abf0608a3d3b48e1563de659c67003973cafc Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 4 Oct 2021 08:35:42 -0700 Subject: [PATCH 808/843] Bumped version to 2021.10.0b5 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index d803b673e41..4dbf3ea2785 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -5,7 +5,7 @@ from typing import Final MAJOR_VERSION: Final = 2021 MINOR_VERSION: Final = 10 -PATCH_VERSION: Final = "0b4" +PATCH_VERSION: Final = "0b5" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0) From b086266508a49f4ca7eb7679abc4dc66362c1cb3 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 4 Oct 2021 08:38:24 -0700 Subject: [PATCH 809/843] [ci skip] Translation update --- .../components/adguard/translations/hu.json | 2 +- .../components/almond/translations/hu.json | 2 +- .../components/ambee/translations/hu.json | 2 +- .../components/broadlink/translations/hu.json | 2 +- .../components/brother/translations/hu.json | 2 +- .../components/cast/translations/hu.json | 2 +- .../components/deconz/translations/hu.json | 4 +- .../components/esphome/translations/hu.json | 2 +- .../components/flux_led/translations/nl.json | 36 ++++++++++ .../components/flux_led/translations/no.json | 36 ++++++++++ .../flux_led/translations/zh-Hant.json | 36 ++++++++++ .../homekit_controller/translations/hu.json | 2 +- .../components/hue/translations/hu.json | 2 +- .../components/kodi/translations/hu.json | 2 +- .../mobile_app/translations/hu.json | 2 +- .../modern_forms/translations/hu.json | 2 +- .../components/motioneye/translations/hu.json | 2 +- .../components/mqtt/translations/hu.json | 2 +- .../components/roon/translations/hu.json | 2 +- .../components/samsungtv/translations/hu.json | 4 +- .../components/smappee/translations/hu.json | 2 +- .../components/tuya/translations/en.json | 65 ++++++++++++++++++- .../components/tuya/translations/nl.json | 1 + .../components/tuya/translations/no.json | 16 ++++- .../components/vera/translations/hu.json | 4 +- .../components/vizio/translations/hu.json | 2 +- .../components/volumio/translations/hu.json | 2 +- .../components/wled/translations/hu.json | 4 +- .../components/zwave_js/translations/nl.json | 8 +++ .../components/zwave_js/translations/no.json | 8 +++ 30 files changed, 230 insertions(+), 28 deletions(-) create mode 100644 homeassistant/components/flux_led/translations/nl.json create mode 100644 homeassistant/components/flux_led/translations/no.json create mode 100644 homeassistant/components/flux_led/translations/zh-Hant.json diff --git a/homeassistant/components/adguard/translations/hu.json b/homeassistant/components/adguard/translations/hu.json index 3939de8aea5..b04d67fbb89 100644 --- a/homeassistant/components/adguard/translations/hu.json +++ b/homeassistant/components/adguard/translations/hu.json @@ -9,7 +9,7 @@ }, "step": { "hassio_confirm": { - "description": "Szeretn\u00e9 be\u00e1ll\u00edtani Home Assistant-ot AdGuard Home-hoz val\u00f3 csatlakoz\u00e1shoz, {addon} kieg\u00e9sz\u00edt\u0151 \u00e1ltal?", + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani Home Assistantot AdGuard Home-hoz val\u00f3 csatlakoz\u00e1shoz, {addon} kieg\u00e9sz\u00edt\u0151 \u00e1ltal?", "title": "Az AdGuard Home a Home Assistant kieg\u00e9sz\u00edt\u0151 seg\u00edts\u00e9g\u00e9vel" }, "user": { diff --git a/homeassistant/components/almond/translations/hu.json b/homeassistant/components/almond/translations/hu.json index 27932696561..d75290b4fd1 100644 --- a/homeassistant/components/almond/translations/hu.json +++ b/homeassistant/components/almond/translations/hu.json @@ -8,7 +8,7 @@ }, "step": { "hassio_confirm": { - "description": "Szeretn\u00e9 be\u00e1ll\u00edtani Home Assistant-ot az Almondhoz val\u00f3 csatlakoz\u00e1shoz, {addon} kieg\u00e9sz\u00edt\u0151 \u00e1ltal?", + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani Home Assistantot Almondhoz val\u00f3 csatlakoz\u00e1shoz, {addon} kieg\u00e9sz\u00edt\u0151 \u00e1ltal?", "title": "Almond - Home Assistant kieg\u00e9sz\u00edt\u0151 \u00e1ltal" }, "pick_implementation": { diff --git a/homeassistant/components/ambee/translations/hu.json b/homeassistant/components/ambee/translations/hu.json index 299d97914bc..6cb59bba925 100644 --- a/homeassistant/components/ambee/translations/hu.json +++ b/homeassistant/components/ambee/translations/hu.json @@ -21,7 +21,7 @@ "longitude": "Hossz\u00fas\u00e1g", "name": "N\u00e9v" }, - "description": "\u00c1ll\u00edtsa be Ambee-t Home Assistant-tal val\u00f3 integr\u00e1ci\u00f3hoz." + "description": "Integr\u00e1lja \u00f6ssze Ambeet Home Assistanttal." } } } diff --git a/homeassistant/components/broadlink/translations/hu.json b/homeassistant/components/broadlink/translations/hu.json index 3d792f43597..d3a59a03cea 100644 --- a/homeassistant/components/broadlink/translations/hu.json +++ b/homeassistant/components/broadlink/translations/hu.json @@ -32,7 +32,7 @@ "data": { "unlock": "Igen, csin\u00e1ld." }, - "description": "{name} ({model} a {host} c\u00edmen) z\u00e1rolva van. Ez hiteles\u00edt\u00e9si probl\u00e9m\u00e1khoz vezethet Home Assistant-ban. Szeretn\u00e9 feloldani?", + "description": "{name} ({model} a {host} c\u00edmen) z\u00e1rolva van. Ez hiteles\u00edt\u00e9si probl\u00e9m\u00e1khoz vezethet Home Assistantban. Szeretn\u00e9 feloldani?", "title": "Az eszk\u00f6z felold\u00e1sa (opcion\u00e1lis)" }, "user": { diff --git a/homeassistant/components/brother/translations/hu.json b/homeassistant/components/brother/translations/hu.json index 9d733e4cda6..f0218dc2647 100644 --- a/homeassistant/components/brother/translations/hu.json +++ b/homeassistant/components/brother/translations/hu.json @@ -22,7 +22,7 @@ "data": { "type": "A nyomtat\u00f3 t\u00edpusa" }, - "description": "Hozz\u00e1 szeretn\u00e9 adni a {model} Brother nyomtat\u00f3t, amelynek sorsz\u00e1ma: `{serial_number}`, Home Assistant-hoz?", + "description": "Hozz\u00e1 szeretn\u00e9 adni a {model} Brother nyomtat\u00f3t, amelynek sorsz\u00e1ma: `{serial_number}`, Home Assistanthoz?", "title": "Felfedezett Brother nyomtat\u00f3" } } diff --git a/homeassistant/components/cast/translations/hu.json b/homeassistant/components/cast/translations/hu.json index 2d74d3183c8..a4c8da3242e 100644 --- a/homeassistant/components/cast/translations/hu.json +++ b/homeassistant/components/cast/translations/hu.json @@ -29,7 +29,7 @@ "ignore_cec": "A CEC figyelmen k\u00edv\u00fcl hagy\u00e1sa", "uuid": "Enged\u00e9lyezett UUID-k" }, - "description": "Enged\u00e9lyezett UUID - vessz\u0151vel elv\u00e1lasztott lista a Cast-eszk\u00f6z\u00f6k UUID-j\u00e9b\u0151l, amelyeket hozz\u00e1 lehet adni Home Assistant-hoz. Csak akkor haszn\u00e1lja, ha nem akarja hozz\u00e1adni az \u00f6sszes rendelkez\u00e9sre \u00e1ll\u00f3 cast eszk\u00f6zt.\nCEC figyelmen k\u00edv\u00fcl hagy\u00e1sa - vessz\u0151vel elv\u00e1lasztott Chromecast-lista, amelynek figyelmen k\u00edv\u00fcl kell hagynia a CEC-adatokat az akt\u00edv bemenet meghat\u00e1roz\u00e1s\u00e1hoz. Ezt tov\u00e1bb\u00edtjuk a pychromecast.IGNORE_CEC c\u00edmre.", + "description": "Enged\u00e9lyezett UUID - vessz\u0151vel elv\u00e1lasztott lista a Cast-eszk\u00f6z\u00f6k UUID-j\u00e9b\u0151l, amelyeket hozz\u00e1 lehet adni Home Assistanthoz. Csak akkor haszn\u00e1lja, ha nem akarja hozz\u00e1adni az \u00f6sszes rendelkez\u00e9sre \u00e1ll\u00f3 cast eszk\u00f6zt.\nCEC figyelmen k\u00edv\u00fcl hagy\u00e1sa - vessz\u0151vel elv\u00e1lasztott Chromecast-lista, amelynek figyelmen k\u00edv\u00fcl kell hagynia a CEC-adatokat az akt\u00edv bemenet meghat\u00e1roz\u00e1s\u00e1hoz. Ezt tov\u00e1bb\u00edtjuk a pychromecast.IGNORE_CEC c\u00edmre.", "title": "Speci\u00e1lis Google Cast-konfigur\u00e1ci\u00f3" }, "basic_options": { diff --git a/homeassistant/components/deconz/translations/hu.json b/homeassistant/components/deconz/translations/hu.json index 664f3768a22..a78a6ef1961 100644 --- a/homeassistant/components/deconz/translations/hu.json +++ b/homeassistant/components/deconz/translations/hu.json @@ -14,11 +14,11 @@ "flow_title": "{host}", "step": { "hassio_confirm": { - "description": "Szeretn\u00e9 be\u00e1ll\u00edtani Home Assistant-ot, hogy csatlakozzon {addon} \u00e1ltal biztos\u00edtott deCONZ \u00e1tj\u00e1r\u00f3hoz?", + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani Home Assistantot, hogy csatlakozzon {addon} \u00e1ltal biztos\u00edtott deCONZ \u00e1tj\u00e1r\u00f3hoz?", "title": "deCONZ Zigbee \u00e1tj\u00e1r\u00f3 Home Assistant kieg\u00e9sz\u00edt\u0151 \u00e1ltal" }, "link": { - "description": "Enged\u00e9lyezze fel a deCONZ \u00e1tj\u00e1r\u00f3ban a Home Assistant-hoz val\u00f3 regisztr\u00e1l\u00e1st.\n\n1. V\u00e1lassza ki a deCONZ rendszer be\u00e1ll\u00edt\u00e1sait\n2. Nyomja meg az \"Authenticate app\" gombot", + "description": "Enged\u00e9lyezze fel a deCONZ \u00e1tj\u00e1r\u00f3ban a Home Assistanthoz val\u00f3 regisztr\u00e1l\u00e1st.\n\n1. V\u00e1lassza ki a deCONZ rendszer be\u00e1ll\u00edt\u00e1sait\n2. Nyomja meg az \"Authenticate app\" gombot", "title": "Kapcsol\u00f3d\u00e1s a deCONZ-hoz" }, "manual_input": { diff --git a/homeassistant/components/esphome/translations/hu.json b/homeassistant/components/esphome/translations/hu.json index d7ac503d83c..e65577f055e 100644 --- a/homeassistant/components/esphome/translations/hu.json +++ b/homeassistant/components/esphome/translations/hu.json @@ -20,7 +20,7 @@ "description": "K\u00e9rem, adja meg a konfigur\u00e1ci\u00f3ban {name} n\u00e9vhez be\u00e1ll\u00edtott jelsz\u00f3t." }, "discovery_confirm": { - "description": "Szeretn\u00e9 hozz\u00e1adni `{name}` ESPHome csom\u00f3pontot Home Assistant-hoz?", + "description": "Szeretn\u00e9 hozz\u00e1adni `{name}` ESPHome csom\u00f3pontot Home Assistanthoz?", "title": "Felfedezett ESPHome csom\u00f3pont" }, "encryption_key": { diff --git a/homeassistant/components/flux_led/translations/nl.json b/homeassistant/components/flux_led/translations/nl.json new file mode 100644 index 00000000000..fd9e04bd475 --- /dev/null +++ b/homeassistant/components/flux_led/translations/nl.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd", + "already_in_progress": "De configuratiestroom is al aan de gang", + "no_devices_found": "Geen apparaten gevonden op het netwerk" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken" + }, + "flow_title": "{model} {id} ({ipaddr})", + "step": { + "discovery_confirm": { + "description": "Wilt u {model} {id} ( {ipaddr} ) instellen?" + }, + "user": { + "data": { + "host": "Host" + }, + "description": "Als u de host leeg laat, wordt detectie gebruikt om apparaten te vinden." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "custom_effect_colors": "Aangepast effect: Lijst van 1 tot 16 [R,G,B] kleuren. Voorbeeld: [255,0,255],[60,128,0]", + "custom_effect_speed_pct": "Aangepast effect: snelheid in procenten voor het effect dat van kleur verandert.", + "custom_effect_transition": "Aangepast effect: Type overgang tussen de kleuren.", + "mode": "De gekozen helderheidsstand." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flux_led/translations/no.json b/homeassistant/components/flux_led/translations/no.json new file mode 100644 index 00000000000..ec105c1ac14 --- /dev/null +++ b/homeassistant/components/flux_led/translations/no.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert", + "already_in_progress": "Konfigurasjonsflyten p\u00e5g\u00e5r allerede", + "no_devices_found": "Ingen enheter funnet p\u00e5 nettverket" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes" + }, + "flow_title": "{model} {id} ( {ipaddr} )", + "step": { + "discovery_confirm": { + "description": "Vil du konfigurere {model} {id} ( {ipaddr} )?" + }, + "user": { + "data": { + "host": "Vert" + }, + "description": "Hvis du lar verten st\u00e5 tom, brukes automatisk oppdagelse til \u00e5 finne enheter" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "custom_effect_colors": "Egendefinert effekt: Liste med farger fra 1 til 16 [R,G,B]. Eksempel: [255,0,255],[60,128,0]", + "custom_effect_speed_pct": "Egendefinert effekt: Hastighet i prosent for effekten som bytter farger.", + "custom_effect_transition": "Egendefinert effekt: Overgangstype mellom fargene.", + "mode": "Den valgte lysstyrkemodusen." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flux_led/translations/zh-Hant.json b/homeassistant/components/flux_led/translations/zh-Hant.json new file mode 100644 index 00000000000..4e14b58ff18 --- /dev/null +++ b/homeassistant/components/flux_led/translations/zh-Hant.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "already_in_progress": "\u8a2d\u5b9a\u5df2\u7d93\u9032\u884c\u4e2d", + "no_devices_found": "\u7db2\u8def\u4e0a\u627e\u4e0d\u5230\u88dd\u7f6e" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557" + }, + "flow_title": "{model} {id} ({ipaddr})", + "step": { + "discovery_confirm": { + "description": "\u662f\u5426\u8981\u8a2d\u5b9a {model} {id} ({ipaddr})\uff1f" + }, + "user": { + "data": { + "host": "\u4e3b\u6a5f\u7aef" + }, + "description": "\u5047\u5982\u4e3b\u6a5f\u7aef\u4f4d\u5740\u6b04\u4f4d\u70ba\u7a7a\u767d\uff0c\u5c07\u6703\u63a2\u7d22\u6240\u6709\u53ef\u7528\u88dd\u7f6e\u3002" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "custom_effect_colors": "\u81ea\u8a02\u7279\u6548\uff1a1 \u5230 16 \u7a2e [R,G,B] \u984f\u8272\u3002\u4f8b\u5982\uff1a[255,0,255]\u3001[60,128,0]", + "custom_effect_speed_pct": "\u81ea\u8a02\u7279\u6548\uff1a\u984f\u8272\u5207\u63db\u7684\u901f\u5ea6\u767e\u5206\u6bd4\u3002", + "custom_effect_transition": "\u81ea\u8a02\u7279\u6548\uff1a\u984f\u8272\u9593\u7684\u8f49\u63db\u985e\u578b\u3002", + "mode": "\u9078\u64c7\u4eae\u5ea6\u6a21\u5f0f\u3002" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/translations/hu.json b/homeassistant/components/homekit_controller/translations/hu.json index aef97c7b3ba..7703925ae67 100644 --- a/homeassistant/components/homekit_controller/translations/hu.json +++ b/homeassistant/components/homekit_controller/translations/hu.json @@ -6,7 +6,7 @@ "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van.", "already_paired": "Ez a tartoz\u00e9k m\u00e1r p\u00e1ros\u00edtva van egy m\u00e1sik eszk\u00f6zzel. \u00c1ll\u00edtsa alaphelyzetbe a tartoz\u00e9kot, majd pr\u00f3b\u00e1lkozzon \u00fajra.", "ignored_model": "A HomeKit t\u00e1mogat\u00e1sa e modelln\u00e9l blokkolva van, mivel a szolg\u00e1ltat\u00e1shoz teljes nat\u00edv integr\u00e1ci\u00f3 \u00e9rhet\u0151 el.", - "invalid_config_entry": "Ez az eszk\u00f6z k\u00e9szen \u00e1ll a p\u00e1ros\u00edt\u00e1sra, de m\u00e1r van egy \u00fctk\u00f6z\u0151 konfigur\u00e1ci\u00f3s bejegyz\u00e9s Home Assistant-ben, amelyet el\u0151sz\u00f6r el kell t\u00e1vol\u00edtani.", + "invalid_config_entry": "Ez az eszk\u00f6z k\u00e9szen \u00e1ll a p\u00e1ros\u00edt\u00e1sra, de m\u00e1r van egy \u00fctk\u00f6z\u0151 konfigur\u00e1ci\u00f3s bejegyz\u00e9s Home Assistantban, amelyet el\u0151sz\u00f6r el kell t\u00e1vol\u00edtani.", "invalid_properties": "Az eszk\u00f6z \u00e1ltal bejelentett \u00e9rv\u00e9nytelen tulajdons\u00e1gok.", "no_devices": "Nem tal\u00e1lhat\u00f3 nem p\u00e1ros\u00edtott eszk\u00f6z" }, diff --git a/homeassistant/components/hue/translations/hu.json b/homeassistant/components/hue/translations/hu.json index 2f04c53163f..a114fc2c890 100644 --- a/homeassistant/components/hue/translations/hu.json +++ b/homeassistant/components/hue/translations/hu.json @@ -22,7 +22,7 @@ "title": "V\u00e1lasszon Hue bridge-t" }, "link": { - "description": "Nyomja meg a gombot a bridge-en a Philips Hue Home Assistant-ben val\u00f3 regisztr\u00e1l\u00e1s\u00e1hoz.\n\n![Location of button on bridge](/static/images/config_philips_hue.jpg)", + "description": "Nyomja meg a gombot a bridge-en a Philips Hue Home Assistantban val\u00f3 regisztr\u00e1l\u00e1s\u00e1hoz.\n\n![Gomb helye](/static/images/config_philips_hue.jpg)", "title": "Kapcsol\u00f3d\u00e1s a hubhoz" }, "manual": { diff --git a/homeassistant/components/kodi/translations/hu.json b/homeassistant/components/kodi/translations/hu.json index 017d33010ac..e561bd5d6a4 100644 --- a/homeassistant/components/kodi/translations/hu.json +++ b/homeassistant/components/kodi/translations/hu.json @@ -22,7 +22,7 @@ "description": "Adja meg a Kodi felhaszn\u00e1l\u00f3nevet \u00e9s jelsz\u00f3t. Ezek megtal\u00e1lhat\u00f3k a Rendszer/Be\u00e1ll\u00edt\u00e1sok/H\u00e1l\u00f3zat/Szolg\u00e1ltat\u00e1sok r\u00e9szben." }, "discovery_confirm": { - "description": "Szeretn\u00e9 hozz\u00e1adni a Kodi (`{name}`)-t Home Assistant-hoz?", + "description": "Szeretn\u00e9 hozz\u00e1adni a Kodi (`{name}`)-t Home Assistanthoz?", "title": "Felfedezett Kodi" }, "user": { diff --git a/homeassistant/components/mobile_app/translations/hu.json b/homeassistant/components/mobile_app/translations/hu.json index 1dda8ce7223..a92f84958be 100644 --- a/homeassistant/components/mobile_app/translations/hu.json +++ b/homeassistant/components/mobile_app/translations/hu.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "install_app": "Nyissa meg a mobil alkalmaz\u00e1st Home Assistant-tal val\u00f3 integr\u00e1ci\u00f3hoz. A kompatibilis alkalmaz\u00e1sok list\u00e1j\u00e1nak megtekint\u00e9s\u00e9hez ellen\u0151rizze [a le\u00edr\u00e1st]({apps_url})." + "install_app": "Nyissa meg a mobil alkalmaz\u00e1st Home Assistanttal val\u00f3 integr\u00e1ci\u00f3hoz. A kompatibilis alkalmaz\u00e1sok list\u00e1j\u00e1nak megtekint\u00e9s\u00e9hez ellen\u0151rizze [a le\u00edr\u00e1st]({apps_url})." }, "step": { "confirm": { diff --git a/homeassistant/components/modern_forms/translations/hu.json b/homeassistant/components/modern_forms/translations/hu.json index 5bea7c3054e..49f5da5339f 100644 --- a/homeassistant/components/modern_forms/translations/hu.json +++ b/homeassistant/components/modern_forms/translations/hu.json @@ -16,7 +16,7 @@ "data": { "host": "C\u00edm" }, - "description": "\u00c1ll\u00edtsa be Modern Forms-t, hogy integr\u00e1l\u00f3djon Home Assistant-ba." + "description": "Integr\u00e1lja \u00f6ssze Modern Formst Home Assistanttal." }, "zeroconf_confirm": { "description": "Hozz\u00e1 szeretn\u00e9 adni `{name}`nev\u0171 Modern Forms rajong\u00f3t Home Assistanthoz?", diff --git a/homeassistant/components/motioneye/translations/hu.json b/homeassistant/components/motioneye/translations/hu.json index c381d3954d4..0acc46509a4 100644 --- a/homeassistant/components/motioneye/translations/hu.json +++ b/homeassistant/components/motioneye/translations/hu.json @@ -12,7 +12,7 @@ }, "step": { "hassio_confirm": { - "description": "Szeretn\u00e9 be\u00e1ll\u00edtani Home Assistant-ot, hogy csatlakozzon {addon} kieg\u00e9sz\u00edt\u0151 \u00e1ltal biztos\u00edtott motionEye szolg\u00e1ltat\u00e1shoz?", + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani Home Assistantot motionEyehez val\u00f3 csatlakoz\u00e1shoz, {addon} kieg\u00e9sz\u00edt\u0151 \u00e1ltal?", "title": "motionEye a Home Assistant kieg\u00e9sz\u00edt\u0151 seg\u00edts\u00e9g\u00e9vel" }, "user": { diff --git a/homeassistant/components/mqtt/translations/hu.json b/homeassistant/components/mqtt/translations/hu.json index 471982756eb..9da3c6d9666 100644 --- a/homeassistant/components/mqtt/translations/hu.json +++ b/homeassistant/components/mqtt/translations/hu.json @@ -22,7 +22,7 @@ "data": { "discovery": "Felfedez\u00e9s enged\u00e9lyez\u00e9se" }, - "description": "Szeretn\u00e9 be\u00e1ll\u00edtani Home Assistant-ot MQTT br\u00f3kerhez val\u00f3 csatlakoz\u00e1shoz, {addon} kieg\u00e9sz\u00edt\u0151 \u00e1ltal?", + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani Home Assistantot MQTT br\u00f3kerhez val\u00f3 csatlakoz\u00e1shoz, {addon} kieg\u00e9sz\u00edt\u0151 \u00e1ltal?", "title": "MQTT Br\u00f3ker - Home Assistant kieg\u00e9sz\u00edt\u0151 \u00e1ltal" } } diff --git a/homeassistant/components/roon/translations/hu.json b/homeassistant/components/roon/translations/hu.json index 09bad262c45..7d2b63f0f4b 100644 --- a/homeassistant/components/roon/translations/hu.json +++ b/homeassistant/components/roon/translations/hu.json @@ -9,7 +9,7 @@ }, "step": { "link": { - "description": "Enged\u00e9lyeznie kell az Home Assistant-ot a Roonban. Miut\u00e1n r\u00e1kattintott a K\u00fcld\u00e9s gombra, nyissa meg a Roon Core alkalmaz\u00e1st, nyissa meg a Be\u00e1ll\u00edt\u00e1sokat, \u00e9s enged\u00e9lyezze a Home Assistant funkci\u00f3t a B\u0151v\u00edtm\u00e9nyek lapon.", + "description": "Enged\u00e9lyeznie kell az Home Assistantot a Roonban. Miut\u00e1n r\u00e1kattintott a K\u00fcld\u00e9s gombra, nyissa meg a Roon Core alkalmaz\u00e1st, nyissa meg a Be\u00e1ll\u00edt\u00e1sokat, \u00e9s enged\u00e9lyezze a Home Assistant funkci\u00f3t a B\u0151v\u00edtm\u00e9nyek lapon.", "title": "Enged\u00e9lyezze a Home Assistant alkalmaz\u00e1st Roon-ban" }, "user": { diff --git a/homeassistant/components/samsungtv/translations/hu.json b/homeassistant/components/samsungtv/translations/hu.json index 93c0b2bee6d..efdf5f4810b 100644 --- a/homeassistant/components/samsungtv/translations/hu.json +++ b/homeassistant/components/samsungtv/translations/hu.json @@ -17,7 +17,7 @@ "flow_title": "{device}", "step": { "confirm": { - "description": "Szeretn\u00e9 be\u00e1ll\u00edtani {device} k\u00e9sz\u00fcl\u00e9k\u00e9t? Ha kor\u00e1bban m\u00e9g sosem csatlakoztatta Home Assistant-hoz, akkor meg kell jelennie egy felugr\u00f3 ablaknak a TV k\u00e9perny\u0151j\u00e9n, ami j\u00f3v\u00e1hagy\u00e1sra v\u00e1r.", + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani {device} k\u00e9sz\u00fcl\u00e9k\u00e9t? Ha kor\u00e1bban m\u00e9g sosem csatlakoztatta Home Assistanthoz, akkor meg kell jelennie egy felugr\u00f3 ablaknak a TV k\u00e9perny\u0151j\u00e9n, ami j\u00f3v\u00e1hagy\u00e1sra v\u00e1r.", "title": "Samsung TV" }, "reauth_confirm": { @@ -28,7 +28,7 @@ "host": "C\u00edm", "name": "N\u00e9v" }, - "description": "\u00cdrd be a Samsung TV adatait. Ha m\u00e9g soha nem csatlakozott Home Assistant-hez, akkor meg kell jelennie egy felugr\u00f3 ablaknak a TV k\u00e9perny\u0151j\u00e9n, ahol hiteles\u00edt\u00e9st k\u00e9r." + "description": "\u00cdrja be a Samsung TV adatait. Ha m\u00e9g soha nem csatlakozott Home Assistanthoz, akkor meg kell jelennie egy felugr\u00f3 ablaknak a TV k\u00e9perny\u0151j\u00e9n, ahol meg kell adni az enged\u00e9lyt." } } } diff --git a/homeassistant/components/smappee/translations/hu.json b/homeassistant/components/smappee/translations/hu.json index 5b4a83a74b0..9c3d90ac43e 100644 --- a/homeassistant/components/smappee/translations/hu.json +++ b/homeassistant/components/smappee/translations/hu.json @@ -15,7 +15,7 @@ "data": { "environment": "K\u00f6rnyezet" }, - "description": "\u00c1ll\u00edtsa be a Smappee k\u00e9sz\u00fcl\u00e9ket az HomeAssistant-al val\u00f3 integr\u00e1ci\u00f3hoz." + "description": "Integr\u00e1lja \u00f6ssze Smappee k\u00e9sz\u00fcl\u00e9ket HomeAssistanttal." }, "local": { "data": { diff --git a/homeassistant/components/tuya/translations/en.json b/homeassistant/components/tuya/translations/en.json index a3630e66406..4b4b9a6d1dd 100644 --- a/homeassistant/components/tuya/translations/en.json +++ b/homeassistant/components/tuya/translations/en.json @@ -1,19 +1,82 @@ { "config": { + "abort": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "single_instance_allowed": "Already configured. Only a single configuration possible." + }, "error": { "invalid_auth": "Invalid authentication", "login_error": "Login error ({code}): {msg}" }, + "flow_title": "Tuya configuration", "step": { + "login": { + "data": { + "access_id": "Access ID", + "access_secret": "Access Secret", + "country_code": "Country Code", + "endpoint": "Availability Zone", + "password": "Password", + "tuya_app_type": "Mobile App", + "username": "Account" + }, + "description": "Enter your Tuya credential", + "title": "Tuya" + }, "user": { "data": { "access_id": "Tuya IoT Access ID", "access_secret": "Tuya IoT Access Secret", + "country_code": "Your account country code (e.g., 1 for USA or 86 for China)", "password": "Password", + "platform": "The app where your account is registered", "region": "Region", + "tuya_project_type": "Tuya cloud project type", "username": "Account" }, - "description": "Enter your Tuya credentials" + "description": "Enter your Tuya credentials", + "title": "Tuya Integration" + } + } + }, + "options": { + "abort": { + "cannot_connect": "Failed to connect" + }, + "error": { + "dev_multi_type": "Multiple selected devices to configure must be of the same type", + "dev_not_config": "Device type not configurable", + "dev_not_found": "Device not found" + }, + "step": { + "device": { + "data": { + "brightness_range_mode": "Brightness range used by device", + "curr_temp_divider": "Current Temperature value divider (0 = use default)", + "max_kelvin": "Max color temperature supported in kelvin", + "max_temp": "Max target temperature (use min and max = 0 for default)", + "min_kelvin": "Min color temperature supported in kelvin", + "min_temp": "Min target temperature (use min and max = 0 for default)", + "set_temp_divided": "Use divided Temperature value for set temperature command", + "support_color": "Force color support", + "temp_divider": "Temperature values divider (0 = use default)", + "temp_step_override": "Target Temperature step", + "tuya_max_coltemp": "Max color temperature reported by device", + "unit_of_measurement": "Temperature unit used by device" + }, + "description": "Configure options to adjust displayed information for {device_type} device `{device_name}`", + "title": "Configure Tuya Device" + }, + "init": { + "data": { + "discovery_interval": "Discovery device polling interval in seconds", + "list_devices": "Select the devices to configure or leave empty to save configuration", + "query_device": "Select device that will use query method for faster status update", + "query_interval": "Query device polling interval in seconds" + }, + "description": "Do not set pollings interval values too low or the calls will fail generating error message in the log", + "title": "Configure Tuya Options" } } } diff --git a/homeassistant/components/tuya/translations/nl.json b/homeassistant/components/tuya/translations/nl.json index be405db1a08..22a63800fd6 100644 --- a/homeassistant/components/tuya/translations/nl.json +++ b/homeassistant/components/tuya/translations/nl.json @@ -13,6 +13,7 @@ "login": { "data": { "access_id": "Toegangs-ID", + "access_secret": "Access Secret", "country_code": "Landcode", "endpoint": "Beschikbaarheidszone", "password": "Wachtwoord", diff --git a/homeassistant/components/tuya/translations/no.json b/homeassistant/components/tuya/translations/no.json index eedf24be696..b5fe4bc1851 100644 --- a/homeassistant/components/tuya/translations/no.json +++ b/homeassistant/components/tuya/translations/no.json @@ -10,15 +10,29 @@ }, "flow_title": "Tuya konfigurasjon", "step": { + "login": { + "data": { + "access_id": "Tilgangs -ID", + "access_secret": "Tilgangshemmelighet", + "country_code": "Landskode", + "endpoint": "Tilgjengelighetssone", + "password": "Passord", + "tuya_app_type": "Mobilapp", + "username": "Konto" + }, + "description": "Skriv inn Tuya-legitimasjonen din", + "title": "Tuya" + }, "user": { "data": { "country_code": "Din landskode for kontoen din (f.eks. 1 for USA eller 86 for Kina)", "password": "Passord", "platform": "Appen der kontoen din er registrert", + "tuya_project_type": "Tuya -skyprosjekttype", "username": "Brukernavn" }, "description": "Angi Tuya-legitimasjonen din.", - "title": "" + "title": "Tuya Integrasjon" } } }, diff --git a/homeassistant/components/vera/translations/hu.json b/homeassistant/components/vera/translations/hu.json index 4e9639b0258..d1d4910c97a 100644 --- a/homeassistant/components/vera/translations/hu.json +++ b/homeassistant/components/vera/translations/hu.json @@ -6,7 +6,7 @@ "step": { "user": { "data": { - "exclude": "Vera eszk\u00f6zazonos\u00edt\u00f3k kiz\u00e1r\u00e1sa Home Assistant-b\u00f3l.", + "exclude": "Vera eszk\u00f6zazonos\u00edt\u00f3k kiz\u00e1r\u00e1sa Home Assistantb\u00f3l.", "lights": "A Vera kapcsol\u00f3eszk\u00f6z-azonos\u00edt\u00f3k f\u00e9nyk\u00e9nt kezelhet\u0151k a Home Assistant alkalmaz\u00e1sban.", "vera_controller_url": "Vez\u00e9rl\u0151 URL" }, @@ -19,7 +19,7 @@ "step": { "init": { "data": { - "exclude": "Vera eszk\u00f6zazonos\u00edt\u00f3k kiz\u00e1r\u00e1sa Home Assistant-b\u00f3l.", + "exclude": "Vera eszk\u00f6zazonos\u00edt\u00f3k kiz\u00e1r\u00e1sa Home Assistantb\u00f3l.", "lights": "A Vera kapcsol\u00f3eszk\u00f6z-azonos\u00edt\u00f3k f\u00e9nyk\u00e9nt kezelhet\u0151k a Home Assistant alkalmaz\u00e1sban." }, "description": "Az opcion\u00e1lis param\u00e9terekr\u0151l a vera dokument\u00e1ci\u00f3j\u00e1ban olvashat: https://www.home-assistant.io/integrations/vera/. Megjegyz\u00e9s: Az itt v\u00e9grehajtott v\u00e1ltoztat\u00e1sokhoz \u00fajra kell ind\u00edtani a h\u00e1zi asszisztens szervert. Az \u00e9rt\u00e9kek t\u00f6rl\u00e9s\u00e9hez adjon meg egy sz\u00f3k\u00f6zt.", diff --git a/homeassistant/components/vizio/translations/hu.json b/homeassistant/components/vizio/translations/hu.json index 3708bfbc379..bb619e359c0 100644 --- a/homeassistant/components/vizio/translations/hu.json +++ b/homeassistant/components/vizio/translations/hu.json @@ -19,7 +19,7 @@ "title": "V\u00e9gezze el a p\u00e1ros\u00edt\u00e1si folyamatot" }, "pairing_complete": { - "description": "A VIZIO SmartCast Eszk\u00f6z mostant\u00f3l csatlakozik Home Assistant-hoz.", + "description": "A VIZIO SmartCast Eszk\u00f6z mostant\u00f3l csatlakozik Home Assistanthoz.", "title": "P\u00e1ros\u00edt\u00e1s k\u00e9sz" }, "pairing_complete_import": { diff --git a/homeassistant/components/volumio/translations/hu.json b/homeassistant/components/volumio/translations/hu.json index 209a892af3d..b504275e03f 100644 --- a/homeassistant/components/volumio/translations/hu.json +++ b/homeassistant/components/volumio/translations/hu.json @@ -10,7 +10,7 @@ }, "step": { "discovery_confirm": { - "description": "Szeretn\u00e9 hozz\u00e1adni a Volumio (`{name}`)-t a Home Assistant-hoz?", + "description": "Szeretn\u00e9 hozz\u00e1adni a Volumio (`{name}`)-t a Home Assistanthoz?", "title": "Felfedezett Volumio" }, "user": { diff --git a/homeassistant/components/wled/translations/hu.json b/homeassistant/components/wled/translations/hu.json index 2e0ac08d3cb..1fa29cfee48 100644 --- a/homeassistant/components/wled/translations/hu.json +++ b/homeassistant/components/wled/translations/hu.json @@ -13,10 +13,10 @@ "data": { "host": "C\u00edm" }, - "description": "\u00c1ll\u00edtsa be a WLED-et Home Assistant-ba val\u00f3 integr\u00e1l\u00e1shoz." + "description": "\u00c1ll\u00edtsa be a WLED-et Home Assistantba val\u00f3 integr\u00e1l\u00e1shoz." }, "zeroconf_confirm": { - "description": "Szeretn\u00e9 hozz\u00e1adni a(z) `{name}` WLED-et Home Assistant-hoz?", + "description": "Szeretn\u00e9 hozz\u00e1adni a(z) `{name}` WLED-et Home Assistanthoz?", "title": "Felfedezett WLED eszk\u00f6z" } } diff --git a/homeassistant/components/zwave_js/translations/nl.json b/homeassistant/components/zwave_js/translations/nl.json index b7a3a68fe6b..76718aa5346 100644 --- a/homeassistant/components/zwave_js/translations/nl.json +++ b/homeassistant/components/zwave_js/translations/nl.json @@ -27,6 +27,10 @@ "configure_addon": { "data": { "network_key": "Netwerksleutel", + "s0_legacy_key": "S0 Sleutel (Legacy)", + "s2_access_control_key": "S2 Toegangscontrolesleutel", + "s2_authenticated_key": "S2 geverifieerde sleutel", + "s2_unauthenticated_key": "S2 niet-geverifieerde sleutel", "usb_path": "USB-apparaatpad" }, "title": "Voer de Z-Wave JS add-on configuratie in" @@ -109,6 +113,10 @@ "emulate_hardware": "Emulate Hardware", "log_level": "Log level", "network_key": "Netwerksleutel", + "s0_legacy_key": "S0 Sleutel (Legacy)", + "s2_access_control_key": "S2 Toegangscontrolesleutel", + "s2_authenticated_key": "S2 geverifieerde sleutel", + "s2_unauthenticated_key": "S2 niet-geverifieerde sleutel", "usb_path": "USB-apparaatpad" }, "title": "Voer de configuratie van de Z-Wave JS-add-on in" diff --git a/homeassistant/components/zwave_js/translations/no.json b/homeassistant/components/zwave_js/translations/no.json index 9ddf12a3b85..f08f5bd07cb 100644 --- a/homeassistant/components/zwave_js/translations/no.json +++ b/homeassistant/components/zwave_js/translations/no.json @@ -27,6 +27,10 @@ "configure_addon": { "data": { "network_key": "Nettverksn\u00f8kkel", + "s0_legacy_key": "S0-n\u00f8kkel (eldre)", + "s2_access_control_key": "N\u00f8kkel for S2-tilgangskontroll", + "s2_authenticated_key": "S2 Autentisert n\u00f8kkel", + "s2_unauthenticated_key": "S2 Uautentisert n\u00f8kkel", "usb_path": "USB enhetsbane" }, "title": "Angi konfigurasjon for Z-Wave JS-tillegg" @@ -109,6 +113,10 @@ "emulate_hardware": "Emuler maskinvare", "log_level": "Loggniv\u00e5", "network_key": "Nettverksn\u00f8kkel", + "s0_legacy_key": "S0-n\u00f8kkel (eldre)", + "s2_access_control_key": "N\u00f8kkel for S2-tilgangskontroll", + "s2_authenticated_key": "S2 Autentisert n\u00f8kkel", + "s2_unauthenticated_key": "S2 Uautentisert n\u00f8kkel", "usb_path": "USB enhetsbane" }, "title": "Angi konfigurasjon for Z-Wave JS-tillegg" From f82fe9d8bba09375683908a8cab72a675aeadb91 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 4 Oct 2021 18:47:44 +0200 Subject: [PATCH 810/843] Improve sensor statistics validation (#56892) --- homeassistant/components/sensor/recorder.py | 59 ++- .../components/recorder/test_websocket_api.py | 202 +------- tests/components/sensor/test_recorder.py | 437 +++++++++++++++++- 3 files changed, 486 insertions(+), 212 deletions(-) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index fd6cf5e0f2f..c485622af80 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -637,35 +637,70 @@ def validate_statistics( """Validate statistics.""" validation_result = defaultdict(list) - sensor_states = _get_sensor_states(hass) + sensor_states = hass.states.all(DOMAIN) + metadatas = statistics.get_metadata(hass, [i.entity_id for i in sensor_states]) for state in sensor_states: entity_id = state.entity_id device_class = state.attributes.get(ATTR_DEVICE_CLASS) + state_class = state.attributes.get(ATTR_STATE_CLASS) state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - if device_class not in UNIT_CONVERSIONS: - metadata = statistics.get_metadata(hass, (entity_id,)) - if not metadata: - continue - metadata_unit = metadata[entity_id][1]["unit_of_measurement"] - if state_unit != metadata_unit: + if metadata := metadatas.get(entity_id): + if not is_entity_recorded(hass, state.entity_id): + # Sensor was previously recorded, but no longer is validation_result[entity_id].append( statistics.ValidationIssue( - "units_changed", + "entity_not_recorded", + {"statistic_id": entity_id}, + ) + ) + + if state_class not in STATE_CLASSES: + # Sensor no longer has a valid state class + validation_result[entity_id].append( + statistics.ValidationIssue( + "unsupported_state_class", + {"statistic_id": entity_id, "state_class": state_class}, + ) + ) + + metadata_unit = metadata[1]["unit_of_measurement"] + if device_class not in UNIT_CONVERSIONS: + if state_unit != metadata_unit: + # The unit has changed + validation_result[entity_id].append( + statistics.ValidationIssue( + "units_changed", + { + "statistic_id": entity_id, + "state_unit": state_unit, + "metadata_unit": metadata_unit, + }, + ) + ) + elif metadata_unit != DEVICE_CLASS_UNITS[device_class]: + # The unit in metadata is not supported for this device class + validation_result[entity_id].append( + statistics.ValidationIssue( + "unsupported_unit_metadata", { "statistic_id": entity_id, - "state_unit": state_unit, + "device_class": device_class, "metadata_unit": metadata_unit, + "supported_unit": DEVICE_CLASS_UNITS[device_class], }, ) ) - continue - if state_unit not in UNIT_CONVERSIONS[device_class]: + if ( + device_class in UNIT_CONVERSIONS + and state_unit not in UNIT_CONVERSIONS[device_class] + ): + # The unit in the state is not supported for this device class validation_result[entity_id].append( statistics.ValidationIssue( - "unsupported_unit", + "unsupported_unit_state", { "statistic_id": entity_id, "device_class": device_class, diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index 4f54a43ca6e..e60659aaab2 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -6,34 +6,19 @@ import pytest from pytest import approx from homeassistant.components.recorder.const import DATA_INSTANCE -from homeassistant.components.recorder.models import StatisticsMeta -from homeassistant.components.recorder.util import session_scope from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM +from homeassistant.util.unit_system import METRIC_SYSTEM from .common import trigger_db_commit from tests.common import init_recorder_component -BATTERY_SENSOR_ATTRIBUTES = { - "device_class": "battery", - "state_class": "measurement", - "unit_of_measurement": "%", -} POWER_SENSOR_ATTRIBUTES = { "device_class": "power", "state_class": "measurement", "unit_of_measurement": "kW", } -NONE_SENSOR_ATTRIBUTES = { - "state_class": "measurement", -} -PRESSURE_SENSOR_ATTRIBUTES = { - "device_class": "pressure", - "state_class": "measurement", - "unit_of_measurement": "hPa", -} TEMPERATURE_SENSOR_ATTRIBUTES = { "device_class": "temperature", "state_class": "measurement", @@ -41,21 +26,8 @@ TEMPERATURE_SENSOR_ATTRIBUTES = { } -@pytest.mark.parametrize( - "units, attributes, unit", - [ - (IMPERIAL_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W"), - (METRIC_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W"), - (IMPERIAL_SYSTEM, TEMPERATURE_SENSOR_ATTRIBUTES, "°F"), - (METRIC_SYSTEM, TEMPERATURE_SENSOR_ATTRIBUTES, "°C"), - (IMPERIAL_SYSTEM, PRESSURE_SENSOR_ATTRIBUTES, "psi"), - (METRIC_SYSTEM, PRESSURE_SENSOR_ATTRIBUTES, "Pa"), - ], -) -async def test_validate_statistics_supported_device_class( - hass, hass_ws_client, units, attributes, unit -): - """Test list_statistic_ids.""" +async def test_validate_statistics(hass, hass_ws_client): + """Test validate_statistics can be called.""" id = 1 def next_id(): @@ -71,177 +43,9 @@ async def test_validate_statistics_supported_device_class( assert response["success"] assert response["result"] == expected_result - now = dt_util.utcnow() - - hass.config.units = units - await hass.async_add_executor_job(init_recorder_component, hass) - await async_setup_component(hass, "sensor", {}) - await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) - client = await hass_ws_client() - # No statistics, no state - empty response - await assert_validation_result(client, {}) - - # No statistics, valid state - empty response - hass.states.async_set( - "sensor.test", 10, attributes={**attributes, **{"unit_of_measurement": unit}} - ) - await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) - await assert_validation_result(client, {}) - - # No statistics, invalid state - expect error - hass.states.async_set( - "sensor.test", 11, attributes={**attributes, **{"unit_of_measurement": "dogs"}} - ) - await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) - expected = { - "sensor.test": [ - { - "data": { - "device_class": attributes["device_class"], - "state_unit": "dogs", - "statistic_id": "sensor.test", - }, - "type": "unsupported_unit", - } - ], - } - await assert_validation_result(client, expected) - - # Statistics has run, invalid state - expect error - await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) - hass.data[DATA_INSTANCE].do_adhoc_statistics(start=now) - hass.states.async_set( - "sensor.test", 12, attributes={**attributes, **{"unit_of_measurement": "dogs"}} - ) - await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) - await assert_validation_result(client, expected) - - # Valid state - empty response - hass.states.async_set( - "sensor.test", 13, attributes={**attributes, **{"unit_of_measurement": unit}} - ) - await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) - await assert_validation_result(client, {}) - - # Valid state, statistic runs again - empty response - hass.data[DATA_INSTANCE].do_adhoc_statistics(start=now) - await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) - await assert_validation_result(client, {}) - - # Remove the state - empty response - hass.states.async_remove("sensor.test") - await assert_validation_result(client, {}) - - -@pytest.mark.parametrize( - "attributes", - [BATTERY_SENSOR_ATTRIBUTES, NONE_SENSOR_ATTRIBUTES], -) -async def test_validate_statistics_unsupported_device_class( - hass, hass_ws_client, attributes -): - """Test list_statistic_ids.""" - id = 1 - - def next_id(): - nonlocal id - id += 1 - return id - - async def assert_validation_result(client, expected_result): - await client.send_json( - {"id": next_id(), "type": "recorder/validate_statistics"} - ) - response = await client.receive_json() - assert response["success"] - assert response["result"] == expected_result - - async def assert_statistic_ids(expected_result): - with session_scope(hass=hass) as session: - db_states = list(session.query(StatisticsMeta)) - assert len(db_states) == len(expected_result) - for i in range(len(db_states)): - assert db_states[i].statistic_id == expected_result[i]["statistic_id"] - assert ( - db_states[i].unit_of_measurement - == expected_result[i]["unit_of_measurement"] - ) - - now = dt_util.utcnow() - await hass.async_add_executor_job(init_recorder_component, hass) - await async_setup_component(hass, "sensor", {}) - await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) client = await hass_ws_client() - rec = hass.data[DATA_INSTANCE] - - # No statistics, no state - empty response - await assert_validation_result(client, {}) - - # No statistics, original unit - empty response - hass.states.async_set("sensor.test", 10, attributes=attributes) - await assert_validation_result(client, {}) - - # No statistics, changed unit - empty response - hass.states.async_set( - "sensor.test", 11, attributes={**attributes, **{"unit_of_measurement": "dogs"}} - ) - await assert_validation_result(client, {}) - - # Run statistics, no statistics will be generated because of conflicting units - await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) - rec.do_adhoc_statistics(start=now) - await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) - await assert_statistic_ids([]) - - # No statistics, changed unit - empty response - hass.states.async_set( - "sensor.test", 12, attributes={**attributes, **{"unit_of_measurement": "dogs"}} - ) - await assert_validation_result(client, {}) - - # Run statistics one hour later, only the "dogs" state will be considered - await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) - rec.do_adhoc_statistics(start=now + timedelta(hours=1)) - await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) - await assert_statistic_ids( - [{"statistic_id": "sensor.test", "unit_of_measurement": "dogs"}] - ) - await assert_validation_result(client, {}) - - # Change back to original unit - expect error - hass.states.async_set("sensor.test", 13, attributes=attributes) - await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) - expected = { - "sensor.test": [ - { - "data": { - "metadata_unit": "dogs", - "state_unit": attributes.get("unit_of_measurement"), - "statistic_id": "sensor.test", - }, - "type": "units_changed", - } - ], - } - await assert_validation_result(client, expected) - - # Changed unit - empty response - hass.states.async_set( - "sensor.test", 14, attributes={**attributes, **{"unit_of_measurement": "dogs"}} - ) - await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) - await assert_validation_result(client, {}) - - # Valid state, statistic runs again - empty response - await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) - hass.data[DATA_INSTANCE].do_adhoc_statistics(start=now) - await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) - await assert_validation_result(client, {}) - - # Remove the state - empty response - hass.states.async_remove("sensor.test") await assert_validation_result(client, {}) diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index f13dc5084bb..9ae4b467da5 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -11,24 +11,37 @@ from pytest import approx from homeassistant import loader from homeassistant.components.recorder import history from homeassistant.components.recorder.const import DATA_INSTANCE -from homeassistant.components.recorder.models import process_timestamp_to_utc_isoformat +from homeassistant.components.recorder.models import ( + StatisticsMeta, + process_timestamp_to_utc_isoformat, +) from homeassistant.components.recorder.statistics import ( get_metadata, list_statistic_ids, statistics_during_period, ) +from homeassistant.components.recorder.util import session_scope from homeassistant.const import STATE_UNAVAILABLE from homeassistant.setup import setup_component import homeassistant.util.dt as dt_util from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM +from tests.common import async_setup_component, init_recorder_component from tests.components.recorder.common import wait_recording_done +BATTERY_SENSOR_ATTRIBUTES = { + "device_class": "battery", + "state_class": "measurement", + "unit_of_measurement": "%", +} ENERGY_SENSOR_ATTRIBUTES = { "device_class": "energy", "state_class": "measurement", "unit_of_measurement": "kWh", } +NONE_SENSOR_ATTRIBUTES = { + "state_class": "measurement", +} POWER_SENSOR_ATTRIBUTES = { "device_class": "power", "state_class": "measurement", @@ -2080,6 +2093,428 @@ def record_states(hass, zero, entity_id, attributes, seq=None): return four, states +@pytest.mark.parametrize( + "units, attributes, unit", + [ + (IMPERIAL_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W"), + (METRIC_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W"), + (IMPERIAL_SYSTEM, TEMPERATURE_SENSOR_ATTRIBUTES, "°F"), + (METRIC_SYSTEM, TEMPERATURE_SENSOR_ATTRIBUTES, "°C"), + (IMPERIAL_SYSTEM, PRESSURE_SENSOR_ATTRIBUTES, "psi"), + (METRIC_SYSTEM, PRESSURE_SENSOR_ATTRIBUTES, "Pa"), + ], +) +async def test_validate_statistics_supported_device_class( + hass, hass_ws_client, units, attributes, unit +): + """Test validate_statistics.""" + id = 1 + + def next_id(): + nonlocal id + id += 1 + return id + + async def assert_validation_result(client, expected_result): + await client.send_json( + {"id": next_id(), "type": "recorder/validate_statistics"} + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == expected_result + + now = dt_util.utcnow() + + hass.config.units = units + await hass.async_add_executor_job(init_recorder_component, hass) + await async_setup_component(hass, "sensor", {}) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + client = await hass_ws_client() + + # No statistics, no state - empty response + await assert_validation_result(client, {}) + + # No statistics, valid state - empty response + hass.states.async_set( + "sensor.test", 10, attributes={**attributes, **{"unit_of_measurement": unit}} + ) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + await assert_validation_result(client, {}) + + # No statistics, invalid state - expect error + hass.states.async_set( + "sensor.test", 11, attributes={**attributes, **{"unit_of_measurement": "dogs"}} + ) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + expected = { + "sensor.test": [ + { + "data": { + "device_class": attributes["device_class"], + "state_unit": "dogs", + "statistic_id": "sensor.test", + }, + "type": "unsupported_unit_state", + } + ], + } + await assert_validation_result(client, expected) + + # Statistics has run, invalid state - expect error + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + hass.data[DATA_INSTANCE].do_adhoc_statistics(start=now) + hass.states.async_set( + "sensor.test", 12, attributes={**attributes, **{"unit_of_measurement": "dogs"}} + ) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + await assert_validation_result(client, expected) + + # Valid state - empty response + hass.states.async_set( + "sensor.test", 13, attributes={**attributes, **{"unit_of_measurement": unit}} + ) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + await assert_validation_result(client, {}) + + # Valid state, statistic runs again - empty response + hass.data[DATA_INSTANCE].do_adhoc_statistics(start=now) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + await assert_validation_result(client, {}) + + # Remove the state - empty response + hass.states.async_remove("sensor.test") + await assert_validation_result(client, {}) + + +@pytest.mark.parametrize( + "units, attributes, unit", + [ + (IMPERIAL_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W"), + ], +) +async def test_validate_statistics_supported_device_class_2( + hass, hass_ws_client, units, attributes, unit +): + """Test validate_statistics.""" + id = 1 + + def next_id(): + nonlocal id + id += 1 + return id + + async def assert_validation_result(client, expected_result): + await client.send_json( + {"id": next_id(), "type": "recorder/validate_statistics"} + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == expected_result + + now = dt_util.utcnow() + + hass.config.units = units + await hass.async_add_executor_job(init_recorder_component, hass) + await async_setup_component(hass, "sensor", {}) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + client = await hass_ws_client() + + # No statistics, no state - empty response + await assert_validation_result(client, {}) + + # No statistics, valid state - empty response + initial_attributes = {"state_class": "measurement"} + hass.states.async_set("sensor.test", 10, attributes=initial_attributes) + await hass.async_block_till_done() + await assert_validation_result(client, {}) + + # Statistics has run, device class set - expect error + hass.data[DATA_INSTANCE].do_adhoc_statistics(start=now) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + hass.states.async_set("sensor.test", 12, attributes=attributes) + await hass.async_block_till_done() + expected = { + "sensor.test": [ + { + "data": { + "device_class": attributes["device_class"], + "metadata_unit": None, + "statistic_id": "sensor.test", + "supported_unit": unit, + }, + "type": "unsupported_unit_metadata", + } + ], + } + await assert_validation_result(client, expected) + + # Invalid state too, expect double errors + hass.states.async_set( + "sensor.test", 13, attributes={**attributes, **{"unit_of_measurement": "dogs"}} + ) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + expected = { + "sensor.test": [ + { + "data": { + "device_class": attributes["device_class"], + "metadata_unit": None, + "statistic_id": "sensor.test", + "supported_unit": unit, + }, + "type": "unsupported_unit_metadata", + }, + { + "data": { + "device_class": attributes["device_class"], + "state_unit": "dogs", + "statistic_id": "sensor.test", + }, + "type": "unsupported_unit_state", + }, + ], + } + await assert_validation_result(client, expected) + + +@pytest.mark.parametrize( + "units, attributes, unit", + [ + (IMPERIAL_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W"), + ], +) +async def test_validate_statistics_unsupported_state_class( + hass, hass_ws_client, units, attributes, unit +): + """Test validate_statistics.""" + id = 1 + + def next_id(): + nonlocal id + id += 1 + return id + + async def assert_validation_result(client, expected_result): + await client.send_json( + {"id": next_id(), "type": "recorder/validate_statistics"} + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == expected_result + + now = dt_util.utcnow() + + hass.config.units = units + await hass.async_add_executor_job(init_recorder_component, hass) + await async_setup_component(hass, "sensor", {}) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + client = await hass_ws_client() + + # No statistics, no state - empty response + await assert_validation_result(client, {}) + + # No statistics, valid state - empty response + hass.states.async_set("sensor.test", 10, attributes=attributes) + await hass.async_block_till_done() + await assert_validation_result(client, {}) + + # Statistics has run, empty response + hass.data[DATA_INSTANCE].do_adhoc_statistics(start=now) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + await assert_validation_result(client, {}) + + # State update with invalid state class, expect error + _attributes = dict(attributes) + _attributes.pop("state_class") + hass.states.async_set("sensor.test", 12, attributes=_attributes) + await hass.async_block_till_done() + expected = { + "sensor.test": [ + { + "data": { + "state_class": None, + "statistic_id": "sensor.test", + }, + "type": "unsupported_state_class", + } + ], + } + await assert_validation_result(client, expected) + + +@pytest.mark.parametrize( + "units, attributes, unit", + [ + (IMPERIAL_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W"), + ], +) +async def test_validate_statistics_sensor_not_recorded( + hass, hass_ws_client, units, attributes, unit +): + """Test validate_statistics.""" + id = 1 + + def next_id(): + nonlocal id + id += 1 + return id + + async def assert_validation_result(client, expected_result): + await client.send_json( + {"id": next_id(), "type": "recorder/validate_statistics"} + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == expected_result + + now = dt_util.utcnow() + + hass.config.units = units + await hass.async_add_executor_job(init_recorder_component, hass) + await async_setup_component(hass, "sensor", {}) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + client = await hass_ws_client() + + # No statistics, no state - empty response + await assert_validation_result(client, {}) + + # No statistics, valid state - empty response + hass.states.async_set("sensor.test", 10, attributes=attributes) + await hass.async_block_till_done() + await assert_validation_result(client, {}) + + # Statistics has run, empty response + hass.data[DATA_INSTANCE].do_adhoc_statistics(start=now) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + await assert_validation_result(client, {}) + + # Sensor no longer recorded, expect error + expected = { + "sensor.test": [ + { + "data": {"statistic_id": "sensor.test"}, + "type": "entity_not_recorded", + } + ], + } + with patch( + "homeassistant.components.sensor.recorder.is_entity_recorded", + return_value=False, + ): + await assert_validation_result(client, expected) + + +@pytest.mark.parametrize( + "attributes", + [BATTERY_SENSOR_ATTRIBUTES, NONE_SENSOR_ATTRIBUTES], +) +async def test_validate_statistics_unsupported_device_class( + hass, hass_ws_client, attributes +): + """Test validate_statistics.""" + id = 1 + + def next_id(): + nonlocal id + id += 1 + return id + + async def assert_validation_result(client, expected_result): + await client.send_json( + {"id": next_id(), "type": "recorder/validate_statistics"} + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == expected_result + + async def assert_statistic_ids(expected_result): + with session_scope(hass=hass) as session: + db_states = list(session.query(StatisticsMeta)) + assert len(db_states) == len(expected_result) + for i in range(len(db_states)): + assert db_states[i].statistic_id == expected_result[i]["statistic_id"] + assert ( + db_states[i].unit_of_measurement + == expected_result[i]["unit_of_measurement"] + ) + + now = dt_util.utcnow() + + await hass.async_add_executor_job(init_recorder_component, hass) + await async_setup_component(hass, "sensor", {}) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + client = await hass_ws_client() + rec = hass.data[DATA_INSTANCE] + + # No statistics, no state - empty response + await assert_validation_result(client, {}) + + # No statistics, original unit - empty response + hass.states.async_set("sensor.test", 10, attributes=attributes) + await assert_validation_result(client, {}) + + # No statistics, changed unit - empty response + hass.states.async_set( + "sensor.test", 11, attributes={**attributes, **{"unit_of_measurement": "dogs"}} + ) + await assert_validation_result(client, {}) + + # Run statistics, no statistics will be generated because of conflicting units + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + rec.do_adhoc_statistics(start=now) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + await assert_statistic_ids([]) + + # No statistics, changed unit - empty response + hass.states.async_set( + "sensor.test", 12, attributes={**attributes, **{"unit_of_measurement": "dogs"}} + ) + await assert_validation_result(client, {}) + + # Run statistics one hour later, only the "dogs" state will be considered + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + rec.do_adhoc_statistics(start=now + timedelta(hours=1)) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + await assert_statistic_ids( + [{"statistic_id": "sensor.test", "unit_of_measurement": "dogs"}] + ) + await assert_validation_result(client, {}) + + # Change back to original unit - expect error + hass.states.async_set("sensor.test", 13, attributes=attributes) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + expected = { + "sensor.test": [ + { + "data": { + "metadata_unit": "dogs", + "state_unit": attributes.get("unit_of_measurement"), + "statistic_id": "sensor.test", + }, + "type": "units_changed", + } + ], + } + await assert_validation_result(client, expected) + + # Changed unit - empty response + hass.states.async_set( + "sensor.test", 14, attributes={**attributes, **{"unit_of_measurement": "dogs"}} + ) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + await assert_validation_result(client, {}) + + # Valid state, statistic runs again - empty response + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + hass.data[DATA_INSTANCE].do_adhoc_statistics(start=now) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + await assert_validation_result(client, {}) + + # Remove the state - empty response + hass.states.async_remove("sensor.test") + await assert_validation_result(client, {}) + + def record_meter_states(hass, zero, entity_id, _attributes, seq): """Record some test states. From fb9a119fc73fcbd966e30e16ecc45e6484748846 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Oct 2021 07:52:08 -1000 Subject: [PATCH 811/843] Update esphome reconnect logic to use newer RecordUpdateListener logic (#57057) --- homeassistant/components/esphome/__init__.py | 54 +++++++++++--------- 1 file changed, 30 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index ee258317357..de301e0c1bb 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -24,7 +24,7 @@ from aioesphomeapi import ( UserServiceArgType, ) import voluptuous as vol -from zeroconf import DNSPointer, DNSRecord, RecordUpdateListener, Zeroconf +from zeroconf import DNSPointer, RecordUpdate, RecordUpdateListener, Zeroconf from homeassistant import const from homeassistant.components import zeroconf @@ -518,34 +518,40 @@ class ReconnectLogic(RecordUpdateListener): """Stop as an async callback function.""" self._hass.async_create_task(self.stop()) - @callback - def _set_reconnect(self) -> None: - self._reconnect_event.set() + def async_update_records( + self, zc: Zeroconf, now: float, records: list[RecordUpdate] + ) -> None: + """Listen to zeroconf updated mDNS records. - def update_record(self, zc: Zeroconf, now: float, record: DNSRecord) -> None: - """Listen to zeroconf updated mDNS records.""" - if not isinstance(record, DNSPointer): - # We only consider PTR records and match using the alias name - return - if self._entry_data is None or self._entry_data.device_info is None: - # Either the entry was already teared down or we haven't received device info yet + This is a mDNS record from the device and could mean it just woke up. + """ + # Check if already connected, no lock needed for this access and + # bail if either the entry was already teared down or we haven't received device info yet + if ( + self._connected + or self._reconnect_event.is_set() + or self._entry_data is None + or self._entry_data.device_info is None + ): return filter_alias = f"{self._entry_data.device_info.name}._esphomelib._tcp.local." - if record.alias != filter_alias: - return - # This is a mDNS record from the device and could mean it just woke up - # Check if already connected, no lock needed for this access - if self._connected: - return + for record_update in records: + # We only consider PTR records and match using the alias name + if ( + not isinstance(record_update.new, DNSPointer) + or record_update.new.alias != filter_alias + ): + continue - # Tell reconnection logic to retry connection attempt now (even before reconnect timer finishes) - _LOGGER.debug( - "%s: Triggering reconnect because of received mDNS record %s", - self._host, - record, - ) - self._hass.add_job(self._set_reconnect) + # Tell reconnection logic to retry connection attempt now (even before reconnect timer finishes) + _LOGGER.debug( + "%s: Triggering reconnect because of received mDNS record %s", + self._host, + record_update.new, + ) + self._reconnect_event.set() + return async def _async_setup_device_registry( From 74aa57e764b61da33e344ff17aa0259841e4a289 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Mon, 4 Oct 2021 23:46:46 +0300 Subject: [PATCH 812/843] Fix: Shelly Gen2 - filter unsupported sensors (#57065) --- .../components/shelly/binary_sensor.py | 16 +++++----------- homeassistant/components/shelly/entity.py | 19 +++++++++++++++---- homeassistant/components/shelly/sensor.py | 17 +++++++++++------ 3 files changed, 31 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/shelly/binary_sensor.py b/homeassistant/components/shelly/binary_sensor.py index 16ffe8b4ee5..46e5468c079 100644 --- a/homeassistant/components/shelly/binary_sensor.py +++ b/homeassistant/components/shelly/binary_sensor.py @@ -122,34 +122,28 @@ REST_SENSORS: Final = { RPC_SENSORS: Final = { "input": RpcAttributeDescription( key="input", + sub_key="state", name="Input", - value=lambda status, _: status["state"], device_class=DEVICE_CLASS_POWER, default_enabled=False, removal_condition=is_rpc_momentary_input, ), "cloud": RpcAttributeDescription( key="cloud", + sub_key="connected", name="Cloud", - value=lambda status, _: status["connected"], device_class=DEVICE_CLASS_CONNECTIVITY, default_enabled=False, ), "fwupdate": RpcAttributeDescription( key="sys", + sub_key="available_updates", name="Firmware Update", device_class=DEVICE_CLASS_UPDATE, - value=lambda status, _: status["available_updates"], default_enabled=False, extra_state_attributes=lambda status: { - "latest_stable_version": status["available_updates"].get( - "stable", - {"version": ""}, - )["version"], - "beta_version": status["available_updates"].get( - "beta", - {"version": ""}, - )["version"], + "latest_stable_version": status.get("stable", {"version": ""})["version"], + "beta_version": status.get("beta", {"version": ""})["version"], }, ), } diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index f12633bd0e3..0fe25884f00 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -166,6 +166,10 @@ async def async_setup_entry_rpc( key_instances = get_rpc_key_instances(wrapper.device.status, description.key) for key in key_instances: + # Filter non-existing sensors + if description.sub_key not in wrapper.device.status[key]: + continue + # Filter and remove entities that according to settings should not create an entity if description.removal_condition and description.removal_condition( wrapper.device.config, key @@ -240,10 +244,11 @@ class RpcAttributeDescription: """Class to describe a RPC sensor.""" key: str + sub_key: str name: str icon: str | None = None unit: str | None = None - value: Callable[[dict, Any], Any] | None = None + value: Callable[[Any, Any], Any] | None = None device_class: str | None = None state_class: str | None = None default_enabled: bool = True @@ -549,6 +554,7 @@ class ShellyRpcAttributeEntity(ShellyRpcEntity, entity.Entity): ) -> None: """Initialize sensor.""" super().__init__(wrapper, key) + self.sub_key = description.sub_key self.attribute = attribute self.description = description @@ -564,8 +570,11 @@ class ShellyRpcAttributeEntity(ShellyRpcEntity, entity.Entity): """Value of sensor.""" if callable(self.description.value): self._last_value = self.description.value( - self.wrapper.device.status[self.key], self._last_value + self.wrapper.device.status[self.key][self.sub_key], self._last_value ) + else: + self._last_value = self.wrapper.device.status[self.key][self.sub_key] + return self._last_value @property @@ -576,7 +585,9 @@ class ShellyRpcAttributeEntity(ShellyRpcEntity, entity.Entity): if not available or not self.description.available: return available - return self.description.available(self.wrapper.device.status[self.key]) + return self.description.available( + self.wrapper.device.status[self.key][self.sub_key] + ) @property def extra_state_attributes(self) -> dict[str, Any] | None: @@ -585,7 +596,7 @@ class ShellyRpcAttributeEntity(ShellyRpcEntity, entity.Entity): return None return self.description.extra_state_attributes( - self.wrapper.device.status[self.key] + self.wrapper.device.status[self.key][self.sub_key] ) diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 09e91946cf3..9ee0712aaef 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -242,51 +242,56 @@ REST_SENSORS: Final = { RPC_SENSORS: Final = { "power": RpcAttributeDescription( key="switch", + sub_key="apower", name="Power", unit=POWER_WATT, - value=lambda status, _: round(float(status["apower"]), 1), + value=lambda status, _: round(float(status), 1), device_class=sensor.DEVICE_CLASS_POWER, state_class=sensor.STATE_CLASS_MEASUREMENT, ), "voltage": RpcAttributeDescription( key="switch", + sub_key="voltage", name="Voltage", unit=ELECTRIC_POTENTIAL_VOLT, - value=lambda status, _: round(float(status["voltage"]), 1), + value=lambda status, _: round(float(status), 1), device_class=sensor.DEVICE_CLASS_VOLTAGE, state_class=sensor.STATE_CLASS_MEASUREMENT, default_enabled=False, ), "energy": RpcAttributeDescription( key="switch", + sub_key="aenergy", name="Energy", unit=ENERGY_KILO_WATT_HOUR, - value=lambda status, _: round(status["aenergy"]["total"] / 1000, 2), + value=lambda status, _: round(status["total"] / 1000, 2), device_class=sensor.DEVICE_CLASS_ENERGY, state_class=sensor.STATE_CLASS_TOTAL_INCREASING, ), "temperature": RpcAttributeDescription( key="switch", + sub_key="temperature", name="Temperature", unit=TEMP_CELSIUS, - value=lambda status, _: round(status["temperature"]["tC"], 1), + value=lambda status, _: round(status["tC"], 1), device_class=sensor.DEVICE_CLASS_TEMPERATURE, state_class=sensor.STATE_CLASS_MEASUREMENT, default_enabled=False, ), "rssi": RpcAttributeDescription( key="wifi", + sub_key="rssi", name="RSSI", unit=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - value=lambda status, _: status["rssi"], device_class=sensor.DEVICE_CLASS_SIGNAL_STRENGTH, state_class=sensor.STATE_CLASS_MEASUREMENT, default_enabled=False, ), "uptime": RpcAttributeDescription( key="sys", + sub_key="uptime", name="Uptime", - value=lambda status, last: get_device_uptime(status["uptime"], last), + value=get_device_uptime, device_class=sensor.DEVICE_CLASS_TIMESTAMP, default_enabled=False, ), From d42350f986113be8527d0c8c0d6e29a69bdde5ae Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 5 Oct 2021 05:52:17 +0200 Subject: [PATCH 813/843] Update frontend to 20211004.0 (#57073) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index d6d38faab27..3b047fbe245 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", "requirements": [ - "home-assistant-frontend==20211002.0" + "home-assistant-frontend==20211004.0" ], "dependencies": [ "api", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index d69ae6d5e43..a496bc80169 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -15,7 +15,7 @@ ciso8601==2.2.0 cryptography==3.4.8 emoji==1.5.0 hass-nabucasa==0.50.0 -home-assistant-frontend==20211002.0 +home-assistant-frontend==20211004.0 httpx==0.19.0 ifaddr==0.1.7 jinja2==3.0.1 diff --git a/requirements_all.txt b/requirements_all.txt index 894b0b9b3de..fbb57f6d7a4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -810,7 +810,7 @@ hole==0.5.1 holidays==0.11.3.1 # homeassistant.components.frontend -home-assistant-frontend==20211002.0 +home-assistant-frontend==20211004.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 319509de017..7bea04e9cee 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -485,7 +485,7 @@ hole==0.5.1 holidays==0.11.3.1 # homeassistant.components.frontend -home-assistant-frontend==20211002.0 +home-assistant-frontend==20211004.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 From f0b22e2f40753a1a78cf042bbb6bc13648377034 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 4 Oct 2021 20:52:40 -0700 Subject: [PATCH 814/843] Fix energy gas price validation (#57075) --- homeassistant/components/energy/validate.py | 38 ++++++++++++++++----- tests/components/energy/test_validate.py | 29 ++++++++++++++-- 2 files changed, 57 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/energy/validate.py b/homeassistant/components/energy/validate.py index d03883d046b..24d060b4352 100644 --- a/homeassistant/components/energy/validate.py +++ b/homeassistant/components/energy/validate.py @@ -24,13 +24,21 @@ ENERGY_USAGE_DEVICE_CLASSES = (sensor.DEVICE_CLASS_ENERGY,) ENERGY_USAGE_UNITS = { sensor.DEVICE_CLASS_ENERGY: (ENERGY_KILO_WATT_HOUR, ENERGY_WATT_HOUR) } +ENERGY_PRICE_UNITS = tuple( + f"/{unit}" for units in ENERGY_USAGE_UNITS.values() for unit in units +) ENERGY_UNIT_ERROR = "entity_unexpected_unit_energy" +ENERGY_PRICE_UNIT_ERROR = "entity_unexpected_unit_energy_price" GAS_USAGE_DEVICE_CLASSES = (sensor.DEVICE_CLASS_ENERGY, sensor.DEVICE_CLASS_GAS) GAS_USAGE_UNITS = { sensor.DEVICE_CLASS_ENERGY: (ENERGY_WATT_HOUR, ENERGY_KILO_WATT_HOUR), sensor.DEVICE_CLASS_GAS: (VOLUME_CUBIC_METERS, VOLUME_CUBIC_FEET), } +GAS_PRICE_UNITS = tuple( + f"/{unit}" for units in GAS_USAGE_UNITS.values() for unit in units +) GAS_UNIT_ERROR = "entity_unexpected_unit_gas" +GAS_PRICE_UNIT_ERROR = "entity_unexpected_unit_gas_price" @dataclasses.dataclass @@ -152,7 +160,11 @@ def _async_validate_usage_stat( @callback def _async_validate_price_entity( - hass: HomeAssistant, entity_id: str, result: list[ValidationIssue] + hass: HomeAssistant, + entity_id: str, + result: list[ValidationIssue], + allowed_units: tuple[str, ...], + unit_error: str, ) -> None: """Validate that the price entity is correct.""" state = hass.states.get(entity_id) @@ -176,10 +188,8 @@ def _async_validate_price_entity( unit = state.attributes.get("unit_of_measurement") - if unit is None or not unit.endswith( - (f"/{ENERGY_KILO_WATT_HOUR}", f"/{ENERGY_WATT_HOUR}") - ): - result.append(ValidationIssue("entity_unexpected_unit_price", entity_id, unit)) + if unit is None or not unit.endswith(allowed_units): + result.append(ValidationIssue(unit_error, entity_id, unit)) @callback @@ -274,7 +284,11 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: _async_validate_cost_stat(hass, flow["stat_cost"], source_result) elif flow.get("entity_energy_price") is not None: _async_validate_price_entity( - hass, flow["entity_energy_price"], source_result + hass, + flow["entity_energy_price"], + source_result, + ENERGY_PRICE_UNITS, + ENERGY_PRICE_UNIT_ERROR, ) if ( @@ -303,7 +317,11 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: ) elif flow.get("entity_energy_price") is not None: _async_validate_price_entity( - hass, flow["entity_energy_price"], source_result + hass, + flow["entity_energy_price"], + source_result, + ENERGY_PRICE_UNITS, + ENERGY_PRICE_UNIT_ERROR, ) if ( @@ -330,7 +348,11 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: _async_validate_cost_stat(hass, source["stat_cost"], source_result) elif source.get("entity_energy_price") is not None: _async_validate_price_entity( - hass, source["entity_energy_price"], source_result + hass, + source["entity_energy_price"], + source_result, + GAS_PRICE_UNITS, + GAS_PRICE_UNIT_ERROR, ) if ( diff --git a/tests/components/energy/test_validate.py b/tests/components/energy/test_validate.py index 1dd38047209..668f3113fea 100644 --- a/tests/components/energy/test_validate.py +++ b/tests/components/energy/test_validate.py @@ -473,7 +473,7 @@ async def test_validation_grid_price_not_exist(hass, mock_energy_manager): "123", "$/Ws", { - "type": "entity_unexpected_unit_price", + "type": "entity_unexpected_unit_energy_price", "identifier": "sensor.grid_price_1", "value": "$/Ws", }, @@ -551,11 +551,19 @@ async def test_validation_gas(hass, mock_energy_manager, mock_is_entity_recorded { "type": "gas", "stat_energy_from": "sensor.gas_consumption_4", - "stat_cost": "sensor.gas_cost_2", + "entity_energy_from": "sensor.gas_consumption_4", + "entity_energy_price": "sensor.gas_price_1", + }, + { + "type": "gas", + "stat_energy_from": "sensor.gas_consumption_3", + "entity_energy_from": "sensor.gas_consumption_3", + "entity_energy_price": "sensor.gas_price_2", }, ] } ) + await hass.async_block_till_done() hass.states.async_set( "sensor.gas_consumption_1", "10.10", @@ -593,6 +601,16 @@ async def test_validation_gas(hass, mock_energy_manager, mock_is_entity_recorded "10.10", {"unit_of_measurement": "EUR/kWh", "state_class": "total_increasing"}, ) + hass.states.async_set( + "sensor.gas_price_1", + "10.10", + {"unit_of_measurement": "EUR/m³", "state_class": "total_increasing"}, + ) + hass.states.async_set( + "sensor.gas_price_2", + "10.10", + {"unit_of_measurement": "EUR/invalid", "state_class": "total_increasing"}, + ) assert (await validate.async_validate(hass)).as_dict() == { "energy_sources": [ @@ -622,6 +640,13 @@ async def test_validation_gas(hass, mock_energy_manager, mock_is_entity_recorded "value": None, }, ], + [ + { + "type": "entity_unexpected_unit_gas_price", + "identifier": "sensor.gas_price_2", + "value": "EUR/invalid", + }, + ], ], "device_consumption": [], } From a457bb74462a4d2b742247ce1287d392c863e944 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Tue, 5 Oct 2021 00:12:33 +0000 Subject: [PATCH 815/843] [ci skip] Translation update --- .../components/switchbot/translations/it.json | 4 +++- homeassistant/components/tuya/translations/ca.json | 10 +++++++--- homeassistant/components/tuya/translations/et.json | 6 +++++- homeassistant/components/tuya/translations/it.json | 6 +++++- homeassistant/components/tuya/translations/nl.json | 6 +++++- 5 files changed, 25 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/switchbot/translations/it.json b/homeassistant/components/switchbot/translations/it.json index fc8296f6442..9529450232b 100644 --- a/homeassistant/components/switchbot/translations/it.json +++ b/homeassistant/components/switchbot/translations/it.json @@ -8,7 +8,9 @@ "unknown": "Errore imprevisto" }, "error": { - "cannot_connect": "Impossibile connettersi" + "cannot_connect": "Impossibile connettersi", + "one": "Vuoto", + "other": "Vuoti" }, "flow_title": "{name}", "step": { diff --git a/homeassistant/components/tuya/translations/ca.json b/homeassistant/components/tuya/translations/ca.json index 6759d322484..bff9b7a35b9 100644 --- a/homeassistant/components/tuya/translations/ca.json +++ b/homeassistant/components/tuya/translations/ca.json @@ -6,7 +6,8 @@ "single_instance_allowed": "Ja configurat. Nom\u00e9s \u00e9s possible una sola configuraci\u00f3." }, "error": { - "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida" + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "login_error": "Error d'inici de sessi\u00f3 ({code}): {msg}" }, "flow_title": "Configuraci\u00f3 de Tuya", "step": { @@ -25,13 +26,16 @@ }, "user": { "data": { + "access_id": "ID d'acc\u00e9s de Tuya IoT", + "access_secret": "Secret d'acc\u00e9s de Tuya IoT", "country_code": "El teu codi de pa\u00eds (per exemple, 1 per l'EUA o 86 per la Xina)", "password": "Contrasenya", "platform": "L'aplicaci\u00f3 on es registra el teu compte", + "region": "Regi\u00f3", "tuya_project_type": "Tipus de projecte al n\u00favol de Tuya", - "username": "Nom d'usuari" + "username": "Compte" }, - "description": "Introdueix les teves credencial de Tuya.", + "description": "Introdueix les teves credencial de Tuya", "title": "Integraci\u00f3 Tuya" } } diff --git a/homeassistant/components/tuya/translations/et.json b/homeassistant/components/tuya/translations/et.json index 45b4e4d2639..96f081621b8 100644 --- a/homeassistant/components/tuya/translations/et.json +++ b/homeassistant/components/tuya/translations/et.json @@ -6,7 +6,8 @@ "single_instance_allowed": "Juba seadistatud. V\u00f5imalik on ainult \u00fcks sidumine." }, "error": { - "invalid_auth": "Tuvastamise viga" + "invalid_auth": "Tuvastamise viga", + "login_error": "Sisenemine nurjus ( {code} ): {msg}" }, "flow_title": "Tuya seaded", "step": { @@ -25,9 +26,12 @@ }, "user": { "data": { + "access_id": "Tuya IoT kasutajatunnus", + "access_secret": "Tuya IoT salas\u00f5na", "country_code": "Konto riigikood (nt 1 USA v\u00f5i 372 Eesti)", "password": "Salas\u00f5na", "platform": "\u00c4pp kus konto registreeriti", + "region": "Piirkond", "tuya_project_type": "Tuya pilveprojekti t\u00fc\u00fcp", "username": "Kasutajanimi" }, diff --git a/homeassistant/components/tuya/translations/it.json b/homeassistant/components/tuya/translations/it.json index 3baed47661c..9f3b7d498e3 100644 --- a/homeassistant/components/tuya/translations/it.json +++ b/homeassistant/components/tuya/translations/it.json @@ -6,7 +6,8 @@ "single_instance_allowed": "Gi\u00e0 configurato. \u00c8 possibile una sola configurazione." }, "error": { - "invalid_auth": "Autenticazione non valida" + "invalid_auth": "Autenticazione non valida", + "login_error": "Errore di accesso ({code}): {msg}" }, "flow_title": "Configurazione di Tuya", "step": { @@ -25,9 +26,12 @@ }, "user": { "data": { + "access_id": "ID accesso IoT Tuya", + "access_secret": "Secret IoT Tuya", "country_code": "Prefisso internazionale del tuo account (ad es. 1 per gli Stati Uniti o 86 per la Cina)", "password": "Password", "platform": "L'app in cui \u00e8 registrato il tuo account", + "region": "Area geografica", "tuya_project_type": "Tipo di progetto Tuya cloud", "username": "Nome utente" }, diff --git a/homeassistant/components/tuya/translations/nl.json b/homeassistant/components/tuya/translations/nl.json index 22a63800fd6..0ceb0c916cc 100644 --- a/homeassistant/components/tuya/translations/nl.json +++ b/homeassistant/components/tuya/translations/nl.json @@ -6,7 +6,8 @@ "single_instance_allowed": "Al geconfigureerd. Er is maar een configuratie mogelijk." }, "error": { - "invalid_auth": "Ongeldige authenticatie" + "invalid_auth": "Ongeldige authenticatie", + "login_error": "Aanmeldingsfout ({code}): {msg}" }, "flow_title": "Tuya-configuratie", "step": { @@ -25,9 +26,12 @@ }, "user": { "data": { + "access_id": "Tuya IoT-toegangs-ID", + "access_secret": "Tuya IoT Access Secret", "country_code": "De landcode van uw account (bijvoorbeeld 1 voor de VS of 86 voor China)", "password": "Wachtwoord", "platform": "De app waar uw account is geregistreerd", + "region": "Regio", "tuya_project_type": "Tuya cloud project type", "username": "Gebruikersnaam" }, From b289bb2f578b04ffb4a56e224a7082a909f397a9 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 4 Oct 2021 21:07:16 -0700 Subject: [PATCH 816/843] Bumped version to 2021.10.0b6 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 4dbf3ea2785..01c031ce339 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -5,7 +5,7 @@ from typing import Final MAJOR_VERSION: Final = 2021 MINOR_VERSION: Final = 10 -PATCH_VERSION: Final = "0b5" +PATCH_VERSION: Final = "0b6" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0) From 70a6930a8a84d8997534b077bbcefac7a8297b99 Mon Sep 17 00:00:00 2001 From: indykoning <15870933+indykoning@users.noreply.github.com> Date: Tue, 5 Oct 2021 21:31:23 +0200 Subject: [PATCH 817/843] Fix Growatt login invalid auth response (#57071) --- .../components/growatt_server/config_flow.py | 13 +++++++++++-- homeassistant/components/growatt_server/const.py | 2 ++ homeassistant/components/growatt_server/sensor.py | 7 +++++-- tests/components/growatt_server/test_config_flow.py | 3 ++- 4 files changed, 20 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/growatt_server/config_flow.py b/homeassistant/components/growatt_server/config_flow.py index c4a97a81f0a..11f082f1eab 100644 --- a/homeassistant/components/growatt_server/config_flow.py +++ b/homeassistant/components/growatt_server/config_flow.py @@ -6,7 +6,13 @@ from homeassistant import config_entries from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.core import callback -from .const import CONF_PLANT_ID, DEFAULT_URL, DOMAIN, SERVER_URLS +from .const import ( + CONF_PLANT_ID, + DEFAULT_URL, + DOMAIN, + LOGIN_INVALID_AUTH_CODE, + SERVER_URLS, +) class GrowattServerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -45,7 +51,10 @@ class GrowattServerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.api.login, user_input[CONF_USERNAME], user_input[CONF_PASSWORD] ) - if not login_response["success"] and login_response["errCode"] == "102": + if ( + not login_response["success"] + and login_response["msg"] == LOGIN_INVALID_AUTH_CODE + ): return self._async_show_user_form({"base": "invalid_auth"}) self.user_id = login_response["user"]["id"] diff --git a/homeassistant/components/growatt_server/const.py b/homeassistant/components/growatt_server/const.py index e0297de5eff..5425e26c806 100644 --- a/homeassistant/components/growatt_server/const.py +++ b/homeassistant/components/growatt_server/const.py @@ -16,3 +16,5 @@ DEFAULT_URL = SERVER_URLS[0] DOMAIN = "growatt_server" PLATFORMS = ["sensor"] + +LOGIN_INVALID_AUTH_CODE = "502" diff --git a/homeassistant/components/growatt_server/sensor.py b/homeassistant/components/growatt_server/sensor.py index 9f0fa509105..804d4157543 100644 --- a/homeassistant/components/growatt_server/sensor.py +++ b/homeassistant/components/growatt_server/sensor.py @@ -37,7 +37,7 @@ from homeassistant.const import ( ) from homeassistant.util import Throttle, dt -from .const import CONF_PLANT_ID, DEFAULT_PLANT_ID, DEFAULT_URL +from .const import CONF_PLANT_ID, DEFAULT_PLANT_ID, DEFAULT_URL, LOGIN_INVALID_AUTH_CODE _LOGGER = logging.getLogger(__name__) @@ -876,7 +876,10 @@ def get_device_list(api, config): # Log in to api and fetch first plant if no plant id is defined. login_response = api.login(config[CONF_USERNAME], config[CONF_PASSWORD]) - if not login_response["success"] and login_response["errCode"] == "102": + if ( + not login_response["success"] + and login_response["msg"] == LOGIN_INVALID_AUTH_CODE + ): _LOGGER.error("Username, Password or URL may be incorrect!") return user_id = login_response["user"]["id"] diff --git a/tests/components/growatt_server/test_config_flow.py b/tests/components/growatt_server/test_config_flow.py index db46ed36911..ba52e09296c 100644 --- a/tests/components/growatt_server/test_config_flow.py +++ b/tests/components/growatt_server/test_config_flow.py @@ -7,6 +7,7 @@ from homeassistant.components.growatt_server.const import ( CONF_PLANT_ID, DEFAULT_URL, DOMAIN, + LOGIN_INVALID_AUTH_CODE, ) from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME @@ -61,7 +62,7 @@ async def test_incorrect_login(hass): with patch( "growattServer.GrowattApi.login", - return_value={"errCode": "102", "success": False}, + return_value={"msg": LOGIN_INVALID_AUTH_CODE, "success": False}, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], FIXTURE_USER_INPUT From 4d44beb9386f4605128c785f57f6d96ec3c9e082 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 5 Oct 2021 10:22:28 +0200 Subject: [PATCH 818/843] Prevent Tuya from accidentally logging credentials in debug mode (#57100) --- homeassistant/components/tuya/__init__.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index c59e29ba348..ffaf36ece8e 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -44,9 +44,6 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Async setup hass config entry.""" - - _LOGGER.debug("tuya.__init__.async_setup_entry-->%s", entry.data) - hass.data[DOMAIN] = {entry.entry_id: {TUYA_HA_TUYA_MAP: {}, TUYA_HA_DEVICES: set()}} success = await _init_tuya_sdk(hass, entry) From d1f790fab0c241440aaf271dd2d279d46c5422f1 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 5 Oct 2021 12:59:51 +0200 Subject: [PATCH 819/843] Small code styling tweaks for Tuya (#57102) --- homeassistant/components/tuya/__init__.py | 5 +---- homeassistant/components/tuya/base.py | 6 ++---- homeassistant/components/tuya/climate.py | 8 ++++---- homeassistant/components/tuya/light.py | 3 +-- homeassistant/components/tuya/scene.py | 7 +------ 5 files changed, 9 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index ffaf36ece8e..77e83793892 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -47,10 +47,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN] = {entry.entry_id: {TUYA_HA_TUYA_MAP: {}, TUYA_HA_DEVICES: set()}} success = await _init_tuya_sdk(hass, entry) - if not success: - return False - - return True + return bool(success) async def _init_tuya_sdk(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/tuya/base.py b/homeassistant/components/tuya/base.py index 572c452a920..2e9840c60e1 100644 --- a/homeassistant/components/tuya/base.py +++ b/homeassistant/components/tuya/base.py @@ -25,10 +25,9 @@ class TuyaHaEntity(Entity): @staticmethod def remap(old_value, old_min, old_max, new_min, new_max): """Remap old_value to new_value.""" - new_value = ((old_value - old_min) / (old_max - old_min)) * ( + return ((old_value - old_min) / (old_max - old_min)) * ( new_max - new_min ) + new_min - return new_value @property def should_poll(self) -> bool: @@ -48,13 +47,12 @@ class TuyaHaEntity(Entity): @property def device_info(self): """Return a device description for device registry.""" - _device_info = { + return { "identifiers": {(DOMAIN, f"{self.tuya_device.id}")}, "manufacturer": "Tuya", "name": self.tuya_device.name, "model": self.tuya_device.product_name, } - return _device_info @property def available(self) -> bool: diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py index 368a65b8499..810e8ad8aab 100644 --- a/homeassistant/components/tuya/climate.py +++ b/homeassistant/components/tuya/climate.py @@ -430,10 +430,10 @@ class TuyaHaClimate(TuyaHaEntity, ClimateEntity): @property def fan_modes(self) -> list[str]: """Return fan modes for select.""" - data = json.loads( - self.tuya_device.function.get(DPCODE_FAN_SPEED_ENUM, {}).values - ).get("range") - return data + fan_speed_device_function = self.tuya_device.function.get(DPCODE_FAN_SPEED_ENUM) + if not fan_speed_device_function: + return [] + return json.loads(fan_speed_device_function.values).get("range", []) @property def swing_mode(self) -> str: diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index 180e3a68450..6a119e71ba9 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -299,7 +299,7 @@ class TuyaHaLight(TuyaHaEntity, LightEntity): """Return the color_temp of the light.""" new_range = self._tuya_temp_range() tuya_color_temp = self.tuya_device.status.get(self.dp_code_temp, 0) - ha_color_temp = ( + return ( self.max_mireds - self.remap( tuya_color_temp, @@ -310,7 +310,6 @@ class TuyaHaLight(TuyaHaEntity, LightEntity): ) + self.min_mireds ) - return ha_color_temp @property def min_mireds(self) -> int: diff --git a/homeassistant/components/tuya/scene.py b/homeassistant/components/tuya/scene.py index c6010f9ef87..c90c6798b9b 100644 --- a/homeassistant/components/tuya/scene.py +++ b/homeassistant/components/tuya/scene.py @@ -20,14 +20,9 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up tuya scenes.""" - entities = [] - home_manager = hass.data[DOMAIN][entry.entry_id][TUYA_HOME_MANAGER] scenes = await hass.async_add_executor_job(home_manager.query_scenes) - for scene in scenes: - entities.append(TuyaHAScene(home_manager, scene)) - - async_add_entities(entities) + async_add_entities(TuyaHAScene(home_manager, scene) for scene in scenes) class TuyaHAScene(Scene): From b76e19c8c20328735e092064b0870e1a799a6ccc Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 5 Oct 2021 11:12:55 +0200 Subject: [PATCH 820/843] Remove Python shebang line from Tuya integration files (#57103) --- homeassistant/components/tuya/__init__.py | 1 - homeassistant/components/tuya/base.py | 1 - homeassistant/components/tuya/const.py | 1 - homeassistant/components/tuya/switch.py | 1 - 4 files changed, 4 deletions(-) diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index 77e83793892..3602f9585af 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python3 """Support for Tuya Smart devices.""" import itertools diff --git a/homeassistant/components/tuya/base.py b/homeassistant/components/tuya/base.py index 2e9840c60e1..a1f65227e95 100644 --- a/homeassistant/components/tuya/base.py +++ b/homeassistant/components/tuya/base.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python3 """Tuya Home Assistant Base Device Model.""" from __future__ import annotations diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index f86180226ee..dd18309d128 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python3 """Constants for the Tuya integration.""" DOMAIN = "tuya" diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index ab34ebbdfc0..5bafbe1b7f6 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python3 """Support for Tuya switches.""" from __future__ import annotations From c5992e296745e1ec4e88ac2f120dae1acba3b4b4 Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Tue, 5 Oct 2021 11:49:39 +0200 Subject: [PATCH 821/843] Bump aioesphomeapi from 9.1.4 to 9.1.5 (#57106) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 33801431994..307227be944 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -3,7 +3,7 @@ "name": "ESPHome", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/esphome", - "requirements": ["aioesphomeapi==9.1.4"], + "requirements": ["aioesphomeapi==9.1.5"], "zeroconf": ["_esphomelib._tcp.local."], "codeowners": ["@OttoWinter", "@jesserockz"], "after_dependencies": ["zeroconf", "tag"], diff --git a/requirements_all.txt b/requirements_all.txt index fbb57f6d7a4..e7864d3dfc4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -164,7 +164,7 @@ aioeagle==1.1.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==9.1.4 +aioesphomeapi==9.1.5 # homeassistant.components.flo aioflo==0.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7bea04e9cee..d42250b7213 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -109,7 +109,7 @@ aioeagle==1.1.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==9.1.4 +aioesphomeapi==9.1.5 # homeassistant.components.flo aioflo==0.4.1 From 5b550689e0483a7f0fd58680cbbd268593ebdd5c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 4 Oct 2021 15:44:17 -0700 Subject: [PATCH 822/843] Update Tuya code owners (#57078) --- CODEOWNERS | 2 +- homeassistant/components/tuya/manifest.json | 10 +++------- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 515aec64774..a43b38e509d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -544,7 +544,7 @@ homeassistant/components/trafikverket_train/* @endor-force homeassistant/components/trafikverket_weatherstation/* @endor-force homeassistant/components/transmission/* @engrbm87 @JPHutchins homeassistant/components/tts/* @pvizeli -homeassistant/components/tuya/* @Tuya +homeassistant/components/tuya/* @Tuya @zlinoliver @METISU homeassistant/components/twentemilieu/* @frenck homeassistant/components/twinkly/* @dr1rrb homeassistant/components/ubus/* @noltari diff --git a/homeassistant/components/tuya/manifest.json b/homeassistant/components/tuya/manifest.json index 85370bdfcac..0097e7635ec 100644 --- a/homeassistant/components/tuya/manifest.json +++ b/homeassistant/components/tuya/manifest.json @@ -2,12 +2,8 @@ "domain": "tuya", "name": "Tuya", "documentation": "https://github.com/tuya/tuya-home-assistant", - "requirements": [ - "tuya-iot-py-sdk==0.4.1" - ], - "codeowners": [ - "@Tuya" - ], + "requirements": ["tuya-iot-py-sdk==0.4.1"], + "codeowners": ["@Tuya", "@zlinoliver", "@METISU"], "config_flow": true, "iot_class": "cloud_push" -} \ No newline at end of file +} From a13a6bc9c9e31bcccf51b281023de4c0c9f93bee Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 5 Oct 2021 19:21:55 +0200 Subject: [PATCH 823/843] Bump tuya-iot-py-sdk to 0.5.0 (#57110) Co-authored-by: Martin Hjelmare --- homeassistant/components/tuya/__init__.py | 26 +++++++++++++------- homeassistant/components/tuya/config_flow.py | 16 ++++++------ homeassistant/components/tuya/const.py | 1 + homeassistant/components/tuya/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/tuya/test_config_flow.py | 8 +++--- 7 files changed, 33 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index 3602f9585af..df4689268cf 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -4,7 +4,7 @@ import itertools import logging from tuya_iot import ( - ProjectType, + AuthType, TuyaDevice, TuyaDeviceListener, TuyaDeviceManager, @@ -22,6 +22,7 @@ from .const import ( CONF_ACCESS_ID, CONF_ACCESS_SECRET, CONF_APP_TYPE, + CONF_AUTH_TYPE, CONF_COUNTRY_CODE, CONF_ENDPOINT, CONF_PASSWORD, @@ -45,28 +46,35 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Async setup hass config entry.""" hass.data[DOMAIN] = {entry.entry_id: {TUYA_HA_TUYA_MAP: {}, TUYA_HA_DEVICES: set()}} + # Project type has been renamed to auth type in the upstream Tuya IoT SDK. + # This migrates existing config entries to reflect that name change. + if CONF_PROJECT_TYPE in entry.data: + data = {**entry.data, CONF_AUTH_TYPE: entry.data[CONF_PROJECT_TYPE]} + data.pop(CONF_PROJECT_TYPE) + hass.config_entries.async_update_entry(entry, data=data) + success = await _init_tuya_sdk(hass, entry) return bool(success) async def _init_tuya_sdk(hass: HomeAssistant, entry: ConfigEntry) -> bool: - project_type = ProjectType(entry.data[CONF_PROJECT_TYPE]) + auth_type = AuthType(entry.data[CONF_AUTH_TYPE]) api = TuyaOpenAPI( - entry.data[CONF_ENDPOINT], - entry.data[CONF_ACCESS_ID], - entry.data[CONF_ACCESS_SECRET], - project_type, + endpoint=entry.data[CONF_ENDPOINT], + access_id=entry.data[CONF_ACCESS_ID], + access_secret=entry.data[CONF_ACCESS_SECRET], + auth_type=auth_type, ) api.set_dev_channel("hass") - if project_type == ProjectType.INDUSTY_SOLUTIONS: + if auth_type == AuthType.CUSTOM: response = await hass.async_add_executor_job( - api.login, entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD] + api.connect, entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD] ) else: response = await hass.async_add_executor_job( - api.login, + api.connect, entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], entry.data[CONF_COUNTRY_CODE], diff --git a/homeassistant/components/tuya/config_flow.py b/homeassistant/components/tuya/config_flow.py index 1b439d49007..8fffed3cd9f 100644 --- a/homeassistant/components/tuya/config_flow.py +++ b/homeassistant/components/tuya/config_flow.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging from typing import Any -from tuya_iot import ProjectType, TuyaOpenAPI +from tuya_iot import AuthType, TuyaOpenAPI import voluptuous as vol from voluptuous.schema_builder import UNDEFINED @@ -14,10 +14,10 @@ from .const import ( CONF_ACCESS_ID, CONF_ACCESS_SECRET, CONF_APP_TYPE, + CONF_AUTH_TYPE, CONF_COUNTRY_CODE, CONF_ENDPOINT, CONF_PASSWORD, - CONF_PROJECT_TYPE, CONF_REGION, CONF_USERNAME, DOMAIN, @@ -44,7 +44,7 @@ class TuyaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): data = { CONF_ENDPOINT: TUYA_REGIONS[user_input[CONF_REGION]], - CONF_PROJECT_TYPE: ProjectType.INDUSTY_SOLUTIONS, + CONF_AUTH_TYPE: AuthType.CUSTOM, CONF_ACCESS_ID: user_input[CONF_ACCESS_ID], CONF_ACCESS_SECRET: user_input[CONF_ACCESS_SECRET], CONF_USERNAME: user_input[CONF_USERNAME], @@ -55,19 +55,19 @@ class TuyaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): for app_type in ("", TUYA_SMART_APP, SMARTLIFE_APP): data[CONF_APP_TYPE] = app_type if data[CONF_APP_TYPE] == "": - data[CONF_PROJECT_TYPE] = ProjectType.INDUSTY_SOLUTIONS + data[CONF_AUTH_TYPE] = AuthType.CUSTOM else: - data[CONF_PROJECT_TYPE] = ProjectType.SMART_HOME + data[CONF_AUTH_TYPE] = AuthType.SMART_HOME api = TuyaOpenAPI( endpoint=data[CONF_ENDPOINT], access_id=data[CONF_ACCESS_ID], access_secret=data[CONF_ACCESS_SECRET], - project_type=data[CONF_PROJECT_TYPE], + auth_type=data[CONF_AUTH_TYPE], ) api.set_dev_channel("hass") - response = api.login( + response = api.connect( username=data[CONF_USERNAME], password=data[CONF_PASSWORD], country_code=data[CONF_COUNTRY_CODE], @@ -97,7 +97,7 @@ class TuyaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ): data[CONF_ENDPOINT] = endpoint - data[CONF_PROJECT_TYPE] = data[CONF_PROJECT_TYPE].value + data[CONF_AUTH_TYPE] = data[CONF_AUTH_TYPE].value return self.async_create_entry( title=user_input[CONF_USERNAME], diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index dd18309d128..7c6440d7e48 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -2,6 +2,7 @@ DOMAIN = "tuya" +CONF_AUTH_TYPE = "auth_type" CONF_PROJECT_TYPE = "tuya_project_type" CONF_ENDPOINT = "endpoint" CONF_ACCESS_ID = "access_id" diff --git a/homeassistant/components/tuya/manifest.json b/homeassistant/components/tuya/manifest.json index 0097e7635ec..20df33f4573 100644 --- a/homeassistant/components/tuya/manifest.json +++ b/homeassistant/components/tuya/manifest.json @@ -2,7 +2,7 @@ "domain": "tuya", "name": "Tuya", "documentation": "https://github.com/tuya/tuya-home-assistant", - "requirements": ["tuya-iot-py-sdk==0.4.1"], + "requirements": ["tuya-iot-py-sdk==0.5.0"], "codeowners": ["@Tuya", "@zlinoliver", "@METISU"], "config_flow": true, "iot_class": "cloud_push" diff --git a/requirements_all.txt b/requirements_all.txt index e7864d3dfc4..9fa20c0fcea 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2332,7 +2332,7 @@ tp-connected==0.0.4 transmissionrpc==0.11 # homeassistant.components.tuya -tuya-iot-py-sdk==0.4.1 +tuya-iot-py-sdk==0.5.0 # homeassistant.components.twentemilieu twentemilieu==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d42250b7213..98c9e90d6c4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1315,7 +1315,7 @@ total_connect_client==0.57 transmissionrpc==0.11 # homeassistant.components.tuya -tuya-iot-py-sdk==0.4.1 +tuya-iot-py-sdk==0.5.0 # homeassistant.components.twentemilieu twentemilieu==0.3.0 diff --git a/tests/components/tuya/test_config_flow.py b/tests/components/tuya/test_config_flow.py index 04fb8ebe009..745bcfde661 100644 --- a/tests/components/tuya/test_config_flow.py +++ b/tests/components/tuya/test_config_flow.py @@ -11,9 +11,9 @@ from homeassistant.components.tuya.const import ( CONF_ACCESS_ID, CONF_ACCESS_SECRET, CONF_APP_TYPE, + CONF_AUTH_TYPE, CONF_ENDPOINT, CONF_PASSWORD, - CONF_PROJECT_TYPE, CONF_REGION, CONF_USERNAME, DOMAIN, @@ -86,7 +86,7 @@ async def test_user_flow( assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" - tuya().login = MagicMock(side_effect=side_effects) + tuya().connect = MagicMock(side_effect=side_effects) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=TUYA_INPUT_DATA ) @@ -101,7 +101,7 @@ async def test_user_flow( assert result["data"][CONF_ENDPOINT] == MOCK_ENDPOINT assert result["data"][CONF_ENDPOINT] != TUYA_REGIONS[TUYA_INPUT_DATA[CONF_REGION]] assert result["data"][CONF_APP_TYPE] == app_type - assert result["data"][CONF_PROJECT_TYPE] == project_type + assert result["data"][CONF_AUTH_TYPE] == project_type assert not result["result"].unique_id @@ -115,7 +115,7 @@ async def test_error_on_invalid_credentials(hass, tuya): assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" - tuya().login = MagicMock(return_value=RESPONSE_ERROR) + tuya().connect = MagicMock(return_value=RESPONSE_ERROR) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=TUYA_INPUT_DATA ) From ecdbb5ff17bde7d3601afae15156de1bbd75199b Mon Sep 17 00:00:00 2001 From: jrester <31157644+jrester@users.noreply.github.com> Date: Tue, 5 Oct 2021 19:40:37 +0200 Subject: [PATCH 824/843] Update tesla_powerwall to 0.3.11 (#57112) --- homeassistant/components/powerwall/binary_sensor.py | 8 ++++++-- homeassistant/components/powerwall/manifest.json | 2 +- homeassistant/components/powerwall/sensor.py | 3 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 11 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/powerwall/binary_sensor.py b/homeassistant/components/powerwall/binary_sensor.py index 1cacaa5fc42..1b097b93408 100644 --- a/homeassistant/components/powerwall/binary_sensor.py +++ b/homeassistant/components/powerwall/binary_sensor.py @@ -1,5 +1,5 @@ """Support for powerwall binary sensors.""" -from tesla_powerwall import GridStatus +from tesla_powerwall import GridStatus, MeterType from homeassistant.components.binary_sensor import ( DEVICE_CLASS_BATTERY_CHARGING, @@ -142,4 +142,8 @@ class PowerWallChargingStatusSensor(PowerWallEntity, BinarySensorEntity): def is_on(self): """Powerwall is charging.""" # is_sending_to returns true for values greater than 100 watts - return self.coordinator.data[POWERWALL_API_METERS].battery.is_sending_to() + return ( + self.coordinator.data[POWERWALL_API_METERS] + .get_meter(MeterType.BATTERY) + .is_sending_to() + ) diff --git a/homeassistant/components/powerwall/manifest.json b/homeassistant/components/powerwall/manifest.json index 5cee6c1fd19..802d1fdf5e3 100644 --- a/homeassistant/components/powerwall/manifest.json +++ b/homeassistant/components/powerwall/manifest.json @@ -3,7 +3,7 @@ "name": "Tesla Powerwall", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/powerwall", - "requirements": ["tesla-powerwall==0.3.10"], + "requirements": ["tesla-powerwall==0.3.11"], "codeowners": ["@bdraco", "@jrester"], "dhcp": [ { diff --git a/homeassistant/components/powerwall/sensor.py b/homeassistant/components/powerwall/sensor.py index 940dcad8647..8c45a142206 100644 --- a/homeassistant/components/powerwall/sensor.py +++ b/homeassistant/components/powerwall/sensor.py @@ -53,7 +53,8 @@ async def async_setup_entry(hass, config_entry, async_add_entities): powerwalls_serial_numbers = powerwall_data[POWERWALL_API_SERIAL_NUMBERS] entities = [] - for meter in MeterType: + # coordinator.data[POWERWALL_API_METERS].meters holds all meters that are available + for meter in coordinator.data[POWERWALL_API_METERS].meters: entities.append( PowerWallEnergySensor( meter, diff --git a/requirements_all.txt b/requirements_all.txt index 9fa20c0fcea..7f00e889051 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2299,7 +2299,7 @@ temperusb==1.5.3 # tensorflow==2.3.0 # homeassistant.components.powerwall -tesla-powerwall==0.3.10 +tesla-powerwall==0.3.11 # homeassistant.components.tensorflow # tf-models-official==2.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 98c9e90d6c4..86168f69065 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1303,7 +1303,7 @@ systembridge==2.1.0 tellduslive==0.10.11 # homeassistant.components.powerwall -tesla-powerwall==0.3.10 +tesla-powerwall==0.3.11 # homeassistant.components.toon toonapi==0.2.1 From 0e00075628f1b64bf31a35e3e403820e44e76ed6 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 5 Oct 2021 10:42:45 -0700 Subject: [PATCH 825/843] Bump aiohue to 2.6.3 (#57125) --- homeassistant/components/hue/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hue/manifest.json b/homeassistant/components/hue/manifest.json index 954ad1f7a7b..6640ffc9fae 100644 --- a/homeassistant/components/hue/manifest.json +++ b/homeassistant/components/hue/manifest.json @@ -3,7 +3,7 @@ "name": "Philips Hue", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/hue", - "requirements": ["aiohue==2.6.2"], + "requirements": ["aiohue==2.6.3"], "ssdp": [ { "manufacturer": "Royal Philips Electronics", diff --git a/requirements_all.txt b/requirements_all.txt index 7f00e889051..cfab25fd3ba 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -189,7 +189,7 @@ aiohomekit==0.6.3 aiohttp_cors==0.7.0 # homeassistant.components.hue -aiohue==2.6.2 +aiohue==2.6.3 # homeassistant.components.imap aioimaplib==0.9.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 86168f69065..66955cfe593 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -128,7 +128,7 @@ aiohomekit==0.6.3 aiohttp_cors==0.7.0 # homeassistant.components.hue -aiohue==2.6.2 +aiohue==2.6.3 # homeassistant.components.apache_kafka aiokafka==0.6.0 From 657037553a4e77006d170c7ae9b862d1208f62d0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 Oct 2021 10:41:56 -1000 Subject: [PATCH 826/843] Fix yeelight connection when bulb stops responding to SSDP (#57138) --- homeassistant/components/yeelight/__init__.py | 21 +++--- tests/components/yeelight/test_init.py | 67 +++++++++++-------- 2 files changed, 53 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py index e7f7b06f58f..a1dce44893b 100644 --- a/homeassistant/components/yeelight/__init__.py +++ b/homeassistant/components/yeelight/__init__.py @@ -181,6 +181,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: DATA_CUSTOM_EFFECTS: conf.get(CONF_CUSTOM_EFFECTS, {}), DATA_CONFIG_ENTRIES: {}, } + # Make sure the scanner is always started in case we are + # going to retry via ConfigEntryNotReady and the bulb has changed + # ip + scanner = YeelightScanner.async_get(hass) + await scanner.async_setup() # Import manually configured devices for host, device_config in config.get(DOMAIN, {}).get(CONF_DEVICES, {}).items(): @@ -281,11 +286,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: device = await _async_get_device(hass, entry.data[CONF_HOST], entry) except BULB_EXCEPTIONS as ex: - # If CONF_ID is not valid we cannot fallback to discovery - # so we must retry by raising ConfigEntryNotReady - if not entry.data.get(CONF_ID): - raise ConfigEntryNotReady from ex - # Otherwise fall through to discovery + # Always retry later since bulbs can stop responding to SSDP + # sometimes even though they are online. If it has changed + # IP we will update it via discovery to the config flow + raise ConfigEntryNotReady from ex else: # Since device is passed this cannot throw an exception anymore await _async_initialize(hass, entry, entry.data[CONF_HOST], device=device) @@ -298,7 +302,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except BULB_EXCEPTIONS: _LOGGER.exception("Failed to connect to bulb at %s", host) - # discovery scanner = YeelightScanner.async_get(hass) await scanner.async_register_callback(entry.data[CONF_ID], _async_from_discovery) return True @@ -501,7 +504,9 @@ class YeelightScanner: _LOGGER.debug("Discovered via SSDP: %s", response) unique_id = response["id"] host = urlparse(response["location"]).hostname - if unique_id not in self._unique_id_capabilities: + current_entry = self._unique_id_capabilities.get(unique_id) + # Make sure we handle ip changes + if not current_entry or host != urlparse(current_entry["location"]).hostname: _LOGGER.debug("Yeelight discovered with %s", response) self._async_discovered_by_ssdp(response) self._host_capabilities[host] = response @@ -571,7 +576,7 @@ class YeelightDevice: self._bulb_device = bulb self.capabilities = {} self._device_type = None - self._available = False + self._available = True self._initialized = False self._did_first_update = False self._name = None diff --git a/tests/components/yeelight/test_init.py b/tests/components/yeelight/test_init.py index aed2025ab5d..3ad99fa34ac 100644 --- a/tests/components/yeelight/test_init.py +++ b/tests/components/yeelight/test_init.py @@ -9,8 +9,6 @@ from homeassistant.components.yeelight import ( CONF_MODEL, CONF_NIGHTLIGHT_SWITCH, CONF_NIGHTLIGHT_SWITCH_TYPE, - DATA_CONFIG_ENTRIES, - DATA_DEVICE, DOMAIN, NIGHTLIGHT_SWITCH_TYPE_LIGHT, STATE_CHANGE_TIME, @@ -57,41 +55,41 @@ async def test_ip_changes_fallback_discovery(hass: HomeAssistant): ) config_entry.add_to_hass(hass) - mocked_bulb = _mocked_bulb(True) - mocked_bulb.bulb_type = BulbType.WhiteTempMood - mocked_bulb.async_listen = AsyncMock(side_effect=[BulbException, None, None, None]) + mocked_fail_bulb = _mocked_bulb(cannot_connect=True) + mocked_fail_bulb.bulb_type = BulbType.WhiteTempMood + with patch( + f"{MODULE}.AsyncBulb", return_value=mocked_fail_bulb + ), _patch_discovery(): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=2)) + await hass.async_block_till_done() + + # The discovery should update the ip address + assert config_entry.data[CONF_HOST] == IP_ADDRESS + assert config_entry.state is ConfigEntryState.SETUP_RETRY + mocked_bulb = _mocked_bulb() with patch(f"{MODULE}.AsyncBulb", return_value=mocked_bulb), _patch_discovery(): - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED binary_sensor_entity_id = ENTITY_BINARY_SENSOR_TEMPLATE.format( f"yeelight_color_{SHORT_ID}" ) - - type(mocked_bulb).async_get_properties = AsyncMock(None) - - await hass.data[DOMAIN][DATA_CONFIG_ENTRIES][config_entry.entry_id][ - DATA_DEVICE - ].async_update() - await hass.async_block_till_done() - await hass.async_block_till_done() - entity_registry = er.async_get(hass) assert entity_registry.async_get(binary_sensor_entity_id) is not None - with patch(f"{MODULE}.AsyncBulb", return_value=mocked_bulb), _patch_discovery(): - # The discovery should update the ip address - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=2)) - await hass.async_block_till_done() - assert config_entry.data[CONF_HOST] == IP_ADDRESS - # Make sure we can still reload with the new ip right after we change it with patch(f"{MODULE}.AsyncBulb", return_value=mocked_bulb), _patch_discovery(): await hass.config_entries.async_reload(config_entry.entry_id) await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + entity_registry = er.async_get(hass) assert entity_registry.async_get(binary_sensor_entity_id) is not None @@ -328,13 +326,21 @@ async def test_bulb_off_while_adding_in_ha(hass: HomeAssistant): ) config_entry.add_to_hass(hass) - mocked_bulb = _mocked_bulb(True) + mocked_bulb = _mocked_bulb(cannot_connect=True) mocked_bulb.bulb_type = BulbType.WhiteTempMood with patch(f"{MODULE}.AsyncBulb", return_value=mocked_bulb), _patch_discovery( no_device=True ), _patch_discovery_timeout(), _patch_discovery_interval(): - assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + with patch(f"{MODULE}.AsyncBulb", return_value=_mocked_bulb()), _patch_discovery( + no_device=True + ), _patch_discovery_timeout(), _patch_discovery_interval(): + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=2)) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED @@ -401,7 +407,7 @@ async def test_async_listen_error_has_host_with_id(hass: HomeAssistant): ): await hass.config_entries.async_setup(config_entry.entry_id) - assert config_entry.state is ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.SETUP_RETRY async def test_async_listen_error_has_host_without_id(hass: HomeAssistant): @@ -433,9 +439,16 @@ async def test_async_setup_with_missing_id(hass: HomeAssistant): f"{MODULE}.AsyncBulb", return_value=_mocked_bulb(cannot_connect=True) ): await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.SETUP_RETRY + assert config_entry.data[CONF_ID] == ID - assert config_entry.state is ConfigEntryState.LOADED - assert config_entry.data[CONF_ID] == ID + with _patch_discovery(), _patch_discovery_timeout(), _patch_discovery_interval(), patch( + f"{MODULE}.AsyncBulb", return_value=_mocked_bulb() + ): + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=2)) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED async def test_connection_dropped_resyncs_properties(hass: HomeAssistant): From 78bb2f5b731a379ae7bfcb515e2ef23db46c29e0 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 5 Oct 2021 13:32:48 -0700 Subject: [PATCH 827/843] Reinstate asking for country in Tuya flow (#57142) --- homeassistant/components/tuya/__init__.py | 17 +- homeassistant/components/tuya/config_flow.py | 37 ++- homeassistant/components/tuya/const.py | 270 +++++++++++++++++- homeassistant/components/tuya/strings.json | 4 +- .../components/tuya/translations/en.json | 67 +---- tests/components/tuya/test_config_flow.py | 17 +- 6 files changed, 312 insertions(+), 100 deletions(-) diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index df4689268cf..28c43d8df46 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -44,7 +44,10 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Async setup hass config entry.""" - hass.data[DOMAIN] = {entry.entry_id: {TUYA_HA_TUYA_MAP: {}, TUYA_HA_DEVICES: set()}} + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { + TUYA_HA_TUYA_MAP: {}, + TUYA_HA_DEVICES: set(), + } # Project type has been renamed to auth type in the upstream Tuya IoT SDK. # This migrates existing config entries to reflect that name change. @@ -54,6 +57,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.config_entries.async_update_entry(entry, data=data) success = await _init_tuya_sdk(hass, entry) + + if not success: + hass.data[DOMAIN].pop(entry.entry_id) + + if not hass.data[DOMAIN]: + hass.data.pop(DOMAIN) + return bool(success) @@ -143,7 +153,10 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN][entry.entry_id][TUYA_MQTT_LISTENER] ) - hass.data.pop(DOMAIN) + hass.data[DOMAIN].pop(entry.entry_id) + + if not hass.data[DOMAIN]: + hass.data.pop(DOMAIN) return unload diff --git a/homeassistant/components/tuya/config_flow.py b/homeassistant/components/tuya/config_flow.py index 8fffed3cd9f..bcde364ae1b 100644 --- a/homeassistant/components/tuya/config_flow.py +++ b/homeassistant/components/tuya/config_flow.py @@ -6,7 +6,6 @@ from typing import Any from tuya_iot import AuthType, TuyaOpenAPI import voluptuous as vol -from voluptuous.schema_builder import UNDEFINED from homeassistant import config_entries @@ -18,11 +17,10 @@ from .const import ( CONF_COUNTRY_CODE, CONF_ENDPOINT, CONF_PASSWORD, - CONF_REGION, CONF_USERNAME, DOMAIN, SMARTLIFE_APP, - TUYA_REGIONS, + TUYA_COUNTRIES, TUYA_RESPONSE_CODE, TUYA_RESPONSE_MSG, TUYA_RESPONSE_PLATFROM_URL, @@ -42,14 +40,20 @@ class TuyaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Try login.""" response = {} + country = [ + country + for country in TUYA_COUNTRIES + if country.name == user_input[CONF_COUNTRY_CODE] + ][0] + data = { - CONF_ENDPOINT: TUYA_REGIONS[user_input[CONF_REGION]], + CONF_ENDPOINT: country.endpoint, CONF_AUTH_TYPE: AuthType.CUSTOM, CONF_ACCESS_ID: user_input[CONF_ACCESS_ID], CONF_ACCESS_SECRET: user_input[CONF_ACCESS_SECRET], CONF_USERNAME: user_input[CONF_USERNAME], CONF_PASSWORD: user_input[CONF_PASSWORD], - CONF_COUNTRY_CODE: user_input[CONF_REGION], + CONF_COUNTRY_CODE: country.country_code, } for app_type in ("", TUYA_SMART_APP, SMARTLIFE_APP): @@ -109,29 +113,32 @@ class TuyaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): TUYA_RESPONSE_MSG: response.get(TUYA_RESPONSE_MSG), } - def _schema_default(key: str) -> str | UNDEFINED: - if not user_input: - return UNDEFINED - return user_input[key] + if user_input is None: + user_input = {} return self.async_show_form( step_id="user", data_schema=vol.Schema( { vol.Required( - CONF_REGION, default=_schema_default(CONF_REGION) - ): vol.In(TUYA_REGIONS.keys()), + CONF_COUNTRY_CODE, + default=user_input.get(CONF_COUNTRY_CODE, "United States"), + ): vol.In( + # We don't pass a dict {code:name} because country codes can be duplicate. + [country.name for country in TUYA_COUNTRIES] + ), vol.Required( - CONF_ACCESS_ID, default=_schema_default(CONF_ACCESS_ID) + CONF_ACCESS_ID, default=user_input.get(CONF_ACCESS_ID, "") ): str, vol.Required( - CONF_ACCESS_SECRET, default=_schema_default(CONF_ACCESS_SECRET) + CONF_ACCESS_SECRET, + default=user_input.get(CONF_ACCESS_SECRET, ""), ): str, vol.Required( - CONF_USERNAME, default=_schema_default(CONF_USERNAME) + CONF_USERNAME, default=user_input.get(CONF_USERNAME, "") ): str, vol.Required( - CONF_PASSWORD, default=_schema_default(CONF_PASSWORD) + CONF_PASSWORD, default=user_input.get(CONF_PASSWORD, "") ): str, } ), diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index 7c6440d7e48..44b66b576e3 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -1,4 +1,5 @@ """Constants for the Tuya integration.""" +from dataclasses import dataclass DOMAIN = "tuya" @@ -9,7 +10,6 @@ CONF_ACCESS_ID = "access_id" CONF_ACCESS_SECRET = "access_secret" CONF_USERNAME = "username" CONF_PASSWORD = "password" -CONF_REGION = "region" CONF_COUNTRY_CODE = "country_code" CONF_APP_TYPE = "tuya_app_type" @@ -31,13 +31,265 @@ TUYA_HA_SIGNAL_UPDATE_ENTITY = "tuya_entry_update" TUYA_SMART_APP = "tuyaSmart" SMARTLIFE_APP = "smartlife" -TUYA_REGIONS = { - "America": "https://openapi.tuyaus.com", - "China": "https://openapi.tuyacn.com", - "Eastern America": "https://openapi-ueaz.tuyaus.com", - "Europe": "https://openapi.tuyaeu.com", - "India": "https://openapi.tuyain.com", - "Western Europe": "https://openapi-weaz.tuyaeu.com", -} +ENDPOINT_AMERICA = "https://openapi.tuyaus.com" +ENDPOINT_CHINA = "https://openapi.tuyacn.com" +ENDPOINT_EASTERN_AMERICA = "https://openapi-ueaz.tuyaus.com" +ENDPOINT_EUROPE = "https://openapi.tuyaeu.com" +ENDPOINT_INDIA = "https://openapi.tuyain.com" +ENDPOINT_WESTERN_EUROPE = "https://openapi-weaz.tuyaeu.com" PLATFORMS = ["climate", "fan", "light", "scene", "switch"] + + +@dataclass +class Country: + """Describe a supported country.""" + + name: str + country_code: str + endpoint: str = ENDPOINT_AMERICA + + +# https://developer.tuya.com/en/docs/iot/oem-app-data-center-distributed?id=Kafi0ku9l07qb#title-4-China%20Data%20Center +TUYA_COUNTRIES = [ + Country("Afghanistan", "93"), + Country("Albania", "355"), + Country("Algeria", "213"), + Country("American Samoa", "1-684"), + Country("Andorra", "376"), + Country("Angola", "244"), + Country("Anguilla", "1-264"), + Country("Antarctica", "672"), + Country("Antigua and Barbuda", "1-268"), + Country("Argentina", "54", ENDPOINT_EUROPE), + Country("Armenia", "374"), + Country("Aruba", "297"), + Country("Australia", "61"), + Country("Austria", "43", ENDPOINT_EUROPE), + Country("Azerbaijan", "994"), + Country("Bahamas", "1-242"), + Country("Bahrain", "973"), + Country("Bangladesh", "880"), + Country("Barbados", "1-246"), + Country("Belarus", "375"), + Country("Belgium", "32", ENDPOINT_EUROPE), + Country("Belize", "501"), + Country("Benin", "229"), + Country("Bermuda", "1-441"), + Country("Bhutan", "975"), + Country("Bolivia", "591"), + Country("Bosnia and Herzegovina", "387"), + Country("Botswana", "267"), + Country("Brazil", "55", ENDPOINT_EUROPE), + Country("British Indian Ocean Territory", "246"), + Country("British Virgin Islands", "1-284"), + Country("Brunei", "673"), + Country("Bulgaria", "359"), + Country("Burkina Faso", "226"), + Country("Burundi", "257"), + Country("Cambodia", "855"), + Country("Cameroon", "237"), + Country("Canada", "1", ENDPOINT_AMERICA), + Country("Cape Verde", "238"), + Country("Cayman Islands", "1-345"), + Country("Central African Republic", "236"), + Country("Chad", "235"), + Country("Chile", "56"), + Country("China", "86", ENDPOINT_CHINA), + Country("Christmas Island", "61"), + Country("Cocos Islands", "61"), + Country("Colombia", "57"), + Country("Comoros", "269"), + Country("Cook Islands", "682"), + Country("Costa Rica", "506"), + Country("Croatia", "385", ENDPOINT_EUROPE), + Country("Cuba", "53"), + Country("Curacao", "599"), + Country("Cyprus", "357", ENDPOINT_EUROPE), + Country("Czech Republic", "420", ENDPOINT_EUROPE), + Country("Democratic Republic of the Congo", "243"), + Country("Denmark", "45", ENDPOINT_EUROPE), + Country("Djibouti", "253"), + Country("Dominica", "1-767"), + Country("Dominican Republic", "1-809"), + Country("East Timor", "670"), + Country("Ecuador", "593"), + Country("Egypt", "20"), + Country("El Salvador", "503"), + Country("Equatorial Guinea", "240"), + Country("Eritrea", "291"), + Country("Estonia", "372", ENDPOINT_EUROPE), + Country("Ethiopia", "251"), + Country("Falkland Islands", "500"), + Country("Faroe Islands", "298"), + Country("Fiji", "679"), + Country("Finland", "358", ENDPOINT_EUROPE), + Country("France", "33", ENDPOINT_EUROPE), + Country("French Polynesia", "689"), + Country("Gabon", "241"), + Country("Gambia", "220"), + Country("Georgia", "995"), + Country("Germany", "49", ENDPOINT_EUROPE), + Country("Ghana", "233"), + Country("Gibraltar", "350"), + Country("Greece", "30", ENDPOINT_EUROPE), + Country("Greenland", "299"), + Country("Grenada", "1-473"), + Country("Guam", "1-671"), + Country("Guatemala", "502"), + Country("Guernsey", "44-1481"), + Country("Guinea", "224"), + Country("Guinea-Bissau", "245"), + Country("Guyana", "592"), + Country("Haiti", "509"), + Country("Honduras", "504"), + Country("Hong Kong", "852"), + Country("Hungary", "36", ENDPOINT_EUROPE), + Country("Iceland", "354", ENDPOINT_EUROPE), + Country("India", "91", ENDPOINT_INDIA), + Country("Indonesia", "62"), + Country("Iran", "98"), + Country("Iraq", "964"), + Country("Ireland", "353", ENDPOINT_EUROPE), + Country("Isle of Man", "44-1624"), + Country("Israel", "972"), + Country("Italy", "39", ENDPOINT_EUROPE), + Country("Ivory Coast", "225"), + Country("Jamaica", "1-876"), + Country("Japan", "81", ENDPOINT_EUROPE), + Country("Jersey", "44-1534"), + Country("Jordan", "962"), + Country("Kazakhstan", "7"), + Country("Kenya", "254"), + Country("Kiribati", "686"), + Country("Kosovo", "383"), + Country("Kuwait", "965"), + Country("Kyrgyzstan", "996"), + Country("Laos", "856"), + Country("Latvia", "371", ENDPOINT_EUROPE), + Country("Lebanon", "961"), + Country("Lesotho", "266"), + Country("Liberia", "231"), + Country("Libya", "218"), + Country("Liechtenstein", "423", ENDPOINT_EUROPE), + Country("Lithuania", "370", ENDPOINT_EUROPE), + Country("Luxembourg", "352", ENDPOINT_EUROPE), + Country("Macau", "853"), + Country("Macedonia", "389"), + Country("Madagascar", "261"), + Country("Malawi", "265"), + Country("Malaysia", "60"), + Country("Maldives", "960"), + Country("Mali", "223"), + Country("Malta", "356", ENDPOINT_EUROPE), + Country("Marshall Islands", "692"), + Country("Mauritania", "222"), + Country("Mauritius", "230"), + Country("Mayotte", "262"), + Country("Mexico", "52"), + Country("Micronesia", "691"), + Country("Moldova", "373"), + Country("Monaco", "377"), + Country("Mongolia", "976"), + Country("Montenegro", "382"), + Country("Montserrat", "1-664"), + Country("Morocco", "212"), + Country("Mozambique", "258"), + Country("Myanmar", "95"), + Country("Namibia", "264"), + Country("Nauru", "674"), + Country("Nepal", "977"), + Country("Netherlands", "31", ENDPOINT_EUROPE), + Country("Netherlands Antilles", "599"), + Country("New Caledonia", "687"), + Country("New Zealand", "64"), + Country("Nicaragua", "505"), + Country("Niger", "227"), + Country("Nigeria", "234"), + Country("Niue", "683"), + Country("North Korea", "850"), + Country("Northern Mariana Islands", "1-670"), + Country("Norway", "47"), + Country("Oman", "968"), + Country("Pakistan", "92"), + Country("Palau", "680"), + Country("Palestine", "970"), + Country("Panama", "507"), + Country("Papua New Guinea", "675"), + Country("Paraguay", "595"), + Country("Peru", "51"), + Country("Philippines", "63"), + Country("Pitcairn", "64"), + Country("Poland", "48", ENDPOINT_EUROPE), + Country("Portugal", "351", ENDPOINT_EUROPE), + Country("Puerto Rico", "1-787, 1-939"), + Country("Qatar", "974"), + Country("Republic of the Congo", "242"), + Country("Reunion", "262"), + Country("Romania", "40", ENDPOINT_EUROPE), + Country("Russia", "7", ENDPOINT_EUROPE), + Country("Rwanda", "250"), + Country("Saint Barthelemy", "590"), + Country("Saint Helena", "290"), + Country("Saint Kitts and Nevis", "1-869"), + Country("Saint Lucia", "1-758"), + Country("Saint Martin", "590"), + Country("Saint Pierre and Miquelon", "508"), + Country("Saint Vincent and the Grenadines", "1-784"), + Country("Samoa", "685"), + Country("San Marino", "378"), + Country("Sao Tome and Principe", "239"), + Country("Saudi Arabia", "966"), + Country("Senegal", "221"), + Country("Serbia", "381"), + Country("Seychelles", "248"), + Country("Sierra Leone", "232"), + Country("Singapore", "65"), + Country("Sint Maarten", "1-721"), + Country("Slovakia", "421", ENDPOINT_EUROPE), + Country("Slovenia", "386", ENDPOINT_EUROPE), + Country("Solomon Islands", "677"), + Country("Somalia", "252"), + Country("South Africa", "27"), + Country("South Korea", "82"), + Country("South Sudan", "211"), + Country("Spain", "34", ENDPOINT_EUROPE), + Country("Sri Lanka", "94"), + Country("Sudan", "249"), + Country("Suriname", "597"), + Country("Svalbard and Jan Mayen", "47", ENDPOINT_EUROPE), + Country("Swaziland", "268"), + Country("Sweden", "46", ENDPOINT_EUROPE), + Country("Switzerland", "41"), + Country("Syria", "963"), + Country("Taiwan", "886"), + Country("Tajikistan", "992"), + Country("Tanzania", "255"), + Country("Thailand", "66"), + Country("Togo", "228"), + Country("Tokelau", "690"), + Country("Tonga", "676"), + Country("Trinidad and Tobago", "1-868"), + Country("Tunisia", "216"), + Country("Turkey", "90"), + Country("Turkmenistan", "993"), + Country("Turks and Caicos Islands", "1-649"), + Country("Tuvalu", "688"), + Country("U.S. Virgin Islands", "1-340"), + Country("Uganda", "256"), + Country("Ukraine", "380"), + Country("United Arab Emirates", "971"), + Country("United Kingdom", "44", ENDPOINT_EUROPE), + Country("United States", "1", ENDPOINT_AMERICA), + Country("Uruguay", "598"), + Country("Uzbekistan", "998"), + Country("Vanuatu", "678"), + Country("Vatican", "379"), + Country("Venezuela", "58"), + Country("Vietnam", "84"), + Country("Wallis and Futuna", "681"), + Country("Western Sahara", "212"), + Country("Yemen", "967"), + Country("Zambia", "260"), + Country("Zimbabwe", "263"), +] diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index 044c068ac9c..0bb59615e6e 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -4,7 +4,7 @@ "user": { "description": "Enter your Tuya credentials", "data": { - "region": "Region", + "country_code": "Country", "access_id": "Tuya IoT Access ID", "access_secret": "Tuya IoT Access Secret", "username": "Account", @@ -17,4 +17,4 @@ "login_error": "Login error ({code}): {msg}" } } -} \ No newline at end of file +} diff --git a/homeassistant/components/tuya/translations/en.json b/homeassistant/components/tuya/translations/en.json index 4b4b9a6d1dd..e69872fd309 100644 --- a/homeassistant/components/tuya/translations/en.json +++ b/homeassistant/components/tuya/translations/en.json @@ -1,82 +1,19 @@ { "config": { - "abort": { - "cannot_connect": "Failed to connect", - "invalid_auth": "Invalid authentication", - "single_instance_allowed": "Already configured. Only a single configuration possible." - }, "error": { "invalid_auth": "Invalid authentication", "login_error": "Login error ({code}): {msg}" }, - "flow_title": "Tuya configuration", "step": { - "login": { - "data": { - "access_id": "Access ID", - "access_secret": "Access Secret", - "country_code": "Country Code", - "endpoint": "Availability Zone", - "password": "Password", - "tuya_app_type": "Mobile App", - "username": "Account" - }, - "description": "Enter your Tuya credential", - "title": "Tuya" - }, "user": { "data": { "access_id": "Tuya IoT Access ID", "access_secret": "Tuya IoT Access Secret", - "country_code": "Your account country code (e.g., 1 for USA or 86 for China)", + "country_code": "Country", "password": "Password", - "platform": "The app where your account is registered", - "region": "Region", - "tuya_project_type": "Tuya cloud project type", "username": "Account" }, - "description": "Enter your Tuya credentials", - "title": "Tuya Integration" - } - } - }, - "options": { - "abort": { - "cannot_connect": "Failed to connect" - }, - "error": { - "dev_multi_type": "Multiple selected devices to configure must be of the same type", - "dev_not_config": "Device type not configurable", - "dev_not_found": "Device not found" - }, - "step": { - "device": { - "data": { - "brightness_range_mode": "Brightness range used by device", - "curr_temp_divider": "Current Temperature value divider (0 = use default)", - "max_kelvin": "Max color temperature supported in kelvin", - "max_temp": "Max target temperature (use min and max = 0 for default)", - "min_kelvin": "Min color temperature supported in kelvin", - "min_temp": "Min target temperature (use min and max = 0 for default)", - "set_temp_divided": "Use divided Temperature value for set temperature command", - "support_color": "Force color support", - "temp_divider": "Temperature values divider (0 = use default)", - "temp_step_override": "Target Temperature step", - "tuya_max_coltemp": "Max color temperature reported by device", - "unit_of_measurement": "Temperature unit used by device" - }, - "description": "Configure options to adjust displayed information for {device_type} device `{device_name}`", - "title": "Configure Tuya Device" - }, - "init": { - "data": { - "discovery_interval": "Discovery device polling interval in seconds", - "list_devices": "Select the devices to configure or leave empty to save configuration", - "query_device": "Select device that will use query method for faster status update", - "query_interval": "Query device polling interval in seconds" - }, - "description": "Do not set pollings interval values too low or the calls will fail generating error message in the log", - "title": "Configure Tuya Options" + "description": "Enter your Tuya credentials" } } } diff --git a/tests/components/tuya/test_config_flow.py b/tests/components/tuya/test_config_flow.py index 745bcfde661..a5aee459bd7 100644 --- a/tests/components/tuya/test_config_flow.py +++ b/tests/components/tuya/test_config_flow.py @@ -12,13 +12,14 @@ from homeassistant.components.tuya.const import ( CONF_ACCESS_SECRET, CONF_APP_TYPE, CONF_AUTH_TYPE, + CONF_COUNTRY_CODE, CONF_ENDPOINT, CONF_PASSWORD, - CONF_REGION, CONF_USERNAME, DOMAIN, + ENDPOINT_INDIA, SMARTLIFE_APP, - TUYA_REGIONS, + TUYA_COUNTRIES, TUYA_SMART_APP, ) from homeassistant.core import HomeAssistant @@ -26,15 +27,15 @@ from homeassistant.core import HomeAssistant MOCK_SMART_HOME_PROJECT_TYPE = 0 MOCK_INDUSTRY_PROJECT_TYPE = 1 -MOCK_REGION = "Europe" +MOCK_COUNTRY = "India" MOCK_ACCESS_ID = "myAccessId" MOCK_ACCESS_SECRET = "myAccessSecret" MOCK_USERNAME = "myUsername" MOCK_PASSWORD = "myPassword" -MOCK_ENDPOINT = "https://openapi-ueaz.tuyaus.com" +MOCK_ENDPOINT = ENDPOINT_INDIA TUYA_INPUT_DATA = { - CONF_REGION: MOCK_REGION, + CONF_COUNTRY_CODE: MOCK_COUNTRY, CONF_ACCESS_ID: MOCK_ACCESS_ID, CONF_ACCESS_SECRET: MOCK_ACCESS_SECRET, CONF_USERNAME: MOCK_USERNAME, @@ -92,16 +93,18 @@ async def test_user_flow( ) await hass.async_block_till_done() + country = [country for country in TUYA_COUNTRIES if country.name == MOCK_COUNTRY][0] + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["title"] == MOCK_USERNAME assert result["data"][CONF_ACCESS_ID] == MOCK_ACCESS_ID assert result["data"][CONF_ACCESS_SECRET] == MOCK_ACCESS_SECRET assert result["data"][CONF_USERNAME] == MOCK_USERNAME assert result["data"][CONF_PASSWORD] == MOCK_PASSWORD - assert result["data"][CONF_ENDPOINT] == MOCK_ENDPOINT - assert result["data"][CONF_ENDPOINT] != TUYA_REGIONS[TUYA_INPUT_DATA[CONF_REGION]] + assert result["data"][CONF_ENDPOINT] == country.endpoint assert result["data"][CONF_APP_TYPE] == app_type assert result["data"][CONF_AUTH_TYPE] == project_type + assert result["data"][CONF_COUNTRY_CODE] == country.country_code assert not result["result"].unique_id From dbde2f1b92f944eff83b4789d95ac2b54b82ca01 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Tue, 5 Oct 2021 16:33:23 -0400 Subject: [PATCH 828/843] Bump zwave-js-server-python to 0.31.3 (#57143) --- homeassistant/components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index e80549d815d..50e0a039488 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -3,7 +3,7 @@ "name": "Z-Wave JS", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zwave_js", - "requirements": ["zwave-js-server-python==0.31.2"], + "requirements": ["zwave-js-server-python==0.31.3"], "codeowners": ["@home-assistant/z-wave"], "dependencies": ["usb", "http", "websocket_api"], "iot_class": "local_push", diff --git a/requirements_all.txt b/requirements_all.txt index cfab25fd3ba..c715088b8b7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2504,4 +2504,4 @@ zigpy==0.38.0 zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.31.2 +zwave-js-server-python==0.31.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 66955cfe593..31a5c6a5d1c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1430,4 +1430,4 @@ zigpy-znp==0.5.4 zigpy==0.38.0 # homeassistant.components.zwave_js -zwave-js-server-python==0.31.2 +zwave-js-server-python==0.31.3 From 73bf53873646fd4baa11238c078cc959879d93e9 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 5 Oct 2021 13:45:52 -0700 Subject: [PATCH 829/843] Bumped version to 2021.10.0b7 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 01c031ce339..7e9d4c36286 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -5,7 +5,7 @@ from typing import Final MAJOR_VERSION: Final = 2021 MINOR_VERSION: Final = 10 -PATCH_VERSION: Final = "0b6" +PATCH_VERSION: Final = "0b7" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0) From 76a6edb4ed4b45c7c753e749da80883c64aeecfb Mon Sep 17 00:00:00 2001 From: Lawrence Date: Wed, 6 Oct 2021 14:34:23 +1100 Subject: [PATCH 830/843] Updated amberelectic attributes to reflect unit change to $/kWh (#57109) --- .../components/amberelectric/sensor.py | 33 +++++++++++-------- tests/components/amberelectric/test_sensor.py | 33 +++++++++---------- 2 files changed, 35 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/amberelectric/sensor.py b/homeassistant/components/amberelectric/sensor.py index 0a47615046e..974de2d5c15 100644 --- a/homeassistant/components/amberelectric/sensor.py +++ b/homeassistant/components/amberelectric/sensor.py @@ -37,6 +37,11 @@ ICONS = { UNIT = f"{CURRENCY_DOLLAR}/{ENERGY_KILO_WATT_HOUR}" +def format_cents_to_dollars(cents: float) -> float: + """Return a formatted conversion from cents to dollars.""" + return round(cents / 100, 2) + + def friendly_channel_type(channel_type: str) -> str: """Return a human readable version of the channel type.""" if channel_type == "controlled_load": @@ -70,13 +75,13 @@ class AmberPriceSensor(AmberSensor): """Amber Price Sensor.""" @property - def native_value(self) -> str | None: + def native_value(self) -> float | None: """Return the current price in $/kWh.""" interval = self.coordinator.data[self.entity_description.key][self.channel_type] if interval.channel_type == ChannelType.FEED_IN: - return round(interval.per_kwh, 0) / 100 * -1 - return round(interval.per_kwh, 0) / 100 + return format_cents_to_dollars(interval.per_kwh) * -1 + return format_cents_to_dollars(interval.per_kwh) @property def device_state_attributes(self) -> Mapping[str, Any] | None: @@ -89,11 +94,11 @@ class AmberPriceSensor(AmberSensor): data["duration"] = interval.duration data["date"] = interval.date.isoformat() - data["per_kwh"] = round(interval.per_kwh) + data["per_kwh"] = format_cents_to_dollars(interval.per_kwh) if interval.channel_type == ChannelType.FEED_IN: data["per_kwh"] = data["per_kwh"] * -1 data["nem_date"] = interval.nem_time.isoformat() - data["spot_per_kwh"] = round(interval.spot_per_kwh) + data["spot_per_kwh"] = format_cents_to_dollars(interval.spot_per_kwh) data["start_time"] = interval.start_time.isoformat() data["end_time"] = interval.end_time.isoformat() data["renewables"] = round(interval.renewables) @@ -102,8 +107,8 @@ class AmberPriceSensor(AmberSensor): data["channel_type"] = interval.channel_type.value if interval.range is not None: - data["range_min"] = interval.range.min - data["range_max"] = interval.range.max + data["range_min"] = format_cents_to_dollars(interval.range.min) + data["range_max"] = format_cents_to_dollars(interval.range.max) return data @@ -112,7 +117,7 @@ class AmberForecastSensor(AmberSensor): """Amber Forecast Sensor.""" @property - def native_value(self) -> str | None: + def native_value(self) -> float | None: """Return the first forecast price in $/kWh.""" intervals = self.coordinator.data[self.entity_description.key].get( self.channel_type @@ -122,8 +127,8 @@ class AmberForecastSensor(AmberSensor): interval = intervals[0] if interval.channel_type == ChannelType.FEED_IN: - return round(interval.per_kwh, 0) / 100 * -1 - return round(interval.per_kwh, 0) / 100 + return format_cents_to_dollars(interval.per_kwh) * -1 + return format_cents_to_dollars(interval.per_kwh) @property def device_state_attributes(self) -> Mapping[str, Any] | None: @@ -146,18 +151,18 @@ class AmberForecastSensor(AmberSensor): datum["duration"] = interval.duration datum["date"] = interval.date.isoformat() datum["nem_date"] = interval.nem_time.isoformat() - datum["per_kwh"] = round(interval.per_kwh) + datum["per_kwh"] = format_cents_to_dollars(interval.per_kwh) if interval.channel_type == ChannelType.FEED_IN: datum["per_kwh"] = datum["per_kwh"] * -1 - datum["spot_per_kwh"] = round(interval.spot_per_kwh) + datum["spot_per_kwh"] = format_cents_to_dollars(interval.spot_per_kwh) datum["start_time"] = interval.start_time.isoformat() datum["end_time"] = interval.end_time.isoformat() datum["renewables"] = round(interval.renewables) datum["spike_status"] = interval.spike_status.value if interval.range is not None: - datum["range_min"] = interval.range.min - datum["range_max"] = interval.range.max + datum["range_min"] = format_cents_to_dollars(interval.range.min) + datum["range_max"] = format_cents_to_dollars(interval.range.max) data["forecasts"].append(datum) diff --git a/tests/components/amberelectric/test_sensor.py b/tests/components/amberelectric/test_sensor.py index 865121bd1ee..ccfcd82b3bd 100644 --- a/tests/components/amberelectric/test_sensor.py +++ b/tests/components/amberelectric/test_sensor.py @@ -108,9 +108,9 @@ async def test_general_price_sensor(hass: HomeAssistant, setup_general: Mock) -> attributes = price.attributes assert attributes["duration"] == 30 assert attributes["date"] == "2021-09-21" - assert attributes["per_kwh"] == 8 + assert attributes["per_kwh"] == 0.08 assert attributes["nem_date"] == "2021-09-21T08:30:00+10:00" - assert attributes["spot_per_kwh"] == 1 + assert attributes["spot_per_kwh"] == 0.01 assert attributes["start_time"] == "2021-09-21T08:00:00+10:00" assert attributes["end_time"] == "2021-09-21T08:30:00+10:00" assert attributes["renewables"] == 51 @@ -132,8 +132,8 @@ async def test_general_price_sensor(hass: HomeAssistant, setup_general: Mock) -> price = hass.states.get("sensor.mock_title_general_price") assert price attributes = price.attributes - assert attributes.get("range_min") == 7.8 - assert attributes.get("range_max") == 12.4 + assert attributes.get("range_min") == 0.08 + assert attributes.get("range_max") == 0.12 async def test_general_and_controlled_load_price_sensor( @@ -141,16 +141,15 @@ async def test_general_and_controlled_load_price_sensor( ) -> None: """Test the Controlled Price sensor.""" assert len(hass.states.async_all()) == 6 - print(hass.states) price = hass.states.get("sensor.mock_title_controlled_load_price") assert price assert price.state == "0.08" attributes = price.attributes assert attributes["duration"] == 30 assert attributes["date"] == "2021-09-21" - assert attributes["per_kwh"] == 8 + assert attributes["per_kwh"] == 0.08 assert attributes["nem_date"] == "2021-09-21T08:30:00+10:00" - assert attributes["spot_per_kwh"] == 1 + assert attributes["spot_per_kwh"] == 0.01 assert attributes["start_time"] == "2021-09-21T08:00:00+10:00" assert attributes["end_time"] == "2021-09-21T08:30:00+10:00" assert attributes["renewables"] == 51 @@ -172,9 +171,9 @@ async def test_general_and_feed_in_price_sensor( attributes = price.attributes assert attributes["duration"] == 30 assert attributes["date"] == "2021-09-21" - assert attributes["per_kwh"] == -8 + assert attributes["per_kwh"] == -0.08 assert attributes["nem_date"] == "2021-09-21T08:30:00+10:00" - assert attributes["spot_per_kwh"] == 1 + assert attributes["spot_per_kwh"] == 0.01 assert attributes["start_time"] == "2021-09-21T08:00:00+10:00" assert attributes["end_time"] == "2021-09-21T08:30:00+10:00" assert attributes["renewables"] == 51 @@ -199,9 +198,9 @@ async def test_general_forecast_sensor( first_forecast = attributes["forecasts"][0] assert first_forecast["duration"] == 30 assert first_forecast["date"] == "2021-09-21" - assert first_forecast["per_kwh"] == 9 + assert first_forecast["per_kwh"] == 0.09 assert first_forecast["nem_date"] == "2021-09-21T09:00:00+10:00" - assert first_forecast["spot_per_kwh"] == 1 + assert first_forecast["spot_per_kwh"] == 0.01 assert first_forecast["start_time"] == "2021-09-21T08:30:00+10:00" assert first_forecast["end_time"] == "2021-09-21T09:00:00+10:00" assert first_forecast["renewables"] == 50 @@ -222,8 +221,8 @@ async def test_general_forecast_sensor( assert price attributes = price.attributes first_forecast = attributes["forecasts"][0] - assert first_forecast.get("range_min") == 7.8 - assert first_forecast.get("range_max") == 12.4 + assert first_forecast.get("range_min") == 0.08 + assert first_forecast.get("range_max") == 0.12 async def test_controlled_load_forecast_sensor( @@ -241,9 +240,9 @@ async def test_controlled_load_forecast_sensor( first_forecast = attributes["forecasts"][0] assert first_forecast["duration"] == 30 assert first_forecast["date"] == "2021-09-21" - assert first_forecast["per_kwh"] == 9 + assert first_forecast["per_kwh"] == 0.09 assert first_forecast["nem_date"] == "2021-09-21T09:00:00+10:00" - assert first_forecast["spot_per_kwh"] == 1 + assert first_forecast["spot_per_kwh"] == 0.01 assert first_forecast["start_time"] == "2021-09-21T08:30:00+10:00" assert first_forecast["end_time"] == "2021-09-21T09:00:00+10:00" assert first_forecast["renewables"] == 50 @@ -265,9 +264,9 @@ async def test_feed_in_forecast_sensor( first_forecast = attributes["forecasts"][0] assert first_forecast["duration"] == 30 assert first_forecast["date"] == "2021-09-21" - assert first_forecast["per_kwh"] == -9 + assert first_forecast["per_kwh"] == -0.09 assert first_forecast["nem_date"] == "2021-09-21T09:00:00+10:00" - assert first_forecast["spot_per_kwh"] == 1 + assert first_forecast["spot_per_kwh"] == 0.01 assert first_forecast["start_time"] == "2021-09-21T08:30:00+10:00" assert first_forecast["end_time"] == "2021-09-21T09:00:00+10:00" assert first_forecast["renewables"] == 50 From 8394157c0aef68466654bf9146958fb5376eada1 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 6 Oct 2021 05:26:18 +0200 Subject: [PATCH 831/843] Fix Fritz shutdown race condition (#57148) --- homeassistant/components/fritz/common.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/fritz/common.py b/homeassistant/components/fritz/common.py index 0fb062af2d7..61cff890a93 100644 --- a/homeassistant/components/fritz/common.py +++ b/homeassistant/components/fritz/common.py @@ -228,7 +228,12 @@ class FritzBoxTools: def _update_hosts_info(self) -> list[HostInfo]: """Retrieve latest hosts information from the FRITZ!Box.""" - return self.fritz_hosts.get_hosts_info() # type: ignore [no-any-return] + try: + return self.fritz_hosts.get_hosts_info() # type: ignore [no-any-return] + except Exception as ex: # pylint: disable=[broad-except] + if not self.hass.is_stopping: + raise HomeAssistantError("Error refreshing hosts info") from ex + return [] def _update_device_info(self) -> tuple[bool, str | None]: """Retrieve latest device information from the FRITZ!Box.""" From 7502956d2b4737eb0fde1a34f46dc8aeada02f82 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 6 Oct 2021 05:25:57 +0200 Subject: [PATCH 832/843] Fix SamsungTV shutdown race condition (#57149) --- homeassistant/components/samsungtv/media_player.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py index 8644335959e..cab6435af95 100644 --- a/homeassistant/components/samsungtv/media_player.py +++ b/homeassistant/components/samsungtv/media_player.py @@ -133,7 +133,7 @@ class SamsungTVDevice(MediaPlayerEntity): def update(self) -> None: """Update state of device.""" - if self._auth_failed: + if self._auth_failed or self.hass.is_stopping: return if self._power_off_in_progress(): self._state = STATE_OFF From 8e02ea19365cad3ef3d32f7dcd46b213a371f4e1 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 5 Oct 2021 21:31:11 -0700 Subject: [PATCH 833/843] Guard upnp create device (#57156) --- homeassistant/components/upnp/__init__.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py index 14a2c39d3d9..6db8b087378 100644 --- a/homeassistant/components/upnp/__init__.py +++ b/homeassistant/components/upnp/__init__.py @@ -8,6 +8,7 @@ from datetime import timedelta from ipaddress import ip_address from typing import Any +from async_upnp_client.exceptions import UpnpConnectionError import voluptuous as vol from homeassistant import config_entries @@ -122,7 +123,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: location = discovery_info[ # pylint: disable=unsubscriptable-object ssdp.ATTR_SSDP_LOCATION ] - device = await Device.async_create_device(hass, location) + try: + device = await Device.async_create_device(hass, location) + except UpnpConnectionError as err: + LOGGER.debug("Error connecting to device %s", location) + raise ConfigEntryNotReady from err # Ensure entry has a unique_id. if not entry.unique_id: From 790a61cfaeaada985e8b291b32a55e566fa0db76 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 5 Oct 2021 21:32:11 -0700 Subject: [PATCH 834/843] Bumped version to 2021.10.0b8 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 7e9d4c36286..01e6c5bd07a 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -5,7 +5,7 @@ from typing import Final MAJOR_VERSION: Final = 2021 MINOR_VERSION: Final = 10 -PATCH_VERSION: Final = "0b7" +PATCH_VERSION: Final = "0b8" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0) From bb617d7b89ca5000aed2d80925807a89fd071dd0 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 1 Oct 2021 15:38:49 -0700 Subject: [PATCH 835/843] Bump netdisco to 3.0.0 (#56903) --- homeassistant/components/discovery/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/discovery/manifest.json b/homeassistant/components/discovery/manifest.json index 558c727c62c..1b7d51c1716 100644 --- a/homeassistant/components/discovery/manifest.json +++ b/homeassistant/components/discovery/manifest.json @@ -2,7 +2,7 @@ "domain": "discovery", "name": "Discovery", "documentation": "https://www.home-assistant.io/integrations/discovery", - "requirements": ["netdisco==2.9.0"], + "requirements": ["netdisco==3.0.0"], "after_dependencies": ["zeroconf"], "codeowners": [], "quality_scale": "internal" diff --git a/requirements_all.txt b/requirements_all.txt index c715088b8b7..74b58de7d14 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1047,7 +1047,7 @@ nessclient==0.9.15 netdata==0.2.0 # homeassistant.components.discovery -netdisco==2.9.0 +netdisco==3.0.0 # homeassistant.components.nmap_tracker netmap==0.7.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 31a5c6a5d1c..9a493f07be7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -612,7 +612,7 @@ ndms2_client==0.1.1 nessclient==0.9.15 # homeassistant.components.discovery -netdisco==2.9.0 +netdisco==3.0.0 # homeassistant.components.nmap_tracker netmap==0.7.0.2 From a692d4de64daf3790427f6e21121d6a4e66676fd Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 5 Oct 2021 21:34:22 -0700 Subject: [PATCH 836/843] Bumped version to 2021.10.0b9 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 01e6c5bd07a..a6a2680eb8a 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -5,7 +5,7 @@ from typing import Final MAJOR_VERSION: Final = 2021 MINOR_VERSION: Final = 10 -PATCH_VERSION: Final = "0b8" +PATCH_VERSION: Final = "0b9" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0) From 9e755fcc4943077dd3b523922d08d3a599a77d1c Mon Sep 17 00:00:00 2001 From: Jean-Yves Avenard Date: Wed, 6 Oct 2021 22:24:17 +1100 Subject: [PATCH 837/843] Change energy state class to STATE_CLASS_TOTAL (#56974) --- homeassistant/components/iotawatt/sensor.py | 4 ++-- tests/components/iotawatt/test_sensor.py | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/iotawatt/sensor.py b/homeassistant/components/iotawatt/sensor.py index ba0ec30caa0..ec2918b0ce6 100644 --- a/homeassistant/components/iotawatt/sensor.py +++ b/homeassistant/components/iotawatt/sensor.py @@ -9,7 +9,7 @@ from iotawattpy.sensor import Sensor from homeassistant.components.sensor import ( STATE_CLASS_MEASUREMENT, - STATE_CLASS_TOTAL_INCREASING, + STATE_CLASS_TOTAL, SensorEntity, SensorEntityDescription, ) @@ -83,6 +83,7 @@ ENTITY_DESCRIPTION_KEY_MAP: dict[str, IotaWattSensorEntityDescription] = { "WattHours": IotaWattSensorEntityDescription( "WattHours", native_unit_of_measurement=ENERGY_WATT_HOUR, + state_class=STATE_CLASS_TOTAL, device_class=DEVICE_CLASS_ENERGY, ), "VA": IotaWattSensorEntityDescription( @@ -242,7 +243,6 @@ class IotaWattAccumulatingSensor(IotaWattSensor, RestoreEntity): super().__init__(coordinator, key, entity_description) - self._attr_state_class = STATE_CLASS_TOTAL_INCREASING if self._attr_unique_id is not None: self._attr_unique_id += ".accumulated" diff --git a/tests/components/iotawatt/test_sensor.py b/tests/components/iotawatt/test_sensor.py index 8928c012d48..2397338c22c 100644 --- a/tests/components/iotawatt/test_sensor.py +++ b/tests/components/iotawatt/test_sensor.py @@ -6,7 +6,7 @@ from homeassistant.components.sensor import ( ATTR_STATE_CLASS, DEVICE_CLASS_ENERGY, STATE_CLASS_MEASUREMENT, - STATE_CLASS_TOTAL_INCREASING, + STATE_CLASS_TOTAL, ) from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -74,7 +74,7 @@ async def test_sensor_type_output(hass, mock_iotawatt): state = hass.states.get("sensor.my_watthour_sensor") assert state is not None assert state.state == "243" - assert ATTR_STATE_CLASS not in state.attributes + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL assert state.attributes[ATTR_FRIENDLY_NAME] == "My WattHour Sensor" assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == ENERGY_WATT_HOUR assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_ENERGY @@ -125,7 +125,7 @@ async def test_sensor_type_accumulated_output(hass, mock_iotawatt): state.attributes[ATTR_FRIENDLY_NAME] == "My WattHour Accumulated Output Sensor.wh Accumulated" ) - assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == ENERGY_WATT_HOUR assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_ENERGY assert state.attributes["type"] == "Output" @@ -166,7 +166,7 @@ async def test_sensor_type_accumulated_output_error_restore(hass, mock_iotawatt) state.attributes[ATTR_FRIENDLY_NAME] == "My WattHour Accumulated Output Sensor.wh Accumulated" ) - assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == ENERGY_WATT_HOUR assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_ENERGY assert state.attributes["type"] == "Output" @@ -224,7 +224,7 @@ async def test_sensor_type_multiple_accumulated_output(hass, mock_iotawatt): state.attributes[ATTR_FRIENDLY_NAME] == "My WattHour Accumulated Output Sensor.wh Accumulated" ) - assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == ENERGY_WATT_HOUR assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_ENERGY assert state.attributes["type"] == "Output" From 72b3bc13e48d9df8da1a53c38ef76c4c4d0046c4 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 6 Oct 2021 13:30:13 +0200 Subject: [PATCH 838/843] Remove Netgear tracker link_rate check on Orbi (#57032) * Netgear tracker: remove link_rate check on Orbi * fix debug message * Add orbi models * check start of model in V2 check * fix black --- homeassistant/components/netgear/const.py | 19 ++++++++++++++++++- homeassistant/components/netgear/router.py | 11 ++++++----- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/netgear/const.py b/homeassistant/components/netgear/const.py index 8b520485e1e..325d9e68cd8 100644 --- a/homeassistant/components/netgear/const.py +++ b/homeassistant/components/netgear/const.py @@ -11,7 +11,24 @@ DEFAULT_CONSIDER_HOME = timedelta(seconds=180) DEFAULT_NAME = "Netgear router" # update method V2 models -MODELS_V2 = ["Orbi"] +MODELS_V2 = [ + "Orbi", + "RBK", + "RBR", + "RBS", + "RBW", + "LBK", + "LBR", + "CBK", + "CBR", + "SRC", + "SRK", + "SRR", + "SRS", + "SXK", + "SXR", + "SXS", +] # Icons DEVICE_ICONS = { diff --git a/homeassistant/components/netgear/router.py b/homeassistant/components/netgear/router.py index 83b1aaa9f32..53cc4f32728 100644 --- a/homeassistant/components/netgear/router.py +++ b/homeassistant/components/netgear/router.py @@ -146,8 +146,9 @@ class NetgearRouter: self.model = self._info.get("ModelName") self.firmware_version = self._info.get("Firmwareversion") - if self.model in MODELS_V2: - self._method_version = 2 + for model in MODELS_V2: + if self.model.startswith(model): + self._method_version = 2 async def async_setup(self) -> None: """Set up a Netgear router.""" @@ -198,12 +199,12 @@ class NetgearRouter: ntg_devices = await self.async_get_attached_devices() now = dt_util.utcnow() + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug("Netgear scan result: \n%s", ntg_devices) + for ntg_device in ntg_devices: device_mac = format_mac(ntg_device.mac) - if self._method_version == 2 and not ntg_device.link_rate: - continue - if not self.devices.get(device_mac): new_device = True From 2fc9cdbe68a6f620c0518c2bd4e9375260ba06dc Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Wed, 6 Oct 2021 10:07:30 +0200 Subject: [PATCH 839/843] Update Daikin config_flow with better error handling (#57069) --- .../components/daikin/config_flow.py | 17 ++++++++------- homeassistant/components/daikin/strings.json | 1 + .../components/daikin/translations/en.json | 1 + tests/components/daikin/test_config_flow.py | 21 ++++++++++++++----- 4 files changed, 27 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/daikin/config_flow.py b/homeassistant/components/daikin/config_flow.py index ea0709e5557..43d169e3440 100644 --- a/homeassistant/components/daikin/config_flow.py +++ b/homeassistant/components/daikin/config_flow.py @@ -75,7 +75,8 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): uuid=uuid, password=password, ) - except asyncio.TimeoutError: + except (asyncio.TimeoutError, ClientError): + self.host = None return self.async_show_form( step_id="user", data_schema=self.schema, @@ -87,13 +88,6 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): data_schema=self.schema, errors={"base": "invalid_auth"}, ) - except ClientError: - _LOGGER.exception("ClientError") - return self.async_show_form( - step_id="user", - data_schema=self.schema, - errors={"base": "unknown"}, - ) except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected error creating device") return self.async_show_form( @@ -109,6 +103,13 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """User initiated config flow.""" if user_input is None: return self.async_show_form(step_id="user", data_schema=self.schema) + if user_input.get(CONF_API_KEY) and user_input.get(CONF_PASSWORD): + self.host = user_input.get(CONF_HOST) + return self.async_show_form( + step_id="user", + data_schema=self.schema, + errors={"base": "api_password"}, + ) return await self._create_device( user_input[CONF_HOST], user_input.get(CONF_API_KEY), diff --git a/homeassistant/components/daikin/strings.json b/homeassistant/components/daikin/strings.json index fc2b6e79a5e..5c759384795 100644 --- a/homeassistant/components/daikin/strings.json +++ b/homeassistant/components/daikin/strings.json @@ -18,6 +18,7 @@ "error": { "unknown": "[%key:common::config_flow::error::unknown%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "api_password": "[%key:common::config_flow::error::invalid_auth%], use either API Key or Password.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" } } diff --git a/homeassistant/components/daikin/translations/en.json b/homeassistant/components/daikin/translations/en.json index d1db170d769..84843ba8211 100644 --- a/homeassistant/components/daikin/translations/en.json +++ b/homeassistant/components/daikin/translations/en.json @@ -5,6 +5,7 @@ "cannot_connect": "Failed to connect" }, "error": { + "api_password": "Invalid authentication, use either API Key or Password.", "cannot_connect": "Failed to connect", "invalid_auth": "Invalid authentication", "unknown": "Unexpected error" diff --git a/tests/components/daikin/test_config_flow.py b/tests/components/daikin/test_config_flow.py index 624268d7ee3..91ea79f4aa7 100644 --- a/tests/components/daikin/test_config_flow.py +++ b/tests/components/daikin/test_config_flow.py @@ -3,13 +3,12 @@ import asyncio from unittest.mock import PropertyMock, patch -from aiohttp import ClientError -from aiohttp.web_exceptions import HTTPForbidden +from aiohttp import ClientError, web_exceptions import pytest from homeassistant.components.daikin.const import KEY_MAC from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PASSWORD from homeassistant.data_entry_flow import ( RESULT_TYPE_ABORT, RESULT_TYPE_CREATE_ENTRY, @@ -84,8 +83,8 @@ async def test_abort_if_already_setup(hass, mock_daikin): "s_effect,reason", [ (asyncio.TimeoutError, "cannot_connect"), - (HTTPForbidden, "invalid_auth"), - (ClientError, "unknown"), + (ClientError, "cannot_connect"), + (web_exceptions.HTTPForbidden, "invalid_auth"), (Exception, "unknown"), ], ) @@ -103,6 +102,18 @@ async def test_device_abort(hass, mock_daikin, s_effect, reason): assert result["step_id"] == "user" +async def test_api_password_abort(hass): + """Test device abort.""" + result = await hass.config_entries.flow.async_init( + "daikin", + context={"source": SOURCE_USER}, + data={CONF_HOST: HOST, CONF_API_KEY: "aa", CONF_PASSWORD: "aa"}, + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {"base": "api_password"} + assert result["step_id"] == "user" + + @pytest.mark.parametrize( "source, data, unique_id", [ From e01e5750928b9a4966da49b0fe968c3250b71cdf Mon Sep 17 00:00:00 2001 From: Thomas Schamm Date: Wed, 6 Oct 2021 10:09:02 +0200 Subject: [PATCH 840/843] Skip link local addresses in bosch_shc discovery step (#57074) --- .../components/bosch_shc/config_flow.py | 18 ++++++++++---- .../components/bosch_shc/test_config_flow.py | 24 ++++++++++++++++++- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/bosch_shc/config_flow.py b/homeassistant/components/bosch_shc/config_flow.py index 4415a0ff6ef..416dc6cf304 100644 --- a/homeassistant/components/bosch_shc/config_flow.py +++ b/homeassistant/components/bosch_shc/config_flow.py @@ -187,16 +187,26 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_abort(reason="not_bosch_shc") try: - self.info = info = await self._get_info(discovery_info["host"]) + hosts = ( + discovery_info["host"] + if isinstance(discovery_info["host"], list) + else [discovery_info["host"]] + ) + for host in hosts: + if host.startswith("169."): # skip link local address + continue + self.info = await self._get_info(host) + self.host = host + if self.host is None: + return self.async_abort(reason="cannot_connect") except SHCConnectionError: return self.async_abort(reason="cannot_connect") local_name = discovery_info["hostname"][:-1] node_name = local_name[: -len(".local")] - await self.async_set_unique_id(info["unique_id"]) - self._abort_if_unique_id_configured({CONF_HOST: discovery_info["host"]}) - self.host = discovery_info["host"] + await self.async_set_unique_id(self.info["unique_id"]) + self._abort_if_unique_id_configured({CONF_HOST: self.host}) self.context["title_placeholders"] = {"name": node_name} return await self.async_step_confirm_discovery() diff --git a/tests/components/bosch_shc/test_config_flow.py b/tests/components/bosch_shc/test_config_flow.py index 6d8ef9bd32e..543d0438738 100644 --- a/tests/components/bosch_shc/test_config_flow.py +++ b/tests/components/bosch_shc/test_config_flow.py @@ -20,7 +20,7 @@ MOCK_SETTINGS = { "device": {"mac": "test-mac", "hostname": "test-host"}, } DISCOVERY_INFO = { - "host": "1.1.1.1", + "host": ["169.1.1.1", "1.1.1.1"], "port": 0, "hostname": "shc012345.local.", "type": "_http._tcp.local.", @@ -526,6 +526,28 @@ async def test_zeroconf_cannot_connect(hass, mock_zeroconf): assert result["reason"] == "cannot_connect" +async def test_zeroconf_link_local(hass, mock_zeroconf): + """Test we get the form.""" + DISCOVERY_INFO_LINK_LOCAL = { + "host": ["169.1.1.1"], + "port": 0, + "hostname": "shc012345.local.", + "type": "_http._tcp.local.", + "name": "Bosch SHC [test-mac]._http._tcp.local.", + } + + with patch( + "boschshcpy.session.SHCSession.mdns_info", side_effect=SHCConnectionError + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=DISCOVERY_INFO_LINK_LOCAL, + context={"source": config_entries.SOURCE_ZEROCONF}, + ) + assert result["type"] == "abort" + assert result["reason"] == "cannot_connect" + + async def test_zeroconf_not_bosch_shc(hass, mock_zeroconf): """Test we filter out non-bosch_shc devices.""" result = await hass.config_entries.flow.async_init( From 9636799dfb67263d21cf3505eccab8e6b3fd3ddd Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 6 Oct 2021 11:33:55 +0200 Subject: [PATCH 841/843] Update frontend to 20211006.0 (#57164) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 3b047fbe245..121cf6ea754 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", "requirements": [ - "home-assistant-frontend==20211004.0" + "home-assistant-frontend==20211006.0" ], "dependencies": [ "api", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a496bc80169..c263e0f4f3c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -15,7 +15,7 @@ ciso8601==2.2.0 cryptography==3.4.8 emoji==1.5.0 hass-nabucasa==0.50.0 -home-assistant-frontend==20211004.0 +home-assistant-frontend==20211006.0 httpx==0.19.0 ifaddr==0.1.7 jinja2==3.0.1 diff --git a/requirements_all.txt b/requirements_all.txt index 74b58de7d14..92a334de096 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -810,7 +810,7 @@ hole==0.5.1 holidays==0.11.3.1 # homeassistant.components.frontend -home-assistant-frontend==20211004.0 +home-assistant-frontend==20211006.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9a493f07be7..0d00bf4a27b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -485,7 +485,7 @@ hole==0.5.1 holidays==0.11.3.1 # homeassistant.components.frontend -home-assistant-frontend==20211004.0 +home-assistant-frontend==20211006.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 From 25fc479cd43b2d4666acc1c8a05e9262da5afa17 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 6 Oct 2021 13:29:42 +0200 Subject: [PATCH 842/843] Correct migration to recorder schema 18 (#57165) --- .../components/recorder/migration.py | 47 +++++++++---------- 1 file changed, 21 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 1ced8b73207..a3d2955e55b 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -470,17 +470,20 @@ def _apply_update(instance, session, new_version, old_version): # noqa: C901 elif new_version == 18: # Recreate the statistics and statistics meta tables. # - # Order matters! Statistics has a relation with StatisticsMeta, - # so statistics need to be deleted before meta (or in pair depending - # on the SQL backend); and meta needs to be created before statistics. - if sqlalchemy.inspect(engine).has_table( - StatisticsMeta.__tablename__ - ) or sqlalchemy.inspect(engine).has_table(Statistics.__tablename__): - Base.metadata.drop_all( - bind=engine, tables=[Statistics.__table__, StatisticsMeta.__table__] - ) + # Order matters! Statistics and StatisticsShortTerm have a relation with + # StatisticsMeta, so statistics need to be deleted before meta (or in pair + # depending on the SQL backend); and meta needs to be created before statistics. + Base.metadata.drop_all( + bind=engine, + tables=[ + StatisticsShortTerm.__table__, + Statistics.__table__, + StatisticsMeta.__table__, + ], + ) StatisticsMeta.__table__.create(engine) + StatisticsShortTerm.__table__.create(engine) Statistics.__table__.create(engine) elif new_version == 19: # This adds the statistic runs table, insert a fake run to prevent duplicating @@ -527,23 +530,15 @@ def _apply_update(instance, session, new_version, old_version): # noqa: C901 # so statistics need to be deleted before meta (or in pair depending # on the SQL backend); and meta needs to be created before statistics. if engine.dialect.name == "oracle": - if ( - sqlalchemy.inspect(engine).has_table(StatisticsMeta.__tablename__) - or sqlalchemy.inspect(engine).has_table(Statistics.__tablename__) - or sqlalchemy.inspect(engine).has_table(StatisticsRuns.__tablename__) - or sqlalchemy.inspect(engine).has_table( - StatisticsShortTerm.__tablename__ - ) - ): - Base.metadata.drop_all( - bind=engine, - tables=[ - StatisticsShortTerm.__table__, - Statistics.__table__, - StatisticsMeta.__table__, - StatisticsRuns.__table__, - ], - ) + Base.metadata.drop_all( + bind=engine, + tables=[ + StatisticsShortTerm.__table__, + Statistics.__table__, + StatisticsMeta.__table__, + StatisticsRuns.__table__, + ], + ) StatisticsRuns.__table__.create(engine) StatisticsMeta.__table__.create(engine) From 46394b50d88d9acf5899bc17f7c7a05702dada31 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 6 Oct 2021 15:53:41 +0200 Subject: [PATCH 843/843] Bumped version to 2021.10.0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index a6a2680eb8a..707e7d87b01 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -5,7 +5,7 @@ from typing import Final MAJOR_VERSION: Final = 2021 MINOR_VERSION: Final = 10 -PATCH_VERSION: Final = "0b9" +PATCH_VERSION: Final = "0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0)