Skip to content

Commit a3bd5b6

Browse files
paulalurimsintov
authored andcommitted
VIC-13744 Add 'name' field to Robot class constructor for mDNS discovery (#167)
Add support for mDNS in sdk Robot object init E.g., anki_vector.Robot(name="Vector-A1B2") If the name param matches the 'name' field in sdk_config.ini, the SDK will start an mDNS browser to find Vector. If successful, it will use the discovered IP--otherwise, it will use the default means (ip param or 'ip' field in config).
1 parent c14082a commit a3bd5b6

File tree

6 files changed

+204
-22
lines changed

6 files changed

+204
-22
lines changed

anki_vector/behavior.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -982,7 +982,7 @@ def __init__(self,
982982
behavior_activation_timeout: int = 10):
983983
config = config if config is not None else {}
984984
self.logger = util.get_class_logger(__name__, self)
985-
config = {**util.read_configuration(serial, self.logger), **config}
985+
config = {**util.read_configuration(serial, name=None, logger=self.logger), **config}
986986
self._name = config["name"]
987987
self._ip = ip if ip is not None else config["ip"]
988988
self._cert_file = config["cert"]

anki_vector/mdns.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
# Copyright (c) 2019 Anki, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License in the file LICENSE.txt or at
6+
#
7+
# https://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""
16+
This contains the :class:`VectorMdns` class for discovering Vector (without already knowing
17+
the IP address) on a LAN (Local Area Network) over mDNS.
18+
19+
mDNS (multicast DNS) is a protocol for sending UDP packets containing a DNS query to all
20+
devices on your Local Area Network. If a device knows how to answer the DNS query, it
21+
will respond by multicasting a UDP packet containing the relevant DNS records.
22+
"""
23+
from threading import Condition
24+
import sys
25+
26+
27+
class VectorMdns: # pylint: disable=too-few-public-methods
28+
"""`VectorMdns` provides a static method for discovering a Vector on the same LAN as
29+
the SDK program and retrieving its IP address.
30+
"""
31+
32+
@staticmethod
33+
def find_vector(name: str, timeout=5):
34+
"""
35+
:param name: A name like `"Vector-A1B2"`. If :code:`None`, will search for any Vector.
36+
:param timeout: The discovery will timeout in :code:`timeout` seconds. Default value is :code:`5`.
37+
:returns: **dict** or **None** -- if Vector found, **dict** contains keys `"name"` and `"ipv4"`
38+
39+
.. testcode::
40+
41+
import anki_vector
42+
vector_mdns = anki_vector.mdns.VectorMdns.find_vector("Vector-A1B2")
43+
44+
if vector_mdns is not None:
45+
print(vector_mdns['ipv4'])
46+
else:
47+
print("No Vector found on your local network!")
48+
"""
49+
50+
# synchronously search for Vector for up to 5 seconds
51+
vector_name = name # should be like 'Vector-V3C7'
52+
return VectorMdns._start_mdns_listener(vector_name, timeout)
53+
54+
@staticmethod
55+
def _start_mdns_listener(name, timeout):
56+
try:
57+
from zeroconf import ServiceBrowser, Zeroconf
58+
except ImportError:
59+
sys.exit("Cannot import from Zeroconf: Do `pip3 install --user zeroconf` to install")
60+
61+
# create a Condition object and acquire the underlying lock
62+
cond = Condition()
63+
cond.acquire()
64+
65+
# instantiate zeroconf and our MdnsListner object for listening to events
66+
zeroconf = Zeroconf()
67+
vector_fullname = None
68+
69+
if name is not None:
70+
vector_fullname = name + ".local."
71+
72+
listener = _MdnsListener(vector_fullname, cond)
73+
74+
# browse for the _ankivector TCP MDNS service, sending events to our listener
75+
ServiceBrowser(zeroconf, "_ankivector._tcp.local.", listener)
76+
77+
# block until 'timeout' seconds or until we discover vector
78+
cond.wait(timeout)
79+
80+
# close zeroconf
81+
zeroconf.close()
82+
83+
# return an IPv4 string (or None)
84+
if listener.ipv4 is None:
85+
return None
86+
87+
return {'ipv4': listener.ipv4, 'name': listener.name}
88+
89+
90+
class _MdnsListener:
91+
"""_MdnsListener is an internal helper class which listens for mDNS messages.
92+
93+
:param name_filter: A String to filter the mDNS responses by name (e.g., `"Vector-A1B2"`).
94+
:param condition: A Condition object to be used for signaling to caller when robot has been discovered.
95+
"""
96+
97+
def __init__(self, name_filter: str, condition):
98+
self.name_filter = name_filter
99+
self.cond = condition
100+
self.ipv4 = None
101+
self.name = ""
102+
103+
@staticmethod
104+
def _bytes_to_str_ipv4(ip_bytes):
105+
return str(ip_bytes[0]) + "." + \
106+
str(ip_bytes[1]) + "." + \
107+
str(ip_bytes[2]) + "." + \
108+
str(ip_bytes[3])
109+
110+
def remove_service(self, zeroconf, mdns_type, name):
111+
# detect service removal
112+
pass
113+
114+
def add_service(self, zeroconf, mdns_type, name):
115+
# detect service
116+
info = zeroconf.get_service_info(mdns_type, name)
117+
118+
if (self.name_filter is None) or (info.server.lower() == self.name_filter.lower()):
119+
# found a match for our filter or there is no filter
120+
self.cond.acquire()
121+
self.ipv4 = _MdnsListener._bytes_to_str_ipv4(info.address) # info.address is IPv4 (DNS record type 'A')
122+
self.name = info.server
123+
124+
# cause anything waiting for this condition to end waiting
125+
# and release so the other thread can continue
126+
self.cond.notify()
127+
self.cond.release()

anki_vector/robot.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
VectorUnreliableEventStreamException)
4040
from .viewer import (ViewerComponent, Viewer3DComponent)
4141
from .messaging import protocol
42+
from .mdns import VectorMdns
4243

4344

4445
class Robot:
@@ -80,6 +81,8 @@ class Robot:
8081
:param serial: Vector's serial number. The robot's serial number (ex. 00e20100) is located on the underside of Vector,
8182
or accessible from Vector's debug screen. Used to identify which Vector configuration to load.
8283
:param ip: Vector's IP address. (optional)
84+
:param name: Vector's name (in format :code:`"Vector-XXXX"`) to be used for mDNS discovery. If a Vector with the given name
85+
is discovered, the :code:`ip` parameter (and config field) will be overridden.
8386
:param config: A custom :class:`dict` to override values in Vector's configuration. (optional)
8487
Example: :code:`{"cert": "/path/to/file.cert", "name": "Vector-XXXX", "guid": "<secret_key>"}`
8588
where :code:`cert` is the certificate to identify Vector, :code:`name` is the name on Vector's face
@@ -106,6 +109,7 @@ class Robot:
106109
def __init__(self,
107110
serial: str = None,
108111
ip: str = None,
112+
name: str = None,
109113
config: dict = None,
110114
default_logging: bool = True,
111115
behavior_activation_timeout: int = 10,
@@ -123,7 +127,13 @@ def __init__(self,
123127
self.logger = util.get_class_logger(__name__, self)
124128
self._force_async = False
125129
config = config if config is not None else {}
126-
config = {**util.read_configuration(serial, self.logger), **config}
130+
config = {**util.read_configuration(serial, name, self.logger), **config}
131+
132+
if name is not None:
133+
vector_mdns = VectorMdns.find_vector(name)
134+
135+
if vector_mdns is not None:
136+
ip = vector_mdns['ipv4']
127137

128138
self._name = config["name"]
129139
self._ip = ip if ip is not None else config["ip"]

anki_vector/util.py

Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1079,11 +1079,17 @@ def grpc_interface(self):
10791079
return self._robot.conn.grpc_interface
10801080

10811081

1082-
def read_configuration(serial: str, logger: logging.Logger) -> dict:
1082+
def read_configuration(serial: str, name: str, logger: logging.Logger) -> dict:
10831083
"""Open the default conf file, and read it into a :class:`configparser.ConfigParser`
1084+
If :code:`serial is not None`, this method will try to find a configuration with serial
1085+
number :code:`serial`, and raise an exception otherwise. If :code:`serial is None` and
1086+
:code:`name is not None`, this method will try to find a configuration which matches
1087+
the provided name, and raise an exception otherwise. If both :code:`serial is None` and
1088+
:code:`name is None`, this method will return a configuration if exactly `1` exists, but
1089+
if multiple configurations exists, it will raise an exception.
10841090
10851091
:param serial: Vector's serial number
1086-
:param logger: Logger object
1092+
:param name: Vector's name
10871093
"""
10881094
home = Path.home() / ".anki_vector"
10891095
conf_file = str(home / "sdk_config.ini")
@@ -1093,20 +1099,34 @@ def read_configuration(serial: str, logger: logging.Logger) -> dict:
10931099
sections = parser.sections()
10941100
if not sections:
10951101
raise VectorConfigurationException('Could not find the sdk configuration file. Please run `python3 -m anki_vector.configure` to set up your Vector for SDK usage.')
1096-
if serial is None and len(sections) == 1:
1097-
serial = sections[0]
1098-
logger.warning("No serial number provided. Automatically selecting {}".format(serial))
1099-
elif serial is None:
1100-
raise VectorConfigurationException("Found multiple robot serial numbers. "
1101-
"Please provide the serial number of the Robot you want to control.\n\n"
1102-
"Example: ./01_hello_world.py --serial {{robot_serial_number}}")
1103-
1104-
serial = serial.lower()
1102+
elif (serial is None) and (name is None):
1103+
if len(sections) == 1:
1104+
serial = sections[0]
1105+
logger.warning("No serial number or name provided. Automatically selecting {}".format(serial))
1106+
else:
1107+
raise VectorConfigurationException("Found multiple robot serial numbers. "
1108+
"Please provide the serial number or name of the Robot you want to control.\n\n"
1109+
"Example: ./01_hello_world.py --serial {{robot_serial_number}}")
1110+
11051111
config = {k.lower(): v for k, v in parser.items()}
1106-
try:
1107-
dict_entry = config[serial]
1108-
except KeyError:
1109-
raise VectorConfigurationException("Could not find matching robot info for given serial number: {}. "
1110-
"Please check your serial number is correct.\n\n"
1111-
"Example: ./01_hello_world.py --serial {{robot_serial_number}}", serial)
1112-
return dict_entry
1112+
1113+
if serial is not None:
1114+
serial = serial.lower()
1115+
try:
1116+
return config[serial]
1117+
except KeyError:
1118+
raise VectorConfigurationException("Could not find matching robot info for given serial number: {}. "
1119+
"Please check your serial number is correct.\n\n"
1120+
"Example: ./01_hello_world.py --serial {{robot_serial_number}}", serial)
1121+
else:
1122+
for keySerial in config:
1123+
for key in config[keySerial]:
1124+
if config[keySerial][key] == name:
1125+
return config[keySerial]
1126+
elif config[keySerial][key].lower() == name.lower():
1127+
logger.warning("Using case-insensitive name match found in config. Set 'name' field to match 'Vector-A1B2' format.")
1128+
return config[keySerial]
1129+
1130+
raise VectorConfigurationException("Could not find matching robot info for given name: {}. "
1131+
"Please check your name is correct.\n\n"
1132+
"Example: ./01_hello_world.py --name {{robot_name}}", name)

docs/source/advanced-tips.rst

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,32 @@ Moving Vector between WiFi networks
1111
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1212

1313
When you move Vector from one WiFi network to another (using the Vector App),
14-
or if your Vector's IP changes, you will also need to make some changes to
15-
your SDK setup. To assist in this migration, the ``anki_vector.configure``
14+
or if your Vector's IP changes, the SDK will need to determine Vector's new IP address.
15+
There are two ways to accomplish this.
16+
17+
****************************
18+
1. Automatic: mDNS Discovery
19+
****************************
20+
21+
The SDK will automatically discover your Vector, even on a new WiFi network,
22+
when you connect as follows::
23+
24+
import anki_vector
25+
26+
with anki_vector.Robot(name="Vector-A1B2") as robot:
27+
# The sdk will try to connect to 'Vector A1B2',
28+
# even if its IP address has changed.
29+
pass
30+
31+
You will need to install the ``zeroconf`` package to use this feature::
32+
33+
pip3 install --user zeroconf
34+
35+
*******************************
36+
2. Manual: Update Configuration
37+
*******************************
38+
39+
Alternatively, you can manually make changes to your SDK setup. To assist in this migration, the ``anki_vector.configure``
1640
executable submodule provides a ``-u`` parameter to quickly reconnect to Vector.
1741

1842
To update your connection, you will need to find the IP address on

docs/source/api.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ The API
1919
anki_vector.exceptions
2020
anki_vector.faces
2121
anki_vector.lights
22+
anki_vector.mdns
2223
anki_vector.messaging
2324
anki_vector.motors
2425
anki_vector.nav_map

0 commit comments

Comments
 (0)