[안드로이드-그 한계를 넘어서] 18장. 원격기기와 통신

안드로이드에서는 원격 기기와의 통신 기능을 제공하는데,

와이파이, 블루투스, USB

이 장에서는 그냥 CV로 다뤘던 블루투스에 대한 이론을 확실히 함을 목표로 작성되었습니다.

모든 안드로이드 기기는 전통적인 블루투스 프로필을 지원한다. 이 프로필은 오디오 스트리밍처럼 배터리를 많이 소모하는 작업에 좀 더 적합하다.

이 기술을 활용하면 GATT프로필을 지원하는 기기를 찾아 통신할 수 있는 애플리케이션을 작성할 수 있다.

좀 더 많은 데이터를 통신해야하는 상황에서는 와이파이를 사용할 수있다. 안드로이드에서는 와이파이가 동작할 수 있는 3가지 모드가 있다.

  1. Infrastructure(엑세스 포인트에 연결된 표준 와이파이)
    기기를 와이파이에 연결하고 인터넷에 연결할 떄 주로 사용하는 모드
  2. 테더링
  3. 와이파이 다이렉트
    애플리케이션이 적용 엑세스 포인트 없이도 동작하는 피어 간 와이파이 네트워크를 설정하게 해준다. 이로인해 와이파이 다이렉트는 애드 혹 시나리오에서 기기 간 네트워크 통신에 사용하기에 매우 매력적인 기술이다. 또 와이파이 다이렉트에서는 인터넷 서버에서 통신을 라우팅하는 방식으로는 구현할 수 없는 빠른 속도를 제공한다. 예를 들어 인터넷 연결이 필요없는 멀티플레이어 게임을 구현하고 싶거나 두 친구 사이에서 빠르고 언전하게 데이터를 공유하고 싶다면 와이파이 다이렉터가 매우 적합한 솔루션이다.

안드로이드 USB

android.hardware.usb패키지 내 API를 통해 지원. USB통신을 위한 호스트 모드를 중점적으로 살펴본다.

USB는 한 기기가 다른 여러 기기의 호스트 역할을 하게끔 설계됐다. 호스트는 여러 가지 일을 할 수 있지만 그 중 하나는 연결된 기기를 전력을 전달하는 것이다.

안드로이드에서 USB를 통해 애플리케이션이 통신하게 하려면 먼저 USB기기를 연결할 때 실행할 Activtiy를 정의해야 한다.

아래 코드에서 metadata엘리먼트를 주의해서 살펴보자.

<Activtiy
 android:name=".MyUsbDemo"
 android:label="@string/app_name">
 <intent-filter>
  <action
  android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
</intent-filter>
<meta-data
  android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"
  android:resource="@xml/device_filter" />
</activity>

device_filter.xml파일의 내용은 다음과 같이 작성할 수 있다. 이렇게 하면 아두이노 우노 보드를 대상으로 필터링을 수해앟고, 이 기기를 안드로이드 스마트폰으로 연결할 떄만 Activity를 실행한다.

다음 코드에 나온 onResume()메서드에서는 Activity를 실행하는 Intent에서 UsbDevice인스턴스를 가져오는 법을 볼 수 있다.

protected void onResume() { super.onResume(); Intent intent = getIntent; UsbDevice device = (UsbDevice) intent. getParcelableExtra(UsbManager.EXTRA_DEVICE); new Thread(new UsbCommunication(device)).start(); }

안드로이드의 USB통신은 무선 통신 인터페이스가 충분하지 않을 경우에 편리하게 활용할 수 있다. 또 블루투스나 와이파이 스택이 준비돼 있지 않은 새 악세서리의 프로토타입을 개발하려고 할 때도 간단한 솔루션으로 활용할 만하다.

블루투스 저전력(BLE)

일단 BLE 를 지원하려면 먼저 다음과 같이 BLUETOOTH_ADMIN 및 퍼미션을 애플리케이션에 추가해야 한다. 그런 다음 블루투스 저전력을 지원하는 기기에서만 사용할 수 있게 bluetooth_le기능 사용을 선언해야 한다.

<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>
<uses-permission android:name="android.permission.BLUETOOTH"/>

<uses-feature android:name="android.hardware.bluetooth_le"
android:required="true"/>

KakaoTalk_Photo_2017-03-15-13-13-07_76

블루투스 작업을 시작하기 전에는 먼저 블루투스가 활성화돼 있는지 확인해야 한다. 다음 메서드에서는 불루투스가 활성화돼 있지 않으면 이를 활성화하게끔 사용자에게 시스템 경고창을 표시한다.

