Use simctl instead of LayoutTestRelay
[WebKit.git] / Tools / Scripts / webkitpy / xcode / simulator.py
1 # Copyright (C) 2014, 2015 Apple Inc. All rights reserved.
2 #
3 # Redistribution and use in source and binary forms, with or without
4 # modification, are permitted provided that the following conditions
5 # are met:
6 # 1.  Redistributions of source code must retain the above copyright
7 #     notice, this list of conditions and the following disclaimer.
8 # 2.  Redistributions in binary form must reproduce the above copyright
9 #     notice, this list of conditions and the following disclaimer in the
10 #     documentation and/or other materials provided with the distribution.
11 #
12 # THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS'' AND ANY
13 # EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
14 # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
15 # DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR ANY
16 # DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
17 # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
18 # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
19 # ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
20 # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
21 # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
22
23 import itertools
24 import logging
25 import os
26 import plistlib
27 import re
28 import subprocess
29 import time
30
31 from webkitpy.benchmark_runner.utils import timeout
32 from webkitpy.common.host import Host
33
34 _log = logging.getLogger(__name__)
35
36 """
37 Minimally wraps CoreSimulator functionality through simctl.
38
39 If possible, use real CoreSimulator.framework functionality by linking to the framework itself.
40 Do not use PyObjC to dlopen the framework.
41 """
42
43
44 class DeviceType(object):
45     """
46     Represents a CoreSimulator device type.
47     """
48     def __init__(self, name, identifier):
49         """
50         :param name: The device type's human-readable name
51         :type name: str
52         :param identifier: The CoreSimulator identifier.
53         :type identifier: str
54         """
55         self.name = name
56         self.identifier = identifier
57
58     @classmethod
59     def from_name(cls, name):
60         """
61         :param name: The name for the desired device type.
62         :type name: str
63         :returns: A `DeviceType` object with the specified identifier or throws a TypeError if it doesn't exist.
64         :rtype: DeviceType
65         """
66         identifier = None
67         for device_type in Simulator().device_types:
68             if device_type.name == name:
69                 identifier = device_type.identifier
70                 break
71
72         if identifier is None:
73             raise TypeError('A device type with name "{name}" does not exist.'.format(name=name))
74
75         return DeviceType(name, identifier)
76
77     @classmethod
78     def from_identifier(cls, identifier):
79         """
80         :param identifier: The CoreSimulator identifier for the desired runtime.
81         :type identifier: str
82         :returns: A `Runtime` object witht the specified identifier or throws a TypeError if it doesn't exist.
83         :rtype: DeviceType
84         """
85         name = None
86         for device_type in Simulator().device_types:
87             if device_type.identifier == identifier:
88                 name = device_type.name
89                 break
90
91         if name is None:
92             raise TypeError('A device type with identifier "{identifier}" does not exist.'.format(
93                 identifier=identifier))
94
95         return DeviceType(name, identifier)
96
97     def __eq__(self, other):
98         return (self.name == other.name) and (self.identifier == other.identifier)
99
100     def __ne__(self, other):
101         return not self.__eq__(other)
102
103     def __repr__(self):
104         return '<DeviceType "{name}": {identifier}>'.format(name=self.name, identifier=self.identifier)
105
106
107 class Runtime(object):
108     """
109     Represents a CoreSimulator runtime associated with an iOS SDK.
110     """
111
112     def __init__(self, version, identifier, available, devices=None, is_internal_runtime=False):
113         """
114         :param version: The iOS SDK version
115         :type version: tuple
116         :param identifier: The CoreSimulator runtime identifier
117         :type identifier: str
118         :param availability: Whether the runtime is available for use.
119         :type availability: bool
120         :param devices: A list of devices under this runtime
121         :type devices: list or None
122         :param is_internal_runtime: Whether the runtime is an Apple internal runtime
123         :type is_internal_runtime: bool
124         """
125         self.version = version
126         self.identifier = identifier
127         self.available = available
128         self.devices = devices or []
129         self.is_internal_runtime = is_internal_runtime
130
131     @classmethod
132     def from_version_string(cls, version):
133         return cls.from_identifier('com.apple.CoreSimulator.SimRuntime.iOS-' + version.replace('.', '-'))
134
135     @classmethod
136     def from_identifier(cls, identifier):
137         """
138         :param identifier: The identifier for the desired CoreSimulator runtime.
139         :type identifier: str
140         :returns: A `Runtime` object with the specified identifier or throws a TypeError if it doesn't exist.
141         :rtype: Runtime
142         """
143         for runtime in Simulator().runtimes:
144             if runtime.identifier == identifier:
145                 return runtime
146         raise TypeError('A runtime with identifier "{identifier}" does not exist.'.format(identifier=identifier))
147
148     def __eq__(self, other):
149         return (self.version == other.version) and (self.identifier == other.identifier) and (self.is_internal_runtime == other.is_internal_runtime)
150
151     def __ne__(self, other):
152         return not self.__eq__(other)
153
154     def __repr__(self):
155         version_suffix = ""
156         if self.is_internal_runtime:
157             version_suffix = " Internal"
158         return '<Runtime {version}: {identifier}. Available: {available}, {num_devices} devices>'.format(
159             version='.'.join(map(str, self.version)) + version_suffix,
160             identifier=self.identifier,
161             available=self.available,
162             num_devices=len(self.devices))
163
164
165 class Device(object):
166     """
167     Represents a CoreSimulator device underneath a runtime
168     """
169
170     def __init__(self, name, udid, available, runtime, host):
171         """
172         :param name: The device name
173         :type name: str
174         :param udid: The device UDID (a UUID string)
175         :type udid: str
176         :param available: Whether the device is available for use.
177         :type available: bool
178         :param runtime: The iOS Simulator runtime that hosts this device
179         :type runtime: Runtime
180         :param host: The host which can run command line commands
181         :type host: Host
182         """
183         self._host = host
184         self.name = name
185         self.udid = udid
186         self.available = available
187         self.runtime = runtime
188
189     @property
190     def state(self):
191         """
192         :returns: The current state of the device.
193         :rtype: Simulator.DeviceState
194         """
195         return Simulator.device_state(self.udid)
196
197     @property
198     def path(self):
199         """
200         :returns: The filesystem path that contains the simulator device's data.
201         :rtype: str
202         """
203         return Simulator.device_directory(self.udid)
204
205     @classmethod
206     def create(cls, name, device_type, runtime):
207         """
208         Create a new CoreSimulator device.
209         :param name: The name of the device.
210         :type name: str
211         :param device_type: The CoreSimulatort device type.
212         :type device_type: DeviceType
213         :param runtime:  The CoreSimualtor runtime.
214         :type runtime: Runtime
215         :return: The new device or raises a CalledProcessError if ``simctl create`` failed.
216         :rtype: Device
217         """
218         device_udid = subprocess.check_output(['xcrun', 'simctl', 'create', name, device_type.identifier, runtime.identifier]).rstrip()
219         _log.debug('"xcrun simctl create %s %s %s" returned %s', name, device_type.identifier, runtime.identifier, device_udid)
220         Simulator.wait_until_device_is_in_state(device_udid, Simulator.DeviceState.SHUTDOWN)
221         return Simulator().find_device_by_udid(device_udid)
222
223     @classmethod
224     def shutdown(cls, udid):
225         """
226         Shut down the given CoreSimulator device.
227         :param udid: The udid of the device.
228         :type udid: str
229         """
230         device_state = Simulator.device_state(udid)
231         if device_state == Simulator.DeviceState.BOOTING or device_state == Simulator.DeviceState.BOOTED:
232             _log.debug('xcrun simctl shutdown %s', udid)
233             # Don't throw on error. Device shutdown seems to be racy with Simulator app killing.
234             subprocess.call(['xcrun', 'simctl', 'shutdown', udid])
235
236         Simulator.wait_until_device_is_in_state(udid, Simulator.DeviceState.SHUTDOWN)
237
238     @classmethod
239     def delete(cls, udid):
240         """
241         Delete the given CoreSimulator device.
242         :param udid: The udid of the device.
243         :type udid: str
244         """
245         Device.shutdown(udid)
246         try:
247             _log.debug('xcrun simctl delete %s', udid)
248             subprocess.check_call(['xcrun', 'simctl', 'delete', udid])
249         except subprocess.CalledProcessError:
250             raise RuntimeError('"xcrun simctl delete" failed: device state is {}'.format(Simulator.device_state(udid)))
251
252     @classmethod
253     def reset(cls, udid):
254         """
255         Reset the given CoreSimulator device.
256         :param udid: The udid of the device.
257         :type udid: str
258         """
259         Device.shutdown(udid)
260         try:
261             _log.debug('xcrun simctl erase %s', udid)
262             subprocess.check_call(['xcrun', 'simctl', 'erase', udid])
263         except subprocess.CalledProcessError:
264             raise RuntimeError('"xcrun simctl erase" failed: device state is {}'.format(Simulator.device_state(udid)))
265
266     def install_app(self, app_path):
267         return not self._host.executive.run_command(['xcrun', 'simctl', 'install', self.udid, app_path], return_exit_code=True)
268
269     def launch_app(self, bundle_id, args, env=None):
270         environment_to_use = {}
271         SIMCTL_ENV_PREFIX = 'SIMCTL_CHILD_'
272         for value in (env or {}):
273             if not value.startswith(SIMCTL_ENV_PREFIX):
274                 environment_to_use[SIMCTL_ENV_PREFIX + value] = env[value]
275             else:
276                 environment_to_use[value] = env[value]
277
278         output = self._host.executive.run_command(
279             ['xcrun', 'simctl', 'launch', self.udid, bundle_id] + args,
280             env=environment_to_use,
281         )
282
283         match = re.match(r'(?P<bundle>[^:]+): (?P<pid>\d+)\n', output)
284         if not match or match.group('bundle') != bundle_id:
285             raise RuntimeError('Failed to find process id for {}: {}'.format(bundle_id, output))
286         return int(match.group('pid'))
287
288     def terminate_app(self, bundle_id):
289         return not self._host.executive.run_command(['xcrun', 'simctl', 'terminate', self.udid, bundle_id], return_exit_code=True)
290
291     def __eq__(self, other):
292         return self.udid == other.udid
293
294     def __ne__(self, other):
295         return not self.__eq__(other)
296
297     def __repr__(self):
298         return '<Device "{name}": {udid}. State: {state}. Runtime: {runtime}, Available: {available}>'.format(
299             name=self.name,
300             udid=self.udid,
301             state=self.state,
302             available=self.available,
303             runtime=self.runtime.identifier)
304
305
306 # FIXME: This class is fragile because it parses the output of the simctl command line utility, which may change.
307 #        We should find a better way to query for simulator device state and capabilities. Maybe take a similiar
308 #        approach as in webkitdirs.pm and utilize the parsed output from the device.plist files in the sub-
309 #        directories of ~/Library/Developer/CoreSimulator/Devices?
310 #        Also, simctl has the option to output in JSON format (xcrun simctl list --json).
311 class Simulator(object):
312     """
313     Represents the iOS Simulator infrastructure under the currently select Xcode.app bundle.
314     """
315     device_type_re = re.compile('(?P<name>[^(]+)\((?P<identifier>[^)]+)\)')
316     # FIXME: runtime_re parses the version from the runtime name, but that does not contain the full version number
317     # (it can omit the revision). We should instead parse the version from the number contained in parentheses.
318     runtime_re = re.compile(
319         '(i|watch|tv)OS (?P<version>\d+\.\d)(?P<internal> Internal)? \(\d+\.\d+(\.\d+)? - (?P<build_version>[^)]+)\) \((?P<identifier>[^)]+)\)( \((?P<availability>[^)]+)\))?')
320     unavailable_version_re = re.compile('-- Unavailable: (?P<identifier>[^ ]+) --')
321     version_re = re.compile('-- (i|watch|tv)OS (?P<version>\d+\.\d+)(?P<internal> Internal)? --')
322     devices_re = re.compile(
323         '\s*(?P<name>[^(]+ )\((?P<udid>[^)]+)\) \((?P<state>[^)]+)\)( \((?P<availability>[^)]+)\))?')
324
325     _managed_devices = {}
326
327     def __init__(self, host=None):
328         self._host = host or Host()
329         self.runtimes = []
330         self.device_types = []
331         self.refresh()
332
333     # Keep these constants synchronized with the SimDeviceState constants in CoreSimulator/SimDevice.h.
334     class DeviceState:
335         DOES_NOT_EXIST = -1
336         CREATING = 0
337         SHUTDOWN = 1
338         BOOTING = 2
339         BOOTED = 3
340         SHUTTING_DOWN = 4
341
342     NAME_FOR_STATE = [
343         'CREATING',
344         'SHUTDOWN',
345         'BOOTING',
346         'BOOTED',
347         'SHUTTING_DOWN'
348     ]
349
350     @staticmethod
351     def create_device(number, device_type, runtime):
352         device = Simulator().lookup_or_create_device(device_type.name + ' WebKit Tester' + str(number), device_type, runtime)
353         _log.debug('created device {} {}'.format(number, device))
354         assert(len(Simulator._managed_devices) == number)
355         Simulator._managed_devices[number] = device
356
357     @staticmethod
358     def remove_device(number):
359         if not Simulator._managed_devices[number]:
360             return
361         device_udid = Simulator._managed_devices[number].udid
362         _log.debug('removing device {} {}'.format(number, device_udid))
363         del Simulator._managed_devices[number]
364         Simulator.delete_device(device_udid)
365
366     @staticmethod
367     def device_number(number):
368         return Simulator._managed_devices[number]
369
370     @staticmethod
371     def device_state_description(state):
372         if (state == Simulator.DeviceState.DOES_NOT_EXIST):
373             return 'DOES_NOT_EXIST'
374         return Simulator.NAME_FOR_STATE[state]
375
376     @staticmethod
377     def wait_until_device_is_booted(udid, timeout_seconds=60 * 5):
378         Simulator.wait_until_device_is_in_state(udid, Simulator.DeviceState.BOOTED, timeout_seconds)
379         with timeout(seconds=timeout_seconds):
380             while True:
381                 try:
382                     state = subprocess.check_output(['xcrun', 'simctl', 'spawn', udid, 'launchctl', 'print', 'system']).strip()
383                     _log.debug('xcrun simctl spawn %s', udid)
384
385                     if re.search("A[\s]+com.apple.springboard.services", state):
386                         return
387                 except subprocess.CalledProcessError:
388                     if Simulator.device_state(udid) != Simulator.DeviceState.BOOTED:
389                         raise RuntimeError('Simuator device quit unexpectedly.')
390                     _log.warn("Error in checking Simulator boot status. Will retry in 1 second.")
391                 time.sleep(1)
392
393     @staticmethod
394     def wait_until_device_is_in_state(udid, wait_until_state, timeout_seconds=60 * 5):
395         _log.debug('waiting for device %s to enter state %s with timeout %s', udid, Simulator.device_state_description(wait_until_state), timeout_seconds)
396         with timeout(seconds=timeout_seconds):
397             device_state = Simulator.device_state(udid)
398             while (device_state != wait_until_state):
399                 device_state = Simulator.device_state(udid)
400                 _log.debug(' device state %s', Simulator.device_state_description(device_state))
401                 time.sleep(0.5)
402
403         end_state = Simulator.device_state(udid)
404         if (end_state != wait_until_state):
405             raise RuntimeError('Timed out waiting for simulator device to enter state {0}; current state is {1}'.format(Simulator.device_state_description(wait_until_state), Simulator.device_state_description(end_state)))
406
407     @staticmethod
408     def device_state(udid):
409         device_plist = os.path.join(Simulator.device_directory(udid), 'device.plist')
410         if not os.path.isfile(device_plist):
411             return Simulator.DeviceState.DOES_NOT_EXIST
412         return plistlib.readPlist(device_plist)['state']
413
414     @staticmethod
415     def device_directory(udid):
416         return os.path.realpath(os.path.expanduser(os.path.join('~/Library/Developer/CoreSimulator/Devices', udid)))
417
418     @staticmethod
419     def delete_device(udid):
420         Device.delete(udid)
421
422     @staticmethod
423     def reset_device(udid):
424         Device.reset(udid)
425
426     def refresh(self):
427         """
428         Refresh runtime and device type information from ``simctl list``.
429         """
430         lines = self._host.platform.xcode_simctl_list()
431         if not lines:
432             return
433         device_types_header = next(lines)
434         if device_types_header != '== Device Types ==':
435             raise RuntimeError('Expected == Device Types == header but got: "{}"'.format(device_types_header))
436         self._parse_device_types(lines)
437
438     def _parse_device_types(self, lines):
439         """
440         Parse device types from ``simctl list``.
441         :param lines: A generator for the output lines from ``simctl list``.
442         :type lines: genexpr
443         :return: None
444         """
445         for line in lines:
446             device_type_match = self.device_type_re.match(line)
447             if not device_type_match:
448                 if line != '== Runtimes ==':
449                     raise RuntimeError('Expected == Runtimes == header but got: "{}"'.format(line))
450                 break
451             device_type = DeviceType(name=device_type_match.group('name').rstrip(),
452                                      identifier=device_type_match.group('identifier'))
453             self.device_types.append(device_type)
454
455         self._parse_runtimes(lines)
456
457     def _parse_runtimes(self, lines):
458         """
459         Continue to parse runtimes from ``simctl list``.
460         :param lines: A generator for the output lines from ``simctl list``.
461         :type lines: genexpr
462         :return: None
463         """
464         for line in lines:
465             runtime_match = self.runtime_re.match(line)
466             if not runtime_match:
467                 if line != '== Devices ==':
468                     raise RuntimeError('Expected == Devices == header but got: "{}"'.format(line))
469                 break
470             version = tuple(map(int, runtime_match.group('version').split('.')))
471             runtime = Runtime(version=version,
472                               identifier=runtime_match.group('identifier'),
473                               available=runtime_match.group('availability') is None,
474                               is_internal_runtime=bool(runtime_match.group('internal')))
475             self.runtimes.append(runtime)
476         self._parse_devices(lines)
477
478     def _parse_devices(self, lines):
479         """
480         Continue to parse devices from ``simctl list``.
481         :param lines: A generator for the output lines from ``simctl list``.
482         :type lines: genexpr
483         :return: None
484         """
485         current_runtime = None
486         for line in lines:
487             version_match = self.version_re.match(line)
488             if version_match:
489                 version = tuple(map(int, version_match.group('version').split('.')))
490                 current_runtime = self.runtime(version=version, is_internal_runtime=bool(version_match.group('internal')))
491                 assert current_runtime
492                 continue
493
494             unavailable_version_match = self.unavailable_version_re.match(line)
495             if unavailable_version_match:
496                 current_runtime = None
497                 continue
498
499             device_match = self.devices_re.match(line)
500             if not device_match:
501                 if line != '== Device Pairs ==':
502                     raise RuntimeError('Expected == Device Pairs == header but got: "{}"'.format(line))
503                 break
504             if current_runtime:
505                 device = Device(name=device_match.group('name').rstrip(),
506                                 udid=device_match.group('udid'),
507                                 available=device_match.group('availability') is None,
508                                 runtime=current_runtime,
509                                 host=self._host)
510                 current_runtime.devices.append(device)
511
512     def device_type(self, name=None, identifier=None):
513         """
514         :param name: The short name of the device type.
515         :type name: str
516         :param identifier: The CoreSimulator identifier of the desired device type.
517         :type identifier: str
518         :return: A device type with the specified name and/or identifier, or None if one doesn't exist as such.
519         :rtype: DeviceType
520         """
521         for device_type in self.device_types:
522             if name and device_type.name != name:
523                 continue
524             if identifier and device_type.identifier != identifier:
525                 continue
526             return device_type
527         return None
528
529     def runtime(self, version=None, identifier=None, is_internal_runtime=None):
530         """
531         :param version: The iOS version of the desired runtime.
532         :type version: tuple
533         :param identifier: The CoreSimulator identifier of the desired runtime.
534         :type identifier: str
535         :return: A runtime with the specified version and/or identifier, or None if one doesn't exist as such.
536         :rtype: Runtime or None
537         """
538         if version is None and identifier is None:
539             raise TypeError('Must supply version and/or identifier.')
540
541         for runtime in self.runtimes:
542             if version and runtime.version != version:
543                 continue
544             if is_internal_runtime and runtime.is_internal_runtime != is_internal_runtime:
545                 continue
546             if identifier and runtime.identifier != identifier:
547                 continue
548             return runtime
549         return None
550
551     def find_device_by_udid(self, udid):
552         """
553         :param udid: The UDID of the device to find.
554         :type udid: str
555         :return: The `Device` with the specified UDID.
556         :rtype: Device
557         """
558         for device in self.devices:
559             if device.udid == udid:
560                 return device
561         return None
562
563     def current_device(self):
564         # FIXME: Find the simulator device that was booted by Simulator.app. For now, pick some booted simulator device, which
565         # may have been booted using the simctl command line tool.
566         for device in self.devices:
567             if device.state == Simulator.DeviceState.BOOTED:
568                 return device
569         return None
570
571     # FIXME: We should find an existing device with respect to its name, device type and runtime.
572     def device(self, name=None, runtime=None, should_ignore_unavailable_devices=False):
573         """
574         :param name: The name of the desired device.
575         :type name: str
576         :param runtime: The runtime of the desired device.
577         :type runtime: Runtime
578         :return: A device with the specified name and/or runtime, or None if one doesn't exist as such
579         :rtype: Device or None
580         """
581         if name is None and runtime is None:
582             raise TypeError('Must supply name and/or runtime.')
583
584         for device in self.devices:
585             if should_ignore_unavailable_devices and not device.available:
586                 continue
587             if name and device.name != name:
588                 continue
589             if runtime and device.runtime != runtime:
590                 continue
591             return device
592         return None
593
594     @property
595     def available_runtimes(self):
596         """
597         :return: An iterator of all available runtimes.
598         :rtype: iter
599         """
600         return itertools.ifilter(lambda runtime: runtime.available, self.runtimes)
601
602     @property
603     def devices(self):
604         """
605         :return: An iterator of all devices from all runtimes.
606         :rtype: iter
607         """
608         return itertools.chain(*[runtime.devices for runtime in self.runtimes])
609
610     @property
611     def latest_available_runtime(self):
612         """
613         :return: Returns a Runtime object with the highest version.
614         :rtype: Runtime or None
615         """
616         if not self.runtimes:
617             return None
618         return sorted(self.available_runtimes, key=lambda runtime: runtime.version, reverse=True)[0]
619
620     def lookup_or_create_device(self, name, device_type, runtime):
621         """
622         Returns an available iOS Simulator device for testing.
623
624         This function will create a new simulator device with the specified name,
625         device type and runtime if one does not already exist.
626
627         :param name: The name of the simulator device to lookup or create.
628         :type name: str
629         :param device_type: The CoreSimulator device type.
630         :type device_type: DeviceType
631         :param runtime: The CoreSimulator runtime.
632         :type runtime: Runtime
633         :return: A dictionary describing the device.
634         :rtype: Device
635         """
636         assert(runtime.available)
637         testing_device = self.device(name=name, runtime=runtime, should_ignore_unavailable_devices=True)
638         if testing_device:
639             _log.debug('lookup_or_create_device %s %s %s found %s', name, device_type, runtime, testing_device.name)
640             return testing_device
641         testing_device = Device.create(name, device_type, runtime)
642         _log.debug('lookup_or_create_device %s %s %s created %s', name, device_type, runtime, testing_device.name)
643         assert(testing_device.available)
644         return testing_device
645
646     def __repr__(self):
647         return '<iOS Simulator: {num_runtimes} runtimes, {num_device_types} device types>'.format(
648             num_runtimes=len(self.runtimes),
649             num_device_types=len(self.device_types))
650
651     def __str__(self):
652         description = ['iOS Simulator:']
653         description += map(str, self.runtimes)
654         description += map(str, self.device_types)
655         description += map(str, self.devices)
656         return '\n'.join(description)