NodeMCU – Aplikacja konfiguracyjna dla systemu Android

Zgodnie z obietnicą w dzisiejszym artykule dowiemy się jak napisać własną aplikację dla Androida, która skonfiguruje nasze NodeMCU do pracy w wybranej przez nas sieci WiFi. Oczywiście na procesorze musi być wgrany program, który napisaliśmy w poprzednich artykułach z serii o NodeMCU. W tym artykule skupię się bardziej na zaimplementowaniu komunikacji UDP po stronie Javy niż typowo androidowych rzeczach, które to omówię raczej powierzchownie :) Na końcu artykułu do pobrania jest dostępna paczka .zip z plikami projektu do Android Studio oraz plik .apk który możemy zainstalować w telefonie sami.

Struktura aplikacji

Na początek przyjrzyjmy się strukturze klas naszej aplikacji i po krótce omówmy która jest za co odpowiedzialna.

Zacznijmy od aktywności, czyli klas zakończonych słowem Activity (3 klasy na samym dole listy).

  • MainActivity – jest to główna aktywność aplikacji. Pierwszy ekran, który wyświetla się użytkownikowi. W tej aktywności umożliwimy skanowanie sieci w poszukiwaniu urządzeń oraz wyświetlimy ich listę.
  • DeviceSetupActivity – aktywność uruchamiana po wybraniu urządzenia z listy. Znajdziemy na niej opcje dostępne dla danego urządzenia.
  • NetworkSetupActivity – aktywność uruchamiana po wybraniu opcji ustawianai danych sieci wifi w danym urządzeniu. Umożliwi ona uzyskanie listy sieci widzianych przez NodeMCU oraz wybranie tej z którą ma się łączyć + przekazanie hasła do niej jeżeli sieć jest zabezpieczona.

W pakiecie adapter znajdują się klasy DevicesAdapter oraz NetworkAdapter. Są to specjalne klasy używane wtedy, kiedy korzystamy w androidzie z widoku RecyclerView. Służy on do budowania list, a adaptery umożliwiają poprawne wyświetlanie widoków pojedynczych elementów oraz obsługę zdarzeń (np. kliknięć).

Pakiet exception zawiera w sobie wyjątki rzucane przez aplikację.

W pakiecie model znajdują się modele przesyłanych danych. W ten sposób tworząc obiekt klasy Command<WirelessCredentials> możemy go zserializować do formatu JSON i przesłać przez UDP. Działa to też w drugą stronę, czyli odbierając string JSON z danymi o wykrytym urządzeniu możemy zdeserializować go do obiektu typu Response<EspDevice> i w prosty sposób odczytać z niego dane (a raczej przekazać do adaptera, który to zrobi).

Ostatni pakiet to util, czyli klasy z narzędziami. Taką klasą jest UdpMessenger który służy nam jako serwer UDP oraz umożliwia komunikację w obie strony.

UdpMessenger

Zajrzyjmy teraz do struktury klasy, która robi całą robotę jeśli chodzi o komunikację z urządzeniem. Jak można zauważyć posiada ona prywatny konstruktor oraz statyczną metodę getInstance() co czyni ją singletonem. Taki signelton będzie w sam raz dla nas. Moglibyśmy pokusić się o zrobienie z tej klasy serwisu Androida, ale na potrzeby tego artykułu mogłoby to być za trudne.

Poza getIntance metodami statycznymi są także buildJsonString, parseDatagramPacket oraz getBroadcastAddress. Nie potrzebujemy instancji klasy, aby je wywołać ponieważ nie modyfikują one żadnych danych a jedynie zwracają wynik.

Do wysyłania danych mamy dwie metody sendData. Jedna z nich przyjmuje parametr typu InetAddress, który jest adresem IP odbiorcy pakiety. Druga z tych metod domyślnie wysyła pakiet na adres ustaiwony przez metodę setTargetAddress. W celu odebrania pakietu zastosujemy tutaj interfejs OnPacketReceiveListener ustawiany w metodzie setOnPacketReceiveListener.

Metody sendData oraz listener do odbioru danych są właściwie tylko opakowaniem dla obiektu wewnętrznej klasy UdpClientServerAsync. To ona jest tak naprawdę serwerem oraz działą asynchronicznie w tle nie powodując zawieszania się aplikacji.

private static class UdpClientServerAsync extends AsyncTask<Void, Void, Void> {
        private static final int SERVER_PORT = 3000;
        private static final int CLIENT_PORT = 1234;
        private static final int TIMEOUT = 10;

        private OnPacketReceiveListener mOnPacketReceiveListener = null;
        private UdpPacketTuple mData = null;
        private DatagramSocket mSocket = null;
        private Boolean mKeepServerRunning = true;

        private UdpClientServerAsync() throws SocketException {
            mSocket = new DatagramSocket(SERVER_PORT);
            mSocket.setSoTimeout(TIMEOUT);
        }

        private void setOnPacketReceiveListener(OnPacketReceiveListener listener) {
            this.mOnPacketReceiveListener = listener;
        }

        private void sendData(UdpPacketTuple data) {
            this.mData = data;
        }

