From 928b6ccfb98682477a6740a3fe579700e0533722 Mon Sep 17 00:00:00 2001
From: Philipp Piwo <github@piwo.space>
Date: Sun, 7 Apr 2024 20:15:31 +0200
Subject: [PATCH] add infloor valve, blind and sensor entities, adjustments for
 oauth handling of different client ids (#220)

---
 .gitignore                       |  1 +
 iolite_client/client.py          | 11 +++---
 iolite_client/entity.py          | 44 +++++++++++++++++++++++-
 iolite_client/entity_factory.py  | 59 ++++++++++++++++++++++++++------
 iolite_client/oauth_handler.py   | 22 ++++++------
 iolite_client/request_handler.py |  6 ++--
 scripts/example.py               | 21 +++++++++---
 7 files changed, 131 insertions(+), 33 deletions(-)

diff --git a/.gitignore b/.gitignore
index 94923b8..07c43ae 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,7 @@
 # Secrets
 /.env
 access_token.json
+access_token_*.json
 
 # Cache
 __pycache__
diff --git a/iolite_client/client.py b/iolite_client/client.py
index 8790d59..86fea2e 100755
--- a/iolite_client/client.py
+++ b/iolite_client/client.py
@@ -304,9 +304,12 @@ def discover(self):
         """Discovers the entities registered within the heating system."""
         asyncio.run(self.async_discover())
 
-    async def async_set_temp(self, device, temp: float):
-        request = self.request_handler.get_action_request(device, temp)
+    async def async_set_property(self, device, property: str, value: float):
+        request = self.request_handler.get_action_request(device, property, value)
         await asyncio.create_task(self._fetch_application([request]))
 
-    def set_temp(self, device, temp: float):
-        asyncio.run(self.async_set_temp(device, temp))
+    def set_temp(self, device, value: float):
+        asyncio.run(self.async_set_property(device, 'heatingTemperatureSetting', value))
+
+    def set_blind_level(self, device, value: float):
+        asyncio.run(self.async_set_property(device, 'blindLevel', value))
diff --git a/iolite_client/entity.py b/iolite_client/entity.py
index 39692a1..257da07 100644
--- a/iolite_client/entity.py
+++ b/iolite_client/entity.py
@@ -29,6 +29,32 @@ def get_type(cls) -> str:
 class Switch(Device):
     pass
 
+class Blind(Device):
+    def __init__(
+        self,
+        identifier: str,
+        name: str,
+        place_identifier: str,
+        manufacturer: str,
+        blind_level: int,
+    ):
+        super().__init__(identifier, name, place_identifier, manufacturer)
+        self.blind_level = blind_level
+
+class HumiditySensor(Device):
+    def __init__(
+        self,
+        identifier: str,
+        name: str,
+        place_identifier: str,
+        manufacturer: str,
+        current_env_temp: float,
+        humidity_level: float,
+    ):
+        super().__init__(identifier, name, place_identifier, manufacturer)
+        self.current_env_temp = current_env_temp
+        self.humidity_level = humidity_level
+
 
 class Lamp(Device):
     pass
@@ -53,6 +79,22 @@ def __init__(
         self.current_env_temp = current_env_temp
 
 
+class InFloorValve(Device):
+    def __init__(
+        self,
+        identifier: str,
+        name: str,
+        place_identifier: str,
+        manufacturer: str,
+        current_env_temp: float,
+        heating_temperature_setting: float,
+        device_status: str,
+    ):
+        super().__init__(identifier, name, place_identifier, manufacturer)
+        self.heating_temperature_setting = heating_temperature_setting
+        self.device_status = device_status
+        self.current_env_temp = current_env_temp
+
 class Heating(Entity):
     def __init__(
         self,
@@ -60,7 +102,7 @@ def __init__(
         name: str,
         current_temp: float,
         target_temp: float,
-        window_open: bool,
+        window_open: Optional[bool],
     ):
         super().__init__(identifier, name)
         self.current_temp = current_temp
diff --git a/iolite_client/entity_factory.py b/iolite_client/entity_factory.py
index d927b23..e65e2e9 100644
--- a/iolite_client/entity_factory.py
+++ b/iolite_client/entity_factory.py
@@ -1,4 +1,4 @@
-from iolite_client.entity import Device, Heating, Lamp, RadiatorValve, Room, Switch
+from iolite_client.entity import Device, Heating, Lamp, RadiatorValve, InFloorValve, Room, Switch, Blind, HumiditySensor
 from iolite_client.exceptions import UnsupportedDeviceError
 
 
@@ -42,14 +42,15 @@ def create_heating(payload: dict) -> Heating:
     return Heating(
         payload["id"],
         payload["name"],
-        payload["currentTemperature"],
+        payload.get("currentTemperature", None),
         payload["targetTemperature"],
-        payload["windowOpen"],
+        payload.get("windowOpen", None),
     )
 
 
 def _create_device(identifier: str, type_name: str, payload: dict):
     place_identifier = payload["placeIdentifier"]
+    model_name = payload["modelName"]
     if type_name == "Lamp":
         return Lamp(
             identifier,
@@ -73,21 +74,59 @@ def _create_device(identifier: str, type_name: str, payload: dict):
         )
     elif type_name == "Heater":
         properties = payload["properties"]
+        current_env_temp = _get_prop(properties, "currentEnvironmentTemperature")
+
+        if model_name.startswith("38de6001c3ad"):
+            heating_temperature_setting = _get_prop(properties, "heatingTemperatureSetting")
+            device_status = _get_prop(properties, "deviceStatus")
+            return InFloorValve(
+                identifier,
+                payload["friendlyName"],
+                place_identifier,
+                payload["manufacturer"],
+                current_env_temp,
+                heating_temperature_setting,
+                device_status,
+            )
+
+        else:
+            battery_level = _get_prop(properties, "batteryLevel")
+            heating_mode = _get_prop(properties, "heatingMode")
+            valve_position = _get_prop(properties, "valvePosition")
+
+            return RadiatorValve(
+                identifier,
+                payload["friendlyName"],
+                place_identifier,
+                payload["manufacturer"],
+                current_env_temp,
+                battery_level,
+                heating_mode,
+                valve_position,
+            )
+    elif type_name == "Blind":
+        properties = payload["properties"]
+        blind_level = _get_prop(properties, "blindLevel")
 
+        return Blind(
+            identifier,
+            payload["friendlyName"],
+            place_identifier,
+            payload["manufacturer"],
+            blind_level
+        )
+    elif type_name == "HumiditySensor":
+        properties = payload["properties"]
         current_env_temp = _get_prop(properties, "currentEnvironmentTemperature")
-        battery_level = _get_prop(properties, "batteryLevel")
-        heating_mode = _get_prop(properties, "heatingMode")
-        valve_position = _get_prop(properties, "valvePosition")
+        humidity_level = _get_prop(properties, "humidityLevel")
 
-        return RadiatorValve(
+        return HumiditySensor(
             identifier,
             payload["friendlyName"],
             place_identifier,
             payload["manufacturer"],
             current_env_temp,
-            battery_level,
-            heating_mode,
-            valve_position,
+            humidity_level
         )
     else:
         raise UnsupportedDeviceError(type_name, identifier, payload)
diff --git a/iolite_client/oauth_handler.py b/iolite_client/oauth_handler.py
index 828fd2a..3e4b6cf 100644
--- a/iolite_client/oauth_handler.py
+++ b/iolite_client/oauth_handler.py
@@ -16,10 +16,10 @@
 
 class OAuthHandlerHelper:
     @staticmethod
-    def get_access_token_query(code: str, name: str) -> str:
+    def get_access_token_query(code: str, name: str, client_id: str) -> str:
         return urlencode(
             {
-                "client_id": CLIENT_ID,
+                "client_id": client_id,
                 "grant_type": "authorization_code",
                 "code": code,
                 "name": name,
@@ -27,10 +27,10 @@ def get_access_token_query(code: str, name: str) -> str:
         )
 
     @staticmethod
-    def get_new_access_token_query(refresh_token: str) -> str:
+    def get_new_access_token_query(refresh_token: str, client_id: str) -> str:
         return urlencode(
             {
-                "client_id": CLIENT_ID,
+                "client_id": client_id,
                 "grant_type": "refresh_token",
                 "refresh_token": refresh_token,
             }
@@ -53,9 +53,10 @@ def add_expires_at(token: dict) -> dict:
 
 
 class OAuthHandler:
-    def __init__(self, username: str, password: str):
+    def __init__(self, username: str, password: str, client_id: str = CLIENT_ID):
         self.username = username
         self.password = password
+        self.client_id = client_id
 
     def get_access_token(self, code: str, name: str) -> dict:
         """
@@ -64,7 +65,7 @@ def get_access_token(self, code: str, name: str) -> dict:
         :param name: The name of the device being paired
         :return:
         """
-        query = OAuthHandlerHelper.get_access_token_query(code, name)
+        query = OAuthHandlerHelper.get_access_token_query(code, name, self.client_id)
         response = requests.post(
             f"{BASE_URL}/ui/token?{query}", auth=(self.username, self.password)
         )
@@ -77,7 +78,7 @@ def get_new_access_token(self, refresh_token: str) -> dict:
         :param refresh_token: The refresh token
         :return: dict containing access token, and new refresh token
         """
-        query = OAuthHandlerHelper.get_new_access_token_query(refresh_token)
+        query = OAuthHandlerHelper.get_new_access_token_query(refresh_token, self.client_id)
         response = requests.post(
             f"{BASE_URL}/ui/token?{query}", auth=(self.username, self.password)
         )
@@ -100,11 +101,12 @@ def get_sid(self, access_token: str) -> str:
 
 class AsyncOAuthHandler:
     def __init__(
-        self, username: str, password: str, web_session: aiohttp.ClientSession
+        self, username: str, password: str, web_session: aiohttp.ClientSession, client_id: str = CLIENT_ID
     ):
         self.username = username
         self.password = password
         self.web_session = web_session
+        self.client_id = client_id
 
     async def get_access_token(self, code: str, name: str) -> dict:
         """
@@ -113,7 +115,7 @@ async def get_access_token(self, code: str, name: str) -> dict:
         :param name: The name of the device being paired
         :return:
         """
-        query = OAuthHandlerHelper.get_access_token_query(code, name)
+        query = OAuthHandlerHelper.get_access_token_query(code, name, self.client_id)
         response = await self.web_session.post(
             f"{BASE_URL}/ui/token?{query}",
             auth=aiohttp.BasicAuth(self.username, self.password),
@@ -127,7 +129,7 @@ async def get_new_access_token(self, refresh_token: str) -> dict:
         :param refresh_token: The refresh token
         :return: dict containing access token, and new refresh token
         """
-        query = OAuthHandlerHelper.get_new_access_token_query(refresh_token)
+        query = OAuthHandlerHelper.get_new_access_token_query(refresh_token, self.client_id)
         response = await self.web_session.post(
             f"{BASE_URL}/ui/token?{query}",
             auth=aiohttp.BasicAuth(self.username, self.password),
diff --git a/iolite_client/request_handler.py b/iolite_client/request_handler.py
index 22155d8..daa300f 100644
--- a/iolite_client/request_handler.py
+++ b/iolite_client/request_handler.py
@@ -34,18 +34,18 @@ def get_subscribe_request(self, object_query: str) -> dict:
 
         return request
 
-    def get_action_request(self, device_id: str, temp: float) -> dict:
+    def get_action_request(self, device_id: str, property: str, value: float) -> dict:
         request = self._build_request(
             ClassMap.ActionRequest.value,
             {
                 "modelID": "http://iolite.de#Environment",
                 "class": ClassMap.ActionRequest.value,
-                "objectQuery": f"devices[id='{device_id}']/properties[name='heatingTemperatureSetting']",
+                "objectQuery": f"devices[id='{device_id}']/properties[name='{property}']",
                 "actionName": "requestValueUpdate",
                 "parameters": [
                     {
                         "class": "ValueParameter",
-                        "value": temp,
+                        "value": value,
                     }
                 ],
             },
diff --git a/scripts/example.py b/scripts/example.py
index ab88f10..54e1659 100644
--- a/scripts/example.py
+++ b/scripts/example.py
@@ -4,7 +4,7 @@
 from environs import Env
 
 from iolite_client.client import Client
-from iolite_client.entity import RadiatorValve
+from iolite_client.entity import RadiatorValve, Blind, HumiditySensor, InFloorValve
 from iolite_client.oauth_handler import LocalOAuthStorage, OAuthHandler, OAuthWrapper
 
 env = Env()
@@ -12,6 +12,7 @@
 
 USERNAME = env("HTTP_USERNAME")
 PASSWORD = env("HTTP_PASSWORD")
+CLIENT_ID = env("CLIENT_ID")
 CODE = env("CODE")
 NAME = env("NAME")
 LOG_LEVEL = env.log_level("LOG_LEVEL", logging.INFO)
@@ -21,7 +22,7 @@
 
 # Get SID
 oauth_storage = LocalOAuthStorage(".")
-oauth_handler = OAuthHandler(USERNAME, PASSWORD)
+oauth_handler = OAuthHandler(USERNAME, PASSWORD, CLIENT_ID)
 oauth_wrapper = OAuthWrapper(oauth_handler, oauth_storage)
 
 access_token = oauth_storage.fetch_access_token()
@@ -34,7 +35,8 @@
 print("------------------")
 print(f"URL: https://remote.iolite.de/ui/?SID={sid}")
 print(f"User: {USERNAME}")
-print(f"Pass: {PASSWORD}")
+print(f"Password: {PASSWORD}")
+print(f"Client Id: {CLIENT_ID}")
 print("------------------")
 
 # Init client
@@ -47,17 +49,26 @@
 logger.info("Finished discovery")
 
 for room in client.discovered.get_rooms():
-    print(f"{room.name} has {len(room.devices)} devices")
+
+    print(f"\n{room.name} has {len(room.devices)} devices")
     if room.heating:
         print(
             f"Current temp: {room.heating.current_temp}, target: {room.heating.target_temp}"
         )
 
     for device in room.devices.values():
-        print(f"- {device.name}")
+        print(f"- {device.name} {device.get_type()}")
         if isinstance(device, RadiatorValve):
             print(f"  - current: {device.current_env_temp}")
             print(f"  - mode: {device.heating_mode}")
+        if isinstance(device, InFloorValve):
+            print(f"  - current: {device.current_env_temp}")
+            print(f"  - setting: {device.heating_temperature_setting}")
+        if isinstance(device, Blind):
+            print(f"  - blind level: {device.blind_level}")
+        if isinstance(device, HumiditySensor):
+            print(f"  - temp: {device.current_env_temp}")
+            print(f"  - humidity: {device.humidity_level}")
 
 bathroom = client.discovered.find_room_by_name("Bathroom")