Skip to content

Commit

Permalink
Revise device (broadcast-using) discovery process
Browse files Browse the repository at this point in the history
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).
  • Loading branch information
rytilahti committed Dec 12, 2018
1 parent 9ba523c commit 62d6d84
Show file tree
Hide file tree
Showing 6 changed files with 173 additions and 59 deletions.
2 changes: 1 addition & 1 deletion miio/ceil_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
95 changes: 52 additions & 43 deletions miio/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand All @@ -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."""
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -163,52 +179,37 @@ 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 = '<broadcast>'
is_broadcast = True
_LOGGER.info("Sending discovery to %s with timeout of %ss..",
addr, timeout)

# 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:
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.
Expand All @@ -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,
Expand Down Expand Up @@ -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:
Expand All @@ -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 "<Device %s token: %s>" % (self.ip, self.pretty_token)
95 changes: 86 additions & 9 deletions miio/discovery.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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" % (
Expand All @@ -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


Expand All @@ -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)
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion miio/philips_eyecare_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion miio/plug_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
36 changes: 32 additions & 4 deletions miio/vacuum_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down

0 comments on commit 62d6d84

Please sign in to comment.