        private void stopServer() {
            mKeepServerRunning = false;
        }

        @Override
        protected Void doInBackground(Void... objects) {
            while (mKeepServerRunning) {
                send();
                receive();
            }
            return null;
        }

        void receive() {
            byte[] buf = new byte[1024];
            DatagramPacket packet = new DatagramPacket(buf, buf.length);
            try {
                mSocket.receive(packet);
                mOnPacketReceiveListener.onPacketReceive(packet);
            } catch (IOException ignored) {
            }
        }

        void send() {
            if (mData != null) {
                try {
                    mSocket.setBroadcast(true);
                    DatagramPacket packet = new DatagramPacket(mData.getData().getBytes(), mData.getData().length(), mData.getIpAddress(), CLIENT_PORT);
                    mSocket.send(packet);
                    mData = null;
                } catch (IOException ignored) {
                }
            }
        }
    }

Jej sercem są metody send() oraz receive() nieustannie działające w tle.

  • receive – nieustannie sprawdza, czy w kolejce czekają do odebrania jakieś dane. Jeżeli tak, to wywołuje metodę onPacketReceive na obiekcie, który jest listenerem (czyli np naszej aktualnie wyświetlanej aktywności).
  • send – sprawdza, czy są jakieś dane do wysłania (mData nie jest nullem). Jeżeli tak to wysyła taki pakiet pod odpowiedni adres i przywraca mData do wartości null.

Jak użyć UdpMessengera

Pierwszym krokiem jest dodanie do pliku manifestu odpowiednich uprawnień:

<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.INTERNET" />

W activity, które chce komunikować się przez UDP musimy utworzyć prywatne pole mUdpMessenger. Następnie w metodzie onCreate przypisać do niego odpowiedni obiekt:

try {
    mUdpMessenger = UdpMessenger.getInstance();
} catch (SocketException e) {
    throw new UnableToStartUdpServerException();
}

Odbieranie danych

Aby odebrać dane przez activity musi ono implementować interfejs UdpMessenger.OnPacketReceiveListener.

public class MyActivity extends AppCompatActivity implements UdpMessenger.OnPacketReceiveListener {
   
   ...
   
   @Override
   public void onPacketReceive(DatagramPacket packet) {
      ...
   }
}

Następnie w metodzie onResume() musimy powiedzieć UdpMessengerowi, że teraz to my przejmujemy nasłuch na porcie:

@Override
protected void onResume() {
    super.onResume();
    mUdpMessenger.setOnPacketReceiveListener(this);
}

Od teraz za każdym razem, kiedy przyjdzie jakis pakiet zostanie wywołana metoda onPacketReceive zawierająca jako parametr dane pakietu (adres ip nadawcy oraz zawartość pakietu).

Przykład odebrania i deserializacji danych o urządzeniu:

@Override
public void onPacketReceive(DatagramPacket packet) {
    String json = UdpMessenger.parseDatagramPacket(packet);
    BaseResponse data = new Gson().fromJson(json, BaseResponse.class);
    if(data.getType().equals("deviceInfo")) {
        Type responseType = new TypeToken<Response<EspDevice>>() {
        }.getType();
        final Response<EspDevice> targetObject = new Gson().fromJson(json, responseType);
        targetObject.getResult().setIpAddress(packet.getAddress());
        runOnUiThread(new Runnable() {
            public void run() {
                mAdapter.addDevice(targetObject.getResult());
            }
        });

    }
}

Musimy tutaj posłużyć się najpierw klasą BaseResponse, aby zdeserializować sam TYP odpowiedzi. Wiedząc już, że typ odpowiedzi to deviceInfo możemy zdeserializować ten sam JSON do docelowej klasy Response<EspDevice>.

Wysyłanie danych

Dysponując obiektem klasy UdpMessenger oraz odpowiednimi modelami przy pomocy biblioteki Gson możemy przesłać dane w np. tak:

Przykład wysyłania danych o sieci WiFi

WirelessCredentials credentials = new WirelessCredentials("Nazwa sieci", "Hasło");
Command<WirelessCredentials> queryData = new Command<>("setWiFi", credentials);
String data = UdpMessenger.buildJsonString(queryData);
mUdpMessenger.sendData(data);

Przykład wysyłania komunikatu getDeviceInfo na adres broadcastowy:

Command command = new Command("getDeviceInfo");
try {
    InetAddress address = UdpMessenger.getBroadcastAddress(this);
    String data = UdpMessenger.buildJsonString(command);
    mUdpMessenger.sendData(address, data);
} catch (IOException ignored) {
}

Screeny z aplikacji

Paczka z projektem

Cały projekt aplikacji dla Android Studio można pobrać tutaj.

Plik instalacyjny

Plik .apk aplikacji gotowy do instalacji na telefonie można pobrać tutaj.

Zadanie domowe

Tych najbardziej upartych i wytrwałych zachęcam do zaimplementowania po stronie urządzenia funkcji nadawania mu własnej nazwy, którą będzie można nadać w aplikacji wybierając odpowiednią opcję, a także wyświetli się na liście urządzeń w MainActivity.