commit 71893eef75b1f21dc964bcf65ae2788c368fcac3 Author: Ole Bittner Date: Thu Apr 4 23:04:53 2024 +0200 first version diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5dc8d96 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/* +!.gitignore +!custom_components/ +!custom_components/reos_integration/** +__pycache__/ +*.pyc diff --git a/custom_components/reos_integration/__init__.py b/custom_components/reos_integration/__init__.py new file mode 100755 index 0000000..6cec6e6 --- /dev/null +++ b/custom_components/reos_integration/__init__.py @@ -0,0 +1,43 @@ +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +import logging + +from .reos_api import ReosApi +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +async def async_setup(hass: HomeAssistant, entry: ConfigEntry) -> bool: + logging.info(msg="Reos async_setup") + hass.data[DOMAIN] = {} + # Return boolean to indicate that initialization was successful. + return True + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + logging.info(msg="Reos async_setup_entry") + host = entry.data["host"] + username = entry.data["username"] + password = entry.data["password"] + client_id = entry.data["client_id"] + client_secret = entry.data["client_secret"] + access_token = None + if "access_token" in entry.data: + access_token = entry.data["access_token"] + refresh_token = None + if "refresh_token" in entry.data: + refresh_token = entry.data["refresh_token"] + + api = ReosApi(host, username, password, client_id, client_secret, access_token, refresh_token) + + async def __update_entry(access, refresh): + new = {**entry.data} + new["access_token"] = access + new["refresh_token"] = refresh + hass.config_entries.async_update_entry(entry, data=new) + + api.set_update_token_callback(__update_entry) + + hass.data[DOMAIN][username] = api + + await hass.config_entries.async_forward_entry_setup(entry, "lock") + return True diff --git a/custom_components/reos_integration/config_flow.py b/custom_components/reos_integration/config_flow.py new file mode 100644 index 0000000..48865a9 --- /dev/null +++ b/custom_components/reos_integration/config_flow.py @@ -0,0 +1,23 @@ +import voluptuous as vol + +from homeassistant import config_entries + +from .const import DOMAIN + + +class ReosConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + VERSION = 1 + MINOR_VERSION = 1 + + async def async_step_user(self, info): + if info is not None: + return self.async_create_entry(title=info["username"], data=info) + + return self.async_show_form(step_id="user", + data_schema=vol.Schema({ + vol.Optional("host", default="feapi.reos.software"): str, + vol.Required("client_id"): str, + vol.Required("client_secret"): str, + vol.Required("username"): str, + vol.Required("password"): str, + })) diff --git a/custom_components/reos_integration/const.py b/custom_components/reos_integration/const.py new file mode 100644 index 0000000..b40d679 --- /dev/null +++ b/custom_components/reos_integration/const.py @@ -0,0 +1 @@ +DOMAIN = "reos_integration" diff --git a/custom_components/reos_integration/lock.py b/custom_components/reos_integration/lock.py new file mode 100644 index 0000000..148c48e --- /dev/null +++ b/custom_components/reos_integration/lock.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from homeassistant.components.lock import LockEntity, LockEntityFeature +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .reos_api import ReosApi, ReosLockModel + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None: + api: ReosApi = hass.data[DOMAIN][config_entry.data["username"]] + + async_add_entities(ReosLock(lock) for lock in await api.get_locks()) + +class ReosLock(LockEntity): + + def __init__(self, model: ReosLockModel) -> None: + self._model = model + self._attr_unique_id = f"reos_lock_{self._model.lock_id}" + self._attr_name = self._model.display + self._attr_is_locked = False + self._attr_device_class = "door" + self._attr_icon = "mdi:door" + + @property + def supported_features(self): + """Flag supported features.""" + return LockEntityFeature.OPEN + + @property + def device_info(self) -> DeviceInfo | None: + return None + + async def async_open(self, **kwargs) -> None: + self.hass.async_create_task(self._model.open()) + + def unlock(self, **kwargs: cv.Any) -> None: + pass + + def lock(self, **kwargs: cv.Any) -> None: + pass + diff --git a/custom_components/reos_integration/manifest.json b/custom_components/reos_integration/manifest.json new file mode 100755 index 0000000..74761d9 --- /dev/null +++ b/custom_components/reos_integration/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "reos_integration", + "name": "Reos Integration", + "version": "0.1.0", + "integration_type": "hub", + "iot_class": "cloud_polling", + "config_flow": true, + "codeowners": ["@obittner"] +} \ No newline at end of file diff --git a/custom_components/reos_integration/reos_api.py b/custom_components/reos_integration/reos_api.py new file mode 100644 index 0000000..f5ebd14 --- /dev/null +++ b/custom_components/reos_integration/reos_api.py @@ -0,0 +1,126 @@ +from homeassistant.core import HomeAssistant +import aiohttp +import asyncio +import logging +import jwt + +_LOGGER = logging.getLogger(__name__) + +TOKEN_ENDPOINT = "/oauth/token" +USER_ENDPOINT = "/feapi/v2/user" +LOCK_ENDPOINT = "/feapi/v2/lock" +LOCK_OPEN_ENDPOINT = "/feapi/v2/lock/open" +PROTOCOL = "https" + +class ReosApi: + + def __init__(self, host: str, user: str, password: str, client_id: str, client_secret: str, access_token: str, refresh_token: str) -> None: + self.host = host + self.user = user + self._id = user + self.password = password + self.client_id = client_id + self.client_secret = client_secret + self._access_token = access_token + self._refresh_token = refresh_token + self._update_token_callback = None + + def set_update_token_callback(self, callback) -> None: + self._update_token_callback = callback + + async def __call_update_token_callback(self) -> None: + if self._update_token_callback: + await self._update_token_callback(self._access_token, self._refresh_token) + + async def get_access_token(self) -> str: + if self._access_token is not None and self.__is_token_valid(self._access_token): + return self._access_token + elif self._refresh_token is not None: + access, refresh = await self.__get_access_and_refresh_token_by_refresh_token() + await self.__store_tokens(access, refresh) + return self._access_token + else: + access, refresh = await self.__get_access_and_refresh_token_by_password() + await self.__store_tokens(access, refresh) + return self._access_token + + async def __store_tokens(self, access, refresh): + self._access_token = access + self._refresh_token = refresh + await self.__call_update_token_callback() + + async def __get_access_and_refresh_token_by_password(self) -> tuple[str, str]: + async with aiohttp.ClientSession() as session, session.post(f"{PROTOCOL}://{self.host}{TOKEN_ENDPOINT}", json={ + "username": self.user, + "password": self.password, + "grant_type": "password", + "client_id": self.client_id, + "client_secret": self.client_secret, + "scope": "tss" + }) as response: + _json = await response.json() + return _json["access_token"], _json["refresh_token"] + + async def __get_access_and_refresh_token_by_refresh_token(self) -> tuple[str, str]: + async with aiohttp.ClientSession() as session, session.post(f"{PROTOCOL}://{self.host}{TOKEN_ENDPOINT}", json={ + "grant_type": "refresh_token", + "client_id": self.client_id, + "client_secret": self.client_secret, + "scope": "tss", + "refresh_token": self._refresh_token + }) as response: + _json = await response.json() + return _json["access_token"], _json["refresh_token"] + + async def open_lock(self, lock_id: int) -> None: + async with await self.__get_session() as session: + await session.post(f"{PROTOCOL}://{self.host}{LOCK_OPEN_ENDPOINT}", json={"lockId": lock_id}) + + def __is_token_valid(self, token: str) -> bool: + try: + jwt.decode(token, options={"verify_signature": False}) + return True + except jwt.ExpiredSignatureError: + return False + + async def __get_session(self) -> aiohttp.ClientSession: + token = await self.get_access_token() + return aiohttp.ClientSession(headers={"Authorization": f"Bearer {token}"}) + + async def test_connection(self) -> bool: + async with await self.__get_session() as session, session.get(f"{PROTOCOL}://{self.host}{USER_ENDPOINT}") as response: + return response.status == 200 + + async def get_locks(self) -> list["ReosLockModel"]: + async with await self.__get_session() as session, session.get(f"{PROTOCOL}://{self.host}{LOCK_ENDPOINT}") as response: + _json = await response.json() + _api_locks = _json["data"] + locks: list[ReosLockModel] = [] + for _api_lock in _api_locks: + if str(_api_lock["type"]).lower() == "lock": + _attr = _api_lock["attributes"] + _lock = ReosLockModel( + _attr["id"], + _attr["title"], + _attr["display_title"], + _attr["is_favorite"], + self + ) + locks.append(_lock) + return locks + + +class ReosLockModel: + def __init__(self, id: int, name: str, display: str, is_favorite: bool, api: ReosApi) -> None: + self._id = id + self.name = name + self.display = display + self._api = api + self.is_favorite = is_favorite + + @property + def lock_id(self) -> int: + return self._id + + async def open(self) -> None: + await self._api.open_lock(self.lock_id) diff --git a/custom_components/reos_integration/strings.json b/custom_components/reos_integration/strings.json new file mode 100644 index 0000000..483cc4b --- /dev/null +++ b/custom_components/reos_integration/strings.json @@ -0,0 +1,23 @@ +{ + "title": "Reos Integraion", + "config": { + "step": { + "user": { + "title": "Setup Reos API", + "data": { + "host": "Host", + "username": "User", + "password": "Password", + "client_id": "Client Id", + "client_secret": "Client Secret" + } + }, + "link": { + } + }, + "error": { + }, + "abort": { + } + } + } \ No newline at end of file diff --git a/custom_components/reos_integration/translations/en.json b/custom_components/reos_integration/translations/en.json new file mode 100644 index 0000000..483cc4b --- /dev/null +++ b/custom_components/reos_integration/translations/en.json @@ -0,0 +1,23 @@ +{ + "title": "Reos Integraion", + "config": { + "step": { + "user": { + "title": "Setup Reos API", + "data": { + "host": "Host", + "username": "User", + "password": "Password", + "client_id": "Client Id", + "client_secret": "Client Secret" + } + }, + "link": { + } + }, + "error": { + }, + "abort": { + } + } + } \ No newline at end of file