protected void onResume() {
  super.onReuse();
  if(!mBluetoothAdapter.isEnable()) {
    Intent enableIntent = new Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE);
    startActivityForResult(enableIntent, ENABLE_REQUEST);
  }
}

다음으로 다음 코드에서 BLE기기의 스캐닝을 시작해야한다.

public void doStartBtleScan(){
        mLeScanCallback = new MyLeScanCallback();
        BluetoothManager bluetoothManager =
                getSystemService(Context.BLUETOOTH_SERVICE);
        mBluetoothAdapter = bluetoothManager.getAdapter();
        mBluetoothAdapter.startLeScan(mLeScanCallback);
    }

BLE기기를 찾고나면 기기와의 연결 작업을 수행할 수 있는 콜백을 수신할 수 있다. 다음 코드에서는 기기 스캐닝을 중단하고 커넥션을 시작한다.

private class MyLeScanCallback implements BluetoothAdapter.LeScanCallback {
  @override
  public void onLeScan(BluetoothDevice bluetoothDevice,
    int rssi, byte[] scanRecord) {
      //TODO: Check the correct Device
      mBluetoothAdapter.stopLeScan(this);
      mMyGattCallback = new MyGattCallback();
      mGatt = bluetoothDevice.connectGatt(BtleDemo.this,
        false, mMyGattCallback);
    }
}

애플리케이션을 특정 기기 타입으로 연결하려는 경우 연결 전에 기기를 검사할 수도 있다. onConnectionStateChange()콜백 메서드는 커넥션이 맺어지고 나면 호출을 받는다. 이 때 다음과 같이 원격 기기에서 사용할 수 있는 서비를 찾을 수 있다.

 private class MyGattCallback extends BluetoothGattCallback {
   @override
   public void onConnectionStateChange(BluetoothGatt gatt,
                                      int status, int newState){
    super.onConnectionStateChange(gatt, status, newState);
    if(newState == BluetoothGatt.STATE_CONNECTED && status == BluetoothGatt.GATT_SUCCESS) {
      Log.d(TAG, "Connected to " + gatt.getDevice().getName());
      gatt.discoverServices();
    }                                
  }                                   
 }

 다음 코드 예제처럼 BLE서비스 검색이 완료되면 서비스를 순회해  성격을 찾을  있다.  성격별로 읽기  알림을 지원하는지 검사하고, 이에 해당할 경우 해당하는 메서드를 호출할  있다.
