Merge branch 'dev' into feature/starlink-device-tracker

This commit is contained in:
Jack Boswell
2023-08-13 15:07:40 +12:00
committed by GitHub
31 changed files with 615 additions and 200 deletions

View File

@@ -56,12 +56,12 @@ from .coordinator import DwdWeatherWarningsCoordinator
SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key=CURRENT_WARNING_SENSOR,
name="Current Warning Level",
translation_key=CURRENT_WARNING_SENSOR,
icon="mdi:close-octagon-outline",
),
SensorEntityDescription(
key=ADVANCE_WARNING_SENSOR,
name="Advance Warning Level",
translation_key=ADVANCE_WARNING_SENSOR,
icon="mdi:close-octagon-outline",
),
)
@@ -131,6 +131,7 @@ class DwdWeatherWarningsSensor(
"""Representation of a DWD-Weather-Warnings sensor."""
_attr_attribution = "Data provided by DWD"
_attr_has_entity_name = True
def __init__(
self,
@@ -142,7 +143,6 @@ class DwdWeatherWarningsSensor(
super().__init__(coordinator)
self.entity_description = description
self._attr_name = f"{DEFAULT_NAME} {entry.title} {description.name}"
self._attr_unique_id = f"{entry.unique_id}-{description.key}"
self._attr_device_info = DeviceInfo(

View File

@@ -15,5 +15,15 @@
"already_configured": "Warncell ID / name is already configured.",
"invalid_identifier": "[%key:component::dwd_weather_warnings::config::error::invalid_identifier%]"
}
},
"entity": {
"sensor": {
"current_warning_level": {
"name": "Current warning level"
},
"advance_warning_level": {
"name": "Advance warning level"
}
}
}
}

View File

@@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/enphase_envoy",
"iot_class": "local_polling",
"loggers": ["pyenphase"],
"requirements": ["pyenphase==1.4.0"],
"requirements": ["pyenphase==1.5.2"],
"zeroconf": [
{
"type": "_enphase-envoy._tcp.local."

View File

@@ -8,6 +8,7 @@ import logging
from pyenphase import (
EnvoyEncharge,
EnvoyEnchargeAggregate,
EnvoyEnchargePower,
EnvoyEnpower,
EnvoyInverter,
@@ -288,6 +289,58 @@ ENPOWER_SENSORS = (
)
@dataclass
class EnvoyEnchargeAggregateRequiredKeysMixin:
"""Mixin for required keys."""
value_fn: Callable[[EnvoyEnchargeAggregate], int]
@dataclass
class EnvoyEnchargeAggregateSensorEntityDescription(
SensorEntityDescription, EnvoyEnchargeAggregateRequiredKeysMixin
):
"""Describes an Envoy Encharge sensor entity."""
ENCHARGE_AGGREGATE_SENSORS = (
EnvoyEnchargeAggregateSensorEntityDescription(
key="battery_level",
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.BATTERY,
value_fn=lambda encharge: encharge.state_of_charge,
),
EnvoyEnchargeAggregateSensorEntityDescription(
key="reserve_soc",
translation_key="reserve_soc",
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.BATTERY,
value_fn=lambda encharge: encharge.reserve_state_of_charge,
),
EnvoyEnchargeAggregateSensorEntityDescription(
key="available_energy",
translation_key="available_energy",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
value_fn=lambda encharge: encharge.available_energy,
),
EnvoyEnchargeAggregateSensorEntityDescription(
key="reserve_energy",
translation_key="reserve_energy",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
value_fn=lambda encharge: encharge.backup_reserve,
),
EnvoyEnchargeAggregateSensorEntityDescription(
key="max_capacity",
translation_key="max_capacity",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
value_fn=lambda encharge: encharge.max_available_capacity,
),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
@@ -329,6 +382,11 @@ async def async_setup_entry(
for description in ENCHARGE_POWER_SENSORS
for encharge in envoy_data.encharge_power
)
if envoy_data.encharge_aggregate:
entities.extend(
EnvoyEnchargeAggregateEntity(coordinator, description)
for description in ENCHARGE_AGGREGATE_SENSORS
)
if envoy_data.enpower:
entities.extend(
EnvoyEnpowerEntity(coordinator, description)
@@ -482,6 +540,19 @@ class EnvoyEnchargePowerEntity(EnvoyEnchargeEntity):
return self.entity_description.value_fn(encharge_power[self._serial_number])
class EnvoyEnchargeAggregateEntity(EnvoySystemSensorEntity):
"""Envoy Encharge Aggregate sensor entity."""
entity_description: EnvoyEnchargeAggregateSensorEntityDescription
@property
def native_value(self) -> int:
"""Return the state of the aggregate sensors."""
encharge_aggregate = self.data.encharge_aggregate
assert encharge_aggregate is not None
return self.entity_description.value_fn(encharge_aggregate)
class EnvoyEnpowerEntity(EnvoySensorBaseEntity):
"""Envoy Enpower sensor entity."""

View File

@@ -63,6 +63,21 @@
},
"lifetime_consumption": {
"name": "Lifetime energy consumption"
},
"reserve_soc": {
"name": "Reserve battery level"
},
"available_energy": {
"name": "Available battery energy"
},
"reserve_energy": {
"name": "Reserve battery energy"
},
"max_capacity": {
"name": "Battery capacity"
},
"configured_reserve_soc": {
"name": "Configured reserve battery level"
}
},
"switch": {

View File

@@ -105,8 +105,8 @@ def esphome_state_property(
if not self._has_state:
return None
val = func(self)
if isinstance(val, float) and math.isnan(val):
# Home Assistant doesn't use NAN values in state machine
if isinstance(val, float) and not math.isfinite(val):
# Home Assistant doesn't use NaN or inf values in state machine
# (not JSON serializable)
return None
return val

View File

@@ -73,7 +73,7 @@ class EsphomeNumber(EsphomeEntity[NumberInfo, NumberState], NumberEntity):
def native_value(self) -> float | None:
"""Return the state of the entity."""
state = self._state
if state.missing_state or math.isnan(state.state):
if state.missing_state or not math.isfinite(state.state):
return None
return state.state

View File

@@ -96,7 +96,7 @@ class EsphomeSensor(EsphomeEntity[SensorInfo, SensorState], SensorEntity):
def native_value(self) -> datetime | str | None:
"""Return the state of the entity."""
state = self._state
if math.isnan(state.state) or state.missing_state:
if state.missing_state or not math.isfinite(state.state):
return None
if self._attr_device_class == SensorDeviceClass.TIMESTAMP:
return dt_util.utc_from_timestamp(state.state)

View File

@@ -54,5 +54,5 @@
"iot_class": "local_push",
"loggers": ["flux_led"],
"quality_scale": "platinum",
"requirements": ["flux-led==1.0.1"]
"requirements": ["flux-led==1.0.2"]
}

View File

@@ -1,6 +1,8 @@
"""Support for Google Assistant SDK."""
from __future__ import annotations
import dataclasses
import aiohttp
from gassist_text import TextAssistant
from google.oauth2.credentials import Credentials
@@ -9,7 +11,12 @@ import voluptuous as vol
from homeassistant.components import conversation
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_NAME, Platform
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.core import (
HomeAssistant,
ServiceCall,
ServiceResponse,
SupportsResponse,
)
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv, discovery, intent
from homeassistant.helpers.config_entry_oauth2_flow import (
@@ -101,19 +108,30 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_service(hass: HomeAssistant) -> None:
"""Add the services for Google Assistant SDK."""
async def send_text_command(call: ServiceCall) -> None:
async def send_text_command(call: ServiceCall) -> ServiceResponse:
"""Send a text command to Google Assistant SDK."""
commands: list[str] = call.data[SERVICE_SEND_TEXT_COMMAND_FIELD_COMMAND]
media_players: list[str] | None = call.data.get(
SERVICE_SEND_TEXT_COMMAND_FIELD_MEDIA_PLAYER
)
await async_send_text_commands(hass, commands, media_players)
command_response_list = await async_send_text_commands(
hass, commands, media_players
)
if call.return_response:
return {
"responses": [
dataclasses.asdict(command_response)
for command_response in command_response_list
]
}
return None
hass.services.async_register(
DOMAIN,
SERVICE_SEND_TEXT_COMMAND,
send_text_command,
schema=SERVICE_SEND_TEXT_COMMAND_SCHEMA,
supports_response=SupportsResponse.OPTIONAL,
)

View File

@@ -1,6 +1,7 @@
"""Helper classes for Google Assistant SDK integration."""
from __future__ import annotations
from dataclasses import dataclass
from http import HTTPStatus
import logging
from typing import Any
@@ -48,9 +49,16 @@ DEFAULT_LANGUAGE_CODES = {
}
@dataclass
class CommandResponse:
"""Response from a single command to Google Assistant Service."""
text: str
async def async_send_text_commands(
hass: HomeAssistant, commands: list[str], media_players: list[str] | None = None
) -> None:
) -> list[CommandResponse]:
"""Send text commands to Google Assistant Service."""
# There can only be 1 entry (config_flow has single_instance_allowed)
entry: ConfigEntry = hass.config_entries.async_entries(DOMAIN)[0]
@@ -68,6 +76,7 @@ async def async_send_text_commands(
with TextAssistant(
credentials, language_code, audio_out=bool(media_players)
) as assistant:
command_response_list = []
for command in commands:
resp = assistant.assist(command)
text_response = resp[0]
@@ -91,6 +100,8 @@ async def async_send_text_commands(
},
blocking=True,
)
command_response_list.append(CommandResponse(text_response))
return command_response_list
def default_language_code(hass: HomeAssistant):

View File

@@ -2,10 +2,10 @@
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE, CONF_NAME
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME
import homeassistant.helpers.config_validation as cv
from .const import DOMAIN, FORECAST_MODE, HOME_LOCATION_NAME
from .const import DOMAIN, HOME_LOCATION_NAME
class IpmaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
@@ -47,7 +47,6 @@ class IpmaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
vol.Required(CONF_NAME, default=name): str,
vol.Required(CONF_LATITUDE, default=latitude): cv.latitude,
vol.Required(CONF_LONGITUDE, default=longitude): cv.longitude,
vol.Required(CONF_MODE, default="daily"): vol.In(FORECAST_MODE),
}
),
errors=self._errors,

View File

@@ -49,6 +49,4 @@ CONDITION_CLASSES = {
ATTR_CONDITION_CLEAR_NIGHT: [-1],
}
FORECAST_MODE = ["hourly", "daily"]
ATTRIBUTION = "Instituto Português do Mar e Atmosfera"

View File

@@ -1,11 +1,14 @@
"""Support for IPMA weather service."""
from __future__ import annotations
import asyncio
import contextlib
import logging
from typing import Literal
import async_timeout
from pyipma.api import IPMA_API
from pyipma.forecast import Forecast
from pyipma.forecast import Forecast as IPMAForecast
from pyipma.location import Location
from homeassistant.components.weather import (
@@ -16,7 +19,9 @@ from homeassistant.components.weather import (
ATTR_FORECAST_PRECIPITATION_PROBABILITY,
ATTR_FORECAST_TIME,
ATTR_FORECAST_WIND_BEARING,
Forecast,
WeatherEntity,
WeatherEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
@@ -26,8 +31,7 @@ from homeassistant.const import (
UnitOfSpeed,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.sun import is_up
from homeassistant.util import Throttle
@@ -53,39 +57,19 @@ async def async_setup_entry(
"""Add a weather entity from a config_entry."""
api = hass.data[DOMAIN][config_entry.entry_id][DATA_API]
location = hass.data[DOMAIN][config_entry.entry_id][DATA_LOCATION]
mode = config_entry.data[CONF_MODE]
# Migrate old unique_id
@callback
def _async_migrator(entity_entry: er.RegistryEntry):
# Reject if new unique_id
if entity_entry.unique_id.count(",") == 2:
return None
new_unique_id = (
f"{location.station_latitude}, {location.station_longitude}, {mode}"
)
_LOGGER.info(
"Migrating unique_id from [%s] to [%s]",
entity_entry.unique_id,
new_unique_id,
)
return {"new_unique_id": new_unique_id}
await er.async_migrate_entries(hass, config_entry.entry_id, _async_migrator)
async_add_entities([IPMAWeather(location, api, config_entry.data)], True)
class IPMAWeather(WeatherEntity, IPMADevice):
"""Representation of a weather condition."""
_attr_attribution = ATTRIBUTION
_attr_native_pressure_unit = UnitOfPressure.HPA
_attr_native_temperature_unit = UnitOfTemperature.CELSIUS
_attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR
_attr_attribution = ATTRIBUTION
_attr_supported_features = (
WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY
)
def __init__(self, location: Location, api: IPMA_API, config) -> None:
"""Initialise the platform with a data instance and station name."""
@@ -95,25 +79,35 @@ class IPMAWeather(WeatherEntity, IPMADevice):
self._mode = config.get(CONF_MODE)
self._period = 1 if config.get(CONF_MODE) == "hourly" else 24
self._observation = None
self._forecast: list[Forecast] = []
self._attr_unique_id = f"{self._location.station_latitude}, {self._location.station_longitude}, {self._mode}"
self._daily_forecast: list[IPMAForecast] | None = None
self._hourly_forecast: list[IPMAForecast] | None = None
if self._mode is not None:
self._attr_unique_id = f"{self._location.station_latitude}, {self._location.station_longitude}, {self._mode}"
else:
self._attr_unique_id = (
f"{self._location.station_latitude}, {self._location.station_longitude}"
)
@Throttle(MIN_TIME_BETWEEN_UPDATES)
async def async_update(self) -> None:
"""Update Condition and Forecast."""
async with async_timeout.timeout(10):
new_observation = await self._location.observation(self._api)
new_forecast = await self._location.forecast(self._api, self._period)
if new_observation:
self._observation = new_observation
else:
_LOGGER.warning("Could not update weather observation")
if new_forecast:
self._forecast = new_forecast
if self._period == 24 or self._forecast_listeners["daily"]:
await self._update_forecast("daily", 24, True)
else:
_LOGGER.warning("Could not update weather forecast")
self._daily_forecast = None
if self._period == 1 or self._forecast_listeners["hourly"]:
await self._update_forecast("hourly", 1, True)
else:
self._hourly_forecast = None
_LOGGER.debug(
"Updated location %s based on %s, current observation %s",
@@ -122,6 +116,21 @@ class IPMAWeather(WeatherEntity, IPMADevice):
self._observation,
)
async def _update_forecast(
self,
forecast_type: Literal["daily", "hourly"],
period: int,
update_listeners: bool,
) -> None:
"""Update weather forecast."""
new_forecast = await self._location.forecast(self._api, period)
if new_forecast:
setattr(self, f"_{forecast_type}_forecast", new_forecast)
if update_listeners:
await self.async_update_listeners((forecast_type,))
else:
_LOGGER.warning("Could not update %s weather forecast", forecast_type)
def _condition_conversion(self, identifier, forecast_dt):
"""Convert from IPMA weather_type id to HA."""
if identifier == 1 and not is_up(self.hass, forecast_dt):
@@ -135,10 +144,12 @@ class IPMAWeather(WeatherEntity, IPMADevice):
@property
def condition(self):
"""Return the current condition."""
if not self._forecast:
forecast = self._hourly_forecast or self._daily_forecast
if not forecast:
return
return self._condition_conversion(self._forecast[0].weather_type.id, None)
return self._condition_conversion(forecast[0].weather_type.id, None)
@property
def native_temperature(self):
@@ -180,10 +191,9 @@ class IPMAWeather(WeatherEntity, IPMADevice):
return self._observation.wind_direction
@property
def forecast(self):
def _forecast(self, forecast: list[IPMAForecast] | None) -> list[Forecast]:
"""Return the forecast array."""
if not self._forecast:
if not forecast:
return []
return [
@@ -198,5 +208,32 @@ class IPMAWeather(WeatherEntity, IPMADevice):
ATTR_FORECAST_NATIVE_WIND_SPEED: data_in.wind_strength,
ATTR_FORECAST_WIND_BEARING: data_in.wind_direction,
}
for data_in in self._forecast
for data_in in forecast
]
@property
def forecast(self) -> list[Forecast]:
"""Return the forecast array."""
return self._forecast(
self._hourly_forecast if self._period == 1 else self._daily_forecast
)
async def _try_update_forecast(
self,
forecast_type: Literal["daily", "hourly"],
period: int,
) -> None:
"""Try to update weather forecast."""
with contextlib.suppress(asyncio.TimeoutError):
async with async_timeout.timeout(10):
await self._update_forecast(forecast_type, period, False)
async def async_forecast_daily(self) -> list[Forecast]:
"""Return the daily forecast in native units."""
await self._try_update_forecast("daily", 24)
return self._forecast(self._daily_forecast)
async def async_forecast_hourly(self) -> list[Forecast]:
"""Return the hourly forecast in native units."""
await self._try_update_forecast("hourly", 1)
return self._forecast(self._hourly_forecast)

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import datetime, timedelta
import logging
from typing import Any
@@ -62,6 +63,19 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
return unload_ok
@dataclass
class MinecraftServerData:
"""Representation of Minecraft server data."""
latency: float | None = None
motd: str | None = None
players_max: int | None = None
players_online: int | None = None
players_list: list[str] | None = None
protocol_version: int | None = None
version: str | None = None
class MinecraftServer:
"""Representation of a Minecraft server."""
@@ -84,13 +98,7 @@ class MinecraftServer:
self._server = JavaServer(self.host, self.port)
# Data provided by 3rd party library
self.version: str | None = None
self.protocol_version: int | None = None
self.latency: float | None = None
self.players_online: int | None = None
self.players_max: int | None = None
self.players_list: list[str] | None = None
self.motd: str | None = None
self.data: MinecraftServerData = MinecraftServerData()
# Dispatcher signal name
self.signal_name = f"{SIGNAL_NAME_PREFIX}_{self.unique_id}"
@@ -170,18 +178,18 @@ class MinecraftServer:
status_response = await self._server.async_status()
# Got answer to request, update properties.
self.version = status_response.version.name
self.protocol_version = status_response.version.protocol
self.players_online = status_response.players.online
self.players_max = status_response.players.max
self.latency = status_response.latency
self.motd = status_response.motd.to_plain()
self.data.version = status_response.version.name
self.data.protocol_version = status_response.version.protocol
self.data.players_online = status_response.players.online
self.data.players_max = status_response.players.max
self.data.latency = status_response.latency
self.data.motd = status_response.motd.to_plain()
self.players_list = []
self.data.players_list = []
if status_response.players.sample is not None:
for player in status_response.players.sample:
self.players_list.append(player.name)
self.players_list.sort()
self.data.players_list.append(player.name)
self.data.players_list.sort()
# Inform user once about successful update if necessary.
if self._last_status_request_failed:
@@ -193,13 +201,13 @@ class MinecraftServer:
self._last_status_request_failed = False
except OSError as error:
# No answer to request, set all properties to unknown.
self.version = None
self.protocol_version = None
self.players_online = None
self.players_max = None
self.latency = None
self.players_list = None
self.motd = None
self.data.version = None
self.data.protocol_version = None
self.data.players_online = None
self.data.players_max = None
self.data.latency = None
self.data.players_list = None
self.data.motd = None
# Inform user once about failed update if necessary.
if not self._last_status_request_failed:

View File

@@ -29,9 +29,9 @@ class MinecraftServerEntity(Entity):
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._server.unique_id)},
manufacturer=MANUFACTURER,
model=f"Minecraft Server ({self._server.version})",
model=f"Minecraft Server ({self._server.data.version})",
name=self._server.name,
sw_version=str(self._server.protocol_version),
sw_version=f"{self._server.data.protocol_version}",
)
self._attr_device_class = device_class
self._extra_state_attributes = None

View File

@@ -89,7 +89,7 @@ class MinecraftServerVersionSensor(MinecraftServerSensorEntity):
async def async_update(self) -> None:
"""Update version."""
self._attr_native_value = self._server.version
self._attr_native_value = self._server.data.version
class MinecraftServerProtocolVersionSensor(MinecraftServerSensorEntity):
@@ -107,7 +107,7 @@ class MinecraftServerProtocolVersionSensor(MinecraftServerSensorEntity):
async def async_update(self) -> None:
"""Update protocol version."""
self._attr_native_value = self._server.protocol_version
self._attr_native_value = self._server.data.protocol_version
class MinecraftServerLatencySensor(MinecraftServerSensorEntity):
@@ -126,7 +126,7 @@ class MinecraftServerLatencySensor(MinecraftServerSensorEntity):
async def async_update(self) -> None:
"""Update latency."""
self._attr_native_value = self._server.latency
self._attr_native_value = self._server.data.latency
class MinecraftServerPlayersOnlineSensor(MinecraftServerSensorEntity):
@@ -145,13 +145,13 @@ class MinecraftServerPlayersOnlineSensor(MinecraftServerSensorEntity):
async def async_update(self) -> None:
"""Update online players state and device state attributes."""
self._attr_native_value = self._server.players_online
self._attr_native_value = self._server.data.players_online
extra_state_attributes = {}
players_list = self._server.players_list
players_list = self._server.data.players_list
if players_list is not None and len(players_list) != 0:
extra_state_attributes[ATTR_PLAYERS_LIST] = self._server.players_list
extra_state_attributes[ATTR_PLAYERS_LIST] = players_list
self._attr_extra_state_attributes = extra_state_attributes
@@ -172,7 +172,7 @@ class MinecraftServerPlayersMaxSensor(MinecraftServerSensorEntity):
async def async_update(self) -> None:
"""Update maximum number of players."""
self._attr_native_value = self._server.players_max
self._attr_native_value = self._server.data.players_max
class MinecraftServerMOTDSensor(MinecraftServerSensorEntity):
@@ -190,4 +190,4 @@ class MinecraftServerMOTDSensor(MinecraftServerSensorEntity):
async def async_update(self) -> None:
"""Update MOTD."""
self._attr_native_value = self._server.motd
self._attr_native_value = self._server.data.motd

View File

@@ -1,7 +1,6 @@
"""Plugwise Climate component for Home Assistant."""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from homeassistant.components.climate import (
@@ -145,14 +144,6 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity):
"""Return the current preset mode."""
return self.device.get("active_preset")
@property
def extra_state_attributes(self) -> Mapping[str, Any] | None:
"""Return entity specific state attributes."""
return {
"available_schemas": self.device["available_schedules"],
"selected_schema": self.device["select_schedule"],
}
@plugwise_command
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/rainbird",
"iot_class": "local_polling",
"loggers": ["pyrainbird"],
"requirements": ["pyrainbird==3.0.0"]
"requirements": ["pyrainbird==4.0.0"]
}

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/roborock",
"iot_class": "local_polling",
"loggers": ["roborock"],
"requirements": ["python-roborock==0.32.2"]
"requirements": ["python-roborock==0.32.3"]
}

View File

@@ -327,7 +327,7 @@ class TadoConnector:
device_type,
"ON",
mode,
fanSpeed=fan_speed,
fan_speed=fan_speed,
swing=swing,
)

