Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Heating scheduler #37

Merged
merged 15 commits into from
Mar 24, 2021
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,6 @@ __pycache__
# Coverage
/coverage.xml
/.coverage

# Jupyter
.ipynb_checkpoints
1 change: 1 addition & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ repos:
rev: v4.3.21
hooks:
- id: isort
additional_dependencies: [toml]
- repo: https://github.com/gvanderest/pylama-pre-commit
rev: 0.1.2
hooks:
Expand Down
5 changes: 5 additions & 0 deletions Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ pre-commit = "*"
coverage = "*"
pytest-cov = "*"
codacy-coverage = "*"
iolite = {editable = true,path = "."}
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to declare this?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For me, the example.py threw an error because iolite was not an installed package. So I added the iolite directory as an editable dev-dependency. This way you can import iolite without having the package actually installed in the environment.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahh now I see it. It feels weird having the package add itself as a dependency but I am not sure of a better way.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another solution would be to turn scripts into a module (add __init__.py) and then it can be invoked as python -m scripts.example

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That would work for the scripts, yes. I just realized the iolite = {editable = true,path = "."} is also needed to import iolite inside the notebooks, though. So I would like to keep this change.

black = "==20.8b1"
toml = "*"
responses = "*"
isort = "==4.3.21"

[packages]
websockets = "*"
Expand Down
211 changes: 189 additions & 22 deletions Pipfile.lock

Large diffs are not rendered by default.

