Initialize docker stack repo

This commit is contained in:
g4st3r
2026-03-07 17:28:59 +00:00
commit 319d4eeb3e
458 changed files with 67215 additions and 0 deletions

View File

@@ -0,0 +1,77 @@
"""Yandex.Pogoda custom integration."""
from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME
from homeassistant.core import HomeAssistant
from .config_flow import get_value
from .const import (
DEFAULT_UPDATES_PER_DAY,
DOMAIN,
ENTRY_NAME,
PLATFORMS,
UPDATE_LISTENER,
UPDATER,
UPDATES_PER_DAY,
UPDATES_PER_DAY_MAP,
)
from .updater import WeatherUpdater
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up entry configured via user interface."""
name = get_value(entry, CONF_NAME)
api_key = get_value(entry, CONF_API_KEY)
latitude = get_value(entry, CONF_LATITUDE, hass.config.latitude)
longitude = get_value(entry, CONF_LONGITUDE, hass.config.longitude)
updates_per_day = get_value(entry, UPDATES_PER_DAY, DEFAULT_UPDATES_PER_DAY)
hass.data.setdefault(DOMAIN, {})
device_data = hass.data[DOMAIN].get(entry.unique_id, {})
weather_data = device_data.get("weather_data")
device_data["weather_data"] = None
weather_updater = WeatherUpdater(
latitude=latitude,
longitude=longitude,
api_key=api_key,
hass=hass,
device_id=entry.unique_id,
name=name,
weather_data=weather_data,
updates_per_day=UPDATES_PER_DAY_MAP[updates_per_day],
)
hass.data[DOMAIN][entry.entry_id] = {
ENTRY_NAME: name,
UPDATER: weather_updater,
}
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
update_listener = entry.add_update_listener(async_update_options)
hass.data[DOMAIN][entry.entry_id][UPDATE_LISTENER] = update_listener
if weather_updater.is_first_update_requred:
await weather_updater.async_request_refresh()
return True
async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Update options for entry that was configured via user interface."""
await hass.config_entries.async_reload(entry.entry_id)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Remove entry configured via user interface."""
unload_ok = True
for platform in PLATFORMS:
unload_ok = unload_ok & await hass.config_entries.async_forward_entry_unload(
entry=entry, domain=platform
)
if unload_ok:
update_listener = hass.data[DOMAIN][entry.entry_id][UPDATE_LISTENER]
update_listener()
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok

View File

@@ -0,0 +1,153 @@
"""Configuration flows."""
from __future__ import annotations
import uuid
import homeassistant.helpers.config_validation as cv
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME
from homeassistant.core import HomeAssistant, callback
from .const import (
DEFAULT_NAME,
DOMAIN,
UPDATES_PER_DAY,
UPDATES_PER_DAY_SELECTOR,
DEFAULT_UPDATES_PER_DAY,
)
from .updater import WeatherUpdater
def get_value(config_entry: config_entries | None, param: str, default=None):
"""Get current value for configuration parameter.
:param config_entry: config_entries|None: config entry from Flow
:param param: str: parameter name for getting value
:param default: default value for parameter, defaults to None
:returns: parameter value, or default value or None
"""
if config_entry is not None:
return config_entry.options.get(param, config_entry.data.get(param, default))
return default
class YandexWeatherConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""First time set up flow."""
@staticmethod
@callback
def async_get_options_flow(config_entry):
"""Get the options flow for this handler."""
return YandexWeatherOptionsFlow()
async def async_step_user(self, user_input=None):
"""Handle a flow initialized by the user."""
errors = {}
if user_input is not None:
latitude = user_input[CONF_LATITUDE]
longitude = user_input[CONF_LONGITUDE]
api_key = user_input[CONF_API_KEY].strip()
await self.async_set_unique_id(f"{uuid.uuid4()}")
self._abort_if_unique_id_configured()
weather = await _is_online(api_key, latitude, longitude, self.hass)
if weather.last_update_success:
user_input[CONF_API_KEY] = api_key
self.hass.data.setdefault(DOMAIN, {})
self.hass.data[DOMAIN][self.unique_id] = {
"weather_data": weather.weather_data
}
return self.async_create_entry(
title=user_input[CONF_NAME], data=user_input
)
errors["base"] = "could_not_get_data"
schema = vol.Schema(
{
vol.Required(CONF_API_KEY): str,
vol.Optional(CONF_NAME, default=DEFAULT_NAME): str,
vol.Required(
CONF_LATITUDE, default=self.hass.config.latitude
): cv.latitude,
vol.Required(
CONF_LONGITUDE, default=self.hass.config.longitude
): cv.longitude,
vol.Required(
UPDATES_PER_DAY, default=DEFAULT_UPDATES_PER_DAY
): UPDATES_PER_DAY_SELECTOR,
}
)
return self.async_show_form(step_id="user", data_schema=schema, errors=errors)
class YandexWeatherOptionsFlow(config_entries.OptionsFlow):
"""Changing options flow."""
@property
def config_entry(self):
return self.hass.config_entries.async_get_entry(self.handler)
async def async_step_init(self, user_input=None):
"""Manage the options."""
errors = {}
if user_input is not None:
api_key = user_input[CONF_API_KEY].strip()
weather = await _is_online(
api_key,
user_input[CONF_LATITUDE],
user_input[CONF_LONGITUDE],
self.hass,
)
if weather.last_update_success:
user_input[CONF_API_KEY] = api_key
self.hass.data.setdefault(DOMAIN, {})
self.hass.data[DOMAIN][self.config_entry.unique_id] = {
"weather_data": weather.weather_data
}
return self.async_create_entry(title="", data=user_input)
errors["base"] = "could_not_get_data"
return self.async_show_form(
step_id="init", data_schema=self._get_options_schema(), errors=errors
)
def _get_options_schema(self):
return vol.Schema(
{
vol.Required(
CONF_API_KEY, default=get_value(self.config_entry, CONF_API_KEY)
): str,
vol.Required(
CONF_LATITUDE, default=get_value(self.config_entry, CONF_LATITUDE)
): cv.latitude,
vol.Required(
CONF_LONGITUDE, default=get_value(self.config_entry, CONF_LONGITUDE)
): cv.longitude,
vol.Required(
UPDATES_PER_DAY,
default=get_value(
self.config_entry,
UPDATES_PER_DAY,
self.config_entry.data.get(
UPDATES_PER_DAY, DEFAULT_UPDATES_PER_DAY
),
),
): UPDATES_PER_DAY_SELECTOR,
}
)
async def _is_online(api_key, lat, lon, hass: HomeAssistant) -> bool:
weather = WeatherUpdater(lat, lon, api_key, hass, f"{uuid.uuid4()}")
await weather.async_request_refresh()
return weather

View File

