From d6faf616fea7484f76c2fcc5f0eeec7ea964beb1 Mon Sep 17 00:00:00 2001 From: Markus Stein Date: Fri, 11 Aug 2023 15:39:45 +0200 Subject: [PATCH] Initial commit --- .github/workflows/codeql-analysis.yml | 71 +++++++++++ .github/workflows/hassfest.yaml | 14 +++ .gitignore | 2 + README.md | 17 ++- custom_components/liquid-check/__init__.py | 69 +++++++++++ custom_components/liquid-check/config_flow.py | 112 ++++++++++++++++++ custom_components/liquid-check/const.py | 23 ++++ custom_components/liquid-check/coordinator.py | 59 +++++++++ custom_components/liquid-check/manifest.json | 11 ++ custom_components/liquid-check/sensor.py | 96 +++++++++++++++ custom_components/liquid-check/strings.json | 20 ++++ .../liquid-check/translations/en.json | 18 +++ hacs.json | 7 ++ 13 files changed, 518 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/codeql-analysis.yml create mode 100644 .github/workflows/hassfest.yaml create mode 100644 .gitignore create mode 100644 custom_components/liquid-check/__init__.py create mode 100644 custom_components/liquid-check/config_flow.py create mode 100644 custom_components/liquid-check/const.py create mode 100644 custom_components/liquid-check/coordinator.py create mode 100644 custom_components/liquid-check/manifest.json create mode 100644 custom_components/liquid-check/sensor.py create mode 100644 custom_components/liquid-check/strings.json create mode 100644 custom_components/liquid-check/translations/en.json create mode 100644 hacs.json diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 0000000..e5e9535 --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,71 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches: [ master ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ master ] + schedule: + - cron: '43 14 * * 2' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ 'python' ] + # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] + # Learn more: + # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v1 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + # queries: ./path/to/local/query, your-org/your-repo/queries@main + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v1 + + # ℹ️ Command-line programs to run using the OS shell. + # 📚 https://git.io/JvXDl + + # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines + # and modify them (or add more) to build your code if your project + # uses a compiled language + + #- run: | + # make bootstrap + # make release + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v1 diff --git a/.github/workflows/hassfest.yaml b/.github/workflows/hassfest.yaml new file mode 100644 index 0000000..18c7d19 --- /dev/null +++ b/.github/workflows/hassfest.yaml @@ -0,0 +1,14 @@ +name: Validate with hassfest + +on: + push: + pull_request: + schedule: + - cron: "0 0 * * *" + +jobs: + validate: + runs-on: "ubuntu-latest" + steps: + - uses: "actions/checkout@v2" + - uses: home-assistant/actions/hassfest@master diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9c891d2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +dev-env/ +.DS_Store \ No newline at end of file diff --git a/README.md b/README.md index 60ad330..53a0804 100644 --- a/README.md +++ b/README.md @@ -1 +1,16 @@ -# homeassistant-liquidcheck \ No newline at end of file +# homeassistant-liquidcheck + +Home Assistant Component for liquid-check + +[![hacs_badge](https://img.shields.io/badge/HACS-Custom-orange.svg?style=for-the-badge)](https://github.com/custom-components/hacs) + +### Installation + +Copy this folder to `/custom_components/liquid-check/`. + +### HACS +Search for liquid-check + +### Configuration + +The integration is configurated via UI \ No newline at end of file diff --git a/custom_components/liquid-check/__init__.py b/custom_components/liquid-check/__init__.py new file mode 100644 index 0000000..5e8572d --- /dev/null +++ b/custom_components/liquid-check/__init__.py @@ -0,0 +1,69 @@ +""" Integration for Liquid-Check""" +import voluptuous as vol + + +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import ( + CONF_HOST, + CONF_MONITORED_CONDITIONS, +) +import homeassistant.helpers.config_validation as cv + +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.typing import HomeAssistantType + +from .const import DOMAIN, SENSOR_TYPES, DATA_COORDINATOR +from .coordinator import LiquidCheckDataUpdateCoordinator + + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_MONITORED_CONDITIONS): vol.All( + cv.ensure_list, [vol.In(list(SENSOR_TYPES))] + ), + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass, config): + """Platform setup, do nothing.""" + hass.data.setdefault(DOMAIN, {}) + + if DOMAIN not in config: + return True + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=dict(config[DOMAIN]) + ) + ) + return True + + +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): + """Load the saved entities.""" + coordinator = LiquidCheckDataUpdateCoordinator( + hass, + config=entry.data, + options=entry.options, + ) + + await coordinator.async_refresh() + + if not coordinator.last_update_success: + raise ConfigEntryNotReady + + hass.data[DOMAIN][entry.entry_id] = { + DATA_COORDINATOR: coordinator, + } + + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, "sensor") + ) + return True diff --git a/custom_components/liquid-check/config_flow.py b/custom_components/liquid-check/config_flow.py new file mode 100644 index 0000000..33baa6e --- /dev/null +++ b/custom_components/liquid-check/config_flow.py @@ -0,0 +1,112 @@ +"""Config flow for liquid-check integration.""" +# import logging + +import voluptuous as vol +import requests +import json +from requests.exceptions import HTTPError, ConnectTimeout + +from homeassistant import config_entries +import homeassistant.helpers.config_validation as cv + +from homeassistant.const import ( + CONF_HOST, + CONF_MONITORED_CONDITIONS, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.util import slugify + +from .const import DOMAIN, SENSOR_TYPES # pylint:disable=unused-import + +SUPPORTED_SENSOR_TYPES = list(SENSOR_TYPES) + +DEFAULT_MONITORED_CONDITIONS = [ + "firmware", + "measure_percent", + "content", +] + + +@callback +def liquidcheck_entries(hass: HomeAssistant): + """Return the hosts for the domain.""" + return set( + (entry.data[CONF_HOST]) for entry in hass.config_entries.async_entries(DOMAIN) + ) + + +class LiquidCheckConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Liquid-Check config flow.""" + + VERSION = 1 + + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + def __init__(self) -> None: + """Initialize the config flow.""" + self._errors = {} + self._info = {} + + def _host_in_configuration_exists(self, host) -> bool: + """Return True if site_id exists in configuration.""" + if host in liquidcheck_entries(self.hass): + return True + return False + + def _check_host(self, host) -> bool: + """Check if we can connect to the liquid-check.""" + try: + response = requests.get(f"http://{host}/infos.json") + self._info = json.loads(response.text) + except (ConnectTimeout, HTTPError): + self._errors[CONF_HOST] = "could_not_connect" + return False + + return True + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + + if user_input is not None: + if self._host_in_configuration_exists(user_input[CONF_HOST]): + self._errors[CONF_HOST] = "host_exists" + else: + host = user_input[CONF_HOST] + conditions = user_input[CONF_MONITORED_CONDITIONS] + can_connect = await self.hass.async_add_executor_job( + self._check_host, host + ) + if can_connect: + return self.async_create_entry( + title=f"{self._info['device']} - {self._info['number']}", + data={ + CONF_HOST: host, + CONF_MONITORED_CONDITIONS: conditions, + }, + ) + else: + user_input = {} + user_input[CONF_HOST] = "192.168.0.?" + user_input[CONF_MONITORED_CONDITIONS] = DEFAULT_MONITORED_CONDITIONS + + default_monitored_conditions = ( + [] if self._async_current_entries() else DEFAULT_MONITORED_CONDITIONS + ) + setup_schema = vol.Schema( + { + vol.Required(CONF_HOST, default=user_input[CONF_HOST]): str, + vol.Required( + CONF_MONITORED_CONDITIONS, default=default_monitored_conditions + ): cv.multi_select(SUPPORTED_SENSOR_TYPES), + } + ) + + return self.async_show_form( + step_id="user", data_schema=setup_schema, errors=self._errors + ) + + async def async_step_import(self, user_input=None): + """Import a config entry.""" + if self._host_in_configuration_exists(user_input[CONF_HOST]): + return self.async_abort(reason="host_exists") + return await self.async_step_user(user_input) \ No newline at end of file diff --git a/custom_components/liquid-check/const.py b/custom_components/liquid-check/const.py new file mode 100644 index 0000000..c726628 --- /dev/null +++ b/custom_components/liquid-check/const.py @@ -0,0 +1,23 @@ +"""Constants for the liquid-check integration.""" +from datetime import timedelta + +from homeassistant.const import ( + UnitOfVolume, + PERCENTAGE, +) + +DOMAIN = "liquid-check" + +DATA_COORDINATOR = "corrdinator" + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=10) + +SENSOR_TYPES = { + "device": ["Device", None, "", "data", "device/name"], + "firmware": ["Firmware Version", None, "", "data", "device/firmware"], + "measure_percent": ["Füllstand", PERCENTAGE, "", "data", "measure/percent"], + "level": ["Level", None, "", "data", "measure/level"], + "content": ["Füllstand", UnitOfVolume.LITERS, "", "data", "measure/conent"], + "age": ["Alter", None, "", "data", "measure/age"], + "error": ["Fehler", None, "", "data", "system/error"], +} diff --git a/custom_components/liquid-check/coordinator.py b/custom_components/liquid-check/coordinator.py new file mode 100644 index 0000000..665ba5d --- /dev/null +++ b/custom_components/liquid-check/coordinator.py @@ -0,0 +1,59 @@ +"""Provides the LiquidCheck DataUpdateCoordinator.""" +from datetime import timedelta +import logging +import requests +import json + +from async_timeout import timeout +from homeassistant.util.dt import utcnow +from homeassistant.const import CONF_HOST +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class LiquidCheckDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching LiquidCheck data.""" + + def __init__(self, hass: HomeAssistantType, *, config: dict, options: dict): + """Initialize global liquitdcheck data updater.""" + self._host = config[CONF_HOST] + self._next_update = 0 + update_interval = timedelta(seconds=30) + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=update_interval, + ) + + async def _async_update_data(self) -> dict: + """Fetch data from LiquidCheck.""" + + def _update_data() -> dict: + """Fetch data from LiquidCheck via sync functions.""" + data = self.data_update() + + return { + "data": data["payload"] + } + + try: + async with timeout(4): + return await self.hass.async_add_executor_job(_update_data) + except Exception as error: + raise UpdateFailed(f"Invalid response from API: {error}") from error + + def data_update(self): + """Update liquid check data.""" + try: + response = requests.get(f"http://{self._host}/data.jsn") + data = json.loads(response.text) + _LOGGER.debug(data) + return data + except: + pass \ No newline at end of file diff --git a/custom_components/liquid-check/manifest.json b/custom_components/liquid-check/manifest.json new file mode 100644 index 0000000..3f3c590 --- /dev/null +++ b/custom_components/liquid-check/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "liquid-check", + "version": "1.0.0", + "name": "Liquid-Check", + "documentation": "https://github.com/shivan/homeassistant-liquidcheck", + "config_flow": true, + "dependencies": [], + "codeowners": ["@shivan"], + "requirements": [], + "iot_class": "local_polling" +} diff --git a/custom_components/liquid-check/sensor.py b/custom_components/liquid-check/sensor.py new file mode 100644 index 0000000..f0086c8 --- /dev/null +++ b/custom_components/liquid-check/sensor.py @@ -0,0 +1,96 @@ +"""The liquid-chekc integration.""" + +import logging +from homeassistant.const import CONF_MONITORED_CONDITIONS +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import SENSOR_TYPES, DOMAIN, DATA_COORDINATOR +from .coordinator import LiquidCheckDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, entry, async_add_entities): + """Add an LiquidCheck entry.""" + coordinator: LiquidCheckDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ + DATA_COORDINATOR + ] + + entities = [] + + for sensor in entry.data[CONF_MONITORED_CONDITIONS]: + entities.append(LiquidCheckDevice(coordinator, sensor, entry.title)) + async_add_entities(entities) + + +class LiquidCheckDevice(CoordinatorEntity): + """Representation of a LiquidCheck device.""" + + def __init__(self, coordinator, sensor_type, name): + """Initialize the sensor.""" + super().__init__(coordinator) + self._sensor = SENSOR_TYPES[sensor_type][0] + self._name = name + self.type = sensor_type + self._data_source = SENSOR_TYPES[sensor_type][3] + # json path for data + self._data_path = SENSOR_TYPES[sensor_type][4] + self.coordinator = coordinator + self._last_value = None + self._unit_of_measurement = SENSOR_TYPES[self.type][1] + self._icon = SENSOR_TYPES[self.type][2] + self.serial_number = self.coordinator.data["device"]["uuid"] + self.model = self.coordinator.data["device"]["name"] + _LOGGER.debug(self.coordinator) + + def getDataByPath(dataObject, jsonPath): + keys = jsonPath.split('/') + value = dataObject + for key in keys: + value = value.get(key) + if value is None: + return None # Schlüssel nicht gefunden, gib None zurück + return value + + @property + def name(self): + """Return the name of the sensor.""" + return f"{self._name} {self._sensor}" + + @property + def state(self): + """Return the state of the device.""" + try: + state = self.getDataByPath(self.coordinator.data[self._data_source], self._data_path) + + self._last_value = state + except Exception as ex: + _LOGGER.error(ex) + state = self._last_value + + return state + + @property + def unit_of_measurement(self): + """Return the unit of measurement this sensor expresses itself in.""" + return self._unit_of_measurement + + @property + def icon(self): + """Return icon.""" + return self._icon + + @property + def unique_id(self): + """Return unique id based on device serial and variable.""" + return "{} {}".format(self.serial_number, self._sensor) + + @property + def device_info(self): + """Return information about the device.""" + return { + "identifiers": {(DOMAIN, self.serial_number)}, + "name": self._name, + "manufacturer": "SI-Elektronik GmbH", + "model": self.model, + } \ No newline at end of file diff --git a/custom_components/liquid-check/strings.json b/custom_components/liquid-check/strings.json new file mode 100644 index 0000000..766d477 --- /dev/null +++ b/custom_components/liquid-check/strings.json @@ -0,0 +1,20 @@ +{ + "title": "LiquidCheck", + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/custom_components/liquid-check/translations/en.json b/custom_components/liquid-check/translations/en.json new file mode 100644 index 0000000..8205d54 --- /dev/null +++ b/custom_components/liquid-check/translations/en.json @@ -0,0 +1,18 @@ +{ + "config": { + "title": "Liquid-Check", + "step": { + "user": { + "data": { + "host": "The ip address of the liquid-check device" + } + } + }, + "error": { + "host_exists": "This host is already configured" + }, + "abort": { + "host_exists": "This host is already configured" + } + } +} diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..e33ab7f --- /dev/null +++ b/hacs.json @@ -0,0 +1,7 @@ +{ + "name": "Liquid-Check", + "content_in_root": false, + "render_readme": true, + "domains": ["sensor"], + "homeassistant": "2023.1" +}