diff --git a/README.md b/README.md new file mode 100644 index 0000000..2c6ddf9 --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +# Coronavirus Hessen + +[Home Assistant](https://www.home-assistant.io/) component to scrape the current SARS-CoV-2 data for the German state of Hessen from the [website of the Hessisches Ministerium für Soziales und Integration](https://soziales.hessen.de/gesundheit/infektionsschutz/coronavirus-sars-cov-2/taegliche-uebersicht-der-bestaetigten-sars-cov-2-faelle-hessen). + +## Setup + + + +Copy the folder `custom_components/coronavirus_hessen` to `/custom_components/`. When you are done you should have `/custom_components/coronavirus_hessen/__init__.py`, `/custom_components/coronavirus_hessen/sensor.py` and so on. + + + +## Configuration: + +In Home Assistant: + +1. Enter configuration menu +2. Select "Integrations" +3. Click the "+" in the bottom right +4. Choose "Coronavirus Hessen" +5. Choose the county you wish to monitor (or "Gesamthessen" for all of Hessen) +6. Save + +## TODO + + * [ ] Find out why the created sensors don't show up in the integration overview + * [ ] Find out if there's a possibility to select more than one county during configuration to have all created sensors under *one* integration entry + * [ ] Make this thing work with HACS for easier installation/updating + +*This is my first integration for Home Assistant ever and I basically learned how to even begin to do this stuff while writing this. I'm happy for any pointers as to how to improve things.* diff --git a/custom_components/coronavirus_hessen/.translations/de.json b/custom_components/coronavirus_hessen/.translations/de.json new file mode 100644 index 0000000..37ccba7 --- /dev/null +++ b/custom_components/coronavirus_hessen/.translations/de.json @@ -0,0 +1,16 @@ +{ + "config": { + "title": "Coronavirus Hessen", + "step": { + "user": { + "title": "Wähle einen Landkreis", + "data": { + "country": "Landkreis" + } + } + }, + "abort": { + "already_configured": "Dieser Landkreis ist bereits konfiguriert." + } + } +} \ No newline at end of file diff --git a/custom_components/coronavirus_hessen/.translations/en.json b/custom_components/coronavirus_hessen/.translations/en.json new file mode 100644 index 0000000..7dc1f37 --- /dev/null +++ b/custom_components/coronavirus_hessen/.translations/en.json @@ -0,0 +1,16 @@ +{ + "config": { + "title": "Coronavirus Hessen", + "step": { + "user": { + "title": "Pick a county to monitor", + "data": { + "country": "County" + } + } + }, + "abort": { + "already_configured": "This county is already configured." + } + } +} \ No newline at end of file diff --git a/custom_components/coronavirus_hessen/__init__.py b/custom_components/coronavirus_hessen/__init__.py new file mode 100644 index 0000000..12f04b9 --- /dev/null +++ b/custom_components/coronavirus_hessen/__init__.py @@ -0,0 +1,105 @@ +"""The corona_hessen component.""" + +from datetime import timedelta +import logging + +import async_timeout +import asyncio +import bs4 + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback + +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, ENDPOINT, OPTION_TOTAL + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = ["sensor"] + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the Coronavirus Hessen component.""" + # Make sure coordinator is initialized. + await get_coordinator(hass) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up Coronavirus Hessen from a config entry.""" + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + + return unload_ok + + +async def get_coordinator(hass): + """Get the data update coordinator.""" + if DOMAIN in hass.data: + return hass.data[DOMAIN] + + async def async_get_data(): + with async_timeout.timeout(10): + response = await aiohttp_client.async_get_clientsession(hass).get(ENDPOINT) + raw_html = await response.text() + + data = bs4.BeautifulSoup(raw_html, "html.parser") + + result = dict() + rows = data.select("article table:first-of-type tr") + + # Counties + for row in rows[1:-1]: + line = row.select("td") + if len(line) != 4: + continue + + try: + county = line[0].text.strip() + cases_str = line[3].text.strip() + if len(cases_str) and cases_str != "-": + cases = int(cases_str) + else: + cases = 0 + except ValueError: + _LOGGER.error("Error processing line {}, skipping".format(line)) + continue + result[county] = cases + + # Total + line = rows[-1].select("td") + try: + result[OPTION_TOTAL] = int(line[-1].select("p strong")[0].text.strip()) + except ValueError: + _LOGGER.error("Error processing total value from {}, skipping".format(line)) + + _LOGGER.debug("Corona Hessen: {!r}".format(result)) + return result + + hass.data[DOMAIN] = DataUpdateCoordinator( + hass, + logging.getLogger(__name__), + name=DOMAIN, + update_method=async_get_data, + update_interval=timedelta(hours=12), # 12h as the data apparently only updates once per day anyhow + ) + await hass.data[DOMAIN].async_refresh() + return hass.data[DOMAIN] diff --git a/custom_components/coronavirus_hessen/config_flow.py b/custom_components/coronavirus_hessen/config_flow.py new file mode 100644 index 0000000..7759b12 --- /dev/null +++ b/custom_components/coronavirus_hessen/config_flow.py @@ -0,0 +1,45 @@ +"""Config flow for Coronavirus Hessen integration.""" +import logging + +import voluptuous as vol + +from homeassistant import config_entries + +from . import get_coordinator +from .const import DOMAIN, OPTION_TOTAL + +_LOGGER = logging.getLogger(__name__) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Coronavirus Hessen.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + _options = None + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + if self._options is None: + self._options = {OPTION_TOTAL: "Gesamthessen"} + coordinator = await get_coordinator(self.hass) + for county in sorted(coordinator.data.keys()): + if county == OPTION_TOTAL: + continue + self._options[county] = county + + if user_input is not None: + await self.async_set_unique_id(user_input["county"]) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=self._options[user_input["county"]], data=user_input + ) + + _LOGGER.debug("Showing config form, options is {!r}".format(self._options)) + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({ + vol.Required("county"): vol.In(self._options) + }), + ) diff --git a/custom_components/coronavirus_hessen/const.py b/custom_components/coronavirus_hessen/const.py new file mode 100644 index 0000000..3d7ee41 --- /dev/null +++ b/custom_components/coronavirus_hessen/const.py @@ -0,0 +1,5 @@ +"""Constants for the Coronavirus Hessen integration.""" +DOMAIN = "coronavirus_hessen" +ENDPOINT = "https://soziales.hessen.de/gesundheit/infektionsschutz/coronavirus-sars-cov-2/taegliche-uebersicht-der-bestaetigten-sars-cov-2-faelle-hessen" +ATTRIBUTION = "Data provided by Hessisches Ministrium für Soziales und Integration" +OPTION_TOTAL = "total" diff --git a/custom_components/coronavirus_hessen/manifest.json b/custom_components/coronavirus_hessen/manifest.json new file mode 100644 index 0000000..d19bc6a --- /dev/null +++ b/custom_components/coronavirus_hessen/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "coronavirus_hessen", + "name": "Coronavirus Hessen", + "config_flow": true, + "documentation": "https://github.com/foosel/homeassistant-coronavirus-hessen", + "requirements": ["beautifulsoup4==4.8.2"], + "dependencies": [], + "codeowners": ["@foosel"] +} diff --git a/custom_components/coronavirus_hessen/sensor.py b/custom_components/coronavirus_hessen/sensor.py new file mode 100644 index 0000000..d74ed39 --- /dev/null +++ b/custom_components/coronavirus_hessen/sensor.py @@ -0,0 +1,63 @@ +"""Support for getting current Corona data from the website of the Hessische Ministerium für Soziales und Integration.""" +import logging + +import voluptuous as vol + +from homeassistant.const import ATTR_ATTRIBUTION +from homeassistant.helpers.entity import Entity + +from . import get_coordinator +from .const import ATTRIBUTION, OPTION_TOTAL + +_LOGGER = logging.getLogger(__name__) + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Defer sensor setup to the shared sensor module.""" + coordinator = await get_coordinator(hass) + + async_add_entities([CoronaHessenSensor(coordinator, config_entry.data["county"])]) + +class CoronaHessenSensor(Entity): + """Representation of a county with Corona cases.""" + + def __init__(self, coordinator, county): + """Initialize sensor.""" + self.coordinator = coordinator + self.county = county + if county == OPTION_TOTAL: + self._name = f"Coronavirus Hessen" + else: + self._name = f"Coronavirus Hessen {county}" + self._state = None + + @property + def available(self): + return self.coordinator.last_update_success and self.county in self.coordinator.data + + @property + def name(self): + return self._name + + @property + def icon(self): + return "mdi:biohazard" + + @property + def unit_of_measurement(self): + return "people" + + @property + def state(self): + return self.coordinator.data[self.county] + + @property + def device_state_attributes(self): + return {ATTR_ATTRIBUTION: ATTRIBUTION} + + async def async_added_to_hass(self): + """When entity is added to hass.""" + self.coordinator.async_add_listener(self.async_write_ha_state) + + async def async_will_remove_from_hass(self): + """When entity will be removed from hass.""" + self.coordinator.async_remove_listener(self.async_write_ha_state) diff --git a/custom_components/coronavirus_hessen/strings.json b/custom_components/coronavirus_hessen/strings.json new file mode 100644 index 0000000..7dc1f37 --- /dev/null +++ b/custom_components/coronavirus_hessen/strings.json @@ -0,0 +1,16 @@ +{ + "config": { + "title": "Coronavirus Hessen", + "step": { + "user": { + "title": "Pick a county to monitor", + "data": { + "country": "County" + } + } + }, + "abort": { + "already_configured": "This county is already configured." + } + } +} \ No newline at end of file