File: mprm.py

package info (click to toggle)
devolo-home-control-api 0.19.1-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 804 kB
  • sloc: python: 3,167; makefile: 3
file content (141 lines) | stat: -rw-r--r-- 6,172 bytes parent folder | download | duplicates (2)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
"""mPRM communication."""
from __future__ import annotations

import contextlib
import socket
import sys
import time
from abc import ABC
from http import HTTPStatus
from json import JSONDecodeError
from threading import Thread
from urllib.parse import urlsplit

import requests
from zeroconf import ServiceBrowser, ServiceStateChange, Zeroconf

from devolo_home_control_api.exceptions import GatewayOfflineError

from .mprm_websocket import MprmWebsocket


class Mprm(MprmWebsocket, ABC):
    """
    The abstract Mprm object handles the connection to the devolo Cloud (remote) or the gateway in your LAN (local). Either
    way is chosen, depending on detecting the gateway via mDNS.
    """

    def __init__(self) -> None:
        """Initialize communication."""
        self._zeroconf: Zeroconf | None

        super().__init__()

        self.detect_gateway_in_lan()
        self.create_connection()

    def create_connection(self) -> None:
        """
        Create session, either locally or remotely via cloud. The remote case has two conditions, that both need to be
        fulfilled: Remote access must be allowed and my devolo must not be in maintenance mode.
        """
        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.")  # noqa: TRY003

    def detect_gateway_in_lan(self) -> str:
        """
        Detect a gateway in local network via mDNS and check if it is the desired one. Unfortunately, the only way to tell is
        to try a connection with the known credentials. If the gateway is not found within 3 seconds, it is assumed that a
        remote connection is needed.

        :return: Local IP of the gateway, if found
        """
        zeroconf = self._zeroconf or Zeroconf()
        browser = ServiceBrowser(zeroconf, "_dvl-deviceapi._tcp.local.", handlers=[self._on_service_state_change])
        self._logger.info("Searching for gateway in LAN.")
        start_time = time.time()
        while not time.time() > start_time + 3 and not self._local_ip:
            time.sleep(0.05)

        Thread(target=browser.cancel, name=f"{self.__class__.__name__}.browser_cancel").start()
        if not self._zeroconf:
            Thread(target=zeroconf.close, name=f"{self.__class__.__name__}.zeroconf_close").start()

        return self._local_ip

    def get_local_session(self) -> bool:
        """
        Connect to the gateway locally. Calling a special portal URL on the gateway returns a second URL with a token. Calling
        that URL establishes the connection.
        """
        self._logger.info("Connecting to gateway locally.")
        self._url = f"http://{self._local_ip}"
        self._logger.debug("Session URL set to '%s'", self._url)
        try:
            connection = self._session.get(
                f"{self._url}/dhlp/portal/full", auth=(self.gateway.local_user, self.gateway.local_passkey), timeout=5
            )

        except (requests.exceptions.ConnectTimeout, requests.exceptions.ConnectionError, requests.exceptions.ReadTimeout):
            self._logger.error("Could not connect to the gateway locally.")
            self._logger.debug(sys.exc_info())
            raise GatewayOfflineError from None

        # After a reboot we can connect to the gateway but it answers with a 503 if not fully started.
        if not connection.ok:
            self._logger.error("Could not connect to the gateway locally.")
            self._logger.debug("Gateway start-up is not finished, yet.")
            raise GatewayOfflineError from None

        token_url = connection.json()["link"]
        self._logger.debug("Got a token URL: %s", token_url)

        self._session.get(token_url)
        return True

    def get_remote_session(self) -> bool:
        """Connect to the gateway remotely. Calling the known portal URL is enough in this case."""
        self._logger.info("Connecting to gateway via cloud.")
        if not self.gateway.full_url:
            self._logger.error("Could not connect to the gateway remotely.")
            raise GatewayOfflineError
        try:
            url = urlsplit(self._session.get(self.gateway.full_url, timeout=15).url)
            self._url = f"{url.scheme}://{url.netloc}"
            self._logger.debug("Session URL set to '%s'", self._url)
        except JSONDecodeError:
            self._logger.error("Could not connect to the gateway remotely.")
            self._logger.debug(sys.exc_info())
            raise GatewayOfflineError from None
        return True

    def _on_service_state_change(
        self, zeroconf: Zeroconf, service_type: str, name: str, state_change: ServiceStateChange
    ) -> None:
        """Service handler for Zeroconf state changes."""
        if state_change is ServiceStateChange.Added:
            service_info = zeroconf.get_service_info(service_type, name)
            if service_info and service_info.server and service_info.server.startswith("devolo-homecontrol"):
                with contextlib.suppress(requests.exceptions.ReadTimeout), contextlib.suppress(
                    requests.exceptions.ConnectTimeout
                ):
                    self._try_local_connection(service_info.addresses)

    def _try_local_connection(self, addresses: list[bytes]) -> None:
        """Try to connect to an mDNS hostname. If connection was successful, save local IP address."""
        for address in addresses:
            ip = socket.inet_ntoa(address)
            if (
                requests.get(
                    f"http://{ip}/dhlp/port/full", auth=(self.gateway.local_user, self.gateway.local_passkey), timeout=0.5
                ).status_code
                == HTTPStatus.OK
            ):
                self._logger.debug("Got successful answer from ip %s. Setting this as local gateway", ip)
                self._local_ip = ip