diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..9f65311 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +custom: ["buymeacoffee.com/PiotrMachowski", "paypal.me/PiMachowski"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6d56b8e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Piotr Machowski + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b3b533f --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +[![hacs_badge](https://img.shields.io/badge/HACS-Custom-orange.svg)](https://hacs.xyz/docs/faq/custom_repositories) +[![buymeacoffee_badge](https://img.shields.io/badge/Donate-Buy%20Me%20a%20Coffee-ff813f?style=flat)](https://www.buymeacoffee.com/PiotrMachowski) +[![paypalme_badge](https://img.shields.io/badge/Donate-PayPal-0070ba?style=flat)](https://paypal.me/PiMachowski) +![GitHub All Releases](https://img.shields.io/github/downloads/PiotrMachowski/Home-Assistant-custom-components-Dom-5/total) + +# Dom 5 Sensor + +This custom integration retrieves data from [Dom 5](https://www.sacer.pl/dom5) - housing cooperative management system. + +## Installation + +### Using [HACS](https://hacs.xyz/) (recommended) + +This integration can be added to HACS as a [custom repository](https://hacs.xyz/docs/faq/custom_repositories): +* URL: `https://github.com/PiotrMachowski/Home-Assistant-custom-components-Dom-5` +* Category: `Integration` + +After adding a custom repository you can use HACS to install this integration using user interface. + +### Manual + +To install this integration manually you have to download [*dom_5.zip*](https://github.com/PiotrMachowski/Home-Assistant-custom-components-Dom-5/releases/latest/download/dom_5.zip) extract its contents to `config/custom_components/dom_5` directory: +```bash +mkdir -p custom_components/dom_5 +cd custom_components/dom_5 +wget https://github.com/PiotrMachowski/Home-Assistant-custom-components-Dom-5/releases/latest/download/dom_5.zip +unzip dom_5.zip +rm dom_5.zip +``` + +## Configuration + +### Config flow (recommended) + +To configure this integration go to: _Configuration_ -> _Integrations_ -> _Add integration_ -> _Dom 5_. + +You can also use following [My Home Assistant](http://my.home-assistant.io/) link + +[![Open your Home Assistant instance and start setting up a new integration.](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start/?domain=dom_5) + +### Manual - yaml +| Key | Type | Required | Value | Description | +|---|---|---|---|---| +| `platform` | string | true | `dom_5` | Name of a platform | +| `name` | string | false | | Desired name of a entity | +| `url` | string | true | | URL of system (in format: `https://dom5.pl` | +| `username` | string | true | | Username in Dom 5 system | +| `password` | string | true | | Password in Dom 5 system | + +#### Example configuration +```yaml +sensor: + - platform: dom_5 + url: "https://dom5.pl" + username: "123456" + password: "SecretPassword" +``` + + +Buy Me A Coffee diff --git a/custom_components/dom_5/__init__.py b/custom_components/dom_5/__init__.py new file mode 100644 index 0000000..9068a82 --- /dev/null +++ b/custom_components/dom_5/__init__.py @@ -0,0 +1,42 @@ +import asyncio + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .connector import Dom5Connector +from .const import * + + +async def async_setup(hass: HomeAssistant, config: dict): + hass.data.setdefault(DOMAIN, {}) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + url = entry.data.get(CONF_URL) + username = entry.data.get(CONF_USERNAME) + password = entry.data.get(CONF_PASSWORD) + hass.data[DOMAIN][entry.entry_id] = Dom5Connector(url, username, password) + + 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_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/custom_components/dom_5/config_flow.py b/custom_components/dom_5/config_flow.py new file mode 100644 index 0000000..c2782c3 --- /dev/null +++ b/custom_components/dom_5/config_flow.py @@ -0,0 +1,60 @@ +import logging + +import voluptuous as vol + +from homeassistant import config_entries, core, exceptions +from homeassistant.core import HomeAssistant + +from .const import * +from .connector import test_connection + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema({ + vol.Required(CONF_URL): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str +}) + + +async def validate_input(hass: HomeAssistant, data: dict): + if len(data.get(CONF_URL)) < 3: + raise InvalidUrl + + url = data.get(CONF_URL) + username = data.get(CONF_USERNAME) + password = data.get(CONF_PASSWORD) + + try: + result = await hass.async_add_executor_job(test_connection, url, username, password) + if not result: + raise InvalidCredentials + except: + raise InvalidUrl + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def async_step_user(self, user_input=None): + errors = {} + if user_input is not None: + try: + await validate_input(self.hass, user_input) + title = f"{user_input.get(CONF_USERNAME)} ({user_input.get(CONF_URL)})" + return self.async_create_entry(title=title, data=user_input) + except InvalidCredentials: + errors[CONF_PASSWORD] = "invalid_auth" + except InvalidUrl: + errors[CONF_URL] = "cannot_connect" + + return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA, errors=errors) + + +class InvalidCredentials(exceptions.HomeAssistantError): + pass + + +class InvalidUrl(exceptions.HomeAssistantError): + pass diff --git a/custom_components/dom_5/connector.py b/custom_components/dom_5/connector.py new file mode 100644 index 0000000..f48115c --- /dev/null +++ b/custom_components/dom_5/connector.py @@ -0,0 +1,170 @@ +from datetime import timedelta + +from homeassistant.util import Throttle +import logging +from typing import Any, Callable, List, Optional + +import requests +import html2text + +from requests import Response, Session + +_LOGGER = logging.getLogger(__name__) + +THROTTLE_INTERVAL = timedelta(minutes=30) + + +def test_connection(url: str, username: str, password: str) -> bool: + sensor = Dom5Connector(url, username, password) + return sensor.test_connection() + + +class Dom5Data: + messages_number: Optional[int] + last_messages_titles: Optional[List[str]] + last_message_id: Optional[str] + last_message_title: Optional[str] + last_message_body: Optional[str] + last_message_date: Optional[str] + announcements_number: Optional[int] + last_announcements_titles: Optional[List[str]] + last_announcement_id: Optional[str] + last_announcement_title: Optional[str] + last_announcement_body: Optional[str] + last_announcement_date: Optional[str] + arrear: Optional[float] + overpayment: Optional[float] + balance: Optional[float] + + def __init__(self): + self.messages_number = None + self.last_messages_titles = None + self.last_message_id = None + self.last_message_title = None + self.last_message_body = None + self.last_message_date = None + self.announcements_number = None + self.last_announcements_titles = None + self.last_announcement_id = None + self.last_announcement_title = None + self.last_announcement_body = None + self.last_announcement_date = None + self.arrear = None + self.overpayment = None + self.balance = None + + def set_messages(self, messages_response: Response): + if not Dom5Data.is_valid(messages_response): + return + self.messages_number, self.last_message_id, self.last_messages_titles = \ + Dom5Data.parse_communications(messages_response.json()) + + def set_last_message(self, last_message_response: Response): + if not Dom5Data.is_valid(last_message_response): + return + self.last_message_title, self.last_message_body, self.last_message_date = \ + Dom5Data.parse_specific_communication(last_message_response.json()) + + def set_announcements(self, announcements_response: Response): + if not Dom5Data.is_valid(announcements_response): + return + self.announcements_number, self.last_announcement_id, self.last_announcements_titles = \ + Dom5Data.parse_communications(announcements_response.json()) + + def set_last_announcement(self, last_announcement_response: Response): + if not Dom5Data.is_valid(last_announcement_response): + return + self.last_announcement_title, self.last_announcement_body, self.last_announcement_date = \ + Dom5Data.parse_specific_communication(last_announcement_response.json()) + + def set_finances(self, finances_response: Response): + if not Dom5Data.is_valid(finances_response): + return + json = finances_response.json()["data"] + self.arrear = json["Zaleglosci"] + self.overpayment = json["Nadplaty"] + self.balance = self.overpayment - self.arrear + + @staticmethod + def is_valid(response: Response): + return response.status_code == 200 and "status" in response.json() and response.json()["status"] == "success" + + @staticmethod + def parse_communications(json: Any): + communications_number = len(json["data"]) + last_communication_id = None if communications_number == 0 else json["data"][0]["Ident"] + last_titles = list(map(lambda r: r["Tytul"], json["data"]))[0:10] + return communications_number, last_communication_id, last_titles + + @staticmethod + def parse_specific_communication(json: Any): + title = json["data"]["Tytul"] + message = html2text.html2text(json["data"]["Tresc"]) + date = json["data"]["Data"] + return title, message, date + + +class Dom5Connector: + data: Optional[Dom5Data] + + def __init__(self, url: str, username: str, password: str): + self._base_url = url + self._username = username + self._password = password + self.data = Dom5Data() + self.update = Throttle(THROTTLE_INTERVAL)(self._update) + + @property + def url(self) -> str: + return self._base_url + + @property + def username(self) -> str: + return self._username + + def _update(self): + session = self._login() + if session is None: + _LOGGER.error('Failed to login') + return + try: + data = Dom5Data() + messages_response = session.get(self._url('/iokEwid/DajKorespPoz')) + data.set_messages(messages_response) + if data.last_message_id is not None: + last_message_response = session.get( + self._url(f'/iokEwid/DajSzczegKorespPoz?Ident={data.last_message_id}')) + data.set_last_message(last_message_response) + announcements_response = session.get(self._url('/iok/DajOgloszenia')) + data.set_announcements(announcements_response) + if data.last_announcement_id is not None: + last_announcement_response = session.get( + self._url(f'/iok/DajOgloszenie?Ident={data.last_announcement_id}')) + data.set_last_announcement(last_announcement_response) + finances_response = session.get(self._url('/iokRozr/DajListeRozrachFin')) + data.set_finances(finances_response) + self.data = data + finally: + self._logout(session) + + def _login(self) -> Optional[Session]: + session = requests.session() + login = session.post(url=self._url('/iok/Zaloguj'), + data={"Ident": self._username, "Haslo": self._password}, + headers={"Referer": self._url("/content/InetObsKontr/login")}) + if Dom5Data.is_valid(login): + return session + return None + + def _logout(self, session: Session): + session.post(self._url('/iok/Wyloguj')) + + def _url(self, path: str): + return f'{self._base_url}{path}' + + def test_connection(self) -> bool: + session = self._login() + if session is not None: + self._logout(session) + return True + return False diff --git a/custom_components/dom_5/const.py b/custom_components/dom_5/const.py new file mode 100644 index 0000000..ebf265b --- /dev/null +++ b/custom_components/dom_5/const.py @@ -0,0 +1,6 @@ +from homeassistant.const import CONF_NAME, CONF_USERNAME, CONF_PASSWORD, CONF_URL + +DOMAIN = "dom_5" +DEFAULT_NAME = 'Dom 5' + +PLATFORMS = ["sensor"] diff --git a/custom_components/dom_5/manifest.json b/custom_components/dom_5/manifest.json new file mode 100644 index 0000000..d764c83 --- /dev/null +++ b/custom_components/dom_5/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "dom_5", + "name": "Dom 5", + "documentation": "https://github.com/PiotrMachowski/Home-Assistant-custom-components-Dom-5", + "issue_tracker": "https://github.com/PiotrMachowski/Home-Assistant-custom-components-Dom-5/issues", + "dependencies": [], + "codeowners": ["@PiotrMachowski"], + "requirements": ["requests", "html2text"], + "version": "v1.0.0", + "config_flow": true +} diff --git a/custom_components/dom_5/sensor.py b/custom_components/dom_5/sensor.py new file mode 100644 index 0000000..8154a47 --- /dev/null +++ b/custom_components/dom_5/sensor.py @@ -0,0 +1,168 @@ +import logging +from datetime import timedelta +from typing import Any, Callable, Dict, Optional + +import homeassistant.helpers.config_validation as cv +import voluptuous as vol +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import StateType + +from .connector import Dom5Connector +from .const import * + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(hours=1) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_URL): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, +}) + + +def messages_state_extractor(connector: Dom5Connector) -> Optional[int]: + return connector.data.messages_number + + +def messages_attributes_extractor(connector: Dom5Connector) -> dict: + return { + "titles": connector.data.last_messages_titles + } + + +def last_message_state_extractor(connector: Dom5Connector) -> Optional[str]: + return connector.data.last_message_title + + +def last_message_attributes_extractor(connector: Dom5Connector) -> dict: + return { + "title": connector.data.last_message_title, + "body": connector.data.last_message_body, + "date": connector.data.last_message_date, + "id": connector.data.last_message_id + } + + +def announcements_state_extractor(connector: Dom5Connector) -> Optional[int]: + return connector.data.announcements_number + + +def announcements_attributes_extractor(connector: Dom5Connector) -> dict: + return { + "titles": connector.data.last_announcements_titles + } + + +def last_announcement_state_extractor(connector: Dom5Connector) -> Optional[str]: + return connector.data.last_announcement_title + + +def last_announcement_attributes_extractor(connector: Dom5Connector) -> dict: + return { + "title": connector.data.last_announcement_title, + "body": connector.data.last_announcement_body, + "date": connector.data.last_announcement_date, + "id": connector.data.last_announcement_id + } + + +def finances_state_extractor(connector: Dom5Connector) -> float: + return connector.data.balance + + +def finances_attributes_extractor(connector: Dom5Connector) -> dict: + return { + "arrear": connector.data.arrear, + "overpayment": connector.data.overpayment + } + + +SENSOR_TYPES = { + "messages": [" ", messages_state_extractor, messages_attributes_extractor], + "last_message": [None, last_message_state_extractor, last_message_attributes_extractor], + "announcements": [" ", announcements_state_extractor, announcements_attributes_extractor], + "last_announcement": [None, last_announcement_state_extractor, last_announcement_attributes_extractor], + "finances": ["zł", finances_state_extractor, finances_attributes_extractor] +} + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + name = config.get(CONF_NAME) + url = config.get(CONF_URL) + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + connector = Dom5Connector(url, username, password) + if not hass.async_add_executor_job(connector.test_connection): + raise Exception('Invalid configuration') + await hass.async_add_executor_job(connector.update) + entities = [] + for sensor_type in SENSOR_TYPES: + entities.append(Dom5Sensor(name, connector, sensor_type)) + async_add_entities(entities, True) + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_devices: Callable): + connector = hass.data[DOMAIN][config_entry.entry_id] + devices = [] + await hass.async_add_executor_job(connector.update) + for sensor_type in SENSOR_TYPES: + devices.append(Dom5ConfigFlowSensor(DEFAULT_NAME, connector, sensor_type)) + async_add_devices(devices) + + +class Dom5Sensor(Entity): + def __init__(self, name: str, connector: Dom5Connector, sensor_type: str): + self._name = name + self._connector = connector + self._sensor_type = sensor_type + + @property + def name(self): + return f'{self._name} {self._sensor_type} - {self._connector.username}' + + @property + def icon(self): + return "mdi:home-city" + + @property + def unit_of_measurement(self) -> Optional[str]: + return SENSOR_TYPES[self._sensor_type][0] + + @property + def state(self) -> StateType: + return SENSOR_TYPES[self._sensor_type][1](self._connector) + + @property + def device_state_attributes(self) -> Optional[Dict[str, Any]]: + return SENSOR_TYPES[self._sensor_type][2](self._connector) + + def update(self): + self._connector.update() + + @property + def unique_id(self): + return f"{DOMAIN}-yaml-{self._name}-{self._connector.url}-{self._connector.username}-{self._sensor_type}" + + +class Dom5ConfigFlowSensor(Dom5Sensor): + def __init__(self, name: str, connector: Dom5Connector, sensor_type: str): + super().__init__(name, connector, sensor_type) + + @property + def device_info(self): + return { + "identifiers": {(DOMAIN, self._connector.url, self._connector.username)}, + "name": f"{self._connector.url}: {self._connector.username}", + "manufacturer": "Sacer", + "via_device": None, + } + + @property + def unique_id(self): + return f"{DOMAIN}-{self._connector.url}-{self._connector.username}-{self._sensor_type}" + diff --git a/custom_components/dom_5/strings.json b/custom_components/dom_5/strings.json new file mode 100644 index 0000000..bb7527a --- /dev/null +++ b/custom_components/dom_5/strings.json @@ -0,0 +1,23 @@ +{ + "title" : "Dom 5", + "config": { + "abort": { + "already_configured": "[This integration is already configured]" + }, + "error": { + "cannot_connect": "[Invalid URL]", + "invalid_auth": "[Invalid username or password]" + }, + "step": { + "user": { + "title": "Dom 5", + "description": "Please provide URL and credentials to Dom 5 system", + "data": { + "url": "URL", + "password": "Password", + "username": "Username" + } + } + } + } +} \ No newline at end of file diff --git a/custom_components/dom_5/translations/en.json b/custom_components/dom_5/translations/en.json new file mode 100644 index 0000000..bb7527a --- /dev/null +++ b/custom_components/dom_5/translations/en.json @@ -0,0 +1,23 @@ +{ + "title" : "Dom 5", + "config": { + "abort": { + "already_configured": "[This integration is already configured]" + }, + "error": { + "cannot_connect": "[Invalid URL]", + "invalid_auth": "[Invalid username or password]" + }, + "step": { + "user": { + "title": "Dom 5", + "description": "Please provide URL and credentials to Dom 5 system", + "data": { + "url": "URL", + "password": "Password", + "username": "Username" + } + } + } + } +} \ No newline at end of file diff --git a/custom_components/dom_5/translations/pl.json b/custom_components/dom_5/translations/pl.json new file mode 100644 index 0000000..1204a8d --- /dev/null +++ b/custom_components/dom_5/translations/pl.json @@ -0,0 +1,23 @@ +{ + "title" : "Dom 5", + "config": { + "abort": { + "already_configured": "[Ta integracja już jest skonfigurowana]" + }, + "error": { + "cannot_connect": "[Nieprawidłowy adres]", + "invalid_auth": "[Nieprawidłowy login bądź hasło]" + }, + "step": { + "user": { + "title": "Dom 5", + "description": "Podaj adres oraz dane dostępowe do serwisu Dom 5", + "data": { + "url": "Adres", + "password": "Hasło", + "username": "Login" + } + } + } + } +} \ No newline at end of file diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..e1c2cae --- /dev/null +++ b/hacs.json @@ -0,0 +1,8 @@ +{ + "name": "Dom 5", + "render_readme": true, + "domains": ["sensor"], + "zip_release": true, + "filename": "dom_5.zip", + "country": "PL" +} \ No newline at end of file