CameraITS: Support connecting multiple devices

Add "device=" command line argument to support running Camera ITS
on a specific device when multiple devices are connected.

Bug: 21755489
Change-Id: I7962a6335310cdf849c3fa3ddabdaa84bd5f65ae
diff --git a/apps/CameraITS/CameraITS.pdf b/apps/CameraITS/CameraITS.pdf
index 5a511c0..8953af9 100644
--- a/apps/CameraITS/CameraITS.pdf
+++ b/apps/CameraITS/CameraITS.pdf
Binary files differ
diff --git a/apps/CameraITS/pymodules/its/device.py b/apps/CameraITS/pymodules/its/device.py
index 8773335..dec37db 100644
--- a/apps/CameraITS/pymodules/its/device.py
+++ b/apps/CameraITS/pymodules/its/device.py
@@ -41,12 +41,21 @@
         sock: The open socket.
     """
 
-    # Open a connection to localhost:6000, forwarded to port 6000 on the device.
-    # TODO: Support multiple devices running over different TCP ports.
+    # Open a connection to localhost:<host_port>, forwarded to port 6000 on the
+    # device. <host_port> is determined at run-time to support multiple
+    # connected devices.
     IPADDR = '127.0.0.1'
-    PORT = 6000
+    REMOTE_PORT = 6000
     BUFFER_SIZE = 4096
 
+    # LOCK_PORT is used as a mutex lock to protect the list of forwarded ports
+    # among all processes. The script assumes LOCK_PORT is available and will
+    # try to use ports between CLIENT_PORT_START and
+    # CLIENT_PORT_START+MAX_NUM_PORTS-1 on host for ITS sessions.
+    CLIENT_PORT_START = 6000
+    MAX_NUM_PORTS = 100
+    LOCK_PORT = CLIENT_PORT_START + MAX_NUM_PORTS
+
     # Seconds timeout on each socket operation.
     SOCK_TIMEOUT = 10.0
 
@@ -57,8 +66,8 @@
     EXTRA_SUCCESS = 'camera.its.extra.SUCCESS'
     EXTRA_SUMMARY = 'camera.its.extra.SUMMARY'
 
-    # TODO: Handle multiple connected devices.
-    ADB = "adb -d"
+    adb = "adb -d"
+    device_id = ""
 
     # Definitions for some of the common output format options for do_capture().
     # Each gets images of full resolution for each requested format.
@@ -74,12 +83,83 @@
     CAP_RAW_YUV_JPEG = [{"format":"raw"}, {"format":"yuv"}, {"format":"jpeg"}]
     CAP_DNG_YUV_JPEG = [{"format":"dng"}, {"format":"yuv"}, {"format":"jpeg"}]
 
-    # Method to handle the case where the service isn't already running.
-    # This occurs when a test is invoked directly from the command line, rather
-    # than as a part of a separate test harness which is setting up the device
-    # and the TCP forwarding.
-    def __pre_init(self):
+    # Initialize the socket port for the host to forward requests to the device.
+    # This method assumes localhost's LOCK_PORT is available and will try to
+    # use ports between CLIENT_PORT_START and CLIENT_PORT_START+MAX_NUM_PORTS-1
+    def __init_socket_port(self):
+        NUM_RETRIES = 100
+        RETRY_WAIT_TIME_SEC = 0.05
 
+        # Bind a socket to use as mutex lock
+        socket_lock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+        for i in range(NUM_RETRIES):
+            try:
+                socket_lock.bind((ItsSession.IPADDR, ItsSession.LOCK_PORT))
+                break
+            except socket.error:
+                if i == NUM_RETRIES - 1:
+                    raise its.error.Error(self.device_id,
+                                          "acquiring socket lock timed out")
+                else:
+                    time.sleep(RETRY_WAIT_TIME_SEC)
+
+        # Check if a port is already assigned to the device.
+        command = "adb forward --list"
+        proc = subprocess.Popen(command.split(), stdout=subprocess.PIPE)
+        output, error = proc.communicate()
+
+        port = None
+        used_ports = []
+        for line in output.split(os.linesep):
+            # each line should be formatted as:
+            # "<device_id> tcp:<host_port> tcp:<remote_port>"
+            forward_info = line.split()
+            if len(forward_info) >= 3 and \
+               len(forward_info[1]) > 4 and forward_info[1][:4] == "tcp:" and \
+               len(forward_info[2]) > 4 and forward_info[2][:4] == "tcp:":
+                local_p = int(forward_info[1][4:])
+                remote_p = int(forward_info[2][4:])
+                if forward_info[0] == self.device_id and \
+                   remote_p == ItsSession.REMOTE_PORT:
+                    port = local_p
+                    break;
+                else:
+                    used_ports.append(local_p)
+
+        # Find the first available port if no port is assigned to the device.
+        if port is None:
+            for p in range(ItsSession.CLIENT_PORT_START,
+                           ItsSession.CLIENT_PORT_START +
+                           ItsSession.MAX_NUM_PORTS):
+                if p not in used_ports:
+                    # Try to run "adb forward" with the port
+                    command = "%s forward tcp:%d tcp:%d" % \
+                              (self.adb, p, self.REMOTE_PORT)
+                    proc = subprocess.Popen(command.split(),
+                                            stdout=subprocess.PIPE,
+                                            stderr=subprocess.PIPE)
+                    output, error = proc.communicate()
+
+                    # Check if there is no error
+                    if error is None or error.find("error") < 0:
+                        port = p
+                        break
+
+        if port is None:
+            raise its.error.Error(self.device_id, " cannot find an available " +
+                                  "port")
+
+        # Release the socket as mutex unlock
+        socket_lock.close()
+
+        # Connect to the socket
+        self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+        self.sock.connect((self.IPADDR, port))
+        self.sock.settimeout(self.SOCK_TIMEOUT)
+
+    # Reboot the device if needed and wait for the service to be ready for
+    # connection.
+    def __wait_for_service(self):
         # This also includes the optional reboot handling: if the user
         # provides a "reboot" or "reboot=N" arg, then reboot the device,
         # waiting for N seconds (default 30) before returning.
@@ -89,19 +169,19 @@
                 if len(s) > 7 and s[6] == "=":
                     duration = int(s[7:])
                 print "Rebooting device"
-                _run("%s reboot" % (ItsSession.ADB));
-                _run("%s wait-for-device" % (ItsSession.ADB))
+                _run("%s reboot" % (self.adb));
+                _run("%s wait-for-device" % (self.adb))
                 time.sleep(duration)
                 print "Reboot complete"
 
         # TODO: Figure out why "--user 0" is needed, and fix the problem.
-        _run('%s shell am force-stop --user 0 %s' % (ItsSession.ADB, self.PACKAGE))
+        _run('%s shell am force-stop --user 0 %s' % (self.adb, self.PACKAGE))
         _run(('%s shell am startservice --user 0 -t text/plain '
-              '-a %s') % (ItsSession.ADB, self.INTENT_START))
+              '-a %s') % (self.adb, self.INTENT_START))
 
         # Wait until the socket is ready to accept a connection.
         proc = subprocess.Popen(
-                ItsSession.ADB.split() + ["logcat"],
+                self.adb.split() + ["logcat"],
                 stdout=subprocess.PIPE)
         logcat = proc.stdout
         while True:
@@ -110,15 +190,14 @@
                 break
         proc.kill()
 
-        # Setup the TCP-over-ADB forwarding.
-        _run('%s forward tcp:%d tcp:%d' % (ItsSession.ADB,self.PORT,self.PORT))
-
     def __init__(self):
-        if "noinit" not in sys.argv:
-            self.__pre_init()
-        self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
-        self.sock.connect((self.IPADDR, self.PORT))
-        self.sock.settimeout(self.SOCK_TIMEOUT)
+        # Initialize device id and adb command.
+        self.device_id = get_device_id()
+        self.adb = "adb -s " + self.device_id
+
+        self.__wait_for_service()
+        self.__init_socket_port()
+
         self.__close_camera()
         self.__open_camera()
 
@@ -544,10 +623,52 @@
             rets.append(objs if ncap>1 else objs[0])
         return rets if len(rets)>1 else rets[0]
 
-def report_result(camera_id, success, summary_path=None):
+def get_device_id():
+    """ Return the ID of the device that the test is running on.
+
+    Return the device ID provided in the command line if it's connected. If no
+    device ID is provided in the command line and there is only one device
+    connected, return the device ID by parsing the result of "adb devices".
+
+    Raise an exception if no device is connected; or the device ID provided in
+    the command line is not connected; or no device ID is provided in the
+    command line and there are more than 1 device connected.
+
+    Returns:
+        Device ID string.
+    """
+    device_id = None
+    for s in sys.argv[1:]:
+        if s[:7] == "device=" and len(s) > 7:
+            device_id = str(s[7:])
+
+    # Get a list of connected devices
+    devices = []
+    command = "adb devices"
+    proc = subprocess.Popen(command.split(), stdout=subprocess.PIPE)
+    output, error = proc.communicate()
+    for line in output.split(os.linesep):
+        device_info = line.split()
+        if len(device_info) == 2 and device_info[1] == "device":
+            devices.append(device_info[0])
+
+    if len(devices) == 0:
+        raise its.error.Error("No device is connected!")
+    elif device_id is not None and device_id not in devices:
+        raise its.error.Error(device_id + " is not connected!")
+    elif device_id is None and len(devices) >= 2:
+        raise its.error.Error("More than 1 device are connected. " +
+                "Use device=<device_id> to specify a device to test.")
+    elif len(devices) == 1:
+        device_id = devices[0]
+
+    return device_id
+
+def report_result(device_id, camera_id, success, summary_path=None):
     """Send a pass/fail result to the device, via an intent.
 
     Args:
