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 13 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
9 changes: 8 additions & 1 deletion Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,19 @@ pre-commit = "*"
coverage = "*"
pytest-cov = "*"
codacy-coverage = "*"
pipenv-setup = "*"
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 = "==19.10b0"
toml = "*"
responses = "*"
isort = "==4.3.21"
pipenv-setup = "*"
pytest-socket = "*"
freezegun = "*"

[packages]
websockets = "*"
environs = "*"
requests = "*"

[pipenv]
allow_prereleases = true
Copy link
Owner

Choose a reason for hiding this comment

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

Why was this declared? Do we really need pre-release dependencies?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Black is only available as a pre-release package and pipenv does not install pre-releases without actively allowing it to do so. When installing it with pipenv install black --dev --pre the "allow_prereleases" part is automatically added by pipenv.
Once black becomes available as a major release, this can be removed again. I guess this can take some time, though: psf/black#1746

Copy link
Owner

Choose a reason for hiding this comment

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

What's the need for black? I've set it up as a pre-commit hook which you can install via pre-commit install or just run manually with pre-commit run --all?

Which is also called on CI

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Having black, isort, and flake8 installed in the environment, I use file watchers to run them on every file save. I prefer this setup to running the pre-commit hook once in a while, because I immediately see the changes made by black and isort. Also, this way I can fix the flake8 warnings right away.

I could probably also just run the pre-commit hook, this feels slightly slower, though (and runs on all files, not just the file I am working on).

So I added the scripts as dev dependencies, cause I though it does no harm 🙂

Copy link
Owner

Choose a reason for hiding this comment

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

Okays - that makes sense.

I think pre-commit runs on the diff by default but you can also tell it to run on all changes.

We can always remove the dev dependencies if it becomes too much.

207 changes: 135 additions & 72 deletions Pipfile.lock

Large diffs are not rendered by default.

50 changes: 42 additions & 8 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`)
- Init your pipenv environment (`pipenv install --dev`)
- 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,36 @@ 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 install [jupyter notebook or jupyter lab](https://jupyter.org/install.html) into the virtual environment and run the notebook:

```sh
pipenv shell
pip install notebook
jupyter notebook
```

If running the notebook gives you a `ModuleNotFoundError`, you may fix this issue by changing the notebook's kernel (following [this StackOverflow post](https://stackoverflow.com/a/47296960/50913)):
```sh
pipenv shell
python -m ipykernel install --user --name=`basename $VIRTUAL_ENV`
```
And then switch the kernel in the notebook's top menu under: _Kernel > Change Kernel_.

## 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/
113 changes: 69 additions & 44 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 Dict, List, NoReturn, Optional

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

discovered: dict
unmapped_devices: dict
discovered_rooms: Dict[str, Room]
unmapped_devices: defaultdict

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

def add_room(self, room: Room) -> NoReturn:
"""
Expand All @@ -29,7 +30,7 @@ def add_room(self, room: Room) -> NoReturn:
:param room: The room to add
:return:
"""
self.discovered[room.identifier] = room
self.discovered_rooms[room.identifier] = room

if room.identifier in self.unmapped_devices:
for device in self.unmapped_devices[room.identifier]:
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
) -> Optional[Room]:
match = None
for room in self.discovered.values():
if room.identifier == identifier:
for room in self.discovered_rooms.values():
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 self.discovered_rooms.values()


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 @@ -195,8 +186,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