Skip to content

Commit

Permalink
Merge pull request #13 from 2Fake/development
Browse files Browse the repository at this point in the history
v0.3.0
  • Loading branch information
2Fake authored Feb 21, 2020
2 parents c51c12c + c316f9c commit f6fa868
Show file tree
Hide file tree
Showing 55 changed files with 1,839 additions and 1,202 deletions.
Empty file.
10 changes: 8 additions & 2 deletions .github/workflows/pythonpackage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ jobs:
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --ignore=E303,W503 --statistics
- name: Test with pytest
run: |
pip install pytest pytest-mock
pip install pytest pytest-mock pytest-cov
cd tests
pytest
pytest --cov=devolo_home_control_api
- name: Coveralls
run: |
pip install coveralls==1.10.0
export COVERALLS_REPO_TOKEN=${{ secrets.COVERALLS_TOKEN }}
cd tests
coveralls
26 changes: 8 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# devolo_home_control_api

![PyPI - Downloads](https://img.shields.io/pypi/dd/devolo-home-control-api) ![Libraries.io SourceRank](https://img.shields.io/librariesio/sourcerank/pypi/devolo-home-control-api)
[![GitHub Workflow Status](https://img.shields.io/github/workflow/status/2Fake/devolo_home_control_api/Python%20package)](https://github.com/2Fake/devolo_home_control_api/actions?query=workflow%3A%22Python+package%22)
[![PyPI - Downloads](https://img.shields.io/pypi/dd/devolo-home-control-api)](https://pypi.org/project/devolo-home-control-api/)
[![Libraries.io SourceRank](https://img.shields.io/librariesio/sourcerank/pypi/devolo-home-control-api)](https://libraries.io/pypi/devolo-home-control-api)
[![Code Climate maintainability](https://img.shields.io/codeclimate/maintainability/2Fake/devolo_home_control_api)](https://codeclimate.com/github/2Fake/devolo_home_control_api)
[![Coverage Status](https://coveralls.io/repos/github/2Fake/devolo_home_control_api/badge.svg?branch=master)](https://coveralls.io/github/2Fake/devolo_home_control_api?branch=master)

This project implements parts of the devolo Home Control API in Python. It is based on reverse engineering and therefore may fail with any new devolo update. If you discover a breakage, please feel free to [report an issue](https://github.com/2Fake/devolo_home_control_api/issues).

Expand Down Expand Up @@ -77,31 +81,17 @@ for gateway_id in mydevolo.gateway_ids:

### Collecting Home Control data

There are three ways of getting data:
There are two ways of getting data:

1. Poll the gateway
1. Let the websocket push data into your object, but still poll the object
1. Subscribe to the publisher and let it push (preferred)

#### Poll the gateway

When polling the gateway, each property will be checked at the time of accessing it.

```python
mprm = MprmRest(gateway_id=gateway_id)
for binary_switch in mprm.binary_switch_devices:
for state in binary_switch.binary_switch_property:
print (f"State of {binary_switch.name} ({binary_switch.binary_switch_property[state].element_uid}): {binary_switch.binary_switch_property[state].state}")
```

To execute this example, you need a configured instance of Mydevolo.

#### Using websockets

Your way of accessing the data is more or less the same. Websocket events will keep the object up to date. This method uses less resources on the devolo Home Control Central Unit.
When using websocket events, messages will keep the object up to date. Nevertheless, no further action is triggered. So you have to ask yourself. The following example will list the current state of all binary switches. If the state changes, you will not notice unless you ask again.

```python
mprm = MprmWebsocket(gateway_id=gateway_id)
homecontrol = HomeControl(gateway_id=gateway_id)
for binary_switch in mprm.binary_switch_devices:
for state in binary_switch.binary_switch_property:
print (f"State of {binary_switch.name} ({binary_switch.binary_switch_property[state].element_uid}): {binary_switch.binary_switch_property[state].state}")
Expand Down
Empty file.
185 changes: 185 additions & 0 deletions devolo_home_control_api/backend/mprm_rest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import json
import logging
import socket
import time
import requests
import threading
from zeroconf import ServiceBrowser, ServiceStateChange, Zeroconf

from ..devices.gateway import Gateway
from ..mydevolo import Mydevolo


class MprmRest:
"""
The MprmRest object handles calls to the so called mPRM as singleton. It does not cover all API calls, just those
requested up to now. All calls are done in a gateway context, so you need to provide the ID of that gateway.
:param gateway_id: Gateway ID
:param url: URL of the mPRM
.. todo:: Make __instance a dict to handle multiple gateways at the same time
"""

__instance = None

@classmethod
def get_instance(cls):
if cls.__instance is None:
raise SyntaxError(f"Please init {cls.__name__}() once to establish a connection to the gateway's backend.")
return cls.__instance

@classmethod
def del_instance(cls):
cls.__instance = None


def __init__(self, gateway_id: str, url: str):
if self.__class__.__instance is not None:
raise SyntaxError(f"Please use {self.__class__.__name__}.get_instance() to reuse the connection to the backend.")
self._logger = logging.getLogger(self.__class__.__name__)
self._gateway = Gateway(gateway_id)
self._mydevolo = Mydevolo.get_instance()
self._session = requests.Session()
self._data_id = 0
self._mprm_url = url
self._local_ip = None

self.__class__.__instance = self


def detect_gateway_in_lan(self):
""" Detects a gateway in local network and check if it is the desired one. """
def on_service_state_change(zeroconf, service_type, name, state_change):
if state_change is ServiceStateChange.Added:
zeroconf.get_service_info(service_type, name)

zeroconf = Zeroconf()
ServiceBrowser(zeroconf, "_http._tcp.local.", handlers=[on_service_state_change])
self._logger.info("Searching for gateway in LAN")
start_time = time.time()
while not time.time() > start_time + 3 and self._local_ip is None:
for mdns_name in zeroconf.cache.entries():
try:
ip = socket.inet_ntoa(mdns_name.address)
if mdns_name.key.startswith("devolo-homecontrol") and \
requests.get("http://" + ip + "/dhlp/port/full",
auth=(self._gateway.local_user, self._gateway.local_passkey),
timeout=0.5).status_code == requests.codes.ok:
self._logger.debug(f"Got successful answer from ip {ip}. Setting this as local gateway")
self._local_ip = ip
break
except OSError:
# Got IPv6 address which isn't supported by socket.inet_ntoa and the gateway as well.
self._logger.debug(f"Found an IPv6 address. This cannot be a gateway.")
except AttributeError:
# The MDNS entry does not provide address information
pass
else:
time.sleep(0.05)
threading.Thread(target=zeroconf.close).start()
return self._local_ip

def create_connection(self):
""" Create session, either locally or via cloud. """
if self._local_ip:
self._gateway.local_connection = True
self.get_local_session()
elif self._gateway.external_access and not self._mydevolo.maintenance:
self.get_remote_session()
else:
self._logger.error("Cannot connect to gateway. No gateway found in LAN and external access is not possible.")
raise ConnectionError("Cannot connect to gateway.")

def extract_data_from_element_uid(self, uid: str) -> dict:
"""
Returns data from an element UID using an RPC call.
:param uid: Element UID, something like devolo.MultiLevelSensor:hdm:ZWave:CBC56091/24#2
:return: Data connected to the element UID, payload so to say
"""
data = {"method": "FIM/getFunctionalItems",
"params": [[uid], 0]}
response = self.post(data)
return response.get("result").get("items")[0]

def get_all_devices(self) -> dict:
"""
Get all devices.
:return: Dict with all devices and their properties.
"""
self._logger.info("Inspecting devices")
data = {"method": "FIM/getFunctionalItems",
"params": [['devolo.DevicesPage'], 0]}
response = self.post(data)
return response.get("result").get("items")[0].get("properties").get("deviceUIDs")

def get_local_session(self):
""" Connect to the gateway locally. """
self._logger.info("Connecting to gateway locally")
self._mprm_url = "http://" + self._local_ip
try:
self._token_url = self._session.get(self._mprm_url + "/dhlp/portal/full",
auth=(self._gateway.local_user, self._gateway.local_passkey), timeout=5).json()
except json.JSONDecodeError:
self._logger.error("Could not connect to the gateway locally.")
raise MprmDeviceCommunicationError("Could not connect to the gateway locally.") from None
except requests.ConnectTimeout:
self._logger.error("Timeout during connecting to the gateway.")
raise
self._session.get(self._token_url.get('link'))

def get_name_and_element_uids(self, uid: str):
"""
Returns the name, all element UIDs and the device model of the given device UID.
:param uid: Element UID, something like devolo.MultiLevelSensor:hdm:ZWave:CBC56091/24#2
"""
data = {"method": "FIM/getFunctionalItems",
"params": [[uid], 0]}
response = self.post(data)
properties = response.get("result").get("items")[0].get("properties")
return properties

def get_remote_session(self):
""" Connect to the gateway remotely. """
self._logger.info("Connecting to gateway via cloud")
try:
self._session.get(self._gateway.full_url, timeout=15)
except json.JSONDecodeError:
raise MprmDeviceCommunicationError("Gateway is offline.") from None

def post(self, data: dict) -> dict:
"""
Communicate with the RPC interface.
:param data: Data to be send
:return: Response to the data
"""
if not(self._gateway.online or self._gateway.sync) and not self._gateway.local_connection:
raise MprmDeviceCommunicationError("Gateway is offline.")

self._data_id += 1
data['jsonrpc'] = "2.0"
data['id'] = self._data_id
try:
response = self._session.post(self._mprm_url + "/remote/json-rpc",
data=json.dumps(data),
headers={"content-type": "application/json"},
timeout=15).json()
except requests.ReadTimeout:
self._logger.error("Gateway is offline.")
self._gateway.update_state(False)
raise MprmDeviceCommunicationError("Gateway is offline.") from None
if response['id'] != data['id']:
self._logger.error("Got an unexpected response after posting data.")
raise ValueError("Got an unexpected response after posting data.")
return response


class MprmDeviceCommunicationError(Exception):
""" Communicating to a device via mPRM failed """


class MprmDeviceNotFoundError(Exception):
""" A device like this was not found """
95 changes: 95 additions & 0 deletions devolo_home_control_api/backend/mprm_websocket.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import json
import threading
import time

import websocket
from requests import ConnectionError, ReadTimeout
from urllib3.connection import ConnectTimeoutError

from devolo_home_control_api.backend.mprm_rest import MprmRest, MprmDeviceCommunicationError


class MprmWebsocket(MprmRest):
"""
The MprmWebsocket object handles calls to the mPRM via websockets. It does not cover all API calls, just those
requested up to now. All calls are done in a gateway context, so you need to provide the ID of that gateway. As
it inherites from MprmRest, it is a singleton as well.
:param gateway_id: Gateway ID (aka serial number), typically found on the label of the device
:param url: URL of the mPRM
"""

def __init__(self, gateway_id: str, url: str):
super().__init__(gateway_id, url)
self._ws = None
self._event_sequence = 0

self.publisher = None
self.on_update = None


def websocket_connection(self):
""" Set up the websocket connection """
ws_url = self._mprm_url.replace("https://", "wss://").replace("http://", "ws://")
cookie = "; ".join([str(name) + "=" + str(value) for name, value in self._session.cookies.items()])
ws_url = f"{ws_url}/remote/events/?topics=com/prosyst/mbs/services/fim/FunctionalItemEvent/PROPERTY_CHANGED," \
f"com/prosyst/mbs/services/fim/FunctionalItemEvent/UNREGISTERED" \
f"&filter=(|(GW_ID={self._gateway.id})(!(GW_ID=*)))"
self._logger.debug(f"Connecting to {ws_url}")
self._ws = websocket.WebSocketApp(ws_url,
cookie=cookie,
on_open=self._on_open,
on_message=self._on_message,
on_error=self._on_error,
on_close=self._on_close)
self._ws.run_forever(ping_interval=30, ping_timeout=5)


def _on_close(self):
""" Callback function to react on closing the websocket. """
self._logger.info("Closed web socket connection.")

def _on_error(self, error: str):
""" Callback function to react on errors. We will try reconnecting with prolonging intervals. """
self._logger.error(error)
i = 16
connected = False
self._ws.close()

self._event_sequence = 0

while not connected:
try:
self._logger.info("Trying to reconnect to the gateway.")
# TODO: Check if local_ip is still correct after lost connection
self.get_local_session() if self._local_ip else self.get_remote_session()
connected = True
except (json.JSONDecodeError, ConnectTimeoutError, ReadTimeout, ConnectionError, MprmDeviceCommunicationError):
self._logger.info(f"Sleeping for {i} seconds.")
time.sleep(i)
i = i * 2 if i < 2048 else 3600
self.websocket_connection()

def _on_message(self, message: str):
""" Callback function to react on a message. """
message = json.loads(message)

event_sequence = message.get("properties").get("com.prosyst.mbs.services.remote.event.sequence.number")
if event_sequence == self._event_sequence:
self._event_sequence += 1
else:
self._logger.warning("We missed a websocket message.")
self._event_sequence = event_sequence

try:
self.on_update(message)
except TypeError:
self._logger.error("on_update is not set.")

def _on_open(self):
""" Callback function to keep the websocket open. """
def run(*args):
self._logger.info("Starting web socket connection")
while self._ws.sock is not None and self._ws.sock.connected:
time.sleep(1)
threading.Thread(target=run).start()
2 changes: 1 addition & 1 deletion devolo_home_control_api/devices/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ def __init__(self, gateway_id: str):

@property
def full_url(self):
""" The full URL is used to get a valid remote session """
""" The full URL is used to get a valid remote session. """
if self._full_url is None:
self._full_url = self._mydevolo.get_full_url(self.id)
self._logger.debug(f"Setting full URL to {self._full_url}")
Expand Down
Loading

0 comments on commit f6fa868

Please sign in to comment.