Multi-Display Communications API

แอปที่มีสิทธิ์ของระบบใน AAOS สามารถใช้ Multi-Display Communications API เพื่อสื่อสารกับแอปเดียวกัน (ชื่อแพ็กเกจเดียวกัน) ที่ทำงานในโซนที่มีการเข้าใช้ที่แตกต่างกันในรถ หน้านี้จะอธิบายวิธีผสานรวม API ดูข้อมูลเพิ่มเติมได้ที่ CarOccupantZoneManager.OccupantZoneInfo

โซนผู้โดยสาร

แนวคิดของโซนผู้ใช้จะเชื่อมโยงผู้ใช้กับชุดจอแสดงผล แต่ละ โซนผู้เข้าพักมีจอแสดงผลประเภท DISPLAY_TYPE_MAIN โซนผู้โดยสารอาจมีจอแสดงผลเพิ่มเติมด้วย เช่น จอแสดงผลแผงหน้าปัด ระบบจะกำหนดผู้ใช้ Android ให้กับโซนผู้เข้าพักแต่ละโซน ผู้ใช้แต่ละรายจะมีบัญชี และแอปของตนเอง

การกำหนดค่าฮาร์ดแวร์

Comms API รองรับ SoC เพียงตัวเดียว ในโมเดล SoC เดียว โซนและผู้ใช้ทั้งหมดจะทำงานใน SoC เดียวกัน Comms API ประกอบด้วย 3 องค์ประกอบ ได้แก่

  • API การจัดการพลังงานช่วยให้ไคลเอ็นต์จัดการพลังงานของ จอแสดงผลในโซนผู้เข้าพักได้

  • Discovery API ช่วยให้ไคลเอ็นต์ตรวจสอบสถานะของโซนที่มีการเข้าใช้อื่นๆ ในรถ และตรวจสอบไคลเอ็นต์เทียบเท่าในโซนที่มีการเข้าใช้เหล่านั้นได้ ใช้ Discovery API ก่อนใช้ Connection API

  • Connection API ช่วยให้ไคลเอ็นต์เชื่อมต่อกับไคลเอ็นต์เพียร์ใน โซนผู้ใช้รายอื่นและส่งเพย์โหลดไปยังไคลเอ็นต์เพียร์ได้

ต้องใช้ Discovery API และ Connection API สำหรับการเชื่อมต่อ Power management API เป็นตัวเลือก

Comms API ไม่รองรับการสื่อสารระหว่างแอปต่างๆ แต่ได้รับการออกแบบเฉพาะสำหรับการสื่อสารระหว่างแอปที่มีชื่อแพ็กเกจเดียวกัน และใช้เฉพาะสำหรับการสื่อสารระหว่างผู้ใช้ที่มองเห็นได้แตกต่างกัน

คู่มือการผสานรวม

ใช้ AbstractReceiverService

หากต้องการรับ Payload แอปตัวรับสัญญาณต้องใช้เมธอดแบบนามธรรม ที่กำหนดไว้ใน AbstractReceiverService เช่น

public class MyReceiverService extends AbstractReceiverService {

    @Override
    public void onConnectionInitiated(@NonNull OccupantZoneInfo senderZone) {
    }

    @Override
    public void onPayloadReceived(@NonNull OccupantZoneInfo senderZone,
            @NonNull Payload payload) {
    }
}

onConnectionInitiated() จะเรียกใช้เมื่อไคลเอ็นต์ผู้ส่งขอเชื่อมต่อกับไคลเอ็นต์ตัวรับนี้ หากต้องมีการยืนยันจากผู้ใช้เพื่อสร้างการเชื่อมต่อ MyReceiverService can จะลบล้างเมธอดนี้เพื่อเปิดใช้งานกิจกรรมการให้สิทธิ์ และเรียกใช้ acceptConnection() หรือ rejectConnection() ตามผลลัพธ์ หรือ MyReceiverService โทรหา acceptConnection() ได้เลย

onPayloadReceived() จะเรียกใช้เมื่อ MyReceiverService ได้รับ Payload จากไคลเอ็นต์ผู้ส่ง MyReceiverService สามารถลบล้างเมธอดนี้เพื่อทำสิ่งต่อไปนี้

  • ส่งต่อ Payload ไปยังปลายทางผู้รับที่เกี่ยวข้อง(หากมี) หากต้องการ รับปลายทางตัวรับที่ลงทะเบียนไว้ ให้โทรหา getAllReceiverEndpoints() หากต้องการ ส่งต่อ Payload ไปยังปลายทางผู้รับที่ระบุ ให้เรียกใช้ forwardPayload()