@@ -0,0 +1,261 @@
"""General constants."""
from __future__ import annotations
import base64
import logging
import pickle
import zlib
from collections.abc import Callable
from datetime import datetime
from homeassistant.components.weather import (
ATTR_WEATHER_TEMPERATURE_UNIT,
ATTR_WEATHER_WIND_SPEED_UNIT,
UNIT_CONVERSIONS,
)
from homeassistant.const import Platform
from homeassistant.helpers import selector
_LOGGER = logging.getLogger(__name__)
DOMAIN = "yandex_pogoda"
DEFAULT_NAME = "Yandex Pogoda"
ATTRIBUTION = "Data provided by Yandex Pogoda"
MANUFACTURER = "Yandex"
ENTRY_NAME = "name"
UPDATER = "updater"
ATTR_API_TEMPERATURE = "temperature"
ATTR_API_TEMPERATURE_MAX = "maxTemperature"
ATTR_API_TEMPERATURE_MIN = "minTemperature"
ATTR_API_FEELS_LIKE_TEMPERATURE = "feelsLike"
ATTR_API_WIND_SPEED = "windSpeed"
ATTR_API_WIND_BEARING = "windAngle"
ATTR_API_DAYTIME = "daytime"
ATTR_API_CONDITION = "condition"
ATTR_API_IMAGE = "icon"
ATTR_API_POLAR = "polar"
ATTR_API_SERVER_TIME = "serverTime"
ATTR_API_TIME = "time"
ATTR_API_SUNRISE_BEGIN_TIME = "sunriseTime"
ATTR_API_SUNRISE_END_TIME = "sunsetTime"
ATTR_API_YA_CONDITION = "yandex_condition"
ATTR_API_WIND_GUST = "windGust"
ATTR_FORECAST_DATA = "forecast"
ATTR_FORECAST_DATA_COMPRESSED = "forecast_compressed"
ATTR_FORECAST_HOURLY = "hourly"
ATTR_FORECAST_HOURLY_ICONS = "forecast_hourly_icons"
ATTR_FORECAST_TWICE_DAILY = "twice_daily"
ATTR_FORECAST_TWICE_DAILY_ICONS = "forecast_twice_daily_icons"
ATTR_MIN_FORECAST_TEMPERATURE = "min_forecast_temperature"
ATTR_WIND_INTERCARDINAL_DIRECTION = "wind_intercardinal"
UPDATE_LISTENER = "update_listener"
PLATFORMS = [Platform.SENSOR, Platform.WEATHER]
YA_CONDITIONS = [
"CLEAR",
"PARTLY_CLOUDY",
"CLOUDY",
"OVERCAST",
"LIGHT_RAIN",
"RAIN",
"HEAVY_RAIN",
"SHOWERS",
"SLEET",
"LIGHT_SNOW",
"SNOW",
"SNOWFALL",
"HAIL",
"THUNDERSTORM",
"THUNDERSTORM_WITH_RAIN",
"THUNDERSTORM_WITH_HAIL",
]
YA_CONDITION_STATE_MAP = {i: i.lower() for i in YA_CONDITIONS}
"""Mapping for yandex states"""
YA_CONDITION_STATES = list(YA_CONDITION_STATE_MAP.values())
WEATHER_STATES_CONVERSION = {
"CLEAR": {
"day": "sunny",
"night": "clear-night",
},
"PARTLY_CLOUDY": "partlycloudy",
"CLOUDY": "cloudy",
"OVERCAST": "cloudy",
"LIGHT_RAIN": "rainy",
"RAIN": "rainy",
"HEAVY_RAIN": "pouring",
"SHOWERS": "pouring",
"SLEET": "snowy-rainy",
"LIGHT_SNOW": "snowy",
"SNOW": "snowy",
"SNOWFALL": "snowy",
"HAIL": "hail",
"THUNDERSTORM": "lightning",
"THUNDERSTORM_WITH_RAIN": "lightning-rainy",
"THUNDERSTORM_WITH_HAIL": "lightning-rainy",
}
"""Map rich Yandex weather condition to ordinary HA"""
HA_WEATHER_STATES = [
i for i in WEATHER_STATES_CONVERSION.values() if isinstance(i, str)
] + list(WEATHER_STATES_CONVERSION["CLEAR"].values())
CONDITION_ICONS = {
"CLEAR": {
"day": "mdi:weather-sunny",
"night": "mdi:weather-night",
},
"PARTLY_CLOUDY": {
"day": "mdi:weather-partly-cloudy",
"night": "mdi:weather-night-partly-cloudy",
},
"CLOUDY": "mdi:weather-cloudy",
"OVERCAST": "mdi:weather-cloudy",
"LIGHT_RAIN": "mdi:weather-rainy",
"RAIN": "mdi:weather-rainy",
"HEAVY_RAIN": "mdi:weather-pouring",
"SHOWERS": "mdi:weather-pouring",
"SLEET": "mdi:weather-snowy-rainy",
"LIGHT_SNOW": "mdi:weather-snowy",
"SNOW": "mdi:weather-snowy",
"SNOWFALL": "mdi:weather-snowy-heavy",
"HAIL": "mdi:weather-hail",
"THUNDERSTORM": "mdi:weather-lightning",
"THUNDERSTORM_WITH_RAIN": "mdi:weather-lightning-rainy",
"THUNDERSTORM_WITH_HAIL": "mdi:weather-lightning-rainy",
}
"""Mapping for state icon"""
TEMPERATURE_CONVERTER = UNIT_CONVERSIONS[ATTR_WEATHER_TEMPERATURE_UNIT]
WIND_SPEED_CONVERTER = UNIT_CONVERSIONS[ATTR_WEATHER_WIND_SPEED_UNIT]
DEFAULT_UPDATES_PER_DAY = "standard"
UPDATES_PER_DAY_MAP = {
"rare": 6,
"standard": 12,
"often": 24,
}
UPDATES_PER_DAY = "updates_per_day"
UPDATES_PER_DAY_SELECTOR = selector.SelectSelector(
selector.SelectSelectorConfig(
options=["rare", "standard", "often"],
mode=selector.SelectSelectorMode.DROPDOWN,
translation_key=UPDATES_PER_DAY,
)
)
def convert_unit_value(
converter: Callable[[float, str, str], float],
val: float | None,
unit_from: str | None,
unit_to: str | None,
) -> float | None:
"""Weather factor unit converter."""
if val is not None and unit_from and unit_to:
return converter(val, unit_from, unit_to)
return None
def map_state(src: str, is_day: bool, mapping: dict | None = None) -> str:
"""Map weather condition based on WEATHER_STATES_CONVERSION.
:param src: str: Yandex weather state
:param is_day: bool: Is it day? Used for icons
:param mapping: use this dict for mapping
:return: str: Home Assistant weather state
"""
if mapping is None:
mapping = {}
try:
result: str | dict[str, str] = mapping[src]
except KeyError:
result = src
if type(result) is dict:
return result["day" if is_day else "night"]
return result
def get_wind_intercardinal_direction(wind_direction_degree: int) -> str:
"""Convert wind direction degree to named direction."""
if wind_direction_degree < 0 or wind_direction_degree > 360:
_LOGGER.error("Angle should be in [0, 360] range")
if wind_direction_degree <= 22.5:
return "n"
if wind_direction_degree <= 67.5:
return "ne"
if wind_direction_degree <= 112.5:
return "e"
if wind_direction_degree <= 157.5:
return "se"
if wind_direction_degree <= 202.5:
return "s"
if wind_direction_degree <= 247.5:
return "sw"
if wind_direction_degree <= 292.5:
return "w"
if wind_direction_degree <= 337.5:
return "nw"
return "n"
def compress_data(data: dict) -> str:
"""Compress dict to string."""
return base64.b64encode(zlib.compress(pickle.dumps(data))).decode("utf-8")
def decompress_data(compressed: str | None) -> dict:
"""Compress dict to string."""
if not compressed:
return {}
try:
return pickle.loads(zlib.decompress(base64.b64decode(compressed)))
except TypeError: # for backward compatibility
return {}
def is_daytime(
hour: datetime,
day: datetime,
sunrise_begin: datetime,
sunset_end: datetime,
polar: str,
) -> bool:
"""returns whether it is daytime for a given hour of the day"""
if polar:
if day == sunrise_begin and day == sunset_end:
return polar == "DAY"
if day == sunrise_begin:
return hour < sunset_end
if day == sunset_end:
return hour >= sunrise_begin
if sunrise_begin <= sunset_end:
return hour >= sunrise_begin and hour < sunset_end
else:
return hour < sunset_end or hour >= sunrise_begin

