diff --git a/CODEOWNERS b/CODEOWNERS index 5a130d0278b..47ab063477a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -953,6 +953,8 @@ build.json @home-assistant/supervisor /tests/components/met_eireann/ @DylanGore /homeassistant/components/meteo_france/ @hacf-fr @oncleben31 @Quentame /tests/components/meteo_france/ @hacf-fr @oncleben31 @Quentame +/homeassistant/components/meteo_lt/ @xE1H +/tests/components/meteo_lt/ @xE1H /homeassistant/components/meteoalarm/ @rolfberkenbosch /homeassistant/components/meteoclimatic/ @adrianmo /tests/components/meteoclimatic/ @adrianmo diff --git a/homeassistant/components/meteo_lt/__init__.py b/homeassistant/components/meteo_lt/__init__.py new file mode 100644 index 00000000000..8e508e76203 --- /dev/null +++ b/homeassistant/components/meteo_lt/__init__.py @@ -0,0 +1,27 @@ +"""The Meteo.lt integration.""" + +from __future__ import annotations + +from homeassistant.core import HomeAssistant + +from .const import CONF_PLACE_CODE, PLATFORMS +from .coordinator import MeteoLtConfigEntry, MeteoLtUpdateCoordinator + + +async def async_setup_entry(hass: HomeAssistant, entry: MeteoLtConfigEntry) -> bool: + """Set up Meteo.lt from a config entry.""" + + coordinator = MeteoLtUpdateCoordinator(hass, entry.data[CONF_PLACE_CODE], entry) + + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: MeteoLtConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/meteo_lt/config_flow.py b/homeassistant/components/meteo_lt/config_flow.py new file mode 100644 index 00000000000..b9478e8b37e --- /dev/null +++ b/homeassistant/components/meteo_lt/config_flow.py @@ -0,0 +1,78 @@ +"""Config flow for Meteo.lt integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +import aiohttp +from meteo_lt import MeteoLtAPI, Place +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult + +from .const import CONF_PLACE_CODE, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class MeteoLtConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Meteo.lt.""" + + def __init__(self) -> None: + """Initialize the config flow.""" + self._api = MeteoLtAPI() + self._places: list[Place] = [] + self._selected_place: Place | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + place_code = user_input[CONF_PLACE_CODE] + self._selected_place = next( + (place for place in self._places if place.code == place_code), + None, + ) + if self._selected_place: + await self.async_set_unique_id(self._selected_place.code) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=self._selected_place.name, + data={ + CONF_PLACE_CODE: self._selected_place.code, + }, + ) + errors["base"] = "invalid_location" + + if not self._places: + try: + await self._api.fetch_places() + self._places = self._api.places + except (aiohttp.ClientError, TimeoutError) as err: + _LOGGER.error("Error fetching places: %s", err) + return self.async_abort(reason="cannot_connect") + + if not self._places: + return self.async_abort(reason="no_places_found") + + places_options = { + place.code: f"{place.name} ({place.administrative_division})" + for place in self._places + } + + data_schema = vol.Schema( + { + vol.Required(CONF_PLACE_CODE): vol.In(places_options), + } + ) + + return self.async_show_form( + step_id="user", + data_schema=data_schema, + errors=errors, + ) diff --git a/homeassistant/components/meteo_lt/const.py b/homeassistant/components/meteo_lt/const.py new file mode 100644 index 00000000000..96aee80b15e --- /dev/null +++ b/homeassistant/components/meteo_lt/const.py @@ -0,0 +1,17 @@ +"""Constants for the Meteo.lt integration.""" + +from datetime import timedelta + +from homeassistant.const import Platform + +DOMAIN = "meteo_lt" +PLATFORMS = [Platform.WEATHER] + +MANUFACTURER = "Lithuanian Hydrometeorological Service" +MODEL = "Weather Station" + +DEFAULT_UPDATE_INTERVAL = timedelta(minutes=30) + +CONF_PLACE_CODE = "place_code" + +ATTRIBUTION = "Data provided by Lithuanian Hydrometeorological Service (LHMT)" diff --git a/homeassistant/components/meteo_lt/coordinator.py b/homeassistant/components/meteo_lt/coordinator.py new file mode 100644 index 00000000000..12044f6fe78 --- /dev/null +++ b/homeassistant/components/meteo_lt/coordinator.py @@ -0,0 +1,61 @@ +"""DataUpdateCoordinator for Meteo.lt integration.""" + +from __future__ import annotations + +import logging + +import aiohttp +from meteo_lt import Forecast as MeteoLtForecast, MeteoLtAPI + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DEFAULT_UPDATE_INTERVAL, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +type MeteoLtConfigEntry = ConfigEntry[MeteoLtUpdateCoordinator] + + +class MeteoLtUpdateCoordinator(DataUpdateCoordinator[MeteoLtForecast]): + """Class to manage fetching Meteo.lt data.""" + + def __init__( + self, + hass: HomeAssistant, + place_code: str, + config_entry: MeteoLtConfigEntry, + ) -> None: + """Initialize the coordinator.""" + self.client = MeteoLtAPI() + self.place_code = place_code + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=DEFAULT_UPDATE_INTERVAL, + config_entry=config_entry, + ) + + async def _async_update_data(self) -> MeteoLtForecast: + """Fetch data from Meteo.lt API.""" + try: + forecast = await self.client.get_forecast(self.place_code) + except aiohttp.ClientResponseError as err: + raise UpdateFailed( + f"API returned error status {err.status}: {err.message}" + ) from err + except aiohttp.ClientConnectionError as err: + raise UpdateFailed(f"Cannot connect to API: {err}") from err + except (aiohttp.ClientError, TimeoutError) as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err + + # Check if forecast data is available + if not forecast.forecast_timestamps: + raise UpdateFailed( + f"No forecast data available for {self.place_code} - API returned empty timestamps" + ) + + return forecast diff --git a/homeassistant/components/meteo_lt/manifest.json b/homeassistant/components/meteo_lt/manifest.json new file mode 100644 index 00000000000..9bd97f4574c --- /dev/null +++ b/homeassistant/components/meteo_lt/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "meteo_lt", + "name": "Meteo.lt", + "codeowners": ["@xE1H"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/meteo_lt", + "integration_type": "service", + "iot_class": "cloud_polling", + "quality_scale": "bronze", + "requirements": ["meteo-lt-pkg==0.2.4"] +} diff --git a/homeassistant/components/meteo_lt/quality_scale.yaml b/homeassistant/components/meteo_lt/quality_scale.yaml new file mode 100644 index 00000000000..52b6505412f --- /dev/null +++ b/homeassistant/components/meteo_lt/quality_scale.yaml @@ -0,0 +1,86 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not register custom service actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: Integration does not provide custom service actions to document. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: Weather entities do not require event subscriptions. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: Integration does not register custom service actions. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: + status: exempt + comment: Public weather service that does not require authentication. + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: Integration does not support discovery. + discovery: + status: exempt + comment: Weather stations cannot be automatically discovered. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: Single weather entity per config entry, no dynamic device addition. + entity-category: + status: exempt + comment: Weather entities are primary entities and do not require categories. + entity-device-class: + status: exempt + comment: Weather entities have implicit device class from the platform. + entity-disabled-by-default: + status: exempt + comment: Primary weather entity should be enabled by default. + entity-translations: todo + exception-translations: todo + icon-translations: + status: exempt + comment: Weather entities use standard condition-based icons. + reconfiguration-flow: todo + repair-issues: todo + stale-devices: + status: exempt + comment: No dynamic device management required. + + # Platinum + async-dependency: done + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/meteo_lt/strings.json b/homeassistant/components/meteo_lt/strings.json new file mode 100644 index 00000000000..9289961f01c --- /dev/null +++ b/homeassistant/components/meteo_lt/strings.json @@ -0,0 +1,25 @@ +{ + "config": { + "step": { + "user": { + "title": "Select station", + "data": { + "place_code": "Station" + }, + "data_description": { + "place_code": "Weather station to get data from" + } + } + }, + "error": { + "cannot_connect": "Failed to connect to Meteo.lt API", + "invalid_location": "Selected station is invalid", + "unknown": "Unexpected error occurred" + }, + "abort": { + "already_configured": "Station is already configured", + "cannot_connect": "Failed to connect to Meteo.lt API", + "no_places_found": "No stations found from the API" + } + } +} diff --git a/homeassistant/components/meteo_lt/weather.py b/homeassistant/components/meteo_lt/weather.py new file mode 100644 index 00000000000..902a899dbc3 --- /dev/null +++ b/homeassistant/components/meteo_lt/weather.py @@ -0,0 +1,190 @@ +"""Weather platform for Meteo.lt integration.""" + +from __future__ import annotations + +from collections import defaultdict +from datetime import datetime +from typing import Any + +from homeassistant.components.weather import ( + Forecast, + WeatherEntity, + WeatherEntityFeature, +) +from homeassistant.const import ( + UnitOfPrecipitationDepth, + UnitOfPressure, + UnitOfSpeed, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ATTRIBUTION, DOMAIN, MANUFACTURER, MODEL +from .coordinator import MeteoLtConfigEntry, MeteoLtUpdateCoordinator + + +async def async_setup_entry( + hass: HomeAssistant, + entry: MeteoLtConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the weather platform.""" + coordinator = entry.runtime_data + + async_add_entities([MeteoLtWeatherEntity(coordinator)]) + + +class MeteoLtWeatherEntity(CoordinatorEntity[MeteoLtUpdateCoordinator], WeatherEntity): + """Weather entity for Meteo.lt.""" + + _attr_has_entity_name = True + _attr_name = None + _attr_attribution = ATTRIBUTION + _attr_native_temperature_unit = UnitOfTemperature.CELSIUS + _attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS + _attr_native_pressure_unit = UnitOfPressure.HPA + _attr_native_wind_speed_unit = UnitOfSpeed.METERS_PER_SECOND + _attr_supported_features = ( + WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY + ) + + def __init__(self, coordinator: MeteoLtUpdateCoordinator) -> None: + """Initialize the weather entity.""" + super().__init__(coordinator) + + self._place_code = coordinator.place_code + self._attr_unique_id = str(self._place_code) + + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, self._place_code)}, + manufacturer=MANUFACTURER, + model=MODEL, + ) + + @property + def native_temperature(self) -> float | None: + """Return the temperature.""" + return self.coordinator.data.current_conditions.temperature + + @property + def native_apparent_temperature(self) -> float | None: + """Return the apparent temperature.""" + return self.coordinator.data.current_conditions.apparent_temperature + + @property + def humidity(self) -> int | None: + """Return the humidity.""" + return self.coordinator.data.current_conditions.humidity + + @property + def native_pressure(self) -> float | None: + """Return the pressure.""" + return self.coordinator.data.current_conditions.pressure + + @property + def native_wind_speed(self) -> float | None: + """Return the wind speed.""" + return self.coordinator.data.current_conditions.wind_speed + + @property + def wind_bearing(self) -> int | None: + """Return the wind bearing.""" + return self.coordinator.data.current_conditions.wind_bearing + + @property + def native_wind_gust_speed(self) -> float | None: + """Return the wind gust speed.""" + return self.coordinator.data.current_conditions.wind_gust_speed + + @property + def cloud_coverage(self) -> int | None: + """Return the cloud coverage.""" + return self.coordinator.data.current_conditions.cloud_coverage + + @property + def condition(self) -> str | None: + """Return the current condition.""" + return self.coordinator.data.current_conditions.condition + + def _convert_forecast_data( + self, forecast_data: Any, include_templow: bool = False + ) -> Forecast: + """Convert forecast timestamp data to Forecast object.""" + return Forecast( + datetime=forecast_data.datetime, + native_temperature=forecast_data.temperature, + native_templow=forecast_data.temperature_low if include_templow else None, + native_apparent_temperature=forecast_data.apparent_temperature, + condition=forecast_data.condition, + native_precipitation=forecast_data.precipitation, + precipitation_probability=None, # Not provided by API + native_wind_speed=forecast_data.wind_speed, + wind_bearing=forecast_data.wind_bearing, + cloud_coverage=forecast_data.cloud_coverage, + ) + + async def async_forecast_daily(self) -> list[Forecast] | None: + """Return the daily forecast.""" + # Using hourly data to create daily summaries, since daily data is not provided directly + if not self.coordinator.data: + return None + + forecasts_by_date = defaultdict(list) + for timestamp in self.coordinator.data.forecast_timestamps: + date = datetime.fromisoformat(timestamp.datetime).date() + forecasts_by_date[date].append(timestamp) + + daily_forecasts = [] + for date in sorted(forecasts_by_date.keys())[:5]: + day_forecasts = forecasts_by_date[date] + if not day_forecasts: + continue + + temps = [ + ts.temperature for ts in day_forecasts if ts.temperature is not None + ] + max_temp = max(temps) if temps else None + min_temp = min(temps) if temps else None + + midday_forecast = min( + day_forecasts, + key=lambda ts: abs(datetime.fromisoformat(ts.datetime).hour - 12), + ) + + daily_forecast = Forecast( + datetime=day_forecasts[0].datetime, + native_temperature=max_temp, + native_templow=min_temp, + native_apparent_temperature=midday_forecast.apparent_temperature, + condition=midday_forecast.condition, + # Calculate precipitation: sum if any values, else None + native_precipitation=( + sum( + ts.precipitation + for ts in day_forecasts + if ts.precipitation is not None + ) + if any(ts.precipitation is not None for ts in day_forecasts) + else None + ), + precipitation_probability=None, + native_wind_speed=midday_forecast.wind_speed, + wind_bearing=midday_forecast.wind_bearing, + cloud_coverage=midday_forecast.cloud_coverage, + ) + daily_forecasts.append(daily_forecast) + + return daily_forecasts + + async def async_forecast_hourly(self) -> list[Forecast] | None: + """Return the hourly forecast.""" + if not self.coordinator.data: + return None + return [ + self._convert_forecast_data(forecast_data) + for forecast_data in self.coordinator.data.forecast_timestamps[:24] + ] diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 03b8f57c6eb..f9e50f9a26c 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -388,6 +388,7 @@ FLOWS = { "met", "met_eireann", "meteo_france", + "meteo_lt", "meteoclimatic", "metoffice", "microbees", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 866ed0115fd..b0ef2400f04 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3920,6 +3920,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "meteo_lt": { + "name": "Meteo.lt", + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling" + }, "meteoalarm": { "name": "MeteoAlarm", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 0904bac3d08..d66bbdfb116 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1436,6 +1436,9 @@ melnor-bluetooth==0.0.25 # homeassistant.components.message_bird messagebird==1.2.0 +# homeassistant.components.meteo_lt +meteo-lt-pkg==0.2.4 + # homeassistant.components.meteoalarm meteoalertapi==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 498c2527870..16397e62653 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1231,6 +1231,9 @@ medcom-ble==0.1.1 # homeassistant.components.melnor melnor-bluetooth==0.0.25 +# homeassistant.components.meteo_lt +meteo-lt-pkg==0.2.4 + # homeassistant.components.meteo_france meteofrance-api==1.4.0 diff --git a/tests/components/meteo_lt/__init__.py b/tests/components/meteo_lt/__init__.py new file mode 100644 index 00000000000..798b9bd2a79 --- /dev/null +++ b/tests/components/meteo_lt/__init__.py @@ -0,0 +1 @@ +"""Tests for Meteo.lt integration.""" diff --git a/tests/components/meteo_lt/conftest.py b/tests/components/meteo_lt/conftest.py new file mode 100644 index 00000000000..97bfc5c044c --- /dev/null +++ b/tests/components/meteo_lt/conftest.py @@ -0,0 +1,68 @@ +"""Fixtures for Meteo.lt integration tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from meteo_lt import Forecast, MeteoLtAPI, Place +import pytest + +from homeassistant.components.meteo_lt.const import CONF_PLACE_CODE, DOMAIN + +from tests.common import ( + MockConfigEntry, + load_json_array_fixture, + load_json_object_fixture, +) + + +@pytest.fixture(autouse=True) +def mock_meteo_lt_api() -> Generator[AsyncMock]: + """Mock MeteoLtAPI with fixture data.""" + with ( + patch( + "homeassistant.components.meteo_lt.coordinator.MeteoLtAPI", + autospec=True, + ) as mock_api_class, + patch( + "homeassistant.components.meteo_lt.config_flow.MeteoLtAPI", + new=mock_api_class, + ), + ): + mock_api = AsyncMock(spec=MeteoLtAPI) + mock_api_class.return_value = mock_api + + places_data = load_json_array_fixture("places.json", DOMAIN) + forecast_data = load_json_object_fixture("forecast.json", DOMAIN) + + mock_places = [Place.from_dict(place_data) for place_data in places_data] + mock_api.places = mock_places + mock_api.fetch_places.return_value = None + + mock_forecast = Forecast.from_dict(forecast_data) + + mock_api.get_forecast.return_value = mock_forecast + + # Mock get_nearest_place to return Vilnius + mock_api.get_nearest_place.return_value = mock_places[0] + + yield mock_api + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.meteo_lt.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Vilnius", + data={CONF_PLACE_CODE: "vilnius"}, + unique_id="vilnius", + ) diff --git a/tests/components/meteo_lt/fixtures/forecast.json b/tests/components/meteo_lt/fixtures/forecast.json new file mode 100644 index 00000000000..d289adb1394 --- /dev/null +++ b/tests/components/meteo_lt/fixtures/forecast.json @@ -0,0 +1,53 @@ +{ + "place": { + "code": "vilnius", + "name": "Vilnius", + "administrativeDivision": "Vilniaus miesto savivaldyb\u0117", + "country": "Lietuva", + "countryCode": "LT", + "coordinates": { "latitude": 54.68705, "longitude": 25.28291 } + }, + "forecastType": "long-term", + "forecastCreationTimeUtc": "2025-09-25 08:01:29", + "forecastTimestamps": [ + { + "forecastTimeUtc": "2025-09-25 10:00:00", + "airTemperature": 10.9, + "feelsLikeTemperature": 10.9, + "windSpeed": 2, + "windGust": 6, + "windDirection": 20, + "cloudCover": 1, + "seaLevelPressure": 1033, + "relativeHumidity": 71, + "totalPrecipitation": 0, + "conditionCode": "clear" + }, + { + "forecastTimeUtc": "2025-09-25 11:00:00", + "airTemperature": 12.2, + "feelsLikeTemperature": 12.2, + "windSpeed": 2, + "windGust": 7, + "windDirection": 25, + "cloudCover": 15, + "seaLevelPressure": 1032, + "relativeHumidity": 68, + "totalPrecipitation": 0, + "conditionCode": "partly-cloudy" + }, + { + "forecastTimeUtc": "2025-09-25 12:00:00", + "airTemperature": 13.5, + "feelsLikeTemperature": 13.5, + "windSpeed": 3, + "windGust": 8, + "windDirection": 30, + "cloudCover": 25, + "seaLevelPressure": 1031, + "relativeHumidity": 65, + "totalPrecipitation": 0.1, + "conditionCode": "cloudy" + } + ] +} diff --git a/tests/components/meteo_lt/fixtures/places.json b/tests/components/meteo_lt/fixtures/places.json new file mode 100644 index 00000000000..b5e2dcb2ca2 --- /dev/null +++ b/tests/components/meteo_lt/fixtures/places.json @@ -0,0 +1,35 @@ +[ + { + "code": "vilnius", + "name": "Vilnius", + "administrativeDivision": "Vilniaus miesto savivaldybė", + "country": "Lietuva", + "countryCode": "LT", + "coordinates": { + "latitude": 54.68705, + "longitude": 25.28291 + } + }, + { + "code": "kaunas", + "name": "Kaunas", + "administrativeDivision": "Kauno miesto savivaldybė", + "country": "Lietuva", + "countryCode": "LT", + "coordinates": { + "latitude": 54.90272, + "longitude": 23.95952 + } + }, + { + "code": "klaipeda", + "name": "Klaipėda", + "administrativeDivision": "Klaipėdos miesto savivaldybė", + "country": "Lietuva", + "countryCode": "LT", + "coordinates": { + "latitude": 55.70329, + "longitude": 21.14427 + } + } +] diff --git a/tests/components/meteo_lt/snapshots/test_weather.ambr b/tests/components/meteo_lt/snapshots/test_weather.ambr new file mode 100644 index 00000000000..a3e5e911530 --- /dev/null +++ b/tests/components/meteo_lt/snapshots/test_weather.ambr @@ -0,0 +1,64 @@ +# serializer version: 1 +# name: test_weather_entity[weather.vilnius-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'weather', + 'entity_category': None, + 'entity_id': 'weather.vilnius', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'meteo_lt', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'vilnius', + 'unit_of_measurement': None, + }) +# --- +# name: test_weather_entity[weather.vilnius-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'apparent_temperature': 10.9, + 'attribution': 'Data provided by Lithuanian Hydrometeorological Service (LHMT)', + 'cloud_coverage': 1, + 'friendly_name': 'Vilnius', + 'humidity': 71, + 'precipitation_unit': , + 'pressure': 1033.0, + 'pressure_unit': , + 'supported_features': , + 'temperature': 10.9, + 'temperature_unit': , + 'visibility_unit': , + 'wind_bearing': 20, + 'wind_gust_speed': 21.6, + 'wind_speed': 7.2, + 'wind_speed_unit': , + }), + 'context': , + 'entity_id': 'weather.vilnius', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'sunny', + }) +# --- diff --git a/tests/components/meteo_lt/test_config_flow.py b/tests/components/meteo_lt/test_config_flow.py new file mode 100644 index 00000000000..67d9bb934b8 --- /dev/null +++ b/tests/components/meteo_lt/test_config_flow.py @@ -0,0 +1,102 @@ +"""Test the Meteo.lt config flow.""" + +from unittest.mock import AsyncMock + +import aiohttp + +from homeassistant.components.meteo_lt.const import CONF_PLACE_CODE, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_user_flow_success( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test user flow shows form and completes successfully.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PLACE_CODE: "vilnius"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Vilnius" + assert result["data"] == {CONF_PLACE_CODE: "vilnius"} + assert result["result"].unique_id == "vilnius" + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_duplicate_entry( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test duplicate entry prevention.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PLACE_CODE: "vilnius"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_api_connection_error( + hass: HomeAssistant, mock_meteo_lt_api: AsyncMock +) -> None: + """Test API connection error during place fetching.""" + mock_meteo_lt_api.places = [] + mock_meteo_lt_api.fetch_places.side_effect = aiohttp.ClientError( + "Connection failed" + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_api_timeout_error( + hass: HomeAssistant, mock_meteo_lt_api: AsyncMock +) -> None: + """Test API timeout error during place fetching.""" + mock_meteo_lt_api.places = [] + mock_meteo_lt_api.fetch_places.side_effect = TimeoutError("Request timed out") + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_no_places_found( + hass: HomeAssistant, mock_meteo_lt_api: AsyncMock +) -> None: + """Test when API returns no places.""" + mock_meteo_lt_api.places = [] + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_places_found" diff --git a/tests/components/meteo_lt/test_weather.py b/tests/components/meteo_lt/test_weather.py new file mode 100644 index 00000000000..27a6c549c03 --- /dev/null +++ b/tests/components/meteo_lt/test_weather.py @@ -0,0 +1,36 @@ +"""Test Meteo.lt weather entity.""" + +from collections.abc import Generator +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +def override_platforms() -> Generator[None]: + """Override PLATFORMS.""" + with patch("homeassistant.components.meteo_lt.PLATFORMS", [Platform.WEATHER]): + yield + + +@pytest.mark.freeze_time("2025-09-25 10:00:00") +async def test_weather_entity( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test weather entity.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)