หรือ

  • แคช Payload และส่งเมื่อลงทะเบียนปลายทางผู้รับที่คาดไว้ ซึ่งระบบจะแจ้ง MyReceiverService ผ่าน onReceiverRegistered()

ประกาศ AbstractReceiverService

แอปตัวรับสัญญาณต้องประกาศ AbstractReceiverService ที่ใช้งานใน ไฟล์ Manifest เพิ่มตัวกรอง Intent ที่มีการดำเนินการ android.car.intent.action.RECEIVER_SERVICE สำหรับบริการนี้ และต้องมีสิทธิ์ android.car.occupantconnection.permission.BIND_RECEIVER_SERVICE ดังนี้

<service android:name=".MyReceiverService"
         android:permission="android.car.occupantconnection.permission.BIND_RECEIVER_SERVICE"
         android:exported="true">
    <intent-filter>
        <action android:name="android.car.intent.action.RECEIVER_SERVICE" />
    </intent-filter>
</service>

android.car.occupantconnection.permission.BIND_RECEIVER_SERVICEสิทธิ์ ช่วยให้มั่นใจได้ว่ามีเพียงเฟรมเวิร์กเท่านั้นที่เชื่อมโยงกับบริการนี้ได้ หากบริการนี้ไม่จำเป็นต้องใช้สิทธิ์ แอปอื่นอาจเชื่อมโยงกับบริการนี้และส่ง Payload ไปยังบริการนี้ได้โดยตรง

ประกาศสิทธิ์

แอปไคลเอ็นต์ต้องประกาศสิทธิ์ในไฟล์ Manifest

<!-- This permission is needed for connection API -->
<uses-permission android:name="android.car.permission.MANAGE_OCCUPANT_CONNECTION"/>
<!-- This permission is needed for discovery API -->
<uses-permission android:name="android.car.permission.MANAGE_REMOTE_DEVICE"/>
<!-- This permission is needed if the client app calls CarRemoteDeviceManager#setOccupantZonePower() -->
<uses-permission android:name="android.car.permission.CAR_POWER"/>

สิทธิ์ทั้ง 3 รายการข้างต้นเป็นสิทธิ์ที่มีสิทธิ์ ซึ่งต้องได้รับ การให้สิทธิ์ล่วงหน้าจากไฟล์รายการที่อนุญาต ตัวอย่างเช่น นี่คือไฟล์รายการที่อนุญาตของแอป MultiDisplayTest

// packages/services/Car/data/etc/com.google.android.car.multidisplaytest.xml
<permissions>
    <privapp-permissions package="com.google.android.car.multidisplaytest">
         
        <permission name="android.car.permission.MANAGE_OCCUPANT_CONNECTION"/>
        <permission name="android.car.permission.MANAGE_REMOTE_DEVICE"/>
        <permission name="android.car.permission.CAR_POWER"/>
    </privapp-permissions>
</permissions>

รับผู้จัดการรถยนต์

หากต้องการใช้ API แอปไคลเอ็นต์ต้องลงทะเบียน CarServiceLifecycleListener เพื่อ รับผู้จัดการรถที่เชื่อมโยง

private CarRemoteDeviceManager mRemoteDeviceManager;
private CarOccupantConnectionManager mOccupantConnectionManager;

private final Car.CarServiceLifecycleListener mCarServiceLifecycleListener = (car, ready) -> {
   if (!ready) {
       Log.w(TAG, "Car service crashed");
       mRemoteDeviceManager = null;
       mOccupantConnectionManager = null;
       return;
   }
   mRemoteDeviceManager = car.getCarManager(CarRemoteDeviceManager.class);
   mOccupantConnectionManager = car.getCarManager(CarOccupantConnectionManager.class);
};

Car.createCar(getContext(), /* handler= */ null, Car.CAR_WAIT_TIMEOUT_WAIT_FOREVER,
       mCarServiceLifecycleListener);

(ผู้ส่ง) สำรวจ

ก่อนเชื่อมต่อกับไคลเอ็นต์ตัวรับ ไคลเอ็นต์ผู้ส่งควรค้นหาไคลเอ็นต์ตัวรับโดยการลงทะเบียนCarRemoteDeviceManager.StateCallback:

