mobly_driver: Pull pasit info from test request

Add support for passport controllers by plumbing host info from UFS into
the output testbed.yaml file.

It also moves away from using a map for user controller params to keep
devices in the same order.

BUG=b:394685986
TEST=unittests

Change-Id: I98783d9396a8f5db1224daa8729f715a68ae06e8
Reviewed-on: https://chromium-review.googlesource.com/c/chromiumos/platform/dev-util/+/6254241
Commit-Queue: Jason Stanko <jstanko@google.com>
Reviewed-by: Derek Beckett <dbeckett@chromium.org>
Reviewed-by: Billy Zhao <billyzhao@chromium.org>
Tested-by: Jason Stanko <jstanko@google.com>
diff --git a/src/go.chromium.org/chromiumos/test/execution/cmd/cros-test/internal/device/dut_info.go b/src/go.chromium.org/chromiumos/test/execution/cmd/cros-test/internal/device/dut_info.go
index 5b19ce9..f5a0bdc 100644
--- a/src/go.chromium.org/chromiumos/test/execution/cmd/cros-test/internal/device/dut_info.go
+++ b/src/go.chromium.org/chromiumos/test/execution/cmd/cros-test/internal/device/dut_info.go
@@ -526,6 +526,18 @@
 
 }
 
+// Returns a list of all devices in a request, both companion and primary.
+func Devices(req *api.CrosTestRequest) []*labapi.Dut {
+	devices := []*labapi.Dut{}
+	if req.GetPrimary().GetDut() != nil {
+		devices = append(devices, req.GetPrimary().GetDut())
+	}
+	for _, c := range req.GetCompanions() {
+		devices = append(devices, c.GetDut())
+	}
+	return devices
+}
+
 // DerviceSerials extracts serial information of all devices from a requests test.
 func DerviceSerials(req *api.CrosTestRequest) ([]string, error) {
 	var serials []string
diff --git a/src/go.chromium.org/chromiumos/test/execution/cmd/cros-test/internal/driver/mobly_config.go b/src/go.chromium.org/chromiumos/test/execution/cmd/cros-test/internal/driver/mobly_config.go
index f7295e3..27b07a7 100644
--- a/src/go.chromium.org/chromiumos/test/execution/cmd/cros-test/internal/driver/mobly_config.go
+++ b/src/go.chromium.org/chromiumos/test/execution/cmd/cros-test/internal/driver/mobly_config.go
@@ -6,18 +6,36 @@
 package driver
 
 import (
+	"encoding/json"
+	"fmt"
 	"log"
 	"os"
 	"path/filepath"
 	"strings"
 
+	"github.com/golang/protobuf/proto"
+
 	"go.chromium.org/chromiumos/config/go/test/api"
+	labapi "go.chromium.org/chromiumos/config/go/test/lab/api"
 	"go.chromium.org/luci/common/errors"
+	"google.golang.org/protobuf/encoding/protojson"
 	"gopkg.in/yaml.v2"
 )
 
 type paramMap = map[string]string
-type deviceParamMap = map[string]paramMap
+
+type deviceParams struct {
+	deviceId string
+	params   paramMap
+}
+
+func deviceParamMap(params []deviceParams) map[string]paramMap {
+	res := make(map[string]paramMap)
+	for _, device := range params {
+		res[device.deviceId] = device.params
+	}
+	return res
+}
 
 type MoblyTestConfig struct {
 	TestBeds []*TestBed `yaml:"TestBeds"`
@@ -29,10 +47,15 @@
 	Controllers *Controllers `yaml:"Controllers"`
 }
 
+type TestParams struct {
+	Params paramMap `yaml:",inline"`
+}
+
 type Controllers struct {
 	OpenWrtDevices    []*OpenWrtDevice     `yaml:"OpenWrtDevice,omitempty"`
 	AndroidDevices    []*AndroidDevice     `yaml:"AndroidDevice,omitempty"`
 	BtReferenceDevice []*BtReferenceDevice `yaml:"BtReferenceDevice,omitempty"`
+	PassportHost      []*PassportHost      `yaml:"PassportHost,omitempty"`
 }
 
 type AndroidDevice struct {
@@ -53,8 +76,24 @@
 	Params   paramMap `yaml:",inline"`
 }
 
-type TestParams struct {
-	Params paramMap `yaml:",inline"`
+type PassportHost struct {
+	HostTopology *labapi.PasitHost
+	Params       paramMap
+}
+
+func (p PassportHost) MarshalYAML() (interface{}, error) {
+	topology, err := yamlFriendlyPb(p.HostTopology)
+	if err != nil {
+		return "", fmt.Errorf("failed to make protobuf yaml compatible: %w", err)
+	}
+
+	return struct {
+		HostTopology interface{} `yaml:"host_topology"`
+		Params       paramMap    `yaml:",inline"`
+	}{
+		HostTopology: topology,
+		Params:       p.Params,
+	}, nil
 }
 
 // Config Parameters from ExecutionMetadata
@@ -64,15 +103,17 @@
 	TestParams paramMap
 	// Values prefixed by `primary` or `secondary` will only apply to that device.
 	// Keyed by `android-params`
-	AndroidParams deviceParamMap
+	AndroidParams []deviceParams
 	// Values prefixed by `primary-<suffix>` or `secondary-<suffix>` will only apply to that device.
 	// Keyed by `openwrt-params`
-	OpenWrtParams deviceParamMap
+	OpenWrtParams []deviceParams
 	// Keyed by `btreference-params`
-	BtReferenceParams deviceParamMap
+	BtReferenceParams []deviceParams
+	// Keyed by `passport-params`
+	PassportParams paramMap
 }
 
-func GenerateMoblyConfig(logger *log.Logger, dir string, serials []string, metadata []*api.Arg) (err error) {
+func NewMoblyConfig(logger *log.Logger, serials []string, metadata []*api.Arg, devices []*labapi.Dut) *MoblyTestConfig {
 	logger.Println("Generating Mobly Test Config")
 	for _, arg := range metadata {
 		logger.Println(arg.Flag, arg.Value)
@@ -83,18 +124,18 @@
 		logger.Println(k, v)
 	}
 
-	for d, m := range configParams.AndroidParams {
-		for k, v := range m {
+	for _, d := range configParams.AndroidParams {
+		for k, v := range d.params {
+			logger.Println(d.deviceId, k, v)
+		}
+	}
+	for _, d := range configParams.OpenWrtParams {
+		for k, v := range d.params {
 			logger.Println(d, k, v)
 		}
 	}
-	for d, m := range configParams.OpenWrtParams {
-		for k, v := range m {
-			logger.Println(d, k, v)
-		}
-	}
-	for d, m := range configParams.BtReferenceParams {
-		for k, v := range m {
+	for _, d := range configParams.BtReferenceParams {
+		for k, v := range d.params {
 			logger.Println(d, k, v)
 		}
 	}
@@ -103,9 +144,10 @@
 		AndroidDevices:    GenerateAndroidDevices(serials, configParams.AndroidParams),
 		OpenWrtDevices:    GenerateOpenWrtDevices(serials, configParams.OpenWrtParams),
 		BtReferenceDevice: GenerateBtReferenceDevices(serials, configParams.BtReferenceParams),
+		PassportHost:      GeneratePassportHost(logger, devices, configParams.PassportParams),
 	}
 
-	Config := &MoblyTestConfig{
+	return &MoblyTestConfig{
 		TestBeds: []*TestBed{
 			{
 				Name:        "LocalTestBed",
@@ -116,8 +158,10 @@
 			},
 		},
 	}
+}
 
-	yamlData, err := yaml.Marshal(Config)
+func (c *MoblyTestConfig) Write(logger *log.Logger, dir string) (err error) {
+	yamlData, err := yaml.Marshal(c)
 	if err != nil {
 		err = errors.Annotate(err, "failed to marshal yaml config").Err()
 		return
@@ -136,7 +180,32 @@
 	return
 }
 
-func GenerateBtReferenceDevices(serials []string, btReferenceParams deviceParamMap) []*BtReferenceDevice {
+func GeneratePassportHost(logger *log.Logger, devices []*labapi.Dut, passportParams paramMap) []*PassportHost {
+	var passportHosts []*PassportHost
+	for _, dut := range devices {
+		if dut.GetChromeos() == nil || dut.GetChromeos().GetPasitHost() == nil {
+			continue
+		}
+		topology := dut.GetChromeos().GetPasitHost()
+		passportHosts = append(passportHosts, &PassportHost{
+			HostTopology: topology,
+			Params:       passportParams,
+		})
+	}
+
+	// If test params indicate a passport device, but none was found then add one with an empty topology
+	// so users can still specify based on command line.
+	if len(passportHosts) == 0 && len(passportParams) > 0 {
+		passportHosts = append(passportHosts, &PassportHost{
+			Params:       passportParams,
+			HostTopology: &labapi.PasitHost{},
+		})
+	}
+
+	return passportHosts
+}
+
+func GenerateBtReferenceDevices(serials []string, btReferenceParams []deviceParams) []*BtReferenceDevice {
 	devices := []*BtReferenceDevice{}
 	deviceKeyToSerial := map[string]string{}
 	for i, serial := range serials {
@@ -147,9 +216,17 @@
 		}
 	}
 
-	paramsForEachDevice := btReferenceParams["all"]
+	paramsForEachDevice := paramMap{}
+	for _, device := range btReferenceParams {
+		if device.deviceId == "all" {
+			paramsForEachDevice = device.params
+			break
+		}
+	}
 
-	for deviceKey, deviceParams := range btReferenceParams {
+	for _, device := range btReferenceParams {
+		deviceKey := device.deviceId
+		deviceParams := device.params
 		if deviceKey == "all" {
 			continue
 		}
@@ -177,7 +254,7 @@
 	return devices
 }
 
-func GenerateOpenWrtDevices(serials []string, openWrtParams deviceParamMap) []*OpenWrtDevice {
+func GenerateOpenWrtDevices(serials []string, openWrtParams []deviceParams) []*OpenWrtDevice {
 	devices := []*OpenWrtDevice{}
 	deviceKeyToSerial := map[string]string{}
 	for i, serial := range serials {
@@ -188,9 +265,17 @@
 		}
 	}
 
-	paramsForEachDevice := openWrtParams["all"]
+	paramsForEachDevice := paramMap{}
+	for _, device := range openWrtParams {
+		if device.deviceId == "all" {
+			paramsForEachDevice = device.params
+			break
+		}
+	}
 
-	for deviceKey, deviceParams := range openWrtParams {
+	for _, device := range openWrtParams {
+		deviceKey := device.deviceId
+		deviceParams := device.params
 		if deviceKey == "all" {
 			continue
 		}
@@ -216,11 +301,11 @@
 	return devices
 }
 
-func GenerateAndroidDevices(serials []string, androidParams deviceParamMap) []*AndroidDevice {
+func GenerateAndroidDevices(serials []string, androidParams []deviceParams) []*AndroidDevice {
 	devices := []*AndroidDevice{}
 
-	paramsForEachDevice := androidParams["all"]
-
+	androidParamsMap := deviceParamMap(androidParams)
+	paramsForEachDevice := androidParamsMap["all"]
 	for i, serial := range serials {
 		var role string
 		params := paramMap{}
@@ -231,10 +316,10 @@
 		var deviceParams paramMap
 		if i == 0 {
 			role = "source_device"
-			deviceParams = androidParams["primary"]
+			deviceParams = androidParamsMap["primary"]
 		} else {
 			role = "target_device"
-			deviceParams = androidParams["secondary"]
+			deviceParams = androidParamsMap["secondary"]
 		}
 		for k, v := range deviceParams {
 			params[k] = v
@@ -269,6 +354,9 @@
 		case "btreference-params":
 			logger.Println("btreference-params")
 			configParams.BtReferenceParams = ParseDeviceArg(logger, arg)
+		case "passport-params":
+			logger.Println("passport-params")
+			configParams.PassportParams = ParseArg(logger, arg, configParams.PassportParams)
 		default:
 			logger.Println("default")
 			// Any additional test params dynamically passed in.
@@ -306,18 +394,20 @@
 	return argMap
 }
 
-func ParseDeviceArg(logger *log.Logger, arg *api.Arg) deviceParamMap {
+func ParseDeviceArg(logger *log.Logger, arg *api.Arg) []deviceParams {
 	logger.Println("ParseDeviceArg")
-	argMap := deviceParamMap{}
+	argMap := make(map[string]paramMap)
 
 	deviceKey := "all"
 	argMap["all"] = paramMap{}
+	keys := []string{"all"}
 	for _, param := range strings.Split(arg.Value, ",") {
 		logger.Println(param)
 		parts := strings.Split(param, ":")
 		if len(parts) == 1 {
 			deviceKey = parts[0]
 			argMap[deviceKey] = paramMap{}
+			keys = append(keys, deviceKey)
 		}
 		if len(parts) != 2 {
 			continue
@@ -327,6 +417,45 @@
 		logger.Println(deviceKey, key, value)
 	}
 
+	params := []deviceParams{}
+	for _, key := range keys {
+		params = append(params, deviceParams{
+			deviceId: key,
+			params:   argMap[key],
+		})
+	}
+
 	logger.Println("ParseDeviceArg Return")
-	return argMap
+	return params
+}
+
+// yamlFriendlyPb converts a protobuf message into an object that, when converted into
+// yaml, can easily be unmarshalled into the original protobuf. Without this,
+// the golang field names will be mangled by the default yaml marshaller.
+//
+//	e.g. go from:
+//	  * DeviceSerial -> deviceserial
+//	to
+//	  * DeviceSerial -> device_serial
+func yamlFriendlyPb(m proto.Message) (interface{}, error) {
+	// If message is empty default to nil.
+	if proto.Size(m) == 0 {
+		return nil, nil
+	}
+
+	marshalOpts := protojson.MarshalOptions{
+		// Optional, but helps keep marshalling in line with the rest of the testbed
+		// .yaml file structs by switching to underscores vs camelCase.
+		UseProtoNames: true,
+	}
+
+	// Convert to .json and then unmarshal into a new object whose fields will now
+	// be marshalled
+	asJson := marshalOpts.Format(proto.MessageV2(m))
+
+	var res interface{}
+	if err := json.Unmarshal([]byte(asJson), &res); err != nil {
+		return nil, err
+	}
+	return res, nil
 }
diff --git a/src/go.chromium.org/chromiumos/test/execution/cmd/cros-test/internal/driver/mobly_config_test.go b/src/go.chromium.org/chromiumos/test/execution/cmd/cros-test/internal/driver/mobly_config_test.go
new file mode 100644
index 0000000..5198a80
--- /dev/null
+++ b/src/go.chromium.org/chromiumos/test/execution/cmd/cros-test/internal/driver/mobly_config_test.go
@@ -0,0 +1,405 @@
+// Copyright 2025 The ChromiumOS Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+package driver
+
+import (
+	"log"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+	"go.chromium.org/chromiumos/config/go/test/api"
+	labapi "go.chromium.org/chromiumos/config/go/test/lab/api"
+	"gopkg.in/yaml.v2"
+)
+
+var fullConfig = MoblyTestConfig{
+	TestBeds: []*TestBed{
+		{
+			Name: "LocalTestBed",
+			Controllers: &Controllers{
+				OpenWrtDevices: []*OpenWrtDevice{
+					{
+						Hostname: "SERIAL_1-router",
+						Params: paramMap{
+							"skip_init_reboot": "True",
+						},
+					},
+					{
+						Hostname: "SERIAL_1-pcap",
+						Params: paramMap{
+							"skip_init_reboot": "True",
+						},
+					},
+				},
+				AndroidDevices: []*AndroidDevice{
+					{
+						Serial: "SERIAL_1",
+						Role:   "source_device",
+						Params: paramMap{},
+					},
+					{
+						Serial: "SERIAL_2",
+						Role:   "target_device",
+						Params: paramMap{},
+					},
+				},
+				BtReferenceDevice: []*BtReferenceDevice{
+					{
+						Hostname: "SERIAL_1-btpeer1",
+						Username: "root",
+						Password: "test0000",
+						Params:   paramMap{},
+					},
+					{
+						Hostname: "SERIAL_1-btpeer2",
+						Username: "root",
+						Password: "test0000",
+						Params:   paramMap{},
+					},
+					{
+						Hostname: "SERIAL_1-btpeer3",
+						Username: "root",
+						Password: "test0000",
+						Params:   paramMap{},
+					},
+					{
+						Hostname: "SERIAL_1-btpeer4",
+						Username: "root",
+						Password: "test0000",
+						Params:   paramMap{},
+					},
+				},
+				PassportHost: []*PassportHost{
+					{
+						Params: paramMap{
+							"foo": "bar", "foo2": "bar2",
+						},
+						HostTopology: &labapi.PasitHost{
+							Devices: []*labapi.PasitHost_Device{
+								{
+									Id:   "SERIAL_1",
+									Type: labapi.PasitHost_Device_DUT,
+								},
+								{
+									Id:   "dock_switch",
+									Type: labapi.PasitHost_Device_SWITCH_FIXTURE,
+								},
+								{
+									Id:   "dock_1",
+									Type: labapi.PasitHost_Device_DOCKING_STATION,
+								},
+								{
+									Id:   "hdmi_switch",
+									Type: labapi.PasitHost_Device_SWITCH_FIXTURE,
+								},
+								{
+									Id:   "monitor_1",
+									Type: labapi.PasitHost_Device_MONITOR,
+								},
+								{
+									Id:   "dp_switch",
+									Type: labapi.PasitHost_Device_SWITCH_FIXTURE,
+								},
+								{
+									Id:   "monitor_2",
+									Type: labapi.PasitHost_Device_MONITOR,
+								},
+								{
+									Id:   "eth_switch",
+									Type: labapi.PasitHost_Device_SWITCH_FIXTURE,
+								},
+								{
+									Id:   "network_1",
+									Type: labapi.PasitHost_Device_NETWORK,
+								},
+							},
+							Connections: []*labapi.PasitHost_Connection{
+								{
+									Type:     "USBC",
+									ParentId: "SERIAL_1",
+									ChildId:  "dock_switch",
+								},
+								{
+									Type:     "USBC",
+									ParentId: "dock_Switch",
+									ChildId:  "dock_1",
+								},
+								{
+									Type:     "HDMI",
+									ParentId: "dock_1",
+									ChildId:  "dp_switch",
+								},
+								{
+									Type:     "HDMI",
+									ParentId: "dp_switch",
+									ChildId:  "monitor_2",
+								},
+								{
+									Type:     "HDMI",
+									ParentId: "hdmi_switch",
+									ChildId:  "monitor_1",
+								},
+								{
+									Type:     "HDMI",
+									ParentId: "dock_1",
+									ChildId:  "hdmi_switch",
+								},
+								{
+									Type:     "ETHERNET",
+									ParentId: "dock_1",
+									ChildId:  "eth_switch",
+								},
+								{
+									Type:     "ETHERNET",
+									ParentId: "eth_switch",
+									ChildId:  "network_1",
+								},
+							},
+						},
+					},
+				},
+			},
+			TestParams: &TestParams{
+				Params: paramMap{
+					"foo": "bar", "foo2": "bar2",
+					"passport_switch_service_host": "10.0.0.2",
+					"passport_switch_service_port": "12345",
+				},
+			},
+		},
+	},
+}
+
+var fullConfigYaml = `TestBeds:
+- Name: LocalTestBed
+  TestParams:
+    foo: bar
+    foo2: bar2
+    passport_switch_service_host: 10.0.0.2
+    passport_switch_service_port: "12345"
+  Controllers:
+    OpenWrtDevice:
+    - hostname: SERIAL_1-router
+      skip_init_reboot: "True"
+    - hostname: SERIAL_1-pcap
+      skip_init_reboot: "True"
+    AndroidDevice:
+    - serial: SERIAL_1
+      role: source_device
+    - serial: SERIAL_2
+      role: target_device
+    BtReferenceDevice:
+    - hostname: SERIAL_1-btpeer1
+      username: root
+      password: test0000
+    - hostname: SERIAL_1-btpeer2
+      username: root
+      password: test0000
+    - hostname: SERIAL_1-btpeer3
+      username: root
+      password: test0000
+    - hostname: SERIAL_1-btpeer4
+      username: root
+      password: test0000
+    PassportHost:
+    - host_topology:
+        connections:
+        - child_id: dock_switch
+          parent_id: SERIAL_1
+          type: USBC
+        - child_id: dock_1
+          parent_id: dock_Switch
+          type: USBC
+        - child_id: dp_switch
+          parent_id: dock_1
+          type: HDMI
+        - child_id: monitor_2
+          parent_id: dp_switch
+          type: HDMI
+        - child_id: monitor_1
+          parent_id: hdmi_switch
+          type: HDMI
+        - child_id: hdmi_switch
+          parent_id: dock_1
+          type: HDMI
+        - child_id: eth_switch
+          parent_id: dock_1
+          type: ETHERNET
+        - child_id: network_1
+          parent_id: eth_switch
+          type: ETHERNET
+        devices:
+        - id: SERIAL_1
+          type: DUT
+        - id: dock_switch
+          type: SWITCH_FIXTURE
+        - id: dock_1
+          type: DOCKING_STATION
+        - id: hdmi_switch
+          type: SWITCH_FIXTURE
+        - id: monitor_1
+          type: MONITOR
+        - id: dp_switch
+          type: SWITCH_FIXTURE
+        - id: monitor_2
+          type: MONITOR
+        - id: eth_switch
+          type: SWITCH_FIXTURE
+        - id: network_1
+          type: NETWORK
+      foo: bar
+      foo2: bar2
+`
+
+// TestYamlConversion verifies that a config is converted into the expected yaml representation.
+func TestYamlConversion(t *testing.T) {
+	yamlGot, _ := yaml.Marshal(&fullConfig)
+	t.Log(string(yamlGot))
+	if diff := cmp.Diff(string(yamlGot), fullConfigYaml); diff != "" {
+		t.Errorf("Got unexpected argument from NewMoblyConfig (-got +want):\n%s\n%v\n--\n%v\n", string(yamlGot), fullConfigYaml, diff)
+	}
+}
+
+// TestGenerateConfigs verifies the Mobly config generation.
+func TestGenerateConfigs(t *testing.T) {
+	tests := []struct {
+		name     string
+		expected MoblyTestConfig
+		serials  []string
+		devices  []*labapi.Dut
+		metadata []*api.Arg
+	}{
+		{
+			name:    "full_config",
+			serials: []string{"SERIAL_1", "SERIAL_2"},
+			metadata: []*api.Arg{
+				{
+					Flag:  "test-params",
+					Value: "foo:bar,foo2:bar2",
+				},
+				{
+					Flag:  "openwrt-params",
+					Value: "skip_init_reboot:True,primary-router,primary-pcap",
+				},
+				{
+					Flag:  "btreference-params",
+					Value: "primary-btpeer1,primary-btpeer2,primary-btpeer3,primary-btpeer4",
+				},
+				{
+					Flag:  "extra-test-params",
+					Value: "passport_switch_service_host:10.0.0.2,passport_switch_service_port:12345",
+				},
+				{
+					Flag:  "passport-params",
+					Value: "foo:bar,foo2:bar2",
+				},
+			},
+			devices: []*labapi.Dut{
+				{
+					DutType: &labapi.Dut_Chromeos{
+						Chromeos: &labapi.Dut_ChromeOS{
+							PasitHost: &labapi.PasitHost{
+								Devices: []*labapi.PasitHost_Device{
+									{
+										Id:   "SERIAL_1",
+										Type: labapi.PasitHost_Device_DUT,
+									},
+									{
+										Id:   "dock_switch",
+										Type: labapi.PasitHost_Device_SWITCH_FIXTURE,
+									},
+									{
+										Id:   "dock_1",
+										Type: labapi.PasitHost_Device_DOCKING_STATION,
+									},
+									{
+										Id:   "hdmi_switch",
+										Type: labapi.PasitHost_Device_SWITCH_FIXTURE,
+									},
+									{
+										Id:   "monitor_1",
+										Type: labapi.PasitHost_Device_MONITOR,
+									},
+									{
+										Id:   "dp_switch",
+										Type: labapi.PasitHost_Device_SWITCH_FIXTURE,
+									},
+									{
+										Id:   "monitor_2",
+										Type: labapi.PasitHost_Device_MONITOR,
+									},
+									{
+										Id:   "eth_switch",
+										Type: labapi.PasitHost_Device_SWITCH_FIXTURE,
+									},
+									{
+										Id:   "network_1",
+										Type: labapi.PasitHost_Device_NETWORK,
+									},
+								},
+								Connections: []*labapi.PasitHost_Connection{
+									{
+										Type:     "USBC",
+										ParentId: "SERIAL_1",
+										ChildId:  "dock_switch",
+									},
+									{
+										Type:     "USBC",
+										ParentId: "dock_Switch",
+										ChildId:  "dock_1",
+									},
+									{
+										Type:     "HDMI",
+										ParentId: "dock_1",
+										ChildId:  "dp_switch",
+									},
+									{
+										Type:     "HDMI",
+										ParentId: "dp_switch",
+										ChildId:  "monitor_2",
+									},
+									{
+										Type:     "HDMI",
+										ParentId: "hdmi_switch",
+										ChildId:  "monitor_1",
+									},
+									{
+										Type:     "HDMI",
+										ParentId: "dock_1",
+										ChildId:  "hdmi_switch",
+									},
+									{
+										Type:     "ETHERNET",
+										ParentId: "dock_1",
+										ChildId:  "eth_switch",
+									},
+									{
+										Type:     "ETHERNET",
+										ParentId: "eth_switch",
+										ChildId:  "network_1",
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+			expected: fullConfig,
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			config := NewMoblyConfig(log.Default(), test.serials, test.metadata, test.devices)
+			// Convert to yaml to make it easier to compare.
+			yamlWant, _ := yaml.Marshal(&test.expected)
+			yamlGot, _ := yaml.Marshal(config)
+
+			if diff := cmp.Diff(string(yamlGot), string(yamlWant)); diff != "" {
+				t.Errorf("Got unexpected argument from NewMoblyConfig (-got +want):\n%s\n%v\n--\n%v\n", string(yamlGot), string(yamlWant), diff)
+			}
+		})
+	}
+}
diff --git a/src/go.chromium.org/chromiumos/test/execution/cmd/cros-test/internal/driver/mobly_driver.go b/src/go.chromium.org/chromiumos/test/execution/cmd/cros-test/internal/driver/mobly_driver.go
index 8ca0ba3..c7f67c0 100644
--- a/src/go.chromium.org/chromiumos/test/execution/cmd/cros-test/internal/driver/mobly_driver.go
+++ b/src/go.chromium.org/chromiumos/test/execution/cmd/cros-test/internal/driver/mobly_driver.go
@@ -18,6 +18,7 @@
 	c "go.chromium.org/chromiumos/test/util/adb"
 
 	"go.chromium.org/chromiumos/config/go/test/api"
+	labapi "go.chromium.org/chromiumos/config/go/test/lab/api"
 	"go.chromium.org/chromiumos/test/execution/cmd/cros-test/internal/common"
 	"go.chromium.org/chromiumos/test/execution/cmd/cros-test/internal/device"
 )
@@ -67,8 +68,9 @@
 }
 
 // runMoblyTest executes a Mobly test.
-func runMoblyTest(ctx context.Context, logger *log.Logger, test *api.TestCaseMetadata, serials []string, metadata []*api.Arg) error {
-	if err := GenerateMoblyConfig(logger, "/usr/local/mobly", serials, metadata); err != nil {
+func runMoblyTest(ctx context.Context, logger *log.Logger, test *api.TestCaseMetadata, serials []string, metadata []*api.Arg, devices []*labapi.Dut) error {
+	config := NewMoblyConfig(logger, serials, metadata, devices)
+	if err := config.Write(logger, "/usr/local/mobly"); err != nil {
 		return fmt.Errorf("generating Mobly config: %w", err)
 	}
 
@@ -139,7 +141,7 @@
 		resDir := filepath.Join("/tmp", "test", "results", "mobly", test.GetTestCase().GetName())
 		os.Setenv("MOBLY_LOGPATH", resDir)
 
-		if err := runMoblyTest(ctx, md.logger, test, serials, metadata); err != nil {
+		if err := runMoblyTest(ctx, md.logger, test, serials, metadata, device.Devices(req)); err != nil {
 			return nil, fmt.Errorf("running Mobly test: %w", err)
 		}