Chromium Code Reviews| OLD | NEW |
|---|---|
| 1 # Copyright 2014 The Chromium Authors. All rights reserved. | 1 # Copyright 2014 The Chromium Authors. All rights reserved. |
| 2 # Use of this source code is governed by a BSD-style license that can be | 2 # Use of this source code is governed by a BSD-style license that can be |
| 3 # found in the LICENSE file. | 3 # found in the LICENSE file. |
| 4 | 4 |
| 5 """Provides a variety of device interactions based on adb. | 5 """Provides a variety of device interactions based on adb. |
| 6 | 6 |
| 7 Eventually, this will be based on adb_wrapper. | 7 Eventually, this will be based on adb_wrapper. |
| 8 """ | 8 """ |
| 9 # pylint: disable=W0613 | 9 # pylint: disable=W0613 |
| 10 | 10 |
| (...skipping 112 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... | |
| 123 timeout: timeout in seconds | 123 timeout: timeout in seconds |
| 124 retries: number of retries | 124 retries: number of retries |
| 125 | 125 |
| 126 Returns: | 126 Returns: |
| 127 True if adbd has root privileges, False otherwise. | 127 True if adbd has root privileges, False otherwise. |
| 128 | 128 |
| 129 Raises: | 129 Raises: |
| 130 CommandTimeoutError on timeout. | 130 CommandTimeoutError on timeout. |
| 131 DeviceUnreachableError on missing device. | 131 DeviceUnreachableError on missing device. |
| 132 """ | 132 """ |
| 133 return self._HasRootImpl() | |
| 134 | |
| 135 def _HasRootImpl(self): | |
| 136 try: | 133 try: |
| 137 self._RunShellCommandImpl('ls /root', check_return=True) | 134 self.RunShellCommand('ls /root', check_return=True) |
| 138 return True | 135 return True |
| 139 except device_errors.AdbShellCommandFailedError: | 136 except device_errors.AdbShellCommandFailedError: |
| 140 return False | 137 return False |
| 141 | 138 |
| 139 def NeedsSU(self, timeout=None, retries=None): | |
| 140 """Checks whether 'su' is needed to access protected resources. | |
| 141 | |
| 142 Args: | |
| 143 timeout: timeout in seconds | |
| 144 retries: number of retries | |
| 145 | |
| 146 Returns: | |
| 147 True if 'su' is available on the device and is needed to to access | |
| 148 protected resources; False otherwise if either 'su' is not available | |
| 149 (e.g. because the device has a user build), or not needed (because adbd | |
| 150 already has root privileges). | |
| 151 | |
| 152 Raises: | |
| 153 CommandTimeoutError on timeout. | |
| 154 DeviceUnreachableError on missing device. | |
| 155 """ | |
| 156 if 'needs_su' not in self._cache: | |
| 157 try: | |
| 158 self.RunShellCommand('su -c ls /root && ! ls /root', check_return=True, | |
| 159 timeout=timeout, retries=retries) | |
| 160 self._cache['needs_su'] = True | |
| 161 except device_errors.AdbShellCommandFailedError: | |
| 162 self._cache['needs_su'] = False | |
| 163 return self._cache['needs_su'] | |
| 164 | |
| 165 | |
| 142 @decorators.WithTimeoutAndRetriesFromInstance() | 166 @decorators.WithTimeoutAndRetriesFromInstance() |
| 143 def EnableRoot(self, timeout=None, retries=None): | 167 def EnableRoot(self, timeout=None, retries=None): |
| 144 """Restarts adbd with root privileges. | 168 """Restarts adbd with root privileges. |
| 145 | 169 |
| 146 Args: | 170 Args: |
| 147 timeout: timeout in seconds | 171 timeout: timeout in seconds |
| 148 retries: number of retries | 172 retries: number of retries |
| 149 | 173 |
| 150 Raises: | 174 Raises: |
| 151 CommandFailedError if root could not be enabled. | 175 CommandFailedError if root could not be enabled. |
| 152 CommandTimeoutError on timeout. | 176 CommandTimeoutError on timeout. |
| 153 """ | 177 """ |
| 178 if 'needs_su' in self._cache: | |
| 179 del self._cache['needs_su'] | |
| 154 if not self.old_interface.EnableAdbRoot(): | 180 if not self.old_interface.EnableAdbRoot(): |
| 155 raise device_errors.CommandFailedError( | 181 raise device_errors.CommandFailedError( |
| 156 'Could not enable root.', device=str(self)) | 182 'Could not enable root.', device=str(self)) |
| 157 | 183 |
| 158 @decorators.WithTimeoutAndRetriesFromInstance() | 184 @decorators.WithTimeoutAndRetriesFromInstance() |
| 159 def IsUserBuild(self, timeout=None, retries=None): | 185 def IsUserBuild(self, timeout=None, retries=None): |
| 160 """Checks whether or not the device is running a user build. | 186 """Checks whether or not the device is running a user build. |
| 161 | 187 |
| 162 Args: | 188 Args: |
| 163 timeout: timeout in seconds | 189 timeout: timeout in seconds |
| (...skipping 24 matching lines...) Expand all Loading... | |
| 188 CommandFailedError if the external storage path could not be determined. | 214 CommandFailedError if the external storage path could not be determined. |
| 189 CommandTimeoutError on timeout. | 215 CommandTimeoutError on timeout. |
| 190 DeviceUnreachableError on missing device. | 216 DeviceUnreachableError on missing device. |
| 191 """ | 217 """ |
| 192 return self._GetExternalStoragePathImpl() | 218 return self._GetExternalStoragePathImpl() |
| 193 | 219 |
| 194 def _GetExternalStoragePathImpl(self): | 220 def _GetExternalStoragePathImpl(self): |
| 195 if 'external_storage' in self._cache: | 221 if 'external_storage' in self._cache: |
| 196 return self._cache['external_storage'] | 222 return self._cache['external_storage'] |
| 197 | 223 |
| 198 value = self._RunShellCommandImpl('echo $EXTERNAL_STORAGE', | 224 value = self.RunShellCommand('echo $EXTERNAL_STORAGE', |
| 199 single_line=True, | 225 single_line=True, |
| 200 check_return=True) | 226 check_return=True) |
| 201 if not value: | 227 if not value: |
| 202 raise device_errors.CommandFailedError('$EXTERNAL_STORAGE is not set', | 228 raise device_errors.CommandFailedError('$EXTERNAL_STORAGE is not set', |
| 203 str(self)) | 229 str(self)) |
| 204 self._cache['external_storage'] = value | 230 self._cache['external_storage'] = value |
| 205 return value | 231 return value |
| 206 | 232 |
| 207 @decorators.WithTimeoutAndRetriesFromInstance() | 233 @decorators.WithTimeoutAndRetriesFromInstance() |
| 208 def GetApplicationPath(self, package, timeout=None, retries=None): | 234 def GetApplicationPath(self, package, timeout=None, retries=None): |
| 209 """Get the path of the installed apk on the device for the given package. | 235 """Get the path of the installed apk on the device for the given package. |
| 210 | 236 |
| (...skipping 175 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... | |
| 386 (with the optional newline at the end stripped). | 412 (with the optional newline at the end stripped). |
| 387 | 413 |
| 388 Raises: | 414 Raises: |
| 389 AdbShellCommandFailedError if check_return is True and the exit code of | 415 AdbShellCommandFailedError if check_return is True and the exit code of |
| 390 the command run on the device is non-zero. | 416 the command run on the device is non-zero. |
| 391 CommandFailedError if single_line is True but the output contains two or | 417 CommandFailedError if single_line is True but the output contains two or |
| 392 more lines. | 418 more lines. |
| 393 CommandTimeoutError on timeout. | 419 CommandTimeoutError on timeout. |
| 394 DeviceUnreachableError on missing device. | 420 DeviceUnreachableError on missing device. |
| 395 """ | 421 """ |
| 396 return self._RunShellCommandImpl(cmd, check_return=check_return, cwd=cwd, | |
| 397 env=env, as_root=as_root, single_line=single_line, timeout=timeout) | |
| 398 | |
| 399 def _RunShellCommandImpl(self, cmd, check_return=False, cwd=None, env=None, | |
|
jbudorick
2014/11/06 00:33:45
Nice cleanup!
| |
| 400 as_root=False, single_line=False, timeout=None): | |
| 401 def env_quote(key, value): | 422 def env_quote(key, value): |
| 402 if not DeviceUtils._VALID_SHELL_VARIABLE.match(key): | 423 if not DeviceUtils._VALID_SHELL_VARIABLE.match(key): |
| 403 raise KeyError('Invalid shell variable name %r' % key) | 424 raise KeyError('Invalid shell variable name %r' % key) |
| 404 # using double quotes here to allow interpolation of shell variables | 425 # using double quotes here to allow interpolation of shell variables |
| 405 return '%s=%s' % (key, cmd_helper.DoubleQuote(value)) | 426 return '%s=%s' % (key, cmd_helper.DoubleQuote(value)) |
| 406 | 427 |
| 407 if not isinstance(cmd, basestring): | 428 if not isinstance(cmd, basestring): |
| 408 cmd = ' '.join(cmd_helper.SingleQuote(s) for s in cmd) | 429 cmd = ' '.join(cmd_helper.SingleQuote(s) for s in cmd) |
| 409 if as_root and not self._HasRootImpl(): | 430 if as_root and self.NeedsSU(): |
| 410 cmd = 'su -c %s' % cmd | 431 cmd = 'su -c %s' % cmd |
| 411 if env: | 432 if env: |
| 412 env = ' '.join(env_quote(k, v) for k, v in env.iteritems()) | 433 env = ' '.join(env_quote(k, v) for k, v in env.iteritems()) |
| 413 cmd = '%s %s' % (env, cmd) | 434 cmd = '%s %s' % (env, cmd) |
| 414 if cwd: | 435 if cwd: |
| 415 cmd = 'cd %s && %s' % (cmd_helper.SingleQuote(cwd), cmd) | 436 cmd = 'cd %s && %s' % (cmd_helper.SingleQuote(cwd), cmd) |
| 416 if timeout is None: | 437 if timeout is None: |
| 417 timeout = self._default_timeout | 438 timeout = self._default_timeout |
| 418 | 439 |
| 419 try: | 440 try: |
| 420 # TODO(perezju) still need to make sure that we call a version of | 441 output = self.adb.Shell(cmd, expect_rc=0) |
| 421 # adb.Shell without a timeout-and-retries wrapper. | |
| 422 output = self.adb.Shell(cmd, expect_rc=0, timeout=timeout, retries=0) | |
| 423 except device_errors.AdbShellCommandFailedError as e: | 442 except device_errors.AdbShellCommandFailedError as e: |
| 424 if check_return: | 443 if check_return: |
| 425 raise | 444 raise |
| 426 else: | 445 else: |
| 427 output = e.output | 446 output = e.output |
| 428 | 447 |
| 429 output = output.splitlines() | 448 output = output.splitlines() |
| 430 if single_line: | 449 if single_line: |
| 431 if not output: | 450 if not output: |
| 432 return '' | 451 return '' |
| (...skipping 25 matching lines...) Expand all Loading... | |
| 458 CommandFailedError if no process was killed. | 477 CommandFailedError if no process was killed. |
| 459 CommandTimeoutError on timeout. | 478 CommandTimeoutError on timeout. |
| 460 DeviceUnreachableError on missing device. | 479 DeviceUnreachableError on missing device. |
| 461 """ | 480 """ |
| 462 pids = self._GetPidsImpl(process_name) | 481 pids = self._GetPidsImpl(process_name) |
| 463 if not pids: | 482 if not pids: |
| 464 raise device_errors.CommandFailedError( | 483 raise device_errors.CommandFailedError( |
| 465 'No process "%s"' % process_name, device=str(self)) | 484 'No process "%s"' % process_name, device=str(self)) |
| 466 | 485 |
| 467 cmd = ['kill', '-%d' % signum] + pids.values() | 486 cmd = ['kill', '-%d' % signum] + pids.values() |
| 468 self._RunShellCommandImpl(cmd, as_root=as_root, check_return=True) | 487 self.RunShellCommand(cmd, as_root=as_root, check_return=True) |
| 469 | 488 |
| 470 if blocking: | 489 if blocking: |
| 471 wait_period = 0.1 | 490 wait_period = 0.1 |
| 472 while self._GetPidsImpl(process_name): | 491 while self._GetPidsImpl(process_name): |
| 473 time.sleep(wait_period) | 492 time.sleep(wait_period) |
| 474 | 493 |
| 475 return len(pids) | 494 return len(pids) |
| 476 | 495 |
| 477 @decorators.WithTimeoutAndRetriesFromInstance() | 496 @decorators.WithTimeoutAndRetriesFromInstance() |
| 478 def StartActivity(self, intent, blocking=False, trace_file_name=None, | 497 def StartActivity(self, intent, blocking=False, trace_file_name=None, |
| (...skipping 130 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... | |
| 609 | 628 |
| 610 Raises: | 629 Raises: |
| 611 CommandFailedError on failure. | 630 CommandFailedError on failure. |
| 612 CommandTimeoutError on timeout. | 631 CommandTimeoutError on timeout. |
| 613 DeviceUnreachableError on missing device. | 632 DeviceUnreachableError on missing device. |
| 614 """ | 633 """ |
| 615 | 634 |
| 616 files = [] | 635 files = [] |
| 617 for h, d in host_device_tuples: | 636 for h, d in host_device_tuples: |
| 618 if os.path.isdir(h): | 637 if os.path.isdir(h): |
| 619 self._RunShellCommandImpl(['mkdir', '-p', d], check_return=True) | 638 self.RunShellCommand(['mkdir', '-p', d], check_return=True) |
| 620 files += self._GetChangedFilesImpl(h, d) | 639 files += self._GetChangedFilesImpl(h, d) |
| 621 | 640 |
| 622 if not files: | 641 if not files: |
| 623 return | 642 return |
| 624 | 643 |
| 625 size = sum(host_utils.GetRecursiveDiskUsage(h) for h, _ in files) | 644 size = sum(host_utils.GetRecursiveDiskUsage(h) for h, _ in files) |
| 626 file_count = len(files) | 645 file_count = len(files) |
| 627 dir_size = sum(host_utils.GetRecursiveDiskUsage(h) | 646 dir_size = sum(host_utils.GetRecursiveDiskUsage(h) |
| 628 for h, _ in host_device_tuples) | 647 for h, _ in host_device_tuples) |
| 629 dir_file_count = 0 | 648 dir_file_count = 0 |
| (...skipping 11 matching lines...) Expand all Loading... | |
| 641 | 660 |
| 642 self._InstallCommands() | 661 self._InstallCommands() |
| 643 | 662 |
| 644 if dir_push_duration < push_duration and ( | 663 if dir_push_duration < push_duration and ( |
| 645 dir_push_duration < zip_duration or not self._commands_installed): | 664 dir_push_duration < zip_duration or not self._commands_installed): |
| 646 self._PushChangedFilesIndividually(host_device_tuples) | 665 self._PushChangedFilesIndividually(host_device_tuples) |
| 647 elif push_duration < zip_duration or not self._commands_installed: | 666 elif push_duration < zip_duration or not self._commands_installed: |
| 648 self._PushChangedFilesIndividually(files) | 667 self._PushChangedFilesIndividually(files) |
| 649 else: | 668 else: |
| 650 self._PushChangedFilesZipped(files) | 669 self._PushChangedFilesZipped(files) |
| 651 self._RunShellCommandImpl( | 670 self.RunShellCommand( |
| 652 ['chmod', '-R', '777'] + [d for _, d in host_device_tuples], | 671 ['chmod', '-R', '777'] + [d for _, d in host_device_tuples], |
| 653 as_root=True, check_return=True) | 672 as_root=True, check_return=True) |
| 654 | 673 |
| 655 def _GetChangedFilesImpl(self, host_path, device_path): | 674 def _GetChangedFilesImpl(self, host_path, device_path): |
| 656 real_host_path = os.path.realpath(host_path) | 675 real_host_path = os.path.realpath(host_path) |
| 657 try: | 676 try: |
| 658 real_device_path = self._RunShellCommandImpl( | 677 real_device_path = self.RunShellCommand( |
| 659 ['realpath', device_path], single_line=True, check_return=True) | 678 ['realpath', device_path], single_line=True, check_return=True) |
| 660 except device_errors.CommandFailedError: | 679 except device_errors.CommandFailedError: |
| 661 real_device_path = None | 680 real_device_path = None |
| 662 if not real_device_path: | 681 if not real_device_path: |
| 663 return [(host_path, device_path)] | 682 return [(host_path, device_path)] |
| 664 | 683 |
| 665 # TODO(jbudorick): Move the md5 logic up into DeviceUtils or base | 684 # TODO(jbudorick): Move the md5 logic up into DeviceUtils or base |
| 666 # this function on mtime. | 685 # this function on mtime. |
| 667 # pylint: disable=W0212 | 686 # pylint: disable=W0212 |
| 668 host_hash_tuples, device_hash_tuples = self.old_interface._RunMd5Sum( | 687 host_hash_tuples, device_hash_tuples = self.old_interface._RunMd5Sum( |
| (...skipping 72 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... | |
| 741 with tempfile.NamedTemporaryFile(suffix='.zip') as zip_file: | 760 with tempfile.NamedTemporaryFile(suffix='.zip') as zip_file: |
| 742 zip_proc = multiprocessing.Process( | 761 zip_proc = multiprocessing.Process( |
| 743 target=DeviceUtils._CreateDeviceZip, | 762 target=DeviceUtils._CreateDeviceZip, |
| 744 args=(zip_file.name, files)) | 763 args=(zip_file.name, files)) |
| 745 zip_proc.start() | 764 zip_proc.start() |
| 746 zip_proc.join() | 765 zip_proc.join() |
| 747 | 766 |
| 748 zip_on_device = '%s/tmp.zip' % self._GetExternalStoragePathImpl() | 767 zip_on_device = '%s/tmp.zip' % self._GetExternalStoragePathImpl() |
| 749 try: | 768 try: |
| 750 self.adb.Push(zip_file.name, zip_on_device) | 769 self.adb.Push(zip_file.name, zip_on_device) |
| 751 self._RunShellCommandImpl( | 770 self.RunShellCommand( |
| 752 ['unzip', zip_on_device], | 771 ['unzip', zip_on_device], |
| 753 as_root=True, | 772 as_root=True, |
| 754 env={'PATH': '$PATH:%s' % install_commands.BIN_DIR}, | 773 env={'PATH': '$PATH:%s' % install_commands.BIN_DIR}, |
| 755 check_return=True) | 774 check_return=True) |
| 756 finally: | 775 finally: |
| 757 if zip_proc.is_alive(): | 776 if zip_proc.is_alive(): |
| 758 zip_proc.terminate() | 777 zip_proc.terminate() |
| 759 if self.IsOnline(): | 778 if self.IsOnline(): |
| 760 self._RunShellCommandImpl(['rm', zip_on_device], check_return=True) | 779 self.RunShellCommand(['rm', zip_on_device], check_return=True) |
| 761 | 780 |
| 762 @staticmethod | 781 @staticmethod |
| 763 def _CreateDeviceZip(zip_path, host_device_tuples): | 782 def _CreateDeviceZip(zip_path, host_device_tuples): |
| 764 with zipfile.ZipFile(zip_path, 'w') as zip_file: | 783 with zipfile.ZipFile(zip_path, 'w') as zip_file: |
| 765 for host_path, device_path in host_device_tuples: | 784 for host_path, device_path in host_device_tuples: |
| 766 if os.path.isfile(host_path): | 785 if os.path.isfile(host_path): |
| 767 zip_file.write(host_path, device_path, zipfile.ZIP_DEFLATED) | 786 zip_file.write(host_path, device_path, zipfile.ZIP_DEFLATED) |
| 768 else: | 787 else: |
| 769 for hd, _, files in os.walk(host_path): | 788 for hd, _, files in os.walk(host_path): |
| 770 dd = '%s/%s' % (device_path, os.path.relpath(host_path, hd)) | 789 dd = '%s/%s' % (device_path, os.path.relpath(host_path, hd)) |
| (...skipping 117 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... | |
| 888 timeout: timeout in seconds | 907 timeout: timeout in seconds |
| 889 retries: number of retries | 908 retries: number of retries |
| 890 | 909 |
| 891 Raises: | 910 Raises: |
| 892 CommandFailedError if the file could not be written on the device. | 911 CommandFailedError if the file could not be written on the device. |
| 893 CommandTimeoutError on timeout. | 912 CommandTimeoutError on timeout. |
| 894 DeviceUnreachableError on missing device. | 913 DeviceUnreachableError on missing device. |
| 895 """ | 914 """ |
| 896 cmd = 'echo %s > %s' % (cmd_helper.SingleQuote(text), | 915 cmd = 'echo %s > %s' % (cmd_helper.SingleQuote(text), |
| 897 cmd_helper.SingleQuote(device_path)) | 916 cmd_helper.SingleQuote(device_path)) |
| 898 self._RunShellCommandImpl(cmd, as_root=as_root, check_return=True) | 917 self.RunShellCommand(cmd, as_root=as_root, check_return=True) |
| 899 | 918 |
| 900 @decorators.WithTimeoutAndRetriesFromInstance() | 919 @decorators.WithTimeoutAndRetriesFromInstance() |
| 901 def Ls(self, device_path, timeout=None, retries=None): | 920 def Ls(self, device_path, timeout=None, retries=None): |
| 902 """Lists the contents of a directory on the device. | 921 """Lists the contents of a directory on the device. |
| 903 | 922 |
| 904 Args: | 923 Args: |
| 905 device_path: A string containing the path of the directory on the device | 924 device_path: A string containing the path of the directory on the device |
| 906 to list. | 925 to list. |
| 907 timeout: timeout in seconds | 926 timeout: timeout in seconds |
| 908 retries: number of retries | 927 retries: number of retries |
| (...skipping 122 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... | |
| 1031 provided |process_name|. | 1050 provided |process_name|. |
| 1032 | 1051 |
| 1033 Raises: | 1052 Raises: |
| 1034 CommandTimeoutError on timeout. | 1053 CommandTimeoutError on timeout. |
| 1035 DeviceUnreachableError on missing device. | 1054 DeviceUnreachableError on missing device. |
| 1036 """ | 1055 """ |
| 1037 return self._GetPidsImpl(process_name) | 1056 return self._GetPidsImpl(process_name) |
| 1038 | 1057 |
| 1039 def _GetPidsImpl(self, process_name): | 1058 def _GetPidsImpl(self, process_name): |
| 1040 procs_pids = {} | 1059 procs_pids = {} |
| 1041 for line in self._RunShellCommandImpl('ps', check_return=True): | 1060 for line in self.RunShellCommand('ps', check_return=True): |
| 1042 try: | 1061 try: |
| 1043 ps_data = line.split() | 1062 ps_data = line.split() |
| 1044 if process_name in ps_data[-1]: | 1063 if process_name in ps_data[-1]: |
| 1045 procs_pids[ps_data[-1]] = ps_data[1] | 1064 procs_pids[ps_data[-1]] = ps_data[1] |
| 1046 except IndexError: | 1065 except IndexError: |
| 1047 pass | 1066 pass |
| 1048 return procs_pids | 1067 return procs_pids |
| 1049 | 1068 |
| 1050 @decorators.WithTimeoutAndRetriesFromInstance() | 1069 @decorators.WithTimeoutAndRetriesFromInstance() |
| 1051 def TakeScreenshot(self, host_path=None, timeout=None, retries=None): | 1070 def TakeScreenshot(self, host_path=None, timeout=None, retries=None): |
| (...skipping 77 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... | |
| 1129 Returns: | 1148 Returns: |
| 1130 A Parallelizer operating over |devices|. | 1149 A Parallelizer operating over |devices|. |
| 1131 """ | 1150 """ |
| 1132 if not devices or len(devices) == 0: | 1151 if not devices or len(devices) == 0: |
| 1133 devices = pylib.android_commands.GetAttachedDevices() | 1152 devices = pylib.android_commands.GetAttachedDevices() |
| 1134 parallelizer_type = (parallelizer.Parallelizer if async | 1153 parallelizer_type = (parallelizer.Parallelizer if async |
| 1135 else parallelizer.SyncParallelizer) | 1154 else parallelizer.SyncParallelizer) |
| 1136 return parallelizer_type([ | 1155 return parallelizer_type([ |
| 1137 d if isinstance(d, DeviceUtils) else DeviceUtils(d) | 1156 d if isinstance(d, DeviceUtils) else DeviceUtils(d) |
| 1138 for d in devices]) | 1157 for d in devices]) |
| OLD | NEW |