diff --git a/.coveragerc b/.coveragerc index 014dc2f0f3998d..9930cbaf0b55ea 100644 --- a/.coveragerc +++ b/.coveragerc @@ -305,6 +305,7 @@ omit = homeassistant/components/enphase_envoy/binary_sensor.py homeassistant/components/enphase_envoy/coordinator.py homeassistant/components/enphase_envoy/entity.py + homeassistant/components/enphase_envoy/number.py homeassistant/components/enphase_envoy/select.py homeassistant/components/enphase_envoy/sensor.py homeassistant/components/enphase_envoy/switch.py diff --git a/homeassistant/components/buienradar/const.py b/homeassistant/components/buienradar/const.py index 8111f63c923f70..718812c5c731fc 100644 --- a/homeassistant/components/buienradar/const.py +++ b/homeassistant/components/buienradar/const.py @@ -14,10 +14,10 @@ SUPPORTED_COUNTRY_CODES = ["NL", "BE"] DEFAULT_COUNTRY = "NL" -"""Schedule next call after (minutes).""" SCHEDULE_OK = 10 -"""When an error occurred, new call after (minutes).""" +"""Schedule next call after (minutes).""" SCHEDULE_NOK = 2 +"""When an error occurred, new call after (minutes).""" STATE_CONDITIONS = ["clear", "cloudy", "fog", "rainy", "snowy", "lightning"] diff --git a/homeassistant/components/buienradar/sensor.py b/homeassistant/components/buienradar/sensor.py index 00740eb4801c67..fe3ce3164fe55e 100644 --- a/homeassistant/components/buienradar/sensor.py +++ b/homeassistant/components/buienradar/sensor.py @@ -714,17 +714,18 @@ async def async_setup_entry( timeframe, ) + # create weather entities: entities = [ BrSensor(config.get(CONF_NAME, "Buienradar"), coordinates, description) for description in SENSOR_TYPES ] - async_add_entities(entities) - + # create weather data: data = BrData(hass, coordinates, timeframe, entities) - # schedule the first update in 1 minute from now: - await data.schedule_update(1) hass.data[DOMAIN][entry.entry_id][Platform.SENSOR] = data + await data.async_update() + + async_add_entities(entities) class BrSensor(SensorEntity): @@ -755,7 +756,7 @@ def __init__( @callback def data_updated(self, data: BrData): """Update data.""" - if self.hass and self._load_data(data.data): + if self._load_data(data.data) and self.hass: self.async_write_ha_state() @callback diff --git a/homeassistant/components/buienradar/util.py b/homeassistant/components/buienradar/util.py index 9d0c2a575c926c..3c50b3097cb0de 100644 --- a/homeassistant/components/buienradar/util.py +++ b/homeassistant/components/buienradar/util.py @@ -27,7 +27,7 @@ from buienradar.urls import JSON_FEED_URL, json_precipitation_forecast_url from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE -from homeassistant.core import CALLBACK_TYPE +from homeassistant.core import CALLBACK_TYPE, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.util import dt as dt_util @@ -77,7 +77,8 @@ async def update_devices(self): for dev in self.devices: dev.data_updated(self) - async def schedule_update(self, minute=1): + @callback + def async_schedule_update(self, minute=1): """Schedule an update after minute minutes.""" _LOGGER.debug("Scheduling next update in %s minutes", minute) nxt = dt_util.utcnow() + timedelta(minutes=minute) @@ -110,7 +111,7 @@ async def get_data(self, url): if resp is not None: await resp.release() - async def async_update(self, *_): + async def _async_update(self): """Update the data from buienradar.""" content = await self.get_data(JSON_FEED_URL) @@ -123,9 +124,7 @@ async def async_update(self, *_): content.get(MESSAGE), content.get(STATUS_CODE), ) - # schedule new call - await self.schedule_update(SCHEDULE_NOK) - return + return None self.load_error_count = 0 # rounding coordinates prevents unnecessary redirects/calls @@ -143,9 +142,7 @@ async def async_update(self, *_): raincontent.get(MESSAGE), raincontent.get(STATUS_CODE), ) - # schedule new call - await self.schedule_update(SCHEDULE_NOK) - return + return None self.rain_error_count = 0 result = parse_data( @@ -164,12 +161,21 @@ async def async_update(self, *_): "Unable to parse data from Buienradar. (Msg: %s)", result.get(MESSAGE), ) - await self.schedule_update(SCHEDULE_NOK) + return None + + return result[DATA] + + async def async_update(self, *_): + """Update the data from buienradar and schedule the next update.""" + data = await self._async_update() + + if data is None: + self.async_schedule_update(SCHEDULE_NOK) return - self.data = result.get(DATA) + self.data = data await self.update_devices() - await self.schedule_update(SCHEDULE_OK) + self.async_schedule_update(SCHEDULE_OK) @property def attribution(self): diff --git a/homeassistant/components/buienradar/weather.py b/homeassistant/components/buienradar/weather.py index cdb8adf1dac004..66c3b23ec8bad4 100644 --- a/homeassistant/components/buienradar/weather.py +++ b/homeassistant/components/buienradar/weather.py @@ -82,6 +82,11 @@ ATTR_CONDITION_WINDY_VARIANT: (), ATTR_CONDITION_EXCEPTIONAL: (), } +CONDITION_MAP = { + cond_code: cond_ha + for cond_ha, cond_codes in CONDITION_CLASSES.items() + for cond_code in cond_codes +} async def async_setup_entry( @@ -106,20 +111,10 @@ async def async_setup_entry( # create weather data: data = BrData(hass, coordinates, DEFAULT_TIMEFRAME, entities) hass.data[DOMAIN][entry.entry_id][Platform.WEATHER] = data - - # create condition helper - if DATA_CONDITION not in hass.data[DOMAIN]: - cond_keys = [str(chr(x)) for x in range(97, 123)] - hass.data[DOMAIN][DATA_CONDITION] = dict.fromkeys(cond_keys) - for cond, condlst in CONDITION_CLASSES.items(): - for condi in condlst: - hass.data[DOMAIN][DATA_CONDITION][condi] = cond + await data.async_update() async_add_entities(entities) - # schedule the first update in 1 minute from now: - await data.schedule_update(1) - class BrWeather(WeatherEntity): """Representation of a weather condition.""" @@ -143,9 +138,6 @@ def __init__(self, config, coordinates): @callback def data_updated(self, data: BrData) -> None: """Update data.""" - if not self.hass: - return - self._attr_attribution = data.attribution self._attr_condition = self._calc_condition(data) self._attr_forecast = self._calc_forecast(data) @@ -158,22 +150,20 @@ def data_updated(self, data: BrData) -> None: self._attr_native_visibility = data.visibility self._attr_native_wind_speed = data.wind_speed self._attr_wind_bearing = data.wind_bearing + + if not self.hass: + return self.async_write_ha_state() def _calc_condition(self, data: BrData): """Return the current condition.""" - if ( - data.condition - and (ccode := data.condition.get(CONDCODE)) - and (conditions := self.hass.data[DOMAIN].get(DATA_CONDITION)) - ): - return conditions.get(ccode) + if data.condition and (ccode := data.condition.get(CONDCODE)): + return CONDITION_MAP.get(ccode) return None def _calc_forecast(self, data: BrData): """Return the forecast array.""" fcdata_out = [] - cond = self.hass.data[DOMAIN][DATA_CONDITION] if not data.forecast: return None @@ -181,10 +171,10 @@ def _calc_forecast(self, data: BrData): for data_in in data.forecast: # remap keys from external library to # keys understood by the weather component: - condcode = data_in.get(CONDITION, []).get(CONDCODE) + condcode = data_in.get(CONDITION, {}).get(CONDCODE) data_out = { ATTR_FORECAST_TIME: data_in.get(DATETIME).isoformat(), - ATTR_FORECAST_CONDITION: cond[condcode], + ATTR_FORECAST_CONDITION: CONDITION_MAP.get(condcode), ATTR_FORECAST_NATIVE_TEMP_LOW: data_in.get(MIN_TEMP), ATTR_FORECAST_NATIVE_TEMP: data_in.get(MAX_TEMP), ATTR_FORECAST_NATIVE_PRECIPITATION: data_in.get(RAIN), diff --git a/homeassistant/components/enphase_envoy/binary_sensor.py b/homeassistant/components/enphase_envoy/binary_sensor.py index eb909197c1e237..a35fdd46b2aa08 100644 --- a/homeassistant/components/enphase_envoy/binary_sensor.py +++ b/homeassistant/components/enphase_envoy/binary_sensor.py @@ -105,8 +105,6 @@ async def async_setup_entry( coordinator: EnphaseUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] envoy_data = coordinator.envoy.data assert envoy_data is not None - envoy_serial_num = config_entry.unique_id - assert envoy_serial_num is not None entities: list[BinarySensorEntity] = [] if envoy_data.encharge_inventory: entities.extend( @@ -176,13 +174,12 @@ def __init__( super().__init__(coordinator, description) enpower = self.data.enpower assert enpower is not None - self._serial_number = enpower.serial_number - self._attr_unique_id = f"{self._serial_number}_{description.key}" + self._attr_unique_id = f"{enpower.serial_number}_{description.key}" self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self._serial_number)}, + identifiers={(DOMAIN, enpower.serial_number)}, manufacturer="Enphase", model="Enpower", - name=f"Enpower {self._serial_number}", + name=f"Enpower {enpower.serial_number}", sw_version=str(enpower.firmware_version), via_device=(DOMAIN, self.envoy_serial_num), ) diff --git a/homeassistant/components/enphase_envoy/const.py b/homeassistant/components/enphase_envoy/const.py index d1c6618502ebf5..c5656a65b6f4ce 100644 --- a/homeassistant/components/enphase_envoy/const.py +++ b/homeassistant/components/enphase_envoy/const.py @@ -5,6 +5,12 @@ DOMAIN = "enphase_envoy" -PLATFORMS = [Platform.BINARY_SENSOR, Platform.SELECT, Platform.SENSOR, Platform.SWITCH] +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, +] INVALID_AUTH_ERRORS = (EnvoyAuthenticationError, EnvoyAuthenticationRequired) diff --git a/homeassistant/components/enphase_envoy/number.py b/homeassistant/components/enphase_envoy/number.py new file mode 100644 index 00000000000000..50d4de18f12494 --- /dev/null +++ b/homeassistant/components/enphase_envoy/number.py @@ -0,0 +1,116 @@ +"""Number platform for Enphase Envoy solar energy monitor.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from pyenphase import EnvoyDryContactSettings + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import EnphaseUpdateCoordinator +from .entity import EnvoyBaseEntity + + +@dataclass +class EnvoyRelayRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[EnvoyDryContactSettings], float] + + +@dataclass +class EnvoyRelayNumberEntityDescription( + NumberEntityDescription, EnvoyRelayRequiredKeysMixin +): + """Describes an Envoy Dry Contact Relay number entity.""" + + +RELAY_ENTITIES = ( + EnvoyRelayNumberEntityDescription( + key="soc_low", + translation_key="cutoff_battery_level", + device_class=NumberDeviceClass.BATTERY, + entity_category=EntityCategory.CONFIG, + value_fn=lambda relay: relay.soc_low, + ), + EnvoyRelayNumberEntityDescription( + key="soc_high", + translation_key="restore_battery_level", + device_class=NumberDeviceClass.BATTERY, + entity_category=EntityCategory.CONFIG, + value_fn=lambda relay: relay.soc_high, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Enphase Envoy number platform.""" + coordinator: EnphaseUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + envoy_data = coordinator.envoy.data + assert envoy_data is not None + entities: list[NumberEntity] = [] + if envoy_data.dry_contact_settings: + entities.extend( + EnvoyRelayNumberEntity(coordinator, entity, relay) + for entity in RELAY_ENTITIES + for relay in envoy_data.dry_contact_settings + ) + async_add_entities(entities) + + +class EnvoyRelayNumberEntity(EnvoyBaseEntity, NumberEntity): + """Representation of an Enphase Enpower number entity.""" + + entity_description: EnvoyRelayNumberEntityDescription + + def __init__( + self, + coordinator: EnphaseUpdateCoordinator, + description: EnvoyRelayNumberEntityDescription, + relay_id: str, + ) -> None: + """Initialize the Enphase relay number entity.""" + super().__init__(coordinator, description) + self.envoy = coordinator.envoy + enpower = self.data.enpower + assert enpower is not None + serial_number = enpower.serial_number + self._relay_id = relay_id + self._attr_unique_id = f"{serial_number}_relay_{relay_id}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, relay_id)}, + manufacturer="Enphase", + model="Dry contact relay", + name=self.data.dry_contact_settings[relay_id].load_name, + sw_version=str(enpower.firmware_version), + via_device=(DOMAIN, serial_number), + ) + + @property + def native_value(self) -> float: + """Return the state of the relay entity.""" + return self.entity_description.value_fn( + self.data.dry_contact_settings[self._relay_id] + ) + + async def async_set_native_value(self, value: float) -> None: + """Update the relay.""" + await self.envoy.update_dry_contact( + {"id": self._relay_id, self.entity_description.key: int(value)} + ) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/enphase_envoy/select.py b/homeassistant/components/enphase_envoy/select.py index 75c9ce0cf7c5d6..5ae73a315f2906 100644 --- a/homeassistant/components/enphase_envoy/select.py +++ b/homeassistant/components/enphase_envoy/select.py @@ -1,12 +1,11 @@ """Select platform for Enphase Envoy solar energy monitor.""" from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Coroutine from dataclasses import dataclass -import logging from typing import Any -from pyenphase import EnvoyDryContactSettings +from pyenphase import Envoy, EnvoyDryContactSettings from pyenphase.models.dry_contacts import DryContactAction, DryContactMode from homeassistant.components.select import SelectEntity, SelectEntityDescription @@ -19,15 +18,15 @@ from .coordinator import EnphaseUpdateCoordinator from .entity import EnvoyBaseEntity -_LOGGER = logging.getLogger(__name__) - @dataclass class EnvoyRelayRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[EnvoyDryContactSettings], str] - update_fn: Callable[[Any, Any, Any], Any] + update_fn: Callable[ + [Envoy, EnvoyDryContactSettings, str], Coroutine[Any, Any, dict[str, Any]] + ] @dataclass @@ -113,8 +112,6 @@ async def async_setup_entry( coordinator: EnphaseUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] envoy_data = coordinator.envoy.data assert envoy_data is not None - envoy_serial_num = config_entry.unique_id - assert envoy_serial_num is not None entities: list[SelectEntity] = [] if envoy_data.dry_contact_settings: entities.extend( @@ -134,36 +131,34 @@ def __init__( self, coordinator: EnphaseUpdateCoordinator, description: EnvoyRelaySelectEntityDescription, - relay: str, + relay_id: str, ) -> None: """Initialize the Enphase relay select entity.""" super().__init__(coordinator, description) self.envoy = coordinator.envoy - assert self.envoy is not None - assert self.data is not None - self.enpower = self.data.enpower - assert self.enpower is not None - self._serial_number = self.enpower.serial_number - self.relay = self.data.dry_contact_settings[relay] - self.relay_id = relay - self._attr_unique_id = ( - f"{self._serial_number}_relay_{relay}_{self.entity_description.key}" - ) + enpower = self.data.enpower + assert enpower is not None + serial_number = enpower.serial_number + self._relay_id = relay_id + self._attr_unique_id = f"{serial_number}_relay_{relay_id}_{description.key}" self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, relay)}, + identifiers={(DOMAIN, relay_id)}, manufacturer="Enphase", model="Dry contact relay", name=self.relay.load_name, - sw_version=str(self.enpower.firmware_version), - via_device=(DOMAIN, self._serial_number), + sw_version=str(enpower.firmware_version), + via_device=(DOMAIN, serial_number), ) + @property + def relay(self) -> EnvoyDryContactSettings: + """Return the relay object.""" + return self.data.dry_contact_settings[self._relay_id] + @property def current_option(self) -> str: """Return the state of the Enpower switch.""" - return self.entity_description.value_fn( - self.data.dry_contact_settings[self.relay_id] - ) + return self.entity_description.value_fn(self.relay) async def async_select_option(self, option: str) -> None: """Update the relay.""" diff --git a/homeassistant/components/enphase_envoy/sensor.py b/homeassistant/components/enphase_envoy/sensor.py index 0e4a9b712323d7..0e0a2aacfd7442 100644 --- a/homeassistant/components/enphase_envoy/sensor.py +++ b/homeassistant/components/enphase_envoy/sensor.py @@ -350,8 +350,6 @@ async def async_setup_entry( coordinator: EnphaseUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] envoy_data = coordinator.envoy.data assert envoy_data is not None - envoy_serial_num = config_entry.unique_id - assert envoy_serial_num is not None _LOGGER.debug("Envoy data: %s", envoy_data) entities: list[Entity] = [ diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index bab16bc6c58b56..8eb8958d6bcb05 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -36,6 +36,14 @@ "name": "Grid status" } }, + "number": { + "cutoff_battery_level": { + "name": "Cutoff battery level" + }, + "restore_battery_level": { + "name": "Restore battery level" + } + }, "select": { "relay_mode": { "name": "Mode", diff --git a/homeassistant/components/enphase_envoy/switch.py b/homeassistant/components/enphase_envoy/switch.py index 368acfbf13ee45..7897d95b5410f3 100644 --- a/homeassistant/components/enphase_envoy/switch.py +++ b/homeassistant/components/enphase_envoy/switch.py @@ -79,8 +79,6 @@ async def async_setup_entry( coordinator: EnphaseUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] envoy_data = coordinator.envoy.data assert envoy_data is not None - envoy_serial_num = config_entry.unique_id - assert envoy_serial_num is not None entities: list[SwitchEntity] = [] if envoy_data.enpower: entities.extend( diff --git a/homeassistant/components/flux_led/strings.json b/homeassistant/components/flux_led/strings.json index d1d812cb2102ca..aa56708c645bbc 100644 --- a/homeassistant/components/flux_led/strings.json +++ b/homeassistant/components/flux_led/strings.json @@ -27,7 +27,7 @@ "data": { "mode": "The chosen brightness mode.", "custom_effect_colors": "Custom Effect: List of 1 to 16 [R,G,B] colors. Example: [255,0,255],[60,128,0]", - "custom_effect_speed_pct": "Custom Effect: Speed in percents for the effect that switch colors.", + "custom_effect_speed_pct": "Custom Effect: Speed in percentage for the effects that switch colors.", "custom_effect_transition": "Custom Effect: Type of transition between the colors." } } diff --git a/tests/components/buienradar/test_sensor.py b/tests/components/buienradar/test_sensor.py index 725b03a6cc5471..fb83d7a13db8f3 100644 --- a/tests/components/buienradar/test_sensor.py +++ b/tests/components/buienradar/test_sensor.py @@ -1,4 +1,6 @@ """The tests for the Buienradar sensor platform.""" +from http import HTTPStatus + from homeassistant.components.buienradar.const import DOMAIN from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant @@ -18,6 +20,9 @@ async def test_smoke_test_setup_component( aioclient_mock: AiohttpClientMocker, hass: HomeAssistant ) -> None: """Smoke test for successfully set-up with default config.""" + aioclient_mock.get( + "https://data.buienradar.nl/2.0/feed/json", status=HTTPStatus.NOT_FOUND + ) mock_entry = MockConfigEntry(domain=DOMAIN, unique_id="TEST_ID", data=TEST_CFG_DATA) mock_entry.add_to_hass(hass) diff --git a/tests/components/buienradar/test_weather.py b/tests/components/buienradar/test_weather.py index c8b0d459b78841..d4c4af5f62a9d0 100644 --- a/tests/components/buienradar/test_weather.py +++ b/tests/components/buienradar/test_weather.py @@ -1,4 +1,6 @@ """The tests for the buienradar weather component.""" +from http import HTTPStatus + from homeassistant.components.buienradar.const import DOMAIN from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant @@ -13,6 +15,9 @@ async def test_smoke_test_setup_component( aioclient_mock: AiohttpClientMocker, hass: HomeAssistant ) -> None: """Smoke test for successfully set-up with default config.""" + aioclient_mock.get( + "https://data.buienradar.nl/2.0/feed/json", status=HTTPStatus.NOT_FOUND + ) mock_entry = MockConfigEntry(domain=DOMAIN, unique_id="TEST_ID", data=TEST_CFG_DATA) mock_entry.add_to_hass(hass)