You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

conn.py 20KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486
  1. # Copyright (c) 2016-2017 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. # http://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. '''Engine connection.
  15. The SDK operates by connecting to the Cozmo "engine" - typically the Cozmo
  16. app that runs on an iOS or Android device.
  17. The engine is responsible for much of the work that Cozmo does, including
  18. image recognition, path planning, behaviors and animation handling, etc.
  19. The :mod:`cozmo.run` module takes care of opening a connection over a USB
  20. connection to a device, but the :class:`CozmoConnection` class defined in
  21. this module does the work of relaying messages to and from the engine and
  22. dispatching them to the :class:`cozmo.robot.Robot` instance.
  23. '''
  24. # __all__ should order by constants, event classes, other classes, functions.
  25. __all__ = ['EvtRobotFound', 'CozmoConnection']
  26. import asyncio
  27. import platform
  28. import cozmoclad
  29. from . import logger
  30. from . import anim
  31. from . import clad_protocol
  32. from . import event
  33. from . import exceptions
  34. from . import robot
  35. from . import version
  36. from . import _clad
  37. from ._clad import _clad_to_engine_cozmo, _clad_to_engine_iface, _clad_to_game_cozmo, _clad_to_game_iface
  38. class EvtConnected(event.Event):
  39. '''Triggered when the initial connection to the device has been established.
  40. This connection is setup before contacting the robot - Wait for EvtRobotFound
  41. or EvtRobotReady for a usefully configured Cozmo instance.
  42. '''
  43. conn = 'The connected CozmoConnection object'
  44. class EvtRobotFound(event.Event):
  45. '''Triggered when a Cozmo robot is detected, but before he's initialized.
  46. :class:`cozmo.robot.EvtRobotReady` is dispatched when the robot is fully initialized.
  47. '''
  48. robot = 'The Cozmo object for the robot'
  49. class EvtConnectionClosed(event.Event):
  50. '''Triggered when the connection to the controlling device is closed.
  51. '''
  52. exc = 'The exception that triggered the closure, or None'
  53. # Some messages have no robotID but should still be forwarded to the primary robot
  54. FORCED_ROBOT_MESSAGES = {"AnimationAborted",
  55. "AnimationEvent",
  56. "BehaviorObjectiveAchieved",
  57. "BehaviorTransition",
  58. "BlockPickedUp",
  59. "BlockPlaced",
  60. "BlockPoolDataMessage",
  61. "CarryStateUpdate",
  62. "ChargerEvent",
  63. "ConnectedObjectStates",
  64. "CreatedFixedCustomObject",
  65. "CubeLightsStateTransition",
  66. "CurrentCameraParams",
  67. "DefinedCustomObject",
  68. "DeviceAccelerometerValuesRaw",
  69. "DeviceAccelerometerValuesUser",
  70. "DeviceGyroValues",
  71. "IsDeviceIMUSupported",
  72. "LoadedKnownFace",
  73. "LocatedObjectStates",
  74. "MemoryMapMessage",
  75. "MemoryMapMessageBegin",
  76. "MemoryMapMessageEnd",
  77. "ObjectAccel",
  78. "ObjectAvailable",
  79. "ObjectConnectionState",
  80. "ObjectMoved",
  81. "ObjectPowerLevel",
  82. "ObjectProjectsIntoFOV",
  83. "ObjectStoppedMoving",
  84. "ObjectTapped",
  85. "ObjectTappedFiltered",
  86. "ObjectUpAxisChanged",
  87. "PerRobotSettings",
  88. "ReactionaryBehaviorTransition",
  89. "RobotChangedObservedFaceID",
  90. "RobotCliffEventFinished",
  91. "RobotCompletedAction",
  92. "RobotDeletedAllCustomObjects",
  93. "RobotDeletedCustomMarkerObjects",
  94. "RobotDeletedFixedCustomObjects",
  95. "RobotDelocalized",
  96. "RobotErasedAllEnrolledFaces",
  97. "RobotErasedEnrolledFace",
  98. "RobotObservedFace",
  99. "RobotObservedMotion",
  100. "RobotObservedObject",
  101. "RobotObservedPet",
  102. "RobotObservedPossibleObject",
  103. "RobotOnChargerPlatformEvent",
  104. "RobotPoked",
  105. "RobotReachedEnrollmentCount",
  106. "RobotRenamedEnrolledFace",
  107. "RobotState",
  108. "UnexpectedMovement"}
  109. class CozmoConnection(event.Dispatcher, clad_protocol.CLADProtocol):
  110. '''Manages the connection to the Cozmo app to communicate with the core engine.
  111. An instance of this class is passed to functions used with
  112. :func:`cozmo.run.connect`. At the point the function is executed,
  113. the connection is already established and verified, and the
  114. :class:`EvtConnected` has already been sent.
  115. However, after the initial connection is established, programs will usually
  116. want to call :meth:`wait_for_robot` to wait for an actual Cozmo robot to
  117. be detected and initialized before doing useful work.
  118. '''
  119. #: callable: The factory function that returns a
  120. #: :class:`cozmo.robot.Robot` class or subclass instance.
  121. robot_factory = robot.Robot
  122. #: callable: The factory function that returns an
  123. #: :class:`cozmo.anim.AnimationNames` class or subclass instance.
  124. anim_names_factory = anim.AnimationNames
  125. # overrides for CLADProtocol
  126. clad_decode_union = _clad_to_game_iface.MessageEngineToGame
  127. clad_encode_union = _clad_to_engine_iface.MessageGameToEngine
  128. def __init__(self, *a, **kw):
  129. super().__init__(*a, **kw)
  130. self._is_connected = False
  131. self._is_ui_connected = False
  132. self._running = True
  133. self._robots = {}
  134. self._primary_robot = None
  135. #: A dict containing information about the device the connection is using.
  136. self.device_info = {}
  137. #: An :class:`cozmo.anim.AnimationNames` object that references all
  138. #: available animation names
  139. self.anim_names = self.anim_names_factory(self)
  140. #### Private Methods ####
  141. def __repr__(self):
  142. info = ' '.join(['%s="%s"' % (k, self.device_info[k])
  143. for k in sorted(self.device_info.keys())])
  144. return '<%s %s>' % (self.__class__.__name__, info)
  145. def connection_made(self, transport):
  146. super().connection_made(transport)
  147. self._is_connected = True
  148. def connection_lost(self, exc):
  149. super().connection_lost(exc)
  150. self._is_connected = False
  151. if self._running:
  152. self.abort(exceptions.ConnectionAborted("Lost connection to the device"))
  153. logger.error("Lost connection to the device: %s", exc)
  154. async def shutdown(self):
  155. '''Close the connection to the device.'''
  156. if self._running and self._is_connected:
  157. logger.info("Shutting down connection")
  158. self._running = False
  159. event._abort_futures(exceptions.SDKShutdown())
  160. self._stop_dispatcher()
  161. self.transport.close()
  162. def abort(self, exc):
  163. '''Abort the connection to the device.'''
  164. if self._running:
  165. logger.info('Aborting connection: %s', exc)
  166. self._running = False
  167. # Allow any currently pending futures to complete before the
  168. # remainder are aborted.
  169. self._loop.call_soon(lambda: event._abort_futures(exc))
  170. self._stop_dispatcher()
  171. self.transport.close()
  172. def msg_received(self, msg):
  173. '''Receives low level communication messages from the engine.'''
  174. if not self._running:
  175. return
  176. try:
  177. tag_name = msg.tag_name
  178. if tag_name == 'Ping':
  179. # short circuit to avoid unnecessary event overhead
  180. return self._handle_ping(msg._data)
  181. elif tag_name == 'UiDeviceConnected':
  182. # handle outside of event dispatch for quick abort in case
  183. # of a version mismatch problem.
  184. return self._handle_ui_device_connected(msg._data)
  185. msg = msg._data
  186. robot_id = getattr(msg, 'robotID', None)
  187. event_name = '_Msg' + tag_name
  188. evttype = getattr(_clad, event_name, None)
  189. if evttype is None:
  190. logger.error('Received unknown CLAD message %s', event_name)
  191. return
  192. # Dispatch messages to the robot if they either:
  193. # a) are explicitly white listed in FORCED_ROBOT_MESSAGES
  194. # b) have a robotID specified in the message
  195. # Otherwise dispatch the message through this connection.
  196. if (robot_id is not None) or (tag_name in FORCED_ROBOT_MESSAGES):
  197. if robot_id is None:
  198. # The only robot ID ever used is 1, so it is safe to assume that here as a default.
  199. robot_id = 1
  200. self._process_robot_msg(robot_id, evttype, msg)
  201. else:
  202. self.dispatch_event(evttype, msg=msg)
  203. except Exception as exc:
  204. # No exceptions should reach this point; it's a bug if they do.
  205. self.abort(exc)
  206. def _process_robot_msg(self, robot_id, evttype, msg):
  207. if robot_id != 1:
  208. # Note: some messages replace robotID with value!=1 (like mfgID for example)
  209. # as a result, this log may fire quite often. Log Level is set to debug
  210. # since it suppressed by default (prevents spamming).
  211. logger.debug('INVALID ROBOT_ID SEEN robot_id=%s event=%s msg=%s', robot_id, evttype, msg.__str__())
  212. robot_id = 1 # XXX remove when errant messages have been fixed
  213. # Note: this code constructs the robot if it doesn't exist at this time
  214. robot = self._robots.get(robot_id)
  215. if not robot:
  216. logger.info('Found robot id=%s', robot_id)
  217. robot = self.robot_factory(self, robot_id, is_primary=self._primary_robot is None)
  218. self._robots[robot_id] = robot
  219. if not self._primary_robot:
  220. self._primary_robot = robot
  221. # Dispatch an event notifying that a new robot has been found
  222. # the robot itself will send EvtRobotReady after initialization
  223. self.dispatch_event(EvtRobotFound, robot=robot)
  224. # _initialize will set the robot to a known good state in the
  225. # background and dispatch a EvtRobotReady event when completed.
  226. robot._initialize()
  227. robot.dispatch_event(evttype, msg=msg)
  228. #### Properties ####
  229. @property
  230. def is_connected(self):
  231. '''bool: True if currently connected to the remote engine.'''
  232. return self._is_connected
  233. #### Private Event handlers ####
  234. def _handle_ping(self, msg):
  235. '''Respond to a ping event.'''
  236. if msg.isResponse:
  237. # To avoid duplication, pings originate from engine, and engine
  238. # accumulates the latency info from the responses
  239. logger.error("Only engine should receive responses")
  240. else:
  241. resp = _clad_to_engine_iface.Ping(
  242. counter=msg.counter,
  243. timeSent_ms=msg.timeSent_ms,
  244. isResponse=True)
  245. self.send_msg(resp)
  246. def _recv_default_handler(self, event, **kw):
  247. '''Default event handler.'''
  248. if event.event_name.startswith('msg_animation'):
  249. return self.anim.dispatch_event(event)
  250. logger.debug('Engine received unhandled event_name=%s kw=%s', event, kw)
  251. def _recv_msg_animation_available(self, evt, msg):
  252. self.anim_names.dispatch_event(evt)
  253. def _recv_msg_end_of_message(self, evt, *a, **kw):
  254. self.anim_names.dispatch_event(evt)
  255. def _handle_ui_device_connected(self, msg):
  256. if msg.connectionType != _clad_to_engine_cozmo.UiConnectionType.SdkOverTcp:
  257. # This isn't for us
  258. return
  259. if msg.deviceID != 1:
  260. logger.error('Unexpected Device Id %s', msg.deviceID)
  261. return
  262. # Verify that engine and SDK are compatible
  263. clad_hashes_match = False
  264. try:
  265. cozmoclad.assert_clad_match(msg.toGameCLADHash, msg.toEngineCLADHash)
  266. clad_hashes_match = True
  267. except cozmoclad.CLADHashMismatch as exc:
  268. logger.error(exc)
  269. build_versions_match = (cozmoclad.__build_version__ == '00000.00000.00000'
  270. or cozmoclad.__build_version__ == msg.buildVersion)
  271. if clad_hashes_match and not build_versions_match:
  272. # If CLAD hashes match, and this is only a minor version change,
  273. # then still allow connection (it's just an app hotfix
  274. # that didn't require CLAD or SDK changes)
  275. sdk_major_version = cozmoclad.__build_version__.split(".")[0:2]
  276. build_major_version = msg.buildVersion.split(".")[0:2]
  277. build_versions_match = (sdk_major_version == build_major_version)
  278. if clad_hashes_match and build_versions_match:
  279. connection_success_msg = _clad_to_engine_iface.UiDeviceConnectionSuccess(
  280. connectionType=msg.connectionType,
  281. deviceID=msg.deviceID,
  282. buildVersion = cozmoclad.__version__,
  283. sdkModuleVersion = version.__version__,
  284. pythonVersion = platform.python_version(),
  285. pythonImplementation = platform.python_implementation(),
  286. osVersion = platform.platform(),
  287. cpuVersion = platform.machine())
  288. self.send_msg(connection_success_msg)
  289. else:
  290. try:
  291. wrong_version_msg = _clad_to_engine_iface.UiDeviceConnectionWrongVersion(
  292. reserved=0,
  293. connectionType=msg.connectionType,
  294. deviceID = msg.deviceID,
  295. buildVersion = cozmoclad.__version__)
  296. self.send_msg(wrong_version_msg)
  297. except AttributeError:
  298. pass
  299. line_separator = "=" * 80
  300. error_message = "\n" + line_separator + "\n"
  301. def _trimmed_version(ver_string):
  302. # Trim leading zeros from the version string.
  303. trimmed_string = ""
  304. for i in ver_string.split("."):
  305. trimmed_string += str(int(i)) + "."
  306. return trimmed_string[:-1] # remove trailing "."
  307. if not build_versions_match:
  308. error_message += ("App and SDK versions do not match!\n"
  309. "----------------------------------\n"
  310. "SDK's cozmoclad version: %s\n"
  311. " != app version: %s\n\n"
  312. % (cozmoclad.__version__, _trimmed_version(msg.buildVersion)))
  313. if cozmoclad.__build_version__ < msg.buildVersion:
  314. # App is newer
  315. error_message += ('Please update your SDK to the newest version by calling command:\n'
  316. '"pip3 install --user --upgrade cozmo"\n'
  317. 'and downloading the latest examples from:\n'
  318. 'http://cozmosdk.anki.com/docs/downloads.html\n')
  319. else:
  320. # SDK is newer
  321. error_message += ('Please either:\n\n'
  322. '1) Update your app to the most recent version on the app store.\n'
  323. '2) Or, if you prefer, please determine which SDK version matches\n'
  324. ' your app version at: http://go.anki.com/cozmo-sdk-version\n'
  325. ' Then downgrade your SDK by calling the following command,\n'
  326. ' replacing SDK_VERSION with the version listed at that page:\n'
  327. ' "pip3 install --ignore-installed cozmo==SDK_VERSION"\n')
  328. else:
  329. # CLAD version mismatch
  330. error_message += ('CLAD Hashes do not match!\n'
  331. '-------------------------\n'
  332. 'Your Python and C++ CLAD versions do not match - connection refused.\n'
  333. 'Please check that you have the most recent versions of both the SDK and the\n'
  334. 'Cozmo app. You may update your SDK by calling:\n'
  335. '"pip3 install --user --upgrade cozmo".\n'
  336. 'Please also check the app store for a Cozmo app update.\n')
  337. error_message += line_separator
  338. logger.error(error_message)
  339. exc = exceptions.SDKVersionMismatch("SDK library does not match software running on device",
  340. sdk_version=version.__version__,
  341. sdk_app_version=cozmoclad.__version__,
  342. app_version=_trimmed_version(msg.buildVersion))
  343. self._abort_connection = True # Ignore remaining messages - they're not safe to unpack
  344. self.abort(exc)
  345. return
  346. self._is_ui_connected = True
  347. self.dispatch_event(EvtConnected, conn=self)
  348. logger.info('App connection established. sdk_version=%s '
  349. 'cozmoclad_version=%s app_build_version=%s',
  350. version.__version__, cozmoclad.__version__, msg.buildVersion)
  351. # We send RequestConnectedObjects and RequestLocatedObjectStates before
  352. # refreshing the animation names as this ensures that we will receive
  353. # the responses before we mark the robot as ready.
  354. self._request_connected_objects()
  355. self._request_located_objects()
  356. self.anim_names.refresh()
  357. def _request_connected_objects(self):
  358. # Request information on connected objects (e.g. the object ID of each cube)
  359. # (this won't provide location/pose info)
  360. msg = _clad_to_engine_iface.RequestConnectedObjects()
  361. self.send_msg(msg)
  362. def _request_located_objects(self):
  363. # Request the pose information for all objects whose location we know
  364. # (this won't include any objects where the location is currently not known)
  365. msg = _clad_to_engine_iface.RequestLocatedObjectStates()
  366. self.send_msg(msg)
  367. def _recv_msg_image_chunk(self, evt, *, msg):
  368. if self._primary_robot:
  369. self._primary_robot.dispatch_event(evt)
  370. #### Public Event Handlers ####
  371. #### Commands ####
  372. async def _wait_for_robot(self, timeout=5):
  373. if not self._primary_robot:
  374. await self.wait_for(EvtRobotFound, timeout=timeout)
  375. if self._primary_robot.is_ready:
  376. return self._primary_robot
  377. await self._primary_robot.wait_for(robot.EvtRobotReady, timeout=timeout)
  378. return self._primary_robot
  379. async def wait_for_robot(self, timeout=5):
  380. '''Wait for a Cozmo robot to connect and complete initialization.
  381. Args:
  382. timeout (float): Maximum length of time to wait for a robot to be ready in seconds.
  383. Returns:
  384. A :class:`cozmo.robot.Robot` instance that's ready to use.
  385. Raises:
  386. :class:`asyncio.TimeoutError` if there's no response from the robot.
  387. '''
  388. try:
  389. robot = await self._wait_for_robot(timeout)
  390. if robot and robot.drive_off_charger_on_connect:
  391. await robot.drive_off_charger_contacts().wait_for_completed()
  392. except asyncio.TimeoutError:
  393. logger.error('Timed out waiting for robot to initialize')
  394. raise
  395. return robot