From bb54a92e3fa6374dbb62b3b1d039677305332a1b Mon Sep 17 00:00:00 2001 From: Guy Khmelnitsky Date: Tue, 29 Oct 2024 11:23:47 +0200 Subject: [PATCH] feat: Add pring-debug service --- custom_components/ims/__init__.py | 31 +++- custom_components/ims/binary_sensor.py | 38 ++-- custom_components/ims/config_flow.py | 46 +++-- custom_components/ims/const.py | 3 +- custom_components/ims/sensor.py | 175 +++++++++++++----- custom_components/ims/services.yaml | 3 + custom_components/ims/translations/en.json | 96 +++++----- custom_components/ims/translations/he.json | 94 +++++----- custom_components/ims/translations/pt.json | 6 + custom_components/ims/utils.py | 4 +- custom_components/ims/weather.py | 90 +++++---- .../ims/weather_update_coordinator.py | 21 ++- 12 files changed, 395 insertions(+), 212 deletions(-) create mode 100644 custom_components/ims/services.yaml diff --git a/custom_components/ims/__init__.py b/custom_components/ims/__init__.py index cb9f5fd..e0f4dd2 100644 --- a/custom_components/ims/__init__.py +++ b/custom_components/ims/__init__.py @@ -9,7 +9,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_MODE, - CONF_NAME, CONF_MONITORED_CONDITIONS, + CONF_NAME, + CONF_MONITORED_CONDITIONS, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -50,14 +51,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ims_scan_int = entry.data[CONF_UPDATE_INTERVAL] conditions = _get_config_value(entry, CONF_MONITORED_CONDITIONS) - # Extract list of int from forecast days/ hours string if present # _LOGGER.warning('forecast_days_type: ' + str(type(forecast_days))) is_legacy_city = False if isinstance(city, int | str): is_legacy_city = True - city_id = city if is_legacy_city else city['lid'] + city_id = city if is_legacy_city else city["lid"] unique_location = f"ims-{language}-{city_id}" @@ -93,7 +93,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # If both platforms if (IMS_PLATFORMS[0] in ims_entity_platform) and ( - IMS_PLATFORMS[1] in ims_entity_platform + IMS_PLATFORMS[1] in ims_entity_platform ): platforms = PLATFORMS # If only sensor @@ -102,11 +102,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # If only weather elif IMS_PLATFORMS[1] in ims_entity_platform: platforms = [PLATFORMS[1]] - + 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 + + # Register the debug service + async def handle_debug_get_coordinator_data(call) -> None: # noqa: ANN001 ARG001 + # Log or return coordinator data + data = weather_coordinator.data + _LOGGER.info("Coordinator data: %s", data) + hass.bus.async_fire("custom_component_debug_event", {"data": data}) + + hass.services.async_register( + DOMAIN, "debug_get_coordinator_data", handle_debug_get_coordinator_data + ) return True @@ -122,7 +133,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: unload_ok = False # If both if (IMS_PLATFORMS[0] in ims_entity_prevplatform) and ( - IMS_PLATFORMS[1] in ims_entity_prevplatform + IMS_PLATFORMS[1] in ims_entity_prevplatform ): unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) # If only sensor @@ -147,7 +158,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -def _get_config_value(config_entry: ConfigEntry, key: str, default = None) -> Any: +def _get_config_value(config_entry: ConfigEntry, key: str, default=None) -> Any: if config_entry.options: val = config_entry.options.get(key) if val: @@ -163,7 +174,6 @@ def _get_config_value(config_entry: ConfigEntry, key: str, default = None) -> An return default - def _filter_domain_configs(elements, domain): return list(filter(lambda elem: elem["platform"] == domain, elements)) @@ -171,6 +181,7 @@ def _filter_domain_configs(elements, domain): @dataclass(kw_only=True, frozen=True) class ImsSensorEntityDescription(SensorEntityDescription): """Describes IMS Weather sensor entity.""" + field_name: str | None = None forecast_mode: str | None = None @@ -181,7 +192,9 @@ class ImsEntity(CoordinatorEntity): _attr_has_entity_name = True def __init__( - self, coordinator: WeatherUpdateCoordinator, description: ImsSensorEntityDescription + self, + coordinator: WeatherUpdateCoordinator, + description: ImsSensorEntityDescription, ) -> None: """Initialize.""" super().__init__(coordinator) diff --git a/custom_components/ims/binary_sensor.py b/custom_components/ims/binary_sensor.py index 99882ee..3345794 100644 --- a/custom_components/ims/binary_sensor.py +++ b/custom_components/ims/binary_sensor.py @@ -1,19 +1,27 @@ from collections.abc import Callable from dataclasses import dataclass -from homeassistant.components.binary_sensor import BinarySensorEntityDescription, BinarySensorEntity +from homeassistant.components.binary_sensor import ( + BinarySensorEntityDescription, + BinarySensorEntity, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MONITORED_CONDITIONS from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ImsEntity, ImsSensorEntityDescription -from .const import (TYPE_IS_RAINING, IMS_SENSOR_KEY_PREFIX, FORECAST_MODE, FIELD_NAME_RAIN, DOMAIN, - ENTRY_WEATHER_COORDINATOR) +from .const import ( + TYPE_IS_RAINING, + IMS_SENSOR_KEY_PREFIX, + FORECAST_MODE, + FIELD_NAME_RAIN, + DOMAIN, + ENTRY_WEATHER_COORDINATOR, +) from .weather_update_coordinator import WeatherData - @dataclass(frozen=True, kw_only=True) class ImsBinaryEntityDescriptionMixin: """Mixin values for required keys.""" @@ -22,7 +30,11 @@ class ImsBinaryEntityDescriptionMixin: @dataclass(frozen=True, kw_only=True) -class ImsBinarySensorEntityDescription(ImsSensorEntityDescription, BinarySensorEntityDescription, ImsBinaryEntityDescriptionMixin): +class ImsBinarySensorEntityDescription( + ImsSensorEntityDescription, + BinarySensorEntityDescription, + ImsBinaryEntityDescriptionMixin, +): """Class describing IMS Binary sensors entities""" @@ -33,17 +45,21 @@ class ImsBinarySensorEntityDescription(ImsSensorEntityDescription, BinarySensorE icon="mdi:weather-rainy", forecast_mode=FORECAST_MODE.CURRENT, field_name=FIELD_NAME_RAIN, - value_fn=lambda data: data.current_weather.rain and data.current_weather.rain > 0.0 + value_fn=lambda data: data.current_weather.rain + and data.current_weather.rain > 0.0, ), ) -BINARY_SENSOR_DESCRIPTIONS_DICT = {desc.key: desc for desc in BINARY_SENSORS_DESCRIPTIONS} +BINARY_SENSOR_DESCRIPTIONS_DICT = { + desc.key: desc for desc in BINARY_SENSORS_DESCRIPTIONS +} BINARY_SENSOR_DESCRIPTIONS_KEYS = [desc.key for desc in BINARY_SENSORS_DESCRIPTIONS] + async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up a IMS binary sensors based on a config entry.""" domain_data = hass.data[DOMAIN][entry.entry_id] @@ -62,9 +78,9 @@ async def async_setup_entry( description = BINARY_SENSOR_DESCRIPTIONS_DICT[condition] sensors.append(ImsBinarySensor(weather_coordinator, description)) - async_add_entities(sensors, update_before_add=True) + class ImsBinarySensor(ImsEntity, BinarySensorEntity): """Defines an IMS binary sensor.""" diff --git a/custom_components/ims/config_flow.py b/custom_components/ims/config_flow.py index 1c740aa..863f80e 100644 --- a/custom_components/ims/config_flow.py +++ b/custom_components/ims/config_flow.py @@ -1,4 +1,5 @@ """Config flow for IMS Weather.""" + import logging from datetime import timedelta @@ -10,7 +11,8 @@ from homeassistant import config_entries from homeassistant.const import ( CONF_MODE, - CONF_NAME, CONF_MONITORED_CONDITIONS, + CONF_NAME, + CONF_MONITORED_CONDITIONS, ) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv @@ -41,6 +43,7 @@ cities_data = None SENSOR_KEYS = SENSOR_DESCRIPTIONS_KEYS + BINARY_SENSOR_DESCRIPTIONS_KEYS + class IMSWeatherConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Config flow for IMSWeather.""" @@ -106,7 +109,9 @@ async def async_step_user(self, user_input=None): cities = await _get_localized_cities(self.hass) if not cities: errors["base"] = "cannot_retrieve_cities" - return self.async_show_form(step_id="user", data_schema=vol.Schema({}), errors=errors) + return self.async_show_form( + step_id="user", data_schema=vol.Schema({}), errors=errors + ) # Step 2: Calculate the closest city based on Home Assistant's coordinates ha_latitude = self.hass.config.latitude @@ -115,11 +120,13 @@ async def async_step_user(self, user_input=None): # Step 3: Create a selection field for cities city_options = {city_id: city["name"] for city_id, city in cities.items()} - + schema = vol.Schema( { vol.Optional(CONF_NAME, default=DEFAULT_NAME): str, - vol.Required(CONF_CITY, default=closest_city["lid"]): vol.In(city_options), + vol.Required(CONF_CITY, default=closest_city["lid"]): vol.In( + city_options + ), vol.Required(CONF_LANGUAGE, default=DEFAULT_LANGUAGE): vol.In( LANGUAGES ), @@ -132,9 +139,9 @@ async def async_step_user(self, user_input=None): vol.Required(CONF_MODE, default=DEFAULT_FORECAST_MODE): vol.In( FORECAST_MODES ), - vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSOR_KEYS): cv.multi_select( - SENSOR_KEYS - ), + vol.Optional( + CONF_MONITORED_CONDITIONS, default=SENSOR_KEYS + ): cv.multi_select(SENSOR_KEYS), vol.Required(CONF_IMAGES_PATH, default="/tmp"): cv.string, } ) @@ -165,17 +172,22 @@ async def async_step_import(self, import_input=None): config[CONF_MONITORED_CONDITIONS] = SENSOR_KEYS return await self.async_step_user(config) + supported_ims_languages = ["en", "he", "ar"] + async def _is_ims_api_online(hass, language, city): forecast_url = "https://ims.gov.il/" + language + "/forecast_data/" + str(city) - async with aiohttp.ClientSession(connector=aiohttp.TCPConnector(family=socket.AF_INET), raise_for_status=False) as session: + async with aiohttp.ClientSession( + connector=aiohttp.TCPConnector(family=socket.AF_INET), raise_for_status=False + ) as session: async with session.get(forecast_url) as resp: status = resp.status return status + async def _get_localized_cities(hass): global cities_data if cities_data: @@ -183,7 +195,7 @@ async def _get_localized_cities(hass): lang = hass.config.language if lang not in supported_ims_languages: - lang = 'en' + lang = "en" locations_info_url = "https://ims.gov.il/" + lang + "/locations_info" try: @@ -204,13 +216,16 @@ async def _get_localized_cities(hass): return cities_data + @callback def _handle_http_error(self, error): """Handle HTTP errors.""" self.hass.logger.error(f"Error fetching data from URL: {error}") + def _find_closest_city(cities, ha_latitude, ha_longitude): """Find the closest city based on the Home Assistant coordinates.""" + def distance(lat1, lon1, lat2, lon2): # Calculate the distance between two lat/lon points (Haversine formula) R = 6371 # Radius of Earth in kilometers @@ -223,23 +238,22 @@ def distance(lat1, lon1, lat2, lon2): closest_city = None closest_distance = float("inf") - + for city_id, city in cities.items(): city_lat = float(city["lat"]) city_lon = float(city["lon"]) dist = distance(ha_latitude, ha_longitude, city_lat, city_lon) - + if dist < closest_distance: closest_distance = dist closest_city = city - + if closest_distance > 10: return cities["1"] else: return closest_city - class IMSWeatherOptionsFlow(config_entries.OptionsFlow): """Handle options.""" @@ -313,7 +327,9 @@ async def async_step_init(self, user_input=None): CONF_MONITORED_CONDITIONS, default=self.config_entry.options.get( CONF_MONITORED_CONDITIONS, - self.config_entry.data.get(CONF_MONITORED_CONDITIONS, SENSOR_KEYS), + self.config_entry.data.get( + CONF_MONITORED_CONDITIONS, SENSOR_KEYS + ), ), ): cv.multi_select(SENSOR_KEYS), vol.Optional( @@ -327,4 +343,4 @@ async def async_step_init(self, user_input=None): ): str, } ), - ) \ No newline at end of file + ) diff --git a/custom_components/ims/const.py b/custom_components/ims/const.py index 4c6bafb..1cf0885 100644 --- a/custom_components/ims/const.py +++ b/custom_components/ims/const.py @@ -1,4 +1,5 @@ """Consts for the OpenWeatherMap.""" + from __future__ import annotations import types @@ -171,7 +172,7 @@ "1140": ATTR_CONDITION_POURING, "1160": ATTR_CONDITION_FOG, "1220": ATTR_CONDITION_PARTLYCLOUDY, - "1220-night": ATTR_CONDITION_PARTLYCLOUDY, #no "-night" + "1220-night": ATTR_CONDITION_PARTLYCLOUDY, # no "-night" "1230": ATTR_CONDITION_CLOUDY, "1250": ATTR_CONDITION_SUNNY, "1250-night": ATTR_CONDITION_CLEAR_NIGHT, diff --git a/custom_components/ims/sensor.py b/custom_components/ims/sensor.py index 220848b..dfbb3c3 100644 --- a/custom_components/ims/sensor.py +++ b/custom_components/ims/sensor.py @@ -1,16 +1,21 @@ import logging import types -import homeassistant.util.dt as dt_util from homeassistant.components.sensor import ( SensorEntity, SensorStateClass, - SensorDeviceClass + SensorDeviceClass, ) from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import UV_INDEX, UnitOfTemperature, PERCENTAGE, UnitOfSpeed, \ - UnitOfPrecipitationDepth, CONF_MONITORED_CONDITIONS +from homeassistant.const import ( + UV_INDEX, + UnitOfTemperature, + PERCENTAGE, + UnitOfSpeed, + UnitOfPrecipitationDepth, + CONF_MONITORED_CONDITIONS, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -18,20 +23,48 @@ from . import ImsEntity, ImsSensorEntityDescription from .const import ( DOMAIN, - PLATFORMS, IMS_PLATFORMS, IMS_PLATFORM, ENTRY_WEATHER_COORDINATOR, TYPE_CURRENT_UV_INDEX, TYPE_CURRENT_UV_LEVEL, - TYPE_MAX_UV_INDEX, FIELD_NAME_UV_INDEX, FIELD_NAME_UV_LEVEL, FIELD_NAME_UV_INDEX_MAX, TYPE_HUMIDITY, - FIELD_NAME_HUMIDITY, FIELD_NAME_TEMPERATURE, FIELD_NAME_LOCATION, TYPE_FEELS_LIKE, FIELD_NAME_FEELS_LIKE, - FIELD_NAME_RAIN, TYPE_WIND_SPEED, TYPE_FORECAST_TIME, FIELD_NAME_FORECAST_TIME, TYPE_CITY, TYPE_TEMPERATURE, - FIELD_NAME_WIND_SPEED, TYPE_FORECAST_PREFIX, TYPE_FORECAST_TODAY, TYPE_FORECAST_DAY1, TYPE_FORECAST_DAY2, - TYPE_FORECAST_DAY3, TYPE_FORECAST_DAY4, TYPE_FORECAST_DAY5, TYPE_FORECAST_DAY6, TYPE_FORECAST_DAY7, - WEATHER_CODE_TO_ICON, TYPE_PRECIPITATION, TYPE_PRECIPITATION_PROBABILITY, - FIELD_NAME_RAIN_CHANCE, IMS_SENSOR_KEY_PREFIX, FORECAST_MODE, UV_LEVEL_EXTREME, UV_LEVEL_HIGH, UV_LEVEL_LOW, - UV_LEVEL_MODERATE, UV_LEVEL_VHIGH + TYPE_MAX_UV_INDEX, + FIELD_NAME_UV_INDEX, + FIELD_NAME_UV_LEVEL, + FIELD_NAME_UV_INDEX_MAX, + TYPE_HUMIDITY, + FIELD_NAME_HUMIDITY, + FIELD_NAME_TEMPERATURE, + FIELD_NAME_LOCATION, + TYPE_FEELS_LIKE, + FIELD_NAME_FEELS_LIKE, + FIELD_NAME_RAIN, + TYPE_WIND_SPEED, + TYPE_FORECAST_TIME, + FIELD_NAME_FORECAST_TIME, + TYPE_CITY, + TYPE_TEMPERATURE, + FIELD_NAME_WIND_SPEED, + TYPE_FORECAST_PREFIX, + TYPE_FORECAST_TODAY, + TYPE_FORECAST_DAY1, + TYPE_FORECAST_DAY2, + TYPE_FORECAST_DAY3, + TYPE_FORECAST_DAY4, + TYPE_FORECAST_DAY5, + TYPE_FORECAST_DAY6, + TYPE_FORECAST_DAY7, + WEATHER_CODE_TO_ICON, + TYPE_PRECIPITATION, + TYPE_PRECIPITATION_PROBABILITY, + FIELD_NAME_RAIN_CHANCE, + IMS_SENSOR_KEY_PREFIX, + FORECAST_MODE, + UV_LEVEL_EXTREME, + UV_LEVEL_HIGH, + UV_LEVEL_LOW, + UV_LEVEL_MODERATE, + UV_LEVEL_VHIGH, ) from .utils import get_hourly_weather_icon @@ -45,16 +78,34 @@ sensor_keys.TYPE_FORECAST_TIME = IMS_SENSOR_KEY_PREFIX + TYPE_FORECAST_TIME sensor_keys.TYPE_FEELS_LIKE = IMS_SENSOR_KEY_PREFIX + TYPE_FEELS_LIKE sensor_keys.TYPE_PRECIPITATION = IMS_SENSOR_KEY_PREFIX + TYPE_PRECIPITATION -sensor_keys.TYPE_PRECIPITATION_PROBABILITY = IMS_SENSOR_KEY_PREFIX + TYPE_PRECIPITATION_PROBABILITY +sensor_keys.TYPE_PRECIPITATION_PROBABILITY = ( + IMS_SENSOR_KEY_PREFIX + TYPE_PRECIPITATION_PROBABILITY +) sensor_keys.TYPE_WIND_SPEED = IMS_SENSOR_KEY_PREFIX + TYPE_WIND_SPEED -sensor_keys.TYPE_FORECAST_TODAY = IMS_SENSOR_KEY_PREFIX + TYPE_FORECAST_PREFIX + TYPE_FORECAST_TODAY -sensor_keys.TYPE_FORECAST_DAY1 = IMS_SENSOR_KEY_PREFIX + TYPE_FORECAST_PREFIX + TYPE_FORECAST_DAY1 -sensor_keys.TYPE_FORECAST_DAY2 = IMS_SENSOR_KEY_PREFIX + TYPE_FORECAST_PREFIX + TYPE_FORECAST_DAY2 -sensor_keys.TYPE_FORECAST_DAY3 = IMS_SENSOR_KEY_PREFIX + TYPE_FORECAST_PREFIX + TYPE_FORECAST_DAY3 -sensor_keys.TYPE_FORECAST_DAY4 = IMS_SENSOR_KEY_PREFIX + TYPE_FORECAST_PREFIX + TYPE_FORECAST_DAY4 -sensor_keys.TYPE_FORECAST_DAY5 = IMS_SENSOR_KEY_PREFIX + TYPE_FORECAST_PREFIX + TYPE_FORECAST_DAY5 -sensor_keys.TYPE_FORECAST_DAY6 = IMS_SENSOR_KEY_PREFIX + TYPE_FORECAST_PREFIX + TYPE_FORECAST_DAY6 -sensor_keys.TYPE_FORECAST_DAY7 = IMS_SENSOR_KEY_PREFIX + TYPE_FORECAST_PREFIX + TYPE_FORECAST_DAY7 +sensor_keys.TYPE_FORECAST_TODAY = ( + IMS_SENSOR_KEY_PREFIX + TYPE_FORECAST_PREFIX + TYPE_FORECAST_TODAY +) +sensor_keys.TYPE_FORECAST_DAY1 = ( + IMS_SENSOR_KEY_PREFIX + TYPE_FORECAST_PREFIX + TYPE_FORECAST_DAY1 +) +sensor_keys.TYPE_FORECAST_DAY2 = ( + IMS_SENSOR_KEY_PREFIX + TYPE_FORECAST_PREFIX + TYPE_FORECAST_DAY2 +) +sensor_keys.TYPE_FORECAST_DAY3 = ( + IMS_SENSOR_KEY_PREFIX + TYPE_FORECAST_PREFIX + TYPE_FORECAST_DAY3 +) +sensor_keys.TYPE_FORECAST_DAY4 = ( + IMS_SENSOR_KEY_PREFIX + TYPE_FORECAST_PREFIX + TYPE_FORECAST_DAY4 +) +sensor_keys.TYPE_FORECAST_DAY5 = ( + IMS_SENSOR_KEY_PREFIX + TYPE_FORECAST_PREFIX + TYPE_FORECAST_DAY5 +) +sensor_keys.TYPE_FORECAST_DAY6 = ( + IMS_SENSOR_KEY_PREFIX + TYPE_FORECAST_PREFIX + TYPE_FORECAST_DAY6 +) +sensor_keys.TYPE_FORECAST_DAY7 = ( + IMS_SENSOR_KEY_PREFIX + TYPE_FORECAST_PREFIX + TYPE_FORECAST_DAY7 +) _LOGGER = logging.getLogger(__name__) @@ -205,7 +256,10 @@ weather = None -async def async_setup_platform(hass, config_entry, async_add_entities, discovery_info=None): + +async def async_setup_platform( + hass, config_entry, async_add_entities, discovery_info=None +): _LOGGER.warning( "Configuration of IMS Weather sensor in YAML is deprecated " "Your existing configuration has been imported into the UI automatically " @@ -223,9 +277,9 @@ async def async_setup_platform(hass, config_entry, async_add_entities, discovery async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up IMS Weather sensor entities based on a config entry.""" @@ -261,7 +315,9 @@ def generate_forecast_extra_state_attributes(daily_forecast): "maximum_uvi": {"value": daily_forecast.maximum_uvi, "unit": UV_INDEX}, "weather": { "value": daily_forecast.weather, - "icon": WEATHER_CODE_TO_ICON.get(daily_forecast.weather_code, "mdi:weather-sunny"), + "icon": WEATHER_CODE_TO_ICON.get( + daily_forecast.weather_code, "mdi:weather-sunny" + ), }, "description": {"value": daily_forecast.description}, "date": {"value": daily_forecast.date.strftime("%Y/%m/%d")}, @@ -285,13 +341,17 @@ def generate_forecast_extra_state_attributes(daily_forecast): attributes[hour.hour] = { "weather": { "value": last_weather_status, - "icon": WEATHER_CODE_TO_ICON.get(hourly_weather_code) + "icon": WEATHER_CODE_TO_ICON.get(hourly_weather_code), + }, + "temperature": { + "value": hour.precise_temperature or hour.temperature, + "unit": UnitOfTemperature.CELSIUS, }, - "temperature": {"value": hour.precise_temperature or hour.temperature, "unit": UnitOfTemperature.CELSIUS}, } return attributes + class ImsSensor(ImsEntity, SensorEntity): """Representation of an IMS sensor.""" @@ -300,14 +360,21 @@ def _update_from_latest_data(self) -> None: """Update the state.""" data = self.coordinator.data - if self.entity_description.forecast_mode == FORECAST_MODE.DAILY or self.entity_description.forecast_mode == FORECAST_MODE.HOURLY: + if ( + self.entity_description.forecast_mode == FORECAST_MODE.DAILY + or self.entity_description.forecast_mode == FORECAST_MODE.HOURLY + ): if not data or not data.forecast: - _LOGGER.warning("For %s - no data.forecast", self.entity_description.key) + _LOGGER.warning( + "For %s - no data.forecast", self.entity_description.key + ) self._attr_native_value = None return elif self.entity_description.forecast_mode == FORECAST_MODE.CURRENT: if not data or not data.current_weather: - _LOGGER.warning("For %s - no data.current_weather", self.entity_description.key) + _LOGGER.warning( + "For %s - no data.current_weather", self.entity_description.key + ) self._attr_native_value = None return @@ -332,7 +399,11 @@ def _update_from_latest_data(self) -> None: self._attr_native_value = data.current_weather.u_v_i_max case sensor_keys.TYPE_CITY: - _LOGGER.debug("Location: %s, entity: %s", data.current_weather.location, self.entity_description.key) + _LOGGER.debug( + "Location: %s, entity: %s", + data.current_weather.location, + self.entity_description.key, + ) self._attr_native_value = data.current_weather.location case sensor_keys.TYPE_TEMPERATURE: @@ -345,29 +416,47 @@ def _update_from_latest_data(self) -> None: self._attr_native_value = data.current_weather.humidity case sensor_keys.TYPE_PRECIPITATION: - self._attr_native_value = data.current_weather.rain if ( - data.current_weather.rain and data.current_weather.rain > 0.0) else 0.0 + self._attr_native_value = ( + data.current_weather.rain + if (data.current_weather.rain and data.current_weather.rain > 0.0) + else 0.0 + ) case sensor_keys.TYPE_PRECIPITATION_PROBABILITY: self._attr_native_value = data.current_weather.rain_chance case sensor_keys.TYPE_FORECAST_TIME: - self._attr_native_value = data.current_weather.forecast_time.astimezone() + self._attr_native_value = ( + data.current_weather.forecast_time.astimezone() + ) case sensor_keys.TYPE_WIND_SPEED: self._attr_native_value = data.current_weather.wind_speed - case sensor_keys.TYPE_FORECAST_TODAY | sensor_keys.TYPE_FORECAST_DAY1 | \ - sensor_keys.TYPE_FORECAST_DAY2 | sensor_keys.TYPE_FORECAST_DAY3 | \ - sensor_keys.TYPE_FORECAST_DAY4 | sensor_keys.TYPE_FORECAST_DAY5 | \ - sensor_keys.TYPE_FORECAST_DAY6 | sensor_keys.TYPE_FORECAST_DAY7: - day_index = 0 if self.entity_description.key == sensor_keys.TYPE_FORECAST_TODAY \ + case ( + sensor_keys.TYPE_FORECAST_TODAY + | sensor_keys.TYPE_FORECAST_DAY1 + | sensor_keys.TYPE_FORECAST_DAY2 + | sensor_keys.TYPE_FORECAST_DAY3 + | sensor_keys.TYPE_FORECAST_DAY4 + | sensor_keys.TYPE_FORECAST_DAY5 + | sensor_keys.TYPE_FORECAST_DAY6 + | sensor_keys.TYPE_FORECAST_DAY7 + ): + day_index = ( + 0 + if self.entity_description.key == sensor_keys.TYPE_FORECAST_TODAY else int(self.entity_description.key[-1]) + ) if day_index < len(data.forecast.days): daily_forecast = data.forecast.days[day_index] self._attr_native_value = daily_forecast.day - self._attr_extra_state_attributes = generate_forecast_extra_state_attributes(daily_forecast) - self._attr_icon = WEATHER_CODE_TO_ICON.get(daily_forecast.weather_code, "mdi:weather-sunny") + self._attr_extra_state_attributes = ( + generate_forecast_extra_state_attributes(daily_forecast) + ) + self._attr_icon = WEATHER_CODE_TO_ICON.get( + daily_forecast.weather_code, "mdi:weather-sunny" + ) case _: self._attr_native_value = None diff --git a/custom_components/ims/services.yaml b/custom_components/ims/services.yaml new file mode 100644 index 0000000..273a422 --- /dev/null +++ b/custom_components/ims/services.yaml @@ -0,0 +1,3 @@ +debug_get_coordinator_data: + description: "Fetch and return the coordinator data for debugging purposes." + fields: {} \ No newline at end of file diff --git a/custom_components/ims/translations/en.json b/custom_components/ims/translations/en.json index 7f6363d..ca32b71 100644 --- a/custom_components/ims/translations/en.json +++ b/custom_components/ims/translations/en.json @@ -1,49 +1,55 @@ { - "config": { - "abort": { - "already_configured": "Location is already configured" - }, - "error": { - "cannot_connect": "Failed to connect" - }, - "step": { - "user": { - "data": { - "city": "City", - "language": "Language", - "mode": "Forecast mode for the Weather entity", - "name": "Integration Name", - "images_path": "Path to download the images to", - "update_interval": "Minutes to wait between updates. Reducing this below 15 minutes is not recommended.", - "ims_platform": "Weather Entity and/or Sensor Entity. Sensor will create entities for each condition at each time. If unsure, only select Weather!", - "monitored_conditions": "Monitored conditions to create sensors for. Only used if sensors are requested." - }, - "description": "Set up IMS Weather integration", - "data_description": { - } - } - } - }, - "options": { - "step": { - "init": { - "data": { - "city": "City", - "language": "Language", - "mode": "Forecast mode for the Weather entity", - "name": "Integration Name", - "units": "Units for sensors. Only used for if sensors are requested.", - "images_path": "Path to download the images to", - "update_interval": "Minutes to wait between updates. Reducing this below 15 minutes is not recommended.", - "ims_platform": "Weather Entity and/or Sensor Entity. Sensor will create entities for each condition at each time. If unsure, only select Weather!", - "monitored_conditions": "Monitored conditions to create sensors for. Only used if sensors are requested.\n NOTE: Removing sensors will produce orphaned entities that need to be deleted." - }, - "description": "Set up IMS Weather integration", - "data_description": { - } - } - } - }, + "config": { + "abort": { + "already_configured": "Location is already configured" + }, + "error": { + "cannot_connect": "Failed to connect" + }, + "step": { + "user": { + "data": { + "city": "City", + "language": "Language", + "mode": "Forecast mode for the Weather entity", + "name": "Integration Name", + "images_path": "Path to download the images to", + "update_interval": "Minutes to wait between updates. Reducing this below 15 minutes is not recommended.", + "ims_platform": "Weather Entity and/or Sensor Entity. Sensor will create entities for each condition at each time. If unsure, only select Weather!", + "monitored_conditions": "Monitored conditions to create sensors for. Only used if sensors are requested." + }, + "description": "Set up IMS Weather integration", + "data_description": { + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "city": "City", + "language": "Language", + "mode": "Forecast mode for the Weather entity", + "name": "Integration Name", + "units": "Units for sensors. Only used for if sensors are requested.", + "images_path": "Path to download the images to", + "update_interval": "Minutes to wait between updates. Reducing this below 15 minutes is not recommended.", + "ims_platform": "Weather Entity and/or Sensor Entity. Sensor will create entities for each condition at each time. If unsure, only select Weather!", + "monitored_conditions": "Monitored conditions to create sensors for. Only used if sensors are requested.\n NOTE: Removing sensors will produce orphaned entities that need to be deleted." + }, + "description": "Set up IMS Weather integration", + "data_description": { + } + } + } + }, + "services": { + "debug_get_coordinator_data": { + "name": "Get IMS Coordinator Data", + "description": "Fetch and return the coordinator data for debugging purposes." + } + }, "entity": { "binary_sensor": { "ims_is_raining_he": { diff --git a/custom_components/ims/translations/he.json b/custom_components/ims/translations/he.json index 3485350..c5a8d9f 100644 --- a/custom_components/ims/translations/he.json +++ b/custom_components/ims/translations/he.json @@ -1,49 +1,55 @@ { - "config": { - "abort": { - "already_configured": "מוגדר חיישן עבור ישוב זה" - }, - "error": { - "cannot_connect": "שגיאה בהתחברות" - }, - "step": { - "user": { - "data": { - "city": "יישוב", - "language": "שפה", - "mode": "מצב תחזית עבור חיישן התחזית", - "name": "שם האינטגרציה", - "images_path": "הנתיב אליו יורדו התמונות", - "update_interval": "מס' הדקות בין כל משיכת עדכון. לא מומלץ לשים ערך קטן מ-15 דק'.", - "ims_platform": "ישות תחזית מזג אוויר ו/או יישות חיישן. בחירה בחיישן תייצר יישות עבור כל אחד מהמאפיינים בנפרד. אם יש ספק, בחרו במזג אוויר!", - "monitored_conditions": "תנאים ליצור חיישנים עבורם, רק כאשר בחרת ליצור חיישנים" + "config": { + "abort": { + "already_configured": "מוגדר חיישן עבור ישוב זה" + }, + "error": { + "cannot_connect": "שגיאה בהתחברות" + }, + "step": { + "user": { + "data": { + "city": "יישוב", + "language": "שפה", + "mode": "מצב תחזית עבור חיישן התחזית", + "name": "שם האינטגרציה", + "images_path": "הנתיב אליו יורדו התמונות", + "update_interval": "מס' הדקות בין כל משיכת עדכון. לא מומלץ לשים ערך קטן מ-15 דק'.", + "ims_platform": "ישות תחזית מזג אוויר ו/או יישות חיישן. בחירה בחיישן תייצר יישות עבור כל אחד מהמאפיינים בנפרד. אם יש ספק, בחרו במזג אוויר!", + "monitored_conditions": "תנאים ליצור חיישנים עבורם, רק כאשר בחרת ליצור חיישנים" - }, - "description": "הגדרות שילוב השירות המטאורולוגי הישראלי", - "data_description": { - } - } - } - }, - "options": { - "step": { - "init": { - "data": { - "city": "יישוב", - "language": "שפה", - "mode": "מצב תחזית עבור חיישן התחזית", - "name": "שם האינטגרציה", - "images_path": "הנתיב אליו יורדו התמונות", - "update_interval": "מס' הדקות בין כל משיכת עדכון. לא מומלץ לשים ערך קטן מ-15 דק'.", - "ims_platform": "ישות תחזית מזג אוויר ו/או יישות חיישן. בחירה בחיישן תייצר יישות עבור כל אחד מהמאפיינים בנפרד. אם יש ספק, בחרו במזג אוויר!", - "monitored_conditions": "תנאים ליצור חיישנים עבורם, רק כאשר בחרת ליצור חיישנים. \n הערה: הסרת חיישנים מהרשימה תיצור חיישנים יתומים שצריך להסיר ידנית" - }, - "description": "הגדרות שילוב השירות המטאורולוגי הישראלי", - "data_description": { - } - } - } - }, + }, + "description": "הגדרות שילוב השירות המטאורולוגי הישראלי", + "data_description": { + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "city": "יישוב", + "language": "שפה", + "mode": "מצב תחזית עבור חיישן התחזית", + "name": "שם האינטגרציה", + "images_path": "הנתיב אליו יורדו התמונות", + "update_interval": "מס' הדקות בין כל משיכת עדכון. לא מומלץ לשים ערך קטן מ-15 דק'.", + "ims_platform": "ישות תחזית מזג אוויר ו/או יישות חיישן. בחירה בחיישן תייצר יישות עבור כל אחד מהמאפיינים בנפרד. אם יש ספק, בחרו במזג אוויר!", + "monitored_conditions": "תנאים ליצור חיישנים עבורם, רק כאשר בחרת ליצור חיישנים. \n הערה: הסרת חיישנים מהרשימה תיצור חיישנים יתומים שצריך להסיר ידנית" + }, + "description": "הגדרות שילוב השירות המטאורולוגי הישראלי", + "data_description": { + } + } + } + }, + "services": { + "debug_get_coordinator_data": { + "name": "הבא מידע שנטען מהשירות המטאורולוגי", + "description": "הדפס מידע במערכת שנטען מהשירות המטאורולוגי לצורך ניפוי שגיאות." + } + }, "entity": { "binary_sensor": { "ims_is_raining_he": { diff --git a/custom_components/ims/translations/pt.json b/custom_components/ims/translations/pt.json index f7baf15..099c0a2 100644 --- a/custom_components/ims/translations/pt.json +++ b/custom_components/ims/translations/pt.json @@ -42,6 +42,12 @@ } } }, + "services": { + "debug_get_coordinator_data": { + "name": "Get IMS Coordinator Data", + "description": "Fetch and return the coordinator data for debugging purposes." + } + }, "entity": { "binary_sensor": { "ims_is_raining_he": { diff --git a/custom_components/ims/utils.py b/custom_components/ims/utils.py index b126962..2e921ba 100644 --- a/custom_components/ims/utils.py +++ b/custom_components/ims/utils.py @@ -2,6 +2,7 @@ night_weather_codes = ["1220", "1250"] + def get_hourly_weather_icon(hour, weather_code, strptime="%H:%M"): hourly_weather_code = weather_code time_object = datetime.strptime(hour, strptime) @@ -10,5 +11,6 @@ def get_hourly_weather_icon(hour, weather_code, strptime="%H:%M"): return hourly_weather_code + def _is_night(hour): - return hour < 6 or hour > 20 \ No newline at end of file + return hour < 6 or hour > 20 diff --git a/custom_components/ims/weather.py b/custom_components/ims/weather.py index 20c55e1..4882c22 100644 --- a/custom_components/ims/weather.py +++ b/custom_components/ims/weather.py @@ -1,8 +1,6 @@ from __future__ import annotations import logging -import homeassistant.util.dt as dt_util -import pytz from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import DiscoveryInfoType @@ -14,15 +12,10 @@ # PLATFORM_SCHEMA, Forecast, WeatherEntity, - WeatherEntityFeature + WeatherEntityFeature, ) -from homeassistant.const import ( - CONF_NAME, - UnitOfSpeed, - UnitOfPressure, - UnitOfLength -) +from homeassistant.const import CONF_NAME, UnitOfSpeed, UnitOfPressure, UnitOfLength from .const import ( ATTRIBUTION, @@ -48,10 +41,10 @@ async def async_setup_platform( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, ) -> None: """Import the platform into a config entry.""" _LOGGER.warning( @@ -71,9 +64,9 @@ async def async_setup_platform( async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up IMS Weather entity based on a config entry.""" domain_data = hass.data[DOMAIN][config_entry.entry_id] @@ -86,14 +79,18 @@ async def async_setup_entry( if isinstance(city, int | str): is_legacy_city = True - unique_id = f"{config_entry.unique_id}" # Round Output output_round = "No" ims_weather = IMSWeather( - name, unique_id, forecast_mode, weather_coordinator, city if is_legacy_city else city["name"], output_round + name, + unique_id, + forecast_mode, + weather_coordinator, + city if is_legacy_city else city["name"], + output_round, ) async_add_entities([ims_weather], False) @@ -105,6 +102,7 @@ def round_if_needed(value: int | float, output_round: bool): else: return round(value, 2) + class IMSWeather(WeatherEntity): """Implementation of an IMSWeather sensor.""" @@ -116,17 +114,17 @@ class IMSWeather(WeatherEntity): _attr_native_visibility_unit = UnitOfLength.KILOMETERS _attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR _attr_supported_features = ( - WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY + WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY ) def __init__( - self, - name: str, - unique_id, - forecast_mode: str, - weather_coordinator: WeatherUpdateCoordinator, - city: str, - output_round: str, + self, + name: str, + unique_id, + forecast_mode: str, + weather_coordinator: WeatherUpdateCoordinator, + city: str, + output_round: str, ) -> None: """Initialize the sensor.""" self._attr_name = name @@ -155,7 +153,11 @@ def unique_id(self): @property def available(self): """Return if weather data is available from IMSWeather.""" - return self._weather_coordinator.data is not None and self._weather_coordinator.data.current_weather is not None and self._weather_coordinator.data.forecast is not None + return ( + self._weather_coordinator.data is not None + and self._weather_coordinator.data.current_weather is not None + and self._weather_coordinator.data.forecast is not None + ) @property def attribution(self): @@ -177,7 +179,9 @@ def native_temperature(self): @property def native_apparent_temperature(self): """Return the native apparent temperature (feel-like).""" - feels_like_temperature = float(self._weather_coordinator.data.current_weather.feels_like) + feels_like_temperature = float( + self._weather_coordinator.data.current_weather.feels_like + ) return round_if_needed(feels_like_temperature, self._output_round) @@ -214,11 +218,13 @@ def condition(self): """Return the weather condition.""" date_str = self._weather_coordinator.data.current_weather.json["forecast_time"] - weather_code = get_hourly_weather_icon(date_str, self._weather_coordinator.data.current_weather.weather_code, "%Y-%m-%d %H:%M:%S") + weather_code = get_hourly_weather_icon( + date_str, + self._weather_coordinator.data.current_weather.weather_code, + "%Y-%m-%d %H:%M:%S", + ) - condition = WEATHER_CODE_TO_CONDITION[ - weather_code - ] + condition = WEATHER_CODE_TO_CONDITION[weather_code] if not condition or condition == "Nothing": condition = WEATHER_CODE_TO_CONDITION[ self._weather_coordinator.data.forecast.days[0].weather_code @@ -243,19 +249,25 @@ def _forecast(self, hourly: bool) -> list[Forecast]: condition=WEATHER_CODE_TO_CONDITION[daily_forecast.weather_code], datetime=daily_forecast.date.isoformat(), native_temperature=daily_forecast.maximum_temperature, - native_templow=daily_forecast.minimum_temperature - ) for daily_forecast in self._weather_coordinator.data.forecast.days + native_templow=daily_forecast.minimum_temperature, + ) + for daily_forecast in self._weather_coordinator.data.forecast.days ] else: last_weather_code = None for daily_forecast in self._weather_coordinator.data.forecast.days: for hourly_forecast in daily_forecast.hours: - if hourly_forecast.weather_code and hourly_forecast.weather_code != "0": + if ( + hourly_forecast.weather_code + and hourly_forecast.weather_code != "0" + ): last_weather_code = hourly_forecast.weather_code elif not last_weather_code: last_weather_code = daily_forecast.weather_code - hourly_weather_code = get_hourly_weather_icon(hourly_forecast.hour, last_weather_code) + hourly_weather_code = get_hourly_weather_icon( + hourly_forecast.hour, last_weather_code + ) data.append( Forecast( @@ -264,10 +276,12 @@ def _forecast(self, hourly: bool) -> list[Forecast]: native_temperature=hourly_forecast.precise_temperature, native_templow=daily_forecast.minimum_temperature, native_precipitation=hourly_forecast.rain, - wind_bearing=WIND_DIRECTIONS[hourly_forecast.wind_direction_id], + wind_bearing=WIND_DIRECTIONS[ + hourly_forecast.wind_direction_id + ], precipitation_probability=hourly_forecast.rain_chance, native_wind_speed=hourly_forecast.wind_speed, - uv_index=hourly_forecast.u_v_index + uv_index=hourly_forecast.u_v_index, ) ) diff --git a/custom_components/ims/weather_update_coordinator.py b/custom_components/ims/weather_update_coordinator.py index c5a148b..0064d98 100644 --- a/custom_components/ims/weather_update_coordinator.py +++ b/custom_components/ims/weather_update_coordinator.py @@ -1,4 +1,5 @@ """Weather data coordinator for the OpenWeatherMap (OWM) service.""" + import asyncio import datetime import logging @@ -17,6 +18,7 @@ timezone = dt_util.get_time_zone("Asia/Jerusalem") + class WeatherUpdateCoordinator(DataUpdateCoordinator): """Weather data update coordinator.""" @@ -58,21 +60,30 @@ async def _get_ims_weather(self): weather_forecast = await loop.run_in_executor(None, self.weather.get_forecast) images = await loop.run_in_executor(None, self.weather.get_radar_images) - _LOGGER.debug("Data fetched from IMS of %s", current_weather.forecast_time.strftime("%m/%d/%Y, %H:%M:%S")) + _LOGGER.debug( + "Data fetched from IMS of %s", + current_weather.forecast_time.strftime("%m/%d/%Y, %H:%M:%S"), + ) self._filter_future_forecast(weather_forecast) return WeatherData(current_weather, weather_forecast, images) @staticmethod def _filter_future_forecast(weather_forecast): - """ Filter Forecast to include only future dates """ - today_datetime = dt_util.as_local(datetime.datetime.combine(dt_util.now(timezone).date(), datetime.time())) - filtered_day_list = list(filter(lambda daily: daily.date >= today_datetime, weather_forecast.days)) + """Filter Forecast to include only future dates""" + today_datetime = dt_util.as_local( + datetime.datetime.combine(dt_util.now(timezone).date(), datetime.time()) + ) + filtered_day_list = list( + filter(lambda daily: daily.date >= today_datetime, weather_forecast.days) + ) for daily_forecast in filtered_day_list: filtered_hours = [] for hourly_forecast in daily_forecast.hours: - forecast_datetime = daily_forecast.date + datetime.timedelta(hours=int(hourly_forecast.hour.split(":")[0])) + forecast_datetime = daily_forecast.date + datetime.timedelta( + hours=int(hourly_forecast.hour.split(":")[0]) + ) if dt_util.now(timezone) <= forecast_datetime: filtered_hours.append(hourly_forecast) daily_forecast.hours = filtered_hours