// The maps are accessed by the main thread only, so there is no multi-thread issue.
private final ArrayMap<OccupantZoneInfo, Integer> mOccupantZoneStateMap = new ArrayMap<>();
private final ArrayMap<OccupantZoneInfo, Integer> mAppStateMap = new ArrayMap<>();

private final StateCallback mStateCallback = new StateCallback() {
        @Override
        public void onOccupantZoneStateChanged(
                @androidx.annotation.NonNull OccupantZoneInfo occupantZone,
                int occupantZoneStates) {
            mOccupantZoneStateMap.put(occupantZone, occupantZoneStates);
        }
        @Override
        public void onAppStateChanged(
                @androidx.annotation.NonNull OccupantZoneInfo occupantZone,
                int appStates) {
            mAppStateMap.put(occupantZone, appStates);
        }
    };

if (mRemoteDeviceManager != null) {
   mRemoteDeviceManager.registerStateCallback(getActivity().getMainExecutor(),
           mStateCallback);
}

ก่อนขอเชื่อมต่อกับเครื่องรับ ผู้ส่งควรตรวจสอบว่าได้ตั้งค่าสถานะทั้งหมดของโซนผู้ครอบครองเครื่องรับและแอปเครื่องรับแล้ว มิฉะนั้น อาจเกิดข้อผิดพลาดได้ เช่น

private boolean canRequestConnectionToReceiver(OccupantZoneInfo receiverZone) {
    Integer zoneState = mOccupantZoneStateMap.get(receiverZone);
    if ((zoneState == null) || (zoneState.intValue() & (FLAG_OCCUPANT_ZONE_POWER_ON
            // FLAG_OCCUPANT_ZONE_SCREEN_UNLOCKED is not implemented yet. Right now
            // just ignore this flag.
            //  | FLAG_OCCUPANT_ZONE_SCREEN_UNLOCKED
            | FLAG_OCCUPANT_ZONE_CONNECTION_READY)) == 0) {
        return false;
    }
    Integer appState = mAppStateMap.get(receiverZone);
    if ((appState == null) ||
        (appState.intValue() & (FLAG_CLIENT_INSTALLED
            | FLAG_CLIENT_SAME_LONG_VERSION | FLAG_CLIENT_SAME_SIGNATURE
            | FLAG_CLIENT_RUNNING | FLAG_CLIENT_IN_FOREGROUND)) == 0) {
        return false;
    }
    return true;
}

เราขอแนะนำให้ผู้ส่งขอเชื่อมต่อกับผู้รับเมื่อตั้งค่า แฟล็กของผู้รับทั้งหมดแล้วเท่านั้น อย่างไรก็ตาม มีข้อยกเว้นดังนี้

  • FLAG_OCCUPANT_ZONE_CONNECTION_READY และ FLAG_CLIENT_INSTALLED เป็น ข้อกำหนดขั้นต่ำที่จำเป็นต่อการสร้างการเชื่อมต่อ

  • หากแอปตัวรับต้องแสดง UI เพื่อขออนุมัติจากผู้ใช้สำหรับการเชื่อมต่อ FLAG_OCCUPANT_ZONE_POWER_ON และ FLAG_OCCUPANT_ZONE_SCREEN_UNLOCKED จะกลายเป็นข้อกำหนดเพิ่มเติม เราขอแนะนำให้ใช้FLAG_CLIENT_RUNNINGและFLAG_CLIENT_IN_FOREGROUNDด้วยเพื่อ ประสบการณ์การใช้งานที่ดียิ่งขึ้น มิฉะนั้นผู้ใช้อาจ แปลกใจ

  • ในตอนนี้ (Android 15) เรายังไม่ได้ใช้ FLAG_OCCUPANT_ZONE_SCREEN_UNLOCKED แอปไคลเอ็นต์สามารถเพิกเฉยต่อข้อความนี้ได้

  • ในตอนนี้ (Android 15) Comms API รองรับผู้ใช้หลายรายในอินสแตนซ์ Android เดียวกันเท่านั้น เพื่อให้แอปเพียร์มีรหัสเวอร์ชันแบบยาว (FLAG_CLIENT_SAME_LONG_VERSION) และลายเซ็น (FLAG_CLIENT_SAME_SIGNATURE) เดียวกันได้ ด้วยเหตุนี้ แอปจึงไม่จำเป็นต้องยืนยันว่าค่าทั้ง 2 ค่าตรงกัน

