diff --git a/.coveragerc b/.coveragerc index 4eb8f1e0210..92ad9555d5d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -10,6 +10,7 @@ omit = # omit pieces of code that rely on external devices being present homeassistant/components/accuweather/__init__.py homeassistant/components/accuweather/const.py + homeassistant/components/accuweather/sensor.py homeassistant/components/accuweather/weather.py homeassistant/components/acer_projector/switch.py homeassistant/components/actiontec/device_tracker.py diff --git a/homeassistant/components/accuweather/__init__.py b/homeassistant/components/accuweather/__init__.py index 0107262e490..3dbb713ab2b 100644 --- a/homeassistant/components/accuweather/__init__.py +++ b/homeassistant/components/accuweather/__init__.py @@ -23,7 +23,7 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -PLATFORMS = ["weather"] +PLATFORMS = ["sensor", "weather"] async def async_setup(hass: HomeAssistant, config: Config) -> bool: diff --git a/homeassistant/components/accuweather/const.py b/homeassistant/components/accuweather/const.py index 2b903d8aa6e..c1b09ebd7b2 100644 --- a/homeassistant/components/accuweather/const.py +++ b/homeassistant/components/accuweather/const.py @@ -1,8 +1,30 @@ """Constants for AccuWeather integration.""" +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + DEVICE_CLASS_TEMPERATURE, + LENGTH_FEET, + LENGTH_INCHES, + LENGTH_METERS, + SPEED_KILOMETERS_PER_HOUR, + SPEED_MILES_PER_HOUR, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, + TIME_HOURS, + UNIT_PERCENTAGE, + UV_INDEX, + VOLUME_CUBIC_METERS, +) + ATTRIBUTION = "Data provided by AccuWeather" +ATTR_ICON = "icon" ATTR_FORECAST = CONF_FORECAST = "forecast" +ATTR_LABEL = "label" +ATTR_UNIT_IMPERIAL = "Imperial" +ATTR_UNIT_METRIC = "Metric" +CONCENTRATION_PARTS_PER_CUBIC_METER = f"p/{VOLUME_CUBIC_METERS}" COORDINATOR = "coordinator" DOMAIN = "accuweather" +LENGTH_MILIMETERS = "mm" UNDO_UPDATE_LISTENER = "undo_update_listener" CONDITION_CLASSES = { @@ -21,3 +43,235 @@ CONDITION_CLASSES = { "sunny": [1, 2, 3, 5], "windy": [32], } + +FORECAST_DAYS = [0, 1, 2, 3, 4] + +FORECAST_SENSOR_TYPES = { + "CloudCoverDay": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:weather-cloudy", + ATTR_LABEL: "Cloud Cover Day", + ATTR_UNIT_METRIC: UNIT_PERCENTAGE, + ATTR_UNIT_IMPERIAL: UNIT_PERCENTAGE, + }, + "CloudCoverNight": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:weather-cloudy", + ATTR_LABEL: "Cloud Cover Night", + ATTR_UNIT_METRIC: UNIT_PERCENTAGE, + ATTR_UNIT_IMPERIAL: UNIT_PERCENTAGE, + }, + "Grass": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:grass", + ATTR_LABEL: "Grass Pollen", + ATTR_UNIT_METRIC: CONCENTRATION_PARTS_PER_CUBIC_METER, + ATTR_UNIT_IMPERIAL: CONCENTRATION_PARTS_PER_CUBIC_METER, + }, + "HoursOfSun": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:weather-partly-cloudy", + ATTR_LABEL: "Hours Of Sun", + ATTR_UNIT_METRIC: TIME_HOURS, + ATTR_UNIT_IMPERIAL: TIME_HOURS, + }, + "Mold": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:blur", + ATTR_LABEL: "Mold Pollen", + ATTR_UNIT_METRIC: CONCENTRATION_PARTS_PER_CUBIC_METER, + ATTR_UNIT_IMPERIAL: CONCENTRATION_PARTS_PER_CUBIC_METER, + }, + "Ozone": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:vector-triangle", + ATTR_LABEL: "Ozone", + ATTR_UNIT_METRIC: None, + ATTR_UNIT_IMPERIAL: None, + }, + "Ragweed": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:sprout", + ATTR_LABEL: "Ragweed Pollen", + ATTR_UNIT_METRIC: CONCENTRATION_PARTS_PER_CUBIC_METER, + ATTR_UNIT_IMPERIAL: CONCENTRATION_PARTS_PER_CUBIC_METER, + }, + "RealFeelTemperatureMax": { + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_ICON: None, + ATTR_LABEL: "RealFeel Temperature Max", + ATTR_UNIT_METRIC: TEMP_CELSIUS, + ATTR_UNIT_IMPERIAL: TEMP_FAHRENHEIT, + }, + "RealFeelTemperatureMin": { + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_ICON: None, + ATTR_LABEL: "RealFeel Temperature Min", + ATTR_UNIT_METRIC: TEMP_CELSIUS, + ATTR_UNIT_IMPERIAL: TEMP_FAHRENHEIT, + }, + "RealFeelTemperatureShadeMax": { + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_ICON: None, + ATTR_LABEL: "RealFeel Temperature Shade Max", + ATTR_UNIT_METRIC: TEMP_CELSIUS, + ATTR_UNIT_IMPERIAL: TEMP_FAHRENHEIT, + }, + "RealFeelTemperatureShadeMin": { + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_ICON: None, + ATTR_LABEL: "RealFeel Temperature Shade Min", + ATTR_UNIT_METRIC: TEMP_CELSIUS, + ATTR_UNIT_IMPERIAL: TEMP_FAHRENHEIT, + }, + "ThunderstormProbabilityDay": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:weather-lightning", + ATTR_LABEL: "Thunderstorm Probability Day", + ATTR_UNIT_METRIC: UNIT_PERCENTAGE, + ATTR_UNIT_IMPERIAL: UNIT_PERCENTAGE, + }, + "ThunderstormProbabilityNight": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:weather-lightning", + ATTR_LABEL: "Thunderstorm Probability Night", + ATTR_UNIT_METRIC: UNIT_PERCENTAGE, + ATTR_UNIT_IMPERIAL: UNIT_PERCENTAGE, + }, + "Tree": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:tree-outline", + ATTR_LABEL: "Tree Pollen", + ATTR_UNIT_METRIC: CONCENTRATION_PARTS_PER_CUBIC_METER, + ATTR_UNIT_IMPERIAL: CONCENTRATION_PARTS_PER_CUBIC_METER, + }, + "UVIndex": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:weather-sunny", + ATTR_LABEL: "UV Index", + ATTR_UNIT_METRIC: UV_INDEX, + ATTR_UNIT_IMPERIAL: UV_INDEX, + }, + "WindGustDay": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:weather-windy", + ATTR_LABEL: "Wind Gust Day", + ATTR_UNIT_METRIC: SPEED_KILOMETERS_PER_HOUR, + ATTR_UNIT_IMPERIAL: SPEED_MILES_PER_HOUR, + }, + "WindGustNight": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:weather-windy", + ATTR_LABEL: "Wind Gust Night", + ATTR_UNIT_METRIC: SPEED_KILOMETERS_PER_HOUR, + ATTR_UNIT_IMPERIAL: SPEED_MILES_PER_HOUR, + }, +} + +OPTIONAL_SENSORS = ( + "ApparentTemperature", + "CloudCover", + "CloudCoverDay", + "CloudCoverNight", + "DewPoint", + "Grass", + "Mold", + "Ozone", + "Ragweed", + "RealFeelTemperatureShade", + "RealFeelTemperatureShadeMax", + "RealFeelTemperatureShadeMin", + "Tree", + "WetBulbTemperature", + "WindChillTemperature", + "WindGust", + "WindGustDay", + "WindGustNight", +) + +SENSOR_TYPES = { + "ApparentTemperature": { + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_ICON: None, + ATTR_LABEL: "Apparent Temperature", + ATTR_UNIT_METRIC: TEMP_CELSIUS, + ATTR_UNIT_IMPERIAL: TEMP_FAHRENHEIT, + }, + "Ceiling": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:weather-fog", + ATTR_LABEL: "Cloud Ceiling", + ATTR_UNIT_METRIC: LENGTH_METERS, + ATTR_UNIT_IMPERIAL: LENGTH_FEET, + }, + "CloudCover": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:weather-cloudy", + ATTR_LABEL: "Cloud Cover", + ATTR_UNIT_METRIC: UNIT_PERCENTAGE, + ATTR_UNIT_IMPERIAL: UNIT_PERCENTAGE, + }, + "DewPoint": { + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_ICON: None, + ATTR_LABEL: "Dew Point", + ATTR_UNIT_METRIC: TEMP_CELSIUS, + ATTR_UNIT_IMPERIAL: TEMP_FAHRENHEIT, + }, + "RealFeelTemperature": { + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_ICON: None, + ATTR_LABEL: "RealFeel Temperature", + ATTR_UNIT_METRIC: TEMP_CELSIUS, + ATTR_UNIT_IMPERIAL: TEMP_FAHRENHEIT, + }, + "RealFeelTemperatureShade": { + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_ICON: None, + ATTR_LABEL: "RealFeel Temperature Shade", + ATTR_UNIT_METRIC: TEMP_CELSIUS, + ATTR_UNIT_IMPERIAL: TEMP_FAHRENHEIT, + }, + "Precipitation": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:weather-rainy", + ATTR_LABEL: "Precipitation", + ATTR_UNIT_METRIC: LENGTH_MILIMETERS, + ATTR_UNIT_IMPERIAL: LENGTH_INCHES, + }, + "PressureTendency": { + ATTR_DEVICE_CLASS: "accuweather__pressure_tendency", + ATTR_ICON: "mdi:gauge", + ATTR_LABEL: "Pressure Tendency", + ATTR_UNIT_METRIC: None, + ATTR_UNIT_IMPERIAL: None, + }, + "UVIndex": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:weather-sunny", + ATTR_LABEL: "UV Index", + ATTR_UNIT_METRIC: UV_INDEX, + ATTR_UNIT_IMPERIAL: UV_INDEX, + }, + "WetBulbTemperature": { + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_ICON: None, + ATTR_LABEL: "Wet Bulb Temperature", + ATTR_UNIT_METRIC: TEMP_CELSIUS, + ATTR_UNIT_IMPERIAL: TEMP_FAHRENHEIT, + }, + "WindChillTemperature": { + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_ICON: None, + ATTR_LABEL: "Wind Chill Temperature", + ATTR_UNIT_METRIC: TEMP_CELSIUS, + ATTR_UNIT_IMPERIAL: TEMP_FAHRENHEIT, + }, + "WindGust": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:weather-windy", + ATTR_LABEL: "Wind Gust", + ATTR_UNIT_METRIC: SPEED_KILOMETERS_PER_HOUR, + ATTR_UNIT_IMPERIAL: SPEED_MILES_PER_HOUR, + }, +} diff --git a/homeassistant/components/accuweather/sensor.py b/homeassistant/components/accuweather/sensor.py new file mode 100644 index 00000000000..4c0634876ef --- /dev/null +++ b/homeassistant/components/accuweather/sensor.py @@ -0,0 +1,177 @@ +"""Support for the AccuWeather service.""" +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_DEVICE_CLASS, + CONF_NAME, + DEVICE_CLASS_TEMPERATURE, +) +from homeassistant.helpers.entity import Entity + +from .const import ( + ATTR_FORECAST, + ATTR_ICON, + ATTR_LABEL, + ATTRIBUTION, + COORDINATOR, + DOMAIN, + FORECAST_DAYS, + FORECAST_SENSOR_TYPES, + OPTIONAL_SENSORS, + SENSOR_TYPES, +) + +PARALLEL_UPDATES = 1 + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Add AccuWeather entities from a config_entry.""" + name = config_entry.data[CONF_NAME] + + coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] + + sensors = [] + for sensor in SENSOR_TYPES: + sensors.append(AccuWeatherSensor(name, sensor, coordinator)) + + if coordinator.forecast: + for sensor in FORECAST_SENSOR_TYPES: + for day in FORECAST_DAYS: + # Some air quality/allergy sensors are only available for certain + # locations. + if sensor in coordinator.data[ATTR_FORECAST][0]: + sensors.append( + AccuWeatherSensor(name, sensor, coordinator, forecast_day=day) + ) + + async_add_entities(sensors, False) + + +class AccuWeatherSensor(Entity): + """Define an AccuWeather entity.""" + + def __init__(self, name, kind, coordinator, forecast_day=None): + """Initialize.""" + self._name = name + self.kind = kind + self.coordinator = coordinator + self._device_class = None + self._attrs = {ATTR_ATTRIBUTION: ATTRIBUTION} + self._unit_system = "Metric" if self.coordinator.is_metric else "Imperial" + self.forecast_day = forecast_day + + @property + def name(self): + """Return the name.""" + if self.forecast_day is not None: + return f"{self._name} {FORECAST_SENSOR_TYPES[self.kind][ATTR_LABEL]} {self.forecast_day}d" + return f"{self._name} {SENSOR_TYPES[self.kind][ATTR_LABEL]}" + + @property + def unique_id(self): + """Return a unique_id for this entity.""" + if self.forecast_day is not None: + return f"{self.coordinator.location_key}-{self.kind}-{self.forecast_day}".lower() + return f"{self.coordinator.location_key}-{self.kind}".lower() + + @property + def should_poll(self): + """Return the polling requirement of the entity.""" + return False + + @property + def available(self): + """Return True if entity is available.""" + return self.coordinator.last_update_success + + @property + def state(self): + """Return the state.""" + if self.forecast_day is not None: + if ( + FORECAST_SENSOR_TYPES[self.kind][ATTR_DEVICE_CLASS] + == DEVICE_CLASS_TEMPERATURE + ): + return self.coordinator.data[ATTR_FORECAST][self.forecast_day][ + self.kind + ]["Value"] + if self.kind in ["WindGustDay", "WindGustNight"]: + return self.coordinator.data[ATTR_FORECAST][self.forecast_day][ + self.kind + ]["Speed"]["Value"] + if self.kind in ["Grass", "Mold", "Ragweed", "Tree", "UVIndex", "Ozone"]: + return self.coordinator.data[ATTR_FORECAST][self.forecast_day][ + self.kind + ]["Value"] + return self.coordinator.data[ATTR_FORECAST][self.forecast_day][self.kind] + if self.kind == "Ceiling": + return round(self.coordinator.data[self.kind][self._unit_system]["Value"]) + if self.kind == "PressureTendency": + return self.coordinator.data[self.kind]["LocalizedText"].lower() + if SENSOR_TYPES[self.kind][ATTR_DEVICE_CLASS] == DEVICE_CLASS_TEMPERATURE: + return self.coordinator.data[self.kind][self._unit_system]["Value"] + if self.kind == "Precipitation": + return self.coordinator.data["PrecipitationSummary"][self.kind][ + self._unit_system + ]["Value"] + if self.kind == "WindGust": + return self.coordinator.data[self.kind]["Speed"][self._unit_system]["Value"] + return self.coordinator.data[self.kind] + + @property + def icon(self): + """Return the icon.""" + if self.forecast_day is not None: + return FORECAST_SENSOR_TYPES[self.kind][ATTR_ICON] + return SENSOR_TYPES[self.kind][ATTR_ICON] + + @property + def device_class(self): + """Return the device_class.""" + if self.forecast_day is not None: + return FORECAST_SENSOR_TYPES[self.kind][ATTR_DEVICE_CLASS] + return SENSOR_TYPES[self.kind][ATTR_DEVICE_CLASS] + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + if self.forecast_day is not None: + return FORECAST_SENSOR_TYPES[self.kind][self._unit_system] + return SENSOR_TYPES[self.kind][self._unit_system] + + @property + def device_state_attributes(self): + """Return the state attributes.""" + if self.forecast_day is not None: + if self.kind == "WindGustDay": + self._attrs["direction"] = self.coordinator.data[ATTR_FORECAST][ + self.forecast_day + ][self.kind]["Direction"]["English"] + elif self.kind == "WindGustNight": + self._attrs["direction"] = self.coordinator.data[ATTR_FORECAST][ + self.forecast_day + ][self.kind]["Direction"]["English"] + elif self.kind in ["Grass", "Mold", "Ragweed", "Tree", "UVIndex", "Ozone"]: + self._attrs["level"] = self.coordinator.data[ATTR_FORECAST][ + self.forecast_day + ][self.kind]["Category"] + return self._attrs + if self.kind == "UVIndex": + self._attrs["level"] = self.coordinator.data["UVIndexText"] + elif self.kind == "Precipitation": + self._attrs["type"] = self.coordinator.data["PrecipitationType"] + return self._attrs + + @property + def entity_registry_enabled_default(self): + """Return if the entity should be enabled when first added to the entity registry.""" + return bool(self.kind not in OPTIONAL_SENSORS) + + async def async_added_to_hass(self): + """Connect to dispatcher listening for entity data notifications.""" + self.async_on_remove( + self.coordinator.async_add_listener(self.async_write_ha_state) + ) + + async def async_update(self): + """Update AccuWeather entity.""" + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/accuweather/strings.json b/homeassistant/components/accuweather/strings.json index 80f3159ad9e..89228cd0692 100644 --- a/homeassistant/components/accuweather/strings.json +++ b/homeassistant/components/accuweather/strings.json @@ -3,7 +3,7 @@ "step": { "user": { "title": "AccuWeather", - "description": "If you need help with the configuration have a look here: https://www.home-assistant.io/integrations/accuweather/\n\nWeather forecast is not enabled by default. You can enable it in the integration options.", + "description": "If you need help with the configuration have a look here: https://www.home-assistant.io/integrations/accuweather/\n\nSome sensors are not enabled by default. You can enable them in the entity registry after the integration configuration.\nWeather forecast is not enabled by default. You can enable it in the integration options.", "data": { "name": "Name of the integration", "api_key": "[%key:common::config_flow::data::api_key%]", diff --git a/homeassistant/components/accuweather/strings.sensor.json b/homeassistant/components/accuweather/strings.sensor.json new file mode 100644 index 00000000000..57cb89bcecf --- /dev/null +++ b/homeassistant/components/accuweather/strings.sensor.json @@ -0,0 +1,9 @@ +{ + "state": { + "accuweather__pressure_tendency": { + "steady": "Steady", + "rising": "Rising", + "falling": "Falling" + } + } +}