+        device_id: The ID string of the device to report the results to.
         camera_id: The ID string of the camera for which to report pass/fail.
         success: Boolean, indicating if the result was pass or fail.
         summary_path: (Optional) path to ITS summary file on host PC
@@ -555,18 +676,19 @@
     Returns:
         Nothing.
     """
+    adb = "adb -s " + device_id
     device_summary_path = "/sdcard/camera_" + camera_id + "_its_summary.txt"
     if summary_path is not None:
         _run("%s push %s %s" % (
-                ItsSession.ADB, summary_path, device_summary_path))
+                adb, summary_path, device_summary_path))
         _run("%s shell am broadcast -a %s --es %s %s --es %s %s --es %s %s" % (
-                ItsSession.ADB, ItsSession.ACTION_ITS_RESULT,
+                adb, ItsSession.ACTION_ITS_RESULT,
                 ItsSession.EXTRA_CAMERA_ID, camera_id,
                 ItsSession.EXTRA_SUCCESS, 'True' if success else 'False',
                 ItsSession.EXTRA_SUMMARY, device_summary_path))
     else:
         _run("%s shell am broadcast -a %s --es %s %s --es %s %s --es %s %s" % (
-                ItsSession.ADB, ItsSession.ACTION_ITS_RESULT,
+                adb, ItsSession.ACTION_ITS_RESULT,
                 ItsSession.EXTRA_CAMERA_ID, camera_id,
                 ItsSession.EXTRA_SUCCESS, 'True' if success else 'False',
                 ItsSession.EXTRA_SUMMARY, "null"))
diff --git a/apps/CameraITS/tools/config.py b/apps/CameraITS/tools/config.py
index 6e83412..52929aa 100644
--- a/apps/CameraITS/tools/config.py
+++ b/apps/CameraITS/tools/config.py
@@ -44,7 +44,7 @@
     # Command line args, ignoring any args that will be passed down to the
     # ItsSession constructor.
     args = [s for s in sys.argv if s[:6] not in \
-            ["reboot", "camera", "target", "noinit"]]
+            ["reboot", "camera", "target", "device"]]
 
     if len(args) == 1:
         with its.device.ItsSession() as cam:
diff --git a/apps/CameraITS/tools/run_all_tests.py b/apps/CameraITS/tools/run_all_tests.py
index 625ebaa..dd12512 100644
--- a/apps/CameraITS/tools/run_all_tests.py
+++ b/apps/CameraITS/tools/run_all_tests.py
@@ -66,6 +66,10 @@
     topdir = tempfile.mkdtemp()
     print "Saving output files to:", topdir, "\n"
 
+    device_id = its.device.get_device_id()
+    device_id_arg = "device=" + device_id
+    print "Testing device " + device_id
+
     camera_ids = []
     for s in sys.argv[1:]:
         if s[:7] == "camera=" and len(s) > 7:
@@ -76,7 +80,8 @@
         camera_ids_path = os.path.join(topdir, "camera_ids.txt")
         out_arg = "out=" + camera_ids_path
         cmd = ['python',
-               os.path.join(os.getcwd(),"tools/get_camera_ids.py"), out_arg]
+               os.path.join(os.getcwd(),"tools/get_camera_ids.py"), out_arg,
+               device_id_arg]
         retcode = subprocess.call(cmd,cwd=topdir)
         assert(retcode == 0)
         with open(camera_ids_path, "r") as f:
@@ -110,7 +115,7 @@
                 scene_arg = "scene=" + scene_req[scene]
                 cmd = ['python',
                         os.path.join(os.getcwd(),"tools/validate_scene.py"),
-                        camera_id_arg, out_arg, scene_arg]
+                        camera_id_arg, out_arg, scene_arg, device_id_arg]
                 retcode = subprocess.call(cmd,cwd=topdir)
                 assert(retcode == 0)
                 print "Start running tests for", scene
@@ -170,7 +175,7 @@
         summary_path = os.path.join(topdir, camera_id, "summary.txt")
         with open(summary_path, "w") as f:
             f.write(summary)
-        its.device.report_result(camera_id, result, summary_path)
+        its.device.report_result(device_id, camera_id, result, summary_path)
 
     print "ITS tests finished. Please go back to CtsVerifier and proceed"