ไคลเอ็นต์ของผู้ส่งสามารถแสดง UI ได้หากไม่ได้ตั้งค่า Flag เพื่อให้ผู้ใช้ได้รับประสบการณ์การใช้งานที่ดียิ่งขึ้น เช่น หากไม่ได้ตั้งค่า FLAG_OCCUPANT_ZONE_SCREEN_UNLOCKED ผู้ส่ง จะแสดงข้อความป๊อปอัปหรือกล่องโต้ตอบเพื่อแจ้งให้ผู้ใช้ปลดล็อกหน้าจอของ โซนผู้รับ

เมื่อผู้ส่งไม่จำเป็นต้องค้นหาผู้รับอีกต่อไป (เช่น เมื่อค้นหาผู้รับทั้งหมดและสร้างการเชื่อมต่อหรือไม่มีการใช้งาน) ผู้ส่งจะหยุดการค้นหาได้

if (mRemoteDeviceManager != null) {
    mRemoteDeviceManager.unregisterStateCallback();
}

เมื่อหยุดการค้นหา การเชื่อมต่อที่มีอยู่จะไม่ได้รับผลกระทบ ผู้ส่งจะ ส่ง Payload ไปยังผู้รับที่เชื่อมต่อได้ต่อไป

(ผู้ส่ง) ขอเชื่อมต่อ

เมื่อตั้งค่า Flag ทั้งหมดของตัวรับแล้ว ผู้ส่งจะขอเชื่อมต่อกับตัวรับได้

    private final ConnectionRequestCallback mRequestCallback = new ConnectionRequestCallback() {
        @Override
        public void onConnected(OccupantZoneInfo receiverZone) {
        }

        @Override
        public void onFailed(OccupantZoneInfo receiverZone, int connectionError) {
        }

        @Override
        public void onDisconnected(OccupantZoneInfo receiverZone) {
        }
    };

if (mOccupantConnectionManager != null && canRequestConnectionToReceiver(receiverZone)) {
    mOccupantConnectionManager.requestConnection(receiverZone,
                getActivity().getMainExecutor(), mRequestCallback);
}

(บริการของผู้รับ) ยอมรับการเชื่อมต่อ

เมื่อผู้ส่งขอเชื่อมต่อกับผู้รับ AbstractReceiverService ในแอปของผู้รับจะผูกกับบริการในรถ และระบบจะเรียกใช้ AbstractReceiverService.onConnectionInitiated() ตามที่อธิบายไว้ใน(ผู้ส่ง) ขอการเชื่อมต่อ onConnectionInitiated() เป็นวิธีการที่แยกออกมาและแอปไคลเอ็นต์ต้องใช้

เมื่อผู้รับยอมรับคำขอเชื่อมต่อ ระบบจะเรียกใช้ ConnectionRequestCallback.onConnected()ของผู้ส่ง จากนั้นจะสร้างการเชื่อมต่อ

(ผู้ส่ง) ส่งเพย์โหลด

เมื่อสร้างการเชื่อมต่อแล้ว ผู้ส่งจะส่งPayloadไปยังผู้รับได้

if (mOccupantConnectionManager != null) {
    Payload payload = ...;
    try {
        mOccupantConnectionManager.sendPayload(receiverZone, payload);
    } catch (CarOccupantConnectionManager.PayloadTransferException e) {
        Log.e(TAG, "Failed to send Payload to " + receiverZone);
    }
}

ผู้ส่งสามารถใส่Binderออบเจ็กต์หรืออาร์เรย์ไบต์ในPayloadได้ หากผู้ส่งต้องการส่งข้อมูลประเภทอื่น จะต้องแปลงข้อมูลเป็นอาร์เรย์ไบต์ ใช้ไบต์อาร์เรย์เพื่อสร้างออบเจ็กต์ Payload และส่ง Payload จากนั้นไคลเอ็นต์ผู้รับจะได้รับอาร์เรย์ไบต์จาก Payload ที่ได้รับ และยกเลิกการซีเรียลไลซ์อาร์เรย์ไบต์เป็นออบเจ็กต์ข้อมูลที่คาดไว้ ตัวอย่างเช่น หากผู้ส่งต้องการส่งสตริง hello ไปยังปลายทางของผู้รับ ที่มีรหัส FragmentB ผู้ส่งสามารถใช้ Proto Buffers เพื่อกำหนดประเภทข้อมูล ได้ดังนี้