35 changes: 28 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,37 +3,47 @@
![CI](https://github.com/inverse/python-iolite-client/workflows/CI/badge.svg)
[![Codacy Badge](https://app.codacy.com/project/badge/Grade/a38c5dbfc12247c893b4f39db4fac2b2)](https://www.codacy.com/manual/inverse/python-iolite-client?utm_source=github.com&utm_medium=referral&utm_content=inverse/python-iolite-client&utm_campaign=Badge_Grade)
[![Codacy Badge](https://app.codacy.com/project/badge/Coverage/a38c5dbfc12247c893b4f39db4fac2b2)](https://www.codacy.com/manual/inverse/python-iolite-client?utm_source=github.com&utm_medium=referral&utm_content=inverse/python-iolite-client&utm_campaign=Badge_Coverage)
[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)

WIP Python client for [IOLite's][0] remote API.

Build by reverse engineering the [Deutsche Wohnen][2] [MIA Android App][1]. I wrote a [short post][3] on how I achieved that.
Build by reverse engineering the [Deutsche Wohnen][2] [MIA Android App][1]. I wrote a [short post][3] on how I achieved
that.

The client is very incomplete and non-functional but the authentication layer and basic command models are in place.
The client is still incomplete, but the authentication layer, some basic command models and a client to change the
heating intervals are available.

## Requirements

- Python 3.6+
- Pipenv
- [Pipenv][4]

## Getting credentials

Open your Deutsche Wohnen tablet and begin pairing device process. Scan QR code and you'll get the following payload.
Open your Deutsche Wohnen tablet and begin pairing device process. Scan the QR code with your QR-Scanner and instead of
opening the QR code in your browser, copy it's content. You'll get the following payload:

```json
{"webApp":"/ui/","code":"<redacted>","basicAuth":"<redacted>"}
{
"webApp": "/ui/",
"code": "<redacted>",
"basicAuth": "<redacted>"
}
```

- `basicAuth` contains base64 encoded HTTP basic username and password. Decode this to get the `:` separated `user:pass`.
- `code` is the pairing code

You can decode the base64 encoded basicAuth information using the `scripts/get_credentials.py` script (see [development](#development) section).

## Development

- Init your pipenv environment (`pipenv install`)
- Copy `.env.example` to `.env`
- Decode credentials (`pipenv run python scripts/get_credentials.py <basic-auth-value>`)
- Add your credentials to `.env` following the above process

The [pre-commit][4] framework is used enforce some linting and style compliance on CI.
The [pre-commit][5] framework is used enforce some linting and style compliance on CI.

To get the same behaviour locally you can run `pre-commit install` within your activated venv.

Expand All @@ -45,12 +55,23 @@ Run `pipenv run python scripts/example.py` and copy the URL to your browser of c

You will need the HTTP basic credentials you defined earlier within the `.env` file.

## Usage example

A jupyter notebook showcasing the heating interval scheduler can be found in `notebooks/Heating Scheduler.ipynb`. To
firstdayofjune marked this conversation as resolved.
Show resolved Hide resolved
access the notebook run the `pipenv run jupyter notebook` command.

## Licence

MIT

[0]: https://iolite.de/

[1]: https://play.google.com/store/apps/details?id=de.iolite.client.android.mia

[2]: https://deutsche-wohnen.com/

[3]: https://www.malachisoord.com/2020/08/06/reverse-engineering-iolite-remote-api/
[4]: https://pre-commit.com/

[4]: https://pipenv.pypa.io/en/latest/#install-pipenv-today

[5]: https://pre-commit.com/
105 changes: 65 additions & 40 deletions iolite/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
import json
import logging
from base64 import b64encode
from typing import NoReturn, Optional
from collections import defaultdict
from typing import List, NoReturn, Optional

import websockets
from iolite import entity_factory
Expand All @@ -16,11 +17,11 @@ class Discovered:
""" Contains the discovered devices. """

discovered: dict
unmapped_devices: dict
unmapped_devices: defaultdict

def __init__(self):
self.discovered = {}
self.unmapped_devices = {}
self.unmapped_devices = defaultdict(list)
firstdayofjune marked this conversation as resolved.
Show resolved Hide resolved

def add_room(self, room: Room) -> NoReturn:
"""
Expand All @@ -45,31 +46,45 @@ def add_device(self, device: Device) -> NoReturn:
"""
room = self.find_room_by_identifier(device.place_identifier)

if not room:
if device.place_identifier not in self.unmapped_devices:
self.unmapped_devices[device.place_identifier] = []

if room:
room.add_device(device)
else:
self.unmapped_devices[device.place_identifier].append(device)

return

room.add_device(device)

def find_room_by_identifier(self, identifier: str) -> Optional[Room]:
"""
Find a room by the given identifier.
"""Finds a room by the given identifier.

:param identifier: The identifier
:return: The matched room or None
"""
return self._find_room_by_attribute_value("identifier", identifier)

def find_room_by_name(self, name: str) -> Optional[Room]:
"""Finds a room by the given name.

:param name: The name
:return: The matched room or None
"""
return self._find_room_by_attribute_value("name", name)

def _find_room_by_attribute_value(self, attribute: str, value: str):
firstdayofjune marked this conversation as resolved.
Show resolved Hide resolved
match = None
for room in self.discovered.values():
if room.identifier == identifier:
if getattr(room, attribute) == value:
match = room
break

return match

def get_rooms(self) -> List[Room]:
"""Returns all discovered rooms.

:return: The list of discovered Room instances
"""
return list(
filter(lambda entity: isinstance(entity, Room), self.discovered.values())
inverse marked this conversation as resolved.
Show resolved Hide resolved
)


class IOLiteClient:
""" The main client. """
Expand Down Expand Up @@ -151,34 +166,10 @@ async def __response_handler(self, response: str, websocket) -> NoReturn:
logger.info("Handling SubscribeSuccess")

if response_dict.get("requestID").startswith("places"):
for value in response_dict.get("initialValues"):
room = entity_factory.create(value)
if not isinstance(room, Room):
logger.warning(
f"Entity factory created unsupported class ({type(room).__name__})"
)
continue

self.discovered.add_room(room)
logger.info(f"Setting up {room.name} ({room.identifier})")
self.__handle_place_response(response_dict)

if response_dict.get("requestID").startswith("devices"):
for value in response_dict.get("initialValues"):
device = entity_factory.create(value)
if not isinstance(device, Device):
logger.warning(
f"Entity factory created unsupported class ({type(device).__name__})"
)
continue

self.discovered.add_device(device)
room = self.discovered.find_room_by_identifier(
device.place_identifier
)
room_name = room.name or "unknown"
logger.info(
f"Adding {type(device).__name__} ({device.name}) to {room_name}"
)
self.__handle_device_response(response_dict)

elif response_class == ClassMap.QuerySuccess.value:
logger.info("Handling QuerySuccess")
Expand All @@ -196,8 +187,42 @@ async def __response_handler(self, response: str, websocket) -> NoReturn:
extra={"response_class": response_class},
)

def __handle_place_response(self, response_dict: dict):
for value in response_dict.get("initialValues"):
room = entity_factory.create(value)
if not isinstance(room, Room):
logger.warning(
f"Entity factory created unsupported class ({type(room).__name__})"
)
continue

self.discovered.add_room(room)
logger.info(f"Setting up {room.name} ({room.identifier})")

def __handle_device_response(self, response_dict: dict):
for value in response_dict.get("initialValues"):
device = entity_factory.create(value)
if not isinstance(device, Device):
logger.warning(
f"Entity factory created unsupported class ({type(device).__name__})"
)
continue

self.discovered.add_device(device)
room = self.discovered.find_room_by_identifier(device.place_identifier)
room_name = room.name or "unknown"
logger.info(
f"Adding {type(device).__name__} ({device.name}) to {room_name}"
)

def connect(self):
"""Connects to the remote endpoint of the heating system."""
loop = asyncio.get_event_loop()
loop.create_task(self.__handler())
loop.create_task(self.__devices_handler())
loop.run_forever()

def discover(self) -> NoReturn:
"""Discovers the entities registered with the heating system."""
asyncio.create_task(self.__handler())
asyncio.create_task(self.__devices_handler())
2 changes: 2 additions & 0 deletions iolite/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
class IOLiteError(Exception):
pass
105 changes: 105 additions & 0 deletions iolite/heating_scheduler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
from base64 import b64encode
from enum import Enum
from typing import Tuple

import requests
from iolite.exceptions import IOLiteError


class Day(Enum):
"""Day constants for heating intervals.

The heating interval API does not have the concept of days. Instead, an interval starting at 0 is considered Monday
morning. To set the same interval on Tuesday, a 24 hour offset has to be set (in minutes).
"""

MONDAY = 60 * 24 * 0
TUESDAY = 60 * 24 * 1
WEDNESDAY = 60 * 24 * 2
THURSDAY = 60 * 24 * 3
FRIDAY = 60 * 24 * 4
SATURDAY = 60 * 24 * 5
SUNDAY = 60 * 24 * 6


class HeatingSchedulerError(IOLiteError):
pass


class HeatingScheduler(object):
BASE_URL = "https://remote.iolite.de"
HEATING_ENDPOINT = "/heating/api/heating/"

def __init__(self, sid: str, username: str, password: str, room_id: str):
"""The HeatingScheduler comprises methods to interact with the heating interval API.

:param sid: The session ID, used for authentication
:param username: The username, used for authentication
:param password: The password mathing the username, used for authentication
:param room_id: The room to set or change the heating intervals for.
"""
self.sid = sid
self.username = username
self.password = password
self.room_id = room_id
user_pass = f"{self.username}:{self.password}"
self.auth_value = b64encode(user_pass.encode()).decode("ascii")

def _prepare_request_arguments(self) -> Tuple[str, dict]:
url = f"{self.BASE_URL}{self.HEATING_ENDPOINT}{self.room_id}"
headers = {"Authorization": f"Basic {self.auth_value}"}
params = {"SID": self.sid}
return (
url,
{
"headers": headers,
"params": params,
},
)

def set_comfort_temperature(self, temperature: float) -> requests.Response:
"""Sets the desired comfort temperature for all heating intervals.

:param temperature: The temperature in degrees celcius
:return: The API response
"""
temperature_within_range = 14 <= temperature <= 30
firstdayofjune marked this conversation as resolved.
Show resolved Hide resolved
if not (temperature_within_range):
firstdayofjune marked this conversation as resolved.
Show resolved Hide resolved
raise HeatingSchedulerError(
"The desired comfort temperature has to be between 14 and 30 degrees celsius."
)
url, params = self._prepare_request_arguments()
response = requests.put(url, json={"comfortTemperature": temperature}, **params)
return response

def add_interval(
self, day: Day, hour: int, minute: int, duration: int
) -> requests.Response:
"""Schedules a heating interval

:param day: The day to set the interval for
:param hour: The hour to begin the interval at
:param minute: The minute of the hour to begin the interval at
:param duration: The duration of for the interval to last in minutes
:return: The API response, including the new interval's iolite ID
"""
url, params = self._prepare_request_arguments()
response = requests.post(
url + "/intervals",
json={
"startTimeInMinutes": day.value + hour * 60 + minute,
"durationInMinutes": duration,
},
**params,
)
return response

def delete_interval(self, interval_id: str) -> requests.Response:
"""Deletes the given interval

:param interval_id: iolite ID of the interval
:return: The API response
"""
url, params = self._prepare_request_arguments()
response = requests.delete(url + f"/intervals/{interval_id}", **params)
return response