Skip to content

Commit

Permalink
Merge pull request #20 from 2Fake/reuse_data
Browse files Browse the repository at this point in the history
Reuse data and discover devices
  • Loading branch information
Shutgun authored Dec 2, 2020
2 parents 687f038 + d1744fd commit ae3b466
Show file tree
Hide file tree
Showing 20 changed files with 429 additions and 126 deletions.
12 changes: 12 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates

version: 2
updates:
- package-ecosystem: "pip"
directory: "/" # Location of package manifests
schedule:
interval: "daily"
target-branch: "development"
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,27 @@ pytest

## Usage

All features we currently support are shown in our examples. If you want to use the package asynchronously, please check [example_async.py](https://github.com/2Fake/devolo_plc_api/blob/master/example_async.py). If you want to use it synchronously, please check [example_sync.py](https://github.com/2Fake/devolo_plc_api/blob/master/example_sync.py).
All features we currently support on device basis are shown in our examples. If you want to use the package asynchronously, please check [example_async.py](https://github.com/2Fake/devolo_plc_api/blob/master/example_async.py). If you want to use it synchronously, please check [example_sync.py](https://github.com/2Fake/devolo_plc_api/blob/master/example_sync.py).

If you don't know the IP addresses of your devices, you can discover them. You will get a dictionary with the device's serial number as key. The connections to the devices will be already established, but you will have to take about disconnecting.

```python
from devolo_plc_api.network import async_discover_network

devices = await async_discover_network()
# Do your magic
await asyncio.gather(*[device.async_disconnect() for device in devices.values()])
```

Or in a synchronous setup:

```python
from devolo_plc_api.network import discover_network

devices = discover_network()
# Do your magic
[device.disconnect() for device in devices.values()]
```

## Supported device

Expand Down
2 changes: 1 addition & 1 deletion devolo_plc_api/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.2.0"
__version__ = "0.3.0"
7 changes: 4 additions & 3 deletions devolo_plc_api/clients/protobuf.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ def __init__(self):
self._loop = asyncio.get_running_loop()
self._logger = logging.getLogger(self.__class__.__name__)

self.password: str

self._ip: str
self._password: str
self._path: str
self._port: int
self._session: AsyncClient
Expand Down Expand Up @@ -50,7 +51,7 @@ async def _async_get(self, sub_url: str, timeout: float = TIMEOUT) -> Response:
url = f"{self.url}{sub_url}"
self._logger.debug("Getting from %s", url)
try:
return await self._session.get(url, auth=DigestAuth(self._user, self._password), timeout=timeout)
return await self._session.get(url, auth=DigestAuth(self._user, self.password), timeout=timeout)
except TypeError:
raise DevicePasswordProtected("The used password is wrong.") from None

Expand All @@ -59,7 +60,7 @@ async def _async_post(self, sub_url: str, content: bytes, timeout: float = TIMEO
url = f"{self.url}{sub_url}"
self._logger.debug("Posting to %s", url)
try:
return await self._session.post(url, auth=DigestAuth(self._user, self._password), content=content, timeout=timeout)
return await self._session.post(url, auth=DigestAuth(self._user, self.password), content=content, timeout=timeout)
except TypeError:
raise DevicePasswordProtected("The used password is wrong.") from None

Expand Down
147 changes: 102 additions & 45 deletions devolo_plc_api/device.py
Original file line number Diff line number Diff line change
@@ -1,34 +1,42 @@
import asyncio
import ipaddress
import logging
import socket
import struct
from datetime import date
from typing import Dict, Optional

import httpx
from zeroconf import ServiceBrowser, ServiceStateChange, Zeroconf
from zeroconf import ServiceBrowser, ServiceInfo, ServiceStateChange, Zeroconf

from .device_api.deviceapi import DeviceApi
from .exceptions.device import DeviceNotFound
from .plcnet_api.plcnetapi import PlcNetApi

EMPTY_INFO: Dict = {
"properties": {}
}


class Device:
"""
Representing object for your devolo PLC device. It stores all properties and functionalities discovered during setup.
:param ip: IP address of the device to communicate with.
:param password: Password of the Web-UI, if it is protected
:param plcnetapi: Reuse externally gathered data for the plcnet API
:param deviceapi: Reuse externally gathered data for the device API
:param zeroconf_instance: Zeroconf instance to be potentially reused.
"""

def __init__(self, ip: str, password: Optional[str] = None, zeroconf_instance: Optional[Zeroconf] = None):
def __init__(self,
ip: str,
plcnetapi: Optional[Dict] = None,
deviceapi: Optional[Dict] = None,
zeroconf_instance: Optional[Zeroconf] = None):
self.firmware_date = date.fromtimestamp(0)
self.firmware_version = ""
self.ip = ip
self.mac = ""
self.mt_number = 0
self.password = password
self.product = ""
self.technology = ""
self.serial_number = 0
Expand All @@ -37,33 +45,71 @@ def __init__(self, ip: str, password: Optional[str] = None, zeroconf_instance: O
self.plcnet = None

self._info: Dict = {
"_dvl-plcnetapi._tcp.local.": {},
"_dvl-deviceapi._tcp.local.": {},
"_dvl-plcnetapi._tcp.local.": plcnetapi or EMPTY_INFO,
"_dvl-deviceapi._tcp.local.": deviceapi or EMPTY_INFO,
}
self._logger = logging.getLogger(self.__class__.__name__)
self._password = ""
self._zeroconf_instance = zeroconf_instance
logging.captureWarnings(True)

self._loop: asyncio.AbstractEventLoop
self._session: httpx.AsyncClient
self._zeroconf: Zeroconf

def __del__(self):
if self._loop.is_running():
self._logger.warning("Please disconnect properly from the device.")

async def __aenter__(self):
self._loop = asyncio.get_running_loop()
await self._setup_device()
await self.async_connect()
return self

async def __aexit__(self, exc_type, exc_val, exc_tb):
if not self._zeroconf_instance:
self._zeroconf.close()
await self._session.aclose()
await self.async_disconnect()

def __enter__(self):
self._loop = asyncio.new_event_loop()
self._loop.run_until_complete(self._setup_device())
self.connect()
return self

def __exit__(self, exc_type, exc_val, exc_tb):
self._loop.run_until_complete(self.__aexit__(exc_type, exc_val, exc_tb))
self.disconnect()

@property
def password(self) -> str:
""" The currently set device password. """
return self._password

@password.setter
def password(self, password: str):
""" Change the currently set device password. """
self._password = password
if self.device:
self.device.password = password

async def async_connect(self):
""" Connect to a device asynchronous. """
self._loop = asyncio.get_running_loop()
self._session = httpx.AsyncClient()
self._zeroconf = self._zeroconf_instance or Zeroconf()
await asyncio.gather(self._get_device_info(), self._get_plcnet_info())
if not self.device and not self.plcnet:
raise DeviceNotFound(f"The device {self.ip} did not answer.")

def connect(self):
""" Connect to a device synchronous. """
self._loop = asyncio.new_event_loop()
self._loop.run_until_complete(self.async_connect())

async def async_disconnect(self):
""" Disconnect from a device asynchronous. """
if not self._zeroconf_instance:
self._zeroconf.close()
await self._session.aclose()

def disconnect(self):
""" Disconnect from a device asynchronous. """
self._loop.run_until_complete(self.async_disconnect())
self._loop.close()

async def _get_device_info(self):
Expand All @@ -74,13 +120,13 @@ async def _get_device_info(self):
except asyncio.TimeoutError:
return

self.firmware_date = date.fromisoformat(self._info[service_type].get("FirmwareDate", "1970-01-01"))
self.firmware_version = self._info[service_type].get("FirmwareVersion", "")
self.serial_number = self._info[service_type].get("SN", 0)
self.mt_number = self._info[service_type].get("MT", 0)
self.product = self._info[service_type].get("Product", "")
self.firmware_date = date.fromisoformat(self._info[service_type]["properties"].get("FirmwareDate", "1970-01-01"))
self.firmware_version = self._info[service_type]["properties"].get("FirmwareVersion", "")
self.serial_number = self._info[service_type]["properties"].get("SN", 0)
self.mt_number = self._info[service_type]["properties"].get("MT", 0)
self.product = self._info[service_type]["properties"].get("Product", "")

self.device = DeviceApi(ip=self.ip, session=self._session, info=self._info[service_type], password=self.password)
self.device = DeviceApi(ip=self.ip, session=self._session, info=self._info[service_type])

async def _get_plcnet_info(self):
""" Get information from the devolo PlcNet API. """
Expand All @@ -90,41 +136,52 @@ async def _get_plcnet_info(self):
except asyncio.TimeoutError:
return

self.mac = self._info[service_type]['PlcMacAddress']
self.technology = self._info[service_type].get("PlcTechnology", "")
self.mac = self._info[service_type]["properties"]["PlcMacAddress"]
self.technology = self._info[service_type]["properties"].get("PlcTechnology", "")

self.plcnet = PlcNetApi(ip=self.ip, session=self._session, info=self._info[service_type])

async def _get_zeroconf_info(self, service_type: str):
""" Browse for the desired mDNS service types and query them. """
if self._info[service_type]["properties"]:
return # No need to continue, if device info already exist

self._logger.debug("Browsing for %s", service_type)
browser = ServiceBrowser(self._zeroconf, service_type, [self._state_change])
while not self._info[service_type]:
browser = ServiceBrowser(self._zeroconf, service_type, [self._state_change], addr=self.ip)
while not self._info[service_type]["properties"]:
await asyncio.sleep(0.1)
browser.cancel()

async def _setup_device(self):
""" Setup device. """
self._session = httpx.AsyncClient()
self._zeroconf = self._zeroconf_instance or Zeroconf()
await asyncio.gather(self._get_device_info(), self._get_plcnet_info())
if not self.device and not self.plcnet:
raise DeviceNotFound(f"The device {self.ip} did not answer.")

def _state_change(self, zeroconf: Zeroconf, service_type: str, name: str, state_change: ServiceStateChange):
""" Evaluate the query result. """
service_info = zeroconf.get_service_info(service_type, name)
if service_info and state_change is ServiceStateChange.Added and \
self.ip in [socket.inet_ntoa(address) for address in service_info.addresses]:
self._logger.debug("Adding service info of %s", service_type)

self._info[service_type]['Port'] = service_info.port
if service_info is None:
return # No need to continue, if there are no service information

# The answer is a byte string, that concatenates key-value pairs with their length as two byte hex value.
total_length = len(service_info.text)
offset = 0
while offset < total_length:
parsed_length, = struct.unpack_from("!B", service_info.text, offset)
key_value = service_info.text[offset + 1:offset + 1 + parsed_length].decode("UTF-8").split("=")
self._info[service_type][key_value[0]] = key_value[1]
offset += parsed_length + 1
if state_change is ServiceStateChange.Added:
self._logger.debug("Adding service info of %s", service_type)
self._info[service_type] = self.info_from_service(service_info)

@staticmethod
def info_from_service(service_info: ServiceInfo) -> Optional[Dict]:
""" Return prepared info from mDNS entries. """
properties = {}
if not service_info.addresses:
return None # No need to continue, if there is no IP address to contact the device

total_length = len(service_info.text)
offset = 0
while offset < total_length:
parsed_length, = struct.unpack_from("!B", service_info.text, offset)
key_value = service_info.text[offset + 1:offset + 1 + parsed_length].decode("UTF-8").split("=")
properties[key_value[0]] = key_value[1]
offset += parsed_length + 1

address = service_info.addresses[0]

return {
"address": str(ipaddress.ip_address(address)),
"port": service_info.port,
"properties": properties,
}
18 changes: 9 additions & 9 deletions devolo_plc_api/device_api/deviceapi.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Callable, Dict, Optional
from typing import Callable, Dict

from httpx import AsyncClient

Expand All @@ -16,21 +16,21 @@ class DeviceApi(Protobuf):
:param ip: IP address of the device to communicate with
:param session: HTTP client session
:param info: Information collected from the mDNS query
:param password: Password of the device
"""

def __init__(self, ip: str, session: AsyncClient, info: Dict, password: Optional[str]):
def __init__(self, ip: str, session: AsyncClient, info: Dict):
super().__init__()

self._ip = ip
self._port = info['Port']
self._path = info["properties"]["Path"]
self._port = info["port"]
self._session = session
self._path = info['Path']
self._version = info['Version']
self._user = "devolo"
self._password = password or ""
self._version = info["properties"]["Version"]

features = info.get("Features", "")
self.features = features.split(",") if features else ['reset', 'update', 'led', 'intmtg']
features = info["properties"].get("Features", "")
self.features = features.split(",") if features else ["reset", "update", "led", "intmtg"]
self.password = ""

def _feature(feature: str): # type: ignore # pylint: disable=no-self-argument
""" Decorator to filter unsupported features before querying the device. """
Expand Down
51 changes: 51 additions & 0 deletions devolo_plc_api/network/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import asyncio
import time
from typing import Dict

from zeroconf import ServiceBrowser, ServiceStateChange, Zeroconf

from ..device import Device

_devices: Dict[str,
Device] = {}


async def async_discover_network() -> Dict[str, Device]:
"""
Discover all devices that expose the devolo device API via mDNS asynchronous.
:return: Devices accessible via serial number.
"""
browser = ServiceBrowser(Zeroconf(), "_dvl-deviceapi._tcp.local.", [_add])
await asyncio.sleep(3)
browser.cancel()
await asyncio.gather(*[device.async_connect() for device in _devices.values()])
return _devices


def discover_network() -> Dict[str, Device]:
"""
Discover devices that expose the devolo device API via mDNS synchronous.
:return: Devices accessible via serial number.
"""
browser = ServiceBrowser(Zeroconf(), "_dvl-deviceapi._tcp.local.", [_add])
time.sleep(3)
browser.cancel()
for device in _devices.values():
device.connect()
return _devices


def _add(zeroconf: Zeroconf, service_type: str, name: str, state_change: ServiceStateChange):
"""" Create a device object to each matching device. """
if state_change is ServiceStateChange.Added:
service_info = zeroconf.get_service_info(service_type, name)
if service_info is None:
return

info = Device.info_from_service(service_info)
if info is None:
return

_devices[info["properties"]["SN"]] = Device(ip=info["address"], deviceapi=info, zeroconf_instance=zeroconf)
Loading

0 comments on commit ae3b466

Please sign in to comment.