message MyData {
  required string receiver_endpoint_id = 1;
  required string data = 2;
}

รูปที่ 1 แสดงโฟลว์ของ Payload

ส่งเพย์โหลด

รูปที่ 1 ส่งเพย์โหลด

(บริการผู้รับ) รับและส่งต่อเพย์โหลด

เมื่อแอปตัวรับได้รับ Payload ระบบจะเรียกใช้ AbstractReceiverService.onPayloadReceived() ดังที่อธิบายไว้ในส่งเพย์โหลด onPayloadReceived() เป็น เมธอดที่แยกออกมาและแอปไคลเอ็นต์ต้องใช้ ในเมธอดนี้ ไคลเอ็นต์สามารถส่งต่อ Payload ไปยังปลายทางตัวรับที่เกี่ยวข้อง หรือ แคช Payload แล้วส่งเมื่อลงทะเบียนปลายทางตัวรับที่คาดไว้

(ปลายทางตัวรับ) ลงทะเบียนและยกเลิกการลงทะเบียน

แอปตัวรับสัญญาณควรเรียกใช้ registerReceiver() เพื่อลงทะเบียนปลายทางของตัวรับสัญญาณ Use Case ทั่วไปคือ Fragment ต้องรับ Payload ดังนั้น จึงลงทะเบียนปลายทางตัวรับดังนี้

private final PayloadCallback mPayloadCallback = (senderZone, payload) -> {
    
};

if (mOccupantConnectionManager != null) {
    mOccupantConnectionManager.registerReceiver("FragmentB",
                getActivity().getMainExecutor(), mPayloadCallback);
}

เมื่อ AbstractReceiverService ในไคลเอ็นต์ผู้รับส่ง Payload ไปยังปลายทางของผู้รับ ระบบจะเรียกใช้ PayloadCallback ที่เชื่อมโยง

แอปไคลเอ็นต์สามารถลงทะเบียนปลายทางตัวรับหลายรายการได้ตราบใดที่receiverEndpointIdไม่ซ้ำกันในแอปไคลเอ็นต์ receiverEndpointId จะใช้โดย AbstractReceiverService เพื่อตัดสินใจว่าจะส่ง Payload ไปยังปลายทางตัวรับใด เช่น

  • ผู้ส่งระบุ receiver_endpoint_id:FragmentB ใน Payload เมื่อได้รับ Payload AbstractReceiverService ในเครื่องรับจะเรียกใช้ forwardPayload("FragmentB", payload) เพื่อส่ง Payload ไปยัง FragmentB
  • ผู้ส่งระบุ data_type:VOLUME_CONTROL ใน Payload เมื่อได้รับ Payload AbstractReceiverService ในเครื่องรับจะทราบ ว่าควรส่ง Payload ประเภทนี้ไปยัง FragmentB จึงเรียกใช้ forwardPayload("FragmentB", payload)
if (mOccupantConnectionManager != null) {
    mOccupantConnectionManager.unregisterReceiver("FragmentB");
}

(ผู้ส่ง) สิ้นสุดการเชื่อมต่อ

เมื่อผู้ส่งไม่จำเป็นต้องส่ง Payload ไปยังผู้รับอีกต่อไป (เช่น ไม่มีการใช้งานแล้ว) ผู้ส่งควรสิ้นสุดการเชื่อมต่อ

if (mOccupantConnectionManager != null) {
    mOccupantConnectionManager.disconnect(receiverZone);
}

เมื่อยกเลิกการเชื่อมต่อแล้ว ผู้ส่งจะส่ง Payload ให้ผู้รับไม่ได้อีก

ขั้นตอนการเชื่อมต่อ

รูปที่ 2 แสดงขั้นตอนการเชื่อมต่อ

ขั้นตอนการเชื่อมต่อ

รูปที่ 2 ขั้นตอนการเชื่อมต่อ

การแก้ปัญหา

ตรวจสอบบันทึก

