From 62d6d847919c101645159f2de8111ee415cdad6e Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Wed, 12 Dec 2018 20:16:36 +0100 Subject: [PATCH] Revise device (broadcast-using) discovery process This is the initial commit to rework the handshake/discovery process, separating them to separate entities. The discovery part from Device class is moved into MiIODiscovery class usable for broadcast discovery of available devices. Also, instead of printing out the details from discovered devices, a list of Devices is being returned. The discover() method of the class is extended to allow discovery on all network interfaces (instead of using 255.255.255.255), making it easier to perform the initial configuration of non-provisioned devices and/or work with devices in other subnets. Although the change cause some duplicate code (mainly the handshake payload), this will make the responsibilities of corresponding classes clearer and code more usable for 3rd party developers. There will be cleanups and (likely also) renamings of some of the parts, but I wanted to make this available for others to comment. TODO: * Upcasting the Device to a corresponding implementation class (miIO.info model information could be useful, too..) * Decide how to handle the returning of discovered devices. Use a separate DiscoveredDevice class which can be used to initialize the real device, or use the existing Device and do the casting trick? Related to #152 (and maybe #422). --- miio/ceil_cli.py | 2 +- miio/device.py | 95 ++++++++++++++++++++----------------- miio/discovery.py | 95 +++++++++++++++++++++++++++++++++---- miio/philips_eyecare_cli.py | 2 +- miio/plug_cli.py | 2 +- miio/vacuum_cli.py | 36 ++++++++++++-- 6 files changed, 173 insertions(+), 59 deletions(-) diff --git a/miio/ceil_cli.py b/miio/ceil_cli.py index a53a47aeb..c0dd8bb6c 100644 --- a/miio/ceil_cli.py +++ b/miio/ceil_cli.py @@ -67,7 +67,7 @@ def cli(ctx, ip: str, token: str, debug: int): @cli.command() def discover(): """Search for plugs in the network.""" - miio.Ceil.discover() + miio.Ceil.send_handshake() @cli.command() diff --git a/miio/device.py b/miio/device.py index 29c845019..3e5dc894a 100644 --- a/miio/device.py +++ b/miio/device.py @@ -4,7 +4,7 @@ import logging import socket from enum import Enum -from typing import Any, List, Optional # noqa: F401 +from typing import Any, List, Optional, Union # noqa: F401 import click import construct @@ -53,11 +53,12 @@ def __init__(self, data): self.data = data def __repr__(self): - return "%s v%s (%s) @ %s - token: %s" % ( + return "%s v%s (%s) @ %s (%s) - token: %s" % ( self.data["model"], self.data["fw_ver"], self.data["mac"], self.network_interface["localIp"], + self.ssid, self.data["token"]) def __json__(self): @@ -73,6 +74,21 @@ def accesspoint(self): """Information about connected wlan accesspoint.""" return self.data["ap"] + @property + def ssid(self) -> Optional[str]: + """SSID of the connected wlan accesspoint.""" + return self.accesspoint["ssid"] or None + + @property + def rssi(self) -> int: + """RSSI of the wifi connection""" + return self.accesspoint["rssi"] + + @property + def is_configured(self) -> bool: + """If the device is TODO paired""" + return self.ssid is not None + @property def model(self) -> Optional[str]: """Model string if available.""" @@ -125,8 +141,10 @@ def __init__(self, ip: str = None, token: str = None, self.ip = ip self.port = 54321 if token is None: - token = 32 * '0' - if token is not None: + self.token = 32 * '0' + elif isinstance(token, bytes): + self.token = token + else: self.token = bytes.fromhex(token) self.debug = debug self.lazy_discover = lazy_discover @@ -137,7 +155,7 @@ def __init__(self, ip: str = None, token: str = None, self.__id = start_id self._device_id = None - def do_discover(self) -> Message: + def do_handshake(self) -> Message: """Send a handshake to the device, which can be used to the device type and serial. The handshake must also be done regularly to enable communication @@ -146,10 +164,8 @@ def do_discover(self) -> Message: :rtype: Message :raises DeviceException: if the device could not be discovered.""" - m = Device.discover(self.ip) + m = Device.send_handshake(self.ip) if m is not None: - self._device_id = m.header.value.device_id - self._device_ts = m.header.value.ts self._discovered = True if self.debug > 1: _LOGGER.debug(m) @@ -163,23 +179,18 @@ def do_discover(self) -> Message: return m - @staticmethod - def discover(addr: str=None) -> Any: - """Scan for devices in the network. - This method is used to discover supported devices by sending a - handshake message to the broadcast address on port 54321. - If the target IP address is given, the handshake will be send as - an unicast packet. + @classmethod + def from_handshake_reply(cls, addr, message, device_class=None): + if device_class: + cls = device_class + dev = cls(addr[0], message.checksum) + dev._device_id = message.header.value.device_id + dev._device_ts = message.header.value.ts + return dev - :param str addr: Target IP address""" + def send_handshake(self, initialize_token=False): timeout = 5 - is_broadcast = addr is None - seen_addrs = [] # type: List[str] - if is_broadcast: - addr = '' - is_broadcast = True - _LOGGER.info("Sending discovery to %s with timeout of %ss..", - addr, timeout) + # magic, length 32 helobytes = bytes.fromhex( '21310020ffffffffffffffffffffffffffffffffffffffffffffffffffffffff') @@ -187,28 +198,18 @@ def discover(addr: str=None) -> Any: s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) s.settimeout(timeout) - s.sendto(helobytes, (addr, 54321)) - while True: + s.sendto(helobytes, (self.ip, 54321)) + while True: # TODO add retry try: data, addr = s.recvfrom(1024) m = Message.parse(data) # type: Message - _LOGGER.debug("Got a response: %s", m) - if not is_broadcast: - return m - - if addr[0] not in seen_addrs: - _LOGGER.info(" IP %s (ID: %s) - token: %s", - addr[0], - binascii.hexlify(m.header.value.device_id).decode(), - codecs.encode(m.checksum, 'hex')) - seen_addrs.append(addr[0]) - except socket.timeout: - if is_broadcast: - _LOGGER.info("Discovery done") - return # ignore timeouts on discover + self._device_id = m.header.value.device_id + self._device_ts = m.header.value.ts + if initialize_token: + self.token = m.checksum + return except Exception as ex: - _LOGGER.warning("error while reading discover results: %s", ex) - break + raise DeviceException("Unable to do a handshake") from ex def send(self, command: str, parameters: Any=None, retry_count=3) -> Any: """Build and send the given command. @@ -221,7 +222,7 @@ def send(self, command: str, parameters: Any=None, retry_count=3) -> Any: :raises DeviceException: if an error has occured during communication.""" if not self.lazy_discover or not self._discovered: - self.do_discover() + self.send_handshake() cmd = { "id": self._id, @@ -344,7 +345,7 @@ def configure_wifi(self, ssid, password, uid=0, extra_params=None): params = {"ssid": ssid, "passwd": password, "uid": uid, **extra_params} - return self.send("miIO.config_router", params)[0] + return self.send("miIO.config_router", params)[0] == "ok" @property def _id(self) -> int: @@ -358,3 +359,11 @@ def _id(self) -> int: def raw_id(self) -> int: """Return the sequence id.""" return self.__id + + @property + def pretty_token(self): + """Return a pretty string presentation for a token.""" + return codecs.encode(self.token, 'hex').decode() + + def __repr__(self): + return "" % (self.ip, self.pretty_token) diff --git a/miio/discovery.py b/miio/discovery.py index 01086efba..94aa5e641 100644 --- a/miio/discovery.py +++ b/miio/discovery.py @@ -1,7 +1,9 @@ +import binascii import codecs import inspect import ipaddress import logging +import netifaces from functools import partial from typing import Union, Callable, Dict, Optional # noqa: F401 @@ -82,11 +84,6 @@ } # type: Dict[str, Union[Callable, Device]] -def pretty_token(token): - """Return a pretty string presentation for a token.""" - return codecs.encode(token, 'hex').decode() - - def other_package_info(info, desc): """Return information about another package supporting the device.""" return "%s @ %s, check %s" % ( @@ -101,12 +98,11 @@ def create_device(name: str, addr: str, device_cls: partial) -> Device: name, device_cls.func.__name__) dev = device_cls(ip=addr) - m = dev.do_discover() - dev.token = m.checksum + dev.send_handshake(initialize_token=True) _LOGGER.info("Found a supported '%s' at %s - token: %s", device_cls.func.__name__, addr, - pretty_token(dev.token)) + dev.pretty_token) return dev @@ -127,9 +123,10 @@ def check_and_create_device(self, info, addr) -> Optional[Device]: return create_device(name, addr, v) elif callable(v): dev = Device(ip=addr) + dev.send_handshake(initialize_token=True) _LOGGER.info("%s: token: %s", v(info), - pretty_token(dev.do_discover().checksum)) + dev.pretty_token) return None _LOGGER.warning("Found unsupported device %s at %s, " "please report to developers", name, addr) @@ -161,3 +158,83 @@ def discover_mdns() -> Dict[str, Device]: browser.cancel() return listener.found_devices + +import socket +from . import Message + +class MiIODiscovery: + """MiIO broadcast discovery""" + + @staticmethod + def send_handshake(addr): + """Scan for devices in the network. + This method is used to discover supported devices by sending a + handshake message to the broadcast address on port 54321. + If the target IP address is given, the handshake will be send as + an unicast packet. + + :param str addr: Target IP address + :param bool return_first: Return a single, first encountered message""" + timeout = 5 + devices = [] + + # magic, length 32 + helobytes = bytes.fromhex( + '21310020ffffffffffffffffffffffffffffffffffffffffffffffffffffffff') + + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + s.settimeout(timeout) + s.sendto(helobytes, (addr, 54321)) + while True: + try: + data, addr = s.recvfrom(1024) + m = Message.parse(data) # type: Message + _LOGGER.debug("Got a response: %s", m) + + m = Device.from_handshake_reply(addr, m) + + devices.append(m) + except Exception as ex: + _LOGGER.warning("error while reading discover results: %s", ex) + break + + return devices + + @staticmethod + def discover(timeout=5, interfaces=None): + """Discover devices using MiIO broadcast hello. + + Note, the timeout is per interface + :param timeout: how long to wait for responses + :param interfaces: list of interfaces to send broadcasts + :return: + """ + if not interfaces: + interfaces = netifaces.interfaces() + + _LOGGER.debug("Available interfaces: %s", interfaces) + ipv4_broadcasts = set() + for interface in interfaces: + addresses = netifaces.ifaddresses(interface) + if netifaces.AF_INET not in addresses: + continue + + v4 = addresses[netifaces.AF_INET] + for addr in v4: + if "broadcast" in addr: + ipv4_broadcasts.add(addr["broadcast"]) + + all_devices = [] + + _LOGGER.debug("Broadcast addreses: %s", ipv4_broadcasts) + for broadcast in ipv4_broadcasts: + _LOGGER.debug("Sending discovery to %s with timeout of %ss..", + broadcast, timeout) + devices = MiIODiscovery.send_handshake(addr=broadcast) + _LOGGER.debug("Got %s devices", len(devices)) + if devices is None: + continue + all_devices.extend(devices) + + return all_devices \ No newline at end of file diff --git a/miio/philips_eyecare_cli.py b/miio/philips_eyecare_cli.py index e985f4b06..7cd800a23 100644 --- a/miio/philips_eyecare_cli.py +++ b/miio/philips_eyecare_cli.py @@ -67,7 +67,7 @@ def cli(ctx, ip: str, token: str, debug: int): @cli.command() def discover(): """Search for plugs in the network.""" - miio.PhilipsEyecare.discover() + miio.PhilipsEyecare.send_handshake() @cli.command() diff --git a/miio/plug_cli.py b/miio/plug_cli.py index 21da1a25b..09d9b4c07 100644 --- a/miio/plug_cli.py +++ b/miio/plug_cli.py @@ -47,7 +47,7 @@ def cli(ctx, ip: str, token: str, debug: int): @cli.command() def discover(): """Search for plugs in the network.""" - miio.ChuangmiPlug.discover() + miio.ChuangmiPlug.send_handshake() @cli.command() diff --git a/miio/vacuum_cli.py b/miio/vacuum_cli.py index 87e6647f5..a2ab9efa3 100644 --- a/miio/vacuum_cli.py +++ b/miio/vacuum_cli.py @@ -19,6 +19,7 @@ validate_token, LiteralParamType) from .device import UpdateState from .updater import OneShotServer +from .exceptions import DeviceException _LOGGER = logging.getLogger(__name__) pass_dev = click.make_pass_decorator(miio.Device, ensure=True) @@ -90,13 +91,40 @@ def cleanup(vac: miio.Vacuum, *args, **kwargs): @cli.command() -@click.option('--handshake', type=bool, default=False) -def discover(handshake): +@click.option('--handshake', is_flag=True, default=False) +@click.argument('interfaces', type=str, required=False, nargs=-1) +def discover(handshake, interfaces): """Search for robots in the network.""" + def configure(dev): + """Ask for wlan configuration. + TODO move this to miiocli""" + if click.prompt("Do you want to connect the device to the wifi?"): + ssid = click.prompt("Name of the network") + password = click.prompt("Password") + + try: + if dev.configure_wifi(ssid, password): + click.echo("Wifi configuration was changed successfully, " + "the device should connect to the given network") + except DeviceException as ex: + click.echo("Unable to configure the wifi connection: %s" % ex) + if handshake: - miio.Vacuum.discover() + for dev in miio.discovery.MiIODiscovery.discover(interfaces=interfaces): + click.echo("Found %s, trying to request device information.." % dev) + try: + info = dev.info() + click.echo(" Device information: %s" % info) + except DeviceException as ex: + click.echo(" Unable to request info from device: %s" % ex) + continue + + if not info.is_configured: + configure(dev) + else: - miio.Discovery.discover_mdns() + for device in miio.Discovery.discover_mdns().values(): + click.echo("Found %s" % device) @cli.command()