View File

@@ -0,0 +1,74 @@
"""Yandex.Pogoda triggers."""
import logging
import voluptuous as vol
from homeassistant.components.device_automation import \
DEVICE_TRIGGER_BASE_SCHEMA as HA_TRIGGER_BASE_SCHEMA
from homeassistant.components.homeassistant.triggers import \
event as event_trigger
from homeassistant.const import (CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM,
CONF_TYPE)
from homeassistant.core import HomeAssistant
from .const import DOMAIN, WEATHER_STATES_CONVERSION, map_state
def generate_triggers() -> list:
"""Generate triggers list."""
result = []
for s in WEATHER_STATES_CONVERSION:
for d in [True, False]:
result.append(map_state(src=s, is_day=d, mapping=WEATHER_STATES_CONVERSION))
return list(set(result))
_LOGGER = logging.getLogger(__name__)
TRIGGERS = generate_triggers()
TRIGGER_SCHEMA = HA_TRIGGER_BASE_SCHEMA.extend(
{
vol.Required(CONF_TYPE): vol.In(TRIGGERS),
}
)
async def async_get_triggers(_hass: HomeAssistant, device_id):
"""Return a list of triggers."""
triggers = []
for t in TRIGGERS:
triggers.append(
{
# Required fields of TRIGGER_BASE_SCHEMA
CONF_PLATFORM: "device",
CONF_DOMAIN: DOMAIN,
CONF_DEVICE_ID: device_id,
# Required fields of TRIGGER_SCHEMA
CONF_TYPE: t,
}
)
return triggers
async def async_attach_trigger(hass: HomeAssistant, config, action, automation_info):
"""Attach a trigger."""
config = TRIGGER_SCHEMA(config)
_LOGGER.debug("Got subscription to trigger: %s", config)
event_config = event_trigger.TRIGGER_SCHEMA(
{
event_trigger.CONF_PLATFORM: "event",
event_trigger.CONF_EVENT_TYPE: DOMAIN + "_event",
event_trigger.CONF_EVENT_DATA: {
CONF_DEVICE_ID: config[CONF_DEVICE_ID],
CONF_TYPE: config[CONF_TYPE],
},
}
)
return await event_trigger.async_attach_trigger(
hass, event_config, action, automation_info, platform_type="device"
)

View File

@@ -0,0 +1,11 @@
{
"domain": "yandex_pogoda",
"name": "Yandex Pogoda",
"codeowners": [],
"config_flow": true,
"documentation": "https://github.com/yandex/pogoda-home-assistant/wiki",
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/yandex/pogoda-home-assistant/issues",
"requirements": [],
"version": "0.0.10"
}

View File