วิธีตรวจสอบบันทึกที่เกี่ยวข้อง

  1. เรียกใช้คำสั่งนี้สำหรับการบันทึก

    adb shell setprop log.tag.CarRemoteDeviceService VERBOSE && adb shell setprop log.tag.CarOccupantConnectionService VERBOSE && adb logcat -s "AbstractReceiverService","CarOccupantConnectionManager","CarRemoteDeviceManager","CarRemoteDeviceService","CarOccupantConnectionService"
  2. วิธีส่งออกสถานะภายในของ CarRemoteDeviceService และ CarOccupantConnectionService

    adb shell dumpsys car_service --services CarRemoteDeviceService && adb shell dumpsys car_service --services CarOccupantConnectionService

Null CarRemoteDeviceManager และ CarOccupantConnectionManager

ดูสาเหตุที่เป็นไปได้ต่อไปนี้

  1. บริการรถยนต์ขัดข้อง ดังที่แสดงไว้ก่อนหน้านี้ เราตั้งใจรีเซ็ตผู้จัดการทั้ง 2 รายให้เป็น null เมื่อบริการรถยนต์ขัดข้อง เมื่อรีสตาร์ทบริการรถยนต์ ระบบจะตั้งค่าตัวจัดการทั้ง 2 รายการเป็นค่าที่ไม่ใช่ Null

  2. ไม่ได้เปิดใช้ CarRemoteDeviceService หรือ CarOccupantConnectionService หากต้องการตรวจสอบว่าเปิดใช้ตัวใดตัวหนึ่งหรือไม่ ให้เรียกใช้คำสั่งต่อไปนี้

    adb shell dumpsys car_service --services CarFeatureController
    • มองหา mDefaultEnabledFeaturesFromConfig ซึ่งควรมี car_remote_device_service และ car_occupant_connection_service เช่น

      mDefaultEnabledFeaturesFromConfig:[car_evs_service, car_navigation_service, car_occupant_connection_service, car_remote_device_service, car_telemetry_service, cluster_home_service, com.android.car.user.CarUserNoticeService, diagnostic, storage_monitoring, vehicle_map_service]
      
    • โดยค่าเริ่มต้น ระบบจะปิดใช้บริการทั้ง 2 รายการนี้ เมื่ออุปกรณ์รองรับ การแสดงผลหลายจอ คุณต้องวางซ้อนไฟล์การกำหนดค่านี้ คุณเปิดใช้บริการทั้ง 2 รายการได้ในไฟล์การกำหนดค่า

      // packages/services/Car/service/res/values/config.xml
      <string-array translatable="false" name="config_allowed_optional_car_features">
           <item>car_occupant_connection_service</item>
           <item>car_remote_device_service</item>
            
      </string-array>
      

ข้อยกเว้นเมื่อเรียก API

หากแอปไคลเอ็นต์ไม่ได้ใช้ API ตามที่ตั้งใจไว้ อาจเกิดข้อยกเว้นได้ ในกรณีนี้ แอปไคลเอ็นต์สามารถตรวจสอบข้อความในข้อยกเว้นและ สแต็กข้อขัดข้องเพื่อแก้ไขปัญหาได้ ตัวอย่างการใช้ API ในทางที่ผิด ได้แก่

  • registerStateCallback() ลูกค้ารายนี้ลงทะเบียน StateCallback แล้ว
  • unregisterStateCallback() ไม่มีการลงทะเบียน StateCallback โดยอินสแตนซ์ CarRemoteDeviceManager นี้
  • registerReceiver() receiverEndpointId ลงทะเบียนแล้ว
  • unregisterReceiver() receiverEndpointId ไม่ได้ลงทะเบียน
  • requestConnection() มีการเชื่อมต่อที่รอดำเนินการหรือที่สร้างไว้แล้ว
  • cancelConnection() ไม่มีการเชื่อมต่อที่รอดำเนินการซึ่งจะยกเลิกได้
  • sendPayload() ไม่มีการเชื่อมต่อที่สร้างไว้
  • disconnect() ไม่มีการเชื่อมต่อที่สร้างไว้

ไคลเอ็นต์ 1 ส่งเพย์โหลดไปยังไคลเอ็นต์ 2 ได้ แต่ไคลเอ็นต์ 2 ส่งเพย์โหลดไปยังไคลเอ็นต์ 1 ไม่ได้

การเชื่อมต่อได้รับการออกแบบมาให้เป็นแบบทางเดียว หากต้องการสร้างการเชื่อมต่อแบบ 2 ทาง ทั้ง client1 และ client2 ต้องขอเชื่อมต่อกันและ รับการอนุมัติ