```java
 @override
 public void onServiceDiscovered(BluetoothGatt gatt, int status) {
   super.onServiceDiscovered(gatt, status);
   if(status == BluetoothGatt.GATT_SUCCESS) {
     List<BluetoothGattService> services = gatt.getServices();
     for(BluetoothGattService service : services) {
       Log.d(TAG, "Found service : " + service.getUuid());
       for(BluetoothGattCharacteristic characteristic :
                service.getCharacteristics()) {
                  Log.d(TAG, "Found characteristic: " +
                  characteristic.getUuid());

                  if(hasProperty(characteristic,
                    BluetoothGattCharacteristic.PROPERTY_READ)) {
                      Log.d(TAG, "Read characteristic: " +
                      characteristic.getUuid());
                      gatt.readCharacteristic(characteristic);
                    }

                    if(hasProperty(characteristic,
                      BluetoothGattCharacteristic.PROPERTY_NOTIFY)) {
                        Log.d(TAG, "Register notification for characteristic: " + characteristic.getUuid());
                        gatt.setCharacteristicNotification(characteristic,
                          true);
                      }
                }
     }
   }

   public static boolean hasProperty(BluetoothGattCharacteristic characteristic, int property) {
     int prop = characteristic.getProperties() & peroperty;
     return prop == property;
   }
 }

성격의 읽기 옵션은 비동기적으므로 콜백에서 값을 읽어야 한다. 알림을 등록할 떄도 같은 인터페이스의 콜백을 사용해 애플리케이션에게 업데이트 정보를 알려준다. 다음 코드에서는 비동기적 읽기 및 알림을 통해 성격에서 32비트 부호형 정수값을 읽는 법을 볼 수 있다.

 @override
 public void onCharacteristicRead(BluetoothGatt gatt,
   BluetoothGattCharacteristic characteristic, int status) {
     super.onCharacteristicRead(gatt, characteristic, status);
     if(status == BluetoothGatt.GATT_SUCCESS) {
       Integer value =
       characteristic.getIntValue(BluetoothGattCharacteristic.FORMAT_SINT32, 0);
       //TODO : 읽은 값 처리!!
     }
   }
   @override
   public void onCharacteristicChanged(BluetoothGatt gatt,
     BluetoothGattCharacteristic characteristic, int status) {
       super.onCharacteristicChanged(gatt, characteristic);
         Integer value =
         characteristic.getIntValue(BluetoothGattCharacteristic.FORMAT_SINT32, 0);
         //TODO : 읽은 값 처리!!
  }

안드로이드 와이파이

와이파이는 와이파이 연맹에서 관리하는 기술 표준 집합을 통칭하는 용어다. 와이파이 다이렉트는 802.11n 표준위에서 실행되는 추가 기술이다. 이 기술에서는 전용 엑세스 포인트 없어도 기기간 통신 할 수 있게 해준다. 이로 인해 이 기술은 블루투스와 매우 유사해졌지만 고속 와이파이통신을 사용한다는 점이 다르다.

하지만 기기가 같은 와이파이에 연결되어 있더라도 커넥션을 설정하려면 기기를 찾아야 한다. 기기를 찾는다는 말은 사용하려는 서비스를 실행하는 기기의 IP주소를 찾아야 함을 뜻한다.

네트워크 디스커버리 서비스

이 서비스는 와이파이 네트워크상에서 발행되는 웹 서비스와는 다르지만, 안드로이드에서는 서비스를 발표하고 로컬 네트워크에서 찾을 수 있게끔 표준 디스커버리 메커니즘을 제공한다. 이 구현체는 두 개의 표준인 mDNS와 DNS-SD로 구성된다. 처 번째 표준 mDNS는 UDP멀티 캐스트 프로토콜을 사용해 호스트를 발표하고 찾기 위한 멀니캐스트 프로토콜이다. DNS-SD는 원격 호스트에서 실행되는 서비스를 발표하고 찾기위한 서비스 디스커버리 프로토콜이다.

와이파이 다이렉트

전용 엑세스 포인트 없이도 기기간 고속 와이파이 통신을 할 수 있게 해준다. 기본적으로 와이파이 다이렉트는 와이파이 기술을 사용하는 피어간 프로토콜이다. 안드로이드 버전 4이상을 실행하는 기기에는 와이파이 다이렉트를 동시모드로 실행 할 수 있다.

가장 먼저 와이파이 다이렉트 네트워크를 프로그래밍적으로 등록해야한다.

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);

    IntentFilter intentFilter = new IntentFilter(WifiP2pManager.
            WIFI_P2P_PEERS_CHANGED_ACTION);
    intentFilter.addAction(WifiP2pManager.
            WIFI_P2P_CONNECTION_CHANGED_ACTION);
    mReceiver = new MyWifiDirectReceiver();
    registerReceiver(mReceiver, intentFilter);
}

다음, 서비스를 발행할 기기에서 와이파이 다이렉트 채널을 초기화하고, 서비스를 식별하는 WIFI_P2P_SERVICEInfo를 생성해 이를 로컬 서비스로 추가한다. 서버사이드에서 와이파이 다이렉트를 설정하기 위해 할 일은 이것뿐이다. 다음 코드에서는 이 작업을 수행하는 메서드를 볼 수있다.

private void announceWiFiDirectService() {
    Log.d(TAG, "Setup service announcement!");
    mWifiP2pManager = (WifiP2pManager) getSystemService(WIFI_P2P_SERVICE);
    HandlerThread handlerThread = new HandlerThread(TAG);
    handlerThread.start();
    mWFDLooper = handlerThread.getLooper();
    mChannel = mWifiP2pManager.initialize(this, mWFDLooper,
            new WifiP2pManager.ChannelListener() {
                @Override
                public void onChannelDisconnected() {
                    Log.d(TAG, "onChannelDisconnected!");
                    mWFDLooper.quit();
                }
            });
    Map<String, String> txtRecords = new HashMap<String, String>();
    mServiceInfo = WifiP2pDnsSdServiceInfo.newInstance(SERVICE_NAME,
            "_http._tcp",
            txtRecords);
    mWifiP2pManager.addLocalService(mChannel, mServiceInfo,
            new WifiP2pManager.ActionListener() {
                @Override
                public void onSuccess() {
                    Log.d(TAG, "Service announcing!");
                }

                @Override
                public void onFailure(int i) {
                    Log.d(TAG, "Service announcement failed: " + i);
                }
            });
}

클라이언트 역할을 하는 기기에서도 비슷한 설정을 수행하지만, 서비스를 발행하는 대신 WifiP2pManager에 피어 기기를 리스닝하고 싶다고 알려주고, 검색할 WifiP2pServiceRequest를 전달한다. 다음 코드는 이 메서드를 보여준다.

private void discoverWiFiDirectServices() {
    mWifiP2pManager = (WifiP2pManager) getSystemService(WIFI_P2P_SERVICE);
    HandlerThread handlerThread = new HandlerThread(TAG);
    handlerThread.start();
    mWFDLooper = handlerThread.getLooper();
    mChannel = mWifiP2pManager.initialize(this, mWFDLooper,
            new WifiP2pManager.ChannelListener() {
                @Override
                public void onChannelDisconnected() {
                    Log.d(TAG, "onChannelDisconnected!");
                    mWFDLooper.quit();
                }
            });
    mServiceRequest = WifiP2pDnsSdServiceRequest.newInstance("_http._tcp");
    mWifiP2pManager.addServiceRequest(mChannel, mServiceRequest, null);
    mWifiP2pManager.setServiceResponseListener(mChannel, this);
    mWifiP2pManager.setDnsSdResponseListeners(mChannel, this, this);
    mWifiP2pManager.discoverPeers(mChannel,
            new WifiP2pManager.ActionListener() {
                @Override
                public void onSuccess() {
                    Log.d(TAG, "Peer discovery started!");
                }

                @Override
                public void onFailure(int i) {
                    Log.d(TAG, "Peer discovery failed: " + i);
                }
            });
    mWifiP2pManager.discoverServices(mChannel,
            new WifiP2pManager.ActionListener() {
                @Override
                public void onSuccess() {
                    Log.d(TAG, "Service discovery started!");
                }

                @Override
                public void onFailure(int i) {
                    Log.d(TAG, "Service discovery failed: " + i);
                }
            });
}

WifiP2pServiceRequest와 일치하는 서비스를 찾으면 다음 콜백이 호출된다.

@Override public void onDnsSdServiceAvailable(String instanceName, String registrationType, WifiP2pDevice srcDevice) { Log.d(TAG, “DNS-SD Service available: “ + srcDevice); mWifiP2pManager.clearServiceRequests(mChannel, null); WifiP2pConfig wifiP2pConfig = new WifiP2pConfig(); wifiP2pConfig.deviceAddress = srcDevice.deviceAddress; wifiP2pConfig.groupOwnerIntent = 0; mWifiP2pManager.connect(mChannel, wifiP2pConfig, null); }

이 예제에서는 와이파이 다이렉트와 관련한 추가 서비스 검색을 취소하고 기기를 연결한다. 여기서 사용한 설정에서는 그릅 Owner가 될 의사가 거의 없음을 기기에 알려준다. 또 여기에는 원격 기기의 네트워크 주소(mac)도 들어 있다. connect()메서드가 호출되면 다른 쪽에 있는 기기에서는 사용자에게 커넥션을 확인하는 대화상자가 표시한다.

두 기기 모두 커넥션이 맺어지고 나면 앞서 등록한 BroadcastReceiver를 트리거하는 브로드 캐스트가 전성된다.

다음 코드에서는 기기가 그룹 Owner인지 아닌지 확인하고, 원격 기기의 서비스로 TCP커넥션을 맺기 위해 InetAddress를 가져온다.

public class MyWifiDirectReceiver extends BroadcastReceiver implements WifiP2pManager.ConnectionInfoListener {
    @Override
    public void onReceive(Context context, Intent intent) {
        String action = intent.getAction();
        if(WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION.equals(action)
                && mWifiP2pManager != null) {
            mWifiP2pManager.requestConnectionInfo(mChannel, this);
        }
    }

    @Override
    public void onConnectionInfoAvailable(WifiP2pInfo wifiP2pInfo) {
        Log.d(TAG, "Group owner address: " + wifiP2pInfo.groupOwnerAddress);
        Log.d(TAG, "Am I group owner: " + wifiP2pInfo.isGroupOwner);
        if(!wifiP2pInfo.isGroupOwner) {
            connectToServer(wifiP2pInfo.groupOwnerAddress);
        }
    }
}

이때 그룹 소유자의 IP주소만을 가져온다는 점을 이해해야 한다. 커넥션이 맺어지고 나면 어떤 기기가 새 P2P그룹의 소유주인지 확인하고, 해당기기로의 통신을 설정해야 한다. 서비스를 호스팅하는 기기가 그룹 소유자가 되면 좋겠지만, 그렇지 않은 경우에는 다른 방식으로 클라이언트에 서비스 IP를 제공해야한다