View File

@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/todoist",
"iot_class": "cloud_polling",
"loggers": ["todoist"],
"requirements": ["todoist-api-python==2.0.2"]
"requirements": ["todoist-api-python==2.1.1"]
}

View File

@@ -8,7 +8,7 @@
"iot_class": "local_push",
"loggers": ["aiounifi"],
"quality_scale": "platinum",
"requirements": ["aiounifi==52"],
"requirements": ["aiounifi==53"],
"ssdp": [
{
"manufacturer": "Ubiquiti Networks",

View File

@@ -12,11 +12,13 @@ from typing import Generic
from aiounifi.interfaces.api_handlers import ItemEvent
from aiounifi.interfaces.clients import Clients
from aiounifi.interfaces.devices import Devices
from aiounifi.interfaces.outlets import Outlets
from aiounifi.interfaces.ports import Ports
from aiounifi.interfaces.wlans import Wlans
from aiounifi.models.api import ApiItemT
from aiounifi.models.client import Client
from aiounifi.models.device import Device
from aiounifi.models.outlet import Outlet
from aiounifi.models.port import Port
from aiounifi.models.wlan import Wlan
@@ -96,6 +98,12 @@ def async_device_outlet_power_supported_fn(
return controller.api.outlets[obj_id].caps == 3
@callback
def async_device_outlet_supported_fn(controller: UniFiController, obj_id: str) -> bool:
"""Determine if a device supports reading overall power metrics."""
return controller.api.devices[obj_id].outlet_ac_power_budget is not None
@dataclass
class UnifiSensorEntityDescriptionMixin(Generic[HandlerT, ApiItemT]):
"""Validate and load entities from different UniFi handlers."""
@@ -224,6 +232,46 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = (
unique_id_fn=lambda controller, obj_id: f"outlet_power-{obj_id}",
value_fn=lambda _, obj: obj.power if obj.relay_state else "0",
),
UnifiSensorEntityDescription[Devices, Device](
key="SmartPower AC power budget",
device_class=SensorDeviceClass.POWER,
entity_category=EntityCategory.DIAGNOSTIC,
native_unit_of_measurement=UnitOfPower.WATT,
suggested_display_precision=1,
has_entity_name=True,
allowed_fn=lambda controller, obj_id: True,
api_handler_fn=lambda api: api.devices,
available_fn=async_device_available_fn,
device_info_fn=async_device_device_info_fn,
event_is_on=None,
event_to_subscribe=None,
name_fn=lambda device: "AC Power Budget",
object_fn=lambda api, obj_id: api.devices[obj_id],
should_poll=False,
supported_fn=async_device_outlet_supported_fn,
unique_id_fn=lambda controller, obj_id: f"ac_power_budget-{obj_id}",
value_fn=lambda controller, device: device.outlet_ac_power_budget,
),
UnifiSensorEntityDescription[Devices, Device](
key="SmartPower AC power consumption",
device_class=SensorDeviceClass.POWER,
entity_category=EntityCategory.DIAGNOSTIC,
native_unit_of_measurement=UnitOfPower.WATT,
suggested_display_precision=1,
has_entity_name=True,
allowed_fn=lambda controller, obj_id: True,
api_handler_fn=lambda api: api.devices,
available_fn=async_device_available_fn,
device_info_fn=async_device_device_info_fn,
event_is_on=None,
event_to_subscribe=None,
name_fn=lambda device: "AC Power Consumption",
object_fn=lambda api, obj_id: api.devices[obj_id],
should_poll=False,
supported_fn=async_device_outlet_supported_fn,
unique_id_fn=lambda controller, obj_id: f"ac_power_conumption-{obj_id}",
value_fn=lambda controller, device: device.outlet_ac_power_consumption,
),
)

View File

@@ -360,7 +360,7 @@ aiosyncthing==0.5.1
aiotractive==0.5.5
# homeassistant.components.unifi
aiounifi==52
aiounifi==53
# homeassistant.components.vlc_telnet
aiovlc==0.1.0
@@ -800,7 +800,7 @@ fjaraskupan==2.2.0
flipr-api==1.5.0
# homeassistant.components.flux_led
flux-led==1.0.1
flux-led==1.0.2
# homeassistant.components.homekit
# homeassistant.components.recorder
@@ -1665,7 +1665,7 @@ pyedimax==0.2.1
pyefergy==22.1.1
# homeassistant.components.enphase_envoy
pyenphase==1.4.0
pyenphase==1.5.2
# homeassistant.components.envisalink
pyenvisalink==4.6
@@ -1955,7 +1955,7 @@ pyqwikswitch==0.93
pyrail==0.0.3
# homeassistant.components.rainbird
pyrainbird==3.0.0
pyrainbird==4.0.0
# homeassistant.components.recswitch
pyrecswitch==1.0.2
@@ -2156,7 +2156,7 @@ python-qbittorrent==0.4.3
python-ripple-api==0.0.3
# homeassistant.components.roborock
python-roborock==0.32.2
python-roborock==0.32.3
# homeassistant.components.smarttub
python-smarttub==0.0.33
@@ -2554,7 +2554,7 @@ tilt-ble==0.2.3
tmb==0.0.4
# homeassistant.components.todoist
todoist-api-python==2.0.2
todoist-api-python==2.1.1
# homeassistant.components.tolo
tololib==0.1.0b4

View File

@@ -335,7 +335,7 @@ aiosyncthing==0.5.1
aiotractive==0.5.5
# homeassistant.components.unifi
aiounifi==52
aiounifi==53
# homeassistant.components.vlc_telnet
aiovlc==0.1.0
@@ -625,7 +625,7 @@ fjaraskupan==2.2.0
flipr-api==1.5.0
# homeassistant.components.flux_led
flux-led==1.0.1
flux-led==1.0.2
# homeassistant.components.homekit
# homeassistant.components.recorder
@@ -1229,7 +1229,7 @@ pyeconet==0.1.20
pyefergy==22.1.1
# homeassistant.components.enphase_envoy
pyenphase==1.4.0
pyenphase==1.5.2
# homeassistant.components.everlights
pyeverlights==0.1.0
@@ -1453,7 +1453,7 @@ pyps4-2ndscreen==1.3.1
pyqwikswitch==0.93
# homeassistant.components.rainbird
pyrainbird==3.0.0
pyrainbird==4.0.0
# homeassistant.components.risco
pyrisco==0.5.7
@@ -1582,7 +1582,7 @@ python-picnic-api==1.1.0
python-qbittorrent==0.4.3
# homeassistant.components.roborock
python-roborock==0.32.2
python-roborock==0.32.3
# homeassistant.components.smarttub
python-smarttub==0.0.33
@@ -1860,7 +1860,7 @@ thermopro-ble==0.4.5
tilt-ble==0.2.3
# homeassistant.components.todoist
todoist-api-python==2.0.2
todoist-api-python==2.1.1
# homeassistant.components.tolo
tololib==0.1.0b4

View File

@@ -162,20 +162,26 @@ async def test_send_text_commands(
command1 = "open the garage door"
command2 = "1234"
command1_response = "what's the PIN?"
command2_response = "opened the garage door"
with patch(
"homeassistant.components.google_assistant_sdk.helpers.TextAssistant"
) as mock_text_assistant:
await hass.services.async_call(
"homeassistant.components.google_assistant_sdk.helpers.TextAssistant.assist",
side_effect=[
(command1_response, None, None),
(command2_response, None, None),
],
) as mock_assist_call:
response = await hass.services.async_call(
DOMAIN,
"send_text_command",
{"command": [command1, command2]},
blocking=True,
return_response=True,
)
mock_text_assistant.assert_called_once_with(
ExpectedCredentials(), "en-US", audio_out=False
)
mock_text_assistant.assert_has_calls([call().__enter__().assist(command1)])
mock_text_assistant.assert_has_calls([call().__enter__().assist(command2)])
assert response == {
"responses": [{"text": command1_response}, {"text": command2_response}]
}
mock_assist_call.assert_has_calls([call(command1), call(command2)])
@pytest.mark.parametrize(

View File

@@ -0,0 +1,104 @@
# serializer version: 1
# name: test_forecast_service
dict({
'forecast': list([
dict({
'condition': 'rainy',
'datetime': datetime.datetime(2020, 1, 16, 0, 0),
'precipitation_probability': '100.0',
'temperature': 16.2,
'templow': 10.6,
'wind_bearing': 'S',
'wind_speed': 10.0,
}),
]),
})
# ---
# name: test_forecast_service.1
dict({
'forecast': list([
dict({
'condition': 'rainy',
'datetime': datetime.datetime(2020, 1, 15, 1, 0, tzinfo=datetime.timezone.utc),
'precipitation_probability': 80.0,
'temperature': 12.0,
'wind_bearing': 'S',
'wind_speed': 32.7,
}),
dict({
'condition': 'clear-night',
'datetime': datetime.datetime(2020, 1, 15, 2, 0, tzinfo=datetime.timezone.utc),
'precipitation_probability': 80.0,
'temperature': 12.0,
'wind_bearing': 'S',
'wind_speed': 32.7,
}),
]),
})
# ---
# name: test_forecast_subscription[daily]
list([
dict({
'condition': 'rainy',
'datetime': '2020-01-16T00:00:00',
'precipitation_probability': '100.0',
'temperature': 16.2,
'templow': 10.6,
'wind_bearing': 'S',
'wind_speed': 10.0,
}),
])
# ---
# name: test_forecast_subscription[daily].1
list([
dict({
'condition': 'rainy',
'datetime': '2020-01-16T00:00:00',
'precipitation_probability': '100.0',
'temperature': 16.2,
'templow': 10.6,
'wind_bearing': 'S',
'wind_speed': 10.0,
}),
])
# ---
# name: test_forecast_subscription[hourly]
list([
dict({
'condition': 'rainy',
'datetime': '2020-01-15T01:00:00+00:00',
'precipitation_probability': 80.0,
'temperature': 12.0,
'wind_bearing': 'S',
'wind_speed': 32.7,
}),
dict({
'condition': 'clear-night',
'datetime': '2020-01-15T02:00:00+00:00',
'precipitation_probability': 80.0,
'temperature': 12.0,
'wind_bearing': 'S',
'wind_speed': 32.7,
}),
])
# ---
# name: test_forecast_subscription[hourly].1
list([
dict({
'condition': 'rainy',
'datetime': '2020-01-15T01:00:00+00:00',
'precipitation_probability': 80.0,
'temperature': 12.0,
'wind_bearing': 'S',
'wind_speed': 32.7,
}),
dict({
'condition': 'clear-night',
'datetime': '2020-01-15T02:00:00+00:00',
'precipitation_probability': 80.0,
'temperature': 12.0,
'wind_bearing': 'S',
'wind_speed': 32.7,
}),
])
# ---

View File

@@ -1,15 +1,9 @@
"""Tests for IPMA config flow."""
from unittest.mock import Mock, patch
from homeassistant.components.ipma import DOMAIN, config_flow
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE
from homeassistant.components.ipma import config_flow
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.setup import async_setup_component
from . import MockLocation
from tests.common import MockConfigEntry, mock_registry
async def test_show_config_form() -> None:
@@ -120,53 +114,3 @@ async def test_flow_entry_config_entry_already_exists() -> None:
assert len(config_form.mock_calls) == 1
assert len(config_entries.mock_calls) == 1
assert len(flow._errors) == 1
async def test_config_entry_migration(hass: HomeAssistant) -> None:
"""Tests config entry without mode in unique_id can be migrated."""
ipma_entry = MockConfigEntry(
domain=DOMAIN,
title="Home",
data={CONF_LATITUDE: 0, CONF_LONGITUDE: 0, CONF_MODE: "daily"},
)
ipma_entry.add_to_hass(hass)
ipma_entry2 = MockConfigEntry(
domain=DOMAIN,
title="Home",
data={CONF_LATITUDE: 0, CONF_LONGITUDE: 0, CONF_MODE: "hourly"},
)
ipma_entry2.add_to_hass(hass)
mock_registry(
hass,
{
"weather.hometown": er.RegistryEntry(
entity_id="weather.hometown",
unique_id="0, 0",
platform="ipma",
config_entry_id=ipma_entry.entry_id,
),
"weather.hometown_2": er.RegistryEntry(
entity_id="weather.hometown_2",
unique_id="0, 0, hourly",
platform="ipma",
config_entry_id=ipma_entry.entry_id,
),
},
)
with patch(
"pyipma.location.Location.get",
return_value=MockLocation(),
):
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
ent_reg = er.async_get(hass)
weather_home = ent_reg.async_get("weather.hometown")
assert weather_home.unique_id == "0, 0, daily"
weather_home2 = ent_reg.async_get("weather.hometown_2")
assert weather_home2.unique_id == "0, 0, hourly"

View File

@@ -1,9 +1,12 @@
"""The tests for the IPMA weather component."""
from datetime import datetime
import datetime
from unittest.mock import patch
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.ipma.const import MIN_TIME_BETWEEN_UPDATES
from homeassistant.components.weather import (
ATTR_FORECAST,
ATTR_FORECAST_CONDITION,
@@ -18,6 +21,8 @@ from homeassistant.components.weather import (
ATTR_WEATHER_TEMPERATURE,
ATTR_WEATHER_WIND_BEARING,
ATTR_WEATHER_WIND_SPEED,
DOMAIN as WEATHER_DOMAIN,
SERVICE_GET_FORECAST,
)
from homeassistant.const import STATE_UNKNOWN
from homeassistant.core import HomeAssistant
@@ -25,6 +30,7 @@ from homeassistant.core import HomeAssistant
from . import MockLocation
from tests.common import MockConfigEntry
from tests.typing import WebSocketGenerator
TEST_CONFIG = {
"name": "HomeTown",
@@ -91,7 +97,7 @@ async def test_daily_forecast(hass: HomeAssistant) -> None:
assert state.state == "rainy"
forecast = state.attributes.get(ATTR_FORECAST)[0]
assert forecast.get(ATTR_FORECAST_TIME) == datetime(2020, 1, 16, 0, 0, 0)
assert forecast.get(ATTR_FORECAST_TIME) == datetime.datetime(2020, 1, 16, 0, 0, 0)
assert forecast.get(ATTR_FORECAST_CONDITION) == "rainy"
assert forecast.get(ATTR_FORECAST_TEMP) == 16.2
assert forecast.get(ATTR_FORECAST_TEMP_LOW) == 10.6
@@ -144,3 +150,93 @@ async def test_failed_get_observation_forecast(hass: HomeAssistant) -> None:
assert data.get(ATTR_WEATHER_WIND_SPEED) is None
assert data.get(ATTR_WEATHER_WIND_BEARING) is None
assert state.attributes.get("friendly_name") == "HomeTown"
async def test_forecast_service(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
) -> None:
"""Test multiple forecast."""
with patch(
"pyipma.location.Location.get",
return_value=MockLocation(),
):
entry = MockConfigEntry(domain="ipma", data=TEST_CONFIG)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
response = await hass.services.async_call(
WEATHER_DOMAIN,
SERVICE_GET_FORECAST,
{
"entity_id": "weather.hometown",
"type": "daily",
},
blocking=True,
return_response=True,
)
assert response == snapshot
response = await hass.services.async_call(
WEATHER_DOMAIN,
SERVICE_GET_FORECAST,
{
"entity_id": "weather.hometown",
"type": "hourly",
},
blocking=True,
return_response=True,
)
assert response == snapshot
@pytest.mark.parametrize("forecast_type", ["daily", "hourly"])
async def test_forecast_subscription(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
freezer: FrozenDateTimeFactory,
snapshot: SnapshotAssertion,
forecast_type: str,
) -> None:
"""Test multiple forecast."""
client = await hass_ws_client(hass)
with patch(
"pyipma.location.Location.get",
return_value=MockLocation(),
):
entry = MockConfigEntry(domain="ipma", data=TEST_CONFIG)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
await client.send_json_auto_id(
{
"type": "weather/subscribe_forecast",
"forecast_type": forecast_type,
"entity_id": "weather.hometown",
}
)
msg = await client.receive_json()
assert msg["success"]
assert msg["result"] is None
subscription_id = msg["id"]
msg = await client.receive_json()
assert msg["id"] == subscription_id
assert msg["type"] == "event"
forecast1 = msg["event"]["forecast"]
assert forecast1 == snapshot
freezer.tick(MIN_TIME_BETWEEN_UPDATES + datetime.timedelta(seconds=1))
await hass.async_block_till_done()
msg = await client.receive_json()
assert msg["id"] == subscription_id
assert msg["type"] == "event"
forecast2 = msg["event"]["forecast"]
assert forecast2 == snapshot

View File

@@ -278,6 +278,27 @@ PDU_DEVICE_1 = {
"x_has_ssh_hostkey": True,
}
PDU_OUTLETS_UPDATE_DATA = [
{
"index": 1,
"relay_state": True,
"cycle_enabled": False,
"name": "USB Outlet 1",
"outlet_caps": 1,
},
{
"index": 2,
"relay_state": True,
"cycle_enabled": False,
"name": "Outlet 2",
"outlet_caps": 3,
"outlet_voltage": "119.644",
"outlet_current": "0.935",
"outlet_power": "123.45",
"outlet_power_factor": "0.659",
},
]
async def test_no_clients(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
@@ -719,31 +740,69 @@ async def test_wlan_client_sensors(
assert hass.states.get("sensor.ssid_1").state == "0"
@pytest.mark.parametrize(
(
"entity_id",
"expected_unique_id",
"expected_value",
"changed_data",
"expected_update_value",
),
[
(
"dummy_usp_pdu_pro_outlet_2_outlet_power",
"outlet_power-01:02:03:04:05:ff_2",
"73.827",
{"outlet_table": PDU_OUTLETS_UPDATE_DATA},
"123.45",
),
(
"dummy_usp_pdu_pro_ac_power_budget",
"ac_power_budget-01:02:03:04:05:ff",
"1875.000",
None,
None,
),
(
"dummy_usp_pdu_pro_ac_power_consumption",
"ac_power_conumption-01:02:03:04:05:ff",
"201.683",
{"outlet_ac_power_consumption": "456.78"},
"456.78",
),
],
)
async def test_outlet_power_readings(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
mock_unifi_websocket,
entity_id: str,
expected_unique_id: str,
expected_value: any,
changed_data: dict | None,
expected_update_value: any,
) -> None:
"""Test the outlet power reporting on PDU devices."""
await setup_unifi_integration(hass, aioclient_mock, devices_response=[PDU_DEVICE_1])
assert len(hass.states.async_all()) == 5
assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 1
assert len(hass.states.async_all()) == 7
assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 3
ent_reg = er.async_get(hass)
ent_reg_entry = ent_reg.async_get("sensor.dummy_usp_pdu_pro_outlet_2_outlet_power")
assert ent_reg_entry.unique_id == "outlet_power-01:02:03:04:05:ff_2"
ent_reg_entry = ent_reg.async_get(f"sensor.{entity_id}")
assert ent_reg_entry.unique_id == expected_unique_id
assert ent_reg_entry.entity_category is EntityCategory.DIAGNOSTIC
outlet_2 = hass.states.get("sensor.dummy_usp_pdu_pro_outlet_2_outlet_power")
assert outlet_2.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER
assert outlet_2.state == "73.827"
sensor_data = hass.states.get(f"sensor.{entity_id}")
assert sensor_data.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER
assert sensor_data.state == expected_value
# Verify state update
pdu_device_state_update = deepcopy(PDU_DEVICE_1)
if changed_data is not None:
updated_device_data = deepcopy(PDU_DEVICE_1)
updated_device_data.update(changed_data)
pdu_device_state_update["outlet_table"][1]["outlet_power"] = "123.45"
mock_unifi_websocket(message=MessageKey.DEVICE, data=updated_device_data)
await hass.async_block_till_done()
mock_unifi_websocket(message=MessageKey.DEVICE, data=pdu_device_state_update)
await hass.async_block_till_done()
outlet_2 = hass.states.get("sensor.dummy_usp_pdu_pro_outlet_2_outlet_power")
assert outlet_2.state == "123.45"
sensor_data = hass.states.get(f"sensor.{entity_id}")
assert sensor_data.state == expected_update_value