diff --git a/.coveragerc b/.coveragerc index d17676d79c94afd56cf1ef111076060aab565b5f..b2290556519c286af5b6c15428d5fea873ba5be9 100644 --- a/.coveragerc +++ b/.coveragerc @@ -495,6 +495,9 @@ omit = homeassistant/components/hive/sensor.py homeassistant/components/hive/switch.py homeassistant/components/hive/water_heater.py + homeassistant/components/hko/__init__.py + homeassistant/components/hko/weather.py + homeassistant/components/hko/coordinator.py homeassistant/components/hlk_sw16/__init__.py homeassistant/components/hlk_sw16/switch.py homeassistant/components/home_connect/__init__.py diff --git a/CODEOWNERS b/CODEOWNERS index 04dd08841a1c72f505b99f2b9acb6ff9143888ce..f52d810958bf9e9c5fb705c6d6446d95e609dcc4 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -528,6 +528,8 @@ build.json @home-assistant/supervisor /tests/components/history/ @home-assistant/core /homeassistant/components/hive/ @Rendili @KJonline /tests/components/hive/ @Rendili @KJonline +/homeassistant/components/hko/ @MisterCommand +/tests/components/hko/ @MisterCommand /homeassistant/components/hlk_sw16/ @jameshilliard /tests/components/hlk_sw16/ @jameshilliard /homeassistant/components/holiday/ @jrieger @gjohansson-ST diff --git a/homeassistant/components/hko/__init__.py b/homeassistant/components/hko/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..a83c1dd2d895024e02c4fdc4c80f21d960df20d1 --- /dev/null +++ b/homeassistant/components/hko/__init__.py @@ -0,0 +1,41 @@ +"""The Hong Kong Observatory integration.""" +from __future__ import annotations + +from hko import LOCATIONS + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_LOCATION, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DEFAULT_DISTRICT, DOMAIN, KEY_DISTRICT, KEY_LOCATION +from .coordinator import HKOUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.WEATHER] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Hong Kong Observatory from a config entry.""" + + location = entry.data[CONF_LOCATION] + district = next( + (item for item in LOCATIONS if item[KEY_LOCATION] == location), + {KEY_DISTRICT: DEFAULT_DISTRICT}, + )[KEY_DISTRICT] + websession = async_get_clientsession(hass) + + coordinator = HKOUpdateCoordinator(hass, websession, district, location) + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/hko/config_flow.py b/homeassistant/components/hko/config_flow.py new file mode 100644 index 0000000000000000000000000000000000000000..21697d2dd539e1fc9867c1e6be54a61b6dd9d5c9 --- /dev/null +++ b/homeassistant/components/hko/config_flow.py @@ -0,0 +1,70 @@ +"""Config flow for Hong Kong Observatory integration.""" +from __future__ import annotations + +from asyncio import timeout +from typing import Any + +from hko import HKO, LOCATIONS, HKOError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_LOCATION +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig + +from .const import API_RHRREAD, DEFAULT_LOCATION, DOMAIN, KEY_LOCATION + + +def get_loc_name(item): + """Return an array of supported locations.""" + return item[KEY_LOCATION] + + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_LOCATION, default=DEFAULT_LOCATION): SelectSelector( + SelectSelectorConfig(options=list(map(get_loc_name, LOCATIONS)), sort=True) + ) + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Hong Kong Observatory.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA + ) + + errors = {} + + try: + websession = async_get_clientsession(self.hass) + hko = HKO(websession) + async with timeout(60): + await hko.weather(API_RHRREAD) + + except HKOError: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + errors["base"] = "unknown" + else: + await self.async_set_unique_id( + user_input[CONF_LOCATION], raise_on_progress=False + ) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=user_input[CONF_LOCATION], data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/hko/const.py b/homeassistant/components/hko/const.py new file mode 100644 index 0000000000000000000000000000000000000000..a9a554850b03843f67743f2be7b7f12a18529a9a --- /dev/null +++ b/homeassistant/components/hko/const.py @@ -0,0 +1,74 @@ +"""Constants for the Hong Kong Observatory integration.""" +from hko import LOCATIONS + +from homeassistant.components.weather import ( + ATTR_CONDITION_CLEAR_NIGHT, + ATTR_CONDITION_CLOUDY, + ATTR_CONDITION_FOG, + ATTR_CONDITION_LIGHTNING_RAINY, + ATTR_CONDITION_PARTLYCLOUDY, + ATTR_CONDITION_POURING, + ATTR_CONDITION_RAINY, + ATTR_CONDITION_SNOWY_RAINY, + ATTR_CONDITION_SUNNY, + ATTR_CONDITION_WINDY, +) + +DOMAIN = "hko" + +DISTRICT = "name" + +KEY_LOCATION = "LOCATION" +KEY_DISTRICT = "DISTRICT" + +DEFAULT_LOCATION = LOCATIONS[0][KEY_LOCATION] +DEFAULT_DISTRICT = LOCATIONS[0][KEY_DISTRICT] + +ATTRIBUTION = "Data provided by the Hong Kong Observatory" +MANUFACTURER = "Hong Kong Observatory" + +API_CURRENT = "current" +API_FORECAST = "forecast" +API_WEATHER_FORECAST = "weatherForecast" +API_FORECAST_DATE = "forecastDate" +API_FORECAST_ICON = "ForecastIcon" +API_FORECAST_WEATHER = "forecastWeather" +API_FORECAST_MAX_TEMP = "forecastMaxtemp" +API_FORECAST_MIN_TEMP = "forecastMintemp" +API_CONDITION = "condition" +API_TEMPERATURE = "temperature" +API_HUMIDITY = "humidity" +API_PLACE = "place" +API_DATA = "data" +API_VALUE = "value" +API_RHRREAD = "rhrread" + +WEATHER_INFO_RAIN = "rain" +WEATHER_INFO_SNOW = "snow" +WEATHER_INFO_WIND = "wind" +WEATHER_INFO_MIST = "mist" +WEATHER_INFO_CLOUD = "cloud" +WEATHER_INFO_THUNDERSTORM = "thunderstorm" +WEATHER_INFO_SHOWER = "shower" +WEATHER_INFO_ISOLATED = "isolated" +WEATHER_INFO_HEAVY = "heavy" +WEATHER_INFO_SUNNY = "sunny" +WEATHER_INFO_FINE = "fine" +WEATHER_INFO_AT_TIMES_AT_FIRST = "at times at first" +WEATHER_INFO_OVERCAST = "overcast" +WEATHER_INFO_INTERVAL = "interval" +WEATHER_INFO_PERIOD = "period" +WEATHER_INFO_FOG = "FOG" + +ICON_CONDITION_MAP = { + ATTR_CONDITION_SUNNY: [50], + ATTR_CONDITION_PARTLYCLOUDY: [51, 52, 53, 54, 76], + ATTR_CONDITION_CLOUDY: [60, 61], + ATTR_CONDITION_RAINY: [62, 63], + ATTR_CONDITION_POURING: [64], + ATTR_CONDITION_LIGHTNING_RAINY: [65], + ATTR_CONDITION_CLEAR_NIGHT: [70, 71, 72, 73, 74, 75, 77], + ATTR_CONDITION_SNOWY_RAINY: [7, 14, 15, 27, 37], + ATTR_CONDITION_WINDY: [80], + ATTR_CONDITION_FOG: [83, 84], +} diff --git a/homeassistant/components/hko/coordinator.py b/homeassistant/components/hko/coordinator.py new file mode 100644 index 0000000000000000000000000000000000000000..05280c4a3bdd358b0a16a75929d56f1c582648bb --- /dev/null +++ b/homeassistant/components/hko/coordinator.py @@ -0,0 +1,187 @@ +"""Weather data coordinator for the HKO API.""" +from asyncio import timeout +from datetime import timedelta +import logging +from typing import Any + +from aiohttp import ClientSession +from hko import HKO, HKOError + +from homeassistant.components.weather import ( + ATTR_CONDITION_CLOUDY, + ATTR_CONDITION_FOG, + ATTR_CONDITION_HAIL, + ATTR_CONDITION_LIGHTNING_RAINY, + ATTR_CONDITION_PARTLYCLOUDY, + ATTR_CONDITION_POURING, + ATTR_CONDITION_RAINY, + ATTR_CONDITION_SNOWY, + ATTR_CONDITION_SNOWY_RAINY, + ATTR_CONDITION_SUNNY, + ATTR_CONDITION_WINDY, + ATTR_CONDITION_WINDY_VARIANT, + ATTR_FORECAST_CONDITION, + ATTR_FORECAST_TEMP, + ATTR_FORECAST_TEMP_LOW, + ATTR_FORECAST_TIME, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + API_CURRENT, + API_DATA, + API_FORECAST, + API_FORECAST_DATE, + API_FORECAST_ICON, + API_FORECAST_MAX_TEMP, + API_FORECAST_MIN_TEMP, + API_FORECAST_WEATHER, + API_HUMIDITY, + API_PLACE, + API_TEMPERATURE, + API_VALUE, + API_WEATHER_FORECAST, + DOMAIN, + ICON_CONDITION_MAP, + WEATHER_INFO_AT_TIMES_AT_FIRST, + WEATHER_INFO_CLOUD, + WEATHER_INFO_FINE, + WEATHER_INFO_FOG, + WEATHER_INFO_HEAVY, + WEATHER_INFO_INTERVAL, + WEATHER_INFO_ISOLATED, + WEATHER_INFO_MIST, + WEATHER_INFO_OVERCAST, + WEATHER_INFO_PERIOD, + WEATHER_INFO_RAIN, + WEATHER_INFO_SHOWER, + WEATHER_INFO_SNOW, + WEATHER_INFO_SUNNY, + WEATHER_INFO_THUNDERSTORM, + WEATHER_INFO_WIND, +) + +_LOGGER = logging.getLogger(__name__) + + +class HKOUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """HKO Update Coordinator.""" + + def __init__( + self, hass: HomeAssistant, session: ClientSession, district: str, location: str + ) -> None: + """Update data via library.""" + self.location = location + self.district = district + self.hko = HKO(session) + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(minutes=15), + ) + + async def _async_update_data(self) -> dict[str, Any]: + """Update data via HKO library.""" + try: + async with timeout(60): + rhrread = await self.hko.weather("rhrread") + fnd = await self.hko.weather("fnd") + except HKOError as error: + raise UpdateFailed(error) from error + return { + API_CURRENT: self._convert_current(rhrread), + API_FORECAST: [ + self._convert_forecast(item) for item in fnd[API_WEATHER_FORECAST] + ], + } + + def _convert_current(self, data: dict[str, Any]) -> dict[str, Any]: + """Return temperature and humidity in the appropriate format.""" + current = { + API_HUMIDITY: data[API_HUMIDITY][API_DATA][0][API_VALUE], + API_TEMPERATURE: next( + ( + item[API_VALUE] + for item in data[API_TEMPERATURE][API_DATA] + if item[API_PLACE] == self.location + ), + 0, + ), + } + return current + + def _convert_forecast(self, data: dict[str, Any]) -> dict[str, Any]: + """Return daily forecast in the appropriate format.""" + date = data[API_FORECAST_DATE] + forecast = { + ATTR_FORECAST_CONDITION: self._convert_icon_condition( + data[API_FORECAST_ICON], data[API_FORECAST_WEATHER] + ), + ATTR_FORECAST_TEMP: data[API_FORECAST_MAX_TEMP][API_VALUE], + ATTR_FORECAST_TEMP_LOW: data[API_FORECAST_MIN_TEMP][API_VALUE], + ATTR_FORECAST_TIME: f"{date[0:4]}-{date[4:6]}-{date[6:8]}T00:00:00+08:00", + } + return forecast + + def _convert_icon_condition(self, icon_code: int, info: str) -> str: + """Return the condition corresponding to an icon code.""" + for condition, codes in ICON_CONDITION_MAP.items(): + if icon_code in codes: + return condition + return self._convert_info_condition(info) + + def _convert_info_condition(self, info: str) -> str: + """Return the condition corresponding to the weather info.""" + info = info.lower() + if WEATHER_INFO_RAIN in info: + return ATTR_CONDITION_HAIL + if WEATHER_INFO_SNOW in info and WEATHER_INFO_RAIN in info: + return ATTR_CONDITION_SNOWY_RAINY + if WEATHER_INFO_SNOW in info: + return ATTR_CONDITION_SNOWY + if WEATHER_INFO_FOG in info or WEATHER_INFO_MIST in info: + return ATTR_CONDITION_FOG + if WEATHER_INFO_WIND in info and WEATHER_INFO_CLOUD in info: + return ATTR_CONDITION_WINDY_VARIANT + if WEATHER_INFO_WIND in info: + return ATTR_CONDITION_WINDY + if WEATHER_INFO_THUNDERSTORM in info and WEATHER_INFO_ISOLATED not in info: + return ATTR_CONDITION_LIGHTNING_RAINY + if ( + ( + WEATHER_INFO_RAIN in info + or WEATHER_INFO_SHOWER in info + or WEATHER_INFO_THUNDERSTORM in info + ) + and WEATHER_INFO_HEAVY in info + and WEATHER_INFO_SUNNY not in info + and WEATHER_INFO_FINE not in info + and WEATHER_INFO_AT_TIMES_AT_FIRST not in info + ): + return ATTR_CONDITION_POURING + if ( + ( + WEATHER_INFO_RAIN in info + or WEATHER_INFO_SHOWER in info + or WEATHER_INFO_THUNDERSTORM in info + ) + and WEATHER_INFO_SUNNY not in info + and WEATHER_INFO_FINE not in info + ): + return ATTR_CONDITION_RAINY + if (WEATHER_INFO_CLOUD in info or WEATHER_INFO_OVERCAST in info) and not ( + WEATHER_INFO_INTERVAL in info or WEATHER_INFO_PERIOD in info + ): + return ATTR_CONDITION_CLOUDY + if (WEATHER_INFO_SUNNY in info) and ( + WEATHER_INFO_INTERVAL in info or WEATHER_INFO_PERIOD in info + ): + return ATTR_CONDITION_PARTLYCLOUDY + if ( + WEATHER_INFO_SUNNY in info or WEATHER_INFO_FINE in info + ) and WEATHER_INFO_SHOWER not in info: + return ATTR_CONDITION_SUNNY + return ATTR_CONDITION_PARTLYCLOUDY diff --git a/homeassistant/components/hko/manifest.json b/homeassistant/components/hko/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..74718bb98c2a893481733af005cf837658b0c74a --- /dev/null +++ b/homeassistant/components/hko/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "hko", + "name": "Hong Kong Observatory", + "codeowners": ["@MisterCommand"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/hko", + "iot_class": "cloud_polling", + "requirements": ["hko==0.3.2"] +} diff --git a/homeassistant/components/hko/strings.json b/homeassistant/components/hko/strings.json new file mode 100644 index 0000000000000000000000000000000000000000..a537c86452863bb5e444d7983424dee22faa144a --- /dev/null +++ b/homeassistant/components/hko/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "description": "Please select a location to use for weather forecasting.", + "data": { + "location": "[%key:common::config_flow::data::location%]" + } + } + } + } +} diff --git a/homeassistant/components/hko/weather.py b/homeassistant/components/hko/weather.py new file mode 100644 index 0000000000000000000000000000000000000000..f4a784c53086d201cebdac49be279bfc07f047ff --- /dev/null +++ b/homeassistant/components/hko/weather.py @@ -0,0 +1,75 @@ +"""Support for the HKO service.""" +from homeassistant.components.weather import ( + Forecast, + WeatherEntity, + WeatherEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ( + API_CONDITION, + API_CURRENT, + API_FORECAST, + API_HUMIDITY, + API_TEMPERATURE, + ATTRIBUTION, + DOMAIN, + MANUFACTURER, +) +from .coordinator import HKOUpdateCoordinator + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Add a HKO weather entity from a config_entry.""" + assert config_entry.unique_id is not None + unique_id = config_entry.unique_id + coordinator = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities([HKOEntity(unique_id, coordinator)], False) + + +class HKOEntity(CoordinatorEntity[HKOUpdateCoordinator], WeatherEntity): + """Define a HKO entity.""" + + _attr_has_entity_name = True + _attr_name = None + _attr_native_temperature_unit = UnitOfTemperature.CELSIUS + _attr_supported_features = WeatherEntityFeature.FORECAST_DAILY + _attr_attribution = ATTRIBUTION + + def __init__(self, unique_id: str, coordinator: HKOUpdateCoordinator) -> None: + """Initialise the weather platform.""" + super().__init__(coordinator) + self._attr_unique_id = unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + manufacturer=MANUFACTURER, + entry_type=DeviceEntryType.SERVICE, + ) + + @property + def condition(self) -> str: + """Return the current condition.""" + return self.coordinator.data[API_FORECAST][0][API_CONDITION] + + @property + def native_temperature(self) -> int: + """Return the temperature.""" + return self.coordinator.data[API_CURRENT][API_TEMPERATURE] + + @property + def humidity(self) -> int: + """Return the humidity.""" + return self.coordinator.data[API_CURRENT][API_HUMIDITY] + + async def async_forecast_daily(self) -> list[Forecast] | None: + """Return the forecast data.""" + return self.coordinator.data[API_FORECAST] diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index cd9b3c6a9823f3007f3d6001108ecacfc6d82d21..8a71d51acf27e55fe8c4ab9f2f3ed341801668e1 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -205,6 +205,7 @@ FLOWS = { "here_travel_time", "hisense_aehw4a1", "hive", + "hko", "hlk_sw16", "holiday", "home_connect", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index f9427b8f3362c7551fb42ad2f03cd3e5294f7eed..4738de291faf2a854e2935938fd86d379f7a5b66 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2446,6 +2446,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "hko": { + "name": "Hong Kong Observatory", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "hlk_sw16": { "name": "Hi-Link HLK-SW16", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index f8a1f9d2c44cb12c9863da256a87736791047b3c..8d68009e18044c6aee0bc0f4534ead203ce57be9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1030,6 +1030,9 @@ hikvision==0.4 # homeassistant.components.harman_kardon_avr hkavr==0.0.5 +# homeassistant.components.hko +hko==0.3.2 + # homeassistant.components.hlk_sw16 hlk-sw16==0.0.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1dacbb8fb2a4f00babace5c5a97e4eb56eb9b98e..b7f526ad15f4f64b7b6d341fb716c56f182973df 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -823,6 +823,9 @@ here-routing==0.2.0 # homeassistant.components.here_travel_time here-transit==1.2.0 +# homeassistant.components.hko +hko==0.3.2 + # homeassistant.components.hlk_sw16 hlk-sw16==0.0.9 diff --git a/tests/components/hko/__init__.py b/tests/components/hko/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..ff4447c26e5b94364040a2559d044582d80d25e5 --- /dev/null +++ b/tests/components/hko/__init__.py @@ -0,0 +1 @@ +"""Tests for the Hong Kong Observatory integration.""" diff --git a/tests/components/hko/conftest.py b/tests/components/hko/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..fd2181ddfc9683485d5b53e876ae375aa3e29f94 --- /dev/null +++ b/tests/components/hko/conftest.py @@ -0,0 +1,17 @@ +"""Configure py.test.""" +import json +from unittest.mock import patch + +import pytest + +from tests.common import load_fixture + + +@pytest.fixture(name="hko_config_flow_connect", autouse=True) +def hko_config_flow_connect(): + """Mock valid config flow setup.""" + with patch( + "homeassistant.components.hko.config_flow.HKO.weather", + return_value=json.loads(load_fixture("hko/rhrread.json")), + ): + yield diff --git a/tests/components/hko/fixtures/rhrread.json b/tests/components/hko/fixtures/rhrread.json new file mode 100644 index 0000000000000000000000000000000000000000..f9c0090ef6af34ed60bb42e6df07dc353e9389c7 --- /dev/null +++ b/tests/components/hko/fixtures/rhrread.json @@ -0,0 +1,82 @@ +{ + "rainfall": { + "data": [ + { + "unit": "mm", + "place": "Central & Western District", + "max": 0, + "main": "FALSE" + }, + { "unit": "mm", "place": "Eastern District", "max": 0, "main": "FALSE" }, + { "unit": "mm", "place": "Kwai Tsing", "max": 0, "main": "FALSE" }, + { "unit": "mm", "place": "Islands District", "max": 0, "main": "FALSE" }, + { "unit": "mm", "place": "North District", "max": 0, "main": "FALSE" }, + { "unit": "mm", "place": "Sai Kung", "max": 0, "main": "FALSE" }, + { "unit": "mm", "place": "Sha Tin", "max": 0, "main": "FALSE" }, + { "unit": "mm", "place": "Southern District", "max": 0, "main": "FALSE" }, + { "unit": "mm", "place": "Tai Po", "max": 0, "main": "FALSE" }, + { "unit": "mm", "place": "Tsuen Wan", "max": 0, "main": "FALSE" }, + { "unit": "mm", "place": "Tuen Mun", "max": 0, "main": "FALSE" }, + { "unit": "mm", "place": "Wan Chai", "max": 0, "main": "FALSE" }, + { "unit": "mm", "place": "Yuen Long", "max": 0, "main": "FALSE" }, + { "unit": "mm", "place": "Yau Tsim Mong", "max": 0, "main": "FALSE" }, + { "unit": "mm", "place": "Sham Shui Po", "max": 0, "main": "FALSE" }, + { "unit": "mm", "place": "Kowloon City", "max": 0, "main": "FALSE" }, + { "unit": "mm", "place": "Wong Tai Sin", "max": 0, "main": "FALSE" }, + { "unit": "mm", "place": "Kwun Tong", "max": 0, "main": "FALSE" } + ], + "startTime": "2023-08-19T15:45:00+08:00", + "endTime": "2023-08-19T16:45:00+08:00" + }, + "icon": [60], + "iconUpdateTime": "2023-08-19T16:40:00+08:00", + "uvindex": { + "data": [{ "place": "King's Park", "value": 2, "desc": "low" }], + "recordDesc": "During the past hour" + }, + "updateTime": "2023-08-19T17:02:00+08:00", + "temperature": { + "data": [ + { "place": "King's Park", "value": 30, "unit": "C" }, + { "place": "Hong Kong Observatory", "value": 29, "unit": "C" }, + { "place": "Wong Chuk Hang", "value": 29, "unit": "C" }, + { "place": "Ta Kwu Ling", "value": 31, "unit": "C" }, + { "place": "Lau Fau Shan", "value": 31, "unit": "C" }, + { "place": "Tai Po", "value": 29, "unit": "C" }, + { "place": "Sha Tin", "value": 31, "unit": "C" }, + { "place": "Tuen Mun", "value": 28, "unit": "C" }, + { "place": "Tseung Kwan O", "value": 29, "unit": "C" }, + { "place": "Sai Kung", "value": 29, "unit": "C" }, + { "place": "Cheung Chau", "value": 27, "unit": "C" }, + { "place": "Chek Lap Kok", "value": 30, "unit": "C" }, + { "place": "Tsing Yi", "value": 29, "unit": "C" }, + { "place": "Shek Kong", "value": 31, "unit": "C" }, + { "place": "Tsuen Wan Ho Koon", "value": 27, "unit": "C" }, + { "place": "Tsuen Wan Shing Mun Valley", "value": 29, "unit": "C" }, + { "place": "Hong Kong Park", "value": 29, "unit": "C" }, + { "place": "Shau Kei Wan", "value": 29, "unit": "C" }, + { "place": "Kowloon City", "value": 30, "unit": "C" }, + { "place": "Happy Valley", "value": 32, "unit": "C" }, + { "place": "Wong Tai Sin", "value": 31, "unit": "C" }, + { "place": "Stanley", "value": 29, "unit": "C" }, + { "place": "Kwun Tong", "value": 30, "unit": "C" }, + { "place": "Sham Shui Po", "value": 30, "unit": "C" }, + { "place": "Kai Tak Runway Park", "value": 30, "unit": "C" }, + { "place": "Yuen Long Park", "value": 29, "unit": "C" }, + { "place": "Tai Mei Tuk", "value": 29, "unit": "C" } + ], + "recordTime": "2023-08-19T17:00:00+08:00" + }, + "warningMessage": "", + "mintempFrom00To09": "", + "rainfallFrom00To12": "", + "rainfallLastMonth": "", + "rainfallJanuaryToLastMonth": "", + "tcmessage": "", + "humidity": { + "recordTime": "2023-08-19T17:00:00+08:00", + "data": [ + { "unit": "percent", "value": 74, "place": "Hong Kong Observatory" } + ] + } +} diff --git a/tests/components/hko/test_config_flow.py b/tests/components/hko/test_config_flow.py new file mode 100644 index 0000000000000000000000000000000000000000..ce32d2cd0dab586999df8e4e8ebeb4adffbd5a5a --- /dev/null +++ b/tests/components/hko/test_config_flow.py @@ -0,0 +1,112 @@ +"""Test the Hong Kong Observatory config flow.""" + +from unittest.mock import patch + +from hko import HKOError + +from homeassistant.components.hko.const import DEFAULT_LOCATION, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_LOCATION +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + + +async def test_config_flow_default(hass: HomeAssistant) -> None: + """Test user config flow with default fields.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == SOURCE_USER + assert "flow_id" in result + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_LOCATION: DEFAULT_LOCATION}, + ) + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == DEFAULT_LOCATION + assert result2["result"].unique_id == DEFAULT_LOCATION + assert result2["data"][CONF_LOCATION] == DEFAULT_LOCATION + + +async def test_config_flow_cannot_connect(hass: HomeAssistant) -> None: + """Test user config flow without connection to the API.""" + with patch("homeassistant.components.hko.config_flow.HKO.weather") as client_mock: + client_mock.side_effect = HKOError() + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_LOCATION: DEFAULT_LOCATION}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"]["base"] == "cannot_connect" + + client_mock.side_effect = None + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_LOCATION: DEFAULT_LOCATION}, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == DEFAULT_LOCATION + assert result["data"][CONF_LOCATION] == DEFAULT_LOCATION + + +async def test_config_flow_timeout(hass: HomeAssistant) -> None: + """Test user config flow with timedout connection to the API.""" + with patch("homeassistant.components.hko.config_flow.HKO.weather") as client_mock: + client_mock.side_effect = TimeoutError() + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_LOCATION: DEFAULT_LOCATION}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"]["base"] == "unknown" + + client_mock.side_effect = None + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_LOCATION: DEFAULT_LOCATION}, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == DEFAULT_LOCATION + assert result["data"][CONF_LOCATION] == DEFAULT_LOCATION + + +async def test_config_flow_already_configured(hass: HomeAssistant) -> None: + """Test user config flow with two equal entries.""" + r1 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert r1["type"] == FlowResultType.FORM + assert r1["step_id"] == SOURCE_USER + assert "flow_id" in r1 + result1 = await hass.config_entries.flow.async_configure( + r1["flow_id"], + user_input={CONF_LOCATION: DEFAULT_LOCATION}, + ) + assert result1["type"] == FlowResultType.CREATE_ENTRY + + r2 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert r2["type"] == FlowResultType.FORM + assert r2["step_id"] == SOURCE_USER + assert "flow_id" in r2 + result2 = await hass.config_entries.flow.async_configure( + r2["flow_id"], + user_input={CONF_LOCATION: DEFAULT_LOCATION}, + ) + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "already_configured"