@@ -0,0 +1,250 @@
"""Sensor component."""
from __future__ import annotations
from dateutil import parser
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
DEGREE,
UnitOfSpeed,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
ATTR_API_CONDITION,
ATTR_API_FEELS_LIKE_TEMPERATURE,
ATTR_API_TEMPERATURE,
ATTR_API_SERVER_TIME,
ATTR_API_WIND_BEARING,
ATTR_WIND_INTERCARDINAL_DIRECTION,
ATTR_API_WIND_SPEED,
ATTR_API_WIND_GUST,
ATTR_API_YA_CONDITION,
ATTR_MIN_FORECAST_TEMPERATURE,
ATTR_API_SUNRISE_BEGIN_TIME,
ATTR_API_SUNRISE_END_TIME,
ATTRIBUTION,
DOMAIN,
ENTRY_NAME,
HA_WEATHER_STATES,
TEMPERATURE_CONVERTER,
UPDATER,
WIND_SPEED_CONVERTER,
YA_CONDITION_STATES,
WEATHER_STATES_CONVERSION,
convert_unit_value,
)
from .updater import WeatherUpdater
UNIT_CONVERTOR_TYPE_MAP: dict[str, str] = {
ATTR_API_TEMPERATURE: TEMPERATURE_CONVERTER,
ATTR_API_FEELS_LIKE_TEMPERATURE: TEMPERATURE_CONVERTER,
ATTR_API_WIND_SPEED: WIND_SPEED_CONVERTER,
ATTR_API_WIND_GUST: WIND_SPEED_CONVERTER,
ATTR_MIN_FORECAST_TEMPERATURE: TEMPERATURE_CONVERTER,
}
WEATHER_SENSORS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key=ATTR_API_TEMPERATURE,
name="Temperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=True,
),
SensorEntityDescription(
key=ATTR_API_FEELS_LIKE_TEMPERATURE,
name="Feels like temperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=True,
),
SensorEntityDescription(
key=ATTR_API_WIND_SPEED,
name="Wind speed",
native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=True,
icon="mdi:weather-windy",
),
SensorEntityDescription(
key=ATTR_API_WIND_GUST,
name="Wind gust",
native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=True,
icon="mdi:weather-windy",
),
SensorEntityDescription(
key=ATTR_API_WIND_BEARING,
name="Wind bearing",
entity_registry_enabled_default=False,
icon="mdi:compass-rose",
translation_key=ATTR_API_WIND_BEARING,
native_unit_of_measurement=DEGREE,
device_class=SensorDeviceClass.WIND_DIRECTION,
state_class=SensorStateClass.MEASUREMENT_ANGLE,
),
SensorEntityDescription(
key=ATTR_WIND_INTERCARDINAL_DIRECTION,
name="Wind direction",
entity_registry_enabled_default=True,
icon="mdi:compass-rose",
translation_key=ATTR_WIND_INTERCARDINAL_DIRECTION,
),
SensorEntityDescription(
key=ATTR_API_CONDITION,
name="Condition HomeAssistant",
entity_registry_enabled_default=False,
translation_key=ATTR_API_CONDITION,
options=HA_WEATHER_STATES,
device_class=SensorDeviceClass.ENUM,
),
SensorEntityDescription(
key=ATTR_API_SERVER_TIME,
name="Data update time",
device_class=SensorDeviceClass.TIMESTAMP,
state_class=None,
entity_registry_enabled_default=True,
entity_category=EntityCategory.DIAGNOSTIC,
),
SensorEntityDescription(
key=ATTR_API_YA_CONDITION,
name="Condition Yandex",
entity_registry_enabled_default=True,
translation_key=ATTR_API_YA_CONDITION,
options=YA_CONDITION_STATES,
device_class=SensorDeviceClass.ENUM,
),
SensorEntityDescription(
key=ATTR_MIN_FORECAST_TEMPERATURE,
name="Minimal forecast temperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=True,
icon="mdi:thermometer-chevron-down",
),
SensorEntityDescription(
key=ATTR_API_SUNRISE_BEGIN_TIME,
name="Next sunrise",
device_class=SensorDeviceClass.TIMESTAMP,
entity_registry_enabled_default=True,
),
SensorEntityDescription(
key=ATTR_API_SUNRISE_END_TIME,
name="Next sunset",
device_class=SensorDeviceClass.TIMESTAMP,
entity_registry_enabled_default=True,
),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up weather "Yandex.Weather" sensor entry."""
domain_data = hass.data[DOMAIN][config_entry.entry_id]
name = domain_data[ENTRY_NAME]
updater = domain_data[UPDATER]
entities: list[YandexWeatherSensor] = [
YandexWeatherSensor(
name,
f"{config_entry.unique_id}-{description.key}",
description,
updater,
)
for description in WEATHER_SENSORS
]
async_add_entities(entities)
class YandexWeatherSensor(SensorEntity, CoordinatorEntity, RestoreEntity):
"""Yandex.Weather sensor entry."""
_attr_attribution = ATTRIBUTION
coordinator: WeatherUpdater
def __init__(
self,
name: str,
unique_id: str,
description: SensorEntityDescription,
updater: WeatherUpdater,
) -> None:
"""Initialize sensor."""
CoordinatorEntity.__init__(self, coordinator=updater)
RestoreEntity.__init__(self)
self.entity_description = description
self._attr_name = f"{name} {description.name}"
self._attr_unique_id = unique_id
self._attr_device_info = self.coordinator.device_info
async def async_added_to_hass(self) -> None:
"""When entity is added to hass."""
await RestoreEntity.async_added_to_hass(self)
await CoordinatorEntity.async_added_to_hass(self)
state = await self.async_get_last_state()
if not state:
return
if state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
self._attr_available = False
else:
self._attr_available = True
if self.entity_description.key in {
ATTR_API_SERVER_TIME,
ATTR_API_SUNRISE_BEGIN_TIME,
ATTR_API_SUNRISE_END_TIME,
}:
self._attr_native_value = parser.parse(state.state)
elif self.entity_description.key in UNIT_CONVERTOR_TYPE_MAP:
self._attr_native_value = str(
convert_unit_value(
UNIT_CONVERTOR_TYPE_MAP[self.entity_description.key],
float(state.state),
state.attributes.get("unit_of_measurement")
or self.unit_of_measurement,
self.native_unit_of_measurement,
)
)
elif (
self.entity_description.key == ATTR_API_YA_CONDITION
and state.state not in YA_CONDITION_STATES
): # for backward compatibility
self._attr_native_value = WEATHER_STATES_CONVERSION.get(state.state)
else:
self._attr_native_value = state.state
self.async_write_ha_state()
def _handle_coordinator_update(self) -> None:
self._attr_available = True
self._attr_native_value = self.coordinator.data.get(
self.entity_description.key, None
)
if self.entity_description.key == ATTR_API_YA_CONDITION:
self._attr_icon = self.coordinator.data.get(f"{ATTR_API_YA_CONDITION}_icon")
self.async_write_ha_state()

View File

@@ -0,0 +1,93 @@
{
"options": {
"abort": {
"already_configured": "This location is already used for Yandex.Pogoda."
},
"error": {
"could_not_get_data": "Could not get data. Check your API key in Yandex developer portal. It may took up to 5 minutes for updating key settings at Yandex side."
},
"step": {
"init": {
"title": "Yandex.Pogoda settings",
"description": "Settings for Yandex Pogoda integration.\n\nCheck [Yandex.Pogoda API for Smart Home](https://yandex.ru/pogoda/b2b/smarthome) to get your API key",
"data": {
"api_key": "Yandex.Pogoda API key",
"latitude": "Latitude for weather info (default is HA latitude)",
"longitude": "Longitude for weather info (default is HA longitude)",
"language": "Language for Yandex weather state sensor",
"updates_per_day": "Updates per day",
"image_source": "Weather condition images"
}
}
}
},
"config": {
"abort": {
"already_configured": "This location is already used for Yandex.Pogoda."
},
"error": {
"could_not_get_data": "Could not get data. Check your API key in Yandex developer portal. It may took up to 5 minutes for updating key settings at Yandex side."
},
"step": {
"user": {
"title": "Yandex.Pogoda settings",
"description": "Settings for Yandex Pogoda integration.\n\nCheck [Yandex.Pogoda API for Smart Home](https://yandex.ru/pogoda/b2b/smarthome) to get your API key",
"data": {
"api_key": "Yandex.Pogoda API key",
"name": "Name",
"latitude": "Latitude for weather info (default is HA latitude)",
"longitude": "Longitude for weather info (default is HA longitude)",
"language": "Language for Yandex weather state sensor",
"updates_per_day": "Updates per day",
"image_source": "Weather condition images"
}
}
}
},
"selector": {
"updates_per_day": {
"options": {
"rare": "Rare (6 times per day)",
"standard": "Standard (12 times per day)",
"often": "Often (24 times per day)"
}
}
},
"title": "Yandex Pogoda",
"entity": {
"sensor": {
"wind_intercardinal": {
"state": {
"ne": "NE",
"e": "E",
"se": "SE",
"s": "S",
"sw": "SW",
"w": "W",
"nw": "NW",
"n": "N"
}
},
"yandex_condition": {
"state": {
"clear": "Clear",
"partly_cloudy": "Partly cloudy",
"cloudy": "Cloudy",
"overcast": "Overcast",
"light_rain": "Light rain",
"rain": "Rain",
"heavy_rain": "Heavy rain",
"showers": "Showers",
"sleet": "Sleet",
"light_snow": "Light snow",
"snow": "Snow",
"snowfall": "Snowfall",
"hail": "Hail",
"thunderstorm": "Thunderstorm",
"thunderstorm_with_rain": "Thunderstorm with rain",
"thunderstorm_with_hail": "Thunderstorm with hail"
}
}
}
}
}

View File

@@ -0,0 +1,112 @@
{
"options": {
"abort": {
"already_configured": "Выбранное местоположение уже используется для получения погодных данных Яндекс.Погоды."
},
"error": {
"could_not_get_data": "Не могу получить данные. Проверьте корректность API-ключа в кабинете разработчика. Может потребоваться до 5 минут, чтобы изменения настроек ключа вступили в силу на стороне Яндекса."
},
"step": {
"init": {
"title": "Настройки Яндекс.Погоды",
"description": "Настройки интеграции Яндекс.Погоды.\n\nДля получения API ключа посетите [API Яндекс.Погоды для умного дома](https://yandex.ru/pogoda/b2b/smarthome)",
"data": {
"api_key": "API ключ погоды",
"latitude": "Широта точки где нужно узнавать погоду (по умолчанию -- широта Home Assistant)",
"longitude": "Долгота точки где нужно узнавать погоду (по умолчанию -- долгота Home Assistant)",
"language": "На каком языке сообщать состояние погоды в сенсоре текущей погоды",
"updates_per_day": "Обновлений в день",
"image_source": "Картинки состояния погоды"
}
}
}
},
"config": {
"abort": {
"already_configured": "Выбранное местоположение уже используется для получения погодных данных Яндекс.Погоды."
},
"error": {
"could_not_get_data": "Не могу получить данные. Проверьте корректность API-ключа в кабинете разработчика. Может потребоваться до 5 минут, чтобы изменения настроек ключа вступили в силу на стороне Яндекса."
},
"step": {
"user": {
"title": "Настройки Яндекс.Погоды",
"description": "Настройки интеграции Яндекс.Погоды.\n\nДля получения API ключа посетите [API Яндекс.Погоды для умного дома](https://yandex.ru/pogoda/b2b/smarthome)",
"data": {
"api_key": "API ключ погоды",
"name": "Название",
"latitude": "Широта точки где нужно узнавать погоду (по умолчанию -- широта Home Assistant)",
"longitude": "Долгота точки где нужно узнавать погоду (по умолчанию -- долгота Home Assistant)",
"language": "На каком языке сообщать состояние погоды в сенсоре текущей погоды",
"updates_per_day": "Обновлений в день",
"image_source": "Картинки состояния погоды"
}
}
}
},
"selector": {
"updates_per_day": {
"options": {
"rare": "Редко (6 раз в сутки)",
"standard": "Стандарт (12 раз в сутки)",
"often": "Часто (24 раза в сутки)"
}
}
},
"title": "Яндекс.Погода",
"entity": {
"sensor": {
"wind_intercardinal": {
"state": {
"ne": "СВ",
"e": "В",
"se": "ЮВ",
"s": "Ю",
"sw": "ЮЗ",
"w": "З",
"nw": "СЗ",
"n": "С"
}
},
"yandex_condition": {
"state": {
"clear": "Ясно",
"partly_cloudy": "Малооблачно",
"cloudy": "Облачно с прояснениями",
"overcast": "Пасмурно",
"light_rain": "Слабый дождь",
"rain": "Дождь",
"heavy_rain": "Сильный дождь",
"showers": "Ливень",
"sleet": "Снег с дождём",
"light_snow": "Слабый снег",
"snow": "Снег",
"snowfall": "Снегопад",
"hail": "Град",
"thunderstorm": "Гроза",
"thunderstorm_with_rain": "Дождь с грозой",
"thunderstorm_with_hail": "Гроза с градом"
}
},
"condition": {
"state": {
"clear-night": "Ясно, ночь",
"cloudy": "Облачно",
"exceptional": "Предупреждение",
"fog": "Туман",
"hail": "Град",
"lightning": "Молния",
"lightning-rainy": "Молния, дождь",
"partlycloudy": "Переменная облачность",
"pouring": "Ливень",
"rainy": "Дождь",
"snowy": "Снег",
"snowy-rainy": "Снег с дождем",
"sunny": "Ясно",
"windy": "Ветрено",
"windy-variant": "Ветрено"
}
}
}
}
}

View File

@@ -0,0 +1,457 @@
"""Pogoda data updater."""
from __future__ import annotations
import aiohttp
import json
import logging
import math
from dataclasses import dataclass
from datetime import timedelta
from dateutil import parser
from homeassistant.components.weather import (
ATTR_FORECAST_CONDITION,
ATTR_FORECAST_NATIVE_APPARENT_TEMP,
ATTR_FORECAST_NATIVE_TEMP,
ATTR_FORECAST_NATIVE_TEMP_LOW,
ATTR_FORECAST_NATIVE_WIND_GUST_SPEED,
ATTR_FORECAST_NATIVE_WIND_SPEED,
ATTR_FORECAST_WIND_BEARING,
Forecast,
)
from homeassistant.core import HomeAssistant, HomeAssistantError, callback
from homeassistant.helpers.device_registry import DeviceEntryType
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import (
ATTR_API_CONDITION,
ATTR_API_DAYTIME,
ATTR_API_FEELS_LIKE_TEMPERATURE,
ATTR_API_IMAGE,
ATTR_API_POLAR,
ATTR_API_SERVER_TIME,
ATTR_API_SUNRISE_BEGIN_TIME,
ATTR_API_SUNRISE_END_TIME,
ATTR_API_TEMPERATURE,
ATTR_API_TEMPERATURE_MAX,
ATTR_API_TEMPERATURE_MIN,
ATTR_API_TIME,
ATTR_API_WIND_BEARING,
ATTR_API_WIND_GUST,
ATTR_API_WIND_SPEED,
ATTR_API_YA_CONDITION,
ATTR_FORECAST_DATA,
ATTR_FORECAST_HOURLY,
ATTR_FORECAST_HOURLY_ICONS,
ATTR_FORECAST_TWICE_DAILY,
ATTR_FORECAST_TWICE_DAILY_ICONS,
ATTR_MIN_FORECAST_TEMPERATURE,
ATTR_WIND_INTERCARDINAL_DIRECTION,
DEFAULT_UPDATES_PER_DAY,
UPDATES_PER_DAY_MAP,
CONDITION_ICONS,
DOMAIN,
MANUFACTURER,
WEATHER_STATES_CONVERSION,
YA_CONDITION_STATE_MAP,
get_wind_intercardinal_direction,
is_daytime,
map_state,
)
_LOGGER = logging.getLogger(__name__)
API_URL = "https://api.weather.yandex.ru/graphql/query"
API_HEADER_NAME = "X-Yandex-Weather-Key"
API_QUERY_TEMPLATE = f"""{{
{ATTR_API_SERVER_TIME}
weatherByPoint(request: {{ lat: %s, lon: %s }}) {{
now {{
{ATTR_API_CONDITION}
{ATTR_API_DAYTIME}
{ATTR_API_FEELS_LIKE_TEMPERATURE}
{ATTR_API_IMAGE}(format: PNG_64)
{ATTR_API_TEMPERATURE}
{ATTR_API_WIND_BEARING}
{ATTR_API_WIND_GUST}
{ATTR_API_WIND_SPEED}
}}
forecast {{
days {{
{ATTR_API_POLAR}
{ATTR_API_TIME}
{ATTR_API_SUNRISE_BEGIN_TIME}
{ATTR_API_SUNRISE_END_TIME}
hours{{
{ATTR_API_TIME}
{ATTR_API_CONDITION}
{ATTR_API_FEELS_LIKE_TEMPERATURE}
{ATTR_API_IMAGE}(format: PNG_64)
{ATTR_API_TEMPERATURE}
{ATTR_API_WIND_BEARING}
{ATTR_API_WIND_GUST}
{ATTR_API_WIND_SPEED}
}}
summary{{
night{{
...forecastFields
}}
day{{
...forecastFields
}}
}}
}}
}}
}}
}}
fragment forecastFields on Daypart {{
{ATTR_API_CONDITION}
{ATTR_API_FEELS_LIKE_TEMPERATURE}
{ATTR_API_IMAGE}(format: PNG_64)
{ATTR_API_WIND_BEARING}
{ATTR_API_WIND_GUST}
{ATTR_API_WIND_SPEED}
{ATTR_API_TEMPERATURE_MAX}
{ATTR_API_TEMPERATURE_MIN}
}}"""
@dataclass
class AttributeMapper:
"""Attribute mapper."""
src: str
_dst: str | None = None
mapping: dict | None = None
default: str | float | None = None
@property
def dst(self) -> str:
"""Destination for mapping."""
return self.src if self._dst is None else self._dst
CURRENT_WEATHER_ATTRIBUTE_TRANSLATION: list[AttributeMapper] = [
AttributeMapper(ATTR_API_WIND_BEARING),
AttributeMapper(
ATTR_API_WIND_BEARING,
ATTR_WIND_INTERCARDINAL_DIRECTION,
mapping=get_wind_intercardinal_direction,
),
AttributeMapper(ATTR_API_CONDITION, ATTR_API_YA_CONDITION, YA_CONDITION_STATE_MAP),
AttributeMapper(
ATTR_API_CONDITION, f"{ATTR_API_YA_CONDITION}_icon", CONDITION_ICONS
),
AttributeMapper(ATTR_API_CONDITION, mapping=WEATHER_STATES_CONVERSION),
AttributeMapper(ATTR_API_FEELS_LIKE_TEMPERATURE),
AttributeMapper(ATTR_API_SUNRISE_BEGIN_TIME),
AttributeMapper(ATTR_API_SUNRISE_END_TIME),
AttributeMapper(ATTR_API_IMAGE),
AttributeMapper(ATTR_API_TEMPERATURE),
AttributeMapper(ATTR_API_WIND_GUST),
AttributeMapper(ATTR_API_WIND_SPEED, default=0),
]
FORECAST_HOUR_ATTRIBUTE_TRANSLATION: list[AttributeMapper] = [
AttributeMapper(ATTR_API_WIND_BEARING, ATTR_FORECAST_WIND_BEARING),
AttributeMapper(ATTR_API_WIND_SPEED, ATTR_FORECAST_NATIVE_WIND_SPEED, default=0),
AttributeMapper(
ATTR_API_FEELS_LIKE_TEMPERATURE, ATTR_FORECAST_NATIVE_APPARENT_TEMP
),
AttributeMapper(ATTR_API_TEMPERATURE, ATTR_FORECAST_NATIVE_TEMP),
AttributeMapper(
ATTR_API_CONDITION, ATTR_FORECAST_CONDITION, mapping=WEATHER_STATES_CONVERSION
),
AttributeMapper(ATTR_API_WIND_GUST, ATTR_FORECAST_NATIVE_WIND_GUST_SPEED),
]
FORECAST_DAY_ATTRIBUTE_TRANSLATION: list[AttributeMapper] = [
AttributeMapper(ATTR_API_WIND_BEARING, ATTR_FORECAST_WIND_BEARING),
AttributeMapper(ATTR_API_WIND_SPEED, ATTR_FORECAST_NATIVE_WIND_SPEED, default=0),
AttributeMapper(
ATTR_API_FEELS_LIKE_TEMPERATURE, ATTR_FORECAST_NATIVE_APPARENT_TEMP
),
AttributeMapper(ATTR_API_TEMPERATURE_MAX, ATTR_FORECAST_NATIVE_TEMP),
AttributeMapper(ATTR_API_TEMPERATURE_MIN, ATTR_FORECAST_NATIVE_TEMP_LOW),
AttributeMapper(
ATTR_API_CONDITION, ATTR_FORECAST_CONDITION, mapping=WEATHER_STATES_CONVERSION
),
AttributeMapper(ATTR_API_WIND_GUST, ATTR_FORECAST_NATIVE_WIND_GUST_SPEED),
]
class WeatherUpdater(DataUpdateCoordinator):
"""Weather data updater for interaction with Yandex.Weather API."""
def __init__(
self,
latitude: float,
longitude: float,
api_key: str,
hass: HomeAssistant,
device_id: str,
name="Yandex Weather",
updates_per_day: int = UPDATES_PER_DAY_MAP[DEFAULT_UPDATES_PER_DAY],
weather_data: dict | None = None,
):
"""Initialize updater.
:param latitude: latitude of location for weather data
:param longitude: longitude of location for weather data
:param api_key: Yandex weather API. MUST be weather for site tariff plan
:param hass: Home Assistant object
:param language: Language for yandex_condition
:param updates_per_day: int: how many updates per day we should do?
:param device_id: ID of integration Device in Home Assistant
:param weather_data: if not None then weather_data will be used for first update
"""
self.__api_key = api_key.strip()
self._lat = latitude
self._lon = longitude
self._device_id = device_id
self._name = name
self.weather_data = weather_data
self.is_first_update_requred = bool(weather_data)
self.update_interval = timedelta(
seconds=math.ceil((24 * 60 * 60) / updates_per_day)
)
super().__init__(
hass,
_LOGGER,
name=f"{self._name} updater",
update_interval=self.update_interval,
update_method=self.update,
)
self.data = {}
def process_data(
self, dst: dict, src: dict, attributes: list[AttributeMapper], is_day: bool
):
"""Convert Yandex API weather response to HA friendly.
:param dst: weather data for HomeAssistant
:param src: weather data form Yandex
:param attributes: how to translate src to dst
"""
for attribute in attributes:
value = src.get(attribute.src, attribute.default)
if value is not None and attribute.mapping is not None:
if isinstance(attribute.mapping, dict):
value = map_state(
src=str(value), is_day=is_day, mapping=attribute.mapping
)
else:
value = attribute.mapping(value)
dst[attribute.dst] = value
@staticmethod
def get_min_forecast_temperature(forecasts: list[dict]) -> float | None:
"""Get minimum temperature from forecast data."""
low_fc_temperatures: list[float] = []
for f in forecasts:
f_low_temperature: float = f.get(ATTR_FORECAST_NATIVE_TEMP, None)
if f_low_temperature is not None:
low_fc_temperatures.append(f_low_temperature)
return min(low_fc_temperatures) if len(low_fc_temperatures) > 0 else None
async def _get_weather_data(self):
if self.is_first_update_requred: # No need to make API request
self.is_first_update_requred = False
_LOGGER.info("Skip API request")
return self.weather_data
timeout = aiohttp.ClientTimeout(total=20)
async with aiohttp.ClientSession(timeout=timeout) as session:
rs = await self.request(session, self.__api_key, self._lat, self._lon)
self.weather_data = rs["data"]
return self.weather_data
async def update(self):
"""Update weather information.
:returns: dict with weather data.
"""
data = await self._get_weather_data()
weather_by_point = data["weatherByPoint"]
now = weather_by_point["now"]
now_dt = parser.parse(data[ATTR_API_SERVER_TIME])
result = {
ATTR_API_SERVER_TIME: now_dt,
ATTR_FORECAST_DATA: {
ATTR_FORECAST_HOURLY: [],
ATTR_FORECAST_TWICE_DAILY: [],
},
ATTR_FORECAST_HOURLY_ICONS: [],
ATTR_FORECAST_TWICE_DAILY_ICONS: [],
ATTR_API_SUNRISE_BEGIN_TIME: "",
ATTR_API_SUNRISE_END_TIME: ""
}
self.process_data(
result,
now,
CURRENT_WEATHER_ATTRIBUTE_TRANSLATION,
now[ATTR_API_DAYTIME] == "DAY",
)
for day in weather_by_point["forecast"]["days"]:
sunrise_begin = parser.parse(day[ATTR_API_SUNRISE_BEGIN_TIME])
sunset_end = parser.parse(day[ATTR_API_SUNRISE_END_TIME])
day_dt = parser.parse(day[ATTR_API_TIME])
polar = day.get(ATTR_API_POLAR)
if sunrise_begin > now_dt and not result[ATTR_API_SUNRISE_BEGIN_TIME]:
result[ATTR_API_SUNRISE_BEGIN_TIME] = sunrise_begin
if sunset_end > now_dt and not result[ATTR_API_SUNRISE_END_TIME]:
result[ATTR_API_SUNRISE_END_TIME] = sunset_end
for hour in day["hours"]:
hour_dt = parser.parse(hour[ATTR_API_TIME])
if now_dt > hour_dt:
continue
hour_forecast = Forecast(datetime=hour_dt)
self.process_data(
dst=hour_forecast,
src=hour,
attributes=FORECAST_HOUR_ATTRIBUTE_TRANSLATION,
is_day=is_daytime(
hour_dt, day_dt, sunrise_begin, sunset_end, polar
),
)
result[ATTR_FORECAST_DATA][ATTR_FORECAST_HOURLY].append(hour_forecast)
result[ATTR_FORECAST_HOURLY_ICONS].append(hour.get(ATTR_API_IMAGE))
day_part = day["summary"]["day"]
night_part = day["summary"]["night"]
for part, is_day in ((night_part, False), (day_part, True)):
part_forecast = Forecast(datetime=day_dt, is_daytime=is_day)
self.process_data(
dst=part_forecast,
src=part,
attributes=FORECAST_DAY_ATTRIBUTE_TRANSLATION,
is_day=is_day,
)
result[ATTR_FORECAST_DATA][ATTR_FORECAST_TWICE_DAILY].append(
part_forecast
)
result[ATTR_FORECAST_TWICE_DAILY_ICONS].append(part.get(ATTR_API_IMAGE))
day_dt = day_dt + timedelta(hours=12)
result[ATTR_MIN_FORECAST_TEMPERATURE] = self.get_min_forecast_temperature(
result[ATTR_FORECAST_DATA][ATTR_FORECAST_HOURLY]
)
return result
@staticmethod
async def request(
session: aiohttp.ClientSession,
api_key: str,
lat: float,
lon: float,
):
"""Make request to API endpoint.
:param session: aiohttp.ClientSession: HTTP session for request
:param api_key: str: API key
:param lat: float: latitude of location where we are getting weather data
:param lon: float: longitude of location where we ate getting weather data
:returns: dict with response data
:raises AssertionError: when response.status is not 200
"""
_LOGGER.info("Sending API request")
async with session.post(
API_URL,
headers={API_HEADER_NAME: api_key},
json={"query": API_QUERY_TEMPLATE % (lat, lon)},
) as response:
try:
assert response.status == 200
except AssertionError as e:
_LOGGER.error("Could not get data from API: %s", response)
raise aiohttp.ClientError(response.status, await response.text()) from e
result = await response.json()
if "errors" in result:
msg = f"Errors in weather API response: {result}"
_LOGGER.error(msg)
raise HomeAssistantError(msg)
_LOGGER.debug(result)
return result
def __str__(self):
"""Show as pretty look data json."""
_d = dict(self.data)
_d[ATTR_API_SERVER_TIME] = str(_d[ATTR_API_SERVER_TIME])
return json.dumps(_d, indent=4, sort_keys=True)
@property
def url(self) -> str:
"""Weather URL."""
return f"https://yandex.ru/weather/?lat={self._lat}&lon={self._lon}"
@property
def device_info(self):
"""Device info."""
return DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, self.device_id)},
manufacturer=MANUFACTURER,
name=self._name,
configuration_url=self.url,
)
def schedule_refresh(self, offset: timedelta):
"""Schedule refresh."""
if self._unsub_refresh:
self._unsub_refresh()
self._unsub_refresh = None
_LOGGER.debug(f"scheduling next refresh after {offset=}")
next_refresh = (
int(self.hass.loop.time()) + self._microsecond + offset.total_seconds()
)
self._unsub_refresh = self.hass.loop.call_at(
next_refresh, self.__wrap_handle_refresh_interval
).cancel
@callback
def __wrap_handle_refresh_interval(self) -> None:
"""Handle a refresh interval occurrence."""
# We need this private callback from parent class
if self.config_entry:
self.config_entry.async_create_background_task(
self.hass,
self._handle_refresh_interval(),
name=f"{self.name} - {self.config_entry.title} - refresh",
eager_start=True,
)
else:
self.hass.async_create_background_task(
self._handle_refresh_interval(),
name=f"{self.name} - refresh",
eager_start=True,
)
@property
def device_id(self) -> str:
"""Device ID."""
return self._device_id

View File

@@ -0,0 +1,258 @@
"""Pogoda component."""
from __future__ import annotations
from collections.abc import Callable
from datetime import datetime, timezone
import logging
from homeassistant.components.weather import (
Forecast,
WeatherEntity,
WeatherEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
UnitOfSpeed,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
ATTR_API_CONDITION,
ATTR_API_FEELS_LIKE_TEMPERATURE,
ATTR_API_IMAGE,
ATTR_API_TEMPERATURE,
ATTR_API_WIND_BEARING,
ATTR_API_WIND_GUST,
ATTR_API_WIND_SPEED,
ATTR_API_YA_CONDITION,
ATTR_FORECAST_DATA,
ATTR_FORECAST_HOURLY,
ATTR_FORECAST_HOURLY_ICONS,
ATTR_FORECAST_TWICE_DAILY,
ATTR_FORECAST_TWICE_DAILY_ICONS,
ATTRIBUTION,
DOMAIN,
ENTRY_NAME,
TEMPERATURE_CONVERTER,
UPDATER,
WIND_SPEED_CONVERTER,
convert_unit_value,
)
from .device_trigger import TRIGGERS
from .updater import WeatherUpdater
_LOGGER = logging.getLogger(__name__)
def _get_converter(
converter: Callable[[float, str, str], float], unit_from: str, unit_to: str
) -> Callable[[float], float | None]:
def wrap(value: float | None) -> float | None:
return convert_unit_value(converter, value, unit_from, unit_to)
return wrap
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up weather "Yandex.Pogoda" weather entry."""
domain_data = hass.data[DOMAIN][config_entry.entry_id]
name = domain_data[ENTRY_NAME]
updater = domain_data[UPDATER]
async_add_entities([YandexWeather(name, config_entry, updater, hass)], False)
class YandexWeather(WeatherEntity, CoordinatorEntity, RestoreEntity):
"""Yandex.Pogoda entry."""
_attr_attribution = ATTRIBUTION
_attr_native_wind_speed_unit = UnitOfSpeed.METERS_PER_SECOND
_attr_native_temperature_unit = UnitOfTemperature.CELSIUS
_hourly_forecast: list[Forecast] | None
_twice_daily_forecast: list[Forecast] | None
_attr_supported_features = (
WeatherEntityFeature.FORECAST_HOURLY | WeatherEntityFeature.FORECAST_TWICE_DAILY
)
_unrecorded_attributes = frozenset(
{
"forecast_hourly",
"forecast_twice_daily",
ATTR_FORECAST_HOURLY_ICONS,
ATTR_FORECAST_TWICE_DAILY_ICONS,
}
)
coordinator: WeatherUpdater
def __init__(
self,
name,
config_entry: ConfigEntry,
updater: WeatherUpdater,
hass: HomeAssistant,
) -> None:
"""Initialize entry."""
WeatherEntity.__init__(self)
CoordinatorEntity.__init__(self, coordinator=updater)
RestoreEntity.__init__(self)
self.hass = hass
self._attr_name = name
self._attr_condition = None
self._attr_unique_id = config_entry.unique_id
self._hourly_forecast = []
self._twice_daily_forecast = []
self._attr_device_info = self.coordinator.device_info
async def async_added_to_hass(self) -> None:
"""When entity is added to hass."""
await RestoreEntity.async_added_to_hass(self)
await CoordinatorEntity.async_added_to_hass(self)
state = await self.async_get_last_state()
if not state:
_LOGGER.debug("Have no state for restore!")
await self.coordinator.async_config_entry_first_refresh()
return
if state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
self._attr_available = False
await self.coordinator.async_config_entry_first_refresh()
else:
_LOGGER.debug(f"state for restore: {state}")
self._attr_available = True
self._attr_condition = state.state
temp_converter = _get_converter(
TEMPERATURE_CONVERTER,
state.attributes.get("temperature_unit")
or self._attr_native_temperature_unit,
self._attr_native_temperature_unit,
)
ws_converter = _get_converter(
WIND_SPEED_CONVERTER,
state.attributes.get("wind_speed_unit")
or self._attr_native_wind_speed_unit,
self._attr_native_wind_speed_unit,
)
self._attr_native_temperature = temp_converter(
state.attributes.get("temperature")
)
self._attr_native_wind_speed = ws_converter(
state.attributes.get("wind_speed")
)
self._attr_native_wind_gust_speed = ws_converter(
state.attributes.get("wind_gust_speed")
)
self._attr_native_apparent_temperature = temp_converter(
state.attributes.get("apparent_temperature")
)
self._attr_wind_bearing = state.attributes.get("wind_bearing")
self._attr_entity_picture = state.attributes.get("entity_picture")
self._attr_extra_state_attributes = {}
f_data = {
ATTR_FORECAST_HOURLY: state.attributes.get("forecast_hourly"),
ATTR_FORECAST_TWICE_DAILY: state.attributes.get("forecast_twice_daily"),
}
self._update_forecast_data(f_data)
for attr in (
ATTR_API_YA_CONDITION,
ATTR_FORECAST_HOURLY_ICONS,
ATTR_FORECAST_TWICE_DAILY_ICONS,
):
if value := state.attributes.get(attr):
self._attr_extra_state_attributes[attr] = value
# last_updated is last call of self.async_write_ha_state(), not a real last update
since_last_update = datetime.now(timezone.utc) - state.last_updated.replace(
tzinfo=timezone.utc
)
_LOGGER.debug(
f"Time since last update: {since_last_update} ({state.last_updated}), "
f"update interval is {self.coordinator.update_interval}"
)
if since_last_update > self.coordinator.update_interval:
await self.coordinator.async_config_entry_first_refresh()
else:
self.coordinator.schedule_refresh(
offset=self.coordinator.update_interval - since_last_update
)
self.async_write_ha_state()
def _update_forecast_data(self, forecast_data: dict):
self._hourly_forecast = forecast_data.get(ATTR_FORECAST_HOURLY, [])
self._twice_daily_forecast = forecast_data.get(ATTR_FORECAST_TWICE_DAILY, [])
self._attr_extra_state_attributes["forecast_hourly"] = self._hourly_forecast
self._attr_extra_state_attributes["forecast_twice_daily"] = (
self._twice_daily_forecast
)
def _handle_coordinator_update(self) -> None:
self._attr_available = True
self.update_condition_and_fire_event(
new_condition=self.coordinator.data.get(ATTR_API_CONDITION)
)
self._attr_entity_picture = self.coordinator.data.get(ATTR_API_IMAGE)
self._attr_native_temperature = self.coordinator.data.get(ATTR_API_TEMPERATURE)
self._attr_native_wind_speed = self.coordinator.data.get(ATTR_API_WIND_SPEED)
self._attr_wind_bearing = self.coordinator.data.get(ATTR_API_WIND_BEARING)
self._attr_native_wind_gust_speed = self.coordinator.data.get(
ATTR_API_WIND_GUST
)
self._attr_native_apparent_temperature = self.coordinator.data.get(
ATTR_API_FEELS_LIKE_TEMPERATURE
)
_LOGGER.debug(f"_handle_coordinator_update: {self._hourly_forecast=}")
self._attr_extra_state_attributes = {
ATTR_API_YA_CONDITION: self.coordinator.data.get(ATTR_API_YA_CONDITION),
ATTR_FORECAST_HOURLY_ICONS: self.coordinator.data.get(
ATTR_FORECAST_HOURLY_ICONS
),
ATTR_FORECAST_TWICE_DAILY_ICONS: self.coordinator.data.get(
ATTR_FORECAST_TWICE_DAILY_ICONS
),
}
self._update_forecast_data(self.coordinator.data.get(ATTR_FORECAST_DATA, {}))
self.async_write_ha_state()
def update_condition_and_fire_event(self, new_condition: str):
"""Set new condition and fire event on change."""
if (
new_condition != self._attr_condition
and self.hass is not None
and new_condition in TRIGGERS
):
self.hass.bus.async_fire(
DOMAIN + "_event",
{
"device_id": self.coordinator.device_id,
"type": new_condition,
},
)
self._attr_condition = new_condition
async def async_forecast_hourly(self) -> list[Forecast] | None:
"""Return the hourly forecast in native units.
Only implement this method if `WeatherEntityFeature.FORECAST_HOURLY` is set
"""
_LOGGER.debug(f"async_forecast_hourly: {self._hourly_forecast=}")
return self._hourly_forecast if self._hourly_forecast else []
async def async_forecast_twice_daily(self) -> list[Forecast] | None:
"""Return the daily forecast in native units."""
_LOGGER.debug(f"async_forecast_twice_daily: {self._twice_daily_forecast=}")
return self._twice_daily_forecast if self._twice_daily_forecast else []