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, ...] = ( SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription( SensorEntityDescription(
key=CURRENT_WARNING_SENSOR, key=CURRENT_WARNING_SENSOR,
name="Current Warning Level", translation_key=CURRENT_WARNING_SENSOR,
icon="mdi:close-octagon-outline", icon="mdi:close-octagon-outline",
), ),
SensorEntityDescription( SensorEntityDescription(
key=ADVANCE_WARNING_SENSOR, key=ADVANCE_WARNING_SENSOR,
name="Advance Warning Level", translation_key=ADVANCE_WARNING_SENSOR,
icon="mdi:close-octagon-outline", icon="mdi:close-octagon-outline",
), ),
) )
@@ -131,6 +131,7 @@ class DwdWeatherWarningsSensor(
"""Representation of a DWD-Weather-Warnings sensor.""" """Representation of a DWD-Weather-Warnings sensor."""
_attr_attribution = "Data provided by DWD" _attr_attribution = "Data provided by DWD"
_attr_has_entity_name = True
def __init__( def __init__(
self, self,
@@ -142,7 +143,6 @@ class DwdWeatherWarningsSensor(
super().__init__(coordinator) super().__init__(coordinator)
self.entity_description = description 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_unique_id = f"{entry.unique_id}-{description.key}"
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(

View File

@@ -15,5 +15,15 @@
"already_configured": "Warncell ID / name is already configured.", "already_configured": "Warncell ID / name is already configured.",
"invalid_identifier": "[%key:component::dwd_weather_warnings::config::error::invalid_identifier%]" "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", "documentation": "https://www.home-assistant.io/integrations/enphase_envoy",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["pyenphase"], "loggers": ["pyenphase"],
"requirements": ["pyenphase==1.4.0"], "requirements": ["pyenphase==1.5.2"],
"zeroconf": [ "zeroconf": [
{ {
"type": "_enphase-envoy._tcp.local." "type": "_enphase-envoy._tcp.local."

View File

@@ -8,6 +8,7 @@ import logging
from pyenphase import ( from pyenphase import (
EnvoyEncharge, EnvoyEncharge,
EnvoyEnchargeAggregate,
EnvoyEnchargePower, EnvoyEnchargePower,
EnvoyEnpower, EnvoyEnpower,
EnvoyInverter, 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( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: ConfigEntry, config_entry: ConfigEntry,
@@ -329,6 +382,11 @@ async def async_setup_entry(
for description in ENCHARGE_POWER_SENSORS for description in ENCHARGE_POWER_SENSORS
for encharge in envoy_data.encharge_power 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: if envoy_data.enpower:
entities.extend( entities.extend(
EnvoyEnpowerEntity(coordinator, description) EnvoyEnpowerEntity(coordinator, description)
@@ -482,6 +540,19 @@ class EnvoyEnchargePowerEntity(EnvoyEnchargeEntity):
return self.entity_description.value_fn(encharge_power[self._serial_number]) 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): class EnvoyEnpowerEntity(EnvoySensorBaseEntity):
"""Envoy Enpower sensor entity.""" """Envoy Enpower sensor entity."""

View File

@@ -63,6 +63,21 @@
}, },
"lifetime_consumption": { "lifetime_consumption": {
"name": "Lifetime energy 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": { "switch": {

View File

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

View File

@@ -73,7 +73,7 @@ class EsphomeNumber(EsphomeEntity[NumberInfo, NumberState], NumberEntity):
def native_value(self) -> float | None: def native_value(self) -> float | None:
"""Return the state of the entity.""" """Return the state of the entity."""
state = self._state 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 None
return state.state return state.state

View File

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

View File

@@ -54,5 +54,5 @@
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["flux_led"], "loggers": ["flux_led"],
"quality_scale": "platinum", "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.""" """Support for Google Assistant SDK."""
from __future__ import annotations from __future__ import annotations
import dataclasses
import aiohttp import aiohttp
from gassist_text import TextAssistant from gassist_text import TextAssistant
from google.oauth2.credentials import Credentials from google.oauth2.credentials import Credentials
@@ -9,7 +11,12 @@ import voluptuous as vol
from homeassistant.components import conversation from homeassistant.components import conversation
from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_NAME, Platform 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.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv, discovery, intent from homeassistant.helpers import config_validation as cv, discovery, intent
from homeassistant.helpers.config_entry_oauth2_flow import ( 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: async def async_setup_service(hass: HomeAssistant) -> None:
"""Add the services for Google Assistant SDK.""" """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.""" """Send a text command to Google Assistant SDK."""
commands: list[str] = call.data[SERVICE_SEND_TEXT_COMMAND_FIELD_COMMAND] commands: list[str] = call.data[SERVICE_SEND_TEXT_COMMAND_FIELD_COMMAND]
media_players: list[str] | None = call.data.get( media_players: list[str] | None = call.data.get(
SERVICE_SEND_TEXT_COMMAND_FIELD_MEDIA_PLAYER 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( hass.services.async_register(
DOMAIN, DOMAIN,
SERVICE_SEND_TEXT_COMMAND, SERVICE_SEND_TEXT_COMMAND,
send_text_command, send_text_command,
schema=SERVICE_SEND_TEXT_COMMAND_SCHEMA, schema=SERVICE_SEND_TEXT_COMMAND_SCHEMA,
supports_response=SupportsResponse.OPTIONAL,
) )

View File

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

View File

@@ -2,10 +2,10 @@
import voluptuous as vol import voluptuous as vol
from homeassistant import config_entries 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 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): 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_NAME, default=name): str,
vol.Required(CONF_LATITUDE, default=latitude): cv.latitude, vol.Required(CONF_LATITUDE, default=latitude): cv.latitude,
vol.Required(CONF_LONGITUDE, default=longitude): cv.longitude, vol.Required(CONF_LONGITUDE, default=longitude): cv.longitude,
vol.Required(CONF_MODE, default="daily"): vol.In(FORECAST_MODE),
} }
), ),
errors=self._errors, errors=self._errors,

View File

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

View File

@@ -1,11 +1,14 @@
"""Support for IPMA weather service.""" """Support for IPMA weather service."""
from __future__ import annotations from __future__ import annotations
import asyncio
import contextlib
import logging import logging
from typing import Literal
import async_timeout import async_timeout
from pyipma.api import IPMA_API from pyipma.api import IPMA_API
from pyipma.forecast import Forecast from pyipma.forecast import Forecast as IPMAForecast
from pyipma.location import Location from pyipma.location import Location
from homeassistant.components.weather import ( from homeassistant.components.weather import (
@@ -16,7 +19,9 @@ from homeassistant.components.weather import (
ATTR_FORECAST_PRECIPITATION_PROBABILITY, ATTR_FORECAST_PRECIPITATION_PROBABILITY,
ATTR_FORECAST_TIME, ATTR_FORECAST_TIME,
ATTR_FORECAST_WIND_BEARING, ATTR_FORECAST_WIND_BEARING,
Forecast,
WeatherEntity, WeatherEntity,
WeatherEntityFeature,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ( from homeassistant.const import (
@@ -26,8 +31,7 @@ from homeassistant.const import (
UnitOfSpeed, UnitOfSpeed,
UnitOfTemperature, UnitOfTemperature,
) )
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.sun import is_up from homeassistant.helpers.sun import is_up
from homeassistant.util import Throttle from homeassistant.util import Throttle
@@ -53,39 +57,19 @@ async def async_setup_entry(
"""Add a weather entity from a config_entry.""" """Add a weather entity from a config_entry."""
api = hass.data[DOMAIN][config_entry.entry_id][DATA_API] api = hass.data[DOMAIN][config_entry.entry_id][DATA_API]
location = hass.data[DOMAIN][config_entry.entry_id][DATA_LOCATION] 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) async_add_entities([IPMAWeather(location, api, config_entry.data)], True)
class IPMAWeather(WeatherEntity, IPMADevice): class IPMAWeather(WeatherEntity, IPMADevice):
"""Representation of a weather condition.""" """Representation of a weather condition."""
_attr_attribution = ATTRIBUTION
_attr_native_pressure_unit = UnitOfPressure.HPA _attr_native_pressure_unit = UnitOfPressure.HPA
_attr_native_temperature_unit = UnitOfTemperature.CELSIUS _attr_native_temperature_unit = UnitOfTemperature.CELSIUS
_attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR _attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR
_attr_supported_features = (
_attr_attribution = ATTRIBUTION WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY
)
def __init__(self, location: Location, api: IPMA_API, config) -> None: def __init__(self, location: Location, api: IPMA_API, config) -> None:
"""Initialise the platform with a data instance and station name.""" """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._mode = config.get(CONF_MODE)
self._period = 1 if config.get(CONF_MODE) == "hourly" else 24 self._period = 1 if config.get(CONF_MODE) == "hourly" else 24
self._observation = None self._observation = None
self._forecast: list[Forecast] = [] self._daily_forecast: list[IPMAForecast] | None = None
self._attr_unique_id = f"{self._location.station_latitude}, {self._location.station_longitude}, {self._mode}" 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) @Throttle(MIN_TIME_BETWEEN_UPDATES)
async def async_update(self) -> None: async def async_update(self) -> None:
"""Update Condition and Forecast.""" """Update Condition and Forecast."""
async with async_timeout.timeout(10): async with async_timeout.timeout(10):
new_observation = await self._location.observation(self._api) new_observation = await self._location.observation(self._api)
new_forecast = await self._location.forecast(self._api, self._period)
if new_observation: if new_observation:
self._observation = new_observation self._observation = new_observation
else: else:
_LOGGER.warning("Could not update weather observation") _LOGGER.warning("Could not update weather observation")
if new_forecast: if self._period == 24 or self._forecast_listeners["daily"]:
self._forecast = new_forecast await self._update_forecast("daily", 24, True)
else: 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( _LOGGER.debug(
"Updated location %s based on %s, current observation %s", "Updated location %s based on %s, current observation %s",
@@ -122,6 +116,21 @@ class IPMAWeather(WeatherEntity, IPMADevice):
self._observation, 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): def _condition_conversion(self, identifier, forecast_dt):
"""Convert from IPMA weather_type id to HA.""" """Convert from IPMA weather_type id to HA."""
if identifier == 1 and not is_up(self.hass, forecast_dt): if identifier == 1 and not is_up(self.hass, forecast_dt):
@@ -135,10 +144,12 @@ class IPMAWeather(WeatherEntity, IPMADevice):
@property @property
def condition(self): def condition(self):
"""Return the current condition.""" """Return the current condition."""
if not self._forecast: forecast = self._hourly_forecast or self._daily_forecast
if not forecast:
return return
return self._condition_conversion(self._forecast[0].weather_type.id, None) return self._condition_conversion(forecast[0].weather_type.id, None)
@property @property
def native_temperature(self): def native_temperature(self):
@@ -180,10 +191,9 @@ class IPMAWeather(WeatherEntity, IPMADevice):
return self._observation.wind_direction return self._observation.wind_direction
@property def _forecast(self, forecast: list[IPMAForecast] | None) -> list[Forecast]:
def forecast(self):
"""Return the forecast array.""" """Return the forecast array."""
if not self._forecast: if not forecast:
return [] return []
return [ return [
@@ -198,5 +208,32 @@ class IPMAWeather(WeatherEntity, IPMADevice):
ATTR_FORECAST_NATIVE_WIND_SPEED: data_in.wind_strength, ATTR_FORECAST_NATIVE_WIND_SPEED: data_in.wind_strength,
ATTR_FORECAST_WIND_BEARING: data_in.wind_direction, 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 __future__ import annotations
from collections.abc import Mapping from collections.abc import Mapping
from dataclasses import dataclass
from datetime import datetime, timedelta from datetime import datetime, timedelta
import logging import logging
from typing import Any from typing import Any
@@ -62,6 +63,19 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
return unload_ok 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: class MinecraftServer:
"""Representation of a Minecraft server.""" """Representation of a Minecraft server."""
@@ -84,13 +98,7 @@ class MinecraftServer:
self._server = JavaServer(self.host, self.port) self._server = JavaServer(self.host, self.port)
# Data provided by 3rd party library # Data provided by 3rd party library
self.version: str | None = None self.data: MinecraftServerData = MinecraftServerData()
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
# Dispatcher signal name # Dispatcher signal name
self.signal_name = f"{SIGNAL_NAME_PREFIX}_{self.unique_id}" self.signal_name = f"{SIGNAL_NAME_PREFIX}_{self.unique_id}"
@@ -170,18 +178,18 @@ class MinecraftServer:
status_response = await self._server.async_status() status_response = await self._server.async_status()
# Got answer to request, update properties. # Got answer to request, update properties.
self.version = status_response.version.name self.data.version = status_response.version.name
self.protocol_version = status_response.version.protocol self.data.protocol_version = status_response.version.protocol
self.players_online = status_response.players.online self.data.players_online = status_response.players.online
self.players_max = status_response.players.max self.data.players_max = status_response.players.max
self.latency = status_response.latency self.data.latency = status_response.latency
self.motd = status_response.motd.to_plain() self.data.motd = status_response.motd.to_plain()
self.players_list = [] self.data.players_list = []
if status_response.players.sample is not None: if status_response.players.sample is not None:
for player in status_response.players.sample: for player in status_response.players.sample:
self.players_list.append(player.name) self.data.players_list.append(player.name)
self.players_list.sort() self.data.players_list.sort()
# Inform user once about successful update if necessary. # Inform user once about successful update if necessary.
if self._last_status_request_failed: if self._last_status_request_failed:
@@ -193,13 +201,13 @@ class MinecraftServer:
self._last_status_request_failed = False self._last_status_request_failed = False
except OSError as error: except OSError as error:
# No answer to request, set all properties to unknown. # No answer to request, set all properties to unknown.
self.version = None self.data.version = None
self.protocol_version = None self.data.protocol_version = None
self.players_online = None self.data.players_online = None
self.players_max = None self.data.players_max = None
self.latency = None self.data.latency = None
self.players_list = None self.data.players_list = None
self.motd = None self.data.motd = None
# Inform user once about failed update if necessary. # Inform user once about failed update if necessary.
if not self._last_status_request_failed: if not self._last_status_request_failed:

View File

@@ -29,9 +29,9 @@ class MinecraftServerEntity(Entity):
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._server.unique_id)}, identifiers={(DOMAIN, self._server.unique_id)},
manufacturer=MANUFACTURER, manufacturer=MANUFACTURER,
model=f"Minecraft Server ({self._server.version})", model=f"Minecraft Server ({self._server.data.version})",
name=self._server.name, 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._attr_device_class = device_class
self._extra_state_attributes = None self._extra_state_attributes = None

View File

@@ -89,7 +89,7 @@ class MinecraftServerVersionSensor(MinecraftServerSensorEntity):
async def async_update(self) -> None: async def async_update(self) -> None:
"""Update version.""" """Update version."""
self._attr_native_value = self._server.version self._attr_native_value = self._server.data.version
class MinecraftServerProtocolVersionSensor(MinecraftServerSensorEntity): class MinecraftServerProtocolVersionSensor(MinecraftServerSensorEntity):
@@ -107,7 +107,7 @@ class MinecraftServerProtocolVersionSensor(MinecraftServerSensorEntity):
async def async_update(self) -> None: async def async_update(self) -> None:
"""Update protocol version.""" """Update protocol version."""
self._attr_native_value = self._server.protocol_version self._attr_native_value = self._server.data.protocol_version
class MinecraftServerLatencySensor(MinecraftServerSensorEntity): class MinecraftServerLatencySensor(MinecraftServerSensorEntity):
@@ -126,7 +126,7 @@ class MinecraftServerLatencySensor(MinecraftServerSensorEntity):
async def async_update(self) -> None: async def async_update(self) -> None:
"""Update latency.""" """Update latency."""
self._attr_native_value = self._server.latency self._attr_native_value = self._server.data.latency
class MinecraftServerPlayersOnlineSensor(MinecraftServerSensorEntity): class MinecraftServerPlayersOnlineSensor(MinecraftServerSensorEntity):
@@ -145,13 +145,13 @@ class MinecraftServerPlayersOnlineSensor(MinecraftServerSensorEntity):
async def async_update(self) -> None: async def async_update(self) -> None:
"""Update online players state and device state attributes.""" """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 = {} 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: 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 self._attr_extra_state_attributes = extra_state_attributes
@@ -172,7 +172,7 @@ class MinecraftServerPlayersMaxSensor(MinecraftServerSensorEntity):
async def async_update(self) -> None: async def async_update(self) -> None:
"""Update maximum number of players.""" """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): class MinecraftServerMOTDSensor(MinecraftServerSensorEntity):
@@ -190,4 +190,4 @@ class MinecraftServerMOTDSensor(MinecraftServerSensorEntity):
async def async_update(self) -> None: async def async_update(self) -> None:
"""Update MOTD.""" """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.""" """Plugwise Climate component for Home Assistant."""
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping
from typing import Any from typing import Any
from homeassistant.components.climate import ( from homeassistant.components.climate import (
@@ -145,14 +144,6 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity):
"""Return the current preset mode.""" """Return the current preset mode."""
return self.device.get("active_preset") 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 @plugwise_command
async def async_set_temperature(self, **kwargs: Any) -> None: async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature.""" """Set new target temperature."""

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/rainbird", "documentation": "https://www.home-assistant.io/integrations/rainbird",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["pyrainbird"], "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", "documentation": "https://www.home-assistant.io/integrations/roborock",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["roborock"], "loggers": ["roborock"],
"requirements": ["python-roborock==0.32.2"] "requirements": ["python-roborock==0.32.3"]
} }

View File

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

View File

@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/todoist", "documentation": "https://www.home-assistant.io/integrations/todoist",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["todoist"], "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", "iot_class": "local_push",
"loggers": ["aiounifi"], "loggers": ["aiounifi"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": ["aiounifi==52"], "requirements": ["aiounifi==53"],
"ssdp": [ "ssdp": [
{ {
"manufacturer": "Ubiquiti Networks", "manufacturer": "Ubiquiti Networks",

View File

@@ -12,11 +12,13 @@ from typing import Generic
from aiounifi.interfaces.api_handlers import ItemEvent from aiounifi.interfaces.api_handlers import ItemEvent
from aiounifi.interfaces.clients import Clients from aiounifi.interfaces.clients import Clients
from aiounifi.interfaces.devices import Devices
from aiounifi.interfaces.outlets import Outlets from aiounifi.interfaces.outlets import Outlets
from aiounifi.interfaces.ports import Ports from aiounifi.interfaces.ports import Ports
from aiounifi.interfaces.wlans import Wlans from aiounifi.interfaces.wlans import Wlans
from aiounifi.models.api import ApiItemT from aiounifi.models.api import ApiItemT
from aiounifi.models.client import Client from aiounifi.models.client import Client
from aiounifi.models.device import Device
from aiounifi.models.outlet import Outlet from aiounifi.models.outlet import Outlet
from aiounifi.models.port import Port from aiounifi.models.port import Port
from aiounifi.models.wlan import Wlan 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 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 @dataclass
class UnifiSensorEntityDescriptionMixin(Generic[HandlerT, ApiItemT]): class UnifiSensorEntityDescriptionMixin(Generic[HandlerT, ApiItemT]):
"""Validate and load entities from different UniFi handlers.""" """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}", unique_id_fn=lambda controller, obj_id: f"outlet_power-{obj_id}",
value_fn=lambda _, obj: obj.power if obj.relay_state else "0", 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 aiotractive==0.5.5
# homeassistant.components.unifi # homeassistant.components.unifi
aiounifi==52 aiounifi==53
# homeassistant.components.vlc_telnet # homeassistant.components.vlc_telnet
aiovlc==0.1.0 aiovlc==0.1.0
@@ -800,7 +800,7 @@ fjaraskupan==2.2.0
flipr-api==1.5.0 flipr-api==1.5.0
# homeassistant.components.flux_led # homeassistant.components.flux_led
flux-led==1.0.1 flux-led==1.0.2
# homeassistant.components.homekit # homeassistant.components.homekit
# homeassistant.components.recorder # homeassistant.components.recorder
@@ -1665,7 +1665,7 @@ pyedimax==0.2.1
pyefergy==22.1.1 pyefergy==22.1.1
# homeassistant.components.enphase_envoy # homeassistant.components.enphase_envoy
pyenphase==1.4.0 pyenphase==1.5.2
# homeassistant.components.envisalink # homeassistant.components.envisalink
pyenvisalink==4.6 pyenvisalink==4.6
@@ -1955,7 +1955,7 @@ pyqwikswitch==0.93
pyrail==0.0.3 pyrail==0.0.3
# homeassistant.components.rainbird # homeassistant.components.rainbird
pyrainbird==3.0.0 pyrainbird==4.0.0
# homeassistant.components.recswitch # homeassistant.components.recswitch
pyrecswitch==1.0.2 pyrecswitch==1.0.2
@@ -2156,7 +2156,7 @@ python-qbittorrent==0.4.3
python-ripple-api==0.0.3 python-ripple-api==0.0.3
# homeassistant.components.roborock # homeassistant.components.roborock
python-roborock==0.32.2 python-roborock==0.32.3
# homeassistant.components.smarttub # homeassistant.components.smarttub
python-smarttub==0.0.33 python-smarttub==0.0.33
@@ -2554,7 +2554,7 @@ tilt-ble==0.2.3
tmb==0.0.4 tmb==0.0.4
# homeassistant.components.todoist # homeassistant.components.todoist
todoist-api-python==2.0.2 todoist-api-python==2.1.1
# homeassistant.components.tolo # homeassistant.components.tolo
tololib==0.1.0b4 tololib==0.1.0b4

View File

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

View File

@@ -162,20 +162,26 @@ async def test_send_text_commands(
command1 = "open the garage door" command1 = "open the garage door"
command2 = "1234" command2 = "1234"
command1_response = "what's the PIN?"
command2_response = "opened the garage door"
with patch( with patch(
"homeassistant.components.google_assistant_sdk.helpers.TextAssistant" "homeassistant.components.google_assistant_sdk.helpers.TextAssistant.assist",
) as mock_text_assistant: side_effect=[
await hass.services.async_call( (command1_response, None, None),
(command2_response, None, None),
],
) as mock_assist_call:
response = await hass.services.async_call(
DOMAIN, DOMAIN,
"send_text_command", "send_text_command",
{"command": [command1, command2]}, {"command": [command1, command2]},
blocking=True, blocking=True,
return_response=True,
) )
mock_text_assistant.assert_called_once_with( assert response == {
ExpectedCredentials(), "en-US", audio_out=False "responses": [{"text": command1_response}, {"text": command2_response}]
) }
mock_text_assistant.assert_has_calls([call().__enter__().assist(command1)]) mock_assist_call.assert_has_calls([call(command1), call(command2)])
mock_text_assistant.assert_has_calls([call().__enter__().assist(command2)])
@pytest.mark.parametrize( @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.""" """Tests for IPMA config flow."""
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
from homeassistant.components.ipma import DOMAIN, config_flow from homeassistant.components.ipma import config_flow
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE
from homeassistant.core import HomeAssistant 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: 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_form.mock_calls) == 1
assert len(config_entries.mock_calls) == 1 assert len(config_entries.mock_calls) == 1
assert len(flow._errors) == 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.""" """The tests for the IPMA weather component."""
from datetime import datetime import datetime
from unittest.mock import patch from unittest.mock import patch
from freezegun.api import FrozenDateTimeFactory
import pytest import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.ipma.const import MIN_TIME_BETWEEN_UPDATES
from homeassistant.components.weather import ( from homeassistant.components.weather import (
ATTR_FORECAST, ATTR_FORECAST,
ATTR_FORECAST_CONDITION, ATTR_FORECAST_CONDITION,
@@ -18,6 +21,8 @@ from homeassistant.components.weather import (
ATTR_WEATHER_TEMPERATURE, ATTR_WEATHER_TEMPERATURE,
ATTR_WEATHER_WIND_BEARING, ATTR_WEATHER_WIND_BEARING,
ATTR_WEATHER_WIND_SPEED, ATTR_WEATHER_WIND_SPEED,
DOMAIN as WEATHER_DOMAIN,
SERVICE_GET_FORECAST,
) )
from homeassistant.const import STATE_UNKNOWN from homeassistant.const import STATE_UNKNOWN
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@@ -25,6 +30,7 @@ from homeassistant.core import HomeAssistant
from . import MockLocation from . import MockLocation
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
from tests.typing import WebSocketGenerator
TEST_CONFIG = { TEST_CONFIG = {
"name": "HomeTown", "name": "HomeTown",
@@ -91,7 +97,7 @@ async def test_daily_forecast(hass: HomeAssistant) -> None:
assert state.state == "rainy" assert state.state == "rainy"
forecast = state.attributes.get(ATTR_FORECAST)[0] 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_CONDITION) == "rainy"
assert forecast.get(ATTR_FORECAST_TEMP) == 16.2 assert forecast.get(ATTR_FORECAST_TEMP) == 16.2
assert forecast.get(ATTR_FORECAST_TEMP_LOW) == 10.6 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_SPEED) is None
assert data.get(ATTR_WEATHER_WIND_BEARING) is None assert data.get(ATTR_WEATHER_WIND_BEARING) is None
assert state.attributes.get("friendly_name") == "HomeTown" 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, "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( async def test_no_clients(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
@@ -719,31 +740,69 @@ async def test_wlan_client_sensors(
assert hass.states.get("sensor.ssid_1").state == "0" 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( 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: ) -> None:
"""Test the outlet power reporting on PDU devices.""" """Test the outlet power reporting on PDU devices."""
await setup_unifi_integration(hass, aioclient_mock, devices_response=[PDU_DEVICE_1]) await setup_unifi_integration(hass, aioclient_mock, devices_response=[PDU_DEVICE_1])
assert len(hass.states.async_all()) == 5 assert len(hass.states.async_all()) == 7
assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 1 assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 3
ent_reg = er.async_get(hass) ent_reg = er.async_get(hass)
ent_reg_entry = ent_reg.async_get("sensor.dummy_usp_pdu_pro_outlet_2_outlet_power") ent_reg_entry = ent_reg.async_get(f"sensor.{entity_id}")
assert ent_reg_entry.unique_id == "outlet_power-01:02:03:04:05:ff_2" assert ent_reg_entry.unique_id == expected_unique_id
assert ent_reg_entry.entity_category is EntityCategory.DIAGNOSTIC assert ent_reg_entry.entity_category is EntityCategory.DIAGNOSTIC
outlet_2 = hass.states.get("sensor.dummy_usp_pdu_pro_outlet_2_outlet_power") sensor_data = hass.states.get(f"sensor.{entity_id}")
assert outlet_2.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER assert sensor_data.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER
assert outlet_2.state == "73.827" assert sensor_data.state == expected_value
# Verify state update if changed_data is not None:
pdu_device_state_update = deepcopy(PDU_DEVICE_1) 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) sensor_data = hass.states.get(f"sensor.{entity_id}")
await hass.async_block_till_done() assert sensor_data.state == expected_update_value
outlet_2 = hass.states.get("sensor.dummy_usp_pdu_pro_outlet_2_outlet_power")
assert outlet_2.state == "123.45"